summaryrefslogtreecommitdiff
path: root/www/wiki/tests
diff options
context:
space:
mode:
authorYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
committerYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
commitfc7369835258467bf97eb64f184b93691f9a9fd5 (patch)
treedaabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/tests
first commit
Diffstat (limited to 'www/wiki/tests')
-rw-r--r--www/wiki/tests/.htaccess1
-rw-r--r--www/wiki/tests/common/TestSetup.php113
-rw-r--r--www/wiki/tests/common/TestsAutoLoader.php222
-rw-r--r--www/wiki/tests/integration/includes/http/CurlHttpRequestTest.php9
-rw-r--r--www/wiki/tests/integration/includes/http/MWHttpRequestTestCase.php251
-rw-r--r--www/wiki/tests/integration/includes/http/PhpHttpRequestTest.php9
-rw-r--r--www/wiki/tests/integration/includes/shell/FirejailCommandTest.php78
-rw-r--r--www/wiki/tests/parser/DbTestPreviewer.php205
-rw-r--r--www/wiki/tests/parser/DbTestRecorder.php87
-rw-r--r--www/wiki/tests/parser/DjVuSupport.php57
-rw-r--r--www/wiki/tests/parser/MultiTestRecorder.php55
-rw-r--r--www/wiki/tests/parser/ParserTestMockParser.php20
-rw-r--r--www/wiki/tests/parser/ParserTestParserHook.php66
-rw-r--r--www/wiki/tests/parser/ParserTestPrinter.php328
-rw-r--r--www/wiki/tests/parser/ParserTestResult.php44
-rw-r--r--www/wiki/tests/parser/ParserTestResultNormalizer.php87
-rw-r--r--www/wiki/tests/parser/ParserTestRunner.php1737
-rw-r--r--www/wiki/tests/parser/PhpunitTestRecorder.php18
-rw-r--r--www/wiki/tests/parser/README12
-rw-r--r--www/wiki/tests/parser/TestFileEditor.php196
-rw-r--r--www/wiki/tests/parser/TestFileReader.php335
-rw-r--r--www/wiki/tests/parser/TestRecorder.php93
-rw-r--r--www/wiki/tests/parser/TidySupport.php77
-rw-r--r--www/wiki/tests/parser/editTests.php488
-rw-r--r--www/wiki/tests/parser/extraParserTests.txtbin0 -> 1994 bytes
-rw-r--r--www/wiki/tests/parser/fuzzTest.php202
-rw-r--r--www/wiki/tests/parser/parserTests.php199
-rw-r--r--www/wiki/tests/parser/parserTests.txt30829
-rw-r--r--www/wiki/tests/parser/preprocess/All_system_messages.expected5604
-rw-r--r--www/wiki/tests/parser/preprocess/All_system_messages.txt5603
-rw-r--r--www/wiki/tests/parser/preprocess/Factorial.expected17
-rw-r--r--www/wiki/tests/parser/preprocess/Factorial.txt16
-rw-r--r--www/wiki/tests/parser/preprocess/Fundraising.expected18
-rw-r--r--www/wiki/tests/parser/preprocess/Fundraising.txt17
-rw-r--r--www/wiki/tests/parser/preprocess/NestedTemplates.expected90
-rw-r--r--www/wiki/tests/parser/preprocess/NestedTemplates.txt89
-rw-r--r--www/wiki/tests/parser/preprocess/QuoteQuran.expected140
-rw-r--r--www/wiki/tests/parser/preprocess/QuoteQuran.txt139
-rwxr-xr-xwww/wiki/tests/phan/bin/phan90
-rw-r--r--www/wiki/tests/phan/bin/postprocess-phan.php146
-rw-r--r--www/wiki/tests/phan/config.php497
-rw-r--r--www/wiki/tests/phan/stubs/README3
-rw-r--r--www/wiki/tests/phan/stubs/hhvm.php26
-rw-r--r--www/wiki/tests/phan/stubs/mail.php89
-rw-r--r--www/wiki/tests/phan/stubs/memcached.php16
-rw-r--r--www/wiki/tests/phan/stubs/phpunit4.php11
-rw-r--r--www/wiki/tests/phan/stubs/tideways.php12
-rw-r--r--www/wiki/tests/phan/stubs/wikidiff.php39
-rw-r--r--www/wiki/tests/phpunit/HamcrestPHPUnitIntegration.php34
-rw-r--r--www/wiki/tests/phpunit/LessFileCompilationTest.php54
-rw-r--r--www/wiki/tests/phpunit/Makefile78
-rw-r--r--www/wiki/tests/phpunit/MediaWikiCoversValidator.php50
-rw-r--r--www/wiki/tests/phpunit/MediaWikiLangTestCase.php24
-rw-r--r--www/wiki/tests/phpunit/MediaWikiPHPUnitTestListener.php130
-rw-r--r--www/wiki/tests/phpunit/MediaWikiTestCase.php2043
-rw-r--r--www/wiki/tests/phpunit/PHPUnit4And6Compat.php121
-rw-r--r--www/wiki/tests/phpunit/README50
-rw-r--r--www/wiki/tests/phpunit/ResourceLoaderTestCase.php185
-rw-r--r--www/wiki/tests/phpunit/TODO20
-rw-r--r--www/wiki/tests/phpunit/autoload.ide.php109
-rw-r--r--www/wiki/tests/phpunit/bootstrap.php29
-rw-r--r--www/wiki/tests/phpunit/data/autoloader/TestAutoloadedCamlClass.php4
-rw-r--r--www/wiki/tests/phpunit/data/autoloader/TestAutoloadedClass.php4
-rw-r--r--www/wiki/tests/phpunit/data/autoloader/TestAutoloadedLocalClass.php4
-rw-r--r--www/wiki/tests/phpunit/data/autoloader/TestAutoloadedSerializedClass.php4
-rw-r--r--www/wiki/tests/phpunit/data/categoriesrdf/categoriesRdf-out.nt23
-rw-r--r--www/wiki/tests/phpunit/data/composer/composer.json48
-rw-r--r--www/wiki/tests/phpunit/data/composer/composer.lock1195
-rw-r--r--www/wiki/tests/phpunit/data/composer/installed.json1682
-rw-r--r--www/wiki/tests/phpunit/data/composer/new-composer.json48
-rw-r--r--www/wiki/tests/phpunit/data/css/bom.css1
-rw-r--r--www/wiki/tests/phpunit/data/css/comments.css7
-rw-r--r--www/wiki/tests/phpunit/data/css/expected.css11
-rw-r--r--www/wiki/tests/phpunit/data/css/simple-ltr.gifbin0 -> 35 bytes
-rw-r--r--www/wiki/tests/phpunit/data/css/simple-rtl.gifbin0 -> 35 bytes
-rw-r--r--www/wiki/tests/phpunit/data/css/test.css11
-rw-r--r--www/wiki/tests/phpunit/data/cssmin/circle.svg5
-rw-r--r--www/wiki/tests/phpunit/data/cssmin/green.gifbin0 -> 35 bytes
-rw-r--r--www/wiki/tests/phpunit/data/cssmin/large.pngbin0 -> 36462 bytes
-rw-r--r--www/wiki/tests/phpunit/data/cssmin/red.gifbin0 -> 35 bytes
-rw-r--r--www/wiki/tests/phpunit/data/db/mysql/functions.sql12
-rw-r--r--www/wiki/tests/phpunit/data/db/postgres/functions.sql12
-rw-r--r--www/wiki/tests/phpunit/data/db/sqlite/tables-1.13.sql342
-rw-r--r--www/wiki/tests/phpunit/data/db/sqlite/tables-1.15.sql454
-rw-r--r--www/wiki/tests/phpunit/data/db/sqlite/tables-1.16.sql478
-rw-r--r--www/wiki/tests/phpunit/data/db/sqlite/tables-1.17.sql511
-rw-r--r--www/wiki/tests/phpunit/data/db/sqlite/tables-1.18.sql530
-rw-r--r--www/wiki/tests/phpunit/data/db/sqlite/tables-1.19.sql531
-rw-r--r--www/wiki/tests/phpunit/data/db/sqlite/tables-1.20.sql534
-rw-r--r--www/wiki/tests/phpunit/data/db/sqlite/tables-1.21.sql577
-rw-r--r--www/wiki/tests/phpunit/data/db/sqlite/tables-1.22.sql575
-rw-r--r--www/wiki/tests/phpunit/data/db/sqlite/tables-1.23.sql580
-rw-r--r--www/wiki/tests/phpunit/data/filecontentshasher/hash.svg110
-rw-r--r--www/wiki/tests/phpunit/data/filecontentshasher/primes.txt105
-rw-r--r--www/wiki/tests/phpunit/data/filerepo/video.pngbin0 -> 116 bytes
-rw-r--r--www/wiki/tests/phpunit/data/filerepo/wiki.pngbin0 -> 22589 bytes
-rw-r--r--www/wiki/tests/phpunit/data/gitinfo/extension/gitinfo.json7
-rw-r--r--www/wiki/tests/phpunit/data/gitinfo/info-testValidJsonData.json7
-rw-r--r--www/wiki/tests/phpunit/data/import/ImportLinkCacheIntegrationTest.xml43
-rw-r--r--www/wiki/tests/phpunit/data/less/common/test.common.mixins.less4
-rw-r--r--www/wiki/tests/phpunit/data/less/module/dependency.less3
-rw-r--r--www/wiki/tests/phpunit/data/less/module/styles.css5
-rw-r--r--www/wiki/tests/phpunit/data/less/module/styles.less6
-rw-r--r--www/wiki/tests/phpunit/data/localisationcache/ba.json3
-rw-r--r--www/wiki/tests/phpunit/data/localisationcache/en.json5
-rw-r--r--www/wiki/tests/phpunit/data/localisationcache/ru.json4
-rw-r--r--www/wiki/tests/phpunit/data/media/1bit-png.pngbin0 -> 167 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/2_webp_a.webpbin0 -> 17128 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/2_webp_ll.webpbin0 -> 29360 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/80x60-2layers.xcfbin0 -> 1162 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/80x60-Greyscale.xcfbin0 -> 667 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/80x60-RGB.xcfbin0 -> 677 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/Animated_PNG_example_bouncing_beach_ball.pngbin0 -> 72209 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/Bishzilla_blink.gifbin0 -> 39057 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/Gtk-media-play-ltr.svg35
-rw-r--r--www/wiki/tests/phpunit/data/media/LoremIpsum.djvubin0 -> 3249 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/Png-native-test.pngbin0 -> 4665 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/QA_icon.svg77
-rw-r--r--www/wiki/tests/phpunit/data/media/README61
-rw-r--r--www/wiki/tests/phpunit/data/media/Soccer_ball_animated.svg55
-rw-r--r--www/wiki/tests/phpunit/data/media/Speech_bubbles.svg14
-rw-r--r--www/wiki/tests/phpunit/data/media/Toll_Texas_1.svg150
-rw-r--r--www/wiki/tests/phpunit/data/media/Tux.svg902
-rw-r--r--www/wiki/tests/phpunit/data/media/US_states_by_total_state_tax_revenue.svg248
-rw-r--r--www/wiki/tests/phpunit/data/media/Wikimedia-logo.svg14
-rw-r--r--www/wiki/tests/phpunit/data/media/Xmp-exif-multilingual_test.jpgbin0 -> 12544 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/adobergb.jpgbin0 -> 5308 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/animated-xmp.gifbin0 -> 3864 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/animated.gifbin0 -> 497 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/broken_exif_date.jpgbin0 -> 3233 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/exif-gps.jpgbin0 -> 665 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/exif-user-comment.jpgbin0 -> 484 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/greyscale-na-png.pngbin0 -> 365 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/greyscale-png.pngbin0 -> 415 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/iptc-invalid-psir.jpgbin0 -> 9574 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/iptc-timetest-invalid.jpgbin0 -> 9573 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/iptc-timetest.jpgbin0 -> 9573 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-comment-binary.jpgbin0 -> 448 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-comment-iso8859-1.jpgbin0 -> 447 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-comment-multiple.jpgbin0 -> 431 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-comment-utf.jpgbin0 -> 445 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-iptc-bad-hash.jpgbin0 -> 499 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-iptc-good-hash.jpgbin0 -> 499 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-padding-even.jpgbin0 -> 450 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-padding-odd.jpgbin0 -> 451 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-segment-loop1.jpgbin0 -> 20 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-segment-loop2.jpgbin0 -> 24 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-xmp-alt.jpgbin0 -> 3255 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-xmp-psir.jpgbin0 -> 3308 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/jpeg-xmp-psir.xmp35
-rw-r--r--www/wiki/tests/phpunit/data/media/landscape-plain.jpgbin0 -> 38771 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/missingprofile.jpgbin0 -> 4576 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/nonanimated.gifbin0 -> 200 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/portrait-rotated.jpgbin0 -> 38577 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/rgb-na-png.pngbin0 -> 593 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/rgb-png.pngbin0 -> 663 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/say-test-mpeg1.mp3bin0 -> 8776 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/say-test-mpeg2.5.mp3bin0 -> 1224 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/say-test-mpeg2.mp3bin0 -> 4389 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/say-test-with-id3.mp3bin0 -> 8795 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/say-test.oggbin0 -> 5132 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/say-test.opusbin0 -> 9798 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/srgb.jpgbin0 -> 7738 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/test.jpgbin0 -> 437 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/test.tiffbin0 -> 566 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/tinyrgb.iccbin0 -> 524 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/tinyrgb.jpgbin0 -> 5118 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/webp_animated.webpbin0 -> 380850 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/xmp.pngbin0 -> 582 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/yuv420.jpgbin0 -> 59007 bytes
-rw-r--r--www/wiki/tests/phpunit/data/media/yuv444.jpgbin0 -> 70637 bytes
-rw-r--r--www/wiki/tests/phpunit/data/parser/320x240.ogvbin0 -> 322279 bytes
-rw-r--r--www/wiki/tests/phpunit/data/parser/LoremIpsum.djvubin0 -> 3249 bytes
-rw-r--r--www/wiki/tests/phpunit/data/parser/headbg.jpgbin0 -> 7881 bytes
-rw-r--r--www/wiki/tests/phpunit/data/parser/wiki.pngbin0 -> 22589 bytes
-rw-r--r--www/wiki/tests/phpunit/data/registration/bad_spdx.json6
-rw-r--r--www/wiki/tests/phpunit/data/registration/good.json4
-rw-r--r--www/wiki/tests/phpunit/data/registration/invalid.json5
-rw-r--r--www/wiki/tests/phpunit/data/registration/newer_manifest_version.json4
-rw-r--r--www/wiki/tests/phpunit/data/registration/no_manifest_version.json3
-rw-r--r--www/wiki/tests/phpunit/data/registration/notjson.txt1
-rw-r--r--www/wiki/tests/phpunit/data/registration/old_manifest_version.json4
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/abc.gifbin0 -> 74 bytes
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/def.svg6
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/def_variantize.svg6
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/ghi.svg4
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/ghi_massage.svg4
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/jkl.svg4
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/mno-ltr.svg10
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/mno-rtl.svg10
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/apex/icons.json6
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/apex/images/icons/stu.svg6
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/wikimediaui/icons.json6
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/wikimediaui/images/icons/stu.svg6
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/pqr-a.svg6
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/pqr-b.svg6
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/pqr-f.svg6
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/script-comment.js3
-rw-r--r--www/wiki/tests/phpunit/data/resourceloader/script-nosemi.js2
-rw-r--r--www/wiki/tests/phpunit/data/templates/bad_partial.mustache1
-rw-r--r--www/wiki/tests/phpunit/data/templates/foobar.mustache1
-rw-r--r--www/wiki/tests/phpunit/data/templates/foobar_args.mustache1
-rw-r--r--www/wiki/tests/phpunit/data/templates/has_partial.mustache1
-rw-r--r--www/wiki/tests/phpunit/data/templates/recurse.mustache1
-rw-r--r--www/wiki/tests/phpunit/data/upload/buggynamespace-bad.svg24
-rw-r--r--www/wiki/tests/phpunit/data/upload/buggynamespace-evilhtml.svg12
-rw-r--r--www/wiki/tests/phpunit/data/upload/buggynamespace-okay.svg52
-rw-r--r--www/wiki/tests/phpunit/data/upload/buggynamespace-okay2.svg52
-rw-r--r--www/wiki/tests/phpunit/data/upload/buggynamespace-original.svg33
-rw-r--r--www/wiki/tests/phpunit/data/upload/headbg.jpgbin0 -> 7881 bytes
-rw-r--r--www/wiki/tests/phpunit/data/xmp/1.result.php8
-rw-r--r--www/wiki/tests/phpunit/data/xmp/1.xmp11
-rw-r--r--www/wiki/tests/phpunit/data/xmp/2.result.php8
-rw-r--r--www/wiki/tests/phpunit/data/xmp/2.xmp12
-rw-r--r--www/wiki/tests/phpunit/data/xmp/3-invalid.result.php7
-rw-r--r--www/wiki/tests/phpunit/data/xmp/3-invalid.xmp31
-rw-r--r--www/wiki/tests/phpunit/data/xmp/3.result.php8
-rw-r--r--www/wiki/tests/phpunit/data/xmp/3.xmp29
-rw-r--r--www/wiki/tests/phpunit/data/xmp/4.result.php7
-rw-r--r--www/wiki/tests/phpunit/data/xmp/4.xmp22
-rw-r--r--www/wiki/tests/phpunit/data/xmp/5.result.php7
-rw-r--r--www/wiki/tests/phpunit/data/xmp/5.xmp16
-rw-r--r--www/wiki/tests/phpunit/data/xmp/6.result.php8
-rw-r--r--www/wiki/tests/phpunit/data/xmp/6.xmp18
-rw-r--r--www/wiki/tests/phpunit/data/xmp/7.result.php52
-rw-r--r--www/wiki/tests/phpunit/data/xmp/7.xmp67
-rw-r--r--www/wiki/tests/phpunit/data/xmp/README3
-rw-r--r--www/wiki/tests/phpunit/data/xmp/bag-for-seq.result.php10
-rw-r--r--www/wiki/tests/phpunit/data/xmp/bag-for-seq.xmp1
-rw-r--r--www/wiki/tests/phpunit/data/xmp/doctype-included.result.php3
-rw-r--r--www/wiki/tests/phpunit/data/xmp/doctype-included.xmp12
-rw-r--r--www/wiki/tests/phpunit/data/xmp/doctype-not-included.xmp11
-rw-r--r--www/wiki/tests/phpunit/data/xmp/flash.result.php8
-rw-r--r--www/wiki/tests/phpunit/data/xmp/flash.xmp11
-rw-r--r--www/wiki/tests/phpunit/data/xmp/gps.result.php11
-rw-r--r--www/wiki/tests/phpunit/data/xmp/gps.xmp17
-rw-r--r--www/wiki/tests/phpunit/data/xmp/invalid-child-not-struct.result.php7
-rw-r--r--www/wiki/tests/phpunit/data/xmp/invalid-child-not-struct.xmp12
-rw-r--r--www/wiki/tests/phpunit/data/xmp/no-namespace.result.php7
-rw-r--r--www/wiki/tests/phpunit/data/xmp/no-namespace.xmp11
-rw-r--r--www/wiki/tests/phpunit/data/xmp/no-recognized-props.result.php2
-rw-r--r--www/wiki/tests/phpunit/data/xmp/no-recognized-props.xmp8
-rw-r--r--www/wiki/tests/phpunit/data/xmp/utf16BE.result.php12
-rw-r--r--www/wiki/tests/phpunit/data/xmp/utf16BE.xmpbin0 -> 930 bytes
-rw-r--r--www/wiki/tests/phpunit/data/xmp/utf16LE.result.php12
-rw-r--r--www/wiki/tests/phpunit/data/xmp/utf16LE.xmpbin0 -> 930 bytes
-rw-r--r--www/wiki/tests/phpunit/data/xmp/utf32BE.result.php12
-rw-r--r--www/wiki/tests/phpunit/data/xmp/utf32BE.xmpbin0 -> 1856 bytes
-rw-r--r--www/wiki/tests/phpunit/data/xmp/utf32LE.result.php12
-rw-r--r--www/wiki/tests/phpunit/data/xmp/utf32LE.xmpbin0 -> 1856 bytes
-rw-r--r--www/wiki/tests/phpunit/data/xmp/xmpExt.result.php8
-rw-r--r--www/wiki/tests/phpunit/data/xmp/xmpExt.xmp13
-rw-r--r--www/wiki/tests/phpunit/data/xmp/xmpExt2.xmp8
-rw-r--r--www/wiki/tests/phpunit/data/zip/cd-gap.zipbin0 -> 182 bytes
-rw-r--r--www/wiki/tests/phpunit/data/zip/cd-truncated.zipbin0 -> 171 bytes
-rw-r--r--www/wiki/tests/phpunit/data/zip/class-trailing-null.zipbin0 -> 173 bytes
-rw-r--r--www/wiki/tests/phpunit/data/zip/class-trailing-slash.zipbin0 -> 173 bytes
-rw-r--r--www/wiki/tests/phpunit/data/zip/class.zipbin0 -> 173 bytes
-rw-r--r--www/wiki/tests/phpunit/data/zip/empty.zipbin0 -> 22 bytes
-rw-r--r--www/wiki/tests/phpunit/data/zip/looks-like-zip64.zipbin0 -> 173 bytes
-rw-r--r--www/wiki/tests/phpunit/data/zip/nosig.zipbin0 -> 173 bytes
-rw-r--r--www/wiki/tests/phpunit/data/zip/split.zipbin0 -> 196 bytes
-rw-r--r--www/wiki/tests/phpunit/data/zip/trail.zipbin0 -> 181 bytes
-rw-r--r--www/wiki/tests/phpunit/data/zip/wrong-cd-start-disk.zipbin0 -> 173 bytes
-rw-r--r--www/wiki/tests/phpunit/data/zip/wrong-central-entry-sig.zipbin0 -> 173 bytes
-rw-r--r--www/wiki/tests/phpunit/docs/ExportDemoTest.php31
-rw-r--r--www/wiki/tests/phpunit/includes/ActorMigrationTest.php695
-rw-r--r--www/wiki/tests/phpunit/includes/AutopromoteTest.php56
-rw-r--r--www/wiki/tests/phpunit/includes/BlockTest.php463
-rw-r--r--www/wiki/tests/phpunit/includes/CommentStoreTest.php778
-rw-r--r--www/wiki/tests/phpunit/includes/DeprecatedGlobalTest.php81
-rw-r--r--www/wiki/tests/phpunit/includes/DiffHistoryBlobTest.php45
-rw-r--r--www/wiki/tests/phpunit/includes/EditPageTest.php727
-rw-r--r--www/wiki/tests/phpunit/includes/ExportTest.php67
-rw-r--r--www/wiki/tests/phpunit/includes/ExtraParserTest.php216
-rw-r--r--www/wiki/tests/phpunit/includes/FauxRequestTest.php245
-rw-r--r--www/wiki/tests/phpunit/includes/FauxResponseTest.php148
-rw-r--r--www/wiki/tests/phpunit/includes/FormOptionsInitializationTest.php87
-rw-r--r--www/wiki/tests/phpunit/includes/FormOptionsTest.php100
-rw-r--r--www/wiki/tests/phpunit/includes/GitInfoTest.php102
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/GlobalTest.php812
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/GlobalWithDBTest.php32
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/README2
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfAppendQueryTest.php79
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfArrayFilterTest.php42
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfArrayPlus2dTest.php94
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php112
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php40
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfEscapeShellArgTest.php43
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfExpandUrlTest.php117
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php46
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfParseUrlTest.php157
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php93
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php20
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php31
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfStringToBoolTest.php51
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfThumbIsStandardTest.php105
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php194
-rw-r--r--www/wiki/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php125
-rw-r--r--www/wiki/tests/phpunit/includes/HooksTest.php253
-rw-r--r--www/wiki/tests/phpunit/includes/HtmlTest.php794
-rw-r--r--www/wiki/tests/phpunit/includes/LicensesTest.php25
-rw-r--r--www/wiki/tests/phpunit/includes/LinkFilterTest.php254
-rw-r--r--www/wiki/tests/phpunit/includes/LinkerTest.php480
-rw-r--r--www/wiki/tests/phpunit/includes/ListToggleTest.php49
-rw-r--r--www/wiki/tests/phpunit/includes/MWNamespaceTest.php578
-rw-r--r--www/wiki/tests/phpunit/includes/MWTimestampTest.php243
-rw-r--r--www/wiki/tests/phpunit/includes/MediaWikiServicesTest.php379
-rw-r--r--www/wiki/tests/phpunit/includes/MediaWikiTest.php157
-rw-r--r--www/wiki/tests/phpunit/includes/MediaWikiVersionFetcherTest.php22
-rw-r--r--www/wiki/tests/phpunit/includes/MergeHistoryTest.php124
-rw-r--r--www/wiki/tests/phpunit/includes/MessageTest.php855
-rw-r--r--www/wiki/tests/phpunit/includes/MovePageTest.php63
-rw-r--r--www/wiki/tests/phpunit/includes/OutputPageTest.php707
-rw-r--r--www/wiki/tests/phpunit/includes/PageArchiveTest.php265
-rw-r--r--www/wiki/tests/phpunit/includes/PagePropsTest.php303
-rw-r--r--www/wiki/tests/phpunit/includes/PathRouterTest.php269
-rw-r--r--www/wiki/tests/phpunit/includes/PreferencesTest.php90
-rw-r--r--www/wiki/tests/phpunit/includes/PrefixSearchTest.php386
-rw-r--r--www/wiki/tests/phpunit/includes/ReadOnlyModeTest.php194
-rw-r--r--www/wiki/tests/phpunit/includes/RevisionContentHandlerDbTest.php14
-rw-r--r--www/wiki/tests/phpunit/includes/RevisionDbTestBase.php1505
-rw-r--r--www/wiki/tests/phpunit/includes/RevisionNoContentHandlerDbTest.php14
-rw-r--r--www/wiki/tests/phpunit/includes/RevisionTest.php1498
-rw-r--r--www/wiki/tests/phpunit/includes/RevisionTestModifyableContent.php23
-rw-r--r--www/wiki/tests/phpunit/includes/RevisionTestModifyableContentHandler.php19
-rw-r--r--www/wiki/tests/phpunit/includes/SampleTest.php106
-rw-r--r--www/wiki/tests/phpunit/includes/SanitizerValidateEmailTest.php105
-rw-r--r--www/wiki/tests/phpunit/includes/SiteConfigurationTest.php363
-rw-r--r--www/wiki/tests/phpunit/includes/SiteStatsTest.php42
-rw-r--r--www/wiki/tests/phpunit/includes/StatusTest.php722
-rw-r--r--www/wiki/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php46
-rw-r--r--www/wiki/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php212
-rw-r--r--www/wiki/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php76
-rw-r--r--www/wiki/tests/phpunit/includes/Storage/NameTableStoreTest.php298
-rw-r--r--www/wiki/tests/phpunit/includes/Storage/RevisionArchiveRecordTest.php272
-rw-r--r--www/wiki/tests/phpunit/includes/Storage/RevisionRecordTests.php512
-rw-r--r--www/wiki/tests/phpunit/includes/Storage/RevisionSlotsTest.php139
-rw-r--r--www/wiki/tests/phpunit/includes/Storage/RevisionStoreDbTest.php1281
-rw-r--r--www/wiki/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php363
-rw-r--r--www/wiki/tests/phpunit/includes/Storage/RevisionStoreTest.php690
-rw-r--r--www/wiki/tests/phpunit/includes/Storage/SlotRecordTest.php298
-rw-r--r--www/wiki/tests/phpunit/includes/Storage/SqlBlobStoreTest.php241
-rw-r--r--www/wiki/tests/phpunit/includes/TemplateCategoriesTest.php98
-rw-r--r--www/wiki/tests/phpunit/includes/TemplateParserTest.php123
-rw-r--r--www/wiki/tests/phpunit/includes/TestLogger.php127
-rw-r--r--www/wiki/tests/phpunit/includes/TestUser.php171
-rw-r--r--www/wiki/tests/phpunit/includes/TestUserRegistry.php125
-rw-r--r--www/wiki/tests/phpunit/includes/TimeAdjustTest.php39
-rw-r--r--www/wiki/tests/phpunit/includes/TitleArrayFromResultTest.php121
-rw-r--r--www/wiki/tests/phpunit/includes/TitleMethodsTest.php367
-rw-r--r--www/wiki/tests/phpunit/includes/TitlePermissionTest.php928
-rw-r--r--www/wiki/tests/phpunit/includes/TitleTest.php968
-rw-r--r--www/wiki/tests/phpunit/includes/WebRequestTest.php626
-rw-r--r--www/wiki/tests/phpunit/includes/WikiMapTest.php253
-rw-r--r--www/wiki/tests/phpunit/includes/WikiReferenceTest.php166
-rw-r--r--www/wiki/tests/phpunit/includes/XmlJsTest.php26
-rw-r--r--www/wiki/tests/phpunit/includes/XmlSelectTest.php182
-rw-r--r--www/wiki/tests/phpunit/includes/XmlTest.php617
-rw-r--r--www/wiki/tests/phpunit/includes/actions/ActionTest.php208
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiBaseTest.php1275
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiBlockTest.php252
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiCheckTokenTest.php95
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiClearHasMsgTest.php24
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiComparePagesTest.php653
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiContinuationManagerTest.php198
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiDeleteTest.php168
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiDisabledTest.php19
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiEditPageTest.php1604
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiErrorFormatterTest.php642
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiLoginTest.php301
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiLogoutTest.php75
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiMainTest.php1072
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiMessageTest.php189
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiModuleManagerTest.php330
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiMoveTest.php393
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiOpenSearchTest.php67
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiOptionsTest.php418
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiPageSetTest.php179
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiParseTest.php849
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiPurgeTest.php40
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiQueryAllPagesTest.php36
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php976
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php1608
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php542
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiResultTest.php1410
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiRevisionDeleteTest.php117
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php51
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiStashEditTest.php27
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiTestCase.php260
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiTestCaseUpload.php8
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiTestContext.php21
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiTokensTest.php40
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiUnblockTest.php23
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiUploadTest.php560
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiUploadTestCase.php153
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiUsageExceptionTest.php44
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiUserrightsTest.php358
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiWatchTest.php148
-rw-r--r--www/wiki/tests/phpunit/includes/api/MockApi.php27
-rw-r--r--www/wiki/tests/phpunit/includes/api/MockApiQueryBase.php19
-rw-r--r--www/wiki/tests/phpunit/includes/api/PrefixUniquenessTest.php30
-rw-r--r--www/wiki/tests/phpunit/includes/api/RandomImageGenerator.php497
-rw-r--r--www/wiki/tests/phpunit/includes/api/UserWrapper.php25
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatBaseTest.php388
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatJsonTest.php129
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatNoneTest.php51
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatPhpTest.php139
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatRawTest.php120
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatTestBase.php93
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatXmlTest.php123
-rw-r--r--www/wiki/tests/phpunit/includes/api/generateRandomImages.php45
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryBasicTest.php346
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php71
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTest.php323
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php210
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php44
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryTest.php151
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryTestBase.php158
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php194
-rw-r--r--www/wiki/tests/phpunit/includes/api/words.txt1000
-rw-r--r--www/wiki/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php30
-rw-r--r--www/wiki/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php228
-rw-r--r--www/wiki/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php45
-rw-r--r--www/wiki/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php174
-rw-r--r--www/wiki/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php84
-rw-r--r--www/wiki/tests/phpunit/includes/auth/AuthManagerTest.php3629
-rw-r--r--www/wiki/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php716
-rw-r--r--www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTest.php517
-rw-r--r--www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php94
-rw-r--r--www/wiki/tests/phpunit/includes/auth/AuthenticationResponseTest.php112
-rw-r--r--www/wiki/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php64
-rw-r--r--www/wiki/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php191
-rw-r--r--www/wiki/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php68
-rw-r--r--www/wiki/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php289
-rw-r--r--www/wiki/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php57
-rw-r--r--www/wiki/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php30
-rw-r--r--www/wiki/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php34
-rw-r--r--www/wiki/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php109
-rw-r--r--www/wiki/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php373
-rw-r--r--www/wiki/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php658
-rw-r--r--www/wiki/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php138
-rw-r--r--www/wiki/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php159
-rw-r--r--www/wiki/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php57
-rw-r--r--www/wiki/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php310
-rw-r--r--www/wiki/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php79
-rw-r--r--www/wiki/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php720
-rw-r--r--www/wiki/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php236
-rw-r--r--www/wiki/tests/phpunit/includes/auth/ThrottlerTest.php238
-rw-r--r--www/wiki/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php176
-rw-r--r--www/wiki/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php34
-rw-r--r--www/wiki/tests/phpunit/includes/cache/GenderCacheTest.php87
-rw-r--r--www/wiki/tests/phpunit/includes/cache/LocalisationCacheTest.php107
-rw-r--r--www/wiki/tests/phpunit/includes/cache/MessageCacheTest.php174
-rw-r--r--www/wiki/tests/phpunit/includes/changes/CategoryMembershipChangeTest.php156
-rw-r--r--www/wiki/tests/phpunit/includes/changes/ChangesListBooleanFilterGroupTest.php96
-rw-r--r--www/wiki/tests/phpunit/includes/changes/ChangesListBooleanFilterTest.php166
-rw-r--r--www/wiki/tests/phpunit/includes/changes/ChangesListFilterGroupTest.php79
-rw-r--r--www/wiki/tests/phpunit/includes/changes/ChangesListFilterTest.php116
-rw-r--r--www/wiki/tests/phpunit/includes/changes/ChangesListStringOptionsFilterGroupTest.php279
-rw-r--r--www/wiki/tests/phpunit/includes/changes/EnhancedChangesListTest.php228
-rw-r--r--www/wiki/tests/phpunit/includes/changes/OldChangesListTest.php234
-rw-r--r--www/wiki/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php236
-rw-r--r--www/wiki/tests/phpunit/includes/changes/RecentChangeTest.php224
-rw-r--r--www/wiki/tests/phpunit/includes/changes/TestRecentChangesHelper.php170
-rw-r--r--www/wiki/tests/phpunit/includes/changetags/ChangeTagsTest.php309
-rw-r--r--www/wiki/tests/phpunit/includes/collation/CollationFaTest.php55
-rw-r--r--www/wiki/tests/phpunit/includes/collation/CollationTest.php118
-rw-r--r--www/wiki/tests/phpunit/includes/collation/CustomUppercaseCollationTest.php68
-rw-r--r--www/wiki/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php163
-rw-r--r--www/wiki/tests/phpunit/includes/config/ConfigFactoryTest.php168
-rw-r--r--www/wiki/tests/phpunit/includes/config/EtcdConfigTest.php621
-rw-r--r--www/wiki/tests/phpunit/includes/config/GlobalVarConfigTest.php97
-rw-r--r--www/wiki/tests/phpunit/includes/config/HashConfigTest.php63
-rw-r--r--www/wiki/tests/phpunit/includes/config/MultiConfigTest.php39
-rw-r--r--www/wiki/tests/phpunit/includes/content/ContentHandlerTest.php497
-rw-r--r--www/wiki/tests/phpunit/includes/content/CssContentHandlerTest.php41
-rw-r--r--www/wiki/tests/phpunit/includes/content/CssContentTest.php133
-rw-r--r--www/wiki/tests/phpunit/includes/content/FileContentHandlerTest.php52
-rw-r--r--www/wiki/tests/phpunit/includes/content/JavaScriptContentHandlerTest.php41
-rw-r--r--www/wiki/tests/phpunit/includes/content/JavaScriptContentTest.php327
-rw-r--r--www/wiki/tests/phpunit/includes/content/JsonContentHandlerTest.php14
-rw-r--r--www/wiki/tests/phpunit/includes/content/JsonContentTest.php152
-rw-r--r--www/wiki/tests/phpunit/includes/content/TextContentHandlerTest.php55
-rw-r--r--www/wiki/tests/phpunit/includes/content/TextContentTest.php477
-rw-r--r--www/wiki/tests/phpunit/includes/content/WikitextContentHandlerTest.php365
-rw-r--r--www/wiki/tests/phpunit/includes/content/WikitextContentTest.php443
-rw-r--r--www/wiki/tests/phpunit/includes/content/WikitextStructureTest.php110
-rw-r--r--www/wiki/tests/phpunit/includes/context/RequestContextTest.php117
-rw-r--r--www/wiki/tests/phpunit/includes/db/DatabaseOracleTest.php52
-rw-r--r--www/wiki/tests/phpunit/includes/db/DatabasePostgresTest.php177
-rw-r--r--www/wiki/tests/phpunit/includes/db/DatabaseSqliteTest.php519
-rw-r--r--www/wiki/tests/phpunit/includes/db/DatabaseTestHelper.php267
-rw-r--r--www/wiki/tests/phpunit/includes/db/LBFactoryTest.php530
-rw-r--r--www/wiki/tests/phpunit/includes/db/LoadBalancerTest.php305
-rw-r--r--www/wiki/tests/phpunit/includes/debug/MWDebugTest.php140
-rw-r--r--www/wiki/tests/phpunit/includes/debug/logger/LegacyLoggerTest.php175
-rw-r--r--www/wiki/tests/phpunit/includes/debug/logger/MonologSpiTest.php136
-rw-r--r--www/wiki/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php76
-rw-r--r--www/wiki/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php227
-rw-r--r--www/wiki/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php75
-rw-r--r--www/wiki/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php55
-rw-r--r--www/wiki/tests/phpunit/includes/deferred/CdnCacheUpdateTest.php31
-rw-r--r--www/wiki/tests/phpunit/includes/deferred/DeferredUpdatesTest.php338
-rw-r--r--www/wiki/tests/phpunit/includes/deferred/LinksUpdateTest.php422
-rw-r--r--www/wiki/tests/phpunit/includes/deferred/MWCallableUpdateTest.php82
-rw-r--r--www/wiki/tests/phpunit/includes/deferred/SearchUpdateTest.php87
-rw-r--r--www/wiki/tests/phpunit/includes/deferred/SiteStatsUpdateTest.php77
-rw-r--r--www/wiki/tests/phpunit/includes/deferred/TransactionRoundDefiningUpdateTest.php19
-rw-r--r--www/wiki/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php134
-rw-r--r--www/wiki/tests/phpunit/includes/diff/DiffOpTest.php68
-rw-r--r--www/wiki/tests/phpunit/includes/diff/DiffTest.php19
-rw-r--r--www/wiki/tests/phpunit/includes/diff/DifferenceEngineTest.php148
-rw-r--r--www/wiki/tests/phpunit/includes/diff/FakeDiffOp.php11
-rw-r--r--www/wiki/tests/phpunit/includes/editpage/TextboxBuilderTest.php210
-rw-r--r--www/wiki/tests/phpunit/includes/exception/BadTitleErrorTest.php30
-rw-r--r--www/wiki/tests/phpunit/includes/exception/ErrorPageErrorTest.php52
-rw-r--r--www/wiki/tests/phpunit/includes/exception/HttpErrorTest.php63
-rw-r--r--www/wiki/tests/phpunit/includes/exception/MWExceptionHandlerTest.php74
-rw-r--r--www/wiki/tests/phpunit/includes/exception/MWExceptionTest.php193
-rw-r--r--www/wiki/tests/phpunit/includes/exception/ReadOnlyErrorTest.php16
-rw-r--r--www/wiki/tests/phpunit/includes/exception/ThrottledErrorTest.php31
-rw-r--r--www/wiki/tests/phpunit/includes/exception/UserNotLoggedInTest.php16
-rw-r--r--www/wiki/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php41
-rw-r--r--www/wiki/tests/phpunit/includes/externalstore/ExternalStoreForTesting.php46
-rw-r--r--www/wiki/tests/phpunit/includes/externalstore/ExternalStoreTest.php53
-rw-r--r--www/wiki/tests/phpunit/includes/filebackend/FileBackendTest.php2641
-rw-r--r--www/wiki/tests/phpunit/includes/filebackend/SwiftFileBackendTest.php216
-rw-r--r--www/wiki/tests/phpunit/includes/filerepo/FileBackendDBRepoWrapperTest.php140
-rw-r--r--www/wiki/tests/phpunit/includes/filerepo/FileRepoTest.php55
-rw-r--r--www/wiki/tests/phpunit/includes/filerepo/MigrateFileRepoLayoutTest.php142
-rw-r--r--www/wiki/tests/phpunit/includes/filerepo/RepoGroupTest.php63
-rw-r--r--www/wiki/tests/phpunit/includes/filerepo/StoreBatchTest.php141
-rw-r--r--www/wiki/tests/phpunit/includes/filerepo/file/FileTest.php389
-rw-r--r--www/wiki/tests/phpunit/includes/filerepo/file/LocalFileTest.php183
-rw-r--r--www/wiki/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php68
-rw-r--r--www/wiki/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php105
-rw-r--r--www/wiki/tests/phpunit/includes/htmlform/HTMLFormTest.php57
-rw-r--r--www/wiki/tests/phpunit/includes/htmlform/HTMLRestrictionsFieldTest.php71
-rw-r--r--www/wiki/tests/phpunit/includes/http/HttpTest.php548
-rw-r--r--www/wiki/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php104
-rw-r--r--www/wiki/tests/phpunit/includes/import/ImportTest.php329
-rw-r--r--www/wiki/tests/phpunit/includes/installer/InstallDocFormatterTest.php83
-rw-r--r--www/wiki/tests/phpunit/includes/installer/OracleInstallerTest.php51
-rw-r--r--www/wiki/tests/phpunit/includes/interwiki/ClassicInterwikiLookupTest.php273
-rw-r--r--www/wiki/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php133
-rw-r--r--www/wiki/tests/phpunit/includes/interwiki/InterwikiTest.php122
-rw-r--r--www/wiki/tests/phpunit/includes/jobqueue/JobQueueMemoryTest.php63
-rw-r--r--www/wiki/tests/phpunit/includes/jobqueue/JobQueueTest.php393
-rw-r--r--www/wiki/tests/phpunit/includes/jobqueue/JobTest.php133
-rw-r--r--www/wiki/tests/phpunit/includes/jobqueue/RefreshLinksPartitionTest.php113
-rw-r--r--www/wiki/tests/phpunit/includes/jobqueue/jobs/CategoryMembershipChangeJobTest.php87
-rw-r--r--www/wiki/tests/phpunit/includes/jobqueue/jobs/ClearUserWatchlistJobTest.php79
-rw-r--r--www/wiki/tests/phpunit/includes/json/FormatJsonTest.php375
-rw-r--r--www/wiki/tests/phpunit/includes/libs/ArrayUtilsTest.php310
-rw-r--r--www/wiki/tests/phpunit/includes/libs/CSSMinTest.php640
-rw-r--r--www/wiki/tests/phpunit/includes/libs/DeferredStringifierTest.php54
-rw-r--r--www/wiki/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php144
-rw-r--r--www/wiki/tests/phpunit/includes/libs/GenericArrayObjectTest.php279
-rw-r--r--www/wiki/tests/phpunit/includes/libs/HashRingTest.php59
-rw-r--r--www/wiki/tests/phpunit/includes/libs/HtmlArmorTest.php55
-rw-r--r--www/wiki/tests/phpunit/includes/libs/IEUrlExtensionTest.php209
-rw-r--r--www/wiki/tests/phpunit/includes/libs/IPTest.php672
-rw-r--r--www/wiki/tests/phpunit/includes/libs/JavaScriptMinifierTest.php242
-rw-r--r--www/wiki/tests/phpunit/includes/libs/MWMessagePackTest.php77
-rw-r--r--www/wiki/tests/phpunit/includes/libs/MapCacheLRUTest.php117
-rw-r--r--www/wiki/tests/phpunit/includes/libs/MemoizedCallableTest.php142
-rw-r--r--www/wiki/tests/phpunit/includes/libs/ProcessCacheLRUTest.php268
-rw-r--r--www/wiki/tests/phpunit/includes/libs/SamplingStatsdClientTest.php77
-rw-r--r--www/wiki/tests/phpunit/includes/libs/StringUtilsTest.php128
-rw-r--r--www/wiki/tests/phpunit/includes/libs/TimingTest.php115
-rw-r--r--www/wiki/tests/phpunit/includes/libs/XhprofDataTest.php278
-rw-r--r--www/wiki/tests/phpunit/includes/libs/XhprofTest.php40
-rw-r--r--www/wiki/tests/phpunit/includes/libs/XmlTypeCheckTest.php79
-rw-r--r--www/wiki/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php499
-rw-r--r--www/wiki/tests/phpunit/includes/libs/composer/ComposerJsonTest.php42
-rw-r--r--www/wiki/tests/phpunit/includes/libs/composer/ComposerLockTest.php121
-rw-r--r--www/wiki/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php150
-rw-r--r--www/wiki/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php56
-rw-r--r--www/wiki/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php131
-rw-r--r--www/wiki/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php300
-rw-r--r--www/wiki/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php158
-rw-r--r--www/wiki/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php163
-rw-r--r--www/wiki/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php140
-rw-r--r--www/wiki/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php62
-rw-r--r--www/wiki/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php1711
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php147
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php139
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php108
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php148
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php133
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php55
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php743
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php2067
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php60
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php613
-rw-r--r--www/wiki/tests/phpunit/includes/libs/xmp/XMPTest.php227
-rw-r--r--www/wiki/tests/phpunit/includes/libs/xmp/XMPValidateTest.php55
-rw-r--r--www/wiki/tests/phpunit/includes/linkeddata/PageDataRequestHandlerTest.php284
-rw-r--r--www/wiki/tests/phpunit/includes/linker/LinkRendererFactoryTest.php82
-rw-r--r--www/wiki/tests/phpunit/includes/linker/LinkRendererTest.php189
-rw-r--r--www/wiki/tests/phpunit/includes/logging/BlockLogFormatterTest.php379
-rw-r--r--www/wiki/tests/phpunit/includes/logging/ContentModelLogFormatterTest.php60
-rw-r--r--www/wiki/tests/phpunit/includes/logging/DatabaseLogEntryTest.php162
-rw-r--r--www/wiki/tests/phpunit/includes/logging/DeleteLogFormatterTest.php556
-rw-r--r--www/wiki/tests/phpunit/includes/logging/ImportLogFormatterTest.php126
-rw-r--r--www/wiki/tests/phpunit/includes/logging/LogFormatterTest.php664
-rw-r--r--www/wiki/tests/phpunit/includes/logging/LogFormatterTestCase.php68
-rw-r--r--www/wiki/tests/phpunit/includes/logging/LogTests.i18n.php15
-rw-r--r--www/wiki/tests/phpunit/includes/logging/MergeLogFormatterTest.php70
-rw-r--r--www/wiki/tests/phpunit/includes/logging/MoveLogFormatterTest.php273
-rw-r--r--www/wiki/tests/phpunit/includes/logging/NewUsersLogFormatterTest.php204
-rw-r--r--www/wiki/tests/phpunit/includes/logging/PageLangLogFormatterTest.php56
-rw-r--r--www/wiki/tests/phpunit/includes/logging/PatrolLogFormatterTest.php121
-rw-r--r--www/wiki/tests/phpunit/includes/logging/ProtectLogFormatterTest.php431
-rw-r--r--www/wiki/tests/phpunit/includes/logging/RightsLogFormatterTest.php219
-rw-r--r--www/wiki/tests/phpunit/includes/logging/UploadLogFormatterTest.php169
-rw-r--r--www/wiki/tests/phpunit/includes/mail/MailAddressTest.php77
-rw-r--r--www/wiki/tests/phpunit/includes/mail/UserMailerTest.php14
-rw-r--r--www/wiki/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php167
-rw-r--r--www/wiki/tests/phpunit/includes/media/BitmapScalingTest.php151
-rw-r--r--www/wiki/tests/phpunit/includes/media/DjVuTest.php69
-rw-r--r--www/wiki/tests/phpunit/includes/media/ExifBitmapTest.php142
-rw-r--r--www/wiki/tests/phpunit/includes/media/ExifRotationTest.php283
-rw-r--r--www/wiki/tests/phpunit/includes/media/ExifTest.php47
-rw-r--r--www/wiki/tests/phpunit/includes/media/FakeDimensionFile.php35
-rw-r--r--www/wiki/tests/phpunit/includes/media/FormatMetadataTest.php144
-rw-r--r--www/wiki/tests/phpunit/includes/media/GIFMetadataExtractorTest.php110
-rw-r--r--www/wiki/tests/phpunit/includes/media/GIFTest.php172
-rw-r--r--www/wiki/tests/phpunit/includes/media/IPTCTest.php85
-rw-r--r--www/wiki/tests/phpunit/includes/media/JpegMetadataExtractorTest.php128
-rw-r--r--www/wiki/tests/phpunit/includes/media/JpegPixelFormatTest.php115
-rw-r--r--www/wiki/tests/phpunit/includes/media/JpegTest.php122
-rw-r--r--www/wiki/tests/phpunit/includes/media/MediaHandlerTest.php68
-rw-r--r--www/wiki/tests/phpunit/includes/media/MediaWikiMediaTestCase.php86
-rw-r--r--www/wiki/tests/phpunit/includes/media/PNGMetadataExtractorTest.php137
-rw-r--r--www/wiki/tests/phpunit/includes/media/PNGTest.php161
-rw-r--r--www/wiki/tests/phpunit/includes/media/SVGMetadataExtractorTest.php155
-rw-r--r--www/wiki/tests/phpunit/includes/media/SVGTest.php113
-rw-r--r--www/wiki/tests/phpunit/includes/media/TiffTest.php44
-rw-r--r--www/wiki/tests/phpunit/includes/media/WebPTest.php145
-rw-r--r--www/wiki/tests/phpunit/includes/media/XCFTest.php83
-rw-r--r--www/wiki/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php107
-rw-r--r--www/wiki/tests/phpunit/includes/objectcache/ObjectCacheTest.php115
-rw-r--r--www/wiki/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php90
-rw-r--r--www/wiki/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php110
-rw-r--r--www/wiki/tests/phpunit/includes/page/ArticleTablesTest.php52
-rw-r--r--www/wiki/tests/phpunit/includes/page/ArticleTest.php57
-rw-r--r--www/wiki/tests/phpunit/includes/page/ImagePage404Test.php54
-rw-r--r--www/wiki/tests/phpunit/includes/page/ImagePageTest.php92
-rw-r--r--www/wiki/tests/phpunit/includes/page/WikiCategoryPageTest.php63
-rw-r--r--www/wiki/tests/phpunit/includes/page/WikiPageContentHandlerDbTest.php42
-rw-r--r--www/wiki/tests/phpunit/includes/page/WikiPageDbTestBase.php1903
-rw-r--r--www/wiki/tests/phpunit/includes/page/WikiPageNoContentHandlerDbTest.php14
-rw-r--r--www/wiki/tests/phpunit/includes/pager/RangeChronologicalPagerTest.php99
-rw-r--r--www/wiki/tests/phpunit/includes/pager/ReverseChronologicalPagerTest.php68
-rw-r--r--www/wiki/tests/phpunit/includes/parser/CoreParserFunctionsTest.php21
-rw-r--r--www/wiki/tests/phpunit/includes/parser/MagicVariableTest.php232
-rw-r--r--www/wiki/tests/phpunit/includes/parser/ParserIntegrationTest.php65
-rw-r--r--www/wiki/tests/phpunit/includes/parser/ParserMethodsTest.php185
-rw-r--r--www/wiki/tests/phpunit/includes/parser/ParserOptionsTest.php223
-rw-r--r--www/wiki/tests/phpunit/includes/parser/ParserOutputTest.php294
-rw-r--r--www/wiki/tests/phpunit/includes/parser/ParserPreloadTest.php95
-rw-r--r--www/wiki/tests/phpunit/includes/parser/PreprocessorTest.php294
-rw-r--r--www/wiki/tests/phpunit/includes/parser/SanitizerTest.php571
-rw-r--r--www/wiki/tests/phpunit/includes/parser/StripStateTest.php136
-rw-r--r--www/wiki/tests/phpunit/includes/parser/TagHooksTest.php134
-rw-r--r--www/wiki/tests/phpunit/includes/parser/TidyTest.php63
-rw-r--r--www/wiki/tests/phpunit/includes/password/BcryptPasswordTest.php44
-rw-r--r--www/wiki/tests/phpunit/includes/password/EncryptedPasswordTest.php84
-rw-r--r--www/wiki/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php63
-rw-r--r--www/wiki/tests/phpunit/includes/password/MWOldPasswordTest.php24
-rw-r--r--www/wiki/tests/phpunit/includes/password/MWSaltedPasswordTest.php21
-rw-r--r--www/wiki/tests/phpunit/includes/password/PasswordFactoryTest.php110
-rw-r--r--www/wiki/tests/phpunit/includes/password/PasswordPolicyChecksTest.php159
-rw-r--r--www/wiki/tests/phpunit/includes/password/PasswordTest.php41
-rw-r--r--www/wiki/tests/phpunit/includes/password/PasswordTestCase.php112
-rw-r--r--www/wiki/tests/phpunit/includes/password/Pbkdf2PasswordFallbackTest.php29
-rw-r--r--www/wiki/tests/phpunit/includes/password/Pbkdf2PasswordTest.php29
-rw-r--r--www/wiki/tests/phpunit/includes/password/UserPasswordPolicyTest.php232
-rw-r--r--www/wiki/tests/phpunit/includes/poolcounter/PoolCounterTest.php84
-rw-r--r--www/wiki/tests/phpunit/includes/preferences/DefaultPreferencesFactoryTest.php183
-rw-r--r--www/wiki/tests/phpunit/includes/rcfeed/RCFeedIntegrationTest.php98
-rw-r--r--www/wiki/tests/phpunit/includes/registration/ExtensionJsonValidatorTest.php84
-rw-r--r--www/wiki/tests/phpunit/includes/registration/ExtensionProcessorTest.php742
-rw-r--r--www/wiki/tests/phpunit/includes/registration/ExtensionRegistryTest.php352
-rw-r--r--www/wiki/tests/phpunit/includes/registration/VersionCheckerTest.php207
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php128
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php224
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php405
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php120
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php353
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php265
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php136
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php224
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderOOUIImageModuleTest.php65
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php207
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php494
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php911
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php380
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/templates/template.html1
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/templates/template2.html1
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/templates/template_awesome.handlebars1
-rw-r--r--www/wiki/tests/phpunit/includes/search/ParserOutputSearchDataExtractorTest.php70
-rw-r--r--www/wiki/tests/phpunit/includes/search/SearchEnginePrefixTest.php362
-rw-r--r--www/wiki/tests/phpunit/includes/search/SearchEngineTest.php368
-rw-r--r--www/wiki/tests/phpunit/includes/search/SearchIndexFieldTest.php56
-rw-r--r--www/wiki/tests/phpunit/includes/search/SearchSuggestionSetTest.php104
-rw-r--r--www/wiki/tests/phpunit/includes/services/ServiceContainerTest.php414
-rw-r--r--www/wiki/tests/phpunit/includes/services/TestWiring1.php10
-rw-r--r--www/wiki/tests/phpunit/includes/services/TestWiring2.php10
-rw-r--r--www/wiki/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php340
-rw-r--r--www/wiki/tests/phpunit/includes/session/CookieSessionProviderTest.php842
-rw-r--r--www/wiki/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php305
-rw-r--r--www/wiki/tests/phpunit/includes/session/MetadataMergeExceptionTest.php30
-rw-r--r--www/wiki/tests/phpunit/includes/session/PHPSessionHandlerTest.php361
-rw-r--r--www/wiki/tests/phpunit/includes/session/SessionBackendTest.php963
-rw-r--r--www/wiki/tests/phpunit/includes/session/SessionIdTest.php22
-rw-r--r--www/wiki/tests/phpunit/includes/session/SessionInfoTest.php356
-rw-r--r--www/wiki/tests/phpunit/includes/session/SessionManagerTest.php1521
-rw-r--r--www/wiki/tests/phpunit/includes/session/SessionProviderTest.php206
-rw-r--r--www/wiki/tests/phpunit/includes/session/SessionTest.php373
-rw-r--r--www/wiki/tests/phpunit/includes/session/TestBagOStuff.php81
-rw-r--r--www/wiki/tests/phpunit/includes/session/TestUtils.php106
-rw-r--r--www/wiki/tests/phpunit/includes/session/TokenTest.php67
-rw-r--r--www/wiki/tests/phpunit/includes/session/UserInfoTest.php186
-rw-r--r--www/wiki/tests/phpunit/includes/shell/CommandFactoryTest.php50
-rw-r--r--www/wiki/tests/phpunit/includes/shell/CommandTest.php181
-rw-r--r--www/wiki/tests/phpunit/includes/shell/FirejailCommandTest.php85
-rw-r--r--www/wiki/tests/phpunit/includes/shell/ShellTest.php105
-rw-r--r--www/wiki/tests/phpunit/includes/site/CachingSiteStoreTest.php163
-rw-r--r--www/wiki/tests/phpunit/includes/site/DBSiteStoreTest.php166
-rw-r--r--www/wiki/tests/phpunit/includes/site/FileBasedSiteLookupTest.php103
-rw-r--r--www/wiki/tests/phpunit/includes/site/HashSiteStoreTest.php105
-rw-r--r--www/wiki/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php117
-rw-r--r--www/wiki/tests/phpunit/includes/site/MediaWikiSiteTest.php108
-rw-r--r--www/wiki/tests/phpunit/includes/site/SiteExporterTest.php150
-rw-r--r--www/wiki/tests/phpunit/includes/site/SiteImporterTest.php202
-rw-r--r--www/wiki/tests/phpunit/includes/site/SiteImporterTest.xml19
-rw-r--r--www/wiki/tests/phpunit/includes/site/SiteListTest.php239
-rw-r--r--www/wiki/tests/phpunit/includes/site/SiteTest.php295
-rw-r--r--www/wiki/tests/phpunit/includes/site/SitesCacheFileBuilderTest.php137
-rw-r--r--www/wiki/tests/phpunit/includes/site/TestSites.php112
-rw-r--r--www/wiki/tests/phpunit/includes/skins/SkinFactoryTest.php82
-rw-r--r--www/wiki/tests/phpunit/includes/skins/SkinTemplateTest.php101
-rw-r--r--www/wiki/tests/phpunit/includes/sparql/SparqlClientTest.php190
-rw-r--r--www/wiki/tests/phpunit/includes/specialpage/AbstractChangesListSpecialPageTestCase.php151
-rw-r--r--www/wiki/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php1098
-rw-r--r--www/wiki/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php285
-rw-r--r--www/wiki/tests/phpunit/includes/specialpage/SpecialPageTest.php104
-rw-r--r--www/wiki/tests/phpunit/includes/specialpage/SpecialPageTestHelper.php24
-rw-r--r--www/wiki/tests/phpunit/includes/specials/ContribsPagerTest.php118
-rw-r--r--www/wiki/tests/phpunit/includes/specials/ImageListPagerTest.php21
-rw-r--r--www/wiki/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php80
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialBlankPageTest.php25
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialBooksourcesTest.php51
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialEditWatchlistTest.php50
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialMIMESearchTest.php49
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialMyLanguageTest.php78
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialPageDataTest.php146
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialPageExecutor.php129
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialPageTestBase.php72
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialPreferencesTest.php58
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialRecentchangesTest.php52
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialSearchTest.php296
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialShortpagesTest.php43
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialUncategorizedcategoriesTest.php63
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialUploadTest.php29
-rw-r--r--www/wiki/tests/phpunit/includes/specials/SpecialWatchlistTest.php192
-rw-r--r--www/wiki/tests/phpunit/includes/tidy/BalancerTest.php169
-rw-r--r--www/wiki/tests/phpunit/includes/tidy/RemexDriverTest.php307
-rw-r--r--www/wiki/tests/phpunit/includes/tidy/html5lib-tests.json80692
-rw-r--r--www/wiki/tests/phpunit/includes/title/ForeignTitleTest.php103
-rw-r--r--www/wiki/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php415
-rw-r--r--www/wiki/tests/phpunit/includes/title/NaiveForeignTitleFactoryTest.php91
-rw-r--r--www/wiki/tests/phpunit/includes/title/NaiveImportTitleFactoryTest.php90
-rw-r--r--www/wiki/tests/phpunit/includes/title/NamespaceAwareForeignTitleFactoryTest.php101
-rw-r--r--www/wiki/tests/phpunit/includes/title/NamespaceImportTitleFactoryTest.php78
-rw-r--r--www/wiki/tests/phpunit/includes/title/SubpageImportTitleFactoryTest.php86
-rw-r--r--www/wiki/tests/phpunit/includes/title/TitleValueTest.php148
-rw-r--r--www/wiki/tests/phpunit/includes/upload/UploadBaseTest.php614
-rw-r--r--www/wiki/tests/phpunit/includes/upload/UploadFromUrlTest.php151
-rw-r--r--www/wiki/tests/phpunit/includes/upload/UploadStashTest.php113
-rw-r--r--www/wiki/tests/phpunit/includes/user/BotPasswordTest.php420
-rw-r--r--www/wiki/tests/phpunit/includes/user/CentralIdLookupTest.php183
-rw-r--r--www/wiki/tests/phpunit/includes/user/ExternalUserNamesTest.php131
-rw-r--r--www/wiki/tests/phpunit/includes/user/LocalIdLookupTest.php156
-rw-r--r--www/wiki/tests/phpunit/includes/user/PasswordResetTest.php193
-rw-r--r--www/wiki/tests/phpunit/includes/user/UserArrayFromResultTest.php114
-rw-r--r--www/wiki/tests/phpunit/includes/user/UserGroupMembershipTest.php153
-rw-r--r--www/wiki/tests/phpunit/includes/user/UserTest.php1208
-rw-r--r--www/wiki/tests/phpunit/includes/utils/AvroValidatorTest.php118
-rw-r--r--www/wiki/tests/phpunit/includes/utils/BatchRowUpdateTest.php252
-rw-r--r--www/wiki/tests/phpunit/includes/utils/ClassCollectorTest.php56
-rw-r--r--www/wiki/tests/phpunit/includes/utils/FileContentsHasherTest.php57
-rw-r--r--www/wiki/tests/phpunit/includes/utils/MWCryptHKDFTest.php98
-rw-r--r--www/wiki/tests/phpunit/includes/utils/MWCryptHashTest.php64
-rw-r--r--www/wiki/tests/phpunit/includes/utils/MWGrantsTest.php117
-rw-r--r--www/wiki/tests/phpunit/includes/utils/MWRestrictionsTest.php217
-rw-r--r--www/wiki/tests/phpunit/includes/utils/UIDGeneratorTest.php173
-rw-r--r--www/wiki/tests/phpunit/includes/utils/ZipDirectoryReaderTest.php87
-rw-r--r--www/wiki/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php246
-rw-r--r--www/wiki/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php1706
-rw-r--r--www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php231
-rw-r--r--www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php2753
-rw-r--r--www/wiki/tests/phpunit/languages/LanguageClassesTestCase.php74
-rw-r--r--www/wiki/tests/phpunit/languages/LanguageCodeTest.php161
-rw-r--r--www/wiki/tests/phpunit/languages/LanguageConverterTest.php207
-rw-r--r--www/wiki/tests/phpunit/languages/LanguageTest.php1869
-rw-r--r--www/wiki/tests/phpunit/languages/SpecialPageAliasTest.php62
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageAmTest.php35
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageArTest.php89
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageArqTest.php26
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageBeTest.php42
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageBe_taraskTest.php100
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageBhoTest.php35
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageBsTest.php46
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageCrhTest.php92
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageCsTest.php41
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageCuTest.php44
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageCyTest.php43
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageDsbTest.php43
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageFrTest.php35
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageGaTest.php35
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageGanTest.php38
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageGdTest.php53
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageGvTest.php44
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageHeTest.php132
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageHiTest.php35
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageHrTest.php42
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageHsbTest.php43
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageHuTest.php37
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageHyTest.php39
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageIuTest.php38
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageKkTest.php33
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageKshTest.php37
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageKuTest.php44
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageLnTest.php35
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageLtTest.php63
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageLvTest.php44
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageMgTest.php36
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageMkTest.php40
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageMlTest.php40
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageMoTest.php45
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageMtTest.php77
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageNlTest.php24
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageNsoTest.php34
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguagePlTest.php104
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageRoTest.php45
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageRuTest.php189
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageSeTest.php53
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageSgsTest.php71
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageShTest.php42
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageShiTest.php36
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageSkTest.php42
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageSlTest.php46
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageSmaTest.php53
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageSrTest.php252
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageTgTest.php34
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageTiTest.php34
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageTlTest.php36
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageTrTest.php63
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageUkTest.php109
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageUzTest.php127
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageWaTest.php36
-rw-r--r--www/wiki/tests/phpunit/languages/classes/LanguageZhTest.php126
-rw-r--r--www/wiki/tests/phpunit/maintenance/BenchmarkerTest.php142
-rw-r--r--www/wiki/tests/phpunit/maintenance/DumpTestCase.php417
-rw-r--r--www/wiki/tests/phpunit/maintenance/MaintenanceBaseTestCase.php93
-rw-r--r--www/wiki/tests/phpunit/maintenance/MaintenanceTest.php536
-rw-r--r--www/wiki/tests/phpunit/maintenance/backupPrefetchTest.php279
-rw-r--r--www/wiki/tests/phpunit/maintenance/backupTextPassTest.php701
-rw-r--r--www/wiki/tests/phpunit/maintenance/backup_LogTest.php241
-rw-r--r--www/wiki/tests/phpunit/maintenance/backup_PageTest.php443
-rw-r--r--www/wiki/tests/phpunit/maintenance/categoriesRdfTest.php102
-rw-r--r--www/wiki/tests/phpunit/maintenance/deleteAutoPatrolLogsTest.php252
-rw-r--r--www/wiki/tests/phpunit/maintenance/fetchTextTest.php272
-rw-r--r--www/wiki/tests/phpunit/mocks/MockChangesListFilter.php15
-rw-r--r--www/wiki/tests/phpunit/mocks/MockChangesListFilterGroup.php21
-rw-r--r--www/wiki/tests/phpunit/mocks/MockMessageLocalizer.php49
-rw-r--r--www/wiki/tests/phpunit/mocks/MockWebRequest.php25
-rw-r--r--www/wiki/tests/phpunit/mocks/content/DummyContentForTesting.php123
-rw-r--r--www/wiki/tests/phpunit/mocks/content/DummyContentHandlerForTesting.php42
-rw-r--r--www/wiki/tests/phpunit/mocks/content/DummyNonTextContent.php121
-rw-r--r--www/wiki/tests/phpunit/mocks/content/DummyNonTextContentHandler.php47
-rw-r--r--www/wiki/tests/phpunit/mocks/content/DummySerializeErrorContentHandler.php51
-rw-r--r--www/wiki/tests/phpunit/mocks/filebackend/MockFSFile.php66
-rw-r--r--www/wiki/tests/phpunit/mocks/filebackend/MockFileBackend.php39
-rw-r--r--www/wiki/tests/phpunit/mocks/filerepo/MockLocalRepo.php23
-rw-r--r--www/wiki/tests/phpunit/mocks/media/MockBitmapHandler.php32
-rw-r--r--www/wiki/tests/phpunit/mocks/media/MockDjVuHandler.php53
-rw-r--r--www/wiki/tests/phpunit/mocks/media/MockImageHandler.php86
-rw-r--r--www/wiki/tests/phpunit/mocks/media/MockSvgHandler.php28
-rw-r--r--www/wiki/tests/phpunit/mocks/session/DummySessionBackend.php29
-rw-r--r--www/wiki/tests/phpunit/mocks/session/DummySessionProvider.php60
-rwxr-xr-xwww/wiki/tests/phpunit/phpunit.php173
-rw-r--r--www/wiki/tests/phpunit/run-tests.bat1
-rw-r--r--www/wiki/tests/phpunit/skins/SideBarTest.php221
-rw-r--r--www/wiki/tests/phpunit/structure/ApiStructureTest.php612
-rw-r--r--www/wiki/tests/phpunit/structure/AutoLoaderTest.php171
-rw-r--r--www/wiki/tests/phpunit/structure/AvailableRightsTest.php53
-rw-r--r--www/wiki/tests/phpunit/structure/ContentHandlerSanityTest.php59
-rw-r--r--www/wiki/tests/phpunit/structure/DatabaseIntegrationTest.php56
-rw-r--r--www/wiki/tests/phpunit/structure/ExtensionJsonValidationTest.php67
-rw-r--r--www/wiki/tests/phpunit/structure/ResourcesTest.php349
-rw-r--r--www/wiki/tests/phpunit/structure/StructureTest.php69
-rw-r--r--www/wiki/tests/phpunit/suite.xml77
-rw-r--r--www/wiki/tests/phpunit/suites/CoreParserTestSuite.php9
-rw-r--r--www/wiki/tests/phpunit/suites/ExtensionsParserTestSuite.php8
-rw-r--r--www/wiki/tests/phpunit/suites/ExtensionsTestSuite.php51
-rw-r--r--www/wiki/tests/phpunit/suites/LessTestSuite.php34
-rw-r--r--www/wiki/tests/phpunit/suites/ParserTestFileSuite.php32
-rw-r--r--www/wiki/tests/phpunit/suites/ParserTestTopLevelSuite.php160
-rw-r--r--www/wiki/tests/phpunit/suites/UploadFromUrlTestSuite.php99
-rw-r--r--www/wiki/tests/phpunit/tests/MediaWikiTestCaseSchema1Test.php51
-rw-r--r--www/wiki/tests/phpunit/tests/MediaWikiTestCaseSchema2Test.php48
-rw-r--r--www/wiki/tests/phpunit/tests/MediaWikiTestCaseSchemaTest.sql18
-rw-r--r--www/wiki/tests/phpunit/tests/MediaWikiTestCaseTest.php184
-rw-r--r--www/wiki/tests/qunit/.htaccess1
-rw-r--r--www/wiki/tests/qunit/QUnitTestResources.php149
-rw-r--r--www/wiki/tests/qunit/data/defineCallMwLoaderTestCallback.js1
-rw-r--r--www/wiki/tests/qunit/data/generateJqueryMsgData.php148
-rw-r--r--www/wiki/tests/qunit/data/load.mock.php107
-rw-r--r--www/wiki/tests/qunit/data/mediawiki.jqueryMsg.data.js492
-rw-r--r--www/wiki/tests/qunit/data/mwLoaderTestCallback.js1
-rw-r--r--www/wiki/tests/qunit/data/requireCallMwLoaderTestCallback.js6
-rw-r--r--www/wiki/tests/qunit/data/styleTest.css.php61
-rw-r--r--www/wiki/tests/qunit/data/testrunner.js652
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.accessKeyLabel.test.js121
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.color.test.js15
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.colorUtil.test.js63
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.getAttrs.test.js14
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.hidpi.test.js38
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.highlightText.test.js235
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js286
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.localize.test.js135
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js377
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.tabIndex.test.js35
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js267
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js1498
-rw-r--r--www/wiki/tests/qunit/suites/resources/jquery/jquery.textSelection.test.js266
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js32
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js114
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js220
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js29
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js141
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js45
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js456
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js33
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js60
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js354
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js205
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js1562
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js520
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js89
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js64
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js38
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js39
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js150
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js738
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js509
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js82
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js179
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.errorLogger.test.js42
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js63
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.html.test.js105
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.inspect.test.js74
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js1233
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js69
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js708
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js980
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js28
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js108
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js56
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js32
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js63
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.test.js447
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js39
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.track.test.js60
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js115
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js465
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js112
-rw-r--r--www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js115
-rw-r--r--www/wiki/tests/qunit/suites/resources/startup.test.js160
-rw-r--r--www/wiki/tests/qunit/suites/resources/test.sinonjs/index.js3
-rw-r--r--www/wiki/tests/selenium/README.md61
-rw-r--r--www/wiki/tests/selenium/pageobjects/createaccount.page.js49
-rw-r--r--www/wiki/tests/selenium/pageobjects/delete.page.js39
-rw-r--r--www/wiki/tests/selenium/pageobjects/edit.page.js39
-rw-r--r--www/wiki/tests/selenium/pageobjects/history.page.js13
-rw-r--r--www/wiki/tests/selenium/pageobjects/page.js8
-rw-r--r--www/wiki/tests/selenium/pageobjects/preferences.page.js20
-rw-r--r--www/wiki/tests/selenium/pageobjects/restore.page.js21
-rw-r--r--www/wiki/tests/selenium/pageobjects/userlogin.page.js27
-rwxr-xr-xwww/wiki/tests/selenium/selenium.sh9
-rw-r--r--www/wiki/tests/selenium/specs/page.js136
-rw-r--r--www/wiki/tests/selenium/specs/user.js69
-rw-r--r--www/wiki/tests/selenium/wdio.conf.js328
998 files changed, 301794 insertions, 0 deletions
diff --git a/www/wiki/tests/.htaccess b/www/wiki/tests/.htaccess
new file mode 100644
index 00000000..3a428827
--- /dev/null
+++ b/www/wiki/tests/.htaccess
@@ -0,0 +1 @@
+Deny from all
diff --git a/www/wiki/tests/common/TestSetup.php b/www/wiki/tests/common/TestSetup.php
new file mode 100644
index 00000000..c176a67f
--- /dev/null
+++ b/www/wiki/tests/common/TestSetup.php
@@ -0,0 +1,113 @@
+<?php
+
+/**
+ * Common code for test environment initialisation and teardown
+ */
+class TestSetup {
+ /**
+ * This should be called before Setup.php, e.g. from the finalSetup() method
+ * of a Maintenance subclass
+ */
+ public static function applyInitialConfig() {
+ global $wgMainCacheType, $wgMessageCacheType, $wgParserCacheType, $wgMainWANCache;
+ global $wgMainStash;
+ global $wgLanguageConverterCacheType, $wgUseDatabaseMessages;
+ global $wgLocaltimezone, $wgLocalisationCacheConf;
+ global $wgSearchType;
+ global $wgDevelopmentWarnings;
+ global $wgSessionProviders, $wgSessionPbkdf2Iterations;
+ global $wgJobTypeConf;
+ global $wgAuthManagerConfig, $wgAuth;
+
+ // wfWarn should cause tests to fail
+ $wgDevelopmentWarnings = true;
+
+ // Make sure all caches and stashes are either disabled or use
+ // in-process cache only to prevent tests from using any preconfigured
+ // cache meant for the local wiki from outside the test run.
+ // See also MediaWikiTestCase::run() which mocks CACHE_DB and APC.
+
+ // Disabled in DefaultSettings, override local settings
+ $wgMainWANCache =
+ $wgMainCacheType = CACHE_NONE;
+ // Uses CACHE_ANYTHING in DefaultSettings, use hash instead of db
+ $wgMessageCacheType =
+ $wgParserCacheType =
+ $wgSessionCacheType =
+ $wgLanguageConverterCacheType = 'hash';
+ // Uses db-replicated in DefaultSettings
+ $wgMainStash = 'hash';
+ // Use memory job queue
+ $wgJobTypeConf = [
+ 'default' => [ 'class' => JobQueueMemory::class, 'order' => 'fifo' ],
+ ];
+
+ $wgUseDatabaseMessages = false; # Set for future resets
+
+ // Assume UTC for testing purposes
+ $wgLocaltimezone = 'UTC';
+
+ $wgLocalisationCacheConf['storeClass'] = LCStoreNull::class;
+
+ // Do not bother updating search tables
+ $wgSearchType = SearchEngineDummy::class;
+
+ // Generic MediaWiki\Session\SessionManager configuration for tests
+ // We use CookieSessionProvider because things might be expecting
+ // cookies to show up in a FauxRequest somewhere.
+ $wgSessionProviders = [
+ [
+ 'class' => MediaWiki\Session\CookieSessionProvider::class,
+ 'args' => [ [
+ 'priority' => 30,
+ 'callUserSetCookiesHook' => true,
+ ] ],
+ ],
+ ];
+
+ // Single-iteration PBKDF2 session secret derivation, for speed.
+ $wgSessionPbkdf2Iterations = 1;
+
+ // Generic AuthManager configuration for testing
+ $wgAuthManagerConfig = [
+ 'preauth' => [],
+ 'primaryauth' => [
+ [
+ 'class' => MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider::class,
+ 'args' => [ [
+ 'authoritative' => false,
+ ] ],
+ ],
+ [
+ 'class' => MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider::class,
+ 'args' => [ [
+ 'authoritative' => true,
+ ] ],
+ ],
+ ],
+ 'secondaryauth' => [],
+ ];
+ $wgAuth = new MediaWiki\Auth\AuthManagerAuthPlugin();
+
+ // T46192 Do not attempt to send a real e-mail
+ Hooks::clear( 'AlternateUserMailer' );
+ Hooks::register(
+ 'AlternateUserMailer',
+ function () {
+ return false;
+ }
+ );
+ // xdebug's default of 100 is too low for MediaWiki
+ ini_set( 'xdebug.max_nesting_level', 1000 );
+
+ // Bug T116683 serialize_precision of 100
+ // may break testing against floating point values
+ // treated with PHP's serialize()
+ ini_set( 'serialize_precision', 17 );
+
+ // TODO: we should call MediaWikiTestCase::prepareServices( new GlobalVarConfig() ) here.
+ // But PHPUnit may not be loaded yet, so we have to wait until just
+ // before PHPUnit_TextUI_Command::main() is executed.
+ }
+
+}
diff --git a/www/wiki/tests/common/TestsAutoLoader.php b/www/wiki/tests/common/TestsAutoLoader.php
new file mode 100644
index 00000000..abf718d0
--- /dev/null
+++ b/www/wiki/tests/common/TestsAutoLoader.php
@@ -0,0 +1,222 @@
+<?php
+/**
+ * AutoLoader for the testing 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
+ */
+
+global $wgAutoloadClasses;
+$testDir = __DIR__ . "/..";
+
+// phpcs:disable Generic.Files.LineLength
+$wgAutoloadClasses += [
+
+ # tests/common
+ 'TestSetup' => "$testDir/common/TestSetup.php",
+
+ # tests/integration
+ 'MWHttpRequestTestCase' => "$testDir/integration/includes/http/MWHttpRequestTestCase.php",
+
+ # tests/parser
+ 'DbTestPreviewer' => "$testDir/parser/DbTestPreviewer.php",
+ 'DbTestRecorder' => "$testDir/parser/DbTestRecorder.php",
+ 'DjVuSupport' => "$testDir/parser/DjVuSupport.php",
+ 'MultiTestRecorder' => "$testDir/parser/MultiTestRecorder.php",
+ 'ParserTestMockParser' => "$testDir/parser/ParserTestMockParser.php",
+ 'ParserTestRunner' => "$testDir/parser/ParserTestRunner.php",
+ 'ParserTestParserHook' => "$testDir/parser/ParserTestParserHook.php",
+ 'ParserTestPrinter' => "$testDir/parser/ParserTestPrinter.php",
+ 'ParserTestResult' => "$testDir/parser/ParserTestResult.php",
+ 'ParserTestResultNormalizer' => "$testDir/parser/ParserTestResultNormalizer.php",
+ 'PhpunitTestRecorder' => "$testDir/parser/PhpunitTestRecorder.php",
+ 'TestFileEditor' => "$testDir/parser/TestFileEditor.php",
+ 'TestFileReader' => "$testDir/parser/TestFileReader.php",
+ 'TestRecorder' => "$testDir/parser/TestRecorder.php",
+ 'TidySupport' => "$testDir/parser/TidySupport.php",
+
+ # tests/phpunit
+ 'MediaWikiTestCase' => "$testDir/phpunit/MediaWikiTestCase.php",
+ 'MediaWikiPHPUnitTestListener' => "$testDir/phpunit/MediaWikiPHPUnitTestListener.php",
+ 'MediaWikiLangTestCase' => "$testDir/phpunit/MediaWikiLangTestCase.php",
+ 'ResourceLoaderTestCase' => "$testDir/phpunit/ResourceLoaderTestCase.php",
+ 'ResourceLoaderTestModule' => "$testDir/phpunit/ResourceLoaderTestCase.php",
+ 'ResourceLoaderFileTestModule' => "$testDir/phpunit/ResourceLoaderTestCase.php",
+ 'ResourceLoaderFileModuleTestModule' => "$testDir/phpunit/ResourceLoaderTestCase.php",
+ 'EmptyResourceLoader' => "$testDir/phpunit/ResourceLoaderTestCase.php",
+ 'TestUser' => "$testDir/phpunit/includes/TestUser.php",
+ 'TestUserRegistry' => "$testDir/phpunit/includes/TestUserRegistry.php",
+ 'LessFileCompilationTest' => "$testDir/phpunit/LessFileCompilationTest.php",
+ 'MediaWikiCoversValidator' => "$testDir/phpunit/MediaWikiCoversValidator.php",
+ 'PHPUnit4And6Compat' => "$testDir/phpunit/PHPUnit4And6Compat.php",
+ 'HamcrestPHPUnitIntegration' => "$testDir/phpunit/HamcrestPHPUnitIntegration.php",
+
+ # tests/phpunit/includes
+ 'RevisionDbTestBase' => "$testDir/phpunit/includes/RevisionDbTestBase.php",
+ 'RevisionTestModifyableContent' => "$testDir/phpunit/includes/RevisionTestModifyableContent.php",
+ 'RevisionTestModifyableContentHandler' => "$testDir/phpunit/includes/RevisionTestModifyableContentHandler.php",
+ 'TestLogger' => "$testDir/phpunit/includes/TestLogger.php",
+
+ # tests/phpunit/includes/api
+ 'ApiFormatTestBase' => "$testDir/phpunit/includes/api/format/ApiFormatTestBase.php",
+ 'ApiQueryTestBase' => "$testDir/phpunit/includes/api/query/ApiQueryTestBase.php",
+ 'ApiQueryContinueTestBase' => "$testDir/phpunit/includes/api/query/ApiQueryContinueTestBase.php",
+ 'ApiTestCase' => "$testDir/phpunit/includes/api/ApiTestCase.php",
+ 'ApiTestCaseUpload' => "$testDir/phpunit/includes/api/ApiTestCaseUpload.php",
+ 'ApiTestContext' => "$testDir/phpunit/includes/api/ApiTestContext.php",
+ 'ApiUploadTestCase' => "$testDir/phpunit/includes/api/ApiUploadTestCase.php",
+ 'MockApi' => "$testDir/phpunit/includes/api/MockApi.php",
+ 'MockApiQueryBase' => "$testDir/phpunit/includes/api/MockApiQueryBase.php",
+ 'UserWrapper' => "$testDir/phpunit/includes/api/UserWrapper.php",
+ 'RandomImageGenerator' => "$testDir/phpunit/includes/api/RandomImageGenerator.php",
+
+ # tests/phpunit/includes/auth
+ 'MediaWiki\\Auth\\AuthenticationRequestTestCase' =>
+ "$testDir/phpunit/includes/auth/AuthenticationRequestTestCase.php",
+
+ # tests/phpunit/includes/changes
+ 'TestRecentChangesHelper' => "$testDir/phpunit/includes/changes/TestRecentChangesHelper.php",
+
+ # tests/phpunit/includes/content
+ 'DummyContentHandlerForTesting' =>
+ "$testDir/phpunit/mocks/content/DummyContentHandlerForTesting.php",
+ 'DummyContentForTesting' => "$testDir/phpunit/mocks/content/DummyContentForTesting.php",
+ 'DummyNonTextContentHandler' => "$testDir/phpunit/mocks/content/DummyNonTextContentHandler.php",
+ 'DummyNonTextContent' => "$testDir/phpunit/mocks/content/DummyNonTextContent.php",
+ 'DummySerializeErrorContentHandler' =>
+ "$testDir/phpunit/mocks/content/DummySerializeErrorContentHandler.php",
+ 'ContentHandlerTest' => "$testDir/phpunit/includes/content/ContentHandlerTest.php",
+ 'JavaScriptContentTest' => "$testDir/phpunit/includes/content/JavaScriptContentTest.php",
+ 'TextContentTest' => "$testDir/phpunit/includes/content/TextContentTest.php",
+ 'WikitextContentTest' => "$testDir/phpunit/includes/content/WikitextContentTest.php",
+
+ # tests/phpunit/includes/db
+ 'DatabaseTestHelper' => "$testDir/phpunit/includes/db/DatabaseTestHelper.php",
+
+ # tests/phpunit/includes/diff
+ 'FakeDiffOp' => "$testDir/phpunit/includes/diff/FakeDiffOp.php",
+
+ # tests/phpunit/includes/externalstore
+ 'ExternalStoreForTesting' => "$testDir/phpunit/includes/externalstore/ExternalStoreForTesting.php",
+
+ # tests/phpunit/includes/logging
+ 'LogFormatterTestCase' => "$testDir/phpunit/includes/logging/LogFormatterTestCase.php",
+
+ # tests/phpunit/includes/page
+ 'WikiPageDbTestBase' => "$testDir/phpunit/includes/page/WikiPageDbTestBase.php",
+
+ # tests/phpunit/includes/parser
+ 'ParserIntegrationTest' => "$testDir/phpunit/includes/parser/ParserIntegrationTest.php",
+
+ # tests/phpunit/includes/password
+ 'PasswordTestCase' => "$testDir/phpunit/includes/password/PasswordTestCase.php",
+
+ # tests/phpunit/includes/resourceloader
+ 'ResourceLoaderImageModuleTest' =>
+ "$testDir/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php",
+ 'ResourceLoaderImageModuleTestable' =>
+ "$testDir/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php",
+
+ # tests/phpunit/includes/session
+ 'MediaWiki\\Session\\TestBagOStuff' => "$testDir/phpunit/includes/session/TestBagOStuff.php",
+ 'MediaWiki\\Session\\TestUtils' => "$testDir/phpunit/includes/session/TestUtils.php",
+
+ # tests/phpunit/includes/site
+ 'SiteTest' => "$testDir/phpunit/includes/site/SiteTest.php",
+ 'TestSites' => "$testDir/phpunit/includes/site/TestSites.php",
+
+ # tests/phpunit/includes/specialpage
+ 'SpecialPageTestHelper' => "$testDir/phpunit/includes/specialpage/SpecialPageTestHelper.php",
+ 'AbstractChangesListSpecialPageTestCase' => "$testDir/phpunit/includes/specialpage/AbstractChangesListSpecialPageTestCase.php",
+
+ # tests/phpunit/includes/specials
+ 'SpecialPageTestBase' => "$testDir/phpunit/includes/specials/SpecialPageTestBase.php",
+ 'SpecialPageExecutor' => "$testDir/phpunit/includes/specials/SpecialPageExecutor.php",
+
+ # tests/phpunit/includes/Storage
+ 'MediaWiki\Tests\Storage\RevisionSlotsTest' => "$testDir/phpunit/includes/Storage/RevisionSlotsTest.php",
+ 'MediaWiki\Tests\Storage\RevisionRecordTests' => "$testDir/phpunit/includes/Storage/RevisionRecordTests.php",
+
+ # tests/phpunit/languages
+ 'LanguageClassesTestCase' => "$testDir/phpunit/languages/LanguageClassesTestCase.php",
+
+ # tests/phpunit/includes/libs
+ 'GenericArrayObjectTest' => "$testDir/phpunit/includes/libs/GenericArrayObjectTest.php",
+
+ # tests/phpunit/maintenance
+ 'MediaWiki\Tests\Maintenance\DumpTestCase' => "$testDir/phpunit/maintenance/DumpTestCase.php",
+ 'MediaWiki\Tests\Maintenance\MaintenanceBaseTestCase' => "$testDir/phpunit/maintenance/MaintenanceBaseTestCase.php",
+
+ # tests/phpunit/media
+ 'FakeDimensionFile' => "$testDir/phpunit/includes/media/FakeDimensionFile.php",
+ 'MediaWikiMediaTestCase' => "$testDir/phpunit/includes/media/MediaWikiMediaTestCase.php",
+
+ # tests/phpunit/mocks
+ 'MockFSFile' => "$testDir/phpunit/mocks/filebackend/MockFSFile.php",
+ 'MockFileBackend' => "$testDir/phpunit/mocks/filebackend/MockFileBackend.php",
+ 'MockLocalRepo' => "$testDir/phpunit/mocks/filerepo/MockLocalRepo.php",
+ 'MockBitmapHandler' => "$testDir/phpunit/mocks/media/MockBitmapHandler.php",
+ 'MockImageHandler' => "$testDir/phpunit/mocks/media/MockImageHandler.php",
+ 'MockSvgHandler' => "$testDir/phpunit/mocks/media/MockSvgHandler.php",
+ 'MockDjVuHandler' => "$testDir/phpunit/mocks/media/MockDjVuHandler.php",
+ 'MockChangesListFilter' => "$testDir/phpunit/mocks/MockChangesListFilter.php",
+ 'MockChangesListFilterGroup' => "$testDir/phpunit/mocks/MockChangesListFilterGroup.php",
+ 'MockWebRequest' => "$testDir/phpunit/mocks/MockWebRequest.php",
+ 'MediaWiki\\Session\\DummySessionBackend'
+ => "$testDir/phpunit/mocks/session/DummySessionBackend.php",
+ 'DummySessionProvider' => "$testDir/phpunit/mocks/session/DummySessionProvider.php",
+ 'MockMessageLocalizer' => "$testDir/phpunit/mocks/MockMessageLocalizer.php",
+
+ # tests/suites
+ 'ParserTestFileSuite' => "$testDir/phpunit/suites/ParserTestFileSuite.php",
+ 'ParserTestTopLevelSuite' => "$testDir/phpunit/suites/ParserTestTopLevelSuite.php",
+];
+// phpcs:enable
+
+/**
+ * Alias any PHPUnit 4 era PHPUnit_... class
+ * to it's PHPUnit 6 replacement. For most classes
+ * this is a direct _ -> \ replacement, but for
+ * some others we might need to maintain a manual
+ * mapping. Once we drop support for PHPUnit 4 this
+ * should be considered deprecated and eventually removed.
+ */
+spl_autoload_register( function ( $class ) {
+ if ( strpos( $class, 'PHPUnit_' ) !== 0 ) {
+ // Skip if it doesn't start with the old prefix
+ return;
+ }
+
+ // Classes that don't map 100%
+ $map = [
+ 'PHPUnit_Framework_TestSuite_DataProvider' => 'PHPUnit\Framework\DataProviderTestSuite',
+ 'PHPUnit_Framework_Error' => 'PHPUnit\Framework\Error\Error',
+ ];
+
+ if ( isset( $map[$class] ) ) {
+ $newForm = $map[$class];
+ } else {
+ $newForm = str_replace( '_', '\\', $class );
+ }
+
+ if ( class_exists( $newForm ) || interface_exists( $newForm ) ) {
+ // If the new class name exists, alias
+ // the old name to it.
+ class_alias( $newForm, $class );
+ }
+} );
diff --git a/www/wiki/tests/integration/includes/http/CurlHttpRequestTest.php b/www/wiki/tests/integration/includes/http/CurlHttpRequestTest.php
new file mode 100644
index 00000000..c1884b87
--- /dev/null
+++ b/www/wiki/tests/integration/includes/http/CurlHttpRequestTest.php
@@ -0,0 +1,9 @@
+<?php
+
+/**
+ * @group large
+ * @covers CurlHttpRequest
+ */
+class CurlHttpRequestTest extends MWHttpRequestTestCase {
+ protected static $httpEngine = 'curl';
+}
diff --git a/www/wiki/tests/integration/includes/http/MWHttpRequestTestCase.php b/www/wiki/tests/integration/includes/http/MWHttpRequestTestCase.php
new file mode 100644
index 00000000..262eb350
--- /dev/null
+++ b/www/wiki/tests/integration/includes/http/MWHttpRequestTestCase.php
@@ -0,0 +1,251 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+abstract class MWHttpRequestTestCase extends PHPUnit\Framework\TestCase {
+ protected static $httpEngine;
+ protected $oldHttpEngine;
+
+ public function setUp() {
+ parent::setUp();
+ $this->oldHttpEngine = Http::$httpEngine;
+ Http::$httpEngine = static::$httpEngine;
+
+ try {
+ $request = MWHttpRequest::factory( 'null:' );
+ } catch ( DomainException $e ) {
+ $this->markTestSkipped( static::$httpEngine . ' engine not supported' );
+ }
+
+ if ( static::$httpEngine === 'php' ) {
+ $this->assertInstanceOf( PhpHttpRequest::class, $request );
+ } else {
+ $this->assertInstanceOf( CurlHttpRequest::class, $request );
+ }
+ }
+
+ public function tearDown() {
+ parent::tearDown();
+ Http::$httpEngine = $this->oldHttpEngine;
+ }
+
+ // --------------------
+
+ public function testIsRedirect() {
+ $request = MWHttpRequest::factory( 'http://httpbin.org/get' );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertFalse( $request->isRedirect() );
+
+ $request = MWHttpRequest::factory( 'http://httpbin.org/redirect/1' );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertTrue( $request->isRedirect() );
+ }
+
+ public function testgetFinalUrl() {
+ $request = MWHttpRequest::factory( 'http://httpbin.org/redirect/3' );
+ if ( !$request->canFollowRedirects() ) {
+ $this->markTestSkipped( 'cannot follow redirects' );
+ }
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertNotSame( 'http://httpbin.org/get', $request->getFinalUrl() );
+
+ $request = MWHttpRequest::factory( 'http://httpbin.org/redirect/3', [ 'followRedirects'
+ => true ] );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertSame( 'http://httpbin.org/get', $request->getFinalUrl() );
+ $this->assertResponseFieldValue( 'url', 'http://httpbin.org/get', $request );
+
+ $request = MWHttpRequest::factory( 'http://httpbin.org/redirect/3', [ 'followRedirects'
+ => true ] );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertSame( 'http://httpbin.org/get', $request->getFinalUrl() );
+ $this->assertResponseFieldValue( 'url', 'http://httpbin.org/get', $request );
+
+ if ( static::$httpEngine === 'curl' ) {
+ $this->markTestIncomplete( 'maxRedirects seems to be ignored by CurlHttpRequest' );
+ return;
+ }
+
+ $request = MWHttpRequest::factory( 'http://httpbin.org/redirect/3', [ 'followRedirects'
+ => true, 'maxRedirects' => 1 ] );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertNotSame( 'http://httpbin.org/get', $request->getFinalUrl() );
+ }
+
+ public function testSetCookie() {
+ $request = MWHttpRequest::factory( 'http://httpbin.org/cookies' );
+ $request->setCookie( 'foo', 'bar' );
+ $request->setCookie( 'foo2', 'bar2', [ 'domain' => 'example.com' ] );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertResponseFieldValue( 'cookies', [ 'foo' => 'bar' ], $request );
+ }
+
+ public function testSetCookieJar() {
+ $request = MWHttpRequest::factory( 'http://httpbin.org/cookies' );
+ $cookieJar = new CookieJar();
+ $cookieJar->setCookie( 'foo', 'bar', [ 'domain' => 'httpbin.org' ] );
+ $cookieJar->setCookie( 'foo2', 'bar2', [ 'domain' => 'example.com' ] );
+ $request->setCookieJar( $cookieJar );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertResponseFieldValue( 'cookies', [ 'foo' => 'bar' ], $request );
+
+ $request = MWHttpRequest::factory( 'http://httpbin.org/cookies/set?foo=bar' );
+ $cookieJar = new CookieJar();
+ $request->setCookieJar( $cookieJar );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertHasCookie( 'foo', 'bar', $request->getCookieJar() );
+
+ $this->markTestIncomplete( 'CookieJar does not handle deletion' );
+ return;
+
+ $request = MWHttpRequest::factory( 'http://httpbin.org/cookies/delete?foo' );
+ $cookieJar = new CookieJar();
+ $cookieJar->setCookie( 'foo', 'bar', [ 'domain' => 'httpbin.org' ] );
+ $cookieJar->setCookie( 'foo2', 'bar2', [ 'domain' => 'httpbin.org' ] );
+ $request->setCookieJar( $cookieJar );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertNotHasCookie( 'foo', $request->getCookieJar() );
+ $this->assertHasCookie( 'foo2', 'bar2', $request->getCookieJar() );
+ }
+
+ public function testGetResponseHeaders() {
+ $request = MWHttpRequest::factory( 'http://httpbin.org/response-headers?Foo=bar' );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $headers = array_change_key_case( $request->getResponseHeaders(), CASE_LOWER );
+ $this->assertArrayHasKey( 'foo', $headers );
+ $this->assertSame( $request->getResponseHeader( 'Foo' ), 'bar' );
+ }
+
+ public function testSetHeader() {
+ $request = MWHttpRequest::factory( 'http://httpbin.org/headers' );
+ $request->setHeader( 'Foo', 'bar' );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertResponseFieldValue( [ 'headers', 'Foo' ], 'bar', $request );
+ }
+
+ public function testGetStatus() {
+ $request = MWHttpRequest::factory( 'http://httpbin.org/status/418' );
+ $status = $request->execute();
+ $this->assertFalse( $status->isOK() );
+ $this->assertSame( $request->getStatus(), 418 );
+ }
+
+ public function testSetUserAgent() {
+ $request = MWHttpRequest::factory( 'http://httpbin.org/user-agent' );
+ $request->setUserAgent( 'foo' );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertResponseFieldValue( 'user-agent', 'foo', $request );
+ }
+
+ public function testSetData() {
+ $request = MWHttpRequest::factory( 'http://httpbin.org/post', [ 'method' => 'POST' ] );
+ $request->setData( [ 'foo' => 'bar', 'foo2' => 'bar2' ] );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertResponseFieldValue( 'form', [ 'foo' => 'bar', 'foo2' => 'bar2' ], $request );
+ }
+
+ public function testSetCallback() {
+ if ( static::$httpEngine === 'php' ) {
+ $this->markTestIncomplete( 'PhpHttpRequest does not use setCallback()' );
+ return;
+ }
+
+ $request = MWHttpRequest::factory( 'http://httpbin.org/ip' );
+ $data = '';
+ $request->setCallback( function ( $fh, $content ) use ( &$data ) {
+ $data .= $content;
+ return strlen( $content );
+ } );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $data = json_decode( $data, true );
+ $this->assertInternalType( 'array', $data );
+ $this->assertArrayHasKey( 'origin', $data );
+ }
+
+ public function testBasicAuthentication() {
+ $request = MWHttpRequest::factory( 'http://httpbin.org/basic-auth/user/pass', [
+ 'username' => 'user',
+ 'password' => 'pass',
+ ] );
+ $status = $request->execute();
+ $this->assertTrue( $status->isGood() );
+ $this->assertResponseFieldValue( 'authenticated', true, $request );
+
+ $request = MWHttpRequest::factory( 'http://httpbin.org/basic-auth/user/pass', [
+ 'username' => 'user',
+ 'password' => 'wrongpass',
+ ] );
+ $status = $request->execute();
+ $this->assertFalse( $status->isOK() );
+ $this->assertSame( 401, $request->getStatus() );
+ }
+
+ public function testFactoryDefaults() {
+ $request = MWHttpRequest::factory( 'http://acme.test' );
+ $this->assertInstanceOf( MWHttpRequest::class, $request );
+ }
+
+ // --------------------
+
+ /**
+ * Verifies that the request was successful, returned valid JSON and the given field of that
+ * JSON data is as expected.
+ * @param string|string[] $key Path to the data in the response object
+ * @param mixed $expectedValue
+ * @param MWHttpRequest $response
+ */
+ protected function assertResponseFieldValue( $key, $expectedValue, MWHttpRequest $response ) {
+ $this->assertSame( 200, $response->getStatus(), 'response status is not 200' );
+ $data = json_decode( $response->getContent(), true );
+ $this->assertInternalType( 'array', $data, 'response is not JSON' );
+ $keyPath = '';
+ foreach ( (array)$key as $keySegment ) {
+ $keyPath .= ( $keyPath ? '.' : '' ) . $keySegment;
+ $this->assertArrayHasKey( $keySegment, $data, $keyPath . ' not found' );
+ $data = $data[$keySegment];
+ }
+ $this->assertSame( $expectedValue, $data );
+ }
+
+ /**
+ * Asserts that the cookie jar has the given cookie with the given value.
+ * @param string $expectedName Cookie name
+ * @param string $expectedValue Cookie value
+ * @param CookieJar $cookieJar
+ */
+ protected function assertHasCookie( $expectedName, $expectedValue, CookieJar $cookieJar ) {
+ $cookieJar = TestingAccessWrapper::newFromObject( $cookieJar );
+ $cookies = array_change_key_case( $cookieJar->cookie, CASE_LOWER );
+ $this->assertArrayHasKey( strtolower( $expectedName ), $cookies );
+ $cookie = TestingAccessWrapper::newFromObject(
+ $cookies[strtolower( $expectedName )] );
+ $this->assertSame( $expectedValue, $cookie->value );
+ }
+
+ /**
+ * Asserts that the cookie jar does not have the given cookie.
+ * @param string $name Cookie name
+ * @param CookieJar $cookieJar
+ */
+ protected function assertNotHasCookie( $name, CookieJar $cookieJar ) {
+ $cookieJar = TestingAccessWrapper::newFromObject( $cookieJar );
+ $this->assertArrayNotHasKey( strtolower( $name ),
+ array_change_key_case( $cookieJar->cookie, CASE_LOWER ) );
+ }
+
+}
diff --git a/www/wiki/tests/integration/includes/http/PhpHttpRequestTest.php b/www/wiki/tests/integration/includes/http/PhpHttpRequestTest.php
new file mode 100644
index 00000000..8c461f35
--- /dev/null
+++ b/www/wiki/tests/integration/includes/http/PhpHttpRequestTest.php
@@ -0,0 +1,9 @@
+<?php
+
+/**
+ * @group large
+ * @covers PhpHttpRequest
+ */
+class PhpHttpRequestTest extends MWHttpRequestTestCase {
+ protected static $httpEngine = 'php';
+}
diff --git a/www/wiki/tests/integration/includes/shell/FirejailCommandTest.php b/www/wiki/tests/integration/includes/shell/FirejailCommandTest.php
new file mode 100644
index 00000000..1e008ee2
--- /dev/null
+++ b/www/wiki/tests/integration/includes/shell/FirejailCommandTest.php
@@ -0,0 +1,78 @@
+<?php
+
+use MediaWiki\Shell\FirejailCommand;
+use MediaWiki\Shell\Shell;
+
+/**
+ * Integration tests to ensure that firejail actually prevents execution.
+ * Meant to run on vagrant, although will probably work on other setups
+ * as long as firejail and sudo has similar config.
+ *
+ * @group large
+ * @group Shell
+ * @covers FirejailCommand
+ */
+class FirejailCommandIntegrationTest extends PHPUnit\Framework\TestCase {
+
+ public function setUp() {
+ parent::setUp();
+ if ( Shell::command( 'which', 'firejail' )->execute()->getExitCode() ) {
+ $this->markTestSkipped( 'firejail not installed' );
+ } elseif ( wfIsWindows() ) {
+ $this->markTestSkipped( 'test supports POSIX environments only' );
+ }
+ }
+
+ public function testSanity() {
+ // Make sure that firejail works at all.
+ $command = new FirejailCommand( 'firejail' );
+ $command
+ ->unsafeParams( 'ls .' )
+ ->restrict( Shell::RESTRICT_DEFAULT );
+ $result = $command->execute();
+ $this->assertSame( 0, $result->getExitCode() );
+ }
+
+ /**
+ * @coversNothing
+ * @dataProvider provideExecute
+ */
+ public function testExecute( $testCommand, $flag ) {
+ if ( preg_match( '/^sudo /', $testCommand ) ) {
+ if ( Shell::command( 'sudo', '-n', 'ls', '/' )->execute()->getExitCode() ) {
+ $this->markTestSkipped( 'need passwordless sudo' );
+ }
+ }
+
+ $command = new FirejailCommand( 'firejail' );
+ $command
+ ->unsafeParams( $testCommand )
+ // If we don't restrict at all, firejail won't be invoked,
+ // so the test will give a false positive if firejail breaks
+ // the command for some non-flag-related reason. Instead,
+ // set some flag that won't get in the way.
+ ->restrict( $flag === Shell::NO_NETWORK ? Shell::PRIVATE_DEV : Shell::NO_NETWORK );
+ $result = $command->execute();
+ $this->assertSame( 0, $result->getExitCode(), 'sanity check' );
+
+ $command = new FirejailCommand( 'firejail' );
+ $command
+ ->unsafeParams( $testCommand )
+ ->restrict( $flag );
+ $result = $command->execute();
+ $this->assertNotSame( 0, $result->getExitCode(), 'real check' );
+ }
+
+ public function provideExecute() {
+ global $IP;
+ return [
+ [ 'sudo -n ls /', Shell::NO_ROOT ],
+ [ 'sudo -n ls /', Shell::SECCOMP ], // not a great test but seems to work
+ [ 'ls /dev/cpu', Shell::PRIVATE_DEV ],
+ [ 'curl -fsSo /dev/null https://wikipedia.org/', Shell::NO_NETWORK ],
+ [ 'exec ls /', Shell::NO_EXECVE ],
+ [ "cat $IP/LocalSettings.php", Shell::NO_LOCALSETTINGS ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/parser/DbTestPreviewer.php b/www/wiki/tests/parser/DbTestPreviewer.php
new file mode 100644
index 00000000..33aee7d3
--- /dev/null
+++ b/www/wiki/tests/parser/DbTestPreviewer.php
@@ -0,0 +1,205 @@
+<?php
+/**
+ * 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
+ */
+
+class DbTestPreviewer extends TestRecorder {
+ protected $filter; // /< Test name filter callback
+ protected $lb; // /< Database load balancer
+ protected $db; // /< Database connection to the main DB
+ protected $curRun; // /< run ID number for the current run
+ protected $prevRun; // /< run ID number for the previous run, if any
+ protected $results; // /< Result array
+
+ /**
+ * This should be called before the table prefix is changed
+ * @param IDatabase $db
+ * @param bool|string $filter
+ */
+ function __construct( $db, $filter = false ) {
+ $this->db = $db;
+ $this->filter = $filter;
+ }
+
+ /**
+ * Set up result recording; insert a record for the run with the date
+ * and all that fun stuff
+ */
+ function start() {
+ if ( !$this->db->tableExists( 'testrun', __METHOD__ )
+ || !$this->db->tableExists( 'testitem', __METHOD__ )
+ ) {
+ print "WARNING> `testrun` table not found in database.\n";
+ $this->prevRun = false;
+ } else {
+ // We'll make comparisons against the previous run later...
+ $this->prevRun = $this->db->selectField( 'testrun', 'MAX(tr_id)' );
+ }
+
+ $this->results = [];
+ }
+
+ function record( $test, ParserTestResult $result ) {
+ $this->results[$test['desc']] = $result->isSuccess() ? 1 : 0;
+ }
+
+ function report() {
+ if ( $this->prevRun ) {
+ // f = fail, p = pass, n = nonexistent
+ // codes show before then after
+ $table = [
+ 'fp' => 'previously failing test(s) now PASSING! :)',
+ 'pn' => 'previously PASSING test(s) removed o_O',
+ 'np' => 'new PASSING test(s) :)',
+
+ 'pf' => 'previously passing test(s) now FAILING! :(',
+ 'fn' => 'previously FAILING test(s) removed O_o',
+ 'nf' => 'new FAILING test(s) :(',
+ 'ff' => 'still FAILING test(s) :(',
+ ];
+
+ $prevResults = [];
+
+ $res = $this->db->select( 'testitem', [ 'ti_name', 'ti_success' ],
+ [ 'ti_run' => $this->prevRun ], __METHOD__ );
+ $filter = $this->filter;
+
+ foreach ( $res as $row ) {
+ if ( !$filter || $filter( $row->ti_name ) ) {
+ $prevResults[$row->ti_name] = $row->ti_success;
+ }
+ }
+
+ $combined = array_keys( $this->results + $prevResults );
+
+ # Determine breakdown by change type
+ $breakdown = [];
+ foreach ( $combined as $test ) {
+ if ( !isset( $prevResults[$test] ) ) {
+ $before = 'n';
+ } elseif ( $prevResults[$test] == 1 ) {
+ $before = 'p';
+ } else /* if ( $prevResults[$test] == 0 ) */ {
+ $before = 'f';
+ }
+
+ if ( !isset( $this->results[$test] ) ) {
+ $after = 'n';
+ } elseif ( $this->results[$test] == 1 ) {
+ $after = 'p';
+ } else /* if ( $this->results[$test] == 0 ) */ {
+ $after = 'f';
+ }
+
+ $code = $before . $after;
+
+ if ( isset( $table[$code] ) ) {
+ $breakdown[$code][$test] = $this->getTestStatusInfo( $test, $after );
+ }
+ }
+
+ # Write out results
+ foreach ( $table as $code => $label ) {
+ if ( !empty( $breakdown[$code] ) ) {
+ $count = count( $breakdown[$code] );
+ printf( "\n%4d %s\n", $count, $label );
+
+ foreach ( $breakdown[$code] as $differing_test_name => $statusInfo ) {
+ print " * $differing_test_name [$statusInfo]\n";
+ }
+ }
+ }
+ } else {
+ print "No previous test runs to compare against.\n";
+ }
+
+ print "\n";
+ }
+
+ /**
+ * Returns a string giving information about when a test last had a status change.
+ * Could help to track down when regressions were introduced, as distinct from tests
+ * which have never passed (which are more change requests than regressions).
+ * @param string $testname
+ * @param string $after
+ * @return string
+ */
+ private function getTestStatusInfo( $testname, $after ) {
+ // If we're looking at a test that has just been removed, then say when it first appeared.
+ if ( $after == 'n' ) {
+ $changedRun = $this->db->selectField( 'testitem',
+ 'MIN(ti_run)',
+ [ 'ti_name' => $testname ],
+ __METHOD__ );
+ $appear = $this->db->selectRow( 'testrun',
+ [ 'tr_date', 'tr_mw_version' ],
+ [ 'tr_id' => $changedRun ],
+ __METHOD__ );
+
+ return "First recorded appearance: "
+ . date( "d-M-Y H:i:s", strtotime( $appear->tr_date ) )
+ . ", " . $appear->tr_mw_version;
+ }
+
+ // Otherwise, this test has previous recorded results.
+ // See when this test last had a different result to what we're seeing now.
+ $conds = [
+ 'ti_name' => $testname,
+ 'ti_success' => ( $after == 'f' ? "1" : "0" ) ];
+
+ if ( $this->curRun ) {
+ $conds[] = "ti_run != " . $this->db->addQuotes( $this->curRun );
+ }
+
+ $changedRun = $this->db->selectField( 'testitem', 'MAX(ti_run)', $conds, __METHOD__ );
+
+ // If no record of ever having had a different result.
+ if ( is_null( $changedRun ) ) {
+ if ( $after == "f" ) {
+ return "Has never passed";
+ } else {
+ return "Has never failed";
+ }
+ }
+
+ // Otherwise, we're looking at a test whose status has changed.
+ // (i.e. it used to work, but now doesn't; or used to fail, but is now fixed.)
+ // In this situation, give as much info as we can as to when it changed status.
+ $pre = $this->db->selectRow( 'testrun',
+ [ 'tr_date', 'tr_mw_version' ],
+ [ 'tr_id' => $changedRun ],
+ __METHOD__ );
+ $post = $this->db->selectRow( 'testrun',
+ [ 'tr_date', 'tr_mw_version' ],
+ [ "tr_id > " . $this->db->addQuotes( $changedRun ) ],
+ __METHOD__,
+ [ "LIMIT" => 1, "ORDER BY" => 'tr_id' ]
+ );
+
+ if ( $post ) {
+ $postDate = date( "d-M-Y H:i:s", strtotime( $post->tr_date ) ) . ", {$post->tr_mw_version}";
+ } else {
+ $postDate = 'now';
+ }
+
+ return ( $after == "f" ? "Introduced" : "Fixed" ) . " between "
+ . date( "d-M-Y H:i:s", strtotime( $pre->tr_date ) ) . ", " . $pre->tr_mw_version
+ . " and $postDate";
+ }
+}
diff --git a/www/wiki/tests/parser/DbTestRecorder.php b/www/wiki/tests/parser/DbTestRecorder.php
new file mode 100644
index 00000000..2089f64a
--- /dev/null
+++ b/www/wiki/tests/parser/DbTestRecorder.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * 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
+ */
+
+use Wikimedia\Rdbms\IMaintainableDatabase;
+
+class DbTestRecorder extends TestRecorder {
+ public $version;
+ /** @var Database */
+ private $db;
+
+ public function __construct( IMaintainableDatabase $db ) {
+ $this->db = $db;
+ }
+
+ /**
+ * Set up result recording; insert a record for the run with the date
+ * and all that fun stuff
+ */
+ function start() {
+ $this->db->begin( __METHOD__ );
+
+ if ( !$this->db->tableExists( 'testrun' )
+ || !$this->db->tableExists( 'testitem' )
+ ) {
+ print "WARNING> `testrun` table not found in database. Trying to create table.\n";
+ $updater = DatabaseUpdater::newForDB( $this->db );
+ $this->db->sourceFile( $updater->patchPath( $this->db, 'patch-testrun.sql' ) );
+ echo "OK, resuming.\n";
+ }
+
+ $this->db->insert( 'testrun',
+ [
+ 'tr_date' => $this->db->timestamp(),
+ 'tr_mw_version' => $this->version,
+ 'tr_php_version' => PHP_VERSION,
+ 'tr_db_version' => $this->db->getServerVersion(),
+ 'tr_uname' => php_uname()
+ ],
+ __METHOD__ );
+ if ( $this->db->getType() === 'postgres' ) {
+ $this->curRun = $this->db->currentSequenceValue( 'testrun_id_seq' );
+ } else {
+ $this->curRun = $this->db->insertId();
+ }
+ }
+
+ /**
+ * Record an individual test item's success or failure to the db
+ *
+ * @param array $test
+ * @param ParserTestResult $result
+ */
+ function record( $test, ParserTestResult $result ) {
+ $this->db->insert( 'testitem',
+ [
+ 'ti_run' => $this->curRun,
+ 'ti_name' => $test['desc'],
+ 'ti_success' => $result->isSuccess() ? 1 : 0,
+ ],
+ __METHOD__ );
+ }
+
+ /**
+ * Commit transaction and clean up for result recording
+ */
+ function end() {
+ $this->db->commit( __METHOD__ );
+ }
+}
diff --git a/www/wiki/tests/parser/DjVuSupport.php b/www/wiki/tests/parser/DjVuSupport.php
new file mode 100644
index 00000000..73d4a47f
--- /dev/null
+++ b/www/wiki/tests/parser/DjVuSupport.php
@@ -0,0 +1,57 @@
+<?php
+
+/**
+ * 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
+ */
+
+/**
+ * Initialize and detect the DjVu files support
+ */
+class DjVuSupport {
+
+ /**
+ * Initialises DjVu tools global with default values
+ */
+ public function __construct() {
+ global $wgDjvuRenderer, $wgDjvuDump, $wgDjvuToXML, $wgFileExtensions, $wgDjvuTxt;
+
+ $wgDjvuRenderer = $wgDjvuRenderer ? $wgDjvuRenderer : '/usr/bin/ddjvu';
+ $wgDjvuDump = $wgDjvuDump ? $wgDjvuDump : '/usr/bin/djvudump';
+ $wgDjvuToXML = $wgDjvuToXML ? $wgDjvuToXML : '/usr/bin/djvutoxml';
+ $wgDjvuTxt = $wgDjvuTxt ? $wgDjvuTxt : '/usr/bin/djvutxt';
+
+ if ( !in_array( 'djvu', $wgFileExtensions ) ) {
+ $wgFileExtensions[] = 'djvu';
+ }
+ }
+
+ /**
+ * Returns true if the DjVu tools are usable
+ *
+ * @return bool
+ */
+ public function isEnabled() {
+ global $wgDjvuRenderer, $wgDjvuDump, $wgDjvuToXML, $wgDjvuTxt;
+
+ return is_executable( $wgDjvuRenderer )
+ && is_executable( $wgDjvuDump )
+ && is_executable( $wgDjvuToXML )
+ && is_executable( $wgDjvuTxt );
+ }
+}
diff --git a/www/wiki/tests/parser/MultiTestRecorder.php b/www/wiki/tests/parser/MultiTestRecorder.php
new file mode 100644
index 00000000..5fbfecf8
--- /dev/null
+++ b/www/wiki/tests/parser/MultiTestRecorder.php
@@ -0,0 +1,55 @@
+<?php
+
+/**
+ * This is a TestRecorder representing a collection of other TestRecorders.
+ * It proxies calls to all constituent objects.
+ */
+class MultiTestRecorder extends TestRecorder {
+ private $recorders = [];
+
+ public function addRecorder( TestRecorder $recorder ) {
+ $this->recorders[] = $recorder;
+ }
+
+ private function proxy( $funcName, $args ) {
+ foreach ( $this->recorders as $recorder ) {
+ call_user_func_array( [ $recorder, $funcName ], $args );
+ }
+ }
+
+ public function start() {
+ $this->proxy( __FUNCTION__, func_get_args() );
+ }
+
+ public function startTest( $test ) {
+ $this->proxy( __FUNCTION__, func_get_args() );
+ }
+
+ public function startSuite( $path ) {
+ $this->proxy( __FUNCTION__, func_get_args() );
+ }
+
+ public function endSuite( $path ) {
+ $this->proxy( __FUNCTION__, func_get_args() );
+ }
+
+ public function record( $test, ParserTestResult $result ) {
+ $this->proxy( __FUNCTION__, func_get_args() );
+ }
+
+ public function warning( $message ) {
+ $this->proxy( __FUNCTION__, func_get_args() );
+ }
+
+ public function skipped( $test, $subtest ) {
+ $this->proxy( __FUNCTION__, func_get_args() );
+ }
+
+ public function report() {
+ $this->proxy( __FUNCTION__, func_get_args() );
+ }
+
+ public function end() {
+ $this->proxy( __FUNCTION__, func_get_args() );
+ }
+}
diff --git a/www/wiki/tests/parser/ParserTestMockParser.php b/www/wiki/tests/parser/ParserTestMockParser.php
new file mode 100644
index 00000000..0757b34c
--- /dev/null
+++ b/www/wiki/tests/parser/ParserTestMockParser.php
@@ -0,0 +1,20 @@
+<?php
+
+/**
+ * A parser used during article insertion which does nothing, to avoid
+ * unnecessary log noise and other interference with debugging.
+ */
+class ParserTestMockParser {
+ public function preSaveTransform( $text, Title $title, User $user,
+ ParserOptions $options, $clearState = true
+ ) {
+ return $text;
+ }
+
+ public function parse(
+ $text, Title $title, ParserOptions $options,
+ $linestart = true, $clearState = true, $revid = null
+ ) {
+ return new ParserOutput;
+ }
+}
diff --git a/www/wiki/tests/parser/ParserTestParserHook.php b/www/wiki/tests/parser/ParserTestParserHook.php
new file mode 100644
index 00000000..5995012b
--- /dev/null
+++ b/www/wiki/tests/parser/ParserTestParserHook.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * A basic extension that's used by the parser tests to test whether input and
+ * arguments are passed to extensions properly.
+ *
+ * Copyright © 2005, 2006 Ævar Arnfjörð Bjarmason
+ *
+ * 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
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ */
+
+class ParserTestParserHook {
+
+ static function setup( &$parser ) {
+ $parser->setHook( 'tag', [ __CLASS__, 'dumpHook' ] );
+ $parser->setHook( 'tåg', [ __CLASS__, 'dumpHook' ] );
+ $parser->setHook( 'statictag', [ __CLASS__, 'staticTagHook' ] );
+ return true;
+ }
+
+ static function dumpHook( $in, $argv ) {
+ return "<pre>\n" .
+ var_export( $in, true ) . "\n" .
+ var_export( $argv, true ) . "\n" .
+ "</pre>";
+ }
+
+ static function staticTagHook( $in, $argv, $parser ) {
+ if ( !count( $argv ) ) {
+ $parser->static_tag_buf = $in;
+ return '';
+ } elseif ( count( $argv ) === 1 && isset( $argv['action'] )
+ && $argv['action'] === 'flush' && $in === null
+ ) {
+ // Clear the buffer, we probably don't need to
+ if ( isset( $parser->static_tag_buf ) ) {
+ $tmp = $parser->static_tag_buf;
+ } else {
+ $tmp = '';
+ }
+ $parser->static_tag_buf = null;
+ return $tmp;
+ } else { // wtf?
+ return "\nCall this extension as <statictag>string</statictag> or as" .
+ " <statictag action=flush/>, not in any other way.\n" .
+ "text: " . var_export( $in, true ) . "\n" .
+ "argv: " . var_export( $argv, true ) . "\n";
+ }
+ }
+}
diff --git a/www/wiki/tests/parser/ParserTestPrinter.php b/www/wiki/tests/parser/ParserTestPrinter.php
new file mode 100644
index 00000000..94d226c1
--- /dev/null
+++ b/www/wiki/tests/parser/ParserTestPrinter.php
@@ -0,0 +1,328 @@
+<?php
+/**
+ * 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
+ */
+
+/**
+ * This is a TestRecorder responsible for printing information about progress,
+ * success and failure to the console. It is specific to the parserTests.php
+ * frontend.
+ */
+class ParserTestPrinter extends TestRecorder {
+ private $total;
+ private $success;
+ private $skipped;
+ private $term;
+ private $showDiffs;
+ private $showProgress;
+ private $showFailure;
+ private $showOutput;
+ private $useDwdiff;
+ private $markWhitespace;
+ private $xmlError;
+
+ function __construct( $term, $options ) {
+ $this->term = $term;
+ $options += [
+ 'showDiffs' => true,
+ 'showProgress' => true,
+ 'showFailure' => true,
+ 'showOutput' => false,
+ 'useDwdiff' => false,
+ 'markWhitespace' => false,
+ ];
+ $this->showDiffs = $options['showDiffs'];
+ $this->showProgress = $options['showProgress'];
+ $this->showFailure = $options['showFailure'];
+ $this->showOutput = $options['showOutput'];
+ $this->useDwdiff = $options['useDwdiff'];
+ $this->markWhitespace = $options['markWhitespace'];
+ }
+
+ public function start() {
+ $this->total = 0;
+ $this->success = 0;
+ $this->skipped = 0;
+ }
+
+ public function startTest( $test ) {
+ if ( $this->showProgress ) {
+ $this->showTesting( $test['desc'] );
+ }
+ }
+
+ private function showTesting( $desc ) {
+ print "Running test $desc... ";
+ }
+
+ /**
+ * Show "Reading tests from ..."
+ *
+ * @param string $path
+ */
+ public function startSuite( $path ) {
+ print $this->term->color( 1 ) .
+ "Running parser tests from \"$path\"..." .
+ $this->term->reset() .
+ "\n";
+ }
+
+ public function endSuite( $path ) {
+ print "\n";
+ }
+
+ public function record( $test, ParserTestResult $result ) {
+ $this->total++;
+ $this->success += ( $result->isSuccess() ? 1 : 0 );
+
+ if ( $result->isSuccess() ) {
+ $this->showSuccess( $result );
+ } else {
+ $this->showFailure( $result );
+ }
+ }
+
+ /**
+ * Print a happy success message.
+ *
+ * @param ParserTestResult $testResult
+ * @return bool
+ */
+ private function showSuccess( ParserTestResult $testResult ) {
+ if ( $this->showProgress ) {
+ print $this->term->color( '1;32' ) . 'PASSED' . $this->term->reset() . "\n";
+ }
+ }
+
+ /**
+ * Print a failure message and provide some explanatory output
+ * about what went wrong if so configured.
+ *
+ * @param ParserTestResult $testResult
+ * @return bool
+ */
+ private function showFailure( ParserTestResult $testResult ) {
+ if ( $this->showFailure ) {
+ if ( !$this->showProgress ) {
+ # In quiet mode we didn't show the 'Testing' message before the
+ # test, in case it succeeded. Show it now:
+ $this->showTesting( $testResult->getDescription() );
+ }
+
+ print $this->term->color( '31' ) . 'FAILED!' . $this->term->reset() . "\n";
+
+ if ( $this->showOutput ) {
+ print "--- Expected ---\n{$testResult->expected}\n";
+ print "--- Actual ---\n{$testResult->actual}\n";
+ }
+
+ if ( $this->showDiffs ) {
+ print $this->quickDiff( $testResult->expected, $testResult->actual );
+ if ( !$this->wellFormed( $testResult->actual ) ) {
+ print "XML error: $this->xmlError\n";
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Run given strings through a diff and return the (colorized) output.
+ * Requires writable /tmp directory and a 'diff' command in the PATH.
+ *
+ * @param string $input
+ * @param string $output
+ * @param string $inFileTail Tailing for the input file name
+ * @param string $outFileTail Tailing for the output file name
+ * @return string
+ */
+ private function quickDiff( $input, $output,
+ $inFileTail = 'expected', $outFileTail = 'actual'
+ ) {
+ if ( $this->markWhitespace ) {
+ $pairs = [
+ "\n" => '¶',
+ ' ' => '·',
+ "\t" => '→'
+ ];
+ $input = strtr( $input, $pairs );
+ $output = strtr( $output, $pairs );
+ }
+
+ # Windows, or at least the fc utility, is retarded
+ $slash = wfIsWindows() ? '\\' : '/';
+ $prefix = wfTempDir() . "{$slash}mwParser-" . mt_rand();
+
+ $infile = "$prefix-$inFileTail";
+ $this->dumpToFile( $input, $infile );
+
+ $outfile = "$prefix-$outFileTail";
+ $this->dumpToFile( $output, $outfile );
+
+ $shellInfile = wfEscapeShellArg( $infile );
+ $shellOutfile = wfEscapeShellArg( $outfile );
+
+ global $wgDiff3;
+ // we assume that people with diff3 also have usual diff
+ if ( $this->useDwdiff ) {
+ $shellCommand = 'dwdiff -Pc';
+ } else {
+ $shellCommand = ( wfIsWindows() && !$wgDiff3 ) ? 'fc' : 'diff -au';
+ }
+
+ $diff = wfShellExec( "$shellCommand $shellInfile $shellOutfile" );
+
+ unlink( $infile );
+ unlink( $outfile );
+
+ if ( $this->useDwdiff ) {
+ return $diff;
+ } else {
+ return $this->colorDiff( $diff );
+ }
+ }
+
+ /**
+ * Write the given string to a file, adding a final newline.
+ *
+ * @param string $data
+ * @param string $filename
+ */
+ private function dumpToFile( $data, $filename ) {
+ $file = fopen( $filename, "wt" );
+ fwrite( $file, $data . "\n" );
+ fclose( $file );
+ }
+
+ /**
+ * Colorize unified diff output if set for ANSI color output.
+ * Subtractions are colored blue, additions red.
+ *
+ * @param string $text
+ * @return string
+ */
+ private function colorDiff( $text ) {
+ return preg_replace(
+ [ '/^(-.*)$/m', '/^(\+.*)$/m' ],
+ [ $this->term->color( 34 ) . '$1' . $this->term->reset(),
+ $this->term->color( 31 ) . '$1' . $this->term->reset() ],
+ $text );
+ }
+
+ private function wellFormed( $text ) {
+ $html =
+ Sanitizer::hackDocType() .
+ '<html>' .
+ $text .
+ '</html>';
+
+ $parser = xml_parser_create( "UTF-8" );
+
+ # case folding violates XML standard, turn it off
+ xml_parser_set_option( $parser, XML_OPTION_CASE_FOLDING, false );
+
+ if ( !xml_parse( $parser, $html, true ) ) {
+ $err = xml_error_string( xml_get_error_code( $parser ) );
+ $position = xml_get_current_byte_index( $parser );
+ $fragment = $this->extractFragment( $html, $position );
+ $this->xmlError = "$err at byte $position:\n$fragment";
+ xml_parser_free( $parser );
+
+ return false;
+ }
+
+ xml_parser_free( $parser );
+
+ return true;
+ }
+
+ private function extractFragment( $text, $position ) {
+ $start = max( 0, $position - 10 );
+ $before = $position - $start;
+ $fragment = '...' .
+ $this->term->color( 34 ) .
+ substr( $text, $start, $before ) .
+ $this->term->color( 0 ) .
+ $this->term->color( 31 ) .
+ $this->term->color( 1 ) .
+ substr( $text, $position, 1 ) .
+ $this->term->color( 0 ) .
+ $this->term->color( 34 ) .
+ substr( $text, $position + 1, 9 ) .
+ $this->term->color( 0 ) .
+ '...';
+ $display = str_replace( "\n", ' ', $fragment );
+ $caret = ' ' .
+ str_repeat( ' ', $before ) .
+ $this->term->color( 31 ) .
+ '^' .
+ $this->term->color( 0 );
+
+ return "$display\n$caret";
+ }
+
+ /**
+ * Show a warning to the user
+ * @param string $message
+ */
+ public function warning( $message ) {
+ echo "$message\n";
+ }
+
+ /**
+ * Mark a test skipped
+ * @param string $test
+ * @param string $subtest
+ */
+ public function skipped( $test, $subtest ) {
+ if ( $this->showProgress ) {
+ print $this->term->color( '1;33' ) . 'SKIPPED' . $this->term->reset() . "\n";
+ }
+ $this->skipped++;
+ }
+
+ public function report() {
+ if ( $this->total > 0 ) {
+ $this->reportPercentage( $this->success, $this->total );
+ } else {
+ print $this->term->color( 31 ) . "No tests found." . $this->term->reset() . "\n";
+ }
+ }
+
+ private function reportPercentage( $success, $total ) {
+ $ratio = wfPercent( 100 * $success / $total );
+ print $this->term->color( 1 ) . "Passed $success of $total tests ($ratio)";
+ if ( $this->skipped ) {
+ print ", skipped {$this->skipped}";
+ }
+ print "... ";
+
+ if ( $success == $total ) {
+ print $this->term->color( 32 ) . "ALL TESTS PASSED!";
+ } else {
+ $failed = $total - $success;
+ print $this->term->color( 31 ) . "$failed tests failed!";
+ }
+
+ print $this->term->reset() . "\n";
+
+ return ( $success == $total );
+ }
+}
diff --git a/www/wiki/tests/parser/ParserTestResult.php b/www/wiki/tests/parser/ParserTestResult.php
new file mode 100644
index 00000000..6396a018
--- /dev/null
+++ b/www/wiki/tests/parser/ParserTestResult.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * @file
+ *
+ * @copyright Copyright © 2013, Antoine Musso
+ * @copyright Copyright © 2013, Wikimedia Foundation Inc.
+ */
+
+/**
+ * Represent the result of a parser test.
+ *
+ * @since 1.22
+ */
+class ParserTestResult {
+ /** The test info array */
+ public $test;
+ /** Text that was expected */
+ public $expected;
+ /** Actual text rendered */
+ public $actual;
+
+ /**
+ * @param array $test The test info array from TestIterator
+ * @param string $expected The normalized expected output
+ * @param string $actual The actual output
+ */
+ public function __construct( $test, $expected, $actual ) {
+ $this->test = $test;
+ $this->expected = $expected;
+ $this->actual = $actual;
+ }
+
+ /**
+ * Whether the test passed
+ * @return bool
+ */
+ public function isSuccess() {
+ return $this->expected === $this->actual;
+ }
+
+ public function getDescription() {
+ return $this->test['desc'];
+ }
+}
diff --git a/www/wiki/tests/parser/ParserTestResultNormalizer.php b/www/wiki/tests/parser/ParserTestResultNormalizer.php
new file mode 100644
index 00000000..fbeed97b
--- /dev/null
+++ b/www/wiki/tests/parser/ParserTestResultNormalizer.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * @file
+ * @ingroup Testing
+ */
+
+class ParserTestResultNormalizer {
+ protected $doc, $xpath, $invalid;
+
+ public static function normalize( $text, $funcs ) {
+ $norm = new self( $text );
+ if ( $norm->invalid ) {
+ return $text;
+ }
+ foreach ( $funcs as $func ) {
+ $norm->$func();
+ }
+ return $norm->serialize();
+ }
+
+ protected function __construct( $text ) {
+ $this->doc = new DOMDocument( '1.0', 'utf-8' );
+
+ // Note: parsing a supposedly XHTML document with an XML parser is not
+ // guaranteed to give accurate results. For example, it may introduce
+ // differences in the number of line breaks in <pre> tags.
+
+ Wikimedia\suppressWarnings();
+ if ( !$this->doc->loadXML( '<html><body>' . $text . '</body></html>' ) ) {
+ $this->invalid = true;
+ }
+ Wikimedia\restoreWarnings();
+ $this->xpath = new DOMXPath( $this->doc );
+ $this->body = $this->xpath->query( '//body' )->item( 0 );
+ }
+
+ protected function removeTbody() {
+ foreach ( $this->xpath->query( '//tbody' ) as $tbody ) {
+ while ( $tbody->firstChild ) {
+ $child = $tbody->firstChild;
+ $tbody->removeChild( $child );
+ $tbody->parentNode->insertBefore( $child, $tbody );
+ }
+ $tbody->parentNode->removeChild( $tbody );
+ }
+ }
+
+ /**
+ * The point of this function is to produce a normalized DOM in which
+ * Tidy's output matches the output of html5depurate. Tidy both trims
+ * and pretty-prints, so this requires fairly aggressive treatment.
+ *
+ * In particular, note that Tidy converts <pre>x</pre> to <pre>\nx\n</pre>,
+ * which theoretically affects display since the second line break is not
+ * ignored by compliant HTML parsers.
+ *
+ * This function also removes empty elements, as does Tidy.
+ */
+ protected function trimWhitespace() {
+ foreach ( $this->xpath->query( '//text()' ) as $child ) {
+ if ( strtolower( $child->parentNode->nodeName ) === 'pre' ) {
+ // Just trim one line break from the start and end
+ if ( substr_compare( $child->data, "\n", 0 ) === 0 ) {
+ $child->data = substr( $child->data, 1 );
+ }
+ if ( substr_compare( $child->data, "\n", -1 ) === 0 ) {
+ $child->data = substr( $child->data, 0, -1 );
+ }
+ } else {
+ // Trim all whitespace
+ $child->data = trim( $child->data );
+ }
+ if ( $child->data === '' ) {
+ $child->parentNode->removeChild( $child );
+ }
+ }
+ }
+
+ /**
+ * Serialize the XML DOM for comparison purposes. This does not generate HTML.
+ * @return string
+ */
+ protected function serialize() {
+ return strtr( $this->doc->saveXML( $this->body ),
+ [ '<body>' => '', '</body>' => '' ] );
+ }
+}
diff --git a/www/wiki/tests/parser/ParserTestRunner.php b/www/wiki/tests/parser/ParserTestRunner.php
new file mode 100644
index 00000000..844a43f3
--- /dev/null
+++ b/www/wiki/tests/parser/ParserTestRunner.php
@@ -0,0 +1,1737 @@
+<?php
+/**
+ * Generic backend for the MediaWiki parser test suite, used by both the
+ * standalone parserTests.php and the PHPUnit "parsertests" suite.
+ *
+ * Copyright © 2004, 2010 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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
+ *
+ * @todo Make this more independent of the configuration (and if possible the database)
+ * @file
+ * @ingroup Testing
+ */
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\ScopedCallback;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @ingroup Testing
+ */
+class ParserTestRunner {
+
+ /**
+ * MediaWiki core parser test files, paths
+ * will be prefixed with __DIR__ . '/'
+ *
+ * @var array
+ */
+ private static $coreTestFiles = [
+ 'parserTests.txt',
+ 'extraParserTests.txt',
+ ];
+
+ /**
+ * @var bool $useTemporaryTables Use temporary tables for the temporary database
+ */
+ private $useTemporaryTables = true;
+
+ /**
+ * @var array $setupDone The status of each setup function
+ */
+ private $setupDone = [
+ 'staticSetup' => false,
+ 'perTestSetup' => false,
+ 'setupDatabase' => false,
+ 'setDatabase' => false,
+ 'setupUploads' => false,
+ ];
+
+ /**
+ * Our connection to the database
+ * @var Database
+ */
+ private $db;
+
+ /**
+ * Database clone helper
+ * @var CloneDatabase
+ */
+ private $dbClone;
+
+ /**
+ * @var TidySupport
+ */
+ private $tidySupport;
+
+ /**
+ * @var TidyDriverBase
+ */
+ private $tidyDriver = null;
+
+ /**
+ * @var TestRecorder
+ */
+ private $recorder;
+
+ /**
+ * The upload directory, or null to not set up an upload directory
+ *
+ * @var string|null
+ */
+ private $uploadDir = null;
+
+ /**
+ * The name of the file backend to use, or null to use MockFileBackend.
+ * @var string|null
+ */
+ private $fileBackendName;
+
+ /**
+ * A complete regex for filtering tests.
+ * @var string
+ */
+ private $regex;
+
+ /**
+ * A list of normalization functions to apply to the expected and actual
+ * output.
+ * @var array
+ */
+ private $normalizationFunctions = [];
+
+ /**
+ * @param TestRecorder $recorder
+ * @param array $options
+ */
+ public function __construct( TestRecorder $recorder, $options = [] ) {
+ $this->recorder = $recorder;
+
+ if ( isset( $options['norm'] ) ) {
+ foreach ( $options['norm'] as $func ) {
+ if ( in_array( $func, [ 'removeTbody', 'trimWhitespace' ] ) ) {
+ $this->normalizationFunctions[] = $func;
+ } else {
+ $this->recorder->warning(
+ "Warning: unknown normalization option \"$func\"\n" );
+ }
+ }
+ }
+
+ if ( isset( $options['regex'] ) && $options['regex'] !== false ) {
+ $this->regex = $options['regex'];
+ } else {
+ # Matches anything
+ $this->regex = '//';
+ }
+
+ $this->keepUploads = !empty( $options['keep-uploads'] );
+
+ $this->fileBackendName = isset( $options['file-backend'] ) ?
+ $options['file-backend'] : false;
+
+ $this->runDisabled = !empty( $options['run-disabled'] );
+ $this->runParsoid = !empty( $options['run-parsoid'] );
+
+ $this->tidySupport = new TidySupport( !empty( $options['use-tidy-config'] ) );
+ if ( !$this->tidySupport->isEnabled() ) {
+ $this->recorder->warning(
+ "Warning: tidy is not installed, skipping some tests\n" );
+ }
+
+ if ( isset( $options['upload-dir'] ) ) {
+ $this->uploadDir = $options['upload-dir'];
+ }
+ }
+
+ /**
+ * Get list of filenames to extension and core parser tests
+ *
+ * @return array
+ */
+ public static function getParserTestFiles() {
+ global $wgParserTestFiles;
+
+ // Add core test files
+ $files = array_map( function ( $item ) {
+ return __DIR__ . "/$item";
+ }, self::$coreTestFiles );
+
+ // Plus legacy global files
+ $files = array_merge( $files, $wgParserTestFiles );
+
+ // Auto-discover extension parser tests
+ $registry = ExtensionRegistry::getInstance();
+ foreach ( $registry->getAllThings() as $info ) {
+ $dir = dirname( $info['path'] ) . '/tests/parser';
+ if ( !file_exists( $dir ) ) {
+ continue;
+ }
+ $counter = 1;
+ $dirIterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $dir )
+ );
+ foreach ( $dirIterator as $fileInfo ) {
+ /** @var SplFileInfo $fileInfo */
+ if ( substr( $fileInfo->getFilename(), -4 ) === '.txt' ) {
+ $name = $info['name'] . $counter;
+ while ( isset( $files[$name] ) ) {
+ $name = $info['name'] . '_' . $counter++;
+ }
+ $files[$name] = $fileInfo->getPathname();
+ }
+ }
+ }
+
+ return array_unique( $files );
+ }
+
+ public function getRecorder() {
+ return $this->recorder;
+ }
+
+ /**
+ * Do any setup which can be done once for all tests, independent of test
+ * options, except for database setup.
+ *
+ * Public setup functions in this class return a ScopedCallback object. When
+ * this object is destroyed by going out of scope, teardown of the
+ * corresponding test setup is performed.
+ *
+ * Teardown objects may be chained by passing a ScopedCallback from a
+ * previous setup stage as the $nextTeardown parameter. This enforces the
+ * convention that teardown actions are taken in reverse order to the
+ * corresponding setup actions. When $nextTeardown is specified, a
+ * ScopedCallback will be returned which first tears down the current
+ * setup stage, and then tears down the previous setup stage which was
+ * specified by $nextTeardown.
+ *
+ * @param ScopedCallback|null $nextTeardown
+ * @return ScopedCallback
+ */
+ public function staticSetup( $nextTeardown = null ) {
+ // A note on coding style:
+
+ // The general idea here is to keep setup code together with
+ // corresponding teardown code, in a fine-grained manner. We have two
+ // arrays: $setup and $teardown. The code snippets in the $setup array
+ // are executed at the end of the method, before it returns, and the
+ // code snippets in the $teardown array are executed in reverse order
+ // when the Wikimedia\ScopedCallback object is consumed.
+
+ // Because it is a common operation to save, set and restore global
+ // variables, we have an additional convention: when the array key of
+ // $setup is a string, the string is taken to be the name of the global
+ // variable, and the element value is taken to be the desired new value.
+
+ // It's acceptable to just do the setup immediately, instead of adding
+ // a closure to $setup, except when the setup action depends on global
+ // variable initialisation being done first. In this case, you have to
+ // append a closure to $setup after the global variable is appended.
+
+ // When you add to setup functions in this class, please keep associated
+ // setup and teardown actions together in the source code, and please
+ // add comments explaining why the setup action is necessary.
+
+ $setup = [];
+ $teardown = [];
+
+ $teardown[] = $this->markSetupDone( 'staticSetup' );
+
+ // Some settings which influence HTML output
+ $setup['wgSitename'] = 'MediaWiki';
+ $setup['wgServer'] = 'http://example.org';
+ $setup['wgServerName'] = 'example.org';
+ $setup['wgScriptPath'] = '';
+ $setup['wgScript'] = '/index.php';
+ $setup['wgResourceBasePath'] = '';
+ $setup['wgStylePath'] = '/skins';
+ $setup['wgExtensionAssetsPath'] = '/extensions';
+ $setup['wgArticlePath'] = '/wiki/$1';
+ $setup['wgActionPaths'] = [];
+ $setup['wgVariantArticlePath'] = false;
+ $setup['wgUploadNavigationUrl'] = false;
+ $setup['wgCapitalLinks'] = true;
+ $setup['wgNoFollowLinks'] = true;
+ $setup['wgNoFollowDomainExceptions'] = [ 'no-nofollow.org' ];
+ $setup['wgExternalLinkTarget'] = false;
+ $setup['wgExperimentalHtmlIds'] = false;
+ $setup['wgLocaltimezone'] = 'UTC';
+ $setup['wgHtml5'] = true;
+ $setup['wgDisableLangConversion'] = false;
+ $setup['wgDisableTitleConversion'] = false;
+
+ // "extra language links"
+ // see https://gerrit.wikimedia.org/r/111390
+ $setup['wgExtraInterlanguageLinkPrefixes'] = [ 'mul' ];
+
+ // All FileRepo changes should be done here by injecting services,
+ // there should be no need to change global variables.
+ RepoGroup::setSingleton( $this->createRepoGroup() );
+ $teardown[] = function () {
+ RepoGroup::destroySingleton();
+ };
+
+ // Set up null lock managers
+ $setup['wgLockManagers'] = [ [
+ 'name' => 'fsLockManager',
+ 'class' => NullLockManager::class,
+ ], [
+ 'name' => 'nullLockManager',
+ 'class' => NullLockManager::class,
+ ] ];
+ $reset = function () {
+ LockManagerGroup::destroySingletons();
+ };
+ $setup[] = $reset;
+ $teardown[] = $reset;
+
+ // This allows article insertion into the prefixed DB
+ $setup['wgDefaultExternalStore'] = false;
+
+ // This might slightly reduce memory usage
+ $setup['wgAdaptiveMessageCache'] = true;
+
+ // This is essential and overrides disabling of database messages in TestSetup
+ $setup['wgUseDatabaseMessages'] = true;
+ $reset = function () {
+ MessageCache::destroyInstance();
+ };
+ $setup[] = $reset;
+ $teardown[] = $reset;
+
+ // It's not necessary to actually convert any files
+ $setup['wgSVGConverter'] = 'null';
+ $setup['wgSVGConverters'] = [ 'null' => 'echo "1">$output' ];
+
+ // Fake constant timestamp
+ Hooks::register( 'ParserGetVariableValueTs', function ( &$parser, &$ts ) {
+ $ts = $this->getFakeTimestamp();
+ return true;
+ } );
+ $teardown[] = function () {
+ Hooks::clear( 'ParserGetVariableValueTs' );
+ };
+
+ $this->appendNamespaceSetup( $setup, $teardown );
+
+ // Set up interwikis and append teardown function
+ $teardown[] = $this->setupInterwikis();
+
+ // This affects title normalization in links. It invalidates
+ // MediaWikiTitleCodec objects.
+ $setup['wgLocalInterwikis'] = [ 'local', 'mi' ];
+ $reset = function () {
+ $this->resetTitleServices();
+ };
+ $setup[] = $reset;
+ $teardown[] = $reset;
+
+ // Set up a mock MediaHandlerFactory
+ MediaWikiServices::getInstance()->disableService( 'MediaHandlerFactory' );
+ MediaWikiServices::getInstance()->redefineService(
+ 'MediaHandlerFactory',
+ function ( MediaWikiServices $services ) {
+ $handlers = $services->getMainConfig()->get( 'ParserTestMediaHandlers' );
+ return new MediaHandlerFactory( $handlers );
+ }
+ );
+ $teardown[] = function () {
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'MediaHandlerFactory' );
+ };
+
+ // SqlBagOStuff broke when using temporary tables on r40209 (T17892).
+ // It seems to have been fixed since (r55079?), but regressed at some point before r85701.
+ // This works around it for now...
+ global $wgObjectCaches;
+ $setup['wgObjectCaches'] = [ CACHE_DB => $wgObjectCaches['hash'] ] + $wgObjectCaches;
+ if ( isset( ObjectCache::$instances[CACHE_DB] ) ) {
+ $savedCache = ObjectCache::$instances[CACHE_DB];
+ ObjectCache::$instances[CACHE_DB] = new HashBagOStuff;
+ $teardown[] = function () use ( $savedCache ) {
+ ObjectCache::$instances[CACHE_DB] = $savedCache;
+ };
+ }
+
+ $teardown[] = $this->executeSetupSnippets( $setup );
+
+ // Schedule teardown snippets in reverse order
+ return $this->createTeardownObject( $teardown, $nextTeardown );
+ }
+
+ private function appendNamespaceSetup( &$setup, &$teardown ) {
+ // Add a namespace shadowing a interwiki link, to test
+ // proper precedence when resolving links. (T53680)
+ $setup['wgExtraNamespaces'] = [
+ 100 => 'MemoryAlpha',
+ 101 => 'MemoryAlpha_talk'
+ ];
+ // Changing wgExtraNamespaces invalidates caches in MWNamespace and
+ // any live Language object, both on setup and teardown
+ $reset = function () {
+ MWNamespace::clearCaches();
+ $GLOBALS['wgContLang']->resetNamespaces();
+ };
+ $setup[] = $reset;
+ $teardown[] = $reset;
+ }
+
+ /**
+ * Create a RepoGroup object appropriate for the current configuration
+ * @return RepoGroup
+ */
+ protected function createRepoGroup() {
+ if ( $this->uploadDir ) {
+ if ( $this->fileBackendName ) {
+ throw new MWException( 'You cannot specify both use-filebackend and upload-dir' );
+ }
+ $backend = new FSFileBackend( [
+ 'name' => 'local-backend',
+ 'wikiId' => wfWikiID(),
+ 'basePath' => $this->uploadDir,
+ 'tmpDirectory' => wfTempDir()
+ ] );
+ } elseif ( $this->fileBackendName ) {
+ global $wgFileBackends;
+ $name = $this->fileBackendName;
+ $useConfig = false;
+ foreach ( $wgFileBackends as $conf ) {
+ if ( $conf['name'] === $name ) {
+ $useConfig = $conf;
+ }
+ }
+ if ( $useConfig === false ) {
+ throw new MWException( "Unable to find file backend \"$name\"" );
+ }
+ $useConfig['name'] = 'local-backend'; // swap name
+ unset( $useConfig['lockManager'] );
+ unset( $useConfig['fileJournal'] );
+ $class = $useConfig['class'];
+ $backend = new $class( $useConfig );
+ } else {
+ # Replace with a mock. We do not care about generating real
+ # files on the filesystem, just need to expose the file
+ # informations.
+ $backend = new MockFileBackend( [
+ 'name' => 'local-backend',
+ 'wikiId' => wfWikiID()
+ ] );
+ }
+
+ return new RepoGroup(
+ [
+ 'class' => MockLocalRepo::class,
+ 'name' => 'local',
+ 'url' => 'http://example.com/images',
+ 'hashLevels' => 2,
+ 'transformVia404' => false,
+ 'backend' => $backend
+ ],
+ []
+ );
+ }
+
+ /**
+ * Execute an array in which elements with integer keys are taken to be
+ * callable objects, and other elements are taken to be global variable
+ * set operations, with the key giving the variable name and the value
+ * giving the new global variable value. A closure is returned which, when
+ * executed, sets the global variables back to the values they had before
+ * this function was called.
+ *
+ * @see staticSetup
+ *
+ * @param array $setup
+ * @return closure
+ */
+ protected function executeSetupSnippets( $setup ) {
+ $saved = [];
+ foreach ( $setup as $name => $value ) {
+ if ( is_int( $name ) ) {
+ $value();
+ } else {
+ $saved[$name] = isset( $GLOBALS[$name] ) ? $GLOBALS[$name] : null;
+ $GLOBALS[$name] = $value;
+ }
+ }
+ return function () use ( $saved ) {
+ $this->executeSetupSnippets( $saved );
+ };
+ }
+
+ /**
+ * Take a setup array in the same format as the one given to
+ * executeSetupSnippets(), and return a ScopedCallback which, when consumed,
+ * executes the snippets in the setup array in reverse order. This is used
+ * to create "teardown objects" for the public API.
+ *
+ * @see staticSetup
+ *
+ * @param array $teardown The snippet array
+ * @param ScopedCallback|null $nextTeardown A ScopedCallback to consume
+ * @return ScopedCallback
+ */
+ protected function createTeardownObject( $teardown, $nextTeardown = null ) {
+ return new ScopedCallback( function () use ( $teardown, $nextTeardown ) {
+ // Schedule teardown snippets in reverse order
+ $teardown = array_reverse( $teardown );
+
+ $this->executeSetupSnippets( $teardown );
+ if ( $nextTeardown ) {
+ ScopedCallback::consume( $nextTeardown );
+ }
+ } );
+ }
+
+ /**
+ * Set a setupDone flag to indicate that setup has been done, and return
+ * the teardown closure. If the flag was already set, throw an exception.
+ *
+ * @param string $funcName The setup function name
+ * @return closure
+ */
+ protected function markSetupDone( $funcName ) {
+ if ( $this->setupDone[$funcName] ) {
+ throw new MWException( "$funcName is already done" );
+ }
+ $this->setupDone[$funcName] = true;
+ return function () use ( $funcName ) {
+ $this->setupDone[$funcName] = false;
+ };
+ }
+
+ /**
+ * Ensure a given setup stage has been done, throw an exception if it has
+ * not.
+ * @param string $funcName
+ * @param string|null $funcName2
+ */
+ protected function checkSetupDone( $funcName, $funcName2 = null ) {
+ if ( !$this->setupDone[$funcName]
+ && ( $funcName === null || !$this->setupDone[$funcName2] )
+ ) {
+ throw new MWException( "$funcName must be called before calling " .
+ wfGetCaller() );
+ }
+ }
+
+ /**
+ * Determine whether a particular setup function has been run
+ *
+ * @param string $funcName
+ * @return bool
+ */
+ public function isSetupDone( $funcName ) {
+ return isset( $this->setupDone[$funcName] ) ? $this->setupDone[$funcName] : false;
+ }
+
+ /**
+ * Insert hardcoded interwiki in the lookup table.
+ *
+ * This function insert a set of well known interwikis that are used in
+ * the parser tests. They can be considered has fixtures are injected in
+ * the interwiki cache by using the 'InterwikiLoadPrefix' hook.
+ * Since we are not interested in looking up interwikis in the database,
+ * the hook completely replace the existing mechanism (hook returns false).
+ *
+ * @return closure for teardown
+ */
+ private function setupInterwikis() {
+ # Hack: insert a few Wikipedia in-project interwiki prefixes,
+ # for testing inter-language links
+ Hooks::register( 'InterwikiLoadPrefix', function ( $prefix, &$iwData ) {
+ static $testInterwikis = [
+ 'local' => [
+ 'iw_url' => 'http://doesnt.matter.org/$1',
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => 0 ],
+ 'wikipedia' => [
+ 'iw_url' => 'http://en.wikipedia.org/wiki/$1',
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => 0 ],
+ 'meatball' => [
+ 'iw_url' => 'http://www.usemod.com/cgi-bin/mb.pl?$1',
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => 0 ],
+ 'memoryalpha' => [
+ 'iw_url' => 'http://www.memory-alpha.org/en/index.php/$1',
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => 0 ],
+ 'zh' => [
+ 'iw_url' => 'http://zh.wikipedia.org/wiki/$1',
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => 1 ],
+ 'es' => [
+ 'iw_url' => 'http://es.wikipedia.org/wiki/$1',
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => 1 ],
+ 'fr' => [
+ 'iw_url' => 'http://fr.wikipedia.org/wiki/$1',
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => 1 ],
+ 'ru' => [
+ 'iw_url' => 'http://ru.wikipedia.org/wiki/$1',
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => 1 ],
+ 'mi' => [
+ 'iw_url' => 'http://mi.wikipedia.org/wiki/$1',
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => 1 ],
+ 'mul' => [
+ 'iw_url' => 'http://wikisource.org/wiki/$1',
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => 1 ],
+ ];
+ if ( array_key_exists( $prefix, $testInterwikis ) ) {
+ $iwData = $testInterwikis[$prefix];
+ }
+
+ // We only want to rely on the above fixtures
+ return false;
+ } );// hooks::register
+
+ // Reset the service in case any other tests already cached some prefixes.
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'InterwikiLookup' );
+
+ return function () {
+ // Tear down
+ Hooks::clear( 'InterwikiLoadPrefix' );
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'InterwikiLookup' );
+ };
+ }
+
+ /**
+ * Reset the Title-related services that need resetting
+ * for each test
+ */
+ private function resetTitleServices() {
+ $services = MediaWikiServices::getInstance();
+ $services->resetServiceForTesting( 'TitleFormatter' );
+ $services->resetServiceForTesting( 'TitleParser' );
+ $services->resetServiceForTesting( '_MediaWikiTitleCodec' );
+ $services->resetServiceForTesting( 'LinkRenderer' );
+ $services->resetServiceForTesting( 'LinkRendererFactory' );
+ }
+
+ /**
+ * Remove last character if it is a newline
+ * @param string $s
+ * @return string
+ */
+ public static function chomp( $s ) {
+ if ( substr( $s, -1 ) === "\n" ) {
+ return substr( $s, 0, -1 );
+ } else {
+ return $s;
+ }
+ }
+
+ /**
+ * Run a series of tests listed in the given text files.
+ * Each test consists of a brief description, wikitext input,
+ * and the expected HTML output.
+ *
+ * Prints status updates on stdout and counts up the total
+ * number and percentage of passed tests.
+ *
+ * Handles all setup and teardown.
+ *
+ * @param array $filenames Array of strings
+ * @return bool True if passed all tests, false if any tests failed.
+ */
+ public function runTestsFromFiles( $filenames ) {
+ $ok = false;
+
+ $teardownGuard = $this->staticSetup();
+ $teardownGuard = $this->setupDatabase( $teardownGuard );
+ $teardownGuard = $this->setupUploads( $teardownGuard );
+
+ $this->recorder->start();
+ try {
+ $ok = true;
+
+ foreach ( $filenames as $filename ) {
+ $testFileInfo = TestFileReader::read( $filename, [
+ 'runDisabled' => $this->runDisabled,
+ 'runParsoid' => $this->runParsoid,
+ 'regex' => $this->regex ] );
+
+ // Don't start the suite if there are no enabled tests in the file
+ if ( !$testFileInfo['tests'] ) {
+ continue;
+ }
+
+ $this->recorder->startSuite( $filename );
+ $ok = $this->runTests( $testFileInfo ) && $ok;
+ $this->recorder->endSuite( $filename );
+ }
+
+ $this->recorder->report();
+ } catch ( DBError $e ) {
+ $this->recorder->warning( $e->getMessage() );
+ }
+ $this->recorder->end();
+
+ ScopedCallback::consume( $teardownGuard );
+
+ return $ok;
+ }
+
+ /**
+ * Determine whether the current parser has the hooks registered in it
+ * that are required by a file read by TestFileReader.
+ * @param array $requirements
+ * @return bool
+ */
+ public function meetsRequirements( $requirements ) {
+ foreach ( $requirements as $requirement ) {
+ switch ( $requirement['type'] ) {
+ case 'hook':
+ $ok = $this->requireHook( $requirement['name'] );
+ break;
+ case 'functionHook':
+ $ok = $this->requireFunctionHook( $requirement['name'] );
+ break;
+ case 'transparentHook':
+ $ok = $this->requireTransparentHook( $requirement['name'] );
+ break;
+ }
+ if ( !$ok ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Run the tests from a single file. staticSetup() and setupDatabase()
+ * must have been called already.
+ *
+ * @param array $testFileInfo Parsed file info returned by TestFileReader
+ * @return bool True if passed all tests, false if any tests failed.
+ */
+ public function runTests( $testFileInfo ) {
+ $ok = true;
+
+ $this->checkSetupDone( 'staticSetup' );
+
+ // Don't add articles from the file if there are no enabled tests from the file
+ if ( !$testFileInfo['tests'] ) {
+ return true;
+ }
+
+ // If any requirements are not met, mark all tests from the file as skipped
+ if ( !$this->meetsRequirements( $testFileInfo['requirements'] ) ) {
+ foreach ( $testFileInfo['tests'] as $test ) {
+ $this->recorder->startTest( $test );
+ $this->recorder->skipped( $test, 'required extension not enabled' );
+ }
+ return true;
+ }
+
+ // Add articles
+ $this->addArticles( $testFileInfo['articles'] );
+
+ // Run tests
+ foreach ( $testFileInfo['tests'] as $test ) {
+ $this->recorder->startTest( $test );
+ $result =
+ $this->runTest( $test );
+ if ( $result !== false ) {
+ $ok = $ok && $result->isSuccess();
+ $this->recorder->record( $test, $result );
+ }
+ }
+
+ return $ok;
+ }
+
+ /**
+ * Get a Parser object
+ *
+ * @param string $preprocessor
+ * @return Parser
+ */
+ function getParser( $preprocessor = null ) {
+ global $wgParserConf;
+
+ $class = $wgParserConf['class'];
+ $parser = new $class( [ 'preprocessorClass' => $preprocessor ] + $wgParserConf );
+ ParserTestParserHook::setup( $parser );
+
+ return $parser;
+ }
+
+ /**
+ * Run a given wikitext input through a freshly-constructed wiki parser,
+ * and compare the output against the expected results.
+ * Prints status and explanatory messages to stdout.
+ *
+ * staticSetup() and setupWikiData() must be called before this function
+ * is entered.
+ *
+ * @param array $test The test parameters:
+ * - test: The test name
+ * - desc: The subtest description
+ * - input: Wikitext to try rendering
+ * - options: Array of test options
+ * - config: Overrides for global variables, one per line
+ *
+ * @return ParserTestResult or false if skipped
+ */
+ public function runTest( $test ) {
+ wfDebug( __METHOD__.": running {$test['desc']}" );
+ $opts = $this->parseOptions( $test['options'] );
+ $teardownGuard = $this->perTestSetup( $test );
+
+ $context = RequestContext::getMain();
+ $user = $context->getUser();
+ $options = ParserOptions::newFromContext( $context );
+ $options->setTimestamp( $this->getFakeTimestamp() );
+
+ if ( isset( $opts['tidy'] ) ) {
+ if ( !$this->tidySupport->isEnabled() ) {
+ $this->recorder->skipped( $test, 'tidy extension is not installed' );
+ return false;
+ } else {
+ $options->setTidy( true );
+ }
+ }
+
+ if ( isset( $opts['title'] ) ) {
+ $titleText = $opts['title'];
+ } else {
+ $titleText = 'Parser test';
+ }
+
+ $local = isset( $opts['local'] );
+ $preprocessor = isset( $opts['preprocessor'] ) ? $opts['preprocessor'] : null;
+ $parser = $this->getParser( $preprocessor );
+ $title = Title::newFromText( $titleText );
+
+ if ( isset( $opts['styletag'] ) ) {
+ // For testing the behavior of <style> (including those deduplicated
+ // into <link> tags), add tag hooks to allow them to be generated.
+ $parser->setHook( 'style', function ( $content, $attributes, $parser ) {
+ $marker = Parser::MARKER_PREFIX . '-style-' . md5( $content ) . Parser::MARKER_SUFFIX;
+ $parser->mStripState->addNoWiki( $marker, $content );
+ return Html::inlineStyle( $marker, 'all', $attributes );
+ } );
+ $parser->setHook( 'link', function ( $content, $attributes, $parser ) {
+ return Html::element( 'link', $attributes );
+ } );
+ }
+
+ if ( isset( $opts['pst'] ) ) {
+ $out = $parser->preSaveTransform( $test['input'], $title, $user, $options );
+ $output = $parser->getOutput();
+ } elseif ( isset( $opts['msg'] ) ) {
+ $out = $parser->transformMsg( $test['input'], $options, $title );
+ } elseif ( isset( $opts['section'] ) ) {
+ $section = $opts['section'];
+ $out = $parser->getSection( $test['input'], $section );
+ } elseif ( isset( $opts['replace'] ) ) {
+ $section = $opts['replace'][0];
+ $replace = $opts['replace'][1];
+ $out = $parser->replaceSection( $test['input'], $section, $replace );
+ } elseif ( isset( $opts['comment'] ) ) {
+ $out = Linker::formatComment( $test['input'], $title, $local );
+ } elseif ( isset( $opts['preload'] ) ) {
+ $out = $parser->getPreloadText( $test['input'], $title, $options );
+ } else {
+ $output = $parser->parse( $test['input'], $title, $options, true, true, 1337 );
+ $out = $output->getText( [
+ 'allowTOC' => !isset( $opts['notoc'] ),
+ 'unwrap' => !isset( $opts['wrap'] ),
+ ] );
+ if ( isset( $opts['tidy'] ) ) {
+ $out = preg_replace( '/\s+$/', '', $out );
+ }
+
+ if ( isset( $opts['showtitle'] ) ) {
+ if ( $output->getTitleText() ) {
+ $title = $output->getTitleText();
+ }
+
+ $out = "$title\n$out";
+ }
+
+ if ( isset( $opts['showindicators'] ) ) {
+ $indicators = '';
+ foreach ( $output->getIndicators() as $id => $content ) {
+ $indicators .= "$id=$content\n";
+ }
+ $out = $indicators . $out;
+ }
+
+ if ( isset( $opts['ill'] ) ) {
+ $out = implode( ' ', $output->getLanguageLinks() );
+ } elseif ( isset( $opts['cat'] ) ) {
+ $out = '';
+ foreach ( $output->getCategories() as $name => $sortkey ) {
+ if ( $out !== '' ) {
+ $out .= "\n";
+ }
+ $out .= "cat=$name sort=$sortkey";
+ }
+ }
+ }
+
+ if ( isset( $output ) && isset( $opts['showflags'] ) ) {
+ $actualFlags = array_keys( TestingAccessWrapper::newFromObject( $output )->mFlags );
+ sort( $actualFlags );
+ $out .= "\nflags=" . implode( ', ', $actualFlags );
+ }
+
+ ScopedCallback::consume( $teardownGuard );
+
+ $expected = $test['result'];
+ if ( count( $this->normalizationFunctions ) ) {
+ $expected = ParserTestResultNormalizer::normalize(
+ $test['expected'], $this->normalizationFunctions );
+ $out = ParserTestResultNormalizer::normalize( $out, $this->normalizationFunctions );
+ }
+
+ $testResult = new ParserTestResult( $test, $expected, $out );
+ return $testResult;
+ }
+
+ /**
+ * Use a regex to find out the value of an option
+ * @param string $key Name of option val to retrieve
+ * @param array $opts Options array to look in
+ * @param mixed $default Default value returned if not found
+ * @return mixed
+ */
+ private static function getOptionValue( $key, $opts, $default ) {
+ $key = strtolower( $key );
+
+ if ( isset( $opts[$key] ) ) {
+ return $opts[$key];
+ } else {
+ return $default;
+ }
+ }
+
+ /**
+ * Given the options string, return an associative array of options.
+ * @todo Move this to TestFileReader
+ *
+ * @param string $instring
+ * @return array
+ */
+ private function parseOptions( $instring ) {
+ $opts = [];
+ // foo
+ // foo=bar
+ // foo="bar baz"
+ // foo=[[bar baz]]
+ // foo=bar,"baz quux"
+ // foo={...json...}
+ $defs = '(?(DEFINE)
+ (?<qstr> # Quoted string
+ "
+ (?:[^\\\\"] | \\\\.)*
+ "
+ )
+ (?<json>
+ \{ # Open bracket
+ (?:
+ [^"{}] | # Not a quoted string or object, or
+ (?&qstr) | # A quoted string, or
+ (?&json) # A json object (recursively)
+ )*
+ \} # Close bracket
+ )
+ (?<value>
+ (?:
+ (?&qstr) # Quoted val
+ |
+ \[\[
+ [^]]* # Link target
+ \]\]
+ |
+ [\w-]+ # Plain word
+ |
+ (?&json) # JSON object
+ )
+ )
+ )';
+ $regex = '/' . $defs . '\b
+ (?<k>[\w-]+) # Key
+ \b
+ (?:\s*
+ = # First sub-value
+ \s*
+ (?<v>
+ (?&value)
+ (?:\s*
+ , # Sub-vals 1..N
+ \s*
+ (?&value)
+ )*
+ )
+ )?
+ /x';
+ $valueregex = '/' . $defs . '(?&value)/x';
+
+ if ( preg_match_all( $regex, $instring, $matches, PREG_SET_ORDER ) ) {
+ foreach ( $matches as $bits ) {
+ $key = strtolower( $bits['k'] );
+ if ( !isset( $bits['v'] ) ) {
+ $opts[$key] = true;
+ } else {
+ preg_match_all( $valueregex, $bits['v'], $vmatches );
+ $opts[$key] = array_map( [ $this, 'cleanupOption' ], $vmatches[0] );
+ if ( count( $opts[$key] ) == 1 ) {
+ $opts[$key] = $opts[$key][0];
+ }
+ }
+ }
+ }
+ return $opts;
+ }
+
+ private function cleanupOption( $opt ) {
+ if ( substr( $opt, 0, 1 ) == '"' ) {
+ return stripcslashes( substr( $opt, 1, -1 ) );
+ }
+
+ if ( substr( $opt, 0, 2 ) == '[[' ) {
+ return substr( $opt, 2, -2 );
+ }
+
+ if ( substr( $opt, 0, 1 ) == '{' ) {
+ return FormatJson::decode( $opt, true );
+ }
+ return $opt;
+ }
+
+ /**
+ * Do any required setup which is dependent on test options.
+ *
+ * @see staticSetup() for more information about setup/teardown
+ *
+ * @param array $test Test info supplied by TestFileReader
+ * @param callable|null $nextTeardown
+ * @return ScopedCallback
+ */
+ public function perTestSetup( $test, $nextTeardown = null ) {
+ $teardown = [];
+
+ $this->checkSetupDone( 'setupDatabase', 'setDatabase' );
+ $teardown[] = $this->markSetupDone( 'perTestSetup' );
+
+ $opts = $this->parseOptions( $test['options'] );
+ $config = $test['config'];
+
+ // Find out values for some special options.
+ $langCode =
+ self::getOptionValue( 'language', $opts, 'en' );
+ $variant =
+ self::getOptionValue( 'variant', $opts, false );
+ $maxtoclevel =
+ self::getOptionValue( 'wgMaxTocLevel', $opts, 999 );
+ $linkHolderBatchSize =
+ self::getOptionValue( 'wgLinkHolderBatchSize', $opts, 1000 );
+
+ // Default to fallback skin, but allow it to be overridden
+ $skin = self::getOptionValue( 'skin', $opts, 'fallback' );
+
+ $setup = [
+ 'wgEnableUploads' => self::getOptionValue( 'wgEnableUploads', $opts, true ),
+ 'wgLanguageCode' => $langCode,
+ 'wgRawHtml' => self::getOptionValue( 'wgRawHtml', $opts, false ),
+ 'wgNamespacesWithSubpages' => array_fill_keys(
+ MWNamespace::getValidNamespaces(), isset( $opts['subpage'] )
+ ),
+ 'wgMaxTocLevel' => $maxtoclevel,
+ 'wgAllowExternalImages' => self::getOptionValue( 'wgAllowExternalImages', $opts, true ),
+ 'wgThumbLimits' => [ self::getOptionValue( 'thumbsize', $opts, 180 ) ],
+ 'wgDefaultLanguageVariant' => $variant,
+ 'wgLinkHolderBatchSize' => $linkHolderBatchSize,
+ // Set as a JSON object like:
+ // wgEnableMagicLinks={"ISBN":false, "PMID":false, "RFC":false}
+ 'wgEnableMagicLinks' => self::getOptionValue( 'wgEnableMagicLinks', $opts, [] )
+ + [ 'ISBN' => true, 'PMID' => true, 'RFC' => true ],
+ // Test with legacy encoding by default until HTML5 is very stable and default
+ 'wgFragmentMode' => [ 'legacy' ],
+ ];
+
+ if ( $config ) {
+ $configLines = explode( "\n", $config );
+
+ foreach ( $configLines as $line ) {
+ list( $var, $value ) = explode( '=', $line, 2 );
+ $setup[$var] = eval( "return $value;" );
+ }
+ }
+
+ /** @since 1.20 */
+ Hooks::run( 'ParserTestGlobals', [ &$setup ] );
+
+ // Create tidy driver
+ if ( isset( $opts['tidy'] ) ) {
+ // Cache a driver instance
+ if ( $this->tidyDriver === null ) {
+ $this->tidyDriver = MWTidy::factory( $this->tidySupport->getConfig() );
+ }
+ $tidy = $this->tidyDriver;
+ } else {
+ $tidy = false;
+ }
+ MWTidy::setInstance( $tidy );
+ $teardown[] = function () {
+ MWTidy::destroySingleton();
+ };
+
+ // Set content language. This invalidates the magic word cache and title services
+ $lang = Language::factory( $langCode );
+ $lang->resetNamespaces();
+ $setup['wgContLang'] = $lang;
+ $reset = function () {
+ MagicWord::clearCache();
+ $this->resetTitleServices();
+ };
+ $setup[] = $reset;
+ $teardown[] = $reset;
+
+ // Make a user object with the same language
+ $user = new User;
+ $user->setOption( 'language', $langCode );
+ $setup['wgLang'] = $lang;
+
+ // We (re)set $wgThumbLimits to a single-element array above.
+ $user->setOption( 'thumbsize', 0 );
+
+ $setup['wgUser'] = $user;
+
+ // And put both user and language into the context
+ $context = RequestContext::getMain();
+ $context->setUser( $user );
+ $context->setLanguage( $lang );
+ // And the skin!
+ $oldSkin = $context->getSkin();
+ $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
+ $context->setSkin( $skinFactory->makeSkin( $skin ) );
+ $context->setOutput( new OutputPage( $context ) );
+ $setup['wgOut'] = $context->getOutput();
+ $teardown[] = function () use ( $context, $oldSkin ) {
+ // Clear language conversion tables
+ $wrapper = TestingAccessWrapper::newFromObject(
+ $context->getLanguage()->getConverter()
+ );
+ $wrapper->reloadTables();
+ // Reset context to the restored globals
+ $context->setUser( $GLOBALS['wgUser'] );
+ $context->setLanguage( $GLOBALS['wgContLang'] );
+ $context->setSkin( $oldSkin );
+ $context->setOutput( $GLOBALS['wgOut'] );
+ };
+
+ $teardown[] = $this->executeSetupSnippets( $setup );
+
+ return $this->createTeardownObject( $teardown, $nextTeardown );
+ }
+
+ /**
+ * List of temporary tables to create, without prefix.
+ * Some of these probably aren't necessary.
+ * @return array
+ */
+ private function listTables() {
+ global $wgCommentTableSchemaMigrationStage, $wgActorTableSchemaMigrationStage;
+
+ $tables = [ 'user', 'user_properties', 'user_former_groups', 'page', 'page_restrictions',
+ 'protected_titles', 'revision', 'ip_changes', 'text', 'pagelinks', 'imagelinks',
+ 'categorylinks', 'templatelinks', 'externallinks', 'langlinks', 'iwlinks',
+ 'site_stats', 'ipblocks', 'image', 'oldimage',
+ 'recentchanges', 'watchlist', 'interwiki', 'logging', 'log_search',
+ 'querycache', 'objectcache', 'job', 'l10n_cache', 'redirect', 'querycachetwo',
+ 'archive', 'user_groups', 'page_props', 'category'
+ ];
+
+ if ( $wgCommentTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ // The new tables for comments are in use
+ $tables[] = 'comment';
+ $tables[] = 'revision_comment_temp';
+ $tables[] = 'image_comment_temp';
+ }
+
+ if ( $wgActorTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ // The new tables for actors are in use
+ $tables[] = 'actor';
+ $tables[] = 'revision_actor_temp';
+ }
+
+ if ( in_array( $this->db->getType(), [ 'mysql', 'sqlite', 'oracle' ] ) ) {
+ array_push( $tables, 'searchindex' );
+ }
+
+ // Allow extensions to add to the list of tables to duplicate;
+ // may be necessary if they hook into page save or other code
+ // which will require them while running tests.
+ Hooks::run( 'ParserTestTables', [ &$tables ] );
+
+ return $tables;
+ }
+
+ public function setDatabase( IDatabase $db ) {
+ $this->db = $db;
+ $this->setupDone['setDatabase'] = true;
+ }
+
+ /**
+ * Set up temporary DB tables.
+ *
+ * For best performance, call this once only for all tests. However, it can
+ * be called at the start of each test if more isolation is desired.
+ *
+ * @todo: This is basically an unrefactored copy of
+ * MediaWikiTestCase::setupAllTestDBs. They should be factored out somehow.
+ *
+ * Do not call this function from a MediaWikiTestCase subclass, since
+ * MediaWikiTestCase does its own DB setup. Instead use setDatabase().
+ *
+ * @see staticSetup() for more information about setup/teardown
+ *
+ * @param ScopedCallback|null $nextTeardown The next teardown object
+ * @return ScopedCallback The teardown object
+ */
+ public function setupDatabase( $nextTeardown = null ) {
+ global $wgDBprefix;
+
+ $this->db = wfGetDB( DB_MASTER );
+ $dbType = $this->db->getType();
+
+ if ( $dbType == 'oracle' ) {
+ $suspiciousPrefixes = [ 'pt_', MediaWikiTestCase::ORA_DB_PREFIX ];
+ } else {
+ $suspiciousPrefixes = [ 'parsertest_', MediaWikiTestCase::DB_PREFIX ];
+ }
+ if ( in_array( $wgDBprefix, $suspiciousPrefixes ) ) {
+ throw new MWException( "\$wgDBprefix=$wgDBprefix suggests DB setup is already done" );
+ }
+
+ $teardown = [];
+
+ $teardown[] = $this->markSetupDone( 'setupDatabase' );
+
+ # CREATE TEMPORARY TABLE breaks if there is more than one server
+ if ( wfGetLB()->getServerCount() != 1 ) {
+ $this->useTemporaryTables = false;
+ }
+
+ $temporary = $this->useTemporaryTables || $dbType == 'postgres';
+ $prefix = $dbType != 'oracle' ? 'parsertest_' : 'pt_';
+
+ $this->dbClone = new CloneDatabase( $this->db, $this->listTables(), $prefix );
+ $this->dbClone->useTemporaryTables( $temporary );
+ $this->dbClone->cloneTableStructure();
+
+ if ( $dbType == 'oracle' ) {
+ $this->db->query( 'BEGIN FILL_WIKI_INFO; END;' );
+ # Insert 0 user to prevent FK violations
+
+ # Anonymous user
+ $this->db->insert( 'user', [
+ 'user_id' => 0,
+ 'user_name' => 'Anonymous' ] );
+ }
+
+ $teardown[] = function () {
+ $this->teardownDatabase();
+ };
+
+ // Wipe some DB query result caches on setup and teardown
+ $reset = function () {
+ LinkCache::singleton()->clear();
+
+ // Clear the message cache
+ MessageCache::singleton()->clear();
+ };
+ $reset();
+ $teardown[] = $reset;
+ return $this->createTeardownObject( $teardown, $nextTeardown );
+ }
+
+ /**
+ * Add data about uploads to the new test DB, and set up the upload
+ * directory. This should be called after either setDatabase() or
+ * setupDatabase().
+ *
+ * @param ScopedCallback|null $nextTeardown The next teardown object
+ * @return ScopedCallback The teardown object
+ */
+ public function setupUploads( $nextTeardown = null ) {
+ $teardown = [];
+
+ $this->checkSetupDone( 'setupDatabase', 'setDatabase' );
+ $teardown[] = $this->markSetupDone( 'setupUploads' );
+
+ // Create the files in the upload directory (or pretend to create them
+ // in a MockFileBackend). Append teardown callback.
+ $teardown[] = $this->setupUploadBackend();
+
+ // Create a user
+ $user = User::createNew( 'WikiSysop' );
+
+ // Register the uploads in the database
+
+ $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Foobar.jpg' ) );
+ # note that the size/width/height/bits/etc of the file
+ # are actually set by inspecting the file itself; the arguments
+ # to recordUpload2 have no effect. That said, we try to make things
+ # match up so it is less confusing to readers of the code & tests.
+ $image->recordUpload2( '', 'Upload of some lame file', 'Some lame file', [
+ 'size' => 7881,
+ 'width' => 1941,
+ 'height' => 220,
+ 'bits' => 8,
+ 'media_type' => MEDIATYPE_BITMAP,
+ 'mime' => 'image/jpeg',
+ 'metadata' => serialize( [] ),
+ 'sha1' => Wikimedia\base_convert( '1', 16, 36, 31 ),
+ 'fileExists' => true
+ ], $this->db->timestamp( '20010115123500' ), $user );
+
+ $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Thumb.png' ) );
+ # again, note that size/width/height below are ignored; see above.
+ $image->recordUpload2( '', 'Upload of some lame thumbnail', 'Some lame thumbnail', [
+ 'size' => 22589,
+ 'width' => 135,
+ 'height' => 135,
+ 'bits' => 8,
+ 'media_type' => MEDIATYPE_BITMAP,
+ 'mime' => 'image/png',
+ 'metadata' => serialize( [] ),
+ 'sha1' => Wikimedia\base_convert( '2', 16, 36, 31 ),
+ 'fileExists' => true
+ ], $this->db->timestamp( '20130225203040' ), $user );
+
+ $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Foobar.svg' ) );
+ $image->recordUpload2( '', 'Upload of some lame SVG', 'Some lame SVG', [
+ 'size' => 12345,
+ 'width' => 240,
+ 'height' => 180,
+ 'bits' => 0,
+ 'media_type' => MEDIATYPE_DRAWING,
+ 'mime' => 'image/svg+xml',
+ 'metadata' => serialize( [] ),
+ 'sha1' => Wikimedia\base_convert( '', 16, 36, 31 ),
+ 'fileExists' => true
+ ], $this->db->timestamp( '20010115123500' ), $user );
+
+ # This image will be blacklisted in [[MediaWiki:Bad image list]]
+ $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Bad.jpg' ) );
+ $image->recordUpload2( '', 'zomgnotcensored', 'Borderline image', [
+ 'size' => 12345,
+ 'width' => 320,
+ 'height' => 240,
+ 'bits' => 24,
+ 'media_type' => MEDIATYPE_BITMAP,
+ 'mime' => 'image/jpeg',
+ 'metadata' => serialize( [] ),
+ 'sha1' => Wikimedia\base_convert( '3', 16, 36, 31 ),
+ 'fileExists' => true
+ ], $this->db->timestamp( '20010115123500' ), $user );
+
+ $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Video.ogv' ) );
+ $image->recordUpload2( '', 'A pretty movie', 'Will it play', [
+ 'size' => 12345,
+ 'width' => 320,
+ 'height' => 240,
+ 'bits' => 0,
+ 'media_type' => MEDIATYPE_VIDEO,
+ 'mime' => 'application/ogg',
+ 'metadata' => serialize( [] ),
+ 'sha1' => Wikimedia\base_convert( '', 16, 36, 31 ),
+ 'fileExists' => true
+ ], $this->db->timestamp( '20010115123500' ), $user );
+
+ $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Audio.oga' ) );
+ $image->recordUpload2( '', 'An awesome hitsong', 'Will it play', [
+ 'size' => 12345,
+ 'width' => 0,
+ 'height' => 0,
+ 'bits' => 0,
+ 'media_type' => MEDIATYPE_AUDIO,
+ 'mime' => 'application/ogg',
+ 'metadata' => serialize( [] ),
+ 'sha1' => Wikimedia\base_convert( '', 16, 36, 31 ),
+ 'fileExists' => true
+ ], $this->db->timestamp( '20010115123500' ), $user );
+
+ # A DjVu file
+ $image = wfLocalFile( Title::makeTitle( NS_FILE, 'LoremIpsum.djvu' ) );
+ $image->recordUpload2( '', 'Upload a DjVu', 'A DjVu', [
+ 'size' => 3249,
+ 'width' => 2480,
+ 'height' => 3508,
+ 'bits' => 0,
+ 'media_type' => MEDIATYPE_BITMAP,
+ 'mime' => 'image/vnd.djvu',
+ 'metadata' => '<?xml version="1.0" ?>
+<!DOCTYPE DjVuXML PUBLIC "-//W3C//DTD DjVuXML 1.1//EN" "pubtext/DjVuXML-s.dtd">
+<DjVuXML>
+<HEAD></HEAD>
+<BODY><OBJECT height="3508" width="2480">
+<PARAM name="DPI" value="300" />
+<PARAM name="GAMMA" value="2.2" />
+</OBJECT>
+<OBJECT height="3508" width="2480">
+<PARAM name="DPI" value="300" />
+<PARAM name="GAMMA" value="2.2" />
+</OBJECT>
+<OBJECT height="3508" width="2480">
+<PARAM name="DPI" value="300" />
+<PARAM name="GAMMA" value="2.2" />
+</OBJECT>
+<OBJECT height="3508" width="2480">
+<PARAM name="DPI" value="300" />
+<PARAM name="GAMMA" value="2.2" />
+</OBJECT>
+<OBJECT height="3508" width="2480">
+<PARAM name="DPI" value="300" />
+<PARAM name="GAMMA" value="2.2" />
+</OBJECT>
+</BODY>
+</DjVuXML>',
+ 'sha1' => Wikimedia\base_convert( '', 16, 36, 31 ),
+ 'fileExists' => true
+ ], $this->db->timestamp( '20010115123600' ), $user );
+
+ return $this->createTeardownObject( $teardown, $nextTeardown );
+ }
+
+ /**
+ * Helper for database teardown, called from the teardown closure. Destroy
+ * the database clone and fix up some things that CloneDatabase doesn't fix.
+ *
+ * @todo Move most things here to CloneDatabase
+ */
+ private function teardownDatabase() {
+ $this->checkSetupDone( 'setupDatabase' );
+
+ $this->dbClone->destroy();
+ $this->databaseSetupDone = false;
+
+ if ( $this->useTemporaryTables ) {
+ if ( $this->db->getType() == 'sqlite' ) {
+ # Under SQLite the searchindex table is virtual and need
+ # to be explicitly destroyed. See T31912
+ # See also MediaWikiTestCase::destroyDB()
+ wfDebug( __METHOD__ . " explicitly destroying sqlite virtual table parsertest_searchindex\n" );
+ $this->db->query( "DROP TABLE `parsertest_searchindex`" );
+ }
+ # Don't need to do anything
+ return;
+ }
+
+ $tables = $this->listTables();
+
+ foreach ( $tables as $table ) {
+ if ( $this->db->getType() == 'oracle' ) {
+ $this->db->query( "DROP TABLE pt_$table DROP CONSTRAINTS" );
+ } else {
+ $this->db->query( "DROP TABLE `parsertest_$table`" );
+ }
+ }
+
+ if ( $this->db->getType() == 'oracle' ) {
+ $this->db->query( 'BEGIN FILL_WIKI_INFO; END;' );
+ }
+ }
+
+ /**
+ * Upload test files to the backend created by createRepoGroup().
+ *
+ * @return callable The teardown callback
+ */
+ private function setupUploadBackend() {
+ global $IP;
+
+ $repo = RepoGroup::singleton()->getLocalRepo();
+ $base = $repo->getZonePath( 'public' );
+ $backend = $repo->getBackend();
+ $backend->prepare( [ 'dir' => "$base/3/3a" ] );
+ $backend->store( [
+ 'src' => "$IP/tests/phpunit/data/parser/headbg.jpg",
+ 'dst' => "$base/3/3a/Foobar.jpg"
+ ] );
+ $backend->prepare( [ 'dir' => "$base/e/ea" ] );
+ $backend->store( [
+ 'src' => "$IP/tests/phpunit/data/parser/wiki.png",
+ 'dst' => "$base/e/ea/Thumb.png"
+ ] );
+ $backend->prepare( [ 'dir' => "$base/0/09" ] );
+ $backend->store( [
+ 'src' => "$IP/tests/phpunit/data/parser/headbg.jpg",
+ 'dst' => "$base/0/09/Bad.jpg"
+ ] );
+ $backend->prepare( [ 'dir' => "$base/5/5f" ] );
+ $backend->store( [
+ 'src' => "$IP/tests/phpunit/data/parser/LoremIpsum.djvu",
+ 'dst' => "$base/5/5f/LoremIpsum.djvu"
+ ] );
+
+ // No helpful SVG file to copy, so make one ourselves
+ $data = '<?xml version="1.0" encoding="utf-8"?>' .
+ '<svg xmlns="http://www.w3.org/2000/svg"' .
+ ' version="1.1" width="240" height="180"/>';
+
+ $backend->prepare( [ 'dir' => "$base/f/ff" ] );
+ $backend->quickCreate( [
+ 'content' => $data, 'dst' => "$base/f/ff/Foobar.svg"
+ ] );
+
+ return function () use ( $backend ) {
+ if ( $backend instanceof MockFileBackend ) {
+ // In memory backend, so dont bother cleaning them up.
+ return;
+ }
+ $this->teardownUploadBackend();
+ };
+ }
+
+ /**
+ * Remove the dummy uploads directory
+ */
+ private function teardownUploadBackend() {
+ if ( $this->keepUploads ) {
+ return;
+ }
+
+ $repo = RepoGroup::singleton()->getLocalRepo();
+ $public = $repo->getZonePath( 'public' );
+
+ $this->deleteFiles(
+ [
+ "$public/3/3a/Foobar.jpg",
+ "$public/e/ea/Thumb.png",
+ "$public/0/09/Bad.jpg",
+ "$public/5/5f/LoremIpsum.djvu",
+ "$public/f/ff/Foobar.svg",
+ "$public/0/00/Video.ogv",
+ "$public/4/41/Audio.oga",
+ ]
+ );
+ }
+
+ /**
+ * Delete the specified files and their parent directories
+ * @param array $files File backend URIs mwstore://...
+ */
+ private function deleteFiles( $files ) {
+ // Delete the files
+ $backend = RepoGroup::singleton()->getLocalRepo()->getBackend();
+ foreach ( $files as $file ) {
+ $backend->delete( [ 'src' => $file ], [ 'force' => 1 ] );
+ }
+
+ // Delete the parent directories
+ foreach ( $files as $file ) {
+ $tmp = FileBackend::parentStoragePath( $file );
+ while ( $tmp ) {
+ if ( !$backend->clean( [ 'dir' => $tmp ] )->isOK() ) {
+ break;
+ }
+ $tmp = FileBackend::parentStoragePath( $tmp );
+ }
+ }
+ }
+
+ /**
+ * Add articles to the test DB.
+ *
+ * @param array $articles Article info array from TestFileReader
+ */
+ public function addArticles( $articles ) {
+ global $wgContLang;
+ $setup = [];
+ $teardown = [];
+
+ // Be sure ParserTestRunner::addArticle has correct language set,
+ // so that system messages get into the right language cache
+ if ( $wgContLang->getCode() !== 'en' ) {
+ $setup['wgLanguageCode'] = 'en';
+ $setup['wgContLang'] = Language::factory( 'en' );
+ }
+
+ // Add special namespaces, in case that hasn't been done by staticSetup() yet
+ $this->appendNamespaceSetup( $setup, $teardown );
+
+ // wgCapitalLinks obviously needs initialisation
+ $setup['wgCapitalLinks'] = true;
+
+ $teardown[] = $this->executeSetupSnippets( $setup );
+
+ foreach ( $articles as $info ) {
+ $this->addArticle( $info['name'], $info['text'], $info['file'], $info['line'] );
+ }
+
+ // Wipe WANObjectCache process cache, which is invalidated by article insertion
+ // due to T144706
+ ObjectCache::getMainWANInstance()->clearProcessCache();
+
+ $this->executeSetupSnippets( $teardown );
+ }
+
+ /**
+ * Insert a temporary test article
+ * @param string $name The title, including any prefix
+ * @param string $text The article text
+ * @param string $file The input file name
+ * @param int|string $line The input line number, for reporting errors
+ * @throws Exception
+ * @throws MWException
+ */
+ private function addArticle( $name, $text, $file, $line ) {
+ $text = self::chomp( $text );
+ $name = self::chomp( $name );
+
+ $title = Title::newFromText( $name );
+ wfDebug( __METHOD__ . ": adding $name" );
+
+ if ( is_null( $title ) ) {
+ throw new MWException( "invalid title '$name' at $file:$line\n" );
+ }
+
+ $newContent = ContentHandler::makeContent( $text, $title );
+
+ $page = WikiPage::factory( $title );
+ $page->loadPageData( 'fromdbmaster' );
+
+ if ( $page->exists() ) {
+ $content = $page->getContent( Revision::RAW );
+ // Only reject the title, if the content/content model is different.
+ // This makes it easier to create Template:(( or Template:)) in different extensions
+ if ( $newContent->equals( $content ) ) {
+ return;
+ }
+ throw new MWException(
+ "duplicate article '$name' with different content at $file:$line\n"
+ );
+ }
+
+ // Use mock parser, to make debugging of actual parser tests simpler.
+ // But initialise the MessageCache clone first, don't let MessageCache
+ // get a reference to the mock object.
+ MessageCache::singleton()->getParser();
+ $restore = $this->executeSetupSnippets( [ 'wgParser' => new ParserTestMockParser ] );
+ try {
+ $status = $page->doEditContent(
+ $newContent,
+ '',
+ EDIT_NEW | EDIT_INTERNAL
+ );
+ } finally {
+ $restore();
+ }
+
+ if ( !$status->isOK() ) {
+ throw new MWException( $status->getWikiText( false, false, 'en' ) );
+ }
+
+ // The RepoGroup cache is invalidated by the creation of file redirects
+ if ( $title->inNamespace( NS_FILE ) ) {
+ RepoGroup::singleton()->clearCache( $title );
+ }
+ }
+
+ /**
+ * Check if a hook is installed
+ *
+ * @param string $name
+ * @return bool True if tag hook is present
+ */
+ public function requireHook( $name ) {
+ global $wgParser;
+
+ $wgParser->firstCallInit(); // make sure hooks are loaded.
+ if ( isset( $wgParser->mTagHooks[$name] ) ) {
+ return true;
+ } else {
+ $this->recorder->warning( " This test suite requires the '$name' hook " .
+ "extension, skipping." );
+ return false;
+ }
+ }
+
+ /**
+ * Check if a function hook is installed
+ *
+ * @param string $name
+ * @return bool True if function hook is present
+ */
+ public function requireFunctionHook( $name ) {
+ global $wgParser;
+
+ $wgParser->firstCallInit(); // make sure hooks are loaded.
+
+ if ( isset( $wgParser->mFunctionHooks[$name] ) ) {
+ return true;
+ } else {
+ $this->recorder->warning( " This test suite requires the '$name' function " .
+ "hook extension, skipping." );
+ return false;
+ }
+ }
+
+ /**
+ * Check if a transparent tag hook is installed
+ *
+ * @param string $name
+ * @return bool True if function hook is present
+ */
+ public function requireTransparentHook( $name ) {
+ global $wgParser;
+
+ $wgParser->firstCallInit(); // make sure hooks are loaded.
+
+ if ( isset( $wgParser->mTransparentTagHooks[$name] ) ) {
+ return true;
+ } else {
+ $this->recorder->warning( " This test suite requires the '$name' transparent " .
+ "hook extension, skipping.\n" );
+ return false;
+ }
+ }
+
+ /**
+ * Fake constant timestamp to make sure time-related parser
+ * functions give a persistent value.
+ *
+ * - Parser::getVariableValue (via ParserGetVariableValueTs hook)
+ * - Parser::preSaveTransform (via ParserOptions)
+ */
+ private function getFakeTimestamp() {
+ // parsed as '1970-01-01T00:02:03Z'
+ return 123;
+ }
+}
diff --git a/www/wiki/tests/parser/PhpunitTestRecorder.php b/www/wiki/tests/parser/PhpunitTestRecorder.php
new file mode 100644
index 00000000..1a2cfc91
--- /dev/null
+++ b/www/wiki/tests/parser/PhpunitTestRecorder.php
@@ -0,0 +1,18 @@
+<?php
+
+class PhpunitTestRecorder extends TestRecorder {
+ private $testCase;
+
+ public function setTestCase( PHPUnit\Framework\TestCase $testCase ) {
+ $this->testCase = $testCase;
+ }
+
+ /**
+ * Mark a test skipped
+ * @param string $test
+ * @param string $reason
+ */
+ public function skipped( $test, $reason ) {
+ $this->testCase->markTestSkipped( "SKIPPED: $reason" );
+ }
+}
diff --git a/www/wiki/tests/parser/README b/www/wiki/tests/parser/README
new file mode 100644
index 00000000..a62db5ac
--- /dev/null
+++ b/www/wiki/tests/parser/README
@@ -0,0 +1,12 @@
+Parser tests can be run either via PHPUnit or by using the standalone
+parserTests.php in this directory. The standalone version provides more
+options.
+
+To run parser tests via PHPUnit:
+
+ $ cd tests/phpunit
+ ./phpunit.php --testsuite parsertests
+
+You can optionally filter by title using --filter, e.g.
+
+ ./phpunit.php --testsuite parsertests --filter="T6400"
diff --git a/www/wiki/tests/parser/TestFileEditor.php b/www/wiki/tests/parser/TestFileEditor.php
new file mode 100644
index 00000000..1bee31ea
--- /dev/null
+++ b/www/wiki/tests/parser/TestFileEditor.php
@@ -0,0 +1,196 @@
+<?php
+
+class TestFileEditor {
+ private $lines;
+ private $numLines;
+ private $deletions;
+ private $changes;
+ private $pos;
+ private $warningCallback;
+ private $result;
+
+ public static function edit( $text, array $deletions, array $changes, $warningCallback = null ) {
+ $editor = new self( $text, $deletions, $changes, $warningCallback );
+ $editor->execute();
+ return $editor->result;
+ }
+
+ private function __construct( $text, array $deletions, array $changes, $warningCallback ) {
+ $this->lines = explode( "\n", $text );
+ $this->numLines = count( $this->lines );
+ $this->deletions = array_flip( $deletions );
+ $this->changes = $changes;
+ $this->pos = 0;
+ $this->warningCallback = $warningCallback;
+ $this->result = '';
+ }
+
+ private function execute() {
+ while ( $this->pos < $this->numLines ) {
+ $line = $this->lines[$this->pos];
+ switch ( $this->getHeading( $line ) ) {
+ case 'test':
+ $this->parseTest();
+ break;
+ case 'hooks':
+ case 'functionhooks':
+ case 'transparenthooks':
+ $this->parseHooks();
+ break;
+ default:
+ if ( $this->pos < $this->numLines - 1 ) {
+ $line .= "\n";
+ }
+ $this->emitComment( $line );
+ $this->pos++;
+ }
+ }
+ foreach ( $this->deletions as $deletion => $unused ) {
+ $this->warning( "Could not find test \"$deletion\" to delete it" );
+ }
+ foreach ( $this->changes as $test => $sectionChanges ) {
+ foreach ( $sectionChanges as $section => $change ) {
+ $this->warning( "Could not find section \"$section\" in test \"$test\" " .
+ "to {$change['op']} it" );
+ }
+ }
+ }
+
+ private function warning( $text ) {
+ $cb = $this->warningCallback;
+ if ( $cb ) {
+ $cb( $text );
+ }
+ }
+
+ private function getHeading( $line ) {
+ if ( preg_match( '/^!!\s*(\S+)/', $line, $m ) ) {
+ return $m[1];
+ } else {
+ return false;
+ }
+ }
+
+ private function parseTest() {
+ $test = [];
+ $line = $this->lines[$this->pos++];
+ $heading = $this->getHeading( $line );
+ $section = [
+ 'name' => $heading,
+ 'headingLine' => $line,
+ 'contents' => ''
+ ];
+
+ while ( $this->pos < $this->numLines ) {
+ $line = $this->lines[$this->pos++];
+ $nextHeading = $this->getHeading( $line );
+ if ( $nextHeading === 'end' ) {
+ $test[] = $section;
+
+ // Add trailing line breaks to the "end" section, to allow for neat deletions
+ $trail = '';
+ for ( $i = 0; $i < $this->numLines - $this->pos - 1; $i++ ) {
+ if ( $this->lines[$this->pos + $i] === '' ) {
+ $trail .= "\n";
+ } else {
+ break;
+ }
+ }
+ $this->pos += strlen( $trail );
+
+ $test[] = [
+ 'name' => 'end',
+ 'headingLine' => $line,
+ 'contents' => $trail
+ ];
+ $this->emitTest( $test );
+ return;
+ } elseif ( $nextHeading !== false ) {
+ $test[] = $section;
+ $heading = $nextHeading;
+ $section = [
+ 'name' => $heading,
+ 'headingLine' => $line,
+ 'contents' => ''
+ ];
+ } else {
+ $section['contents'] .= "$line\n";
+ }
+ }
+
+ throw new Exception( 'Unexpected end of file' );
+ }
+
+ private function parseHooks() {
+ $line = $this->lines[$this->pos++];
+ $heading = $this->getHeading( $line );
+ $expectedEnd = 'end' . $heading;
+ $contents = "$line\n";
+
+ do {
+ $line = $this->lines[$this->pos++];
+ $nextHeading = $this->getHeading( $line );
+ $contents .= "$line\n";
+ } while ( $this->pos < $this->numLines && $nextHeading !== $expectedEnd );
+
+ if ( $nextHeading !== $expectedEnd ) {
+ throw new Exception( 'Unexpected end of file' );
+ }
+ $this->emitHooks( $heading, $contents );
+ }
+
+ protected function emitComment( $contents ) {
+ $this->result .= $contents;
+ }
+
+ protected function emitTest( $test ) {
+ $testName = false;
+ foreach ( $test as $section ) {
+ if ( $section['name'] === 'test' ) {
+ $testName = rtrim( $section['contents'], "\n" );
+ }
+ }
+ if ( isset( $this->deletions[$testName] ) ) {
+ // Acknowledge deletion
+ unset( $this->deletions[$testName] );
+ return;
+ }
+ if ( isset( $this->changes[$testName] ) ) {
+ $changes =& $this->changes[$testName];
+ foreach ( $test as $i => $section ) {
+ $sectionName = $section['name'];
+ if ( isset( $changes[$sectionName] ) ) {
+ $change = $changes[$sectionName];
+ switch ( $change['op'] ) {
+ case 'rename':
+ $test[$i]['name'] = $change['value'];
+ $test[$i]['headingLine'] = "!! {$change['value']}";
+ break;
+ case 'update':
+ $test[$i]['contents'] = $change['value'];
+ break;
+ case 'delete':
+ $test[$i]['deleted'] = true;
+ break;
+ default:
+ throw new Exception( "Unknown op: ${change['op']}" );
+ }
+ // Acknowledge
+ // Note that we use the old section name for the rename op
+ unset( $changes[$sectionName] );
+ }
+ }
+ }
+ foreach ( $test as $section ) {
+ if ( isset( $section['deleted'] ) ) {
+ continue;
+ }
+ $this->result .= $section['headingLine'] . "\n";
+ $this->result .= $section['contents'];
+ }
+ }
+
+ protected function emitHooks( $heading, $contents ) {
+ $this->result .= $contents;
+ }
+}
diff --git a/www/wiki/tests/parser/TestFileReader.php b/www/wiki/tests/parser/TestFileReader.php
new file mode 100644
index 00000000..a96485d4
--- /dev/null
+++ b/www/wiki/tests/parser/TestFileReader.php
@@ -0,0 +1,335 @@
+<?php
+/**
+ * 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
+ */
+
+class TestFileReader {
+ private $file;
+ private $fh;
+ private $section = null;
+ /** String|null: current test section being analyzed */
+ private $sectionData = [];
+ private $sectionLineNum = [];
+ private $lineNum = 0;
+ private $runDisabled;
+ private $runParsoid;
+ private $regex;
+
+ private $articles = [];
+ private $requirements = [];
+ private $tests = [];
+
+ public static function read( $file, array $options = [] ) {
+ $reader = new self( $file, $options );
+ $reader->execute();
+
+ $requirements = [];
+ foreach ( $reader->requirements as $type => $reqsOfType ) {
+ foreach ( $reqsOfType as $name => $unused ) {
+ $requirements[] = [
+ 'type' => $type,
+ 'name' => $name
+ ];
+ }
+ }
+
+ return [
+ 'requirements' => $requirements,
+ 'tests' => $reader->tests,
+ 'articles' => $reader->articles
+ ];
+ }
+
+ private function __construct( $file, $options ) {
+ $this->file = $file;
+ $this->fh = fopen( $this->file, "rt" );
+
+ if ( !$this->fh ) {
+ throw new MWException( "Couldn't open file '$file'\n" );
+ }
+
+ $options = $options + [
+ 'runDisabled' => false,
+ 'runParsoid' => false,
+ 'regex' => '//',
+ ];
+ $this->runDisabled = $options['runDisabled'];
+ $this->runParsoid = $options['runParsoid'];
+ $this->regex = $options['regex'];
+ }
+
+ private function addCurrentTest() {
+ // "input" and "result" are old section names allowed
+ // for backwards-compatibility.
+ $input = $this->checkSection( [ 'wikitext', 'input' ], false );
+ $nonTidySection = $this->checkSection(
+ [ 'html/php', 'html/*', 'html', 'result' ], false );
+ // Some tests have "with tidy" and "without tidy" variants
+ $tidySection = $this->checkSection( [ 'html/php+tidy', 'html+tidy' ], false );
+
+ // Remove trailing newline
+ $data = array_map( 'ParserTestRunner::chomp', $this->sectionData );
+
+ // Apply defaults
+ $data += [
+ 'options' => '',
+ 'config' => ''
+ ];
+
+ if ( $input === false ) {
+ throw new MWException( "Test at {$this->file}:{$this->sectionLineNum['test']} " .
+ "lacks input section" );
+ }
+
+ if ( preg_match( '/\\bdisabled\\b/i', $data['options'] ) && !$this->runDisabled ) {
+ // Disabled
+ return;
+ }
+
+ if ( $tidySection === false && $nonTidySection === false ) {
+ if ( isset( $data['html/parsoid'] ) || isset( $data['wikitext/edited'] ) ) {
+ // Parsoid only
+ return;
+ } else {
+ throw new MWException( "Test at {$this->file}:{$this->sectionLineNum['test']} " .
+ "lacks result section" );
+ }
+ }
+
+ if ( preg_match( '/\\bparsoid\\b/i', $data['options'] ) && $nonTidySection === 'html'
+ && !$this->runParsoid
+ ) {
+ // A test which normally runs on Parsoid but can optionally be run with MW
+ return;
+ }
+
+ if ( !preg_match( $this->regex, $data['test'] ) ) {
+ // Filtered test
+ return;
+ }
+
+ $commonInfo = [
+ 'test' => $data['test'],
+ 'desc' => $data['test'],
+ 'input' => $data[$input],
+ 'options' => $data['options'],
+ 'config' => $data['config'],
+ 'line' => $this->sectionLineNum['test'],
+ 'file' => $this->file
+ ];
+
+ if ( $nonTidySection !== false ) {
+ // Add non-tidy test
+ $this->tests[] = [
+ 'result' => $data[$nonTidySection],
+ 'resultSection' => $nonTidySection
+ ] + $commonInfo;
+
+ if ( $tidySection !== false ) {
+ // Add tidy subtest
+ $this->tests[] = [
+ 'desc' => $data['test'] . ' (with tidy)',
+ 'result' => $data[$tidySection],
+ 'resultSection' => $tidySection,
+ 'options' => $data['options'] . ' tidy',
+ 'isSubtest' => true,
+ ] + $commonInfo;
+ }
+ } elseif ( $tidySection !== false ) {
+ // No need to override desc when there is no subtest
+ $this->tests[] = [
+ 'result' => $data[$tidySection],
+ 'resultSection' => $tidySection,
+ 'options' => $data['options'] . ' tidy'
+ ] + $commonInfo;
+ } else {
+ throw new MWException( "Test at {$this->file}:{$this->sectionLineNum['test']} " .
+ "lacks result section" );
+ }
+ }
+
+ private function execute() {
+ while ( false !== ( $line = fgets( $this->fh ) ) ) {
+ $this->lineNum++;
+ $matches = [];
+
+ if ( preg_match( '/^!!\s*(\S+)/', $line, $matches ) ) {
+ $this->section = strtolower( $matches[1] );
+
+ if ( $this->section == 'endarticle' ) {
+ $this->checkSection( 'text' );
+ $this->checkSection( 'article' );
+
+ $this->addArticle(
+ ParserTestRunner::chomp( $this->sectionData['article'] ),
+ $this->sectionData['text'], $this->lineNum );
+
+ $this->clearSection();
+
+ continue;
+ }
+
+ if ( $this->section == 'endhooks' ) {
+ $this->checkSection( 'hooks' );
+
+ foreach ( explode( "\n", $this->sectionData['hooks'] ) as $line ) {
+ $line = trim( $line );
+
+ if ( $line ) {
+ $this->addRequirement( 'hook', $line );
+ }
+ }
+
+ $this->clearSection();
+
+ continue;
+ }
+
+ if ( $this->section == 'endfunctionhooks' ) {
+ $this->checkSection( 'functionhooks' );
+
+ foreach ( explode( "\n", $this->sectionData['functionhooks'] ) as $line ) {
+ $line = trim( $line );
+
+ if ( $line ) {
+ $this->addRequirement( 'functionHook', $line );
+ }
+ }
+
+ $this->clearSection();
+
+ continue;
+ }
+
+ if ( $this->section == 'endtransparenthooks' ) {
+ $this->checkSection( 'transparenthooks' );
+
+ foreach ( explode( "\n", $this->sectionData['transparenthooks'] ) as $line ) {
+ $line = trim( $line );
+
+ if ( $line ) {
+ $this->addRequirement( 'transparentHook', $line );
+ }
+ }
+
+ $this->clearSection();
+
+ continue;
+ }
+
+ if ( $this->section == 'end' ) {
+ $this->checkSection( 'test' );
+ $this->addCurrentTest();
+ $this->clearSection();
+ continue;
+ }
+
+ if ( isset( $this->sectionData[$this->section] ) ) {
+ throw new MWException( "duplicate section '$this->section' "
+ . "at line {$this->lineNum} of $this->file\n" );
+ }
+
+ $this->sectionLineNum[$this->section] = $this->lineNum;
+ $this->sectionData[$this->section] = '';
+
+ continue;
+ }
+
+ if ( $this->section ) {
+ $this->sectionData[$this->section] .= $line;
+ }
+ }
+ }
+
+ /**
+ * Clear section name and its data
+ */
+ private function clearSection() {
+ $this->sectionLineNum = [];
+ $this->sectionData = [];
+ $this->section = null;
+ }
+
+ /**
+ * Verify the current section data has some value for the given token
+ * name(s) (first parameter).
+ * Throw an exception if it is not set, referencing current section
+ * and adding the current file name and line number
+ *
+ * @param string|array $tokens Expected token(s) that should have been
+ * mentioned before closing this section
+ * @param bool $fatal True iff an exception should be thrown if
+ * the section is not found.
+ * @return bool|string
+ * @throws MWException
+ */
+ private function checkSection( $tokens, $fatal = true ) {
+ if ( is_null( $this->section ) ) {
+ throw new MWException( __METHOD__ . " can not verify a null section!\n" );
+ }
+ if ( !is_array( $tokens ) ) {
+ $tokens = [ $tokens ];
+ }
+ if ( count( $tokens ) == 0 ) {
+ throw new MWException( __METHOD__ . " can not verify zero sections!\n" );
+ }
+
+ $data = $this->sectionData;
+ $tokens = array_filter( $tokens, function ( $token ) use ( $data ) {
+ return isset( $data[$token] );
+ } );
+
+ if ( count( $tokens ) == 0 ) {
+ if ( !$fatal ) {
+ return false;
+ }
+ throw new MWException( sprintf(
+ "'%s' without '%s' at line %s of %s\n",
+ $this->section,
+ implode( ',', $tokens ),
+ $this->lineNum,
+ $this->file
+ ) );
+ }
+ if ( count( $tokens ) > 1 ) {
+ throw new MWException( sprintf(
+ "'%s' with unexpected tokens '%s' at line %s of %s\n",
+ $this->section,
+ implode( ',', $tokens ),
+ $this->lineNum,
+ $this->file
+ ) );
+ }
+
+ return array_values( $tokens )[0];
+ }
+
+ private function addArticle( $name, $text, $line ) {
+ $this->articles[] = [
+ 'name' => $name,
+ 'text' => $text,
+ 'line' => $line,
+ 'file' => $this->file
+ ];
+ }
+
+ private function addRequirement( $type, $name ) {
+ $this->requirements[$type][$name] = true;
+ }
+}
diff --git a/www/wiki/tests/parser/TestRecorder.php b/www/wiki/tests/parser/TestRecorder.php
new file mode 100644
index 00000000..2731c4c5
--- /dev/null
+++ b/www/wiki/tests/parser/TestRecorder.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * 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
+ */
+
+/**
+ * Interface to record parser test results.
+ *
+ * The TestRecorder is an class hierarchy to record the result of
+ * MediaWiki parser tests. One should call start() before running the
+ * full parser tests and end() once all the tests have been finished.
+ * After each test, you should use record() to keep track of your tests
+ * results. Finally, report() is used to generate a summary of your
+ * test run, one could dump it to the console for human consumption or
+ * register the result in a database for tracking purposes.
+ *
+ * @since 1.22
+ */
+class TestRecorder {
+
+ /**
+ * Called at beginning of the parser test run
+ */
+ public function start() {
+ }
+
+ /**
+ * Called before starting a test
+ */
+ public function startTest( $test ) {
+ }
+
+ /**
+ * Called before starting an input file
+ */
+ public function startSuite( $path ) {
+ }
+
+ /**
+ * Called after ending an input file
+ */
+ public function endSuite( $path ) {
+ }
+
+ /**
+ * Called after each test
+ * @param array $test
+ * @param ParserTestResult $result
+ */
+ public function record( $test, ParserTestResult $result ) {
+ }
+
+ /**
+ * Show a warning to the user
+ */
+ public function warning( $message ) {
+ }
+
+ /**
+ * Mark a test skipped
+ */
+ public function skipped( $test, $subtest ) {
+ }
+
+ /**
+ * Called before finishing the test run
+ */
+ public function report() {
+ }
+
+ /**
+ * Called at the end of the parser test run
+ */
+ public function end() {
+ }
+
+}
diff --git a/www/wiki/tests/parser/TidySupport.php b/www/wiki/tests/parser/TidySupport.php
new file mode 100644
index 00000000..559960de
--- /dev/null
+++ b/www/wiki/tests/parser/TidySupport.php
@@ -0,0 +1,77 @@
+<?php
+
+/**
+ * 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
+ */
+
+/**
+ * Initialize and detect the tidy support
+ */
+class TidySupport {
+ private $enabled;
+ private $config;
+
+ /**
+ * Determine if there is a usable tidy.
+ * @param bool $useConfiguration
+ */
+ public function __construct( $useConfiguration = false ) {
+ global $wgUseTidy, $wgTidyBin, $wgTidyInternal, $wgTidyConfig,
+ $wgTidyConf, $wgTidyOpts;
+
+ $this->enabled = true;
+ if ( $useConfiguration ) {
+ if ( $wgTidyConfig !== null ) {
+ $this->config = $wgTidyConfig;
+ } elseif ( $wgUseTidy ) {
+ $this->config = [
+ 'tidyConfigFile' => $wgTidyConf,
+ 'debugComment' => false,
+ 'tidyBin' => $wgTidyBin,
+ 'tidyCommandLine' => $wgTidyOpts
+ ];
+ if ( $wgTidyInternal ) {
+ $this->config['driver'] = wfIsHHVM() ? 'RaggettInternalHHVM' : 'RaggettInternalPHP';
+ } else {
+ $this->config['driver'] = 'RaggettExternal';
+ }
+ } else {
+ $this->enabled = false;
+ }
+ } else {
+ $this->config = [ 'driver' => 'RemexHtml' ];
+ }
+ if ( !$this->enabled ) {
+ $this->config = [ 'driver' => 'disabled' ];
+ }
+ }
+
+ /**
+ * Returns true if tidy is usable
+ *
+ * @return bool
+ */
+ public function isEnabled() {
+ return $this->enabled;
+ }
+
+ public function getConfig() {
+ return $this->config;
+ }
+}
diff --git a/www/wiki/tests/parser/editTests.php b/www/wiki/tests/parser/editTests.php
new file mode 100644
index 00000000..3e18370a
--- /dev/null
+++ b/www/wiki/tests/parser/editTests.php
@@ -0,0 +1,488 @@
+<?php
+
+require __DIR__.'/../../maintenance/Maintenance.php';
+
+define( 'MW_PARSER_TEST', true );
+
+/**
+ * Interactive parser test runner and test file editor
+ */
+class ParserEditTests extends Maintenance {
+ private $termWidth;
+ private $testFiles;
+ private $testCount;
+ private $recorder;
+ private $runner;
+ private $numExecuted;
+ private $numSkipped;
+ private $numFailed;
+
+ function __construct() {
+ parent::__construct();
+ $this->addOption( 'session-data', 'internal option, do not use', false, true );
+ $this->addOption( 'use-tidy-config',
+ 'Use the wiki\'s Tidy configuration instead of known-good' .
+ 'defaults.' );
+ }
+
+ public function finalSetup() {
+ parent::finalSetup();
+ self::requireTestsAutoloader();
+ TestSetup::applyInitialConfig();
+ }
+
+ public function execute() {
+ $this->termWidth = $this->getTermSize()[0] - 1;
+
+ $this->recorder = new TestRecorder();
+ $this->setupFileData();
+
+ if ( $this->hasOption( 'session-data' ) ) {
+ $this->session = json_decode( $this->getOption( 'session-data' ), true );
+ } else {
+ $this->session = [ 'options' => [] ];
+ }
+ if ( $this->hasOption( 'use-tidy-config' ) ) {
+ $this->session['options']['use-tidy-config'] = true;
+ }
+ $this->runner = new ParserTestRunner( $this->recorder, $this->session['options'] );
+
+ $this->runTests();
+
+ if ( $this->numFailed === 0 ) {
+ if ( $this->numSkipped === 0 ) {
+ print "All tests passed!\n";
+ } else {
+ print "All tests passed (but skipped {$this->numSkipped})\n";
+ }
+ return;
+ }
+ print "{$this->numFailed} test(s) failed.\n";
+ $this->showResults();
+ }
+
+ protected function setupFileData() {
+ $this->testFiles = [];
+ $this->testCount = 0;
+ foreach ( ParserTestRunner::getParserTestFiles() as $file ) {
+ $fileInfo = TestFileReader::read( $file );
+ $this->testFiles[$file] = $fileInfo;
+ $this->testCount += count( $fileInfo['tests'] );
+ }
+ }
+
+ protected function runTests() {
+ $teardown = $this->runner->staticSetup();
+ $teardown = $this->runner->setupDatabase( $teardown );
+ $teardown = $this->runner->setupUploads( $teardown );
+
+ print "Running tests...\n";
+ $this->results = [];
+ $this->numExecuted = 0;
+ $this->numSkipped = 0;
+ $this->numFailed = 0;
+ foreach ( $this->testFiles as $fileName => $fileInfo ) {
+ $this->runner->addArticles( $fileInfo['articles'] );
+ foreach ( $fileInfo['tests'] as $testInfo ) {
+ $result = $this->runner->runTest( $testInfo );
+ if ( $result === false ) {
+ $this->numSkipped++;
+ } elseif ( !$result->isSuccess() ) {
+ $this->results[$fileName][$testInfo['desc']] = $result;
+ $this->numFailed++;
+ }
+ $this->numExecuted++;
+ $this->showProgress();
+ }
+ }
+ print "\n";
+ }
+
+ protected function showProgress() {
+ $done = $this->numExecuted;
+ $total = $this->testCount;
+ $width = $this->termWidth - 9;
+ $pos = round( $width * $done / $total );
+ printf( '│' . str_repeat( '█', $pos ) . str_repeat( '-', $width - $pos ) .
+ "│ %5.1f%%\r", $done / $total * 100 );
+ }
+
+ protected function showResults() {
+ if ( isset( $this->session['startFile'] ) ) {
+ $startFile = $this->session['startFile'];
+ $startTest = $this->session['startTest'];
+ $foundStart = false;
+ } else {
+ $startFile = false;
+ $startTest = false;
+ $foundStart = true;
+ }
+
+ $testIndex = 0;
+ foreach ( $this->testFiles as $fileName => $fileInfo ) {
+ if ( !isset( $this->results[$fileName] ) ) {
+ continue;
+ }
+ if ( !$foundStart && $startFile !== false && $fileName !== $startFile ) {
+ $testIndex += count( $this->results[$fileName] );
+ continue;
+ }
+ foreach ( $fileInfo['tests'] as $testInfo ) {
+ if ( !isset( $this->results[$fileName][$testInfo['desc']] ) ) {
+ continue;
+ }
+ $result = $this->results[$fileName][$testInfo['desc']];
+ $testIndex++;
+ if ( !$foundStart && $startTest !== false ) {
+ if ( $testInfo['desc'] !== $startTest ) {
+ continue;
+ }
+ $foundStart = true;
+ }
+
+ $this->handleFailure( $testIndex, $testInfo, $result );
+ }
+ }
+
+ if ( !$foundStart ) {
+ print "Could not find the test after a restart, did you rename it?";
+ unset( $this->session['startFile'] );
+ unset( $this->session['startTest'] );
+ $this->showResults();
+ }
+ print "All done\n";
+ }
+
+ protected function heading( $text ) {
+ $term = new AnsiTermColorer;
+ $heading = "─── $text ";
+ $heading .= str_repeat( '─', $this->termWidth - mb_strlen( $heading ) );
+ $heading = $term->color( 34 ) . $heading . $term->reset() . "\n";
+ return $heading;
+ }
+
+ protected function unifiedDiff( $left, $right ) {
+ $fromLines = explode( "\n", $left );
+ $toLines = explode( "\n", $right );
+ $formatter = new UnifiedDiffFormatter;
+ return $formatter->format( new Diff( $fromLines, $toLines ) );
+ }
+
+ protected function handleFailure( $index, $testInfo, $result ) {
+ $term = new AnsiTermColorer;
+ $div1 = $term->color( 34 ) . str_repeat( '━', $this->termWidth ) .
+ $term->reset() . "\n";
+ $div2 = $term->color( 34 ) . str_repeat( '─', $this->termWidth ) .
+ $term->reset() . "\n";
+
+ print $div1;
+ print "Failure $index/{$this->numFailed}: {$testInfo['file']} line {$testInfo['line']}\n" .
+ "{$testInfo['desc']}\n";
+
+ print $this->heading( 'Input' );
+ print "{$testInfo['input']}\n";
+
+ print $this->heading( 'Alternating expected/actual output' );
+ print $this->alternatingAligned( $result->expected, $result->actual );
+
+ print $this->heading( 'Diff' );
+
+ $dwdiff = $this->dwdiff( $result->expected, $result->actual );
+ if ( $dwdiff !== false ) {
+ $diff = $dwdiff;
+ } else {
+ $diff = $this->unifiedDiff( $result->expected, $result->actual );
+ }
+ print $diff;
+
+ if ( $testInfo['options'] || $testInfo['config'] ) {
+ print $this->heading( 'Options / Config' );
+ if ( $testInfo['options'] ) {
+ print $testInfo['options'] . "\n";
+ }
+ if ( $testInfo['config'] ) {
+ print $testInfo['config'] . "\n";
+ }
+ }
+
+ print $div2;
+ print "What do you want to do?\n";
+ $specs = [
+ '[R]eload code and run again',
+ '[U]pdate source file, copy actual to expected',
+ '[I]gnore' ];
+
+ if ( strpos( $testInfo['options'], ' tidy' ) === false ) {
+ if ( empty( $testInfo['isSubtest'] ) ) {
+ $specs[] = "Enable [T]idy";
+ }
+ } else {
+ $specs[] = 'Disable [T]idy';
+ }
+
+ if ( !empty( $testInfo['isSubtest'] ) ) {
+ $specs[] = 'Delete [s]ubtest';
+ }
+ $specs[] = '[D]elete test';
+ $specs[] = '[Q]uit';
+
+ $options = [];
+ foreach ( $specs as $spec ) {
+ if ( !preg_match( '/^(.*\[)(.)(\].*)$/', $spec, $m ) ) {
+ throw new MWException( 'Invalid option spec: ' . $spec );
+ }
+ print '* ' . $m[1] . $term->color( 35 ) . $m[2] . $term->color( 0 ) . $m[3] . "\n";
+ $options[strtoupper( $m[2] )] = true;
+ }
+
+ do {
+ $response = $this->readconsole();
+ $cmdResult = false;
+ if ( $response === false ) {
+ exit( 0 );
+ }
+
+ $response = strtoupper( trim( $response ) );
+ if ( !isset( $options[$response] ) ) {
+ print "Invalid response, please enter a single letter from the list above\n";
+ continue;
+ }
+
+ switch ( strtoupper( trim( $response ) ) ) {
+ case 'R':
+ $cmdResult = $this->reload( $testInfo );
+ break;
+ case 'U':
+ $cmdResult = $this->update( $testInfo, $result );
+ break;
+ case 'I':
+ return;
+ case 'T':
+ $cmdResult = $this->switchTidy( $testInfo );
+ break;
+ case 'S':
+ $cmdResult = $this->deleteSubtest( $testInfo );
+ break;
+ case 'D':
+ $cmdResult = $this->deleteTest( $testInfo );
+ break;
+ case 'Q':
+ exit( 0 );
+ }
+ } while ( !$cmdResult );
+ }
+
+ protected function dwdiff( $expected, $actual ) {
+ if ( !is_executable( '/usr/bin/dwdiff' ) ) {
+ return false;
+ }
+
+ $markers = [
+ "\n" => '¶',
+ ' ' => '·',
+ "\t" => '→'
+ ];
+ $markedExpected = strtr( $expected, $markers );
+ $markedActual = strtr( $actual, $markers );
+ $diff = $this->unifiedDiff( $markedExpected, $markedActual );
+
+ $tempFile = tmpfile();
+ fwrite( $tempFile, $diff );
+ fseek( $tempFile, 0 );
+ $pipes = [];
+ $proc = proc_open( '/usr/bin/dwdiff -Pc --diff-input',
+ [ 0 => $tempFile, 1 => [ 'pipe', 'w' ], 2 => STDERR ],
+ $pipes );
+
+ if ( !$proc ) {
+ return false;
+ }
+
+ $result = stream_get_contents( $pipes[1] );
+ proc_close( $proc );
+ fclose( $tempFile );
+ return $result;
+ }
+
+ protected function alternatingAligned( $expectedStr, $actualStr ) {
+ $expectedLines = explode( "\n", $expectedStr );
+ $actualLines = explode( "\n", $actualStr );
+ $maxLines = max( count( $expectedLines ), count( $actualLines ) );
+ $result = '';
+ for ( $i = 0; $i < $maxLines; $i++ ) {
+ if ( $i < count( $expectedLines ) ) {
+ $expectedLine = $expectedLines[$i];
+ $expectedChunks = str_split( $expectedLine, $this->termWidth - 3 );
+ } else {
+ $expectedChunks = [];
+ }
+
+ if ( $i < count( $actualLines ) ) {
+ $actualLine = $actualLines[$i];
+ $actualChunks = str_split( $actualLine, $this->termWidth - 3 );
+ } else {
+ $actualChunks = [];
+ }
+
+ $maxChunks = max( count( $expectedChunks ), count( $actualChunks ) );
+
+ for ( $j = 0; $j < $maxChunks; $j++ ) {
+ if ( isset( $expectedChunks[$j] ) ) {
+ $result .= "E: " . $expectedChunks[$j];
+ if ( $j === count( $expectedChunks ) - 1 ) {
+ $result .= "¶";
+ }
+ $result .= "\n";
+ } else {
+ $result .= "E:\n";
+ }
+ $result .= "\33[4m" . // underline
+ "A: ";
+ if ( isset( $actualChunks[$j] ) ) {
+ $result .= $actualChunks[$j];
+ if ( $j === count( $actualChunks ) - 1 ) {
+ $result .= "¶";
+ }
+ }
+ $result .= "\33[0m\n"; // reset
+ }
+ }
+ return $result;
+ }
+
+ protected function reload( $testInfo ) {
+ global $argv;
+ pcntl_exec( PHP_BINARY, [
+ $argv[0],
+ '--session-data',
+ json_encode( [
+ 'startFile' => $testInfo['file'],
+ 'startTest' => $testInfo['desc']
+ ] + $this->session ) ] );
+
+ print "pcntl_exec() failed\n";
+ return false;
+ }
+
+ protected function findTest( $file, $testInfo ) {
+ $initialPart = '';
+ for ( $i = 1; $i < $testInfo['line']; $i++ ) {
+ $line = fgets( $file );
+ if ( $line === false ) {
+ print "Error reading from file\n";
+ return false;
+ }
+ $initialPart .= $line;
+ }
+
+ $line = fgets( $file );
+ if ( !preg_match( '/^!!\s*test/', $line ) ) {
+ print "Test has moved, cannot edit\n";
+ return false;
+ }
+
+ $testPart = $line;
+
+ $desc = fgets( $file );
+ if ( trim( $desc ) !== $testInfo['desc'] ) {
+ print "Description does not match, cannot edit\n";
+ return false;
+ }
+ $testPart .= $desc;
+ return [ $initialPart, $testPart ];
+ }
+
+ protected function getOutputFileName( $inputFileName ) {
+ if ( is_writable( $inputFileName ) ) {
+ $outputFileName = $inputFileName;
+ } else {
+ $outputFileName = wfTempDir() . '/' . basename( $inputFileName );
+ print "Cannot write to input file, writing to $outputFileName instead\n";
+ }
+ return $outputFileName;
+ }
+
+ protected function editTest( $fileName, $deletions, $changes ) {
+ $text = file_get_contents( $fileName );
+ if ( $text === false ) {
+ print "Unable to open test file!";
+ return false;
+ }
+ $result = TestFileEditor::edit( $text, $deletions, $changes,
+ function ( $msg ) {
+ print "$msg\n";
+ }
+ );
+ if ( is_writable( $fileName ) ) {
+ file_put_contents( $fileName, $result );
+ print "Wrote updated file\n";
+ } else {
+ print "Cannot write updated file, here is a patch you can paste:\n\n";
+ print "--- {$fileName}\n" .
+ "+++ {$fileName}~\n" .
+ $this->unifiedDiff( $text, $result ) .
+ "\n";
+ }
+ }
+
+ protected function update( $testInfo, $result ) {
+ $this->editTest( $testInfo['file'],
+ [], // deletions
+ [ // changes
+ $testInfo['test'] => [
+ $testInfo['resultSection'] => [
+ 'op' => 'update',
+ 'value' => $result->actual . "\n"
+ ]
+ ]
+ ]
+ );
+ }
+
+ protected function deleteTest( $testInfo ) {
+ $this->editTest( $testInfo['file'],
+ [ $testInfo['test'] ], // deletions
+ [] // changes
+ );
+ }
+
+ protected function switchTidy( $testInfo ) {
+ $resultSection = $testInfo['resultSection'];
+ if ( in_array( $resultSection, [ 'html/php', 'html/*', 'html', 'result' ] ) ) {
+ $newSection = 'html+tidy';
+ } elseif ( in_array( $resultSection, [ 'html/php+tidy', 'html+tidy' ] ) ) {
+ $newSection = 'html';
+ } else {
+ print "Unrecognised result section name \"$resultSection\"";
+ return;
+ }
+
+ $this->editTest( $testInfo['file'],
+ [], // deletions
+ [ // changes
+ $testInfo['test'] => [
+ $resultSection => [
+ 'op' => 'rename',
+ 'value' => $newSection
+ ]
+ ]
+ ]
+ );
+ }
+
+ protected function deleteSubtest( $testInfo ) {
+ $this->editTest( $testInfo['file'],
+ [], // deletions
+ [ // changes
+ $testInfo['test'] => [
+ $testInfo['resultSection'] => [
+ 'op' => 'delete'
+ ]
+ ]
+ ]
+ );
+ }
+}
+
+$maintClass = 'ParserEditTests';
+require RUN_MAINTENANCE_IF_MAIN;
diff --git a/www/wiki/tests/parser/extraParserTests.txt b/www/wiki/tests/parser/extraParserTests.txt
new file mode 100644
index 00000000..50d1bc9e
--- /dev/null
+++ b/www/wiki/tests/parser/extraParserTests.txt
Binary files differ
diff --git a/www/wiki/tests/parser/fuzzTest.php b/www/wiki/tests/parser/fuzzTest.php
new file mode 100644
index 00000000..eb4181c7
--- /dev/null
+++ b/www/wiki/tests/parser/fuzzTest.php
@@ -0,0 +1,202 @@
+<?php
+
+use Wikimedia\ScopedCallback;
+
+require __DIR__ . '/../../maintenance/Maintenance.php';
+
+// Make RequestContext::resetMain() happy
+define( 'MW_PARSER_TEST', 1 );
+
+class ParserFuzzTest extends Maintenance {
+ private $parserTest;
+ private $maxFuzzTestLength = 300;
+ private $memoryLimit = 100;
+ private $seed;
+
+ function __construct() {
+ parent::__construct();
+ $this->addDescription( 'Run a fuzz test on the parser, until it segfaults ' .
+ 'or throws an exception' );
+ $this->addOption( 'file', 'Use the specified file as a dictionary, ' .
+ ' or leave blank to use parserTests.txt', false, true, true );
+
+ $this->addOption( 'seed', 'Start the fuzz test from the specified seed', false, true );
+ }
+
+ function finalSetup() {
+ self::requireTestsAutoloader();
+ TestSetup::applyInitialConfig();
+ }
+
+ function execute() {
+ $files = $this->getOption( 'file', [ __DIR__ . '/parserTests.txt' ] );
+ $this->seed = intval( $this->getOption( 'seed', 1 ) ) - 1;
+ $this->parserTest = new ParserTestRunner(
+ new MultiTestRecorder,
+ [] );
+ $this->fuzzTest( $files );
+ }
+
+ /**
+ * Run a fuzz test series
+ * Draw input from a set of test files
+ * @param array $filenames
+ */
+ function fuzzTest( $filenames ) {
+ $dict = $this->getFuzzInput( $filenames );
+ $dictSize = strlen( $dict );
+ $logMaxLength = log( $this->maxFuzzTestLength );
+
+ $teardown = $this->parserTest->staticSetup();
+ $teardown = $this->parserTest->setupDatabase( $teardown );
+ $teardown = $this->parserTest->setupUploads( $teardown );
+
+ $fakeTest = [
+ 'test' => '',
+ 'desc' => '',
+ 'input' => '',
+ 'result' => '',
+ 'options' => '',
+ 'config' => ''
+ ];
+
+ ini_set( 'memory_limit', $this->memoryLimit * 1048576 * 2 );
+
+ $numTotal = 0;
+ $numSuccess = 0;
+ $user = new User;
+ $opts = ParserOptions::newFromUser( $user );
+ $title = Title::makeTitle( NS_MAIN, 'Parser_test' );
+
+ while ( true ) {
+ // Generate test input
+ mt_srand( ++$this->seed );
+ $totalLength = mt_rand( 1, $this->maxFuzzTestLength );
+ $input = '';
+
+ while ( strlen( $input ) < $totalLength ) {
+ $logHairLength = mt_rand( 0, 1000000 ) / 1000000 * $logMaxLength;
+ $hairLength = min( intval( exp( $logHairLength ) ), $dictSize );
+ $offset = mt_rand( 0, $dictSize - $hairLength );
+ $input .= substr( $dict, $offset, $hairLength );
+ }
+
+ $perTestTeardown = $this->parserTest->perTestSetup( $fakeTest );
+ $parser = $this->parserTest->getParser();
+
+ // Run the test
+ try {
+ $parser->parse( $input, $title, $opts );
+ $fail = false;
+ } catch ( Exception $exception ) {
+ $fail = true;
+ }
+
+ if ( $fail ) {
+ echo "Test failed with seed {$this->seed}\n";
+ echo "Input:\n";
+ printf( "string(%d) \"%s\"\n\n", strlen( $input ), $input );
+ echo "$exception\n";
+ } else {
+ $numSuccess++;
+ }
+
+ $numTotal++;
+ ScopedCallback::consume( $perTestTeardown );
+
+ if ( $numTotal % 100 == 0 ) {
+ $usage = intval( memory_get_usage( true ) / $this->memoryLimit / 1048576 * 100 );
+ echo "{$this->seed}: $numSuccess/$numTotal (mem: $usage%)\n";
+ if ( $usage >= 100 ) {
+ echo "Out of memory:\n";
+ $memStats = $this->getMemoryBreakdown();
+
+ foreach ( $memStats as $name => $usage ) {
+ echo "$name: $usage\n";
+ }
+ if ( function_exists( 'hphpd_break' ) ) {
+ hphpd_break();
+ }
+ return;
+ }
+ }
+ }
+ }
+
+ /**
+ * Get a memory usage breakdown
+ * @return array
+ */
+ function getMemoryBreakdown() {
+ $memStats = [];
+
+ foreach ( $GLOBALS as $name => $value ) {
+ $memStats['$' . $name] = $this->guessVarSize( $value );
+ }
+
+ $classes = get_declared_classes();
+
+ foreach ( $classes as $class ) {
+ $rc = new ReflectionClass( $class );
+ $props = $rc->getStaticProperties();
+ $memStats[$class] = $this->guessVarSize( $props );
+ $methods = $rc->getMethods();
+
+ foreach ( $methods as $method ) {
+ $memStats[$class] += $this->guessVarSize( $method->getStaticVariables() );
+ }
+ }
+
+ $functions = get_defined_functions();
+
+ foreach ( $functions['user'] as $function ) {
+ $rf = new ReflectionFunction( $function );
+ $memStats["$function()"] = $this->guessVarSize( $rf->getStaticVariables() );
+ }
+
+ asort( $memStats );
+
+ return $memStats;
+ }
+
+ /**
+ * Estimate the size of the input variable
+ */
+ function guessVarSize( $var ) {
+ $length = 0;
+ try {
+ Wikimedia\suppressWarnings();
+ $length = strlen( serialize( $var ) );
+ Wikimedia\restoreWarnings();
+ } catch ( Exception $e ) {
+ }
+ return $length;
+ }
+
+ /**
+ * Get an input dictionary from a set of parser test files
+ * @param array $filenames
+ * @return string
+ */
+ function getFuzzInput( $filenames ) {
+ $dict = '';
+
+ foreach ( $filenames as $filename ) {
+ $contents = file_get_contents( $filename );
+ preg_match_all(
+ '/!!\s*(input|wikitext)\n(.*?)\n!!\s*(result|html|html\/\*|html\/php)/s',
+ $contents,
+ $matches
+ );
+
+ foreach ( $matches[1] as $match ) {
+ $dict .= $match . "\n";
+ }
+ }
+
+ return $dict;
+ }
+}
+
+$maintClass = 'ParserFuzzTest';
+require RUN_MAINTENANCE_IF_MAIN;
diff --git a/www/wiki/tests/parser/parserTests.php b/www/wiki/tests/parser/parserTests.php
new file mode 100644
index 00000000..6a423d5c
--- /dev/null
+++ b/www/wiki/tests/parser/parserTests.php
@@ -0,0 +1,199 @@
+<?php
+/**
+ * MediaWiki parser test suite
+ *
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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
+ */
+
+// Some methods which are discouraged for normal code throw exceptions unless
+// we declare this is just a test.
+define( 'MW_PARSER_TEST', true );
+
+require __DIR__ . '/../../maintenance/Maintenance.php';
+
+use MediaWiki\MediaWikiServices;
+
+class ParserTestsMaintenance extends Maintenance {
+ function __construct() {
+ parent::__construct();
+ $this->addDescription( 'Run parser tests' );
+
+ $this->addOption( 'quick', 'Suppress diff output of failed tests' );
+ $this->addOption( 'quiet', 'Suppress notification of passed tests (shows only failed tests)' );
+ $this->addOption( 'show-output', 'Show expected and actual output' );
+ $this->addOption( 'color', '[=yes|no] Override terminal detection and force ' .
+ 'color output on or off. Use wgCommandLineDarkBg = true; if your term is dark',
+ false, true );
+ $this->addOption( 'regex', 'Only run tests whose descriptions which match given regex',
+ false, true );
+ $this->addOption( 'filter', 'Alias for --regex', false, true );
+ $this->addOption( 'file', 'Run test cases from a custom file instead of parserTests.txt',
+ false, true, false, true );
+ $this->addOption( 'record', 'Record tests in database' );
+ $this->addOption( 'compare', 'Compare with recorded results, without updating the database.' );
+ $this->addOption( 'setversion', 'When using --record, set the version string to use (useful' .
+ 'with "git rev-parse HEAD" to get the exact revision)',
+ false, true );
+ $this->addOption( 'keep-uploads', 'Re-use the same upload directory for each ' .
+ 'test, don\'t delete it' );
+ $this->addOption( 'file-backend', 'Use the file backend with the given name,' .
+ 'and upload files to it, instead of creating a mock file backend.', false, true );
+ $this->addOption( 'upload-dir', 'Specify the upload directory to use. Useful in ' .
+ 'conjunction with --keep-uploads. Causes a real (non-mock) file backend to ' .
+ 'be used.', false, true );
+ $this->addOption( 'run-disabled', 'run disabled tests' );
+ $this->addOption( 'run-parsoid', 'run parsoid tests (normally disabled)' );
+ $this->addOption( 'dwdiff', 'Use dwdiff to display diff output' );
+ $this->addOption( 'mark-ws', 'Mark whitespace in diffs by replacing it with symbols' );
+ $this->addOption( 'norm', 'Apply a comma-separated list of normalization functions to ' .
+ 'both the expected and actual output in order to resolve ' .
+ 'irrelevant differences. The accepted normalization functions ' .
+ 'are: removeTbody to remove <tbody> tags; and trimWhitespace ' .
+ 'to trim whitespace from the start and end of text nodes.',
+ false, true );
+ $this->addOption( 'use-tidy-config',
+ 'Use the wiki\'s Tidy configuration instead of known-good' .
+ 'defaults.' );
+ }
+
+ public function finalSetup() {
+ parent::finalSetup();
+ self::requireTestsAutoloader();
+ TestSetup::applyInitialConfig();
+ }
+
+ public function execute() {
+ global $wgDBtype;
+
+ // Cases of weird db corruption were encountered when running tests on earlyish
+ // versions of SQLite
+ if ( $wgDBtype == 'sqlite' ) {
+ $db = wfGetDB( DB_MASTER );
+ $version = $db->getServerVersion();
+ if ( version_compare( $version, '3.6' ) < 0 ) {
+ die( "Parser tests require SQLite version 3.6 or later, you have $version\n" );
+ }
+ }
+
+ // Print out software version to assist with locating regressions
+ $version = SpecialVersion::getVersion( 'nodb' );
+ echo "This is MediaWiki version {$version}.\n\n";
+
+ // Only colorize output if stdout is a terminal.
+ $color = !wfIsWindows() && Maintenance::posix_isatty( 1 );
+
+ if ( $this->hasOption( 'color' ) ) {
+ switch ( $this->getOption( 'color' ) ) {
+ case 'no':
+ $color = false;
+ break;
+ case 'yes':
+ default:
+ $color = true;
+ break;
+ }
+ }
+
+ $record = $this->hasOption( 'record' );
+ $compare = $this->hasOption( 'compare' );
+
+ $regex = $this->getOption( 'filter', $this->getOption( 'regex', false ) );
+ if ( $regex !== false ) {
+ $regex = "/$regex/i";
+
+ if ( $record ) {
+ echo "Warning: --record cannot be used with --regex, disabling --record\n";
+ $record = false;
+ }
+ }
+
+ $term = $color
+ ? new AnsiTermColorer()
+ : new DummyTermColorer();
+
+ $recorder = new MultiTestRecorder;
+
+ $recorder->addRecorder( new ParserTestPrinter(
+ $term,
+ [
+ 'showDiffs' => !$this->hasOption( 'quick' ),
+ 'showProgress' => !$this->hasOption( 'quiet' ),
+ 'showFailure' => !$this->hasOption( 'quiet' )
+ || ( !$record && !$compare ), // redundant output
+ 'showOutput' => $this->hasOption( 'show-output' ),
+ 'useDwdiff' => $this->hasOption( 'dwdiff' ),
+ 'markWhitespace' => $this->hasOption( 'mark-ws' ),
+ ]
+ ) );
+
+ $recorderLB = false;
+ if ( $record || $compare ) {
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $recorderLB = $lbFactory->newMainLB();
+ // This connection will have the wiki's table prefix, not parsertest_
+ $recorderDB = $recorderLB->getConnection( DB_MASTER );
+
+ // Add recorder before previewer because recorder will create the
+ // DB table if it doesn't exist
+ if ( $record ) {
+ $recorder->addRecorder( new DbTestRecorder( $recorderDB ) );
+ }
+ $recorder->addRecorder( new DbTestPreviewer(
+ $recorderDB,
+ function ( $name ) use ( $regex ) {
+ // Filter reports of old tests by the filter regex
+ if ( $regex === false ) {
+ return true;
+ } else {
+ return (bool)preg_match( $regex, $name );
+ }
+ } ) );
+ }
+
+ // Default parser tests and any set from extensions or local config
+ $files = $this->getOption( 'file', ParserTestRunner::getParserTestFiles() );
+
+ $norm = $this->hasOption( 'norm' ) ? explode( ',', $this->getOption( 'norm' ) ) : [];
+
+ $tester = new ParserTestRunner( $recorder, [
+ 'norm' => $norm,
+ 'regex' => $regex,
+ 'keep-uploads' => $this->hasOption( 'keep-uploads' ),
+ 'run-disabled' => $this->hasOption( 'run-disabled' ),
+ 'run-parsoid' => $this->hasOption( 'run-parsoid' ),
+ 'use-tidy-config' => $this->hasOption( 'use-tidy-config' ),
+ 'file-backend' => $this->getOption( 'file-backend' ),
+ 'upload-dir' => $this->getOption( 'upload-dir' ),
+ ] );
+
+ $ok = $tester->runTestsFromFiles( $files );
+ if ( $recorderLB ) {
+ $recorderLB->closeAll();
+ }
+ if ( !$ok ) {
+ exit( 1 );
+ }
+ }
+}
+
+$maintClass = 'ParserTestsMaintenance';
+require_once RUN_MAINTENANCE_IF_MAIN;
diff --git a/www/wiki/tests/parser/parserTests.txt b/www/wiki/tests/parser/parserTests.txt
new file mode 100644
index 00000000..451c50f5
--- /dev/null
+++ b/www/wiki/tests/parser/parserTests.txt
@@ -0,0 +1,30829 @@
+# MediaWiki Parser test cases
+# Some taken from https://meta.wikimedia.org/wiki/Parser_testing
+# All (C) their respective authors and released under the GPL
+#
+# The syntax should be fairly self-explanatory.
+#
+# Currently supported test options:
+# One of the following three:
+#
+# (default) generate HTML output
+# pst apply pre-save transform
+# msg apply message transform
+#
+# Plus any combination of these:
+#
+# cat add category links
+# (ignored by Parsoid, since it emits <link>s)
+# ill add inter-language links
+# (ignored by Parsoid, since it emits <link>s)
+# subpage enable subpages (disabled by default)
+# title=[[XXX]] run test using article title XXX
+# language=XXX set content language to XXX for this test
+# variant=XXX set the variant of language for this test (eg zh-tw)
+# disabled do not run test
+# parsoid parsoid-specific options (not run by PHP parser unless
+# the test includes an html/php section)
+# php php-only test (not run by the parsoid parser unless
+# the test includes an html/parsoid section)
+# showtitle make the first line the title
+# showindicators make the first lines the page status indicators
+# comment run through Linker::formatComment() instead of main parser
+# local format section links in edit comment text as local links
+# notoc disable table of contents
+# thumbsize=NNN set the default thumb size to NNNpx for this test
+# wrap include the normal wrapper <div class="mw-parser-output"> (since 1.30)
+#
+# You can also set the following parser properties via test options:
+# wgEnableUploads, wgAllowExternalImages, wgMaxTocLevel,
+# wgLinkHolderBatchSize, wgRawHtml, wgInterwikiMagic,
+# wgEnableMagicLinks
+#
+# For testing purposes, temporary articles can created:
+# !!article / NAMESPACE:TITLE / !!text / ARTICLE TEXT / !!endarticle
+# where '/' denotes a newline.
+
+# This is the standard article assumed to exist.
+!! article
+Main Page
+!! text
+blah blah
+!! endarticle
+
+!!article
+Foo
+!!text
+FOO
+!!endarticle
+
+!!article
+Template:Foo
+!!text
+FOO
+!!endarticle
+
+!! article
+Template:Blank
+!! text
+!! endarticle
+
+!! article
+Template:pipe
+!! text
+|
+!! endarticle
+
+!! article
+Template:=
+!! text
+<nowiki>=</nowiki>
+!! endarticle
+
+!!article
+MediaWiki:bad image list
+!!text
+* [[File:Bad.jpg]] except [[Nasty page]]
+!!endarticle
+
+!! article
+Template:inner list
+!! text
+* item 1
+!! endarticle
+
+!! article
+Template:tbl-start
+!! text
+{|
+!! endarticle
+
+!! article
+Template:tbl-end
+!! text
+|}
+!! endarticle
+
+!! article
+Template:echo
+!! text
+{{{1}}}
+!! endarticle
+
+// For Serbian; localize Template namespace
+!! article
+Шаблон:Echo
+!! text
+{{{1}}}
+!! endarticle
+
+!! article
+Template:echo_with_span
+!! text
+<span>{{{1}}}</span>
+!! endarticle
+
+!! article
+Template:echo_with_div
+!! text
+<div>{{{1}}}</div>
+!! endarticle
+
+!! article
+Template:blank_param
+!! text
+{{{1}}}
+{{{}}}
+!! endarticle
+
+!! article
+Template:table_attribs
+!! text
+<noinclude>
+|</noinclude>style="color:red;"|Foo
+!! endarticle
+
+!! article
+Template:table_attribs_2
+!! text
+<noinclude>
+|</noinclude>style="color:red;"|Foo
+|Bar||Baz
+!! endarticle
+
+!! article
+Template:table_attribs_3
+!! text
+<noinclude>
+|</noinclude>style{{=}}"background:&#35;f9f9f9;"|Foo
+!! endarticle
+
+!! article
+Template:table_attribs_4
+!! text
+| style="background-color:#DC241f;" width="10px" |
+!! endarticle
+
+!! article
+Template:table_attribs_5
+!! text
+<noinclude>
+|</noinclude>style="color:red;"||Bar
+!! endarticle
+
+!! article
+Template:table_attribs_6
+!! text
+style="background: <nowiki>
+
+
+red;</nowiki>" |
+!! endarticle
+
+!! article
+Template:table_attribs_7
+!! text
+<noinclude>
+|</noinclude>style{{=}}"background:&#35;f9f9f9;"|Foo<ref>foo</ref>
+!! endarticle
+
+!! article
+Template:table_header_cells
+!! text
+{{table_attribs}}!!style='color:red;'|''Bar''||style='color:brown;'|''Foo'' and Baz
+!! endarticle
+
+!! article
+Template:table_cells
+!! text
+{{table_attribs}}||style='color:red;'|''Bar''||style='color:brown;'|''Foo'' and Baz
+!! endarticle
+
+!! article
+Template:PartialTable
+!! text
+{|
+|-
+!! endarticle
+
+!! article
+Template:image_attribs
+!! text
+<noinclude>
+[[File:foobar.jpg|</noinclude>right|Caption text<noinclude>]]</noinclude>
+!! endarticle
+
+## See T48811 for details
+!! article
+Template:mixed_attr_content_template
+!! text
+style="color:red;" title="T48811"
+|-
+|foo
+!! endarticle
+
+!! article
+Template:definition_list
+!! text
+one
+::two
+!! endarticle
+
+!! article
+A?b
+!! text
+Weirdo titles!
+!! endarticle
+
+!!article
+Template:Bullet
+!!text
+* Bar
+!!endarticle
+
+!!article
+Template:OpenTable
+!!text
+{|
+!!endarticle
+
+!!article
+Template:EmptyLITest
+!!text
+*a
+*
+*
+*b
+!!endarticle
+
+!!article
+Template:EmptyTRTest
+!!text
+{|
+|-
+|-
+|foo
+|-
+|-
+|bar
+|}
+!!endarticle
+
+!!article
+Template:EmptyTRWithHTMLAttrTest
+!!text
+<table>
+<tr align="center"></tr>
+<tr><td>foo</td></tr>
+<tr align="center"></tr>
+<tr><td>bar</td></tr>
+</table>
+!!endarticle
+
+!! article
+Template:With: Colon
+!! text
+Template with colon
+!! endarticle
+
+###
+### Basic tests
+###
+
+!! test
+Blank input
+!! wikitext
+!! html
+!! end
+
+!! test
+Simple paragraph
+!! wikitext
+This is a simple paragraph.
+!! html
+<p>This is a simple paragraph.
+</p>
+!! end
+
+!! test
+Paragraphs with extra newline spacing
+!! wikitext
+foo
+
+bar
+
+
+baz
+
+
+
+booz
+!! html
+<p>foo
+</p><p>bar
+</p><p><br />
+baz
+</p><p><br />
+</p><p>booz
+</p>
+!! end
+
+!! test
+Paragraphs with newline spacing with comment lines in between
+!! wikitext
+----
+a
+<!--foo-->
+b
+----
+a
+<!--foo--><!--More than 1 comment, still stripped-->
+b
+----
+a
+ <!--foo--> <!----> <!-- bar -->
+b
+----
+a
+<!--foo-->
+
+b
+----
+a
+
+<!--foo-->
+b
+----
+a
+<!--foo-->
+
+
+b
+----
+a
+
+
+<!--foo-->
+b
+----
+!! html
+<hr />
+<p>a
+b
+</p>
+<hr />
+<p>a
+b
+</p>
+<hr />
+<p>a
+b
+</p>
+<hr />
+<p>a
+</p><p>b
+</p>
+<hr />
+<p>a
+</p><p>b
+</p>
+<hr />
+<p>a
+</p><p><br />
+b
+</p>
+<hr />
+<p>a
+</p><p><br />
+b
+</p>
+<hr />
+
+!! end
+
+!! test
+Paragraphs with newline spacing with non-empty white-space lines in between
+!! wikitext
+----
+a
+
+b
+----
+a
+
+
+b
+----
+!! html
+<hr />
+<p>a
+</p><p>b
+</p>
+<hr />
+<p>a
+</p><p><br />
+b
+</p>
+<hr />
+
+!! end
+
+!! test
+Paragraphs with newline spacing with non-empty mixed comment and white-space lines in between
+!! wikitext
+----
+a
+ <!--foo-->
+b
+----
+a
+ <!--foo--><!--More than 1 comment doesn't disable stripping of this line!-->
+b
+----
+a
+
+<!--foo-->
+ <!--bar-->
+b
+----
+a
+
+ <!--foo-->
+ <!--bar-->
+
+b
+----
+!! html
+<hr />
+<p>a
+b
+</p>
+<hr />
+<p>a
+b
+</p>
+<hr />
+<p>a
+</p><p>b
+</p>
+<hr />
+<p>a
+</p><p><br />
+b
+</p>
+<hr />
+
+!! end
+
+!! test
+Extra newlines: More paragraphs with indented comment
+!! wikitext
+a
+
+ <!--boo-->
+
+b
+!! html
+<p>a
+</p><p><br />
+b
+</p>
+!!end
+
+!! test
+Extra newlines followed by heading
+!! wikitext
+a
+
+
+
+=b=
+[[a]]
+
+
+=b=
+!! html
+<p>a
+</p><p><br />
+</p>
+<h1><span class="mw-headline" id="b">b</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: b">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<p><a href="/index.php?title=A&amp;action=edit&amp;redlink=1" class="new" title="A (page does not exist)">a</a>
+</p><p><br />
+</p>
+<h1><span class="mw-headline" id="b_2">b</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: b">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+
+!! end
+
+!! test
+Extra newlines between heading and content are swallowed
+!! wikitext
+=b=
+
+
+
+[[a]]
+!! html
+<h1><span class="mw-headline" id="b">b</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: b">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<p><a href="/index.php?title=A&amp;action=edit&amp;redlink=1" class="new" title="A (page does not exist)">a</a>
+</p>
+!! end
+
+!! test
+Heading with line break in nowiki
+!! options
+parsoid=wt2html
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+==A <nowiki>B
+C</nowiki>==
+!! html/php
+<h2><span id="A_B.0AC"></span><span class="mw-headline" id="A_B
+C">A B
+C</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: A B&#10;C">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<h2 id="A_B
+C"><span id="A_B.0AC" typeof="mw:FallbackId"></span>A <span typeof="mw:Nowiki">B
+C</span></h2>
+!! end
+
+!! test
+Parsing an URL
+!! wikitext
+http://fr.wikipedia.org/wiki/🍺
+<!-- EasterEgg we love beer, better be able be able to link to it -->
+!! html
+<p><a rel="nofollow" class="external free" href="http://fr.wikipedia.org/wiki/🍺">http://fr.wikipedia.org/wiki/🍺</a>
+</p>
+!! end
+
+!! test
+Simple list
+!! wikitext
+*Item 1
+*Item 2
+!! html
+<ul><li>Item 1</li>
+<li>Item 2</li></ul>
+
+!! end
+
+!! test
+Italics and bold
+!! wikitext
+*plain
+*plain''italic''plain
+*plain''italic''plain''italic''plain
+*plain'''bold'''plain
+*plain'''bold'''plain'''bold'''plain
+*plain''italic''plain'''bold'''plain
+*plain'''bold'''plain''italic''plain
+*plain''italic'''bold-italic'''italic''plain
+*plain'''bold''bold-italic''bold'''plain
+*plain'''''bold-italic'''italic''plain
+*plain'''''bold-italic''bold'''plain
+*plain''italic'''bold-italic'''''plain
+*plain'''bold''bold-italic'''''plain
+*plain l'''italic''plain
+*plain l''''bold''' plain
+!! html
+<ul><li>plain</li>
+<li>plain<i>italic</i>plain</li>
+<li>plain<i>italic</i>plain<i>italic</i>plain</li>
+<li>plain<b>bold</b>plain</li>
+<li>plain<b>bold</b>plain<b>bold</b>plain</li>
+<li>plain<i>italic</i>plain<b>bold</b>plain</li>
+<li>plain<b>bold</b>plain<i>italic</i>plain</li>
+<li>plain<i>italic<b>bold-italic</b>italic</i>plain</li>
+<li>plain<b>bold<i>bold-italic</i>bold</b>plain</li>
+<li>plain<i><b>bold-italic</b>italic</i>plain</li>
+<li>plain<b><i>bold-italic</i>bold</b>plain</li>
+<li>plain<i>italic<b>bold-italic</b></i>plain</li>
+<li>plain<b>bold<i>bold-italic</i></b>plain</li>
+<li>plain l'<i>italic</i>plain</li>
+<li>plain l'<b>bold</b> plain</li></ul>
+
+!! end
+
+# this example taken from the [[simple:Moon]] article (T49326)
+!! test
+Italics and possessives (1)
+!! wikitext
+obtained by ''[[Lunar Prospector]]'''s gamma-ray spectrometer
+!! html
+<p>obtained by <i><a href="/index.php?title=Lunar_Prospector&amp;action=edit&amp;redlink=1" class="new" title="Lunar Prospector (page does not exist)">Lunar Prospector</a>'</i>s gamma-ray spectrometer
+</p>
+!! end
+
+# this example taken from [[en:Flaming Pie]] (T51926)
+!! test
+Italics and possessives (2)
+!! wikitext
+'''''Flaming Pie''''' is ... released in 1997. In ''Flaming Pie'''s liner notes
+!! html
+<p><i><b>Flaming Pie</b></i> is ... released in 1997. In <i>Flaming Pie'</i>s liner notes
+</p>
+!! end
+
+# this example taken from [[en:Dictionary]] (T51926)
+!! test
+Italics and possessives (3)
+!! wikitext
+The first monolingual dictionary written in a Romance language was ''Sebastián Covarrubias''' ''Tesoro de la lengua castellana o española'', published in 1611 in Madrid. In 1612 the first edition of the ''Vocabolario dell'[[Accademia della Crusca]]'', for Italian, was published. In 1690 in Rotterdam was published, posthumously, the ''Dictionnaire Universel''.
+!! html
+<p>The first monolingual dictionary written in a Romance language was <i>Sebastián Covarrubias'</i> <i>Tesoro de la lengua castellana o española</i>, published in 1611 in Madrid. In 1612 the first edition of the <i>Vocabolario dell'<a href="/index.php?title=Accademia_della_Crusca&amp;action=edit&amp;redlink=1" class="new" title="Accademia della Crusca (page does not exist)">Accademia della Crusca</a></i>, for Italian, was published. In 1690 in Rotterdam was published, posthumously, the <i>Dictionnaire Universel</i>.
+</p>
+!! end
+
+
+###
+### 2-quote opening sequence tests
+###
+!! test
+Italics and bold: 2-quote opening sequence: (2,2)
+!! wikitext
+''foo''
+!! html
+<p><i>foo</i>
+</p>
+!!end
+
+!! test
+Italics and bold: 2-quote opening sequence: (2,3)
+!! wikitext
+''foo'''
+!! html/*
+<p><i>foo'</i>
+</p>
+!!end
+
+!! test
+Italics and bold: 2-quote opening sequence: (2,4)
+!! options
+parsoid=wt2html
+!! wikitext
+''foo''''
+!! html/*
+<p><i>foo''</i>
+</p>
+!!end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: 2-quote opening sequence: (2,4) w/ nowiki
+!! wikitext
+''foo<nowiki>''</nowiki>''
+!! html
+<p><i>foo''</i>
+</p>
+!! end
+
+# The PHP parser strips the empty tags out for giggles; parsoid doesn't.
+!! test
+Italics and bold: 2-quote opening sequence: (2,5)
+!! options
+parsoid=wt2html
+!! wikitext
+''foo'''''
+!! html/php
+<p><i>foo</i>
+</p>
+!! html/parsoid
+<p><i>foo</i><b></b>
+</p>
+!!end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: 2-quote opening sequence: (2,5+3) w/ nowiki
+!! wikitext
+''foo'''''<nowiki/>'''
+!! html/php
+<p><i>foo</i>
+</p>
+!! html/parsoid
+<p><i>foo</i><b></b>
+</p>
+!! end
+
+
+###
+### 3-quote opening sequence tests
+###
+
+!! test
+Italics and bold: 3-quote opening sequence: (3,2)
+!! wikitext
+'''foo''
+!! html/*
+<p>'<i>foo</i>
+</p>
+!!end
+
+!! test
+Italics and bold: 3-quote opening sequence: (3,3)
+!! wikitext
+'''foo'''
+!! html
+<p><b>foo</b>
+</p>
+!!end
+
+!! test
+Italics and bold: 3-quote opening sequence: (3,4)
+!! wikitext
+'''foo''''
+!! html/*
+<p><b>foo'</b>
+</p>
+!!end
+
+# The PHP parser strips the empty tags out for giggles; parsoid doesn't.
+!! test
+Italics and bold: 3-quote opening sequence: (3,5)
+!! options
+parsoid=wt2html
+!! wikitext
+'''foo'''''
+!! html/php
+<p><b>foo</b>
+</p>
+!! html/parsoid
+<p><b>foo</b><i></i>
+</p>
+!!end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: 3-quote opening sequence: (3,5+2) w/ nowiki
+!! wikitext
+'''foo'''''<nowiki/>''
+!! html/php
+<p><b>foo</b>
+</p>
+!! html/parsoid
+<p><b>foo</b><i></i>
+</p>
+!! end
+
+
+###
+### 4-quote opening sequence tests
+###
+
+!! test
+Italics and bold: 4-quote opening sequence: (4,2)
+!! options
+parsoid=wt2html
+!! wikitext
+''''foo''
+!! html/*
+<p>''<i>foo</i>
+</p>
+!!end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: 4-quote opening sequence: (4,2) w/ nowiki
+!! wikitext
+<nowiki>''</nowiki>''foo''
+!! html
+<p>''<i>foo</i>
+</p>
+!! end
+
+!! test
+Italics and bold: 4-quote opening sequence: (4,3)
+!! wikitext
+''''foo'''
+!! html/*
+<p>'<b>foo</b>
+</p>
+!!end
+
+!! test
+Italics and bold: 4-quote opening sequence: (4,4)
+!! options
+parsoid=wt2html
+!! wikitext
+''''foo''''
+!! html/*
+<p>'<b>foo'</b>
+</p>
+!!end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: 4-quote opening sequence: (4,4) w/ nowiki
+!! wikitext
+'<nowiki/>'''foo''''
+!! html
+<p>'<b>foo'</b>
+</p>
+!! end
+
+# The PHP parser strips the empty tags out for giggles; parsoid doesn't.
+!! test
+Italics and bold: 4-quote opening sequence: (4,5)
+!! options
+parsoid=wt2html
+!! wikitext
+''''foo'''''
+!! html/php
+<p>'<b>foo</b>
+</p>
+!! html/parsoid
+<p>'<b>foo</b><i></i>
+</p>
+!!end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: 4-quote opening sequence: (4,5+2) w/ nowiki
+!! wikitext
+'<nowiki/>'''foo'''''<nowiki/>''
+!! html/php
+<p>'<b>foo</b>
+</p>
+!! html/parsoid
+<p>'<b>foo</b><i></i>
+</p>
+!! end
+
+
+###
+### 5-quote opening sequence tests
+###
+
+!! test
+Italics and bold: 5-quote opening sequence: (5,2)
+!! options
+parsoid=wt2html
+!! wikitext
+'''''foo''
+!! html/*
+<p><b><i>foo</i></b>
+</p>
+!!end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: 5-quote opening sequence: (5,2+3)
+!! wikitext
+'''''foo'''''
+!! html/*
+<p><i><b>foo</b></i>
+</p>
+!! end
+
+!! test
+Italics and bold: 5-quote opening sequence: (5,3)
+!! options
+parsoid=wt2html
+!! wikitext
+'''''foo'''
+!! html/*
+<p><i><b>foo</b></i>
+</p>
+!!end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: 5-quote opening sequence: (5,3+2)
+!! wikitext
+'''''foo'''''
+!! html
+<p><i><b>foo</b></i>
+</p>
+!! end
+
+!! test
+Italics and bold: 5-quote opening sequence: (5,4)
+!! options
+parsoid=wt2html
+!! wikitext
+'''''foo''''
+!! html/*
+<p><i><b>foo'</b></i>
+</p>
+!!end
+
+!! test
+Italics and bold: 5-quote opening sequence: (5,5)
+!! wikitext
+'''''foo'''''
+!! html
+<p><i><b>foo</b></i>
+</p>
+!!end
+
+!! test
+Italics and bold: 5-quote opening sequence: (5,6)
+!! wikitext
+'''''foo''''''
+!! html/*
+<p><i><b>foo'</b></i>
+</p>
+!! end
+
+###
+### multiple quote sequences in a line
+###
+
+!! test
+Italics and bold: multiple quote sequences: (2,4,2)
+!! options
+parsoid=wt2html
+!! wikitext
+''foo''''bar''
+!! html/*
+<p><i>foo'<b>bar</b></i>
+</p>
+!! end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: multiple quote sequences: (2,4,2+3) w/ nowiki
+!! wikitext
+''foo'<nowiki/>'''bar'''''
+!! html
+<p><i>foo'<b>bar</b></i>
+</p>
+!! end
+
+!! test
+Italics and bold: multiple quote sequences: (2,4,3)
+!! options
+parsoid=wt2html
+!! wikitext
+''foo''''bar'''
+!! html/*
+<p><i>foo'<b>bar</b></i>
+</p>
+!! end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: multiple quote sequences: (2,4,3+2) w/ nowiki
+!! wikitext
+''foo'<nowiki/>'''bar'''''
+!! html
+<p><i>foo'<b>bar</b></i>
+</p>
+!! end
+
+!! test
+Italics and bold: multiple quote sequences: (2,4,4)
+!! options
+parsoid=wt2html
+!! wikitext
+''foo''''bar''''
+!! html/*
+<p><i>foo'<b>bar'</b></i>
+</p>
+!! end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: multiple quote sequences: (2,4,4+2) w/ nowiki
+!! wikitext
+''foo'<nowiki/>'''bar'<nowiki/>'''''
+!! html
+<p><i>foo'<b>bar'</b></i>
+</p>
+!! end
+
+# The PHP parser strips the empty tags out for giggles; parsoid doesn't.
+!! test
+Italics and bold: multiple quote sequences: (3,4,2)
+!! options
+parsoid=wt2html
+!! wikitext
+'''foo''''bar''
+!! html/php
+<p><b>foo'</b>bar
+</p>
+!! html/parsoid
+<p><b>foo'</b>bar<i></i>
+</p>
+!! end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: multiple quote sequences: (3,4,2+2) w/ nowiki
+!! wikitext
+'''foo''''bar''<nowiki/>''
+!! html/php
+<p><b>foo'</b>bar
+</p>
+!! html/parsoid
+<p><b>foo'</b>bar<i></i>
+</p>
+!! end
+
+# The PHP parser strips the empty tags out for giggles; parsoid doesn't.
+!! test
+Italics and bold: multiple quote sequences: (3,4,3)
+!! options
+parsoid=wt2html
+!! wikitext
+'''foo''''bar'''
+!! html/php
+<p><b>foo'</b>bar
+</p>
+!! html/parsoid
+<p><b>foo'</b>bar<b></b>
+</p>
+!! end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: multiple quote sequences: (3,4,3+3) w/ nowiki
+!! wikitext
+'''foo''''bar'''<nowiki/>'''
+!! html/php
+<p><b>foo'</b>bar
+</p>
+!! html/parsoid
+<p><b>foo'</b>bar<b></b>
+</p>
+!! end
+
+###
+### other quote tests
+###
+!! test
+Italics and bold: other quote tests: (2,3,5)
+!! wikitext
+''this is about '''foo's family'''''
+!! html
+<p><i>this is about <b>foo's family</b></i>
+</p>
+!!end
+
+
+!! test
+Italics and bold: other quote tests: (2,(3,3),2)
+!! wikitext
+''this is about '''foo's''' family''
+!! html
+<p><i>this is about <b>foo's</b> family</i>
+</p>
+!!end
+
+
+!! test
+Italics and bold: other quote tests: (3,2,3,2)
+!! options
+parsoid=wt2html
+!! wikitext
+'''this is about ''foo'''s family''
+!! html/*
+<p><b>this is about <i>foo</i></b><i>s family</i>
+</p>
+!!end
+
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+!! test
+Italics and bold: other quote tests: (3,2,3+2+2,2)
+!! wikitext
+'''this is about ''foo'''''<nowiki/>''s family''
+!! html
+<p><b>this is about <i>foo</i></b><i>s family</i>
+</p>
+!! end
+
+
+!! test
+Italics and bold: other quote tests: (3,2,3,3)
+!! wikitext
+'''this is about ''foo'''s family'''
+!! html/*
+<p>'<i>this is about </i>foo<b>s family</b>
+</p>
+!!end
+
+
+!! test
+Italics and bold: other quote tests: (3,(2,2),3)
+!! wikitext
+'''this is about ''foo's'' family'''
+!! html
+<p><b>this is about <i>foo's</i> family</b>
+</p>
+!!end
+
+
+!! test
+Italicized possessive
+!! wikitext
+The ''[[Main Page]]'''s talk page.
+!! html/php
+<p>The <i><a href="/wiki/Main_Page" title="Main Page">Main Page</a>'</i>s talk page.
+</p>
+!! html/parsoid
+<p>The <i><a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a>'</i>s talk page.</p>
+!! end
+
+!! test
+Quote balancing context should be restricted to td/th cells on the same wikitext line
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+{|
+!''a!!''b
+|''a||''b
+|}
+!! html+tidy
+<table>
+<tbody><tr>
+<th><i>a</i></th>
+<th><i>b</i>
+</th>
+<td><i>a</i></td>
+<td><i>b</i>
+</td></tr></tbody></table>
+!! end
+
+###
+### Non-html5 tags
+###
+
+!! test
+Non-html5 tags should be accepted
+!! wikitext
+<center>''foo''</center>
+<big>''foo''</big>
+<font>''foo''</font>
+<strike>''foo''</strike>
+<tt>''foo''</tt>
+!! html
+<center><i>foo</i></center>
+<p><big><i>foo</i></big>
+<font><i>foo</i></font>
+<strike><i>foo</i></strike>
+<tt><i>foo</i></tt>
+</p>
+!! end
+
+!! test
+<wbr> is valid wikitext (T54468)
+!! wikitext
+<wbr>
+!! html
+<p><wbr />
+</p>
+!! end
+
+# <strike> is HTML4, <s> is HTML4/5.
+!! test
+<s> or <strike> for strikethrough
+!! wikitext
+<strike>strike</strike>
+
+<s>s</s>
+!! html
+<p><strike>strike</strike>
+</p><p><s>s</s>
+</p>
+!! end
+
+## a not permitted
+## i,b,br omitted
+!! test
+Text-level semantic html elements in wikitext
+!! wikitext
+<em>text</em>
+<strong>text</strong>
+<small>text</small>
+<s>text</s>
+<cite>text</cite>
+<q>text</q>
+<dfn>text</dfn>
+<abbr>text</abbr>
+<data>text</data>
+<time>text</time>
+<code>text</code>
+<var>text</var>
+<samp>text</samp>
+<kbd>text</kbd>
+<sub>text</sub>
+<u>text</u>
+<mark>text</mark>
+<ruby><rb>明日</rb><rp>(</rp><rt>Ashita</rt><rp> </rp><rtc>あした</rtc><rp>)</rp></ruby>
+<bdi>text</bdi>
+<bdo>text</bdo>
+<span>text</span>
+<wbr />
+!! html
+<p><em>text</em>
+<strong>text</strong>
+<small>text</small>
+<s>text</s>
+<cite>text</cite>
+<q>text</q>
+<dfn>text</dfn>
+<abbr>text</abbr>
+<data>text</data>
+<time>text</time>
+<code>text</code>
+<var>text</var>
+<samp>text</samp>
+<kbd>text</kbd>
+<sub>text</sub>
+<u>text</u>
+<mark>text</mark>
+<ruby><rb>明日</rb><rp>(</rp><rt>Ashita</rt><rp> </rp><rtc>あした</rtc><rp>)</rp></ruby>
+<bdi>text</bdi>
+<bdo>text</bdo>
+<span>text</span>
+<wbr />
+</p>
+!! end
+
+# test cases taken from
+# https://www.w3.org/TR/html5/text-level-semantics.html#the-ruby-element
+!! test
+Ruby markup (W3C-style)
+!! wikitext
+;Mono-ruby for individual base characters
+:<ruby>日<rt>に</rt>本<rt>ほん</rt>語<rt>ご</rt></ruby>
+;Group ruby
+:<ruby>今日<rt>きょう</rt></ruby>
+;Jukugo ruby
+:<ruby>法<rb>華</rb><rb>経</rb><rt>ほ</rt><rt>け</rt><rt>きょう</rt></ruby>
+;Inline ruby
+:<ruby>東<rb>京</rb><rp>(</rp><rt>とう</rt><rt>きょう</rt><rp>)</rp></ruby>
+;Double-sided ruby
+:<ruby><rb>旧</rb><rb>金</rb><rb>山</rb><rt>jiù</rt><rt>jīn</rt><rt>shān</rt><rtc>San Francisco</rtc></ruby>
+
+<ruby>
+<rb>♥</rb><rtc><rt>Heart</rt></rtc><rtc lang="fr"><rt>Cœur</rt></rtc>
+<rb>☘</rb><rtc><rt>Shamrock</rt></rtc><rtc lang="fr"><rt>Trèfle</rt></rtc>
+<rb>✶</rb><rtc><rt>Star</rt></rtc><rtc lang="fr"><rt>Étoile</rt></rtc>
+</ruby>
+!! html
+<dl><dt>Mono-ruby for individual base characters</dt>
+<dd><ruby>日<rt>に</rt>本<rt>ほん</rt>語<rt>ご</rt></ruby></dd>
+<dt>Group ruby</dt>
+<dd><ruby>今日<rt>きょう</rt></ruby></dd>
+<dt>Jukugo ruby</dt>
+<dd><ruby>法<rb>華</rb><rb>経</rb><rt>ほ</rt><rt>け</rt><rt>きょう</rt></ruby></dd>
+<dt>Inline ruby</dt>
+<dd><ruby>東<rb>京</rb><rp>(</rp><rt>とう</rt><rt>きょう</rt><rp>)</rp></ruby></dd>
+<dt>Double-sided ruby</dt>
+<dd><ruby><rb>旧</rb><rb>金</rb><rb>山</rb><rt>jiù</rt><rt>jīn</rt><rt>shān</rt><rtc>San Francisco</rtc></ruby></dd></dl>
+<p><ruby>
+<rb>♥</rb><rtc><rt>Heart</rt></rtc><rtc lang="fr"><rt>Cœur</rt></rtc>
+<rb>☘</rb><rtc><rt>Shamrock</rt></rtc><rtc lang="fr"><rt>Trèfle</rt></rtc>
+<rb>✶</rb><rtc><rt>Star</rt></rtc><rtc lang="fr"><rt>Étoile</rt></rtc>
+</ruby>
+</p>
+!! end
+
+# The next two test different paths in the sanitizer.
+!! test
+Non-word characters don't terminate tag names (T19663, T42670, T54022)
+!! wikitext
+<blockquote|>a</blockquote>
+
+<b→> doesn't terminate </b→>
+
+<bä> doesn't terminate </bä>
+
+<boo> doesn't terminate </boo>
+
+<s.foo> doesn't terminate </s.foo>
+
+<sub-ID#1>
+!! html
+<p>&lt;blockquote|&gt;a&lt;/blockquote&gt;
+</p><p>&lt;b→&gt; doesn't terminate &lt;/b→&gt;
+</p><p>&lt;bä&gt; doesn't terminate &lt;/bä&gt;
+</p><p>&lt;boo&gt; doesn't terminate &lt;/boo&gt;
+</p><p>&lt;s.foo&gt; doesn't terminate &lt;/s.foo&gt;
+</p><p>&lt;sub-ID#1&gt;
+</p>
+!! end
+
+!! test
+Non-word characters don't terminate tag names
+!! wikitext
+<blockquote|>a</blockquote>
+
+<b→> doesn't terminate </b→>
+
+<bä> doesn't terminate </bä>
+
+<boo> doesn't terminate </boo>
+
+<s.foo> doesn't terminate </s.foo>
+
+<sub-ID#1>
+!! html+tidy
+<p>&lt;blockquote|&gt;a
+</p><p>&lt;b→&gt; doesn't terminate &lt;/b→&gt;
+</p><p>&lt;bä&gt; doesn't terminate &lt;/bä&gt;
+</p><p>&lt;boo&gt; doesn't terminate &lt;/boo&gt;
+</p><p>&lt;s.foo&gt; doesn't terminate &lt;/s.foo&gt;
+</p><p>&lt;sub-ID#1&gt;
+</p>
+!! end
+
+###
+### See tests/parser/parserTestsParserHook.php for the <tåg> extension)
+### This checks that HTML5 tags (with non-word characters in the tag
+### name) make it safely through the parser -- the Sanitizer will
+### munge them later, as it should.
+###
+!! test
+Non-word characters are valid in extension tags (T19663)
+!! wikitext
+<tåg>tåg</tåg>
+!! html/php
+<pre>
+'tåg'
+array (
+)
+</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/tåg" data-mw='{"name":"tåg","attrs":{},"body":{"extsrc":"tåg"}}' data-parsoid='{}' about="#mwt2"></pre>
+!! end
+
+!! test
+Isolated close tags should be treated as literal text (T54760)
+!! options
+parsoid=wt2html
+!! wikitext
+</b>
+
+<s.foo>s</s>
+!! html/php+tidy
+<p class="mw-empty-elt">
+</p><p>&lt;s.foo&gt;s
+</p>
+!! html/parsoid
+<p>&lt;s.foo&gt;s</p>
+!! end
+
+###
+### Special characters
+###
+
+!! test
+Bare pipe character (T54363)
+!! wikitext
+|
+!! html
+<p>|
+</p>
+!! end
+
+!! test
+Bare pipe character from a template (T54363)
+!! wikitext
+{{pipe}}
+!! html
+<p>|
+</p>
+!! end
+
+###
+### <nowiki> test cases
+###
+
+!! test
+<nowiki> unordered list
+!! wikitext
+<nowiki>* This is not an unordered list item.</nowiki>
+!! html/php
+<p>* This is not an unordered list item.
+</p>
+!! html/parsoid
+<p><span typeof="mw:Nowiki">* This is not an unordered list item.</span></p>
+!! end
+
+!! test
+<nowiki> spacing
+!! wikitext
+<nowiki>Lorem ipsum dolor
+
+sed abit.
+ sed nullum.
+
+:and a colon
+</nowiki>
+!! html/php
+<p>Lorem ipsum dolor
+
+sed abit.
+ sed nullum.
+
+:and a colon
+
+</p>
+!! html/parsoid
+<p><span typeof="mw:Nowiki">Lorem ipsum dolor
+
+sed abit.
+ sed nullum.
+
+:and a colon
+</span></p>
+!! end
+
+!! test
+Don't parse <nowiki><span class="error"></nowiki> (T149622)
+!! wikitext
+<nowiki><span class="error"></nowiki>
+!! html/php
+<p>&lt;span class="error"&gt;
+</p>
+!! html/parsoid
+<p><span typeof="mw:Nowiki">&lt;span class="error"></span></p>
+!! end
+
+!! test
+nowiki 3
+!! wikitext
+:There is not nowiki.
+:There is <nowiki>nowiki</nowiki>.
+
+#There is not nowiki.
+#There is <nowiki>nowiki</nowiki>.
+
+*There is not nowiki.
+*There is <nowiki>nowiki</nowiki>.
+!! html/php
+<dl><dd>There is not nowiki.</dd>
+<dd>There is nowiki.</dd></dl>
+<ol><li>There is not nowiki.</li>
+<li>There is nowiki.</li></ol>
+<ul><li>There is not nowiki.</li>
+<li>There is nowiki.</li></ul>
+
+!! html/parsoid
+<dl><dd data-parsoid='{}'>There is not nowiki.</dd>
+<dd data-parsoid='{}'>There is <span typeof="mw:Nowiki">nowiki</span>.</dd></dl>
+
+<ol><li data-parsoid='{}'>There is not nowiki.</li>
+<li data-parsoid='{}'>There is <span typeof="mw:Nowiki">nowiki</span>.</li></ol>
+
+<ul><li data-parsoid='{}'>There is not nowiki.</li>
+<li data-parsoid='{}'>There is <span typeof="mw:Nowiki">nowiki</span>.</li></ul>
+!! end
+
+!! test
+Entities inside <nowiki>
+!! wikitext
+<nowiki>&lt;</nowiki>
+!! html/php
+<p>&lt;
+</p>
+!! html/parsoid
+<p><span typeof="mw:Nowiki"><span typeof="mw:Entity" data-parsoid='{"src":"&amp;lt;","srcContent":"&lt;"}'>&lt;</span></span></p>
+!! end
+
+!! test
+Entities inside template parameters
+!! wikitext
+{{echo|&ndash;}}
+!! html/php+tidy
+<p>&#8211;
+</p>
+!! html/parsoid
+<p><span typeof="mw:Transclusion mw:Entity" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&amp;ndash;"}},"i":0}}]}'>&ndash;</span></p>
+!! end
+
+!! test
+Properly escape nowiki when combined with other wiki markup
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>* &lt;/nowiki&gt; tag</p>
+!! wikitext
+<nowiki>*</nowiki> <nowiki>&lt;/nowiki&gt;</nowiki> tag
+!! end
+
+!! test
+T93824: Put escaped HTML tags inside nowiki
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>&lt;h2&gt;foo&lt;/h2&gt;</p>
+!! wikitext
+<nowiki><h2>foo</h2></nowiki>
+!! end
+
+!! test
+T71950: 1. Put nowiki as close to cause as possible, even with non-quote escapable chars
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>This text: L'<a rel="mw:WikiLink" href="./Foo">Foo</a>
+This text: L''<a rel="mw:WikiLink" href="./Foo">Foo</a>
+This text: L'''<a rel="mw:WikiLink" href="./Foo">Foo</a>''</p>
+!! wikitext
+This text: L'[[Foo]]
+This text: L<nowiki>''</nowiki>[[Foo]]
+This text: L<nowiki>'''</nowiki>[[Foo]]<nowiki>''</nowiki>
+!! end
+
+# This test fails because wikitext whitespace is not normalized before comparing.
+!! test
+T71950: 2. Put nowiki as close to cause as possible, after ' :'
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>This text : L''<a rel="mw:WikiLink" href="./Foo">Foo</a>
+</p>
+!! wikitext
+This text : L<nowiki>''</nowiki>[[Foo]]
+!! end
+
+# This test and the next one are html2wt only as they test that incorrect wikitext
+# passed in template arguments gets escaped or wrapped in nowikis where required.
+!! test
+T71482: Use {{!}} instead of nowiki for single pipe in template argument
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><span typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;foo|bar&quot;}},&quot;i&quot;:0}}]}" about="#mwt1"></span>
+<span typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;foo|bar |[[&quot;}},&quot;i&quot;:0}}]}" about="#mwt2"></p>
+!! wikitext
+{{echo|foo{{!}}bar}}
+{{echo|<nowiki>foo|bar |[[</nowiki>}}
+!! end
+
+!! test
+T53961: Output correct nowikis in template arguments
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><span typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;a [ b&quot;}},&quot;i&quot;:0}}]}" about="#mwt1"></span>
+<span typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;a }} b&quot;}},&quot;i&quot;:0}}]}" about="#mwt2"></span>
+<span typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;a [[ b&quot;}},&quot;i&quot;:0}}]}" about="#mwt3"></span>
+<span typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;a | {{ ]]&quot;}},&quot;i&quot;:0}}]}" about="#mwt4"></span>
+<span typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;a }&quot;}},&quot;i&quot;:0}}]}" about="#mwt5"></span></p>
+!! wikitext
+{{echo|a [ b}}
+{{echo|<nowiki>a }} b</nowiki>}}
+{{echo|<nowiki>a [[ b</nowiki>}}
+{{echo|<nowiki>a | {{ ]]</nowiki>}}
+{{echo|a <nowiki>}</nowiki>}}
+!! end
+
+!! test
+Cases where "!!" needs nowiki protection
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table>
+<tr><th>this needs protection !! here</th></tr>
+</table>
+
+<table>
+<tr><th>this does not need
+protection !! here</th></tr>
+</table>
+!! wikitext
+{|
+!<nowiki>this needs protection !! here</nowiki>
+|}
+
+{|
+!this does not need
+protection !! here
+|}
+!! end
+
+###
+### Comments
+###
+!! test
+Comments and Indent-Pre
+!! wikitext
+<!-- comment 1 --> asdf
+
+<!-- comment 1 --> asdf
+<!-- comment 2 -->
+
+<!-- comment 1 --> asdf
+<!-- comment 2 -->xyz
+
+<!-- comment 1 --> asdf
+<!-- comment 2 --> xyz
+!! html
+<pre>asdf
+</pre>
+<pre>asdf
+</pre>
+<pre>asdf
+</pre>
+<p>xyz
+</p>
+<pre>asdf
+xyz
+</pre>
+!! end
+
+!! test
+Comment test 2a
+!! wikitext
+asdf
+<!-- comment 1 -->
+jkl
+!! html
+<p>asdf
+jkl
+</p>
+!! end
+
+!! test
+Comment test 2b
+!! wikitext
+asdf
+<!-- comment 1 -->
+
+jkl
+!! html
+<p>asdf
+</p><p>jkl
+</p>
+!! end
+
+!! test
+Comment test 3
+!! wikitext
+asdf
+<!-- comment 1 -->
+<!-- comment 2 -->
+jkl
+!! html
+<p>asdf
+jkl
+</p>
+!! end
+
+!! test
+Comment test 4
+!! wikitext
+asdf<!-- comment 1 -->jkl
+!! html
+<p>asdfjkl
+</p>
+!! end
+
+!! test
+Comment spacing
+!! wikitext
+a
+ <!-- foo --> b <!-- bar -->
+c
+!! html
+<p>a
+</p>
+<pre> b
+</pre>
+<p>c
+</p>
+!! end
+
+!! test
+Comment whitespace
+!! wikitext
+<!-- returns a single newline, not nothing, since the newline after > is not stripped -->
+!! html
+
+!! end
+
+!! test
+Comment semantics and delimiters
+!! wikitext
+<!-- --><!----><!-----><!------>
+!! html/php
+
+!! html/parsoid
+<!-- --><!----><!--&#x2D;--><!--&#x2D;&#x2D;-->
+!! end
+
+!! test
+Comment semantics and delimiters, redux
+!! wikitext
+<!-- In SGML every "foo" here would actually show up in the text -- foo -- bar
+-- foo -- funky huh? ... -->
+!! html/php
+
+!! html/parsoid
+<!-- In SGML every "foo" here would actually show up in the text &#x2D;&#x2D; foo &#x2D;&#x2D; bar
+&#x2D;&#x2D; foo &#x2D;&#x2D; funky huh? ... -->
+!! end
+
+!! test
+Comment semantics and delimiters: directors cut
+!! wikitext
+<!-- ... However we like to keep things simple and somewhat XML-ish so we eat
+everything starting with < followed by !-- until the first -- and > we see,
+that wouldn't be valid XML however, since in XML -- has to terminate a comment
+-->-->
+!! html/php
+<p>--&gt;
+</p>
+!! html/parsoid
+<!-- ... However we like to keep things simple and somewhat XML&#x2D;ish so we eat
+everything starting with < followed by !&#x2D;&#x2D; until the first &#x2D;&#x2D; and &#x3E; we see,
+that wouldn't be valid XML however, since in XML &#x2D;&#x2D; has to terminate a comment
+--><p>--></p>
+!! end
+
+!! test
+Comment semantics: nesting
+!! wikitext
+<!--<!-- no, we're not going to do anything fancy here -->-->
+!! html/php
+<p>--&gt;
+</p>
+!! html/parsoid
+<!--<!&#x2D;&#x2D; no, we're not going to do anything fancy here --><p>--></p>
+!! end
+
+# Parsoid closes the unclosed comment, even if it means a slight
+# round-trip diff.
+!! test
+Comment semantics: unclosed comment at end
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<!--This comment will run out to the end of the document
+!! html/php
+
+!! html/parsoid
+<!--This comment will run out to the end of the document-->
+!! end
+
+!! test
+Comment semantics: normalize comments to play nice with XML and browsers
+!! wikitext
+<!-- Browsers --!> think this is closed -->
+<!--> This would normally be text -->
+<!---> As would this -->
+<!-- XML doesn't like trailing dashes -------->
+<!-- Nor doubled hyphens -- anywhere in the data -->
+But this is not a comment.
+!! html/php
+<p>But this is not a comment.
+</p>
+!! html/parsoid
+<!-- Browsers &#x2D;&#x2D;!&#x3E; think this is closed -->
+<!--&#x3E; This would normally be text -->
+<!--&#x2D;&#x3E; As would this -->
+<!-- XML doesn't like trailing dashes &#x2D;&#x2D;&#x2D;&#x2D;&#x2D;&#x2D;-->
+<!-- Nor doubled hyphens &#x2D;&#x2D; anywhere in the data -->
+<p>But this is not a comment.</p>
+!! end
+
+!! test
+Comment semantics: round-trip even text which contains encoded -->
+!! wikitext
+<!-- hello & goodbye - > --&gt; --&amp;gt; --&xx -->
+!! html/parsoid
+<!-- hello &#x26; goodbye &#x2D; &#x3E; &#x2D;&#x2D;&#x3E; &#x2D;&#x2D;&#x26;gt; &#x2D;&#x2D;&#x26;xx -->
+!! end
+
+!! test
+Comment in template title
+!! wikitext
+{{f<!---->oo}}
+!! html
+<p>FOO
+</p>
+!! end
+
+!! test
+Comment on its own line post-expand
+!! wikitext
+a
+{{blank}}<!---->
+b
+!! html
+<p>a
+</p><p>b
+</p>
+!! end
+
+!! test
+Comment on its own line post-expand with non-significant whitespace
+!! wikitext
+a
+ {{blank}} <!---->
+b
+!! html
+<p>a
+</p><p>b
+</p>
+!! end
+
+!! test
+Multiple comments should still parse as SOL-transparent
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+<!--c1-->*a
+<!--c2--><!--c3--><!--c4-->*b
+!! html/php
+<ul><li>a</li>
+<li>b</li></ul>
+
+!! html/parsoid
+<!--c1--><ul>
+<li>a
+</li>
+<!--c2--><!--c3--><!--c4-->
+<li>b
+</li>
+</ul>
+!! end
+
+## Make sure ">" gets escaped in comments to avoid XSS
+!! test
+IE conditional comments
+!! wikitext
+<!--[if lt IE 9]>
+ <script>alert('hi');</script>
+<![endif]-->
+!! html/parsoid
+<!--[if lt IE 9]&#x3E;
+ <script&#x3E;alert('hi');</script&#x3E;
+<![endif]-->
+!! end
+
+###
+### paragraph wrapping tests
+###
+
+!! test
+No block tags
+!! wikitext
+a
+
+b
+!! html
+<p>a
+</p><p>b
+</p>
+!! end
+
+!! test
+Block tag on one line (<div>)
+!! wikitext
+a <div>foo</div>
+
+b
+!! html
+a <div>foo</div>
+<p>b
+</p>
+!! html+tidy
+<p>a </p><div>foo</div>
+<p>b
+</p>
+!! end
+
+# Remex wraps empty tag runs with p-tags.
+# Parsoid strips them out during p-wrapping.
+!! test
+No p-wrappable content
+!! wikitext
+<span><div>x</div></span>
+<span><s><div>x</div></s></span>
+<small><em></em></small><span><s><div>x</div></s></span>
+!! html/php+tidy
+<span><div>x</div></span>
+<span><s><div>x</div></s></span>
+<p><small><em></em></small></p><span><s><div>x</div></s></span>
+!! html/parsoid
+<span><div>x</div></span>
+<span><s><div>x</div></s></span>
+<small><em></em></small><span><s><div>x</div></s></span>
+!! end
+
+# T177612: Parsoid-only test
+!! test
+Transclusion meta tags shouldn't trip Parsoid's useless p-wrapper stripping code
+!! wikitext
+{{echo|<span><div>x</div></span>}}
+x
+!! html/parsoid
+<span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"stx":"html","pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;span>&lt;div>x&lt;/div>&lt;/span>"}},"i":0}}]}'><div>x</div></span>
+<p>x</p>
+!! end
+
+!! test
+Block tag on one line (<blockquote>)
+!! wikitext
+a <blockquote>foo</blockquote>
+
+b
+!! html
+a <blockquote>foo</blockquote>
+<p>b
+</p>
+!! html+tidy
+<p>a </p><blockquote><p>foo</p></blockquote>
+<p>b
+</p>
+!! end
+
+!! test
+Block tag on both lines (<div>)
+!! wikitext
+a <div>foo</div>
+
+b <div>foo</div>
+!! html
+a <div>foo</div>
+b <div>foo</div>
+
+!! html+tidy
+<p>a </p><div>foo</div><p>
+b </p><div>foo</div>
+!! end
+
+!! test
+Block tag on both lines (<blockquote>)
+!! wikitext
+a <blockquote>foo</blockquote>
+
+b <blockquote>foo</blockquote>
+!! html
+a <blockquote>foo</blockquote>
+b <blockquote>foo</blockquote>
+
+!! html+tidy
+<p>a </p><blockquote><p>foo</p></blockquote><p>
+b </p><blockquote><p>foo</p></blockquote>
+!! end
+
+!! test
+Multiple lines without block tags
+!! wikitext
+<div>foo</div> a
+b
+c
+d<!--foo--> e
+x <div>foo</div> z
+!! html
+<div>foo</div> a
+<p>b
+c
+d e
+</p>
+x <div>foo</div> z
+
+!! html+tidy
+<div>foo</div><p> a
+</p><p>b
+c
+d e
+</p><p>
+x </p><div>foo</div><p> z
+</p>
+!! end
+
+# The difference between Parsoid & Remex here
+# is because of Parsoid's Tidy-emulation code
+# for p-wrapping. We'll start work to remove this
+# emulation code in Parsoid sooner than later.
+# Remex wraps empty tag runs with p-tags.
+# Parsoid strips them out in a separate pass.
+!! test
+Empty lines between lines with block tags
+!! wikitext
+<div></div>
+
+
+<div></div>a
+
+b
+<div>a</div>b
+
+<div>b</div>d
+
+
+<div>e</div>
+!! html
+<div></div>
+<p><br />
+</p>
+<div></div>a
+<p>b
+</p>
+<div>a</div>b
+<div>b</div>d
+<p><br />
+</p>
+<div>e</div>
+
+!! html+tidy
+<div></div>
+<p><br />
+</p>
+<div></div><p>a
+</p><p>b
+</p>
+<div>a</div><p>b
+</p><div>b</div><p>d
+</p><p><br />
+</p>
+<div>e</div>
+!! html/parsoid
+<div data-parsoid='{"stx":"html"}'></div>
+
+<p><br /></p>
+<div data-parsoid='{"stx":"html"}'></div><p>a</p>
+
+<p>b</p>
+<div data-parsoid='{"stx":"html"}'>a</div><p>b</p>
+
+<div data-parsoid='{"stx":"html"}'>b</div><p>d</p>
+
+<p><br /></p>
+<div data-parsoid='{"stx":"html"}'>e</div>
+!! end
+
+!! test
+Unclosed HTML p-tags should be handled properly
+!! wikitext
+<div><p>foo</div>
+a
+
+b
+!! html/php+tidy
+<div><p>foo</p></div>
+<p>a
+</p><p>b
+</p>
+!! html/parsoid
+<div data-parsoid='{"stx":"html"}'><p data-parsoid='{"stx":"html", "autoInsertedEnd":true}'>foo</p></div>
+<p>a</p>
+<p>b</p>
+!! end
+
+## SSS FIXME: I can come up with other scenarios where this doesn't work because
+## of eager output of buffered tokens in the p-wrapper. But, I'm going to ignore
+## them for now.
+!! test
+1. P-wrapping should leave sol-transparent tags outside p-tags where possible
+!! options
+parsoid=wt2html
+!! wikitext
+a [[Category:A1]] [[Category:A2]]
+[[Category:A3]]
+[[Category:A4]]
+!! html/parsoid
+<p>a</p>
+<link rel="mw:PageProp/Category" href="./Category:A1"/> <link rel="mw:PageProp/Category" href="./Category:A2"/> <link rel="mw:PageProp/Category" href="./Category:A3"/> <link rel="mw:PageProp/Category" href="./Category:A4"/>
+!! end
+
+!! test
+2. P-wrapping should leave sol-transparent tags outside p-tags where possible
+!! options
+parsoid=wt2html
+!! wikitext
+[[Category:A1]]a
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Category:A1"/><p>a</p>
+!! end
+
+!! test
+No paragraph necessary for SOL transparent template
+!! wikitext
+<span><div>foo</div></span>
+[[Category:Foo]]
+
+<span><div>foo</div></span>
+{{echo|[[Category:Foo]]}}
+!! html/php
+<span><div>foo</div></span>
+<span><div>foo</div></span>
+
+!! html/parsoid
+<span data-parsoid='{"stx":"html"}'><div data-parsoid='{"stx":"html"}'>foo</div></span>
+<link rel="mw:PageProp/Category" href="./Category:Foo"/>
+
+<span data-parsoid='{"stx":"html"}'><div data-parsoid='{"stx":"html"}'>foo</div></span>
+<link rel="mw:PageProp/Category" href="./Category:Foo" about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[Category:Foo]]"}},"i":0}}]}'/>
+!! end
+
+!! test
+Avoid expanding multiline sol transparent template ranges unnecessarily
+!! wikitext
+hi
+
+
+{{echo|<br/>
+}}
+
+[[Category:Ho]]
+!! html/php
+<p>hi
+</p><p><br />
+<br />
+</p>
+!! html/parsoid
+<p>hi</p>
+
+<p><br />
+<br about="#mwt1" typeof="mw:Transclusion" data-parsoid='{}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;br/>\n"}},"i":0}}]}'/><span about="#mwt1">
+</span></p>
+
+<link rel="mw:PageProp/Category" href="./Category:Ho" />
+!! end
+
+###
+### Preformatted text
+###
+
+!! test
+Preformatted text
+!! wikitext
+ This is some
+ Preformatted text
+ With ''italic''
+ And '''bold'''
+ And a [[Main Page|link]]
+!! html
+<pre>This is some
+Preformatted text
+With <i>italic</i>
+And <b>bold</b>
+And a <a href="/wiki/Main_Page" title="Main Page">link</a>
+</pre>
+!! end
+
+!! test
+Tabs don't trigger preformatted text
+!! wikitext
+ This is not
+ preformatted text.
+ This is preformatted text.
+ So is this.
+!! html/php
+<p> This is not
+ preformatted text.
+</p>
+<pre>This is preformatted text.
+ So is this.
+</pre>
+!! html/parsoid
+<p> This is not
+ preformatted text.</p>
+<pre>This is preformatted text.
+ So is this.</pre>
+!! end
+
+!! test
+Space before tab needs nowiki pre protection
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p> a</p>
+!! wikitext
+<nowiki> </nowiki> a
+!! end
+
+!! test
+Ident preformatting with inline content
+!! wikitext
+ a
+ ''b''
+!! html
+<pre>a
+<i>b</i>
+</pre>
+!! end
+
+!! test
+<pre> with <nowiki> inside (compatibility with 1.6 and earlier)
+!! wikitext
+<pre><nowiki>
+<b>
+<cite>
+<em>
+</nowiki></pre>
+!! html
+<pre>
+&lt;b&gt;
+&lt;cite&gt;
+&lt;em&gt;
+</pre>
+
+!! end
+
+!! test
+Regression with preformatted in <center>
+!! wikitext
+<center>
+ Blah
+</center>
+!! html
+<center>
+<pre>Blah
+</pre>
+</center>
+
+!! end
+
+!! test
+T54763: Preformatted in <blockquote>
+!! wikitext
+<blockquote>
+ Blah
+{|
+|
+ indented cell (no pre-wrapping!)
+|}
+</blockquote>
+!! html
+<blockquote>
+<p> Blah
+</p>
+<table>
+<tr>
+<td>
+<p> indented cell (no pre-wrapping!)
+</p>
+</td></tr></table>
+</blockquote>
+
+!! end
+
+!! test
+T53086: Double newlines in blockquotes should be turned into paragraphs
+!! wikitext
+<blockquote>
+Foo
+
+Bar
+</blockquote>
+!! html
+<blockquote>
+<p>Foo
+</p><p>Bar
+</p>
+</blockquote>
+
+!! end
+
+!! test
+T17491: <ins>/<del> in blockquote
+!! wikitext
+<blockquote>
+Foo <del>bar</del> <ins>baz</ins> quux
+</blockquote>
+!! html
+<blockquote>
+<p>Foo <del>bar</del> <ins>baz</ins> quux
+</p>
+</blockquote>
+
+!! html+tidy
+<blockquote>
+<p>Foo <del>bar</del> <ins>baz</ins> quux
+</p>
+</blockquote>
+!! end
+
+!! test
+T17491: <ins>/<del> in blockquote (2)
+!! wikitext
+<blockquote>Foo <del>bar</del> <ins>baz</ins> quux
+</blockquote>
+!! html
+<blockquote>Foo <del>bar</del> <ins>baz</ins> quux
+</blockquote>
+
+!! html+tidy
+<blockquote><p>Foo <del>bar</del> <ins>baz</ins> quux
+</p></blockquote>
+!! end
+
+!! test
+<pre> with attributes (T5202)
+!! wikitext
+<pre style="background: blue; color:white">Bluescreen of WikiDeath</pre>
+!! html
+<pre style="background: blue; color:white">Bluescreen of WikiDeath</pre>
+
+!! end
+
+!! test
+<pre> with width attribute (T5202)
+!! wikitext
+<pre width="8">Narrow screen goodies</pre>
+!! html
+<pre width="8">Narrow screen goodies</pre>
+
+!! end
+
+!! test
+<pre> with forbidden attribute (T5202)
+!! wikitext
+<pre width="8" onmouseover="alert(document.cookie)">Narrow screen goodies</pre>
+!! html
+<pre width="8">Narrow screen goodies</pre>
+
+!! end
+
+!! test
+Entities inside <pre>
+!! wikitext
+<pre>&lt;</pre>
+!! html
+<pre>&lt;</pre>
+
+!! end
+
+!! test
+<pre> with forbidden attribute values (T5202)
+!! wikitext
+<pre width="8" style="border-width: expression(alert(document.cookie))">Narrow screen goodies</pre>
+!! html
+<pre width="8" style="/* insecure input */">Narrow screen goodies</pre>
+
+!! end
+
+!! test
+<nowiki> inside <pre> (T15238)
+!! wikitext
+<pre>
+<nowiki>
+</pre>
+<pre>
+<nowiki></nowiki>
+</pre>
+<pre><nowiki><nowiki></nowiki>Foo<nowiki></nowiki></nowiki></pre>
+!! html
+<pre>
+&lt;nowiki&gt;
+</pre>
+<pre>
+
+</pre>
+<pre>&lt;nowiki&gt;Foo&lt;/nowiki&gt;</pre>
+
+!! end
+
+!! test
+<nowiki> inside of #tag:pre
+!! wikitext
+{{#tag:pre|Foo <nowiki>&rarr;bar</nowiki>}}
+!! html/php
+<pre>Foo &#8594;bar</pre>
+
+!! html/parsoid
+<pre about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"#tag:pre","function":"tag"},"params":{"1":{"wt":"Foo &lt;nowiki>&amp;rarr;bar&lt;/nowiki>"}},"i":0}}]}'>Foo <span typeof="mw:Entity">→</span>bar</pre>
+!! end
+
+## Don't expect this to rt, Parsoid drops the unmatched closing pre tags that
+## aren't enclosed in nowikis.
+!! test
+<nowiki> and <pre> preference (first one wins)
+!! options
+parsoid=wt2html
+!! wikitext
+<pre>
+<nowiki>
+</pre>
+</nowiki>
+</pre>
+
+<nowiki>
+<pre>
+<nowiki>
+</pre>
+</nowiki>
+</pre>
+
+!! html/php
+<pre>
+&lt;nowiki&gt;
+</pre>
+<p>&lt;/nowiki&gt;
+&lt;/pre&gt;
+</p><p>
+&lt;pre&gt;
+&lt;nowiki&gt;
+&lt;/pre&gt;
+
+&lt;/pre&gt;
+</p>
+!! html/parsoid
+<pre typeof="mw:Extension/pre" about="#mwt2" data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"\n&lt;nowiki>\n"}}'>&lt;nowiki>
+</pre>
+<p>&lt;/nowiki></p>
+
+
+<p><span typeof="mw:Nowiki">
+&lt;pre>
+&lt;nowiki>
+&lt;/pre>
+</span></p>
+!! end
+
+!! test
+</pre> inside nowiki
+!! wikitext
+<nowiki></pre></nowiki>
+!! html
+<p>&lt;/pre&gt;
+</p>
+!! end
+
+!! test
+Empty pre; pre inside other HTML tags (T56946)
+!! wikitext
+a
+
+<div><pre>
+foo
+</pre></div>
+<pre></pre>
+!! html/php+tidy
+<p>a
+</p>
+<div><pre>foo
+</pre></div>
+<pre></pre>
+!! html/parsoid
+<p>a</p>
+
+<div data-parsoid='{"stx":"html"}'><pre typeof="mw:Extension/pre" about="#mwt2" data-parsoid='{"stx":"html"}' data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"\nfoo\n"}}'>foo
+</pre></div>
+<pre typeof="mw:Extension/pre" about="#mwt4" data-parsoid='{"stx":"html"}' data-mw='{"name":"pre","attrs":{},"body":{"extsrc":""}}'></pre>
+!! end
+
+!! test
+HTML pre followed by indent-pre
+!! wikitext
+<pre>foo</pre>
+ bar
+!! html
+<pre>foo</pre>
+<pre>bar
+</pre>
+!! end
+
+!! test
+Block tag pre
+!! wikitext
+<p><pre>foo</pre></p>
+!! html/php+tidy
+<p class="mw-empty-elt"></p><pre>foo</pre><p class="mw-empty-elt"></p>
+!! html/parsoid
+<p class='mw-empty-elt' data-parsoid='{"stx":"html","autoInsertedEnd":true}'></p><pre typeof="mw:Extension/pre" about="#mwt2" data-parsoid='{"stx":"html"}' data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"foo"}}'>foo</pre><p class='mw-empty-elt' data-parsoid='{"autoInsertedStart":true,"stx":"html"}'></p>
+!! end
+
+!!test
+Templates: Indent-Pre: 1a. Templates that break a line should suppress <pre>
+!! wikitext
+ {{echo|}}
+!! html
+
+!!end
+
+!!test
+Templates: Indent-Pre: 1b. Templates that break a line should suppress <pre>
+!! wikitext
+ {{echo|
+foo}}
+!! html
+<p>foo
+</p>
+!!end
+
+!! test
+Templates: Indent-Pre: 1c: Wrapping should be based on expanded content
+!! wikitext
+ {{echo|a
+b}}
+!! html
+<pre>a
+</pre>
+<p>b
+</p>
+!!end
+
+!! test
+Templates: Indent-Pre: 1d: Wrapping should be based on expanded content
+!! wikitext
+ {{echo|a
+b
+c
+ d
+e
+}}
+!! html
+<pre>a
+</pre>
+<p>b
+c
+</p>
+<pre>d
+</pre>
+<p>e
+</p>
+!!end
+
+!!test
+Templates: Indent-Pre: 1e. Wrapping should be based on expanded content
+!! wikitext
+{{echo| foo}}
+
+{{echo| foo}}{{echo| bar}}
+
+{{echo| foo}}
+{{echo| bar}}
+
+{{echo|<!--cmt--> foo}}
+
+<!--cmt-->{{echo| foo}}
+
+{{echo|{{echo| }}bar}}
+!! html
+<pre>foo
+</pre>
+<pre>foo bar
+</pre>
+<pre>foo
+bar
+</pre>
+<pre>foo
+</pre>
+<pre>foo
+</pre>
+<pre>bar
+</pre>
+!!end
+
+!! test
+Templates: Indent-Pre: 1f: Wrapping should be based on expanded content
+!! wikitext
+{{echo| }}a
+
+{{echo|
+ }}a
+
+{{echo|
+ b}}
+
+{{echo|a
+ }}b
+
+{{echo|a
+}} b
+!! html
+<pre>a
+</pre>
+<p><br />
+</p>
+<pre>a
+</pre>
+<p><br />
+</p>
+<pre>b
+</pre>
+<p>a
+</p>
+<pre>b
+</pre>
+<p>a
+</p>
+<pre>b
+</pre>
+!!end
+
+## Hmm, should Parsoid rt this?
+!! test
+Pres with newline attributes
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<pre class="one
+two">hi</pre>
+!! html/php
+<pre class="one two">hi</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/pre" about="#mwt2" class="one two" data-mw='{"name":"pre","attrs":{"class":"one two"},"body":{"extsrc":"hi"}}'>hi</pre>
+!! end
+
+!! test
+Things that look like <pre> tags aren't treated as such
+!! wikitext
+Barack Obama <President> of the United States
+<President></President>
+!! html
+<p>Barack Obama &lt;President&gt; of the United States
+&lt;President&gt;&lt;/President&gt;
+</p>
+!! end
+
+!! test
+Handle broken pre-like tags (T66025)
+!! options
+parsoid=wt2html
+!! wikitext
+{{echo|<pre <pre>x</pre>}}
+
+<table><pre </table>
+!! html/php
+<pre>x</pre>
+<table>&lt;pre </table>
+
+!! html/php+tidy
+<pre>x</pre>
+&lt;pre <table></table>
+!! html/parsoid
+<pre about="#mwt1" typeof="mw:Transclusion mw:Extension/pre" data-parsoid='{"a":{"&lt;pre":null},"sa":{"&lt;pre":""},"stx":"html","pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;pre &lt;pre>x&lt;/pre>"}},"i":0}}]}'>x</pre>
+
+
+<p>&lt;pre </p>
+
+<table></table>
+!! end
+
+!! test
+Parsoid: handle pre with space after attribute
+!! options
+parsoid=wt2html
+!! wikitext
+<pre style="width:50%;" >{{echo|foo}}</pre>
+!! html/php
+<pre style="width:50%;">{{echo|foo}}</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/pre" about="#mwt2" style="width:50%;" data-mw='{"name":"pre","attrs":{"style":"width:50%;"},"body":{"extsrc":"{{echo|foo}}"}}'>{{echo|foo}}</pre>
+!! end
+
+# TODO / maybe: fix wt2wt for this
+!! test
+Parsoid: Don't paragraph-wrap fosterable content
+!! options
+parsoid=wt2html
+!! wikitext
+{|
+<td></td>
+<td></td>
+
+
+
+|}
+!! html
+<table>
+
+<tbody>
+<tr>
+<td></td>
+
+<td></td></tr>
+
+
+
+</tbody></table>
+!! end
+
+!! test
+Self-closed pre
+!! wikitext
+<pre />
+!! html/php
+<pre></pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/pre" about="#mwt2" data-mw='{"name":"pre","attrs":{},"body":null}'></pre>
+!! end
+
+!! test
+Parsoid: Don't paragraph-wrap fosterable content even if table syntax is unbalanced
+!! options
+parsoid=wt2html
+!! wikitext
+{|
+<td>
+<td>
+</td>
+
+
+
+|}
+!! html
+<table>
+
+<tbody>
+<tr>
+<td></td>
+
+<td>
+</td></tr>
+
+
+
+</tbody></table>
+!! end
+
+
+#--------------------------------------------------------------------
+# Transclusion parameter whitespace stripping tests
+# Behavior is different for positional and named parameters
+#--------------------------------------------------------------------
+!! test
+Templates: Strip leading and trailing whitespace from named-param values
+!! wikitext
+{{echo|1= a }}
+
+{{echo|1= {{echo|b}} }}
+
+{{echo| 1 =
+ c }}
+
+{{echo| 1 =
+* d
+}}
+!! html
+<p>a
+</p><p>b
+</p><p>c
+</p>
+<ul><li>d</li></ul>
+
+!! end
+
+!! test
+Templates: Don't strip whitespace from positional-param values
+!! wikitext
+{{echo|a }}
+
+{{echo|{{echo|b}} }}
+
+{{echo| c
+}}
+
+{{echo| {{echo|d}}
+}}
+
+{{echo|
+ e}}
+
+{{echo|
+*f}}
+
+{{echo|
+ }}g
+!! html
+<p>a
+</p><p>b
+</p>
+<pre>c
+</pre>
+<p><br />
+</p>
+<pre>d
+</pre>
+<p><br />
+</p>
+<pre>e
+</pre>
+<p><br />
+</p>
+<ul><li>f</li></ul>
+<p><br />
+</p>
+<pre>g
+</pre>
+!! end
+
+!! test
+Templates: Don't recognize targets split by newlines
+!! options
+parsoid=wt2html
+!! wikitext
+{{ech
+o|foo}}
+!! html/php
+<p>{{ech
+o|foo}}
+</p>
+!! html/parsoid
+<p>{{ech
+o|foo}}</p>
+!! end
+
+!! test
+Templates: Recognize targets when newlines and comments don't split the target
+!! options
+parsoid=wt2html
+!! wikitext
+{{
+ <!--X--> ech<!--X-->o<!--X-->
+ <!--X--> <!--X-->
+
+ |foo}}
+!! html/php
+<p>foo
+</p>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"ech&lt;!--X-->o&lt;!--X--> \n &lt;!--X--> &lt;!--X-->\n\n ","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</p>
+!! end
+
+!! test
+Templates: Handle empty comment-and-ws-only lines correctly
+!! wikitext
+{{echo|foo
+<!--should be ignored-->
+ <!--should be ignored as well-->
+bar}}
+!! html/php
+<p>foo
+bar
+</p>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo\n&lt;!--should be ignored-->\n &lt;!--should be ignored as well-->\nbar"}},"i":0}}]}'>foo <!--should be ignored--> <!--should be ignored as well--> bar</p>
+!! end
+
+!! test
+Templates: Handle comments in the target
+!! wikitext
+{{echo
+<!-- should be ignored -->
+|foo}}
+
+{{echo
+<!-- should be ignored and spaces on next line should not trip us up (T147742) -->
+ |foo}}
+
+{{echo<!-- should be ignored -->
+|foo}}
+
+{{echo<!-- should be ignored -->|foo}}
+
+{{<!-- should be ignored -->echo|foo}}
+!! html/php
+<p>foo
+</p><p>foo
+</p><p>foo
+</p><p>foo
+</p><p>foo
+</p>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo\n&lt;!-- should be ignored -->\n","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</p>
+
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo\n&lt;!-- should be ignored and spaces on next line should not trip us up (T147742) -->\n ","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</p>
+
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo&lt;!-- should be ignored -->\n","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</p>
+
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo&lt;!-- should be ignored -->","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</p>
+
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</p>
+!! end
+
+!! test
+Templates: Handle comments in parameter names (T69657)
+!! wikitext
+{{echo|1
+<!-- should be ignored -->
+=foo}}
+
+{{echo|
+<!-- should be ignored -->
+1 = foo}}
+
+{{echo|1<!-- should be ignored -->=foo}}
+
+{{echo|<!-- should be ignored -->1=foo}}
+!! html/php
+<p>foo
+</p><p>foo
+</p><p>foo
+</p><p>foo
+</p>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo","key":{"wt":"1\n&lt;!-- should be ignored -->"}}},"i":0}}]}'>foo</p>
+
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo","key":{"wt":"&lt;!-- should be ignored -->\n1"}}},"i":0}}]}'>foo</p>
+
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo","key":{"wt":"1&lt;!-- should be ignored -->"}}},"i":0}}]}'>foo</p>
+
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo","key":{"wt":"&lt;!-- should be ignored -->1"}}},"i":0}}]}'>foo</p>
+!! end
+
+!! test
+Templates: Other wikitext in parameter names (T69657)
+!! wikitext
+{{echo|''1''=foo}}
+!! html/php
+<p>{{{1}}}
+</p>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"&#39;&#39;1&#39;&#39;":{"wt":"foo"}},"i":0}}]}'>{{{1}}}</p>
+!! end
+
+!! test
+Templates: With colons
+!! wikitext
+{{With: Colon}}
+!! html/php
+<p>Template with colon
+</p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"With: Colon","href":"./Template:With:_Colon"},"params":{},"i":0}}]}'>Template with colon</p>
+!! end
+
+#--------------------------------------------------------------------
+# Transclusion parameter escaping tests
+#--------------------------------------------------------------------
+
+!! test
+Templates: Parsoid parameter escaping test 1
+!! wikitext
+{{echo|[foo]|{{echo|[bar]}}}}
+!! html/php+tidy
+<p>[foo]
+</p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion"
+data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[foo]"},"2":{"wt":"{{echo|[bar]}}"}},"i":0}}]}'>[foo]</p>
+!! end
+
+!! test
+Parsoid: Pipes in external links in template parameter
+!! wikitext
+{{echo|[{{echo|http://example.com}} link]}}
+!! html/php+tidy
+<p><a rel="nofollow" class="external text" href="http://example.com">link</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external text" href="http://example.com" about="#mwt31" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[{{echo|http://example.com}} link]"}},"i":0}}]}'>link</a></p>
+!! end
+
+!! test
+Parsoid: pipe in transclusion parameter
+!! wikitext
+{{echo|http://foo.com/a&#124;b}}
+!! html/php+tidy
+<p><a rel="nofollow" class="external free" href="http://foo.com/a%7Cb">http://foo.com/a%7Cb</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://foo.com/a%7Cb" about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"http://foo.com/a&amp;#124;b"}},"i":0}}]}'>http://foo.com/a%7Cb</a></p>
+!! end
+
+!! test
+Parsoid: Pipe in external link target and content in template parameter
+!! options
+parsoid=html2wt,wt2wt
+!! wikitext
+{{echo|[http://foo.com/a&#124;b a&#124;b]}}
+!! html/php+tidy
+<p><a rel="nofollow" class="external text" href="http://foo.com/a%7Cb">a&#124;b</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" href="http://foo.com/a|b" about="#mwt1"
+typeof="mw:Transclusion"
+data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},
+"params":{"1":{"wt":"[http://foo.com/a|b a|b]"}},"i":0}}]}'>a|b</a></p>
+!! end
+
+!! test
+Parsoid: Pipe in template with nested template in external link target in template parameter (seriously)
+!! options
+parsoid
+!! wikitext
+{{echo|[{{fullurl:{{FULLPAGENAME}}|action=edit}} bar]}}
+!! html
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[{{fullurl:{{FULLPAGENAME}}|action=edit}} bar]"}},"i":0}}]}'>[Main Page bar]</p>
+!! end
+
+!! test
+Templates: Don't escape already nowiki-escaped text in template parameters
+!! options
+parsoid=html2wt,wt2wt
+!! wikitext
+{{echo|foo<nowiki>|</nowiki>bar}}
+{{echo|<nowiki>&lt;div&gt;</nowiki>}}
+{{echo|<nowiki></nowiki>}}
+!! html/php+tidy
+<p>foo|bar
+&lt;div&gt;
+
+</p>
+!! html/parsoid
+<p><span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo<nowiki>|</nowiki>bar"}},"i":0}}]}'}'>foo</span><span typeof="mw:Nowiki" about="#mwt1">|</span><span about="#mwt1">bar</span>
+<span typeof="mw:Transclusion mw:Nowiki" about="#mwt2" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"<nowiki>&amp;lt;div&amp;gt;</nowiki>"}},"i":0}}]}'><span typeof="mw:Entity">&lt;</span>div<span typeof="mw:Entity">&gt;</span></span>
+<span typeof="mw:Transclusion mw:Nowiki" about="#mwt3" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"<nowiki></nowiki>"}},"i":0}}]}'></span>
+</p>
+!! end
+
+## T54824
+!! test
+Templates: '=' char in nested transclusions should not trigger nowiki escapes or conversion to named param
+!! options
+parsoid=html2wt,wt2wt
+!! wikitext
+{{echo|{{echo|1=bar}}}}
+!! html/php+tidy
+<p>bar
+</p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"{{echo|1=bar}}"}},"i":0}}]}'>bar</p>
+!! end
+
+## T58733
+!! test
+Templates parameters with special tokenizing behavior dont get modified because of arg escaping
+!! wikitext
+{{echo|a : b}}
+!! html/php+tidy
+<p>a&#160;: b
+</p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"a : b"}},"i":0}}]}'>a<span typeof="mw:DisplaySpace mw:Placeholder" data-parsoid='{"isDisplayHack":true}'> </span>: b</p>
+!! end
+
+## T73412
+!! test
+Templates: Preserve blank parameter names
+!! wikitext
+{{echo|=foo}}
+!! html/php+tidy
+<p>{{{1}}}
+</p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"":{"wt":"foo"}},"i":0}}]}'>{{{1}}}</p>
+!! end
+
+!! test
+Templates: Preserve blank parameter names in other positions
+!! wikitext
+{{blank_param|bar|=foo}}
+!! html/php+tidy
+<p>bar
+foo
+</p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1"},{"k":"","named":true}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"blank_param","href":"./Template:Blank_param"},"params":{"1":{"wt":"bar"},"":{"wt":"foo"}},"i":0}}]}'>bar
+foo</p>
+!! end
+
+###
+### Parsoid-centric tests for testing RT edge cases for pre
+###
+
+!!test
+1a. Indent-Pre and Comments
+!! wikitext
+ a
+<!--a-->
+c
+!! html
+<pre>a
+</pre>
+<p>c
+</p>
+!!end
+
+!!test
+1b. Indent-Pre and Comments
+!! wikitext
+ a
+ <!--a-->
+c
+!! html
+<pre>a
+</pre>
+<p>c
+</p>
+!!end
+
+!!test
+1c. Indent-Pre and Comments
+!! wikitext
+<!--a--> a
+
+ <!--a--> a
+!! html
+<pre> a
+</pre>
+<pre> a
+</pre>
+!!end
+
+!!test
+1d. Indent-Pre and Comments
+(Pre-handler currently cannot distinguish between comment/ws order and normalizes them to [comment,ws] order)
+!! wikitext
+<!--a--> a
+
+ <!--b-->b
+!! html
+<pre>a
+</pre>
+<pre>b
+</pre>
+!!end
+
+!!test
+2a. Indent-Pre and tables
+!! wikitext
+ {|
+ |-
+ !h1!!h2
+ |foo||bar
+ |}
+!! html
+<table>
+
+<tr>
+<th>h1</th>
+<th>h2
+</th>
+<td>foo</td>
+<td>bar
+</td></tr></table>
+
+!!end
+
+!!test
+2b. Indent-Pre and tables
+!! wikitext
+ {|
+ |-
+|foo
+|}
+!! html
+<table>
+
+<tr>
+<td>foo
+</td></tr></table>
+
+!!end
+
+!!test
+2c. Indent-Pre and tables (T44252)
+!! wikitext
+{|
+ |+foo
+ ! |bar
+|}
+!! html
+<table>
+<caption>foo
+</caption>
+<tr>
+<th>bar
+</th></tr></table>
+
+!!end
+
+!!test
+2d. Indent-Pre and tables
+!! wikitext
+ a
+ {|
+ |b
+ |}
+!! html/php
+<pre>a
+</pre>
+<table>
+<tr>
+<td>b
+</td></tr></table>
+
+!! html/parsoid
+<pre>a</pre>
+ <table>
+ <tbody><tr><td> b</td></tr>
+ </tbody></table>
+!!end
+
+!!test
+2e. Indent-Pre and table-line syntax
+!! wikitext
+ a
+ | b
+ | c
+!! html/php
+<pre>a
+| b
+| c
+</pre>
+!!end
+
+!!test
+2f. Indent-pre started by table-line syntax
+!! wikitext
+a
+ | b
+ | c
+!! html/php
+<p>a
+</p>
+<pre>| b
+| c
+</pre>
+!! html/parsoid
+<p>a</p>
+<pre>
+| b
+| c</pre>
+!!end
+
+!! test
+2g. Indented table markup mixed with indented pre content (proposed in T8200)
+!! wikitext
+ <table>
+ <tr>
+ <td>
+ Text that should be rendered preformatted
+ </td>
+ </tr>
+ </table>
+!! html
+ <table>
+ <tr>
+ <td>
+<pre>Text that should be rendered preformatted
+</pre>
+ </td>
+ </tr>
+ </table>
+
+!! end
+
+!!test
+3a. Indent-Pre and block tags (single-line html)
+!! wikitext
+ a <p> foo </p>
+ b <div> foo </div>
+ c <blockquote> foo </blockquote>
+ <span> foo </span>
+!! html
+ a <p> foo </p>
+ b <div> foo </div>
+ c <blockquote> foo </blockquote>
+<pre><span> foo </span>
+</pre>
+!! html/parsoid
+ <p>a </p><p data-parsoid='{"stx":"html"}'> foo </p>
+ <p>b </p><div data-parsoid='{"stx":"html"}'> foo </div>
+ <p>c </p><blockquote data-parsoid='{"stx":"html"}'> foo </blockquote>
+<pre><span> foo </span>
+</pre>
+!! html/php+tidy
+<p> a </p><p> foo </p><p>
+ b </p><div> foo </div><p>
+ c </p><blockquote><p> foo </p></blockquote>
+<pre><span> foo </span>
+</pre>
+!! end
+
+!!test
+3b. Indent-Pre and block tags (multi-line html)
+!! wikitext
+ a <span>foo</span>
+ b <div> foo </div>
+!! html
+<pre>a <span>foo</span>
+</pre>
+ b <div> foo </div>
+
+!! html/parsoid
+<pre>a <span data-parsoid='{"stx":"html"}'>foo</span></pre>
+ b <div data-parsoid='{"stx":"html"}'> foo </div>
+!! html/php+tidy
+<pre>a <span>foo</span>
+</pre><p>
+ b </p><div> foo </div>
+!!end
+
+!!test
+3c. Indent-Pre and block tags (pre-content on separate line)
+!! wikitext
+<p>
+ foo
+</p>
+
+<div>
+ foo
+</div>
+
+<center>
+ foo
+</center>
+
+<blockquote>
+ foo
+</blockquote>
+
+<blockquote>
+<pre>
+foo
+</pre>
+</blockquote>
+
+<table><tr><td>
+ foo
+</td></tr></table>
+
+<ul><li>
+ foo
+</li></ul>
+
+!! html
+<p>
+ foo
+</p>
+<div>
+<pre>foo
+</pre>
+</div>
+<center>
+<pre>foo
+</pre>
+</center>
+<blockquote>
+<p> foo
+</p>
+</blockquote>
+<blockquote>
+<pre>
+foo
+</pre>
+</blockquote>
+<table><tr><td>
+<pre>foo
+</pre>
+</td></tr></table>
+<ul><li>
+ foo
+</li></ul>
+
+!!end
+
+!! test
+4. Indent-Pre and extension tags
+!! wikitext
+ a <tag />
+!! html/php
+ a <pre>
+NULL
+array (
+)
+</pre>
+
+!! html/parsoid
+ a <pre typeof="mw:Extension/tag" about="#mwt2" data-parsoid='{}' data-mw='{"name":"tag","attrs":{},"body":null}'></pre>
+!! end
+
+!! test
+5. Indent-Pre and html pre
+!! wikitext
+ <pre class="123">hi</pre>
+!! html/php
+ <pre class="123">hi</pre>
+
+!! html/parsoid
+ <pre typeof="mw:Extension/pre" about="#mwt2" class="123" data-mw='{"name":"pre","attrs":{"class":"123"},"body":{"extsrc":"hi"}}'>hi</pre>
+!! end
+
+!!test
+Render paragraphs when indent-pre is suppressed in blocklevels
+!! wikitext
+<blockquote>
+ foo
+
+ bar
+</blockquote>
+!! html
+<blockquote>
+<p> foo
+</p><p> bar
+</p>
+</blockquote>
+
+!!end
+
+!!test
+4. Multiple spaces at start-of-line
+!! wikitext
+ <p> foo </p>
+ foo
+ {|
+|foo
+|}
+!! html
+ <p> foo </p>
+<pre> foo
+</pre>
+<table>
+<tr>
+<td>foo
+</td></tr></table>
+
+!!end
+
+## NOTE: the leading white-space chars on empty line are significant
+!! test
+5a. White-space in indent-pre
+!! wikitext
+ a<br />
+
+ b
+!! html
+<pre>a<br />
+
+b
+</pre>
+!! end
+
+## NOTE: the leading white-space chars on empty line are significant
+!! test
+5b. White-space in indent-pre
+!! wikitext
+ a
+
+ b
+
+
+ c
+!! html
+<pre>a
+
+b
+
+
+c
+</pre>
+!! end
+
+!! test
+5c. White-space in indent-pre
+!! wikitext
+ ''a''
+ ''b''
+ ''c''
+!! html
+<pre><i>a</i>
+ <i>b</i>
+ <i>c</i>
+</pre>
+!! end
+
+!! test
+6. Pre-blocks should extend across lines with leading WS even when there is no wrappable content
+!! wikitext
+ a
+
+ <!-- continue -->
+ b
+
+ c
+
+d
+!! html
+<pre>a
+
+b
+</pre>
+<pre>c
+
+</pre>
+<p>d
+</p>
+!! end
+
+!! test
+7a. Indent-pre and category links
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+ [[Category:foo]] <!-- No pre-wrapping -->
+{{echo| [[Category:foo]]}} <!-- No pre-wrapping -->
+!! html/php+tidy
+!! html/parsoid
+ <link rel="mw:PageProp/Category" href="./Category:Foo"> <!-- No pre&#x2D;wrapping -->
+<span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":" [[Category:foo]]"}},"i":0}}]}'> </span><link rel="mw:PageProp/Category" href="./Category:Foo" about="#mwt1"> <!-- No pre&#x2D;wrapping -->
+!! end
+
+## We used to, but no longer wt2wt this test since the default serializer
+## will normalize all categories to serialize on their own line.
+## This wikitext usage is going to be fairly uncommon in production and
+## selser will take care of preserving formatting in those scenarios.
+!! test
+7b. Indent-pre and category links
+!! options
+parsoid=wt2html
+!! wikitext
+ [[Category:foo]] a
+ [[Category:foo]] {{echo|b}}
+!! html/parsoid
+<pre><link rel="mw:PageProp/Category" href="./Category:Foo"> a
+ <link rel="mw:PageProp/Category" href="./Category:Foo"> <span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"b"}},"i":0}}]}'>b</span></pre>
+!! end
+
+!! test
+Indent-Pre: Newlines in comments shouldn't affect sol state
+!! wikitext
+a <!--
+foo
+--> b
+!! html/php+tidy
+<p>a b
+</p>
+!! html/parsoid
+<p>a <!--
+foo
+--> b</p>
+!! end
+
+###
+### HTML-pre (some to spec PHP parser behavior and some Parsoid-RT-centric)
+###
+
+!!test
+HTML-pre: 1. embedded newlines
+!! wikitext
+<pre>foo</pre>
+
+<pre>
+foo
+</pre>
+
+<pre>
+
+foo
+</pre>
+
+<pre>
+
+
+foo
+</pre>
+!! html/php+tidy
+<pre>foo</pre>
+<pre>foo
+</pre>
+<pre>
+
+foo
+</pre>
+<pre>
+
+
+foo
+</pre>
+!! html/parsoid
+<pre typeof="mw:Extension/pre" about="#mwt2" data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"foo"}}'>foo</pre>
+
+<pre typeof="mw:Extension/pre" about="#mwt4" data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"\nfoo\n"}}'>foo
+</pre>
+
+<pre typeof="mw:Extension/pre" about="#mwt6" data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"\n\nfoo\n"}}'>
+
+foo
+</pre>
+
+<pre typeof="mw:Extension/pre" about="#mwt8" data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"\n\n\nfoo\n"}}'>
+
+
+foo
+</pre>
+!!end
+
+!! test
+HTML-pre: big spaces
+!! wikitext
+<pre>
+
+
+
+
+haha
+
+
+
+
+haha
+
+
+
+
+</pre>
+!! html/php+tidy
+<pre>
+
+
+
+
+haha
+
+
+
+
+haha
+
+
+
+
+</pre>
+!! html/parsoid
+<pre typeof="mw:Extension/pre" about="#mwt2" data-parsoid='{"stx":"html"}' data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"\n\n\n\n\nhaha\n\n\n\n\nhaha\n\n\n\n\n"}}'>
+
+
+
+
+haha
+
+
+
+
+haha
+
+
+
+
+</pre>
+!! end
+
+!!test
+HTML-pre: 2: indented text
+!! wikitext
+<pre>
+ foo
+</pre>
+!! html
+<pre>
+ foo
+</pre>
+
+!!end
+
+!!test
+HTML-pre: 3: other wikitext
+!! wikitext
+<pre>
+* foo
+# bar
+= no-h =
+'' no-italic ''
+[[ NoLink ]]
+</pre>
+!! html/php
+<pre>
+* foo
+# bar
+= no-h =
+'' no-italic ''
+[[ NoLink ]]
+</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/pre" about="#mwt2" data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"\n* foo\n# bar\n= no-h =\n&#39;&#39; no-italic &#39;&#39;\n[[ NoLink ]]\n"}}'>* foo
+# bar
+= no-h =
+'' no-italic ''
+[[ NoLink ]]
+</pre>
+!!end
+
+###
+### Definition lists
+###
+!! test
+Simple definition
+!! wikitext
+;name :Definition
+!! html
+<dl><dt>name&#160;</dt>
+<dd>Definition</dd></dl>
+
+!! end
+
+!! test
+Definition list for indentation only
+!! wikitext
+:Indented text
+!! html
+<dl><dd>Indented text</dd></dl>
+
+!! end
+
+!! test
+Definition list with no space
+!! wikitext
+;name:Definition
+!! html
+<dl><dt>name</dt>
+<dd>Definition</dd></dl>
+
+!!end
+
+!! test
+Definition list with URL link
+!! wikitext
+;http://example.com/ :definition
+!! html
+<dl><dt><a rel="nofollow" class="external free" href="http://example.com/">http://example.com/</a>&#160;</dt>
+<dd>definition</dd></dl>
+
+!! end
+
+!! test
+Definition list with bracketed URL link
+!! wikitext
+;[http://www.example.com/ Example]:Something about it
+!! html
+<dl><dt><a rel="nofollow" class="external text" href="http://www.example.com/">Example</a></dt>
+<dd>Something about it</dd></dl>
+
+!! end
+
+!! test
+Definition list with wikilink containing colon
+!! wikitext
+; [[Help:FAQ]]:The least-read page on Wikipedia
+!! html
+<dl><dt><a href="/index.php?title=Help:FAQ&amp;action=edit&amp;redlink=1" class="new" title="Help:FAQ (page does not exist)">Help:FAQ</a></dt>
+<dd>The least-read page on Wikipedia</dd></dl>
+
+!! end
+
+# At Brion's and JeLuF's insistence... :)
+!! test
+Definition list with news link containing colon
+!! wikitext
+;news:alt.wikipedia.rox: This isn't even a real newsgroup!
+!! html/php
+<dl><dt><a rel="nofollow" class="external free" href="news:alt.wikipedia.rox">news:alt.wikipedia.rox</a></dt>
+<dd>This isn't even a real newsgroup!</dd></dl>
+
+!! html/parsoid
+<dl><dt> <a rel="mw:ExtLink" class="external free" href="news:alt.wikipedia.rox" data-parsoid='{"stx":"url"}'>news:alt.wikipedia.rox</a></dt><dd data-parsoid='{"stx":"row"}'>This isn't even a real newsgroup!</dd></dl>
+!! end
+
+!! test
+Malformed definition list with colon
+!! wikitext
+; news:alt.wikipedia.rox -- don't crash or enter an infinite loop
+!! html
+<dl><dt><a rel="nofollow" class="external free" href="news:alt.wikipedia.rox">news:alt.wikipedia.rox</a> -- don't crash or enter an infinite loop</dt></dl>
+
+!! end
+
+!! test
+Definition lists: colon in external link text
+!! wikitext
+;[http://www.wikipedia2.org/ Wikipedia :The Next Generation] :OK, I made that up
+!! html
+<dl><dt><a rel="nofollow" class="external text" href="http://www.wikipedia2.org/">Wikipedia&#160;:The Next Generation</a>&#160;</dt>
+<dd>OK, I made that up</dd></dl>
+
+!! end
+
+!! test
+Definition lists: colon in HTML attribute
+!! wikitext
+;<b style="display: inline">bold</b>
+!! html
+<dl><dt><b style="display: inline">bold</b></dt></dl>
+
+!! end
+
+!! test
+Definition lists: self-closed tag
+!! wikitext
+;one<br/>two :two-line fun
+!! html
+<dl><dt>one<br />two&#160;</dt>
+<dd>two-line fun</dd></dl>
+
+!! end
+
+!! test
+Definition lists: ignore colons inside tags
+!! wikitext
+;one <b>two : tag <i>fun:</i>:</b>:def
+!! html
+<dl><dt>one <b>two&#160;: tag <i>fun:</i>:</b></dt>
+<dd>def</dd></dl>
+
+!! end
+
+!! test
+Definition lists: excess closed tags
+!! wikitext
+;one</b>two :bad tag fun
+!! html/php+tidy
+<dl><dt>onetwo&#160;</dt>
+<dd>bad tag fun</dd></dl>
+!! html/parsoid
+<dl>
+<dt>onetwo</dt>
+<dd>bad tag fun</dd>
+</dl>
+!! end
+
+!! test
+T13748: Literal closing tags
+!! wikitext
+<dl>
+<dt>test 1</dt>
+<dd>test test test test test</dd>
+<dt>test 2</dt>
+<dd>test test test test test</dd>
+</dl>
+!! html
+<dl>
+<dt>test 1</dt>
+<dd>test test test test test</dd>
+<dt>test 2</dt>
+<dd>test test test test test</dd>
+</dl>
+
+!! end
+
+!! test
+Definition and unordered list using wiki syntax nested in unordered list using html tags.
+!! wikitext
+<ul><li>
+;term :description
+*unordered
+</li></ul>
+!! html
+<ul><li>
+<dl><dt>term&#160;</dt>
+<dd>description</dd></dl>
+<ul><li>unordered</li></ul>
+</li></ul>
+
+!! end
+
+!! test
+Definition list with empty definition and following paragraph
+!! wikitext
+;term:
+
+Paragraph text
+!! html
+<dl><dt>term</dt>
+<dd></dd></dl>
+<p>Paragraph text
+</p>
+!! end
+
+!! test
+Nested definition lists using html syntax
+!! wikitext
+<dl><dt>x</dt>
+<dd>a</dd>
+<dd>b</dd></dl>
+!! html
+<dl><dt>x</dt>
+<dd>a</dd>
+<dd>b</dd></dl>
+
+!! end
+
+!! test
+Definition Lists: No nesting: Multiple dd's
+!! wikitext
+;x
+:a
+:b
+!! html
+<dl><dt>x</dt>
+<dd>a</dd>
+<dd>b</dd></dl>
+
+!! end
+
+!! test
+Definition Lists: Indentation: Regular
+!! wikitext
+:i1
+::i2
+:::i3
+!! html
+<dl><dd>i1
+<dl><dd>i2
+<dl><dd>i3</dd></dl></dd></dl></dd></dl>
+
+!! end
+
+!! test
+Definition Lists: Indentation: Missing 1st level
+!! wikitext
+::i2
+:::i3
+!! html
+<dl><dd><dl><dd>i2
+<dl><dd>i3</dd></dl></dd></dl></dd></dl>
+
+!! end
+
+!! test
+Definition Lists: Indentation: Multi-level indent
+!! wikitext
+:::i3
+!! html
+<dl><dd><dl><dd><dl><dd>i3</dd></dl></dd></dl></dd></dl>
+
+!! end
+
+!! test
+Definition Lists: Hacky use to indent tables
+!! wikitext
+::{|
+|foo
+|bar
+|}
+this text
+should be left alone
+!! html
+<dl><dd><dl><dd><table>
+<tr>
+<td>foo
+</td>
+<td>bar
+</td></tr></table></dd></dl></dd></dl>
+<p>this text
+should be left alone
+</p>
+!! end
+
+!! test
+Definition Lists: Hacky use to indent tables (with content following table)
+!! wikitext
+:{|
+|foo
+|bar
+|} <!--c1--> this text should be part of the dl
+!! html/php+tidy
+<dl><dd><table>
+<tbody><tr>
+<td>foo
+</td>
+<td>bar
+</td></tr></tbody></table> this text should be part of the dl</dd></dl>
+!! html/parsoid
+<dl><dd><table>
+<tbody><tr>
+<td>foo
+</td>
+<td>bar
+</td></tr></tbody></table> <!--c1--> this text should be part of the dl</dd></dl>
+!! end
+
+!! test
+Definition Lists: Hacky use to indent tables, with comments (T65979)
+!! wikitext
+<!-- foo -->
+::{|
+|foo
+|bar
+|}<!-- bar -->
+this text
+should be left alone
+!! html/parsoid
+<!-- foo -->
+<dl><dd><dl><dd><table><tr>
+<td>foo</td>
+<td>bar</td>
+</tr></table><!-- bar --></dd></dl></dd></dl>
+<p>this text
+should be left alone</p>
+!! end
+
+!! test
+Definition Lists: Hacky use to indent tables, with comment before table
+!! wikitext
+::<!-- foo -->{|
+|foo
+|}
+!! html/parsoid
+<dl><dd><dl><dd><!-- foo --><table><tr>
+<td>foo</td>
+</tr></table></dd></dl></dd></dl>
+!! end
+
+# The trailing whitespace in this test is to catch a regression in
+# Parsoid after T54473.
+!! test
+Definition Lists: Hacky use to indent tables (WS-insensitive)
+!! wikitext
+: {|
+|a
+|}
+!! html/php
+<dl><dd><table>
+<tr>
+<td>a
+</td></tr></table></dd></dl>
+
+!! html/parsoid
+<dl><dd> <table>
+<tbody><tr><td>a</td></tr>
+</tbody></table> </dd></dl>
+!! end
+
+## The PHP parser treats : items (dd) without a corresponding ; item (dt)
+## as an empty dt item. It also ignores all but the last ";" when followed
+## by ":" later on. So, ";" are not ignored in ";;;t3" but are ignored in
+## ";;;t3 :d1". So, PHP parser behavior is a little inconsistent wrt multiple
+## ";"s.
+##
+## Ex: ";;t2 ::d2" is transformed into:
+##
+## <dl>
+## <dt>t2 </dt>
+## <dd>
+## <dl>
+## <dt></dt>
+## <dd>d2</dd>
+## </dl>
+## </dd>
+## </dl>
+##
+## But, Parsoid treats "; :" as a tight atomic unit and excess ":" as plain text
+## So, the same wikitext above (;;t2 ::d2) is transformed into:
+##
+## <dl>
+## <dt>
+## <dl>
+## <dt>t2 </dt>
+## <dd>:d2</dd>
+## </dl>
+## </dt>
+## </dl>
+##
+## All Parsoid only definition list tests have this difference.
+##
+## See also: https://phabricator.wikimedia.org/T8569
+## and https://lists.wikimedia.org/pipermail/wikitext-l/2011-November/000483.html
+
+!! test
+Table / list interaction: indented table with lists in table contents
+!! wikitext
+:{|
+|-
+|a
+
+*b
+|-
+|c
+
+*d
+|}
+!! html
+<dl><dd><table>
+
+<tr>
+<td>a
+<ul><li>b</li></ul>
+</td></tr>
+<tr>
+<td>c
+<ul><li>d</li></ul>
+</td></tr></table></dd></dl>
+
+!! end
+
+!!test
+Table / list interaction: lists nested in tables nested in indented lists
+!! wikitext
+:{|
+|
+:a
+:b
+|
+*c
+*d
+|}
+
+*e
+*f
+!! html
+<dl><dd><table>
+<tr>
+<td>
+<dl><dd>a</dd>
+<dd>b</dd></dl>
+</td>
+<td>
+<ul><li>c</li>
+<li>d</li></ul>
+</td></tr></table></dd></dl>
+<ul><li>e</li>
+<li>f</li></ul>
+
+!!end
+
+!! test
+Definition Lists: Nesting: Multi-level (Parsoid only)
+!! wikitext
+;t1 :d1
+;;t2 ::d2
+;;;t3 :::d3
+!! html/parsoid
+<dl>
+ <dt>t1 </dt>
+ <dd>d1</dd>
+ <dt>
+ <dl>
+ <dt>t2 </dt>
+ <dd>:d2</dd>
+ <dt>
+ <dl>
+ <dt>t3 </dt>
+ <dd>::d3</dd>
+ </dl>
+ </dt>
+ </dl>
+ </dt>
+</dl>
+
+
+!! end
+
+
+!! test
+Definition Lists: Nesting: Test 2
+!! wikitext
+;t1
+::d2
+!! html+tidy
+<dl><dt>t1</dt>
+<dd>
+<dl><dd>d2</dd></dl></dd></dl>
+!! end
+
+
+!! test
+Definition Lists: Nesting: Test 3
+!! wikitext
+:;t1
+::::d2
+!! html+tidy
+<dl><dd><dl><dt>t1</dt>
+<dd>
+<dl><dd><dl><dd>d2</dd></dl></dd></dl></dd></dl></dd></dl>
+!! end
+
+
+!! test
+Definition Lists: Nesting: Test 4
+!! wikitext
+::;t3
+:::d3
+!! html
+<dl><dd><dl><dd><dl><dt>t3</dt>
+<dd>d3</dd></dl></dd></dl></dd></dl>
+
+!! end
+
+
+## The Parsoid team believes the following three test exposes a
+## bug in the PHP parser. (Parsoid team thinks the PHP parser is
+## wrong to close the <dl> after the <dt> containing the <ul>.)
+## It also exposes a "misfeature" in tidy, which doesn't like
+## <dl> tags with a single <dt> child; it converts the <dt> into
+## a <dd> in that case. (Parsoid leaves the <dt> alone!)
+!! test
+Definition Lists: Mixed Lists: Test 1
+!! wikitext
+:;*foo
+::*bar
+:;baz
+!! html/php
+<dl><dd><dl><dt><ul><li>foo</li>
+<li>bar</li></ul></dt></dl>
+<dl><dt>baz</dt></dl></dd></dl>
+
+!! html/php+tidy
+<dl><dd><dl><dt><ul><li>foo</li>
+<li>bar</li></ul></dt></dl>
+<dl><dt>baz</dt></dl></dd></dl>
+!! html/parsoid
+<dl>
+<dd><dl>
+<dt><ul>
+<li>foo
+</li>
+</ul></dt>
+<dd><ul>
+<li>bar
+</li>
+</ul></dd>
+<dt>baz</dt>
+</dl></dd>
+</dl>
+!! end
+
+!! test
+Definition Lists: Mixed Lists: Test 2
+!! wikitext
+*:d1
+*:d2
+!! html
+<ul><li><dl><dd>d1</dd>
+<dd>d2</dd></dl></li></ul>
+
+!! end
+
+
+!! test
+Definition Lists: Mixed Lists: Test 3
+!! wikitext
+*:::d1
+*:::d2
+!! html
+<ul><li><dl><dd><dl><dd><dl><dd>d1</dd>
+<dd>d2</dd></dl></dd></dl></dd></dl></li></ul>
+
+!! end
+
+
+!! test
+Definition Lists: Mixed Lists: Test 4
+!! wikitext
+*;d1 :d2
+*;d3 :d4
+!! html
+<ul><li><dl><dt>d1&#160;</dt>
+<dd>d2</dd>
+<dt>d3&#160;</dt>
+<dd>d4</dd></dl></li></ul>
+
+!! end
+
+
+!! test
+Definition Lists: Mixed Lists: Test 5
+!! wikitext
+*:d1
+*::d2
+!! html
+<ul><li><dl><dd>d1
+<dl><dd>d2</dd></dl></dd></dl></li></ul>
+
+!! end
+
+
+!! test
+Definition Lists: Mixed Lists: Test 6
+!! wikitext
+#*:d1
+#*:::d3
+!! html
+<ol><li><ul><li><dl><dd>d1
+<dl><dd><dl><dd>d3</dd></dl></dd></dl></dd></dl></li></ul></li></ol>
+
+!! end
+
+
+!! test
+Definition Lists: Mixed Lists: Test 7
+!! wikitext
+:*d1
+:*d2
+!! html
+<dl><dd><ul><li>d1</li>
+<li>d2</li></ul></dd></dl>
+
+!! end
+
+
+!! test
+Definition Lists: Mixed Lists: Test 8
+!! wikitext
+:*d1
+::*d2
+!! html
+<dl><dd><ul><li>d1</li></ul>
+<dl><dd><ul><li>d2</li></ul></dd></dl></dd></dl>
+
+!! end
+
+
+!! test
+Definition Lists: Mixed Lists: Test 9
+!! wikitext
+*;foo :bar
+!! html
+<ul><li><dl><dt>foo&#160;</dt>
+<dd>bar</dd></dl></li></ul>
+
+!! end
+
+
+!! test
+Definition Lists: Mixed Lists: Test 10
+!! wikitext
+*#;foo :bar
+!! html
+<ul><li><ol><li><dl><dt>foo&#160;</dt>
+<dd>bar</dd></dl></li></ol></li></ul>
+
+!! end
+
+# The Parsoid team disagrees with the PHP parser's seemingly-random
+# rules regarding dd/dt on the next few tests. Parsoid is more
+# consistent, and recognizes the shared nesting and keeps the
+# still-open tags around until the nesting is complete.
+
+# This is a regression test for T175099
+!! test
+Definition Lists: Mixed Lists: Test 11
+!! wikitext
+;a
+:*b
+!! html/php
+<dl><dt>a</dt>
+<dd>
+<ul><li>b</li></ul></dd></dl>
+
+!! html/parsoid
+<dl><dt>a
+<dd><ul><li>b</li></ul></dd></dl>
+!! end
+
+# FIXME: Maybe get rid of this test?
+!! test
+Definition Lists: Mixed Lists: Test 12
+!! wikitext
+*#*#;*;;foo :bar
+*#*#;boo :baz
+!! html/php
+<ul><li><ol><li><ul><li><ol><li><dl><dt>foo&#160;</dt>
+<dd><ul><li><dl><dt><dl><dt>bar</dt></dl></dd></dl></li></ul></dd></dl>
+<dl><dt>boo&#160;</dt>
+<dd>baz</dd></dl></li></ol></li></ul></li></ol></li></ul>
+
+!! html/php+tidy
+<ul><li><ol><li><ul><li><ol><li><dl><dt>foo&#160;</dt>
+<dd><ul><li><dl><dt><dl><dt>bar</dt></dl></dt></dl></li></ul></dd></dl></li></ol></li></ul>
+<dl><dt>boo&#160;</dt>
+<dd>baz</dd></dl></li></ol></li></ul>
+!! html/parsoid
+<ul>
+<li>
+<ol>
+<li>
+<ul>
+<li>
+<ol>
+<li>
+<dl>
+<dt>
+<ul>
+<li>
+<dl>
+<dt>
+<dl>
+<dt>foo<span typeof="mw:Placeholder" data-parsoid='{"src":" "}'>&nbsp;</span></dt>
+<dd data-parsoid='{"stx":"row"}'>bar</dd>
+</dl></dt>
+</dl></li>
+</ul></dt>
+<dt>boo<span typeof="mw:Placeholder" data-parsoid='{"src":" "}'>&nbsp;</span></dt>
+<dd data-parsoid='{"stx":"row"}'>baz</dd>
+</dl></li>
+</ol></li>
+</ul></li>
+</ol></li>
+</ul>
+!! end
+
+# FIXME: Maybe get rid of this test?
+# From whitelist:
+# * The test is wrong, there are two colons where there should be :;
+# * The PHP parser is wrong to close the <dl> after the <dt> containing the <ul>.
+!! test
+Definition Lists: Weird Ones: Test 1
+!! wikitext
+*#;*::;;foo :bar (who uses this?)
+!! html/php+tidy
+<ul><li><ol><li><dl><dt>foo&#160;</dt>
+<dd><ul><li><dl><dd><dl><dd><dl><dt><dl><dt>bar (who uses this?)</dt></dl></dt></dl></dd></dl></dd></dl></li></ul></dd></dl></li></ol></li></ul>
+!! html/parsoid
+<ul>
+<li>
+<ol>
+<li>
+<dl>
+<dt>
+<ul>
+<li>
+<dl>
+<dd>
+<dl>
+<dd>
+<dl>
+<dt>
+<dl>
+<dt>foo<span typeof="mw:DisplaySpace mw:Placeholder" data-parsoid='{"src":" ","isDisplayHack":true}'> </span></dt>
+<dd data-parsoid='{"stx":"row"}'>bar (who uses this?)</dd>
+</dl></dt>
+</dl></dd>
+</dl></dd>
+</dl></li>
+</ul></dt>
+</dl></li>
+</ol></li>
+</ul>
+!! end
+
+!! test
+Definition Lists: colons occurring in tags
+!! wikitext
+;a:b
+;'''a:b'''
+;<i>a:b</i>
+;<span>a:b</span>
+;<div>a:b</div>
+;<div>a
+:b</div>
+;{{echo|a:b}}
+;{{echo|''a:b''}}
+;;;''a:b''
+!! html+tidy
+<dl><dt>a</dt>
+<dd>b</dd>
+<dt><b>a:b</b></dt>
+<dt><i>a:b</i></dt>
+<dt><span>a:b</span></dt>
+<dt><div>a:b</div></dt>
+<dt><div>a</div></dt>
+<dd>b</dd>
+<dt>a</dt>
+<dd>b</dd>
+<dt><i>a:b</i></dt></dl>
+<dl><dt><dl><dt><dl><dt><i>a:b</i></dt></dl></dt></dl></dt></dl>
+!! html/parsoid
+<dl><dt>a</dt><dd data-parsoid='{"stx":"row"}'>b</dd>
+<dt><b>a:b</b></dt>
+<dt><i data-parsoid='{"stx":"html"}'>a:b</i></dt>
+<dt><span data-parsoid='{"stx":"html"}'>a:b</span></dt>
+<dt><div data-parsoid='{"stx":"html"}'>a:b</div></dt>
+<dt><div data-parsoid='{"stx":"html","autoInsertedEnd":true}'>a</div></dt>
+<dd>b</dd>
+<dt><span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"a:b"}},"i":0}}]}'>a:b</span></dt>
+<dt><i about="#mwt2" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&#39;&#39;a:b&#39;&#39;"}},"i":0}}]}'>a:b</i>
+<dl><dt><dl><dt><i>a:b</i></dt></dl></dt></dl></dt></dl>
+!! end
+
+# Parsoid's output differs here again because it shares
+# nesting between the two lists unlike the PHP parser.
+# Unsure which is more desirable.
+!! test
+Definition Lists: colons and tables 1
+!! wikitext
+:{|
+|x
+|}
+:{|
+|y
+|}
+!! html/php
+<dl><dd><table>
+<tr>
+<td>x
+</td></tr></table></dd></dl>
+<dl><dd><table>
+<tr>
+<td>y
+</td></tr></table></dd></dl>
+
+!! html/parsoid
+<dl><dd><table>
+<tr>
+<td>x
+</td></tr></table></dd>
+<dd><table>
+<tr>
+<td>y
+</td></tr></table></dd></dl>
+!! end
+
+# FIXME: Does this need a html/php section?
+!! test
+Definition Lists: template interaction
+!! wikitext
+::{{definition_list}}
+
+:one
+::{{definition_list}}
+:::two
+:::three
+::four
+!! html/parsoid
+<dl><dd><dl data-parsoid='{}'><dd about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[]]}' data-mw='{"parts":[":",{"template":{"target":{"wt":"definition_list","href":"./Template:Definition_list"},"params":{},"i":0}}]}'>one</dd><span about="#mwt1">
+</span><dd about="#mwt1">two</dd></dl></dd></dl>
+
+<dl><dd data-parsoid='{}'>one
+<dl><dd about="#mwt2" typeof="mw:Transclusion" data-parsoid='{"pi":[[]]}' data-mw='{"parts":["::",{"template":{"target":{"wt":"definition_list","href":"./Template:Definition_list"},"params":{},"i":0}},"\n:::two\n:::three"]}'>one</dd><span about="#mwt2">
+</span><dd about="#mwt2">two
+<dl><dd>two</dd>
+<dd>three</dd></dl></dd>
+<dd data-parsoid='{}'>four</dd></dl></dd></dl>
+!! end
+
+
+###
+### External links
+###
+!! test
+External links: non-bracketed
+!! wikitext
+Non-bracketed: http://example.com
+!! html
+<p>Non-bracketed: <a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>
+</p>
+!! end
+
+# parsoid doesn't explicitly mark autonumbered links, see T55505
+!! test
+External links: numbered
+!! wikitext
+Numbered: [http://example.com]
+Numbered: [http://example.net]
+Numbered: [http://example.com]
+!! html/php
+<p>Numbered: <a rel="nofollow" class="external autonumber" href="http://example.com">[1]</a>
+Numbered: <a rel="nofollow" class="external autonumber" href="http://example.net">[2]</a>
+Numbered: <a rel="nofollow" class="external autonumber" href="http://example.com">[3]</a>
+</p>
+!! html/parsoid
+<p>Numbered: <a rel="mw:ExtLink" class="external autonumber" href="http://example.com"></a>
+Numbered: <a rel="mw:ExtLink" class="external autonumber" href="http://example.net"></a>
+Numbered: <a rel="mw:ExtLink" class="external autonumber" href="http://example.com"></a></p>
+!!end
+
+!! test
+External links: specified text
+!! wikitext
+Specified text: [http://example.com link]
+!! html
+<p>Specified text: <a rel="nofollow" class="external text" href="http://example.com">link</a>
+</p>
+!!end
+
+!! test
+External links: trail
+!! wikitext
+Linktrails should not work for external links: [http://example.com link]s
+!! html
+<p>Linktrails should not work for external links: <a rel="nofollow" class="external text" href="http://example.com">link</a>s
+</p>
+!! end
+
+!! test
+External links: dollar sign in URL
+!! wikitext
+http://example.com/1$2345
+!! html
+<p><a rel="nofollow" class="external free" href="http://example.com/1$2345">http://example.com/1$2345</a>
+</p>
+!! end
+
+# parsoid doesn't explicitly mark autonumbered links, see T55505
+!! test
+External links: dollar sign in URL (autonumber)
+!! wikitext
+[http://example.com/1$2345]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="http://example.com/1$2345">[1]</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="http://example.com/1$2345"></a></p>
+!!end
+
+!! test
+External links: open square bracket forbidden in URL (T6377)
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+http://example.com/1[2345
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com/1">http://example.com/1</a>[2345
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com/1">http://example.com/1</a>[2345</p>
+!! end
+
+!! test
+External links: open square bracket forbidden in URL (named) (T6377)
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+[http://example.com/1[2345]
+!! html/php
+<p><a rel="nofollow" class="external text" href="http://example.com/1">[2345</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external text" href="http://example.com/1">[2345</a></p>
+!!end
+
+# parsoid adds a space before the link name
+!! test
+External links: open square bracket forbidden in URL (named) (T6377)
+Parsoid variant.
+!! wikitext
+[http://example.com/1 [2345]
+!! html
+<p><a rel="nofollow" class="external text" href="http://example.com/1">[2345</a>
+</p>
+!!end
+
+!! test
+External links: nowiki in URL link text (T8230)
+!! wikitext
+[http://example.com/ <nowiki>''example site''</nowiki>]
+!! html
+<p><a rel="nofollow" class="external text" href="http://example.com/">''example site''</a>
+</p>
+!! end
+
+!! test
+External links: newline forbidden in text (T8230 regression check)
+!! wikitext
+[http://example.com/ first
+second]
+!! html
+<p>[<a rel="nofollow" class="external free" href="http://example.com/">http://example.com/</a> first
+second]
+</p>
+!!end
+
+!! test
+External links: Pipe char between url and text
+!! wikitext
+[http://example.com | link]
+!! html
+<p><a rel="nofollow" class="external text" href="http://example.com">| link</a>
+</p>
+!!end
+
+!! test
+External links: protocol-relative URL in brackets
+!! wikitext
+[//example.com/ Test]
+!! html
+<p><a rel="nofollow" class="external text" href="//example.com/">Test</a>
+</p>
+!! end
+
+# parsoid doesn't explicitly mark autonumbered links, see T55505
+!! test
+External links: protocol-relative URL in brackets without text
+!! wikitext
+[//example.com]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="//example.com">[1]</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="//example.com"></a></p>
+!! end
+
+!! test
+External links: protocol-relative URL in free text is left alone
+!! wikitext
+//example.com/Foo
+!! html
+<p>//example.com/Foo
+</p>
+!!end
+
+!! test
+External links: protocol-relative URL in the middle of a word is left alone (T32269)
+!! wikitext
+foo//example.com/Foo
+!! html
+<p>foo//example.com/Foo
+</p>
+!! end
+
+## html2wt and html2html will fail because we will prefer the :en: interwiki prefix over wikipedia:
+!! test
+External links: with no contents
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+[http://en.wikipedia.org/wiki/Foo]
+
+[[wikipedia:Foo|Bar]]
+
+[[wikipedia:Foo|<span>Bar</span>]]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="http://en.wikipedia.org/wiki/Foo">[1]</a>
+</p><p><a href="http://en.wikipedia.org/wiki/Foo" class="extiw" title="wikipedia:Foo">Bar</a>
+</p><p><a href="http://en.wikipedia.org/wiki/Foo" class="extiw" title="wikipedia:Foo"><span>Bar</span></a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="http://en.wikipedia.org/wiki/Foo"></a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/Foo" title="wikipedia:Foo">Bar</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/Foo" title="wikipedia:Foo"><span>Bar</span></a></p>
+!! end
+
+!! test
+External links: Free with trailing punctuation
+!! wikitext
+http://example.com,
+http://example.com;
+http://example.com\
+http://example.com.
+http://example.com:
+http://example.com!
+http://example.com?
+http://example.com)
+http://example.com/url_with_(brackets)
+(http://example.com/url_without_brackets)
+http://example.com/url_with_entity&amp;
+http://example.com/url_with_entity&#x26;
+http://example.com/url_with_entity&#038;
+http://example.com/url_with_entity&nbsp;
+http://example.com/url_with_entity&#xA0;
+http://example.com/url_with_entity&#160;
+http://example.com/url_with_entity&lt;
+http://example.com/url_with_entity&#x3C;
+http://example.com/url_with_entity&#60;
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>,
+<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>;
+<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>\
+<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>.
+<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>:
+<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>!
+<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>?
+<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>)
+<a rel="nofollow" class="external free" href="http://example.com/url_with_(brackets)">http://example.com/url_with_(brackets)</a>
+(<a rel="nofollow" class="external free" href="http://example.com/url_without_brackets">http://example.com/url_without_brackets</a>)
+<a rel="nofollow" class="external free" href="http://example.com/url_with_entity&amp;">http://example.com/url_with_entity&amp;</a>
+<a rel="nofollow" class="external free" href="http://example.com/url_with_entity&amp;">http://example.com/url_with_entity&amp;</a>
+<a rel="nofollow" class="external free" href="http://example.com/url_with_entity&amp;">http://example.com/url_with_entity&amp;</a>
+<a rel="nofollow" class="external free" href="http://example.com/url_with_entity">http://example.com/url_with_entity</a>&#160;
+<a rel="nofollow" class="external free" href="http://example.com/url_with_entity">http://example.com/url_with_entity</a>&#xa0;
+<a rel="nofollow" class="external free" href="http://example.com/url_with_entity">http://example.com/url_with_entity</a>&#160;
+<a rel="nofollow" class="external free" href="http://example.com/url_with_entity">http://example.com/url_with_entity</a>&lt;
+<a rel="nofollow" class="external free" href="http://example.com/url_with_entity">http://example.com/url_with_entity</a>&#x3c;
+<a rel="nofollow" class="external free" href="http://example.com/url_with_entity">http://example.com/url_with_entity</a>&#60;
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a>,
+<a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a>;
+<a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a>\
+<a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a>.
+<a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a>:
+<a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a>!
+<a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a>?
+<a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a>)
+<a rel="mw:ExtLink" class="external free" href="http://example.com/url_with_(brackets)">http://example.com/url_with_(brackets)</a>
+(<a rel="mw:ExtLink" class="external free" href="http://example.com/url_without_brackets">http://example.com/url_without_brackets</a>)
+<a rel="mw:ExtLink" class="external free" href="http://example.com/url_with_entity&amp;">http://example.com/url_with_entity&amp;</a>
+<a rel="mw:ExtLink" class="external free" href="http://example.com/url_with_entity&amp;">http://example.com/url_with_entity&amp;</a>
+<a rel="mw:ExtLink" class="external free" href="http://example.com/url_with_entity&amp;">http://example.com/url_with_entity&amp;</a>
+<a rel="mw:ExtLink" class="external free" href="http://example.com/url_with_entity">http://example.com/url_with_entity</a><span typeof="mw:Entity" data-parsoid='{"src":"&amp;nbsp;","srcContent":" "}'> </span>
+<a rel="mw:ExtLink" class="external free" href="http://example.com/url_with_entity">http://example.com/url_with_entity</a><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#xA0;","srcContent":" "}'> </span>
+<a rel="mw:ExtLink" class="external free" href="http://example.com/url_with_entity">http://example.com/url_with_entity</a><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#160;","srcContent":" "}'> </span>
+<a rel="mw:ExtLink" class="external free" href="http://example.com/url_with_entity">http://example.com/url_with_entity</a><span typeof="mw:Entity" data-parsoid='{"src":"&amp;lt;","srcContent":"&lt;"}'>&lt;</span>
+<a rel="mw:ExtLink" class="external free" href="http://example.com/url_with_entity">http://example.com/url_with_entity</a><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#x3C;","srcContent":"&lt;"}'>&lt;</span>
+<a rel="mw:ExtLink" class="external free" href="http://example.com/url_with_entity">http://example.com/url_with_entity</a><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#60;","srcContent":"&lt;"}'>&lt;</span></p>
+!! end
+
+!! test
+External links: tricky Parsoid html2html case
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+http://example.com/url_with_entity&amp;amp;
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com/url_with_entity&amp;amp">http://example.com/url_with_entity&amp;amp</a>;
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com/url_with_entity&amp;amp">http://example.com/url_with_entity&amp;amp</a>;</p>
+!! end
+
+!! test
+External links: Free with trailing quotes (T113666)
+!! wikitext
+'''News:''' Stuff here
+
+news:'a'b''c''d e
+!! html/php
+<p><b>News:</b> Stuff here
+</p><p><a rel="nofollow" class="external free" href="news:&#39;a&#39;b">news:'a'b</a><i>c</i>d e
+</p>
+!! html/parsoid
+<p><b>News:</b> Stuff here</p>
+<p><a rel="mw:ExtLink" class="external free" href="news:'a'b">news:'a'b</a><i>c</i>d e</p>
+!! end
+
+!! test
+External links: with entity
+!! wikitext
+[http://&#x20;www.librarieswithoutborders.org Libraries without borders]
+!! html/php
+<p><a rel="nofollow" class="external text" href="http://+www.librarieswithoutborders.org">Libraries without borders</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external text" href="http://+www.librarieswithoutborders.org" data-parsoid='{"a":{"href":"http://+www.librarieswithoutborders.org"},"sa":{"href":"http://&amp;#x20;www.librarieswithoutborders.org"}}'>Libraries without borders</a></p>
+!! end
+
+!! test
+External links: Lone protocols are never linked (T105697)
+!! wikitext
+http://
+http://;
+(http://)
+bitcoin:
+bitcoin:;
+(bitcoin:)
+!! html
+<p>http://
+http://;
+(http://)
+bitcoin:
+bitcoin:;
+(bitcoin:)
+</p>
+!! end
+
+!! test
+External links: No preceding word characters allowed (T67278)
+!! wikitext
+NOPEhttp://example.com
+N0http://example.com
+ok:http://example.com
+ok-http://example.com
+!! html
+<p>NOPEhttp://example.com
+N0http://example.com
+ok:<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>
+ok-<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>
+</p>
+!! end
+
+!! test
+External links: nofollow domain exception
+!! wikitext
+A [https://no-nofollow.org/foobar link], and another [https://example.org link].
+!! html
+<p>A <a class="external text" href="https://no-nofollow.org/foobar">link</a>, and another <a rel="nofollow" class="external text" href="https://example.org">link</a>.
+</p>
+!!end
+
+!! test
+External image
+!! wikitext
+External image: http://meta.wikimedia.org/upload/f/f1/Ncwikicol.png
+!! html
+<p>External image: <img src="http://meta.wikimedia.org/upload/f/f1/Ncwikicol.png" alt="Ncwikicol.png"/>
+</p>
+!! end
+
+!! test
+External image from https
+!! wikitext
+External image from https: https://meta.wikimedia.org/upload/f/f1/Ncwikicol.png
+!! html
+<p>External image from https: <img src="https://meta.wikimedia.org/upload/f/f1/Ncwikicol.png" alt="Ncwikicol.png"/>
+</p>
+!! end
+
+!! test
+External image (when not allowed)
+!! options
+wgAllowExternalImages=0
+!! wikitext
+External image: http://meta.wikimedia.org/upload/f/f1/Ncwikicol.png
+!! html
+<p>External image: <a rel="nofollow" class="external free" href="http://meta.wikimedia.org/upload/f/f1/Ncwikicol.png">http://meta.wikimedia.org/upload/f/f1/Ncwikicol.png</a>
+</p>
+!! end
+
+!! test
+Link to non-http image, no img tag
+!! wikitext
+Link to non-http image, no img tag: ftp://example.com/test.jpg
+!! html
+<p>Link to non-http image, no img tag: <a rel="nofollow" class="external free" href="ftp://example.com/test.jpg">ftp://example.com/test.jpg</a>
+</p>
+!! end
+
+!! test
+External links: terminating separator
+!! wikitext
+Terminating separator: http://example.com/thing,
+!! html
+<p>Terminating separator: <a rel="nofollow" class="external free" href="http://example.com/thing">http://example.com/thing</a>,
+</p>
+!! end
+
+!! test
+External links: intervening separator
+!! wikitext
+Intervening separator: http://example.com/1,2,3
+!! html
+<p>Intervening separator: <a rel="nofollow" class="external free" href="http://example.com/1,2,3">http://example.com/1,2,3</a>
+</p>
+!! end
+
+!! test
+External links: old bug with URL in query
+!! wikitext
+Old bug with URL in query: [http://example.com/thing?url=http://example.com link]
+!! html
+<p>Old bug with URL in query: <a rel="nofollow" class="external text" href="http://example.com/thing?url=http://example.com">link</a>
+</p>
+!! end
+
+!! test
+External links: old URL-in-URL bug, mixed protocols
+!! wikitext
+And again with mixed protocols: [ftp://example.com?url=http://example.com link]
+!! html
+<p>And again with mixed protocols: <a rel="nofollow" class="external text" href="ftp://example.com?url=http://example.com">link</a>
+</p>
+!!end
+
+# Since Parsoid is starting to emit canonical wikitext for links,
+# [http://example.com http://example.com] will not RT back to that
+# form anymore.
+!! test
+External links: URL in text
+!! options
+parsoid=wt2html
+!! wikitext
+URL in text: [http://example.com http://example.com]
+!! html/php
+<p>URL in text: <a rel="nofollow" class="external text" href="http://example.com">http://example.com</a>
+</p>
+!! html/parsoid
+<p>URL in text: <a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a></p>
+!! end
+
+!! test
+External links: Clickable images
+!! wikitext
+ja-style clickable images: [http://example.com http://meta.wikimedia.org/upload/f/f1/Ncwikicol.png]
+!! html/php
+<p>ja-style clickable images: <a rel="nofollow" class="external text" href="http://example.com"><img src="http://meta.wikimedia.org/upload/f/f1/Ncwikicol.png" alt="Ncwikicol.png"/></a>
+</p>
+!! html/parsoid
+<p>ja-style clickable images: <a rel="mw:ExtLink" class="external text" href="http://example.com"><img src="http://meta.wikimedia.org/upload/f/f1/Ncwikicol.png" alt="Ncwikicol.png" data-parsoid='{"type":"extlink"}'/></a></p>
+!! end
+
+!! test
+External links: raw ampersand
+!! wikitext
+Old &amp; use: http://x&y
+!! html
+<p>Old &amp; use: <a rel="nofollow" class="external free" href="http://x&amp;y">http://x&amp;y</a>
+</p>
+!! end
+
+!! test
+External links: encoded ampersand
+!! wikitext
+Old &amp; use: http://x&amp;y
+!! html/php
+<p>Old &amp; use: <a rel="nofollow" class="external free" href="http://x&amp;y">http://x&amp;y</a>
+</p>
+!! html/parsoid
+<p>Old <span typeof="mw:Entity">&amp;</span> use: <a rel="mw:ExtLink" class="external free" href="http://x&amp;y">http://x&amp;y</a></p>
+!! end
+
+!! test
+External links: encoded equals (T8102)
+!! wikitext
+http://example.com/?foo&#61;bar
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com/?foo=bar">http://example.com/?foo=bar</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com/?foo=bar">http://example.com/?foo=bar</a></p>
+!! end
+
+##
+## Note that parsoid doesn't explicit mark autonumbered links, nor
+## does it number them. As discussed in T55505, we can identify
+## autonumbered links via CSS.
+##
+
+!! test
+External links: [raw ampersand]
+!! wikitext
+Old &amp; use: [http://x&y]
+!! html/php
+<p>Old &amp; use: <a rel="nofollow" class="external autonumber" href="http://x&amp;y">[1]</a>
+</p>
+!! html/parsoid
+<p>Old <span typeof="mw:Entity">&amp;</span> use: <a rel="mw:ExtLink" class="external autonumber" href="http://x&amp;y"></a></p>
+!! end
+
+# note that parsoid html is identical to [raw ampersand] case; so html2wt
+# mode will return the [raw ampersand] wikitext
+!! test
+External links: [encoded ampersand]
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+Old &amp; use: [http://x&amp;y]
+!! html/php
+<p>Old &amp; use: <a rel="nofollow" class="external autonumber" href="http://x&amp;y">[1]</a>
+</p>
+!! html/parsoid
+<p>Old <span typeof="mw:Entity">&amp;</span> use: <a rel="mw:ExtLink" class="external autonumber" href="http://x&amp;y"></a></p>
+!! end
+
+!! test
+External links: [raw equals]
+!! wikitext
+[http://example.com/?foo=bar]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="http://example.com/?foo=bar">[1]</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="http://example.com/?foo=bar"></a></p>
+!! end
+
+# note that parsoid html is identical to [raw equals] case; so html2wt
+# mode will return the [raw equals] wikitext
+!! test
+External links: [encoded equals] (T8102)
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[http://example.com/?foo&#61;bar]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="http://example.com/?foo=bar">[1]</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="http://example.com/?foo=bar"></a></p>
+!! end
+
+# xxx parsoid strips the IDN character, so the round-trip tests will
+# obviously fail and are disabled. --cscott
+!! test
+External links: [IDN ignored character reference in hostname; strip it right off]
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[http://e&zwnj;xample.com/]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="http://example.com/">[1]</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="http://example.com/"></a></p>
+!! end
+
+# FIXME: This test (the IDN characters in the text of a link) is an inconsistency.
+# Where an external link could easily circumvent the sanitization of the text of
+# a link like this (where an IDN-ignore character is in the URL somewhere), this
+# test demands a higher standard. That's a bit strange.
+#
+# Example:
+#
+# http://e‌xample.com -> [http://example.com|http://example.com]
+# [http://example.com|http://e‌xample.com] -> [http://example.com|http://e‌xample.com]
+#
+# The first example is sanitized, but the second is not. Any security benefits
+# from this production are trivial to circumvent. Either remove this test and
+# let the parser(s) do their thing unaccosted, or fix the inconsistency and change
+# the test accordingly.
+#
+# All our love,
+# The Parsoid team.
+# xxx parsoid strips the IDN character, so the round-trip tests will
+# obviously fail and are disabled. --cscott
+!! test
+External links: IDN ignored character reference in hostname; strip it right off
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+http://e&zwnj;xample.com/
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com/">http://example.com/</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com/">http://example.com/</a></p>
+!! end
+
+!! test
+External links: www.jpeg.org (T2554)
+!! wikitext
+http://www.jpeg.org
+!! html
+<p><a rel="nofollow" class="external free" href="http://www.jpeg.org">http://www.jpeg.org</a>
+</p>
+!! end
+
+# parsoid doesn't explicitly mark autonumbered links, see T55505
+!! test
+External links: URL within URL (T2002)
+!! wikitext
+[http://www.unausa.org/newindex.asp?place=http://www.unausa.org/programs/mun.asp]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="http://www.unausa.org/newindex.asp?place=http://www.unausa.org/programs/mun.asp">[1]</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="http://www.unausa.org/newindex.asp?place=http://www.unausa.org/programs/mun.asp"></a></p>
+!! end
+
+!! test
+T2361: URL inside bracketed URL
+!! wikitext
+[http://www.example.com/foo http://www.example.com/bar]
+!! html
+<p><a rel="nofollow" class="external text" href="http://www.example.com/foo">http://www.example.com/bar</a>
+</p>
+!! end
+
+!! test
+T2361: URL within URL, not bracketed
+!! wikitext
+http://www.example.com/foo?=http://www.example.com/bar
+!! html
+<p><a rel="nofollow" class="external free" href="http://www.example.com/foo?=http://www.example.com/bar">http://www.example.com/foo?=http://www.example.com/bar</a>
+</p>
+!! end
+
+!! test
+T2289: ">"-token in URL-tail
+!! wikitext
+http://www.example.com/<hello>
+!! html
+<p><a rel="nofollow" class="external free" href="http://www.example.com/">http://www.example.com/</a>&lt;hello&gt;
+</p>
+!!end
+
+!! test
+T2289: literal ">"-token in URL-tail
+!! wikitext
+http://www.example.com/<b>html</b>
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://www.example.com/">http://www.example.com/</a><b>html</b>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://www.example.com/" data-parsoid='{"stx":"url"}'>http://www.example.com/</a><b data-parsoid='{"stx":"html"}'>html</b></p>
+!! end
+
+!! test
+T2289: ">"-token in bracketed URL
+!! wikitext
+[http://www.example.com/<hello> stuff]
+!! html
+<p><a rel="nofollow" class="external text" href="http://www.example.com/">&lt;hello&gt; stuff</a>
+</p>
+!!end
+
+!! test
+T2289: literal ">"-token in bracketed URL
+!! wikitext
+[http://www.example.com/<b>html</b> stuff]
+!! html
+<p><a rel="nofollow" class="external text" href="http://www.example.com/"><b>html</b> stuff</a>
+</p>
+!!end
+
+!! test
+T2289: literal double quote at end of URL
+!! wikitext
+http://www.example.com/"hello"
+!! html
+<p><a rel="nofollow" class="external free" href="http://www.example.com/">http://www.example.com/</a>"hello"
+</p>
+!!end
+
+!! test
+T2289: literal double quote in bracketed URL
+!! wikitext
+[http://www.example.com/"hello" stuff]
+!! html
+<p><a rel="nofollow" class="external text" href="http://www.example.com/">"hello" stuff</a>
+</p>
+!!end
+
+!! test
+External links: multiple legal whitespace is fine, Magnus. Don't break it please. (T7081)
+!! wikitext
+[http://www.example.com test]
+!! html
+<p><a rel="nofollow" class="external text" href="http://www.example.com">test</a>
+</p>
+!! end
+
+!! test
+External links: link text with spaces
+!! wikitext
+[http://www.example.com a b c]
+[http://www.example.com ''a'' ''b'']
+!! html
+<p><a rel="nofollow" class="external text" href="http://www.example.com">a b c</a>
+<a rel="nofollow" class="external text" href="http://www.example.com"><i>a</i> <i>b</i></a>
+</p>
+!! end
+
+# Note edge case difference between PHP and Parsoid here.
+!! test
+External links: wiki links within external link (T5695)
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+[http://example.com [[wikilink]] embedded in ext link]
+
+[http://example.com test [[wikilink]] embedded in ext link]
+!! html/php
+<p><a rel="nofollow" class="external text" href="http://example.com"></a><a href="/index.php?title=Wikilink&amp;action=edit&amp;redlink=1" class="new" title="Wikilink (page does not exist)">wikilink</a><a rel="nofollow" class="external text" href="http://example.com"> embedded in ext link</a>
+</p><p><a rel="nofollow" class="external text" href="http://example.com">test </a><a href="/index.php?title=Wikilink&amp;action=edit&amp;redlink=1" class="new" title="Wikilink (page does not exist)">wikilink</a><a rel="nofollow" class="external text" href="http://example.com"> embedded in ext link</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="http://example.com"></a><a rel="mw:WikiLink" href="./Wikilink" title="Wikilink">wikilink</a><span> embedded in ext link</span></p>
+<p><a rel="mw:ExtLink" class="external text" href="http://example.com">test </a><a rel="mw:WikiLink" href="./Wikilink" title="Wikilink">wikilink</a><span> embedded in ext link</span></p>
+!! end
+
+!! test
+T2787: Links with one slash after the url protocol are invalid
+!! wikitext
+http:/example.com
+
+[http:/example.com title]
+!! html
+<p>http:/example.com
+</p><p>[http:/example.com title]
+</p>
+!! end
+
+!! test
+Bracketed external links with template-generated invalid target
+!! wikitext
+[{{echo|http:/example.com}} title]
+!! html
+<p>[http:/example.com title]
+</p>
+!! end
+
+# wt2html only because Parsoid would want to add <nowiki>s coming from html
+!! test
+Broken wikilinks (but not external links) prevent templates from closing
+!! options
+parsoid=wt2html
+!! wikitext
+[http://example.com x
+
+{{echo|[http://example.com x}}
+
+[[Foo
+
+{{echo|[[Foo}}
+!! html/php
+<p>[<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a> x
+</p><p>[<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a> x
+</p><p>[[Foo
+</p><p>{{echo|[[Foo}}
+</p>
+!! html/parsoid
+<p>[<a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a> x</p>
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[http://example.com x"}},"i":0}}]}'>[<a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a> x</p>
+<p>[[Foo</p>
+<p>{{echo|[[Foo}}</p>
+!! end
+
+!! test
+Wikilinks with embedded newlines are not broken
+!! wikitext
+{{echo|[[ Foo
+B
+C]]}}
+!! html/php
+<p>[[ Foo
+B
+C]]
+</p>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[ Foo\nB\nC]]"}},"i":0}}]}'>[[ Foo B C]]</p>
+!! end
+
+!! test
+Broken templates
+!! options
+parsoid=wt2html
+!! wikitext
+{{echo|[[Foo|}}]]
+
+[[Foo|{{echo|]]}}
+!! html/php
+<p>{{echo|<a href="/wiki/Foo" title="Foo">}}</a>
+</p><p>[[Foo|]]
+</p>
+!! html/parsoid
+<p>{{echo|<a rel="mw:WikiLink" href="./Foo" title="Foo">}}</a></p>
+<p>[[Foo|<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"]]"}},"i":0}}]}'>]]</span></p>
+!! end
+
+!! test
+T4702: Mismatched <i>, <b> and <a> tags are invalid
+!! wikitext
+''[http://example.com text'']
+[http://example.com '''text]'''
+''Something [http://example.com in italic'']
+''Something [http://example.com mixed''''', even bold]'''
+'''''Now [http://example.com both''''']
+!! html
+<p><a rel="nofollow" class="external text" href="http://example.com"><i>text</i></a>
+<a rel="nofollow" class="external text" href="http://example.com"><b>text</b></a>
+<i>Something </i><a rel="nofollow" class="external text" href="http://example.com"><i>in italic</i></a>
+<i>Something </i><a rel="nofollow" class="external text" href="http://example.com"><i>mixed</i><b>, even bold</b></a>
+<i><b>Now </b></i><a rel="nofollow" class="external text" href="http://example.com"><i><b>both</b></i></a>
+</p>
+!! end
+
+
+!! test
+T6781: %26 in URL
+!! wikitext
+http://www.example.com/?title=AT%26T
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://www.example.com/?title=AT%26T">http://www.example.com/?title=AT%26T</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://www.example.com/?title=AT%26T">http://www.example.com/?title=AT%26T</a></p>
+!! end
+
+# According to https://www.w3.org/TR/2011/WD-html5-20110525/Overview.html#parsing-urls a plain
+# % is actually legal in HTML5. Any change in output would need testing though.
+!! test
+T6781, T7267: %25 in URL
+!! wikitext
+http://www.example.com/?title=100%25_Bran
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://www.example.com/?title=100%25_Bran">http://www.example.com/?title=100%25_Bran</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://www.example.com/?title=100%25_Bran">http://www.example.com/?title=100%25_Bran</a></p>
+!! end
+
+!! test
+T6781, T7267: %28, %29 in URL
+!! wikitext
+http://www.example.com/?title=Ben-Hur_%281959_film%29
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://www.example.com/?title=Ben-Hur_%281959_film%29">http://www.example.com/?title=Ben-Hur_%281959_film%29</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://www.example.com/?title=Ben-Hur_%281959_film%29">http://www.example.com/?title=Ben-Hur_%281959_film%29</a></p>
+!! end
+
+
+!! test
+T6781: %26 in autonumber URL
+!! wikitext
+[http://www.example.com/?title=AT%26T]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="http://www.example.com/?title=AT%26T">[1]</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="http://www.example.com/?title=AT%26T"></a></p>
+!! end
+
+!! test
+T6781, T7267: %26 in autonumber URL
+!! wikitext
+[http://www.example.com/?title=100%25_Bran]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="http://www.example.com/?title=100%25_Bran">[1]</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="http://www.example.com/?title=100%25_Bran"></a></p>
+!! end
+
+!! test
+T6781, T7267: %28, %29 in autonumber URL
+!! wikitext
+[http://www.example.com/?title=Ben-Hur_%281959_film%29]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="http://www.example.com/?title=Ben-Hur_%281959_film%29">[1]</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="http://www.example.com/?title=Ben-Hur_%281959_film%29"></a></p>
+!! end
+
+
+!! test
+T6781: %26 in bracketed URL
+!! wikitext
+[http://www.example.com/?title=AT%26T link]
+!! html/php
+<p><a rel="nofollow" class="external text" href="http://www.example.com/?title=AT%26T">link</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external text" href="http://www.example.com/?title=AT%26T">link</a></p>
+!! end
+
+!! test
+T6781, T7267: %25 in bracketed URL
+!! wikitext
+[http://www.example.com/?title=100%25_Bran link]
+!! html
+<p><a rel="nofollow" class="external text" href="http://www.example.com/?title=100%25_Bran">link</a>
+</p>
+!! end
+
+!! test
+T6781, T7267: %28, %29 in bracketed URL
+!! wikitext
+[http://www.example.com/?title=Ben-Hur_%281959_film%29 link]
+!! html/php
+<p><a rel="nofollow" class="external text" href="http://www.example.com/?title=Ben-Hur_%281959_film%29">link</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external text" href="http://www.example.com/?title=Ben-Hur_%281959_film%29">link</a></p>
+!! end
+
+!! test
+External link containing a period in the anchor. (T65947)
+!! wikitext
+[//foo.org/bar#baz. bang]
+
+[//foo.org/bar. bang]
+!! html/php
+<p><a rel="nofollow" class="external text" href="//foo.org/bar#baz.">bang</a>
+</p><p><a rel="nofollow" class="external text" href="//foo.org/bar.">bang</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external text" href="//foo.org/bar#baz.">bang</a></p>
+<p><a rel="mw:ExtLink" class="external text" href="//foo.org/bar.">bang</a></p>
+!! end
+
+!! test
+External link containing a single quote. (T65947)
+!! wikitext
+[//foo.org/bar'baz]
+
+[//foo.org/bar'baz bang]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="//foo.org/bar&#39;baz">[1]</a>
+</p><p><a rel="nofollow" class="external text" href="//foo.org/bar&#39;baz">bang</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="//foo.org/bar'baz"></a></p>
+<p><a rel="mw:ExtLink" class="external text" href="//foo.org/bar'baz">bang</a></p>
+!! end
+
+!! test
+External link containing double-single-quotes in text '' (T6598 sanity check)
+!! wikitext
+Some [http://example.com/ pretty ''italics'' and stuff]!
+!! html
+<p>Some <a rel="nofollow" class="external text" href="http://example.com/">pretty <i>italics</i> and stuff</a>!
+</p>
+!! end
+
+!! test
+External link containing double-single-quotes in text embedded in italics (T6598 sanity check)
+!! wikitext
+''Some [http://example.com/ pretty ''italics'' and stuff]!''
+!! html
+<p><i>Some </i><a rel="nofollow" class="external text" href="http://example.com/"><i>pretty </i>italics<i> and stuff</i></a><i>!</i>
+</p>
+!! end
+
+# Don't add the html/php section since the output is broken and there isn't any reason to spec it
+!! test
+External link containing double-single-quotes with no space separating the url from text in italics
+!! wikitext
+[http://www.musee-picasso.fr/pages/page_id18528_u1l2.htm''La muerte de Casagemas'' (1901) en el sitio de [[Museo Picasso (París)|Museo Picasso]].]
+!! html/php+tidy
+<p><a rel="nofollow" class="external text" href="http://www.musee-picasso.fr/pages/page_id18528_u1l2.htm"><i>La muerte de Casagemas</i> (1901) en el sitio de </a><a href="/index.php?title=Museo_Picasso_(Par%C3%ADs)&amp;action=edit&amp;redlink=1" class="new" title="Museo Picasso (París) (page does not exist)">Museo Picasso</a>.
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external text" href="http://www.musee-picasso.fr/pages/page_id18528_u1l2.htm"><i>La muerte de Casagemas</i> (1901) en el sitio de </a><a rel="mw:WikiLink" href="./Museo_Picasso_(París)" title="Museo Picasso (París)">Museo Picasso</a><span>.</span></p>
+!! end
+
+!! test
+External link with comments in link text
+!! wikitext
+[http://www.google.com Google <!-- comment -->]
+!! html/php
+<p><a rel="nofollow" class="external text" href="http://www.google.com">Google </a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external text" href="http://www.google.com">Google <!-- comment --></a></p>
+!! end
+
+!! test
+External link to bare IPv4 address
+!! wikitext
+[http://192.168.0.1 Link]
+!! html/php
+<p><a rel="nofollow" class="external text" href="http://192.168.0.1">Link</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external text" href="http://192.168.0.1">Link</a></p>
+!! end
+
+!! test
+URL-encoding in URL functions (single parameter)
+!! wikitext
+{{localurl:Some page|amp=&}}
+!! html
+<p>/index.php?title=Some_page&amp;amp=&amp;
+</p>
+!! end
+
+!! test
+URL-encoding in URL functions (multiple parameters)
+!! wikitext
+{{localurl:Some page|q=?&amp=&}}
+!! html
+<p>/index.php?title=Some_page&amp;q=?&amp;amp=&amp;
+</p>
+!! end
+
+!! test
+Brackets in urls
+!! wikitext
+http://example.com/index.php?foozoid%5B%5D=bar
+
+http://example.com/index.php?foozoid&#x5B;&#x5D;=bar
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com/index.php?foozoid%5B%5D=bar">http://example.com/index.php?foozoid%5B%5D=bar</a>
+</p><p><a rel="nofollow" class="external free" href="http://example.com/index.php?foozoid%5B%5D=bar">http://example.com/index.php?foozoid%5B%5D=bar</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com/index.php?foozoid%5B%5D=bar">http://example.com/index.php?foozoid%5B%5D=bar</a></p>
+
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com/index.php?foozoid%5B%5D=bar" data-parsoid='{"stx":"url","a":{"href":"http://example.com/index.php?foozoid%5B%5D=bar"},"sa":{"href":"http://example.com/index.php?foozoid&amp;#x5B;&amp;#x5D;=bar"}}'>http://example.com/index.php?foozoid%5B%5D=bar</a></p>
+!! end
+
+!! test
+IPv6 urls, autolink format (T23261)
+!! wikitext
+http://[2404:130:0:1000::187:2]/index.php
+
+Examples from RFC 2373, section 2.2:
+
+*http://[1080::8:800:200C:417A]/unicast
+*http://[FF01::101]/multicast
+*http://[::1]/loopback
+*http://[::]/unspecified
+*http://[::13.1.68.3]/ipv4compat
+*http://[::FFFF:129.144.52.38]/ipv4compat
+
+Examples from RFC 2732, section 2:
+
+*http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html
+*http://[1080:0:0:0:8:800:200C:417A]/index.html
+*http://[3ffe:2a00:100:7031::1]
+*http://[1080::8:800:200C:417A]/foo
+*http://[::192.9.5.5]/ipng
+*http://[::FFFF:129.144.52.38]:80/index.html
+*http://[2010:836B:4179::836B:4179]
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://[2404:130:0:1000::187:2]/index.php">http://[2404:130:0:1000::187:2]/index.php</a>
+</p><p>Examples from <a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc2373">RFC 2373</a>, section 2.2:
+</p>
+<ul><li><a rel="nofollow" class="external free" href="http://[1080::8:800:200C:417A]/unicast">http://[1080::8:800:200C:417A]/unicast</a></li>
+<li><a rel="nofollow" class="external free" href="http://[FF01::101]/multicast">http://[FF01::101]/multicast</a></li>
+<li><a rel="nofollow" class="external free" href="http://[::1]/loopback">http://[::1]/loopback</a></li>
+<li><a rel="nofollow" class="external free" href="http://[::]/unspecified">http://[::]/unspecified</a></li>
+<li><a rel="nofollow" class="external free" href="http://[::13.1.68.3]/ipv4compat">http://[::13.1.68.3]/ipv4compat</a></li>
+<li><a rel="nofollow" class="external free" href="http://[::FFFF:129.144.52.38]/ipv4compat">http://[::FFFF:129.144.52.38]/ipv4compat</a></li></ul>
+<p>Examples from <a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc2732">RFC 2732</a>, section 2:
+</p>
+<ul><li><a rel="nofollow" class="external free" href="http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html">http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html</a></li>
+<li><a rel="nofollow" class="external free" href="http://[1080:0:0:0:8:800:200C:417A]/index.html">http://[1080:0:0:0:8:800:200C:417A]/index.html</a></li>
+<li><a rel="nofollow" class="external free" href="http://[3ffe:2a00:100:7031::1]">http://[3ffe:2a00:100:7031::1]</a></li>
+<li><a rel="nofollow" class="external free" href="http://[1080::8:800:200C:417A]/foo">http://[1080::8:800:200C:417A]/foo</a></li>
+<li><a rel="nofollow" class="external free" href="http://[::192.9.5.5]/ipng">http://[::192.9.5.5]/ipng</a></li>
+<li><a rel="nofollow" class="external free" href="http://[::FFFF:129.144.52.38]:80/index.html">http://[::FFFF:129.144.52.38]:80/index.html</a></li>
+<li><a rel="nofollow" class="external free" href="http://[2010:836B:4179::836B:4179]">http://[2010:836B:4179::836B:4179]</a></li></ul>
+
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://[2404:130:0:1000::187:2]/index.php">http://[2404:130:0:1000::187:2]/index.php</a></p>
+
+<p>Examples from <a href="https://tools.ietf.org/html/rfc2373" rel="mw:ExtLink" class="external text">RFC 2373</a>, section 2.2:</p>
+<ul><li><a rel="mw:ExtLink" class="external free" href="http://[1080::8:800:200C:417A]/unicast">http://[1080::8:800:200C:417A]/unicast</a></li>
+<li><a rel="mw:ExtLink" class="external free" href="http://[FF01::101]/multicast">http://[FF01::101]/multicast</a></li>
+<li><a rel="mw:ExtLink" class="external free" href="http://[::1]/loopback">http://[::1]/loopback</a></li>
+<li><a rel="mw:ExtLink" class="external free" href="http://[::]/unspecified">http://[::]/unspecified</a></li>
+<li><a rel="mw:ExtLink" class="external free" href="http://[::13.1.68.3]/ipv4compat">http://[::13.1.68.3]/ipv4compat</a></li>
+<li><a rel="mw:ExtLink" class="external free" href="http://[::FFFF:129.144.52.38]/ipv4compat">http://[::FFFF:129.144.52.38]/ipv4compat</a></li></ul>
+
+<p>Examples from <a href="https://tools.ietf.org/html/rfc2732" rel="mw:ExtLink" class="external text">RFC 2732</a>, section 2:</p>
+<ul><li><a rel="mw:ExtLink" class="external free" href="http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html">http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html</a></li>
+<li><a rel="mw:ExtLink" class="external free" href="http://[1080:0:0:0:8:800:200C:417A]/index.html">http://[1080:0:0:0:8:800:200C:417A]/index.html</a></li>
+<li><a rel="mw:ExtLink" class="external free" href="http://[3ffe:2a00:100:7031::1]">http://[3ffe:2a00:100:7031::1]</a></li>
+<li><a rel="mw:ExtLink" class="external free" href="http://[1080::8:800:200C:417A]/foo">http://[1080::8:800:200C:417A]/foo</a></li>
+<li><a rel="mw:ExtLink" class="external free" href="http://[::192.9.5.5]/ipng">http://[::192.9.5.5]/ipng</a></li>
+<li><a rel="mw:ExtLink" class="external free" href="http://[::FFFF:129.144.52.38]:80/index.html">http://[::FFFF:129.144.52.38]:80/index.html</a></li>
+<li><a rel="mw:ExtLink" class="external free" href="http://[2010:836B:4179::836B:4179]">http://[2010:836B:4179::836B:4179]</a></li></ul>
+!! end
+
+!! test
+IPv6 urls, bracketed format (T23261)
+!! wikitext
+[http://[2404:130:0:1000::187:2]/index.php test]
+
+Examples from RFC 2373, section 2.2:
+
+*[http://[1080::8:800:200C:417A] unicast]
+*[http://[FF01::101] multicast]
+*[http://[::1]/ loopback]
+*[http://[::] unspecified]
+*[http://[::13.1.68.3] ipv4compat]
+*[http://[::FFFF:129.144.52.38] ipv4compat]
+
+Examples from RFC 2732, section 2:
+
+*[http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html 1]
+*[http://[1080:0:0:0:8:800:200C:417A]/index.html 2]
+*[http://[3ffe:2a00:100:7031::1] 3]
+*[http://[1080::8:800:200C:417A]/foo 4]
+*[http://[::192.9.5.5]/ipng 5]
+*[http://[::FFFF:129.144.52.38]:80/index.html 6]
+*[http://[2010:836B:4179::836B:4179] 7]
+!! html/php
+<p><a rel="nofollow" class="external text" href="http://[2404:130:0:1000::187:2]/index.php">test</a>
+</p><p>Examples from <a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc2373">RFC 2373</a>, section 2.2:
+</p>
+<ul><li><a rel="nofollow" class="external text" href="http://[1080::8:800:200C:417A]">unicast</a></li>
+<li><a rel="nofollow" class="external text" href="http://[FF01::101]">multicast</a></li>
+<li><a rel="nofollow" class="external text" href="http://[::1]/">loopback</a></li>
+<li><a rel="nofollow" class="external text" href="http://[::]">unspecified</a></li>
+<li><a rel="nofollow" class="external text" href="http://[::13.1.68.3]">ipv4compat</a></li>
+<li><a rel="nofollow" class="external text" href="http://[::FFFF:129.144.52.38]">ipv4compat</a></li></ul>
+<p>Examples from <a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc2732">RFC 2732</a>, section 2:
+</p>
+<ul><li><a rel="nofollow" class="external text" href="http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html">1</a></li>
+<li><a rel="nofollow" class="external text" href="http://[1080:0:0:0:8:800:200C:417A]/index.html">2</a></li>
+<li><a rel="nofollow" class="external text" href="http://[3ffe:2a00:100:7031::1]">3</a></li>
+<li><a rel="nofollow" class="external text" href="http://[1080::8:800:200C:417A]/foo">4</a></li>
+<li><a rel="nofollow" class="external text" href="http://[::192.9.5.5]/ipng">5</a></li>
+<li><a rel="nofollow" class="external text" href="http://[::FFFF:129.144.52.38]:80/index.html">6</a></li>
+<li><a rel="nofollow" class="external text" href="http://[2010:836B:4179::836B:4179]">7</a></li></ul>
+
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external text" href="http://[2404:130:0:1000::187:2]/index.php">test</a></p>
+
+<p>Examples from <a href="https://tools.ietf.org/html/rfc2373" rel="mw:ExtLink" class="external text">RFC 2373</a>, section 2.2:</p>
+<ul><li><a rel="mw:ExtLink" class="external text" href="http://[1080::8:800:200C:417A]">unicast</a></li>
+<li><a rel="mw:ExtLink" class="external text" href="http://[FF01::101]">multicast</a></li>
+<li><a rel="mw:ExtLink" class="external text" href="http://[::1]/">loopback</a></li>
+<li><a rel="mw:ExtLink" class="external text" href="http://[::]">unspecified</a></li>
+<li><a rel="mw:ExtLink" class="external text" href="http://[::13.1.68.3]">ipv4compat</a></li>
+<li><a rel="mw:ExtLink" class="external text" href="http://[::FFFF:129.144.52.38]">ipv4compat</a></li></ul>
+
+<p>Examples from <a href="https://tools.ietf.org/html/rfc2732" rel="mw:ExtLink" class="external text">RFC 2732</a>, section 2:</p>
+<ul><li><a rel="mw:ExtLink" class="external text" href="http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html">1</a></li>
+<li><a rel="mw:ExtLink" class="external text" href="http://[1080:0:0:0:8:800:200C:417A]/index.html">2</a></li>
+<li><a rel="mw:ExtLink" class="external text" href="http://[3ffe:2a00:100:7031::1]">3</a></li>
+<li><a rel="mw:ExtLink" class="external text" href="http://[1080::8:800:200C:417A]/foo">4</a></li>
+<li><a rel="mw:ExtLink" class="external text" href="http://[::192.9.5.5]/ipng">5</a></li>
+<li><a rel="mw:ExtLink" class="external text" href="http://[::FFFF:129.144.52.38]:80/index.html">6</a></li>
+<li><a rel="mw:ExtLink" class="external text" href="http://[2010:836B:4179::836B:4179]">7</a></li></ul>
+!! end
+
+!! test
+Non-extlinks in brackets
+!! wikitext
+[foo]
+[foo bar]
+[foo ''bar'']
+[fool's] errand
+[fool's errand]
+[{{echo|foo}}]
+[{{echo|foo}} bar]
+[{{echo|foo}} ''bar'']
+[{{echo|foo}}l's] errand
+[{{echo|foo}}l's errand]
+[url={{echo|foo}}]
+[url=http://example.com]
+[http:// bare protocols don't count]
+!! html/php
+<p>[foo]
+[foo bar]
+[foo <i>bar</i>]
+[fool's] errand
+[fool's errand]
+[foo]
+[foo bar]
+[foo <i>bar</i>]
+[fool's] errand
+[fool's errand]
+[url=foo]
+[url=<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>]
+[http:// bare protocols don't count]
+</p>
+!! html/parsoid
+<p>[foo]
+[foo bar]
+[foo <i>bar</i>]
+[fool's] errand
+[fool's errand]
+[<span about="#mwt19" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</span>]
+[<span about="#mwt20" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</span> bar]
+[<span about="#mwt21" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</span> <i>bar</i>]
+[<span about="#mwt22" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</span>l's] errand
+[<span about="#mwt23" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</span>l's errand]
+[url=<span about="#mwt24" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</span>]
+[url=<a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a>]
+[http:// bare protocols don't count]</p>
+!! end
+
+!! test
+Percent encoding in external links
+!! wikitext
+[https://github.com/search?l=&q=ResourceLoader+%40wikimedia Search]
+!! html/php
+<p><a rel="nofollow" class="external text" href="https://github.com/search?l=&amp;q=ResourceLoader+%40wikimedia">Search</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external text" href="https://github.com/search?l=&amp;q=ResourceLoader+%40wikimedia">Search</a></p>
+!! end
+
+!! test
+Use url link syntax for links where the content is equal the link target
+!! wikitext
+http://example.com
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a></p>
+!! end
+
+!! test
+Parenthesis in external links, especially URL links
+!! wikitext
+http://example.com)
+
+http://example.com/test)
+
+http://example.com/(test)
+
+http://example.com/((test)
+
+(http://example.com/(test))
+
+(http://example.com/(test)))))
+
+http://example.com/a)b
+
+[http://example.com) foo]
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>)
+</p><p><a rel="nofollow" class="external free" href="http://example.com/test">http://example.com/test</a>)
+</p><p><a rel="nofollow" class="external free" href="http://example.com/(test)">http://example.com/(test)</a>
+</p><p><a rel="nofollow" class="external free" href="http://example.com/((test)">http://example.com/((test)</a>
+</p><p>(<a rel="nofollow" class="external free" href="http://example.com/(test))">http://example.com/(test))</a>
+</p><p>(<a rel="nofollow" class="external free" href="http://example.com/(test)))))">http://example.com/(test)))))</a>
+</p><p><a rel="nofollow" class="external free" href="http://example.com/a)b">http://example.com/a)b</a>
+</p><p><a rel="nofollow" class="external text" href="http://example.com)">foo</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a>)</p>
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com/test">http://example.com/test</a>)</p>
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com/(test)">http://example.com/(test)</a></p>
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com/((test)">http://example.com/((test)</a></p>
+<p>(<a rel="mw:ExtLink" class="external free" href="http://example.com/(test))">http://example.com/(test))</a></p>
+<p>(<a rel="mw:ExtLink" class="external free" href="http://example.com/(test)))))">http://example.com/(test)))))</a></p>
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com/a)b">http://example.com/a)b</a></p>
+<p><a rel="mw:ExtLink" class="external text" href="http://example.com)">foo</a></p>
+!! end
+
+!! test
+Parenthesis in external links, w/ transclusion or comment
+!! wikitext
+(http://example.com/{{echo|hi}})
+
+(http://example.com<!-- hi -->)
+!! html/php
+<p>(<a rel="nofollow" class="external free" href="http://example.com/hi">http://example.com/hi</a>)
+</p><p>(<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>)
+</p>
+!! html/parsoid
+<p>(<a typeof="mw:ExpandedAttrs" about="#mwt2" rel="mw:ExtLink" class="external free" href="http://example.com/hi" data-parsoid='{"stx":"url","a":{"href":"http://example.com/hi"},"sa":{"href":"http://example.com/{{echo|hi}}"}}' data-mw='{"attribs":[[{"txt":"href"},{"html":"http://example.com/&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[20,31,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"hi\"}},\"i\":0}}]}&#39;>hi&lt;/span>"}]]}'>http://example.com/hi</a>)</p>
+
+<p>(<a rel="mw:ExtLink" class="external free" href="http://example.com" data-parsoid='{"stx":"url","a":{"href":"http://example.com"},"sa":{"href":"http://example.com&lt;!-- hi -->"}}'>http://example.com</a>)</p>
+!! end
+
+!! test
+Serialize <a> tags with invalid link targets as plain text
+!! options
+parsoid={ "modes": ["html2wt"], "suppressErrors": true }
+!! html/parsoid
+<a rel="mw:WikiLink" href="[[foo]]">text</a>
+<a rel="mw:WikiLink" href="[[foo]]">*text</a>
+<a rel="mw:WikiLink" href="[[foo]]">[[foo]]</a>
+<a rel="mw:WikiLink" href="[[foo]]">*a [[foo]]</a>
+!! wikitext
+text
+<nowiki>*</nowiki>text
+<nowiki>[[foo]]</nowiki>
+<nowiki>*</nowiki>a <nowiki>[[foo]]</nowiki>
+!! end
+
+!! test
+mw:ExtLink -vs- mw:WikiLink (T94723)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<a rel="mw:WikiLink" href="./Foo" title="Foo" data-parsoid='{"stx":"piped","a":{"href":"./Foo"},"sa":{"href":"Foo"}}'>Bar</a>
+<a rel="mw:WikiLink" href="./Foo" title="Foo">Bar</a>
+<a rel="mw:WikiLink" href="http://en.wikipedia.org/wiki/Foo" title="Foo">Bar</a>
+<a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/Foo" title="Foo">Bar</a>
+<p>
+<a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/European_Robin">European Robin</a>
+<a rel="mw:WikiLink" href="http://en.wikipedia.org/wiki/European_Robin">European Robin</a>
+</p>
+!! wikitext
+[[Foo|Bar]]
+[[Foo|Bar]]
+[[:en:Foo|Bar]]
+[[:en:Foo|Bar]]
+
+[[:en:European_Robin|European Robin]]
+[[:en:European_Robin|European Robin]]
+!! end
+
+!! test
+mw:ExtLink linking to a interwiki URL can be round-tripped losslessly (T94723)
+!! options
+parsoid=wt2wt
+!! wikitext
+[http://en.wikipedia.org/wiki/European_Robin European Robin]
+!! html/parsoid
+THIS SECTION IS NOT USED (but Parsoid won't run the test without it)
+!! end
+
+
+###
+### Quotes
+###
+
+!! test
+Quotes
+!! wikitext
+Normal text. '''Bold text.''' Normal text. ''Italic text.''
+
+Normal text. '''''Bold italic text.''''' Normal text.
+!! html
+<p>Normal text. <b>Bold text.</b> Normal text. <i>Italic text.</i>
+</p><p>Normal text. <i><b>Bold italic text.</b></i> Normal text.
+</p>
+!! end
+
+
+# Parsoid inserts an empty bold tag pair at the end of the line, that the PHP
+# parser strips. The wikitext contains just the first half of the bold
+# quote pair.
+!! test
+Unclosed and unmatched quotes
+!! wikitext
+'''''Bold italic text '''with bold deactivated''' in between.'''''
+
+'''''Bold italic text ''with italic deactivated'' in between.'''''
+
+'''Bold text..
+
+..spanning two paragraphs (should not work).'''
+
+'''Bold tag left open
+
+''Italic tag left open
+
+Normal text.
+
+<!-- Unmatching number of opening, closing tags: -->
+'''This year''''s election ''should'' beat '''last year''''s.
+
+''Tom'''s car is bigger than ''Susan'''s.
+
+Plain ''italic'''s plain
+!! html/php
+<p><i><b>Bold italic text </b>with bold deactivated<b> in between.</b></i>
+</p><p><b><i>Bold italic text </i>with italic deactivated<i> in between.</i></b>
+</p><p><b>Bold text..</b>
+</p><p>..spanning two paragraphs (should not work).
+</p><p><b>Bold tag left open</b>
+</p><p><i>Italic tag left open</i>
+</p><p>Normal text.
+</p><p><b>This year'</b>s election <i>should</i> beat <b>last year'</b>s.
+</p><p><i>Tom<b>s car is bigger than </b></i><b>Susan</b>s.
+</p><p>Plain <i>italic'</i>s plain
+</p>
+!! html/parsoid
+<p><i><b>Bold italic text </b>with bold deactivated<b> in between.</b></i>
+</p><p><b><i>Bold italic text </i>with italic deactivated<i> in between.</i></b>
+</p><p><b>Bold text..</b>
+</p><p>..spanning two paragraphs (should not work).<b></b>
+</p><p><b>Bold tag left open</b>
+</p><p><i>Italic tag left open</i>
+</p><p>Normal text.
+</p>
+<!-- Unmatching number of opening, closing tags: -->
+<p><b>This year'</b>s election <i>should</i> beat <b>last year'</b>s.
+</p><p><i>Tom<b>s car is bigger than </b></i><b>Susan</b>s.
+</p><p>Plain <i>italic'</i>s plain
+</p>
+!! end
+
+###
+### Tables
+###
+### some content taken from http://meta.wikimedia.org/wiki/MediaWiki_User%27s_Guide:_Using_tables
+###
+
+# This should not produce <table></table> as <table><tr><td></td></tr></table>
+# is the bare minimum required by the spec, see:
+# https://www.w3.org/TR/xhtml-modularization/dtd_module_defs.html#a_module_Basic_Tables
+# Parsoid team replies: empty table tags are legal in HTML5
+!! test
+A table with no data.
+!! options
+parsoid=wt2html
+!! wikitext
+{||}
+!! html/php
+
+!! html/parsoid
+<table></table>
+
+!! end
+
+!! test
+A table with stray table end tags on start tag line (wt2html)
+!! options
+parsoid=wt2html
+!! wikitext
+{|style="color: red;"|}
+
+{|style="color: red;" |}
+|foo
+|}
+
+{|style="color: red;"|} id="foo"
+|foo
+|}
+
+{|style="color: red;" |} id="foo"
+|foo
+|}
+!! html
+<table style="color: red;"></table>
+
+<table style="color: red;">
+<tbody><tr>
+<td>foo</td>
+</tr></tbody>
+</table>
+
+<table style="color: red;" id="foo">
+<tbody><tr>
+<td>foo</td>
+</tr></tbody>
+</table>
+
+<table style="color: red;" id="foo">
+<tbody><tr>
+<td>foo</td>
+</tr></tbody>
+</table>
+
+!! end
+
+!! test
+A table with no data (take 2)
+!! wikitext
+{|
+|}
+!! html/parsoid
+<table></table>
+!! end
+
+# A table with nothing but a caption is invalid XHTML, we might want to render
+# this as <p>caption</p>
+# Parsoid team replies: table with only a caption is legal in HTML5
+!! test
+A table with nothing but a caption
+!! wikitext
+{|
+|+caption
+|}
+!! html/php
+<table>
+<caption>caption
+</caption><tr><td></td></tr></table>
+
+!! html/parsoid
+<table><caption>caption</caption></table>
+!! end
+
+!! test
+A table with caption with default-spaced attributes and a table row
+!! wikitext
+{|
+|+ style="color: red;" | caption1
+|-
+|foo
+|}
+!! html
+<table>
+<caption style="color: red;">caption1
+</caption>
+<tr>
+<td>foo
+</td></tr></table>
+
+!! end
+
+!! test
+A table with captions with non-default spaced attributes and a table row
+!! wikitext
+{|
+|+style="color: red;"|caption2
+|+ style="color: red;"|caption3
+|-
+|foo
+|}
+!! html
+<table>
+<caption style="color: red;">caption2
+</caption>
+<caption style="color: red;">caption3
+</caption>
+<tr>
+<td>foo
+</td></tr></table>
+
+!! end
+
+!! test
+Table td-cell syntax variations
+!! wikitext
+{|
+|foo bar foo|baz
+|foo bar foo||baz
+|style='color:red;'|baz
+|style='color:red;'||baz
+|}
+!! html
+<table>
+<tr>
+<td>baz
+</td>
+<td>foo bar foo</td>
+<td>baz
+</td>
+<td style="color:red;">baz
+</td>
+<td>style='color:red;'</td>
+<td>baz
+</td></tr></table>
+
+!! end
+
+!! test
+Simple table
+!! wikitext
+{|
+|1||2
+|-
+|3||4
+|}
+!! html
+<table>
+<tr>
+<td>1</td>
+<td>2
+</td></tr>
+<tr>
+<td>3</td>
+<td>4
+</td></tr></table>
+
+!! end
+
+!! test
+Simple table but with multiple dashes for row wikitext
+!! wikitext
+{|
+|foo
+|-----
+|bar
+|}
+!! html
+<table>
+<tr>
+<td>foo
+</td></tr>
+<tr>
+<td>bar
+</td></tr></table>
+
+!! end
+
+!! test
+Multiplication table
+!! wikitext
+{| border="1" cellpadding="2"
+|+Multiplication table
+|-
+!&times;!!1!!2!!3
+|-
+!1
+|1||2||3
+|-
+!2
+|2||4||6
+|-
+!3
+|3||6||9
+|-
+!4
+|4||8||12
+|-
+!5
+|5||10||15
+|}
+!! html
+<table border="1" cellpadding="2">
+<caption>Multiplication table
+</caption>
+<tr>
+<th>&#215;</th>
+<th>1</th>
+<th>2</th>
+<th>3
+</th></tr>
+<tr>
+<th>1
+</th>
+<td>1</td>
+<td>2</td>
+<td>3
+</td></tr>
+<tr>
+<th>2
+</th>
+<td>2</td>
+<td>4</td>
+<td>6
+</td></tr>
+<tr>
+<th>3
+</th>
+<td>3</td>
+<td>6</td>
+<td>9
+</td></tr>
+<tr>
+<th>4
+</th>
+<td>4</td>
+<td>8</td>
+<td>12
+</td></tr>
+<tr>
+<th>5
+</th>
+<td>5</td>
+<td>10</td>
+<td>15
+</td></tr></table>
+
+!! end
+
+!! test
+Accept "||" in table headings
+!! wikitext
+{|
+!h1||h2
+|}
+!! html
+<table>
+<tr>
+<th>h1</th>
+<th>h2
+</th></tr></table>
+
+!! end
+
+!! test
+Accept "!!" in table data
+!! wikitext
+{|
+|Foo!!||
+|}
+!! html
+<table>
+<tr>
+<td>Foo!!</td>
+<td>
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr data-parsoid='{"autoInsertedEnd":true,"autoInsertedStart":true}'><td data-parsoid='{"autoInsertedEnd":true}'> Foo!! </td><td data-parsoid='{"stx":"row","autoInsertedEnd":true}'></td></tr>
+</tbody></table>
+!! end
+
+!! test
+Accept "||" in indented table headings
+!! wikitext
+:{|
+!h1||h2
+|}
+!! html
+<dl><dd><table>
+<tr>
+<th>h1</th>
+<th>h2
+</th></tr></table></dd></dl>
+
+!! end
+
+!! test
+Accept "!!" in templates
+!! wikitext
+{|
+!a {{echo|b!!c}}
+|}
+!! html/php
+<table>
+<tr>
+<th>a b</th>
+<th>c
+</th></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr><th typeof="mw:Transclusion" about="#mwt1" data-parsoid='{"autoInsertedEnd":true,"pi":[[{"k":"1"}]]}' data-mw='{"parts":["!a ",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"b!!c"}},"i":0}}]}'>a b</th><th about="#mwt1">c</th></tr>
+!! end
+
+!! test
+Accept "!!" in table headings after newline
+!! wikitext
+{|
+!a
+b!!c
+|}
+!! html/php
+<table>
+<tr>
+<th>a
+<p>b!!c
+</p>
+</th></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr><th>a
+<p>b!!c</p></th></tr>
+</tbody></table>
+!! end
+
+!! test
+Accept "!!" in table data of mixed wikitext / html syntax
+!! wikitext
+{|
+!a
+<tr><td>b!!c</td></tr>
+|}
+!! html/php+tidy
+<table>
+<tbody><tr>
+<th>a
+</th></tr><tr><td>b!!c</td></tr>
+</tbody></table>
+!! html/parsoid
+<table>
+<tbody><tr><th>a</th></tr>
+<tr data-parsoid='{"stx":"html"}'><td data-parsoid='{"stx":"html"}'>b!!c</td></tr>
+</tbody></table>
+!! end
+
+!! test
+Accept empty attributes in td/th cells (td/th cells starting with leading ||)
+!! wikitext
+{|
+!| h1
+|| a
+|}
+!! html
+<table>
+<tr>
+<th>h1
+</th>
+<td>a
+</td></tr></table>
+
+!! end
+
+!!test
+Accept "| !" at start of line in tables (ignore !-attribute)
+!! wikitext
+{|
+|-
+|!style="color:red"|bar
+|}
+!! html
+<table>
+
+<tr>
+<td>bar
+</td></tr></table>
+
+!!end
+
+!!test
+Allow +/- in 2nd and later cells in a row, in 1st cell when td-attrs are present, or in 1st cell when there is a space between "|" and +/-
+!! wikitext
+{|
+|-
+|style='color:red;'|+1
+|style='color:blue;'|-1
+|-
+|1||2||3
+|1||+2||-3
+|-
+| +1
+| -1
+|}
+!! html
+<table>
+
+<tr>
+<td style="color:red;">+1
+</td>
+<td style="color:blue;">-1
+</td></tr>
+<tr>
+<td>1</td>
+<td>2</td>
+<td>3
+</td>
+<td>1</td>
+<td>+2</td>
+<td>-3
+</td></tr>
+<tr>
+<td>+1
+</td>
+<td>-1
+</td></tr></table>
+
+!!end
+
+!! test
+Table rowspan
+!! wikitext
+{| border=1
+|Cell 1, row 1
+|rowspan=2|Cell 2, row 1 (and 2)
+|Cell 3, row 1
+|-
+|Cell 1, row 2
+|Cell 3, row 2
+|}
+!! html
+<table border="1">
+<tr>
+<td>Cell 1, row 1
+</td>
+<td rowspan="2">Cell 2, row 1 (and 2)
+</td>
+<td>Cell 3, row 1
+</td></tr>
+<tr>
+<td>Cell 1, row 2
+</td>
+<td>Cell 3, row 2
+</td></tr></table>
+
+!! end
+
+!! test
+Nested table
+!! wikitext
+{| border=1
+| &alpha;
+|
+{| bgcolor=#ABCDEF border=2
+|nested
+|-
+|table
+|}
+|the original table again
+|}
+!! html
+<table border="1">
+<tr>
+<td>&#945;
+</td>
+<td>
+<table bgcolor="#ABCDEF" border="2">
+<tr>
+<td>nested
+</td></tr>
+<tr>
+<td>table
+</td></tr></table>
+</td>
+<td>the original table again
+</td></tr></table>
+
+!! end
+
+!! test
+Invalid attributes in table cell (T3830)
+!! wikitext
+{|
+|Cell:|broken
+|}
+!! html
+<table>
+<tr>
+<td>broken
+</td></tr></table>
+
+!! end
+
+!! test
+Table cell attributes: Pipes protected by nowikis should be treated as a plain character
+!! wikitext
+{|
+| title="foo" |bar
+| title="foo<nowiki>|</nowiki>" |bar
+| title="foo<nowiki>|</nowiki>" bar
+|}
+!! html/php
+<table>
+<tr>
+<td title="foo">bar
+</td>
+<td title="foo&#124;">bar
+</td>
+<td>title="foo|" bar
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr><td title="foo">bar</td>
+<td title="foo|" data-parsoid='{"a":{"title":"foo|"},"sa":{"title":"foo&lt;nowiki>|&lt;/nowiki>"},"autoInsertedEnd":true}'>bar</td>
+<td> title="foo<span typeof="mw:Nowiki">|</span>" bar</td></tr>
+</tbody></table>
+!! end
+
+# See: http://lists.wikimedia.org/mailman/htdig/wikitech-l/2006-April/022293.html
+# N.B. The "|}" to close the table is missing from the input, so parsoid's
+# *2wt modes will fail.
+!! test
+Table security: embedded pipes
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+{|
+| |[ftp://|x||]" onmouseover="alert(document.cookie)">test
+!! html/php
+<table>
+<tr>
+<td>[<a rel="nofollow" class="external free" href="ftp://%7Cx">ftp://%7Cx</a></td>
+<td>]" onmouseover="alert(document.cookie)"&gt;test
+</td>
+</tr>
+</table>
+
+!! html/parsoid
+<table><tbody>
+<tr>
+<td data-parsoid='{"startTagSrc":"| ","attrSepSrc":"|","autoInsertedEnd":true}'>[<a rel="mw:ExtLink" class="external free" href="ftp://%7Cx" data-parsoid='{"stx":"url","a":{"href":"ftp://%7Cx"},"sa":{"href":"ftp://|x"}}'>ftp://%7Cx</a></td><td data-parsoid='{"stx":"row","autoInsertedEnd":true}'>]" onmouseover="alert(document.cookie)">test</td></tr></tbody></table>
+!! end
+
+!! test
+Element attributes with double ! should not be broken up by <th>
+!! wikitext
+{|
+!hi <div class="!!">ha</div> ho
+|}
+!! html/php
+<table>
+<tr>
+<th>hi <div class="!!">ha</div> ho
+</th></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr><th>hi <div class="!!" data-parsoid='{"stx":"html"}'>ha</div> ho</th></tr>
+</tbody></table>
+!! end
+
+!! test
+! and || in element attributes should not be parsed as <th>/<td>
+!! wikitext
+{|
+|<div style="color: red !important;" data-contrived="put this here ||">hi</div>
+|}
+!! html/php
+<table>
+<tr>
+<td><div style="color: red !important;" data-contrived="put this here &#124;&#124;">hi</div>
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr><td><div style="color: red !important;" data-contrived="put this here ||" data-parsoid='{"stx":"html"}'>hi</div></td></tr>
+</tbody></table>
+!! end
+
+# FIXME: The output seems broken. Filed as T110268.
+!! test
+! and || in td attributes should not be parsed as <th>/<td>
+!! options
+parsoid=wt2html
+!! wikitext
+{|
+|style="color: red !important;" data-contrived="put this here ||"|foo
+|}
+!! html/php
+<table>
+<tr>
+<td>style="color: red !important;" data-contrived="put this here</td>
+<td>foo
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr><td>style="color: red !important;" data-contrived="put this here</td><td data-parsoid='{"stx":"row","a":{"\"":null},"sa":{"\"":""},"autoInsertedEnd":true}'>foo</td></tr>
+</tbody></table>
+!! end
+
+!! test
+Break on | in element attribute in template
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+{{echo|1=<div class="hi|ho">ha</div>}}
+!! html/php
+<p>ho"&gt;ha&lt;/div&gt;
+</p>
+!! html/parsoid
+<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"ho\">ha&lt;/div>"}},"i":0}}]}'>ho">ha</span>
+!! end
+
+!! test
+Break on | in element attribute name in template
+!! wikitext
+{{echo|<div cla|ss="hiho">ha</div>}}
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1"},{"k":"ss","named":true}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;div cla"},"ss":{"wt":"\"hiho\">ha&lt;/div>"}},"i":0}}]}'>&lt;div cla</p>
+!! end
+
+!! test
+Don't break on | in extension attribute in template
+!! wikitext
+{{echo|<ref name="hi|ho">ha</ref>}}
+
+<references />
+!! html/parsoid
+<p><sup about="#mwt2" class="mw-ref" id="cite_ref-hi|ho_1-0" rel="dc:references" typeof="mw:Transclusion mw:Extension/ref" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;ref name=\"hi|ho\">ha&lt;/ref>"}},"i":0}}]}'><a href="./Main_Page#cite_note-hi|ho-1" style="counter-reset: mw-Ref 1;"><span class="mw-reflink-text">[1]</span></a></sup></p>
+
+<ol class="mw-references references" typeof="mw:Extension/references" about="#mwt5" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-hi|ho-1" id="cite_note-hi|ho-1"><a href="./Main_Page#cite_ref-hi|ho_1-0" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-hi|ho-1" class="mw-reference-text">ha</span></li></ol>
+!! end
+
+## We don't support roundtripping of these attributes in Parsoid.
+## Selective serialization takes care of preventing dirty diffs.
+## But, on edits, we dirty-diff the invalid attribute text.
+!! test
+Invalid text in table attributes should be discarded
+!! options
+parsoid=wt2html
+!! wikitext
+{| <span>boo</span> style='border:1px solid black'
+| <span>boo</span> style='color:blue' |1
+|<span>boo</span> style='color:blue'|2
+|}
+!! html/php
+<table style="border:1px solid black">
+<tr>
+<td style="color:blue">1
+</td>
+<td style="color:blue">2
+</td></tr></table>
+
+!! html/parsoid
+<table style="border:1px solid black">
+<tr>
+<td style="color:blue">1</td>
+<td style="color:blue">2</td>
+</tr>
+</table>
+!! end
+
+!! test
+Invalid text in table attributes should be preserved by selective serializer
+!! options
+parsoid={
+ "modes": ["selser"],
+ "changes": [
+ ["td:first-child", "text", "abc"],
+ ["td + td", "text", "xyz"]
+ ]
+}
+!! wikitext
+{| <span>boo</span> style='border:1px solid black'
+| <span>boo</span> style='color:blue' | 1
+|<span>boo</span> style='color:blue'| 2
+|}
+!! wikitext/edited
+{| <span>boo</span> style='border:1px solid black'
+| <span>boo</span> style='color:blue' |abc
+|<span>boo</span> style='color:blue'|xyz
+|}
+!! end
+
+!! test
+1. Template-generated table cell attributes and cell content
+!! wikitext
+{|
+|{{table_attribs}}
+| {{table_attribs}}
+|| {{table_attribs_5}}
+| <!--foo--> <!--bar--> <!--baz--> {{table_attribs}}
+|align=center {{table_attribs}}
+| <!--foo--> align=center <!--bar--> {{table_attribs}}
+|}
+!! html
+<table>
+<tr>
+<td style="color:red;">Foo
+</td>
+<td style="color:red;">Foo
+</td>
+<td>style="color:red;"</td>
+<td>Bar
+</td>
+<td style="color:red;">Foo
+</td>
+<td align="center" style="color:red;">Foo
+</td>
+<td align="center" style="color:red;">Foo
+</td></tr></table>
+
+!! end
+
+!! test
+2. Template-generated table cell attributes and cell content
+!! wikitext
+{|
+|{{table_attribs_2}}
+|}
+!! html/php
+<table>
+<tr>
+<td style="color:red;">Foo
+</td>
+<td>Bar</td>
+<td>Baz
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr><td about="#mwt1" typeof="mw:Transclusion" style="color:red;" data-mw='{"parts":["|",{"template":{"target":{"wt":"table_attribs_2","href":"./Template:Table_attribs_2"},"params":{},"i":0}}]}'>Foo</td>
+<td about="#mwt1">Bar</td><td about="#mwt1">Baz</td></tr>
+</tbody></table>
+!! end
+
+!! test
+3. Template-generated table cell attributes and cell content
+!! wikitext
+{|
+!align=center {{table_header_cells}}
+|-
+|align=center {{table_cells}}
+|}
+!! html/php
+<table>
+<tr>
+<th align="center" style="color:red;">Foo</th>
+<th style="color:red;"><i>Bar</i></th>
+<th style="color:brown;"><i>Foo</i> and Baz
+</th></tr>
+<tr>
+<td align="center" style="color:red;">Foo</td>
+<td style="color:red;"><i>Bar</i></td>
+<td style="color:brown;"><i>Foo</i> and Baz
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr><th align="center" style="color:red;" typeof="mw:Transclusion" about="#mwt1" data-mw='{"parts":["!align=center ",{"template":{"target":{"wt":"table_header_cells","href":"./Template:Table_header_cells"},"params":{},"i":0}}]}'>Foo</th><th about="#mwt1" style="color:red;"><i about="#mwt1">Bar</i></th><th about="#mwt1" style="color:brown;"><i about="#mwt1">Foo</i> and Baz</th></tr><tr>
+<td align="center" style="color:red;" typeof="mw:Transclusion" about="#mwt1" data-mw='{"parts":["|align=center ",{"template":{"target":{"wt":"table_cells","href":"./Template:Table_cells"},"params":{},"i":0}}]}'>Foo</td><td about="#mwt1" style="color:red;"><i about="#mwt1">Bar</i></td><td about="#mwt1" style="color:brown;"><i about="#mwt1">Foo</i> and Baz</td></tr>
+</tbody></table>
+!! end
+
+!! test
+4. Template-generated table cell attributes and cell content inside a templated table
+!! wikitext
+{{tbl-start}}
+!align=center {{table_header_cells}}
+|-
+|align=center {{table_cells}}
+{{tbl-end}}
+!! html/php
+<table>
+<tr>
+<th align="center" style="color:red;">Foo</th>
+<th style="color:red;"><i>Bar</i></th>
+<th style="color:brown;"><i>Foo</i> and Baz
+</th></tr>
+<tr>
+<td align="center" style="color:red;">Foo</td>
+<td style="color:red;"><i>Bar</i></td>
+<td style="color:brown;"><i>Foo</i> and Baz
+</td></tr></table>
+
+!! html/parsoid
+<table about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[],[],[],[]]}' data-mw='{"parts":[{"template":{"target":{"wt":"tbl-start","href":"./Template:Tbl-start"},"params":{},"i":0}},"\n!align=center ",{"template":{"target":{"wt":"table_header_cells","href":"./Template:Table_header_cells"},"params":{},"i":1}},"\n|-\n|align=center ",{"template":{"target":{"wt":"table_cells","href":"./Template:Table_cells"},"params":{},"i":2}},"\n",{"template":{"target":{"wt":"tbl-end","href":"./Template:Tbl-end"},"params":{},"i":3}}]}'>
+<tbody><tr><th align="center" style="color:red;">Foo</th><th style="color:red;"><i>Bar</i></th><th style="color:brown;"><i>Foo</i> and Baz</th></tr>
+<tr>
+<td align="center" style="color:red;">Foo</td><td style="color:red;"><i>Bar</i></td><td style="color:brown;"><i>Foo</i> and Baz</td></tr>
+</tbody></table>
+!! end
+
+## Edge case fix to prevent future regressions
+!! test
+T107652: <ref>s in templates that also generate table cell attributes should be rendered properly
+!! wikitext
+{|
+|{{table_attribs_7}}
+|}
+<references />
+!! html/parsoid
+<table>
+<tbody><tr><td style="background:#f9f9f9;" typeof="mw:Transclusion" about="#mwt1" data-mw='{"parts":["|",{"template":{"target":{"wt":"table_attribs_7","href":"./Template:Table_attribs_7"},"params":{},"i":0}}]}'>Foo<sup class="mw-ref" id="cite_ref-1" rel="dc:references" typeof="mw:Extension/ref" data-mw='{"name":"ref","body":{"id":"mw-reference-text-cite_note-1"},"attrs":{}}'><a href="./Main_Page#cite_note-1" style="counter-reset: mw-Ref 1;"><span class="mw-reflink-text">[1]</span></a></s></td></tr>
+</tbody></table>
+<ol class="mw-references references" typeof="mw:Extension/references" about="#mwt5" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-1" id="cite_note-1"><a href="./Main_Page#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text" data-parsoid="{}">foo</span></li></ol>
+!! end
+
+!! test
+Table with row followed by newlines and table heading
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+{|
+|-
+
+!foo
+|}
+!! html/*
+<table>
+
+
+<tr>
+<th>foo
+</th></tr></table>
+
+!! end
+
+!! test
+Table with empty line following the start tag
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+{|
+
+|-
+|foo
+|}
+!! html/*
+<table>
+
+
+<tr>
+<td>foo
+</td></tr></table>
+
+!! end
+
+!! test
+Table attributes with empty value
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+{|
+| style=|hello
+|}
+!! html/php
+<table>
+<tr>
+<td style="">hello
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr><td style="">hello</td></tr>
+</tbody></table>
+!! end
+
+!! test
+Wikitext table with a lot of comments
+!! wikitext
+{|
+<!-- c0 -->
+|foo
+<!-- c1 -->
+|-<!-- c2 -->
+<!-- c3 -->
+|<!-- c4 -->
+<!-- c5 -->
+|}
+!! html
+<table>
+<tr>
+<td>foo
+</td></tr>
+<tr>
+<td>
+</td></tr></table>
+
+!! end
+
+!! test
+Wikitext table comments represented in parsoid dom
+!! wikitext
+{|<!--c1--><!--c2-->
+|-<!--c3-->
+|x
+|}
+!! html/php+tidy
+<table>
+
+<tbody><tr>
+<td>x
+</td></tr></tbody></table>
+!! html/parsoid
+<table><!--c1--><!--c2-->
+<tbody><tr data-parsoid='{"startTagSrc":"|-","autoInsertedEnd":true}'><!--c3-->
+<td data-parsoid='{"autoInsertedEnd":true}'>x</td></tr>
+</tbody></table>
+!! end
+
+!! test
+Wikitext table with double-line table cell
+!! wikitext
+{|
+|a
+b
+|}
+!! html
+<table>
+<tr>
+<td>a
+<p>b
+</p>
+</td></tr></table>
+
+!! end
+
+!! test
+Table cell with a single comment
+!! wikitext
+{|
+| <!-- c1 -->
+|a
+|}
+!! html
+<table>
+<tr>
+<td>
+</td>
+<td>a
+</td></tr></table>
+
+!! end
+
+!! test
+Table-cell after a comment-only-empty-line
+!! wikitext
+{|
+|a
+<!--c1-->
+<!--c2-->|b
+|}
+!! html
+<table>
+<tr>
+<td>a
+</td>
+<td>b
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr data-parsoid='{"autoInsertedEnd":true,"autoInsertedStart":true}'><td data-parsoid='{"autoInsertedEnd":true}'>a</td>
+<!--c1-->
+<!--c2--><td data-parsoid='{"autoInsertedEnd":true}'>b</td></tr>
+</tbody></table>
+
+!! end
+
+!! test
+Build table with {{!}}
+!! wikitext
+{{{!}} class="wikitable"
+!header
+!second header
+{{!}}- style="color:red;"
+{{!}}data{{!}}{{!}} style="color:red;" {{!}}second data
+{{!}}}
+!! html
+<table class="wikitable">
+<tr>
+<th>header
+</th>
+<th>second header
+</th></tr>
+<tr style="color:red;">
+<td>data</td>
+<td style="color:red;">second data
+</td></tr></table>
+
+!! end
+
+!! test
+Build table with pipe as data
+!! wikitext
+{| class="wikitable"
+!header
+!second header
+|- style="color:red;"
+|data|| style="color:red;" |second data
+|-
+| style="color:red;" |data with | || style="color:red;" | second data with |
+|-
+||data with | |||second data with |
+|}
+!! html
+<table class="wikitable">
+<tr>
+<th>header
+</th>
+<th>second header
+</th></tr>
+<tr style="color:red;">
+<td>data</td>
+<td style="color:red;">second data
+</td></tr>
+<tr>
+<td style="color:red;">data with |</td>
+<td style="color:red;">second data with |
+</td></tr>
+<tr>
+<td>data with |</td>
+<td>second data with |
+</td></tr></table>
+
+!! end
+
+!! test
+Build table with wikilink
+!! wikitext
+{| class="wikitable"
+!header||second header
+|- style="color:red;"
+|data [[Main Page|linktext]]||second data [[Main Page|linktext]]
+|-
+|data||second data [[Main Page|link|text with pipe]]
+|}
+!! html
+<table class="wikitable">
+<tr>
+<th>header</th>
+<th>second header
+</th></tr>
+<tr style="color:red;">
+<td>data <a href="/wiki/Main_Page" title="Main Page">linktext</a></td>
+<td>second data <a href="/wiki/Main_Page" title="Main Page">linktext</a>
+</td></tr>
+<tr>
+<td>data</td>
+<td>second data <a href="/wiki/Main_Page" title="Main Page">link|text with pipe</a>
+</td></tr></table>
+
+!! end
+
+# The expected HTML structure in this test is debatable. The PHP parser does
+# not parse this kind of table at all. The main focus for Parsoid is on
+# round-tripping, so this output is ok for now. TODO: revisit!
+!! test
+Wikitext table with html-syntax row
+!! wikitext
+{|
+|-
+<td>foo</td>
+|}
+!! html/parsoid
+<table>
+<tbody>
+<tr>
+<td>foo</td></tr></tbody></table>
+!! end
+
+!! test
+Fostered content in tables: Plain text
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+{|
+|-
+a
+|}
+!! html/php
+<table>
+
+a
+</table>
+
+!! html/php+tidy
+
+
+a
+<table></table>
+!! html/parsoid
+<p data-parsoid='{"fostered":true,"autoInsertedEnd":true}'>a</p><table>
+<tbody><tr data-parsoid='{"startTagSrc":"|-","autoInsertedEnd":true}'>
+
+</tr></tbody></table>
+!! end
+
+!! test
+Fostered content in tables: Lists
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+{|
+|-
+*a
+|}
+!! html/php
+<table>
+
+<ul><li>a</li></ul>
+</table>
+
+!! html/php+tidy
+<ul><li>a</li></ul><table>
+
+
+</table>
+!! html/parsoid
+<ul data-parsoid='{"fostered":true,"autoInsertedEnd":true}'><li>a</li></ul><table>
+<tbody><tr data-parsoid='{"startTagSrc":"|-","autoInsertedEnd":true}'>
+
+</tr></tbody></table>
+!! end
+
+!! test
+Template generated table cell with attributes
+!! wikitext
+{|
+|-
+{{table_attribs_4}} ||a||b
+|}
+!! html/php+tidy
+<table>
+
+<tbody><tr>
+<td style="background-color:#DC241f;" width="10px"></td>
+<td>a</td>
+<td>b
+</td></tr></tbody></table>
+!! html/parsoid
+<table>
+<tbody><tr data-parsoid='{"startTagSrc":"|-","autoInsertedEnd":true}'>
+<td style="background-color:#DC241f;" width="10px" about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"autoInsertedEnd":true,"pi":[[]]}' data-mw='{"parts":[{"template":{"target":{"wt":"table_attribs_4","href":"./Template:Table_attribs_4"},"params":{},"i":0}}," ||a||b"]}'></td><td about="#mwt1">a</td><td about="#mwt1">b</td></tr>
+!! end
+
+!! test
+Parsoid: Round-trip tables directly followed by content (T53219)
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+{|
+|foo
+|} bar
+
+{|
+|baz
+|}<b>quux</b>
+!! html+tidy
+<table>
+<tbody><tr>
+<td>foo
+</td></tr></tbody></table><p> bar
+</p><table>
+<tbody><tr>
+<td>baz
+</td></tr></tbody></table><p><b>quux</b>
+</p>
+!! end
+
+!! test
+Parsoid: Default to a newline after tables in new content (T53219)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table><tbody>
+<tr><td>foo</td></tr></tbody></table> bar
+<table><tbody>
+<tr><td>baz</td></tr></tbody></table><b>quux</b>
+!! wikitext
+{|
+|foo
+|}
+<nowiki> </nowiki>bar
+{|
+|baz
+|}
+'''quux'''
+!! end
+
+!! test
+Parsoid: newline inducing block nodes don't suppress <nowiki>
+!! options
+parsoid=html2wt
+!! html/parsoid
+ a<h1>foo</h1>
+!! wikitext
+<nowiki> </nowiki>a
+
+= foo =
+!! end
+
+!! test
+Parsoid: Row-syntax table headings followed by comment & table cells
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+{|
+!foo||bar
+<!-- foo --> ||baz||quux
+|}
+!! html/php
+<table>
+<tr>
+<th>foo</th>
+<th>bar
+</th>
+<td>baz</td>
+<td>quux
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr><th> foo </th><th> bar
+<!-- foo --> </th><td> baz </td><td> quux</td></tr>
+</tbody></table>
+!! end
+
+!!test
+Parsoid: Recover better from broken table attributes
+!!options
+parsoid=wt2html
+!!wikitext
+{| class="foo
+| class="bar" |
+foo
+|}
+!!html/php+tidy
+<table class="foo">
+<tbody><tr>
+<td class="bar">
+<p>foo
+</p>
+</td></tr></tbody></table>
+!!html/parsoid
+<table class="foo">
+<tr>
+<td class="bar">
+<p>foo</p></td></tr>
+</tbody></table>
+!!end
+
+!! test
+Tables: Digest broken attributes on table and tr tag
+!! options
+parsoid=wt2html
+!! wikitext
+{| || |} ++
+|- || || ++ --
+|- > [
+|}
+!! html
+<table>
+<tbody>
+<tr class='mw-empty-elt'></tr>
+<tr class='mw-empty-elt'></tr>
+</tbody></table>
+!! end
+
+# T137406: Whitespace in the HTML
+!! test
+1. Generate correct wikitext for tables with thead/tbody/tfoot
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table>
+<caption>Test</caption>
+<thead>
+<tr>
+<th>Month</th>
+<th>Savings</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>January</td>
+<td>$100</td>
+</tr>
+<tr>
+<td>February</td>
+<td>$80</td>
+</tr>
+</tbody>
+<tfoot>
+<tr>
+<td>Sum</td>
+<td>$180</td>
+</tr>
+</tfoot>
+</table>
+!! wikitext
+{|
+|+Test
+!Month
+!Savings
+|-
+|January
+|$100
+|-
+|February
+|$80
+|-
+|Sum
+|$180
+|}
+!! html/php+tidy
+<table>
+<caption>Test
+</caption>
+<tbody><tr>
+<th>Month
+</th>
+<th>Savings
+</th></tr>
+<tr>
+<td>January
+</td>
+<td>$100
+</td></tr>
+<tr>
+<td>February
+</td>
+<td>$80
+</td></tr>
+<tr>
+<td>Sum
+</td>
+<td>$180
+</td></tr></tbody></table>
+!! end
+
+# T137406: No whitespace in the HTML
+!! test
+2. Generate correct wikitext for tables with thead/tbody/tfoot
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table><thead><tr><th>heading</th></tr></thead><tbody><tr><td>foo</td></tr></tbody></table>
+!! wikitext
+{|
+!heading
+|-
+|foo
+|}
+!! end
+
+!! test
+Testing serialization after deletion in references
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ ["#x", "remove"]
+ ]
+}
+!! wikitext
+hi <ref><div id="x">ho</div></ref>
+
+<references />
+!! wikitext/edited
+hi <ref></ref>
+
+<references />
+!! end
+
+!!test
+Testing serialization after deletion of table cells
+!!options
+parsoid={
+ "modes": ["wt2wt", "selser"],
+ "changes": [
+ ["#x", "remove"]
+ ]
+}
+!!wikitext
+{|
+!h1 !!h2 !!h3
+| id="x" |c1 {{!}}{{!}}{{!}}c2 |||c3
+|}
+!! wikitext/edited
+{|
+!h1 !!h2 !!h3
+|c2 |||c3
+|}
+!!end
+
+!! test
+Testing selser after addition of new row before first row (T125419)
+!! options
+parsoid={
+ "modes": ["wt2wt", "selser"],
+ "changes": [
+ [ "tr", "before", "<tr><td>X</td></tr>" ]
+ ]
+}
+!! wikitext
+{|
+|a
+|}
+!! wikitext/edited
+{|
+|X
+|-
+|a
+|}
+!! end
+
+!! test
+Serialize new table rows in a HTML table using HTML tags
+!! options
+parsoid={
+ "modes": ["wt2wt", "selser"],
+ "changes": [
+ [ "tr", "before", "<tr><td>X</td></tr>" ]
+ ]
+}
+!! wikitext
+<table><tr><td>a</td></tr></table>
+!! wikitext/edited
+<table><tr><td>X</td></tr><tr><td>a</td></tr></table>
+!! end
+
+!! test
+Serialize new table cells in a HTML row using HTML tags
+!! options
+parsoid={
+ "modes": ["wt2wt", "selser"],
+ "changes": [
+ [ "td", "before", "<td>X</td>" ]
+ ]
+}
+!! wikitext
+<table><tr><td>a</td></tr></table>
+!! wikitext/edited
+<table><tr><td>X</td><td>a</td></tr></table>
+!! end
+
+!! test
+Wikitext tables can be nested inside HTML tables
+!! options
+parsoid=html2wt
+!! html
+<table data-parsoid='{"stx":"html"}'>
+<tr><td>
+<table>
+<tr><td>foo</td></tr>
+</table>
+</td></tr>
+</table>
+!! wikitext
+<table>
+<tr><td>
+{|
+|foo
+|}
+</td></tr>
+</table>
+!! end
+
+!! test
+Serialize wikitext list items as HTML list items when embedded in a HTML list
+!! options
+parsoid=html2wt
+!! html
+<ul data-parsoid='{"stx": "html"}'>
+<li data-parsoid='{}'>a</li>
+<li>b</li>
+</ul>
+!! wikitext
+<ul>
+<li>a</li>
+<li>b</li>
+</ul>
+!! end
+
+# SSS FIXME: Is this actually a good thing given the
+# odd nested list output that is generated by MW?
+# <ul><li>foo<ul>..</ul></li></ul> instead of
+# <ul><li>foo</li><ul>..</ul></ul>
+!! test
+Wikitext lists can be nested inside HTML lists
+!! options
+parsoid=html2wt
+!! html
+<ul data-parsoid='{"stx": "html"}'>
+<li data-parsoid='{"stx": "html"}'>a
+<ul><li>b</li></ul>
+</li>
+</ul>
+
+<ul data-parsoid='{"stx": "html"}'>
+<li>x
+<ul><li>y</li></ul>
+</li>
+</ul>
+!! wikitext
+<ul>
+<li>a
+* b
+</li>
+</ul>
+
+<ul>
+<li>x
+* y
+</li>
+</ul>
+!! end
+
+###
+### Internal links
+###
+!! test
+Plain link, capitalized
+!! wikitext
+[[Main Page]]
+!! html
+<p><a href="/wiki/Main_Page" title="Main Page">Main Page</a>
+</p>
+!! end
+
+!! test
+Plain link, uncapitalized
+!! wikitext
+[[main Page]]
+!! html
+<p><a href="/wiki/Main_Page" title="Main Page">main Page</a>
+</p>
+!! end
+
+!! test
+Piped link
+!! wikitext
+[[Main Page|The Main Page]]
+!! html
+<p><a href="/wiki/Main_Page" title="Main Page">The Main Page</a>
+</p>
+!! end
+
+!! test
+Piped link with comment in link text
+!! wikitext
+[[Main Page|The Main<!--front--> Page]]
+!! html
+<p><a href="/wiki/Main_Page" title="Main Page">The Main Page</a>
+</p>
+!! end
+
+!! test
+Piped link with multiple pipe characters in link text
+!! wikitext
+[[Main Page||The|Main|Page|]]
+!! html/php
+<p><a href="/wiki/Main_Page" title="Main Page">|The|Main|Page|</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page" title="Main Page">|The|Main|Page|</a></p>
+!! end
+
+!! test
+Piped link with no link text
+!! wikitext
+[[Thomas Bek (bishop of St David's)|]]
+!! html/php
+<p>[[Thomas Bek (bishop of St David's)|]]
+</p>
+!! html/parsoid
+<p>[[Thomas Bek (bishop of St David's)|]]</p>
+!! end
+
+!! test
+Piped link with empty link text
+!! wikitext
+[[Main Page|<nowiki/>]] - empty nowiki
+[[Main Page| ]] - empty space
+[[Main Page|&nbsp;]] - empty non breaking space
+!! html/php
+<p><a href="/wiki/Main_Page" title="Main Page"></a> - empty nowiki
+<a href="/wiki/Main_Page" title="Main Page"> </a> - empty space
+<a href="/wiki/Main_Page" title="Main Page">&#160;</a> - empty non breaking space
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page" title="Main Page"><span typeof="mw:Nowiki"></span></a> - empty nowiki
+<a rel="mw:WikiLink" href="./Main_Page" title="Main Page"> </a> - empty space
+<a rel="mw:WikiLink" href="./Main_Page" title="Main Page"><span typeof="mw:Entity" data-parsoid='{"src":"&amp;nbsp;","srcContent":" "}'> </span></a> - empty non breaking space</p>
+!! end
+
+!! test
+Broken link
+!! wikitext
+[[Zigzagzogzagzig]]
+!! html
+<p><a href="/index.php?title=Zigzagzogzagzig&amp;action=edit&amp;redlink=1" class="new" title="Zigzagzogzagzig (page does not exist)">Zigzagzogzagzig</a>
+</p>
+!! end
+
+!! test
+Broken link with fragment
+!! wikitext
+[[Zigzagzogzagzig#zug]]
+!! html
+<p><a href="/index.php?title=Zigzagzogzagzig&amp;action=edit&amp;redlink=1" class="new" title="Zigzagzogzagzig (page does not exist)">Zigzagzogzagzig#zug</a>
+</p>
+!! end
+
+!! test
+Special page link with fragment
+!! wikitext
+[[Special:Version#anchor]]
+!! html
+<p><a href="/wiki/Special:Version#anchor" title="Special:Version">Special:Version#anchor</a>
+</p>
+!! end
+
+!! test
+Nonexistent special page link with fragment
+!! wikitext
+[[Special:ThisNameWillHopefullyNeverBeUsed#anchor]]
+!! html
+<p><a href="/wiki/Special:ThisNameWillHopefullyNeverBeUsed" class="new" title="Special:ThisNameWillHopefullyNeverBeUsed (page does not exist)">Special:ThisNameWillHopefullyNeverBeUsed#anchor</a>
+</p>
+!! end
+
+!! test
+Link with prefix
+!! wikitext
+xxx[[main Page]], xxx[[Main Page]], Xxx[[main Page]] XXX[[main Page]], XXX[[Main Page]]
+!! html
+<p>xxx<a href="/wiki/Main_Page" title="Main Page">main Page</a>, xxx<a href="/wiki/Main_Page" title="Main Page">Main Page</a>, Xxx<a href="/wiki/Main_Page" title="Main Page">main Page</a> XXX<a href="/wiki/Main_Page" title="Main Page">main Page</a>, XXX<a href="/wiki/Main_Page" title="Main Page">Main Page</a>
+</p>
+!! end
+
+!! test
+Link with suffix
+!! wikitext
+[[Main Page]]xxx, [[Main Page]]XXX, [[Main Page]]!!!
+!! html
+<p><a href="/wiki/Main_Page" title="Main Page">Main Pagexxx</a>, <a href="/wiki/Main_Page" title="Main Page">Main Page</a>XXX, <a href="/wiki/Main_Page" title="Main Page">Main Page</a>!!!
+</p>
+!! end
+
+!! article
+prefixed article
+!! text
+Some text
+!! endarticle
+
+!! test
+T45661: Piped links with identical prefixes
+!! wikitext
+[[prefixed article|prefixed articles with spaces]]
+
+[[prefixed article|prefixed articlesaoeu]]
+
+[[Main Page|Main Page test]]
+!! html
+<p><a href="/wiki/Prefixed_article" title="Prefixed article">prefixed articles with spaces</a>
+</p><p><a href="/wiki/Prefixed_article" title="Prefixed article">prefixed articlesaoeu</a>
+</p><p><a href="/wiki/Main_Page" title="Main Page">Main Page test</a>
+</p>
+!! end
+
+
+!! test
+Link with HTML entity in suffix / tail
+!! wikitext
+[[Main Page]]&quot;, [[Main Page]]&#97;
+!! html/php
+<p><a href="/wiki/Main_Page" title="Main Page">Main Page</a>&quot;, <a href="/wiki/Main_Page" title="Main Page">Main Page</a>&#97;
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a><span typeof="mw:Entity" data-parsoid='{"src":"&amp;quot;","srcContent":"\""}'>"</span>, <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#97;","srcContent":"a"}'>a</span></p>
+!! end
+
+!! test
+Link with 3 brackets
+!! wikitext
+[[[Main Page]]]
+Foo [[[Main Page]]]
+!! html
+<p>[[[Main Page]]]
+Foo [[[Main Page]]]
+</p>
+!! end
+
+!! test
+Link with 4 brackets
+!! wikitext
+[[[[Main Page]]]]
+!! html
+<p>[[<a href="/wiki/Main_Page" title="Main Page">Main Page</a>]]
+</p>
+!! end
+
+!! test
+Piped link with 3 brackets
+!! wikitext
+[[[main page|the main page]]]
+!! html
+<p>[[[main page|the main page]]]
+</p>
+!! end
+
+!! test
+Piped link with extlink-like text
+!! wikitext
+[[Main Page|[bar]]]
+[[Main Page|This is a [bar]]]
+[[Main Page|[bar]]
+!! html/php
+<p><a href="/wiki/Main_Page" title="Main Page">[bar]</a>
+<a href="/wiki/Main_Page" title="Main Page">This is a [bar]</a>
+<a href="/wiki/Main_Page" title="Main Page">[bar</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page" title="Main Page" data-parsoid='{"stx":"piped"}'>[bar]</a>
+<a rel="mw:WikiLink" href="./Main_Page" title="Main Page" data-parsoid='{"stx":"piped"}'>This is a [bar]</a>
+<a rel="mw:WikiLink" href="./Main_Page" title="Main Page" data-parsoid='{"stx":"piped"}'>[bar</a></p>
+!! end
+
+!! test
+Link with multiple pipes
+!! wikitext
+[[Main Page|The|Main|Page]]
+!! html
+<p><a href="/wiki/Main_Page" title="Main Page">The|Main|Page</a>
+</p>
+!! end
+
+!! test
+Anchor containing a #. (T65430)
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+[[Main Page#And#Link]]
+!! html/php
+<p><a href="/wiki/Main_Page#And#Link" title="Main Page">Main Page#And#Link</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page#And#Link" title="Main Page" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#And#Link"},"sa":{"href":"Main Page#And#Link"}}'>Main Page#And#Link</a></p>
+!! end
+
+!! test
+Link to namespaces
+!! wikitext
+[[Talk:Parser testing]], [[Meta:Disclaimers]]
+!! html
+<p><a href="/index.php?title=Talk:Parser_testing&amp;action=edit&amp;redlink=1" class="new" title="Talk:Parser testing (page does not exist)">Talk:Parser testing</a>, <a href="/index.php?title=Meta:Disclaimers&amp;action=edit&amp;redlink=1" class="new" title="Meta:Disclaimers (page does not exist)">Meta:Disclaimers</a>
+</p>
+!! end
+
+!! test
+Link with space in namespace
+!! wikitext
+[[User talk:Foo bar]]
+!! html
+<p><a href="/index.php?title=User_talk:Foo_bar&amp;action=edit&amp;redlink=1" class="new" title="User talk:Foo bar (page does not exist)">User talk:Foo bar</a>
+</p>
+!! end
+
+!! article
+MemoryAlpha:AlphaTest
+!! text
+This is an article in the MemoryAlpha namespace
+(which shadows the memoryalpha interwiki link).
+!! endarticle
+
+!! test
+Namespace takes precedence over interwiki link (T53680)
+!! wikitext
+[[MemoryAlpha:AlphaTest]]
+!! html
+<p><a href="/wiki/MemoryAlpha:AlphaTest" title="MemoryAlpha:AlphaTest">MemoryAlpha:AlphaTest</a>
+</p>
+!! end
+
+# The previous test doesn't work correctly in html2*, due to not recognizing the
+# link as an internal one. This one checks for the correct behavior.
+!! test
+Link to namespace preferred over interwiki with correct rel attribute
+!! options
+parsoid=html2wt,html2html
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./MemoryAlpha:AlphaTest" title="MemoryAlpha:AlphaTest">MemoryAlpha:AlphaTest</a></p>
+!! wikitext
+[[MemoryAlpha:AlphaTest]]
+!! end
+
+!! test
+Piped link to namespace
+!! wikitext
+[[Meta:Disclaimers|The disclaimers]]
+!! html
+<p><a href="/index.php?title=Meta:Disclaimers&amp;action=edit&amp;redlink=1" class="new" title="Meta:Disclaimers (page does not exist)">The disclaimers</a>
+</p>
+!! end
+
+!! test
+Link containing }
+!! wikitext
+[[Usually caused by a typo (oops}]]
+!! html
+<p>[[Usually caused by a typo (oops}]]
+</p>
+!! end
+
+!! article
+7% Solution
+!! text
+Just a test of an article title containing a percent.
+!! endarticle
+
+!! test
+Link containing % (not as a hex sequence)
+!! wikitext
+[[7% Solution]]
+[[7% Solution|7%25 Solution]]
+!! html/php
+<p><a href="/wiki/7%25_Solution" title="7% Solution">7% Solution</a>
+<a href="/wiki/7%25_Solution" title="7% Solution">7%25 Solution</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./7%25_Solution" title="7% Solution">7% Solution</a>
+<a rel="mw:WikiLink" href="./7%25_Solution" title="7% Solution">7%25 Solution</a></p>
+!! end
+
+# note that the parsoid HTML is identical to the previous test output,
+# so the previous test ensures that the html2wt mode will generate the
+# "not as a hex sequence" wikitext.
+!! test
+Link containing % as a single hex sequence interpreted to char
+!! options
+parsoid=wt2wt,wt2html,html2html
+!! wikitext
+[[7%25 Solution]]
+[[7%25 Solution|7%25 Solution]]
+!! html/php
+<p><a href="/wiki/7%25_Solution" title="7% Solution">7% Solution</a>
+<a href="/wiki/7%25_Solution" title="7% Solution">7%25 Solution</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./7%25_Solution" title="7% Solution">7% Solution</a>
+<a rel="mw:WikiLink" href="./7%25_Solution" title="7% Solution">7%25 Solution</a></p>
+!!end
+
+!! test
+Link containing % as a double hex sequence interpreted to hex sequence
+!! wikitext
+[[7%2525 Solution]]
+!! html
+<p>[[7%2525 Solution]]
+</p>
+!!end
+
+## Example for such a section: == < ==
+!! test
+Link containing "#<" and "#>" % as a hex sequences- these are valid section anchors
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+[[%23%3c]][[%23%3e]]
+!! html/php
+<p><a href="#&lt;">#&lt;</a><a href="#&gt;">#&gt;</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page#&lt;" title="Main Page" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#&lt;"},"sa":{"href":"%23%3c"}}'>#&lt;</a><a rel="mw:WikiLink" href="./Main_Page#>" title="Main Page" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#>"},"sa":{"href":"%23%3e"}}'>#></a></p>
+!! end
+
+## Example for such a section: == < ==
+!! test
+Link containing "#<" and "#>" % as a hex sequences- these are valid section anchors (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
+!! wikitext
+[[%23%3c]][[%23%3e]]
+!! html/php
+<p><a href="#.3C">#&lt;</a><a href="#.3E">#&gt;</a>
+</p>
+!! end
+
+!! test
+Link containing "<#" and ">#" as a hex sequences
+!! wikitext
+[[%3c%23]][[%3e%23]]
+!! html
+<p>[[%3c%23]][[%3e%23]]
+</p>
+!! end
+
+!! test
+Link containing an equals sign
+!! wikitext
+[[Special:BookSources/isbn=4-00-026157-6]]
+!! html/php
+<p><a href="/wiki/Special:BookSources/isbn%3D4-00-026157-6" title="Special:BookSources/isbn=4-00-026157-6">Special:BookSources/isbn=4-00-026157-6</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Special:BookSources/isbn=4-00-026157-6" title="Special:BookSources/isbn=4-00-026157-6">Special:BookSources/isbn=4-00-026157-6</a></p>
+!! end
+
+!! article
+Foo~bar
+!! text
+Just a test of an article title containing a tilde.
+!! endarticle
+
+# note that links containing signatures, like [[Foo~~~~]], are
+# massaged by the pre-save transform (PST) and so the tildes are never
+# seen by the parser.
+!! test
+Link containing a tilde
+!! wikitext
+[[Foo~bar]]
+!! html/php
+<p><a href="/wiki/Foo~bar" title="Foo~bar">Foo~bar</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Foo~bar" title="Foo~bar">Foo~bar</a></p>
+!! end
+
+!! test
+Link containing double-single-quotes '' (T6598)
+!! wikitext
+[[Lista d''e paise d''o munno]]
+!! html/php
+<p><a href="/index.php?title=Lista_d%27%27e_paise_d%27%27o_munno&amp;action=edit&amp;redlink=1" class="new" title="Lista d&#39;&#39;e paise d&#39;&#39;o munno (page does not exist)">Lista d''e paise d''o munno</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Lista_d''e_paise_d''o_munno" title="Lista d''e paise d''o munno">Lista d''e paise d''o munno</a></p>
+!! end
+
+!! test
+Link containing double quotes and spaces
+!! wikitext
+[[Cool "Gator"]]
+!! html/php
+<p><a href="/index.php?title=Cool_%22Gator%22&amp;action=edit&amp;redlink=1" class="new" title="Cool &quot;Gator&quot; (page does not exist)">Cool "Gator"</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href='./Cool_"Gator"' title='Cool "Gator"'>Cool "Gator"</a></p>
+!! end
+
+!! test
+File containing double quotes and spaces
+!! wikitext
+[[File:Cool "Gator".png]]
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Cool_%22Gator%22.png" data-parsoid='{"a":{"href":"./File:Cool_%22Gator%22.png"},"sa":{"href":"File:Cool \"Gator\".png"}}'><img resource='./File:Cool_"Gator".png' src="./Special:FilePath/Cool_%22Gator%22.png" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Cool_\"Gator\".png","height":"220","width":"220","src":"./Special:FilePath/Cool_%22Gator%22.png"},"sa":{"resource":"File:Cool \"Gator\".png","src":"./Special:FilePath/Cool_\"Gator\".png"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Redirect containing double quotes and spaces
+!! wikitext
+#REDIRECT [[Cool "Gator"]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Cool_%22Gator%22" data-parsoid='{"src":"#REDIRECT ","a":{"href":"./Cool_%22Gator%22"},"sa":{"href":"Cool \"Gator\""}}'/>
+!! end
+
+!! test
+Link containing double-single-quotes '' in text (T6598 sanity check)
+!! wikitext
+Some [[Link|pretty ''italics'' and stuff]]!
+!! html/php
+<p>Some <a href="/index.php?title=Link&amp;action=edit&amp;redlink=1" class="new" title="Link (page does not exist)">pretty <i>italics</i> and stuff</a>!
+</p>
+!! html/parsoid
+<p>Some <a rel="mw:WikiLink" href="./Link" title="Link">pretty <i>italics</i> and stuff</a>!</p>
+!! end
+
+!! test
+Link containing double-single-quotes '' in text embedded in italics (T6598 sanity check)
+!! wikitext
+''Some [[Link|pretty ''italics'' and stuff]]!''
+!! html
+<p><i>Some <a href="/index.php?title=Link&amp;action=edit&amp;redlink=1" class="new" title="Link (page does not exist)">pretty <i>italics</i> and stuff</a>!</i>
+</p>
+!! end
+
+!! test
+Link with double quotes in title part (literal) and alternate part (interpreted)
+!! wikitext
+[[File:Denys_Savchenko_''Pentecoste''.jpg]]
+
+[[''Pentecoste'']]
+
+[[''Pentecoste''|Pentecoste]]
+
+[[''Pentecoste''|''Pentecoste'']]
+!! html/php
+<p><a href="/index.php?title=Special:Upload&amp;wpDestFile=Denys_Savchenko_%27%27Pentecoste%27%27.jpg" class="new" title="File:Denys Savchenko &#39;&#39;Pentecoste&#39;&#39;.jpg">File:Denys Savchenko <i>Pentecoste</i>.jpg</a>
+</p><p><a href="/index.php?title=%27%27Pentecoste%27%27&amp;action=edit&amp;redlink=1" class="new" title="&#39;&#39;Pentecoste&#39;&#39; (page does not exist)">''Pentecoste''</a>
+</p><p><a href="/index.php?title=%27%27Pentecoste%27%27&amp;action=edit&amp;redlink=1" class="new" title="&#39;&#39;Pentecoste&#39;&#39; (page does not exist)">Pentecoste</a>
+</p><p><a href="/index.php?title=%27%27Pentecoste%27%27&amp;action=edit&amp;redlink=1" class="new" title="&#39;&#39;Pentecoste&#39;&#39; (page does not exist)"><i>Pentecoste</i></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Denys_Savchenko_''Pentecoste''.jpg"><img resource="./File:Denys_Savchenko_''Pentecoste''.jpg" src="./Special:FilePath/Denys_Savchenko_''Pentecoste''.jpg" height="220" width="220"/></a></figure-inline></p>
+<p><a rel="mw:WikiLink" href="./''Pentecoste''" title="''Pentecoste''">''Pentecoste''</a></p>
+<p><a rel="mw:WikiLink" href="./''Pentecoste''" title="''Pentecoste''">Pentecoste</a></p>
+<p><a rel="mw:WikiLink" href="./''Pentecoste''" title="''Pentecoste''"><i>Pentecoste</i></a></p>
+!! end
+
+!! test
+Broken image links with HTML captions (T41700)
+!! wikitext
+[[File:Nonexistent|<script></script>]]
+[[File:Nonexistent|100x100px|<script></script>]]
+[[File:Nonexistent|&lt;]]
+[[File:Nonexistent|a<i>b</i>c]]
+!! html/php
+<p><a href="/index.php?title=Special:Upload&amp;wpDestFile=Nonexistent" class="new" title="File:Nonexistent">&lt;script&gt;&lt;/script&gt;</a>
+<a href="/index.php?title=Special:Upload&amp;wpDestFile=Nonexistent" class="new" title="File:Nonexistent">&lt;script&gt;&lt;/script&gt;</a>
+<a href="/index.php?title=Special:Upload&amp;wpDestFile=Nonexistent" class="new" title="File:Nonexistent">&lt;</a>
+<a href="/index.php?title=Special:Upload&amp;wpDestFile=Nonexistent" class="new" title="File:Nonexistent">abc</a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&lt;script>&lt;/script>"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&amp;lt;script>&amp;lt;/script>"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"220","width":"220"},"sa":{"resource":"File:Nonexistent"}}'/></a></figure-inline>
+<figure-inline typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"width","ak":"100x100px"},{"ck":"caption","ak":"&lt;script>&lt;/script>"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&amp;lt;script>&amp;lt;/script>"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="100" width="100" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"100","width":"100"},"sa":{"resource":"File:Nonexistent"}}'/></a></figure-inline>
+<figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp;lt;"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&lt;span typeof=\"mw:Entity\" data-parsoid=&#39;{\"src\":\"&amp;amp;lt;\",\"srcContent\":\"&amp;lt;\",\"dsr\":[107,111,null,null]}&#39;>&amp;lt;&lt;/span>"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"220","width":"220"},"sa":{"resource":"File:Nonexistent"}}'/></a></figure-inline>
+<figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"a&lt;i>b&lt;/i>c"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"a&lt;i data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[134,142,3,4]}&#39;>b&lt;/i>c"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"220","width":"220"},"sa":{"resource":"File:Nonexistent"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Plain link to URL
+!! wikitext
+[[http://www.example.com]]
+!! html/php
+<p>[<a rel="nofollow" class="external autonumber" href="http://www.example.com">[1]</a>]
+</p>
+!! html/parsoid
+<p>[<a rel="mw:ExtLink" class="external autonumber" href="http://www.example.com"></a>]</p>
+!! end
+
+!! test
+Plain link to URL with link text
+!! wikitext
+[[http://www.example.com Link text]]
+!! html
+<p>[<a rel="nofollow" class="external text" href="http://www.example.com">Link text</a>]
+</p>
+!! end
+
+!! test
+Plain link to protocol-relative URL
+!! wikitext
+[[//www.example.com]]
+!! html/php
+<p>[<a rel="nofollow" class="external autonumber" href="//www.example.com">[1]</a>]
+</p>
+!! html/parsoid
+<p>[<a rel="mw:ExtLink" class="external autonumber" href="//www.example.com"></a>]</p>
+!! end
+
+!! test
+Plain link to protocol-relative URL with link text
+!! wikitext
+[[//www.example.com Link text]]
+!! html
+<p>[<a rel="nofollow" class="external text" href="//www.example.com">Link text</a>]
+</p>
+!! end
+
+!! test
+Plain link to page with question mark in title
+!! wikitext
+[[A?b]]
+
+[[A?b|Baz]]
+!! html
+<p><a href="/wiki/A%3Fb" title="A?b">A?b</a>
+</p><p><a href="/wiki/A%3Fb" title="A?b">Baz</a>
+</p>
+!! end
+
+# I'm fairly sure the expected result here is wrong.
+# We want these to be URL links, not pseudo-pages with URLs for titles....
+# However the current output is also pretty screwy.
+#
+# ----
+# I'm changing it to match the current output--it arguably makes more
+# sense in the light of the test above. Old expected result was:
+#<p>Piped link to URL: <a href="/index.php?title=Http://www.example.com&amp;action=edit" class="new">an example URL</a>
+#</p>
+# But I think this test is bordering on "garbage in, garbage out" anyway.
+# -- wtm
+!! test
+Piped link to URL
+!! wikitext
+Piped link to URL: [[http://www.example.com|an example URL]]
+!! html/php
+<p>Piped link to URL: [<a rel="nofollow" class="external text" href="http://www.example.com%7Can">example URL</a>]
+</p>
+!! html/parsoid
+<p>Piped link to URL: [<a rel="mw:ExtLink" class="external text" href="http://www.example.com%7Can" data-parsoid='{"a":{"href":"http://www.example.com%7Can"},"sa":{"href":"http://www.example.com|an"}}'>example URL</a>]</p>
+!! end
+
+!! test
+Plain link in template argument
+!! options
+parsoid=wt2html
+!! wikitext
+{{echo|[http://www.example.com |123]}}
+
+{{echo|[[http://www.example.com |123]]}}
+
+{{echo|[[http://www.example.com |123]}}
+
+{{echo|[http://www.example.com |123]]}}
+!! html/php
+<p>[<a rel="nofollow" class="external free" href="http://www.example.com">http://www.example.com</a>
+</p><p>[<a rel="nofollow" class="external text" href="http://www.example.com">|123</a>]
+</p><p>{{echo|[<a rel="nofollow" class="external text" href="http://www.example.com">|123</a>}}
+</p><p>[<a rel="nofollow" class="external free" href="http://www.example.com">http://www.example.com</a>
+</p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[http://www.example.com "},"2":{"wt":"123]"}},"i":0}}]}'>[<a rel="mw:ExtLink" class="external free" href="http://www.example.com">http://www.example.com</a> </p>
+
+<p about="#mwt2" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[http://www.example.com |123]]"}},"i":0}}]}'>[<a rel="mw:ExtLink" class="external text" href="http://www.example.com">|123</a>]</p>
+
+<p>{{echo|[<a rel="mw:ExtLink" class="external text" href="http://www.example.com" data-parsoid='{"targetOff":114,"contentOffsets":[114,118],"dsr":[90,119,24,1]}'>|123</a>}}</p>
+
+<p about="#mwt3" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[http://www.example.com "},"2":{"wt":"123]]"}},"i":0}}]}'>[<a rel="mw:ExtLink" class="external free" href="http://www.example.com">http://www.example.com</a> </p>
+!! end
+
+!! test
+T2002: [[page|http://url/]] should link to page, not http://url/
+!! wikitext
+[[Main Page|http://url/]]
+!! html/php
+<p><a href="/wiki/Main_Page" title="Main Page">http://url/</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page" title="Main Page">http://url/</a></p>
+!! end
+
+# Parsoid does not mark self-links, by design.
+!! test
+T2337: Escaped self-links should be bold
+!! options
+title=[[Bug462]]
+!! wikitext
+[[Bu&#103;462]] [[Bug462]]
+!! html/php+tidy
+<p><a class="mw-selflink selflink">Bu&#103;462</a> <a class="mw-selflink selflink">Bug462</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Bug462" title="Bug462">Bug462</a> <a rel="mw:WikiLink" href="./Bug462" title="Bug462">Bug462</a></p>
+!! end
+
+!! test
+Self-link to section should not be bold
+!! options
+title=[[Main Page]]
+!! wikitext
+[[Main Page#section]]
+!! html
+<p><a href="/wiki/Main_Page#section" title="Main Page">Main Page#section</a>
+</p>
+!! end
+
+!! article
+00
+!! text
+This is 00.
+!! endarticle
+
+!!test
+Self-link to numeric title
+!!options
+title=[[0]]
+!! wikitext
+[[0]]
+!! html
+<p><a class="mw-selflink selflink">0</a>
+</p>
+!!end
+
+!!test
+Link to numeric-equivalent title
+!!options
+title=[[0]]
+!! wikitext
+[[00]]
+!! html
+<p><a href="/wiki/00" title="00">00</a>
+</p>
+!!end
+
+!! test
+<nowiki> inside a link
+!! wikitext
+[[Main<nowiki> Page</nowiki>]] [[Main Page|the main page <nowiki>[it's not very good]</nowiki>]]
+!! html
+<p>[[Main Page]] <a href="/wiki/Main_Page" title="Main Page">the main page [it's not very good]</a>
+</p>
+!! end
+
+!! test
+Non-breaking spaces in title
+!! wikitext
+[[&nbsp; Main &nbsp; Page &nbsp;]]
+!! html
+<p><a href="/wiki/Main_Page" title="Main Page">&#160; Main &#160; Page &#160;</a>
+</p>
+!!end
+
+# Add new article for the test below so that it doesn't red-link
+!! article
+Foo bar baz
+!! text
+boo
+!! endarticle
+
+!! test
+Multiple spaces in titles should normalize to a single underscore
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+[[Foo bar baz|x]]
+[[Foo bar baz|x]]
+[[Foo bar baz|x]]
+!! html/php
+<p><a href="/wiki/Foo_bar_baz" title="Foo bar baz">x</a>
+<a href="/wiki/Foo_bar_baz" title="Foo bar baz">x</a>
+<a href="/wiki/Foo_bar_baz" title="Foo bar baz">x</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Foo_bar_baz" title="Foo bar baz">x</a>
+<a rel="mw:WikiLink" href="./Foo_bar_baz" title="Foo bar baz">x</a>
+<a rel="mw:WikiLink" href="./Foo_bar_baz" title="Foo bar baz">x</a>
+</p>
+!! end
+
+!! test
+Internal link with ca linktrail, surrounded by bold apostrophes (T29473 primary issue)
+!! options
+language=ca
+!! wikitext
+'''[[Main Page]]'''
+!! html
+<p><b><a href="/wiki/Main_Page" title="Main Page">Main Page</a></b>
+</p>
+!! end
+
+!! test
+Internal link with ca linktrail, surrounded by italic apostrophes (T29473 primary issue)
+!! options
+language=ca
+!! wikitext
+''[[Main Page]]''
+!! html
+<p><i><a href="/wiki/Main_Page" title="Main Page">Main Page</a></i>
+</p>
+!! end
+
+!! test
+Internal link with en linktrail: no apostrophes (T29473)
+!! options
+language=en
+!! wikitext
+[[Something]]'nice
+!! html
+<p><a href="/index.php?title=Something&amp;action=edit&amp;redlink=1" class="new" title="Something (page does not exist)">Something</a>'nice
+</p>
+!! end
+
+!! test
+Internal link with ca linktrail with apostrophes (T29473)
+!! options
+language=ca
+!! wikitext
+[[Something]]'nice
+!! html
+<p><a href="/index.php?title=Something&amp;action=edit&amp;redlink=1" class="new" title="Something (encara no existeix)">Something'nice</a>
+</p>
+!! end
+
+!! test
+Internal link with kaa linktrail with apostrophes (T29473)
+!! options
+language=kaa
+!! wikitext
+[[Something]]'nice
+!! html
+<p><a href="/index.php?title=Something&amp;action=edit&amp;redlink=1" class="new" title="Something (bet ele jaratılmag&#39;an)">Something'nice</a>
+</p>
+!! end
+
+!! test
+Link with multiple ":" in a subpage-supporting namespace (T65636)
+!! wikitext
+[[User:Foo/Test/63636:Bar|Test]]
+!! html/php
+<p><a href="/index.php?title=User:Foo/Test/63636:Bar&amp;action=edit&amp;redlink=1" class="new" title="User:Foo/Test/63636:Bar (page does not exist)">Test</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./User:Foo/Test/63636:Bar" title="User:Foo/Test/63636:Bar">Test</a></p>
+!! end
+
+## Mainly a sanity check for Parsoid
+!! test
+Handle title parsing for subpages
+!! options
+title=[[/123123]]
+subpage
+!! wikitext
+123
+!! html/php
+<p>123
+</p>
+!! html/parsoid
+<p>123</p>
+!! end
+
+!! article
+User:Test/123
+!! text
+test 123
+!! endarticle
+
+!! test
+Link to a subpage from a namespace other than main
+!! options
+title=[[User:Test]]
+subpage
+!! wikitext
+[[/123]]
+!! html/php
+<p><a href="/wiki/User:Test/123" title="User:Test/123">/123</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./User:Test/123" title="User:Test/123" data-parsoid='{"stx":"simple","a":{"href":"./User:Test/123"},"sa":{"href":"/123"}}'>/123</a></p>
+!! end
+
+!! test
+Ensure that transclusion titles are not url-decoded
+!! options
+subpage title=[[Test]]
+parsoid=wt2html
+!! wikitext
+{{Bar%C3%A9}} {{/Bar%C3%A9}}
+!! html/php
+<p>{{Bar%C3%A9}} {{/Bar%C3%A9}}
+</p>
+!! html/parsoid
+<p>{{Bar%C3%A9}} {{/Bar%C3%A9}}</p>
+!! end
+
+!! test
+Purely hash wikilink
+!! options
+title=[[User:Test/123]]
+subpage
+!! wikitext
+[[#a|b]]
+!! html/php
+<p><a href="#a">b</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./User:Test/123#a" data-parsoid='{"stx":"piped","a":{"href":"./User:Test/123#a"},"sa":{"href":"#a"}}'>b</a></p>
+!! end
+
+!! test
+Serialization of purely hash wikilink
+!! options
+title=[[User:Test/123]]
+subpage
+parsoid=html2wt
+!! html/parsoid
+<p><a href="#a">[[</a></p>
+!! wikitext
+[[#a|<nowiki>[[</nowiki>]]
+!! html/php
+<p><a href="#a">[[</a>
+</p>
+!! end
+
+!! test
+1. Interaction of linktrail and template encapsulation
+!! wikitext
+{{echo|[[Foo]]}}l
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Foo" title="Foo" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[Foo]]"}},"i":0}},"l"]}'>Fool</a></p>
+!! end
+
+!! test
+2. Interaction of linktrail and template encapsulation
+!! options
+parsoid
+!! wikitext
+{{echo|Some [[Fool]]}}s
+!! html
+<p><span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"Some [[Fool]]"}},"i":0}},"s"]}' data-parsoid='{"pi":[[{"k":"1"}]]}'>Some </span><a rel="mw:WikiLink" href="./Fool" title="Fool" about="#mwt1" data-parsoid='{"stx":"simple","a":{"href":"./Fool"},"sa":{"href":"Fool"},"tail":"s"}'>Fools</a></p>
+!! end
+
+!! test
+3. Interaction of linktrail and template encapsulation
+!! options
+parsoid
+!! wikitext
+{{echo|Some [[Fool]]s are '''bold and foolish'''}}
+!! html
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"Some [[Fool]]s are &#39;&#39;&#39;bold and foolish&#39;&#39;&#39;"}},"i":0}}]}' data-parsoid='{"pi":[[{"k":"1"}]]}'>Some <a rel="mw:WikiLink" href="./Fool" title="Fool" data-parsoid='{"stx":"simple","a":{"href":"./Fool"},"sa":{"href":"Fool"},"tail":"s"}'>Fools</a> are <b>bold and foolish</b></p>
+!! end
+
+!! article
+Söfnuður
+!! text
+Test.
+!! endarticle
+
+!! test
+Internal link with is link prefix
+!! options
+language=is
+!! wikitext
+Aðrir mótmælenda[[söfnuður|söfnuðir]] og
+!! html
+<p>Aðrir <a href="/wiki/S%C3%B6fnu%C3%B0ur" title="Söfnuður">mótmælendasöfnuðir</a> og
+</p>
+!! end
+
+!! article
+Mótmælendatrú
+!! text
+Test.
+!! endarticle
+
+!! test
+Internal link with is link trail and link prefix
+!! options
+language=is
+!! wikitext
+[[mótmælendatrú|xxx]]ar
+[[mótmælendatrú]]ar
+mótmælenda[[söfnuður]]
+mótmælenda[[söfnuður|söfnuðir]]
+mótmælenda[[söfnuður|söfnuðir]]xxx
+!! html
+<p><a href="/wiki/M%C3%B3tm%C3%A6lendatr%C3%BA" title="Mótmælendatrú">xxxar</a>
+<a href="/wiki/M%C3%B3tm%C3%A6lendatr%C3%BA" title="Mótmælendatrú">mótmælendatrúar</a>
+<a href="/wiki/S%C3%B6fnu%C3%B0ur" title="Söfnuður">mótmælendasöfnuður</a>
+<a href="/wiki/S%C3%B6fnu%C3%B0ur" title="Söfnuður">mótmælendasöfnuðir</a>
+<a href="/wiki/S%C3%B6fnu%C3%B0ur" title="Söfnuður">mótmælendasöfnuðirxxx</a>
+</p>
+!! end
+
+!! test
+Parsoid link trail escaping
+!! options
+parsoid=html2wt,html2html
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Apple" title="Apple">apple</a>s</p>
+!! wikitext
+[[apple]]<nowiki/>s
+!! end
+
+!! test
+Parsoid link prefix escaping
+!! options
+language=is
+parsoid=html2wt,html2html
+!! html/parsoid
+<p>Aðrir mótmælenda<a rel="mw:WikiLink" href="./Söfnuður" title="Söfnuður">söfnuður</a></p>
+!! wikitext
+Aðrir mótmælenda<nowiki/>[[söfnuður]]
+!! end
+
+!! test
+Parsoid link bracket escaping
+!! options
+parsoid=html2wt,html2html
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Test" title="Test">Test</a></p>
+<p>[<a rel="mw:WikiLink" href="./Test" title="Test">Test</a>]</p>
+<p>[[<a rel="mw:WikiLink" href="./Test" title="Test">Test</a>]]</p>
+<p>[[[<a rel="mw:WikiLink" href="./Test" title="Test">Test</a>]]]</p>
+<p>[[[[<a rel="mw:WikiLink" href="./Test" title="Test">Test</a>]]]]</p>
+<p>[[[[[<a rel="mw:WikiLink" href="./Test" title="Test">Test</a>]]]]]</p>
+!! wikitext
+[[Test]]
+
+[<nowiki/>[[Test]]]
+
+[[[[Test]]]]
+
+[[[<nowiki/>[[Test]]]]]
+
+[[[[[[Test]]]]]]
+
+[[[[[<nowiki/>[[Test]]]]]]]
+!! end
+
+!! test
+Parsoid-centric test: Whitespace in ext- and wiki-links should be preserved
+!! wikitext
+[[Foo| bar]]
+
+[[Foo| ''bar'']]
+
+[http://wp.org foo]
+
+[http://wp.org ''foo'']
+!! html
+<p><a href="/wiki/Foo" title="Foo"> bar</a>
+</p><p><a href="/wiki/Foo" title="Foo"> <i>bar</i></a>
+</p><p><a rel="nofollow" class="external text" href="http://wp.org">foo</a>
+</p><p><a rel="nofollow" class="external text" href="http://wp.org"><i>foo</i></a>
+</p>
+!! end
+
+!! test
+Parsoid: Scoped parsing should handle mixed transclusions and plain text
+!! wikitext
+[[Foo|{{echo|a}} b {{echo|c}}]]
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Foo" title="Foo"><span about="#mwt2" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"a"}},"i":0}}]}'>a</span> b <span about="#mwt3" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"c"}},"i":0}}]}'>c</span></a></p>
+!! end
+
+!! test
+Link with angle bracket after anchor
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+[[Foo#<bar>]]
+!! html/php
+<p><a href="/wiki/Foo#&lt;bar&gt;" title="Foo">Foo#&lt;bar&gt;</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Foo#&lt;bar>" title="Foo" data-parsoid='{"stx":"simple","a":{"href":"./Foo#&lt;bar>"},"sa":{"href":"Foo#&lt;bar>"}}'>Foo#&lt;bar></a></p>
+!! end
+
+!! test
+Link with angle bracket after anchor (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
+!! wikitext
+[[Foo#<bar>]]
+!! html/php
+<p><a href="/wiki/Foo#.3Cbar.3E" title="Foo">Foo#&lt;bar&gt;</a>
+</p>
+!! end
+
+###
+### Interwiki links (see maintenance/interwiki.sql)
+###
+
+!! test
+Inline interwiki link
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[MeatBall:SoftSecurity]]
+!! html/php
+<p><a href="http://www.usemod.com/cgi-bin/mb.pl?SoftSecurity" class="extiw" title="meatball:SoftSecurity">MeatBall:SoftSecurity</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?SoftSecurity" title="meatball:SoftSecurity">MeatBall:SoftSecurity</a></p>
+!! end
+
+!! test
+Inline interwiki link with empty title (T4372)
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[MeatBall:]]
+!! html/php
+<p><a href="http://www.usemod.com/cgi-bin/mb.pl" class="extiw" title="meatball:">MeatBall:</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?" title="meatball:">MeatBall:</a></p>
+!! end
+
+## html2wt and html2html will fail because we will prefer the :en: interwiki prefix over wikipedia:
+!! test
+Interwiki link encoding conversion (T3636)
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+*[[Wikipedia:ro:Olteni&#0355;a]]
+*[[Wikipedia:ro:Olteni&#355;a]]
+!! html
+<ul><li><a href="http://en.wikipedia.org/wiki/ro:Olteni%C5%A3a" class="extiw" title="wikipedia:ro:Olteniţa">Wikipedia:ro:Olteni&#355;a</a></li>
+<li><a href="http://en.wikipedia.org/wiki/ro:Olteni%C5%A3a" class="extiw" title="wikipedia:ro:Olteniţa">Wikipedia:ro:Olteni&#355;a</a></li></ul>
+
+!! html/php+tidy
+<ul>
+<li><a href="http://en.wikipedia.org/wiki/ro:Olteni%C5%A3a" class="extiw" title="wikipedia:ro:Olteniţa">Wikipedia:ro:Olteniţa</a></li>
+<li><a href="http://en.wikipedia.org/wiki/ro:Olteni%C5%A3a" class="extiw" title="wikipedia:ro:Olteniţa">Wikipedia:ro:Olteniţa</a></li>
+</ul>
+!! html/parsoid
+<ul>
+<li><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/ro:Olteniţa" title="wikipedia:ro:Olteniţa">Wikipedia:ro:Olteniţa</a></li>
+<li><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/ro:Olteniţa" title="wikipedia:ro:Olteniţa">Wikipedia:ro:Olteniţa</a></li>
+</ul>
+!! end
+
+!! test
+Interwiki link with fragment (T4130)
+!! wikitext
+[[MeatBall:SoftSecurity#foo]]
+!! html
+<p><a href="http://www.usemod.com/cgi-bin/mb.pl?SoftSecurity#foo" class="extiw" title="meatball:SoftSecurity">MeatBall:SoftSecurity#foo</a>
+</p>
+!! end
+
+!! test
+Link scenarios with escaped fragments
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+[[#Is this great?]]
+[[Foo#Is this great?]]
+[[meatball:Foo#Is this great?]]
+!! html/php
+<p><a href="#Is_this_great?">#Is this great?</a>
+<a href="/wiki/Foo#Is_this_great?" title="Foo">Foo#Is this great?</a>
+<a href="http://www.usemod.com/cgi-bin/mb.pl?Foo#Is_this_great.3F" class="extiw" title="meatball:Foo">meatball:Foo#Is this great?</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page#Is_this_great?" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Is_this_great?"},"sa":{"href":"#Is this great?"}}'>#Is this great?</a>
+<a rel="mw:WikiLink" href="./Foo#Is_this_great?" title="Foo" data-parsoid='{"stx":"simple","a":{"href":"./Foo#Is_this_great?"},"sa":{"href":"Foo#Is this great?"}}'>Foo#Is this great?</a>
+<a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?Foo#Is_this_great?" title="meatball:Foo" data-parsoid='{"stx":"simple","a":{"href":"http://www.usemod.com/cgi-bin/mb.pl?Foo#Is_this_great?"},"sa":{"href":"meatball:Foo#Is this great?"},"isIW":true}'>meatball:Foo#Is this great?</a></p>
+!! end
+
+!! test
+Link scenarios with escaped fragments (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
+!! wikitext
+[[#Is this great?]]
+[[Foo#Is this great?]]
+[[meatball:Foo#Is this great?]]
+!! html/php
+<p><a href="#Is_this_great.3F">#Is this great?</a>
+<a href="/wiki/Foo#Is_this_great.3F" title="Foo">Foo#Is this great?</a>
+<a href="http://www.usemod.com/cgi-bin/mb.pl?Foo#Is_this_great.3F" class="extiw" title="meatball:Foo">meatball:Foo#Is this great?</a>
+</p>
+!! end
+
+# Ideally the wikipedia: prefix here should be proto-relative too
+# [CSA]: this is kind of a bogus test, as the PHP parser test doesn't
+# define the 'en' prefix, and originally the test used 'wikipedia',
+# which isn't a localinterwiki prefix hence the links to the 'en:Foo'
+# article.
+!! test
+Different interwiki prefixes mapping to the same URL
+!! wikitext
+[[:en:Foo]]
+
+[[:en:Foo|Foo]]
+
+[[wikipedia:Foo]]
+
+[[:wikipedia:Foo|Foo]]
+
+[[wikipedia:en:Foo]]
+
+[[:wikipedia:en:Foo]]
+
+[[ wikiPEdia :Foo]]
+!! html/parsoid
+<p><a rel="mw:WikiLink/Interwiki" href="//en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"simple","a":{"href":"//en.wikipedia.org/wiki/Foo"},"sa":{"href":":en:Foo"},"isIW":true}' title="en:Foo">en:Foo</a></p>
+
+<p><a rel="mw:WikiLink/Interwiki" href="//en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"piped","a":{"href":"//en.wikipedia.org/wiki/Foo"},"sa":{"href":":en:Foo"},"isIW":true}' title="en:Foo">Foo</a></p>
+
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"simple","a":{"href":"http://en.wikipedia.org/wiki/Foo"},"sa":{"href":"wikipedia:Foo"},"isIW":true}' title="wikipedia:Foo">wikipedia:Foo</a></p>
+
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"piped","a":{"href":"http://en.wikipedia.org/wiki/Foo"},"sa":{"href":":wikipedia:Foo"},"isIW":true}' title="wikipedia:Foo">Foo</a></p>
+
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/en:Foo" data-parsoid='{"stx":"simple","a":{"href":"http://en.wikipedia.org/wiki/en:Foo"},"sa":{"href":"wikipedia:en:Foo"},"isIW":true}' title="wikipedia:en:Foo">wikipedia:en:Foo</a></p>
+
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/en:Foo" data-parsoid='{"stx":"simple","a":{"href":"http://en.wikipedia.org/wiki/en:Foo"},"sa":{"href":":wikipedia:en:Foo"},"isIW":true}' title="wikipedia:en:Foo">wikipedia:en:Foo</a></p>
+
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"simple","a":{"href":"http://en.wikipedia.org/wiki/Foo"},"sa":{"href":" wikiPEdia :Foo"},"isIW":true}' title="wikipedia:Foo"> wikiPEdia :Foo</a></p>
+!! end
+
+!! test
+Interwiki links that cannot be represented in wiki syntax
+!! wikitext
+[[meatball:ok]]
+[[meatball:ok#foo|ok with fragment]]
+[[meatball:ok_as_well?|ok ending with ? mark]]
+[http://de.wikipedia.org/wiki/Foo?action=history has query]
+[http://de.wikipedia.org/wiki/#foo is just fragment]
+
+!! html/php
+<p><a href="http://www.usemod.com/cgi-bin/mb.pl?ok" class="extiw" title="meatball:ok">meatball:ok</a>
+<a href="http://www.usemod.com/cgi-bin/mb.pl?ok#foo" class="extiw" title="meatball:ok">ok with fragment</a>
+<a href="http://www.usemod.com/cgi-bin/mb.pl?ok_as_well%3F" class="extiw" title="meatball:ok as well?">ok ending with ? mark</a>
+<a rel="nofollow" class="external text" href="http://de.wikipedia.org/wiki/Foo?action=history">has query</a>
+<a rel="nofollow" class="external text" href="http://de.wikipedia.org/wiki/#foo">is just fragment</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?ok" title="meatball:ok">meatball:ok</a>
+<a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?ok#foo" title="meatball:ok">ok with fragment</a>
+<a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?ok_as_well?" title="meatball:ok as well?">ok ending with ? mark</a>
+<a rel="mw:ExtLink" class="external text" href="http://de.wikipedia.org/wiki/Foo?action=history">has query</a>
+<a rel="mw:ExtLink" class="external text" href="http://de.wikipedia.org/wiki/#foo">is just fragment</a></p>
+!! end
+
+!! test
+Interwiki links: trail
+!! wikitext
+[[wikipedia:Foo|Ba]]r
+!! html/php
+<p><a href="http://en.wikipedia.org/wiki/Foo" class="extiw" title="wikipedia:Foo">Bar</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"piped","a":{"href":"http://en.wikipedia.org/wiki/Foo"},"sa":{"href":"wikipedia:Foo"},"isIW":true,"tail":"r"}' title="wikipedia:Foo">Bar</a></p>
+!! end
+
+!! test
+Local interwiki link
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[local:Template:Foo]]
+!! html/php
+<p><a href="/wiki/Template:Foo" title="Template:Foo">local:Template:Foo</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Template:Foo" title="Template:Foo">local:Template:Foo</a></p>
+!! end
+
+# Parsoid does not mark self-links, by design.
+!! test
+Local interwiki link: self-link to current page
+!! options
+title=[[Main Page]]
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[local:Main Page]]
+!! html/php
+<p><a class="mw-selflink selflink">local:Main Page</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page" title="Main Page">local:Main Page</a></p>
+!! end
+
+!! test
+Local interwiki link: prefix only (T66167)
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[local:]]
+!! html/php
+<p><a href="/wiki/Main_Page" title="Main Page">local:</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page" title="Main Page">local:</a></p>
+!! end
+
+!! test
+Local interwiki link: with additional interwiki prefix (T63357)
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[local:meatball:Hello]]
+!! html/php
+<p><a href="http://www.usemod.com/cgi-bin/mb.pl?Hello" class="extiw" title="meatball:Hello">local:meatball:Hello</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?Hello" title="meatball:Hello">local:meatball:Hello</a></p>
+!! end
+
+!! test
+Multiple local interwiki link prefixes
+!! wikitext
+[[local:local:local:local:mi:local:Foo]]
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! html/php
+<p><a href="/wiki/Foo" title="Foo">local:local:local:local:mi:local:Foo</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Foo" title="Foo">local:local:local:local:mi:local:Foo</a></p>
+!! end
+
+###
+### Interlanguage links
+### Language links (so that searching for '### language' matches..)
+###
+
+!! test
+Interlanguage link
+!! wikitext
+Blah blah blah
+[[zh:Chinese]]
+!! html/php
+<p>Blah blah blah
+</p>
+!! html/parsoid
+<p>Blah blah blah</p>
+<link rel="mw:PageProp/Language" href="http://zh.wikipedia.org/wiki/Chinese"/>
+!! end
+
+## parsoid html2wt will lose the space variations
+!! test
+Interlanguage link with spacing
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+Blah blah blah
+[[ zh : Chinese ]]
+!! html/php
+<p>Blah blah blah
+</p>
+!! html/parsoid
+<p>Blah blah blah</p>
+<link rel="mw:PageProp/Language" href="http://zh.wikipedia.org/wiki/Chinese"/>
+!! end
+
+!! test
+Double interlanguage link
+!! wikitext
+Blah blah blah
+[[es:Spanish]]
+[[zh:Chinese]]
+!! html/php
+<p>Blah blah blah
+</p>
+!! html/parsoid
+<p>Blah blah blah</p>
+<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/Spanish"/>
+<link rel="mw:PageProp/Language" href="http://zh.wikipedia.org/wiki/Chinese"/>
+!! end
+
+## parsoid html2wt will lose the space variations
+!! test
+Interlanguage link variations
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+Blah blah blah
+[[ es :Spanish]]
+[[ ZH :Chinese]]
+[[es:Foo_bar]]
+!! html/php
+<p>Blah blah blah
+</p>
+!! html/parsoid
+<p>Blah blah blah</p>
+<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/Spanish" />
+<link rel="mw:PageProp/Language" href="http://zh.wikipedia.org/wiki/Chinese" />
+<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/Foo_bar" />
+!! end
+
+!! test
+Escaping of interlanguage links (T129218, T156308)
+!! wikitext
+Blah blah blah
+[[:es:Spanish]]
+[[ : zh : Chinese ]]
+!! html/php
+<p>Blah blah blah
+<a href="http://es.wikipedia.org/wiki/Spanish" class="extiw" title="es:Spanish">es:Spanish</a>
+<a href="http://zh.wikipedia.org/wiki/Chinese" class="extiw" title="zh:Chinese"> zh : Chinese </a>
+</p>
+!! html/parsoid
+<p>Blah blah blah
+<a rel="mw:WikiLink/Interwiki" href="http://es.wikipedia.org/wiki/Spanish" title="es:Spanish">es:Spanish</a>
+<a rel="mw:WikiLink/Interwiki" href="http://zh.wikipedia.org/wiki/Chinese" title="zh:Chinese"> zh : Chinese </a></p>
+!! end
+
+!! test
+Multiple colons escaping interlanguage links
+!! options
+parsoid=wt2html
+!! wikitext
+[[:es:Spanish]]
+[[::es:Spanish]]
+[[:::es:Spanish]]
+!! html/php
+<p><a href="http://es.wikipedia.org/wiki/Spanish" class="extiw" title="es:Spanish">es:Spanish</a>
+[[::es:Spanish]]
+[[:::es:Spanish]]
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink/Interwiki" href="http://es.wikipedia.org/wiki/Spanish" title="es:Spanish">es:Spanish</a>
+[[::es:Spanish]]
+[[:::es:Spanish]]</p>
+!! end
+
+## parsoid html2wt will normalize the space to _
+!! test
+Space and question mark encoding in interlanguage links (T95473)
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+Blah blah blah
+[[es:Foo bar?]]
+!! html/php
+<p>Blah blah blah
+</p>
+!! html/parsoid
+<p>Blah blah blah</p>
+<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/Foo_bar%3F" />
+!! end
+
+!! test
+Interlanguage link, with prefix links
+!! options
+language=ln
+!! wikitext
+Blah blah blah
+[[zh:Chinese]]
+!! html/php
+<p>Blah blah blah
+</p>
+!! html/parsoid
+<p>Blah blah blah</p>
+<link rel="mw:PageProp/Language" href="http://zh.wikipedia.org/wiki/Chinese"/>
+!! end
+
+!! test
+Double interlanguage link, with prefix links (T10897)
+!! options
+language=ln
+!! wikitext
+Blah blah blah
+[[es:Spanish]]
+[[zh:Chinese]]
+!! html/php
+<p>Blah blah blah
+</p>
+!! html/parsoid
+<p>Blah blah blah</p>
+<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/Spanish"/>
+<link rel="mw:PageProp/Language" href="http://zh.wikipedia.org/wiki/Chinese"/>
+!! end
+
+!! test
+"Extra" interlanguage links (T34189 / gerrit 111390)
+!! wikitext
+Blah blah blah
+[[mul:Article]]
+!! html/php
+<p>Blah blah blah
+</p>
+!! html/parsoid
+<p>Blah blah blah</p>
+<link rel="mw:PageProp/Language" title="Multilingual" href="http://wikisource.org/wiki/Article"/>
+!! end
+
+## PHP parser tests script needs an update
+## Parsoid html2wt will normalize output to [[:zh:Chinese]]
+!! test
+Language links render as inline links if $wgInterwikiMagic=false
+!! options
+wgInterwikiMagic=false
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+Blah blah blah
+[[zh:Chinese]]
+!! html/parsoid
+<p>Blah blah blah <a rel="mw:WikiLink/Interwiki" href="http://zh.wikipedia.org/wiki/Chinese" title="zh:Chinese">zh:Chinese</a></p>
+!! end
+
+## PHP parser tests script needs an update
+## Parsoid html2wt will normalize output to [[:zh:Chinese]]
+!! test
+Language links render as inline links in the Talk namespace
+!! options
+title=Talk:Foo
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+Blah blah blah
+[[zh:Chinese]]
+!! html/parsoid
+<p>Blah blah blah <a rel="mw:WikiLink/Interwiki" href="http://zh.wikipedia.org/wiki/Chinese" title="zh:Chinese">zh:Chinese</a></p>
+!! end
+
+!! test
+Parsoid-specific test: Wikilinks with &nbsp; should RT properly
+!! options
+language=ln
+!! wikitext
+[[WW&nbsp;II]]
+!! html
+<p><a href="/index.php?title=WW_II&amp;action=edit&amp;redlink=1" class="new" title="WW II (lonkásá ezalí tɛ̂)">WW&#160;II</a>
+</p>
+!! end
+
+!! test
+Parsoid T55221: Wikilinks should be properly entity-escaped
+!! options
+parsoid={ "modes": ["html2wt"], "suppressErrors": true }
+!! html/parsoid
+<p>He&amp;nbsp;llo <a href="./Foo" rel="mw:WikiLink">He&amp;nbsp;llo</a></p>
+<p>He&amp;nbsp;llo <a href="./He&amp;nbsp;llo" rel="mw:WikiLink">He&amp;nbsp;llo</a></p>
+!! wikitext
+He&amp;nbsp;llo [[Foo|He&amp;nbsp;llo]]
+
+He&amp;nbsp;llo He&amp;nbsp;llo
+!! html/php
+<p>He&amp;nbsp;llo <a href="/wiki/Foo" title="Foo">He&amp;nbsp;llo</a>
+</p><p>He&amp;nbsp;llo He&amp;nbsp;llo
+</p>
+!! end
+
+# html2wt will fail because of title normalization without data-parsoid
+!! test
+Parsoid: handle constructor well
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+[[constructor]]
+
+[[constructor:foo]]
+!! html/php
+<p><a href="/index.php?title=Constructor&amp;action=edit&amp;redlink=1" class="new" title="Constructor (page does not exist)">constructor</a>
+</p><p><a href="/index.php?title=Constructor:foo&amp;action=edit&amp;redlink=1" class="new" title="Constructor:foo (page does not exist)">constructor:foo</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Constructor" title="Constructor" data-parsoid='{"stx":"simple","a":{"href":"./Constructor"},"sa":{"href":"constructor"}}'>constructor</a></p>
+
+<p><a rel="mw:WikiLink" href="./Constructor:foo" title="Constructor:foo" data-parsoid='{"stx":"simple","a":{"href":"./Constructor:foo"},"sa":{"href":"constructor:foo"}}'>constructor:foo</a></p>
+!! end
+
+!! article
+ko:
+!! text
+Test.
+!! endarticle
+
+# Note that `ko` isn't a known interlanguage prefix
+!! test
+Parsoid: recognize interlanguage links without a target page
+!! options
+ill
+!! wikitext
+[[es:]]
+
+[[ko:]]
+!! html/php
+es:
+!! html/parsoid
+<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/"/>
+
+<p><a rel="mw:WikiLink" href="./Ko:" title="Ko:">ko:</a></p>
+!! end
+
+# Note that `ko` isn't a known interwiki prefix
+!! test
+Parsoid: recognize interwiki links without a target page
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[:es:]]
+
+[[:ko:]]
+!! html/php
+<p><a href="http://es.wikipedia.org/wiki/" class="extiw" title="es:">es:</a>
+</p><p><a href="/wiki/Ko:" title="Ko:">ko:</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink/Interwiki" href="http://es.wikipedia.org/wiki/" title="es:">es:</a></p>
+<p><a rel="mw:WikiLink" href="./Ko:" title="Ko:">ko:</a></p>
+!! end
+
+!! test
+Handle interwiki links pointing to the current wiki as plain wiki links (T47209)
+!! wikitext
+[[mi:Foo]]
+!! html/php
+<p><a href="/wiki/Foo" title="Foo">mi:Foo</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Foo" title="Foo" data-parsoid='{"stx":"simple","a":{"href":"./Foo"},"sa":{"href":"mi:Foo"}}'>mi:Foo</a></p>
+!! end
+
+!! test
+Interlanguage link with preceding local interwiki link (T70085)
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+Blah blah blah
+[[local:es:Spanish]]
+!! html/php
+<p>Blah blah blah
+<a href="http://es.wikipedia.org/wiki/Spanish" class="extiw" title="es:Spanish">local:es:Spanish</a>
+</p>
+!! html/parsoid
+<p>Blah blah blah
+<a rel="mw:WikiLink/Interwiki" href="http://es.wikipedia.org/wiki/Spanish" title="es:Spanish">local:es:Spanish</a></p>
+!! end
+
+!! test
+Looks like an interlanguage link, but is actually a local interwiki
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+Blah blah blah
+[[mi:Template:Foo]]
+!! html/php
+<p>Blah blah blah
+<a href="/wiki/Template:Foo" title="Template:Foo">mi:Template:Foo</a>
+</p>
+!! html/parsoid
+<p>Blah blah blah
+<a rel="mw:WikiLink" href="./Template:Foo" title="Template:Foo">mi:Template:Foo</a></p>
+!! end
+
+###
+### Redirects, Parsoid-only
+###
+
+!! test
+1. Simple redirect to page
+!! wikitext
+#REDIRECT [[Main Page]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Main_Page"/>
+!! end
+
+!! test
+2. Other redirect variants
+!! wikitext
+#REDIRECT [[Main_Page]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Main_Page" data-parsoid='{"src":"#REDIRECT ","a":{"href":"./Main_Page"},"sa":{"href":"Main_Page"}}'/>
+!! end
+
+# Not a valid redirect in PHP (although perhaps it was, once upon a time)
+# This tests the Parsoid bail-out code.
+!! test
+3. Other redirect variants
+!! options
+parsoid=wt2html
+!! wikitext
+#REDIRECT [[<nowiki>[[Bar]]</nowiki>]]
+!! html/parsoid
+<ol><li>REDIRECT [[<span typeof="mw:Nowiki">[[Bar]]</span>]]</li></ol>
+!! end
+
+!! test
+4. Redirect to a templated destination
+!! wikitext
+#REDIRECT [[{{echo|Foo}}bar]]
+!! html/parsoid
+<link about="#mwt2" typeof="mw:ExpandedAttrs" rel="mw:PageProp/redirect" href="./Foobar" data-parsoid='{"a":{"href":"./Foobar"},"sa":{"href":"{{echo|Foo}}bar"}}' data-mw='{"attribs":[[{"txt":"href"},{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[12,24,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"Foo\"}},\"i\":0}}]}&#39;>Foo&lt;/span>bar"}]]}'/>
+!! end
+
+!! test
+Empty redirect
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+#REDIRECT [[]]
+!! html/parsoid
+<ol>
+<li>REDIRECT [[]]</li></ol>
+!! end
+
+!! test
+Optional colon in #REDIRECT
+!! options
+# the colon is archaic syntax. we support it for wt2html, but we
+# don't care that it roundtrips back to the modern syntax.
+parsoid=wt2html,html2html
+!! wikitext
+#REDIRECT:[[Main Page]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Main_Page"/>
+!! end
+
+!! test
+Whitespace in #REDIRECT with optional colon
+!! options
+# the colon and gratuitous whitespace is archaic syntax. we support
+# it for wt2html, but we don't care that it roundtrips back to the
+# modern syntax (without extra whitespace)
+parsoid=wt2html,html2html
+!! wikitext
+
+ #REDIRECT
+:
+[[Main Page]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Main_Page"/>
+!! end
+
+!! test
+Piped link in #REDIRECT
+!! options
+# content after piped link is ignored. we support this syntax,
+# but don't care that the piped link is lost when we roundtrip this.
+parsoid=wt2html
+!! wikitext
+#REDIRECT [[Main Page|bar]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Main_Page"/>
+!! end
+
+!! test
+Redirect to category (T104502)
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+#REDIRECT [[Category:Foo]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Category:Foo"/>
+!! end
+
+!! test
+Redirect to category with URL encoding (T104502)
+!! options
+parsoid=wt2html
+!! wikitext
+#REDIRECT [[Category%3AFoo]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Category:Foo"/>
+!! end
+
+!! test
+Redirect to category page
+!! wikitext
+#REDIRECT [[:Category:Foo]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Category:Foo"/>
+!! end
+
+!! test
+Redirect to image page (1)
+!! wikitext
+#REDIRECT [[File:Wiki.png]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./File:Wiki.png"/>
+!! end
+
+!! test
+Redirect to image page (2)
+!! wikitext
+#REDIRECT [[Image:Wiki.png]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./File:Wiki.png" data-parsoid='{"src":"#REDIRECT ","a":{"href":"./File:Wiki.png"},"sa":{"href":"Image:Wiki.png"}}'/>
+!! end
+
+# html2wt disabled because wts serializes as "#REDIRECT [[:en:File:Wiki.png]]"
+# Next test confirms this.
+!! test
+Redirect to language (1) (T104918)
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+#REDIRECT [[en:File:Wiki.png]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="//en.wikipedia.org/wiki/File:Wiki.png"/>
+!! end
+
+!! test
+Redirect to language (2) (T104918)
+!! wikitext
+#REDIRECT [[:en:File:Wiki.png]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="//en.wikipedia.org/wiki/File:Wiki.png"/>
+!! end
+
+!! test
+Redirect to interwiki (T104918)
+!! wikitext
+#REDIRECT [[meatball:File:Wiki.png]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="http://www.usemod.com/cgi-bin/mb.pl?File:Wiki.png"/>
+!! end
+
+!! test
+Non-English #REDIRECT
+!! options
+language=is
+!! wikitext
+#TILVÍSUN [[Main Page]]
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Main_Page" data-parsoid='{"src":"#TILVÍSUN ","a":{"href":"./Main_Page"},"sa":{"href":"Main Page"}}'/>
+!! end
+
+!! test
+Redirect syntax under text isn't considered a redirect
+!! wikitext
+some text
+
+#redirect [[Main Page]]
+!! html/parsoid
+<p>some text</p>
+<ol data-parsoid='{}'><li data-parsoid='{}'>redirect <a rel="mw:WikiLink" href="./Main_Page" title="Main Page" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page"},"sa":{"href":"Main Page"}}'>Main Page</a></li></ol>
+!! end
+
+!! test
+New redirect
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>Foo<link rel="mw:PageProp/redirect" href="./Foo"/></p>
+!! wikitext
+#REDIRECT [[Foo]]
+Foo
+!! end
+
+!! test
+Redirect followed by block on the same line
+!! options
+parsoid=wt2html
+!! wikitext
+#REDIRECT [[Main Page]]<!-- haha -->==hi==
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Main_Page"/><!-- haha --><h2 id="hi">hi</h2>
+!! end
+
+!! test
+Redirect followed by a newline
+!! wikitext
+#REDIRECT [[Main Page]]
+A newline
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Main_Page"/>
+<p>A newline</p>
+!! end
+
+!! test
+Redirect followed by multiple newlines
+!! wikitext
+#REDIRECT [[Main Page]]
+
+
+A newline
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Main_Page"/>
+
+<p><br/>
+A newline</p>
+!! end
+
+!! test
+Drop duplicate redirects
+!! options
+parsoid=html2wt
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Foo"/>
+<link rel="mw:PageProp/redirect" href="./Bar"/>
+<link rel="mw:PageProp/redirect" href="./Baz"/>
+!! wikitext
+#REDIRECT [[Foo]]
+!! end
+
+##
+## XHTML tidiness
+###
+
+!! test
+<br> to <br />
+!! wikitext
+1<br>2<br />3
+!! html
+<p>1<br />2<br />3
+</p>
+!! end
+
+!! test
+Broken br tag sanitization
+!! wikitext
+</br>
+!! html/php
+<p>&lt;/br&gt;
+</p>
+!! end
+
+# TODO: Fix html2html mode (T53055)!
+!! test
+Parsoid: Broken br tag recognition
+!! options
+parsoid=wt2html
+!! wikitext
+</br>
+
+<br/ >
+!! html+tidy
+<p><br />
+</p><p><br />
+</p>
+!! end
+
+!! test
+Incorrecly removing closing slashes from correctly formed XHTML
+!! wikitext
+<br style="clear:both;" />
+!! html
+<p><br style="clear:both;" />
+</p>
+!! end
+
+!! test
+Failing to transform badly formed HTML into correct XHTML
+!! wikitext
+<br style="clear: left;">
+<br style="clear: right;">
+<br style="clear: both;">
+!! html
+<p><br style="clear: left;" />
+<br style="clear: right;" />
+<br style="clear: both;" />
+</p>
+!!end
+
+## FIXME: Is Parsoid's acceptance of self-closing html-tags
+## a feature or a bug? See https://phabricator.wikimedia.org/T76962
+!! test
+Handling html with a div self-closing tag
+!! wikitext
+<div title />
+<div title/>
+<div title/ >
+<div title=bar />
+<div title=bar/>
+<div title=bar/ >
+!! html/php
+<p>&lt;div title /&gt;
+&lt;div title/&gt;
+</p>
+<div>
+<p>&lt;div title=bar /&gt;
+&lt;div title=bar/&gt;
+</p>
+<div title="bar/"></div>
+</div>
+
+!! html/parsoid
+<div title="" data-parsoid='{"stx":"html","selfClose":true}'></div>
+<div title="" data-parsoid='{"stx":"html","selfClose":true}'></div>
+<div title="" data-parsoid='{"stx":"html","selfClose":true}'></div>
+<div title="bar" data-parsoid='{"stx":"html","selfClose":true}'></div>
+<div title="bar" data-parsoid='{"stx":"html","selfClose":true}'></div>
+<div title="bar/" data-parsoid='{"stx":"html","autoInsertedEnd":true}'></div>
+!! end
+
+!! test
+Handling html with a br self-closing tag
+!! wikitext
+<br title />
+<br title/>
+<br title/ >
+<br title=bar />
+<br title=bar/>
+<br title=bar/ >
+!! html/php
+<p><br title="" />
+<br title="" />
+<br />
+<br title="bar" />
+<br title="bar" />
+<br title="bar/" />
+</p>
+!! html/parsoid
+<p><br title="" />
+<br title="" />
+<br title="" />
+<br title="bar" />
+<br title="bar" />
+<br title="bar/" />
+</p>
+!! end
+
+!! test
+Horizontal ruler (should it add that extra space?)
+!! wikitext
+<hr>
+<hr >
+foo <hr
+> bar
+!! html+tidy
+<hr />
+<hr /><p>
+foo </p><hr /><p> bar
+</p>
+!! end
+
+!! test
+Horizontal ruler -- 4+ dashes render hr
+!! wikitext
+----
+!! html
+<hr />
+
+!! end
+
+!! test
+Horizontal ruler -- eats additional dashes on the same line
+!! wikitext
+---------
+!! html
+<hr />
+
+!! end
+
+!! test
+Horizontal ruler -- does not collapse dashes on consecutive lines
+!! wikitext
+----
+----
+!! html
+<hr />
+<hr />
+
+!! end
+
+!! test
+Horizontal ruler -- <4 dashes render as plain text
+!! wikitext
+---
+!! html
+<p>---
+</p>
+!! end
+
+!! test
+Horizontal ruler -- Supports content following dashes on same line
+!! wikitext
+---- Foo
+!! html
+<hr /> Foo
+
+!! html+tidy
+<hr /><p> Foo
+</p>
+!! end
+
+###
+### Block-level elements
+###
+!! test
+Common list
+!! wikitext
+*Common list
+*item 2
+*item 3
+!! html
+<ul><li>Common list</li>
+<li>item 2</li>
+<li>item 3</li></ul>
+
+!! end
+
+!! test
+Numbered list
+!! wikitext
+#Numbered list
+#item 2
+#item 3
+!! html
+<ol><li>Numbered list</li>
+<li>item 2</li>
+<li>item 3</li></ol>
+
+!! end
+
+# the switch from level 3 to ordered should not introduce a newline between
+!! test
+Mixed list
+!! wikitext
+*Mixed list
+*#with numbers
+**and bullets
+*#and numbers
+*bullets again
+**bullet level 2
+***bullet level 3
+***#Number on level 4
+**bullet level 2
+**#Number on level 3
+**#Number on level 3
+*#number level 2
+*Level 1
+***Level 3
+#**Level 3, but ordered
+!! html
+<ul><li>Mixed list
+<ol><li>with numbers</li></ol>
+<ul><li>and bullets</li></ul>
+<ol><li>and numbers</li></ol></li>
+<li>bullets again
+<ul><li>bullet level 2
+<ul><li>bullet level 3
+<ol><li>Number on level 4</li></ol></li></ul></li>
+<li>bullet level 2
+<ol><li>Number on level 3</li>
+<li>Number on level 3</li></ol></li></ul>
+<ol><li>number level 2</li></ol></li>
+<li>Level 1
+<ul><li><ul><li>Level 3</li></ul></li></ul></li></ul>
+<ol><li><ul><li><ul><li>Level 3, but ordered</li></ul></li></ul></li></ol>
+
+!! end
+
+!! test
+1. Nested mixed wikitext and html list
+!! wikitext
+*hi
+*<ul><li>ho</li></ul>
+*hi
+**ho
+!! html/php
+<ul><li>hi</li>
+<li><ul><li>ho</li></ul></li>
+<li>hi
+<ul><li>ho</li></ul></li></ul>
+
+!! html/parsoid
+<ul><li>hi</li>
+<li><ul data-parsoid='{"stx":"html"}'><li data-parsoid='{"stx":"html"}'>ho</li></ul></li>
+<li>hi
+<ul><li>ho</li></ul></li></ul>
+!! end
+
+!! test
+2. Nested mixed wikitext and html list (incompatible)
+!! wikitext
+;hi
+:{{echo|<li>ho</li>}}
+!! html/php
+<dl><dt>hi</dt>
+<dd><li>ho</li></dd></dl>
+
+!! html/parsoid
+<dl><dt>hi</dt>
+<dd><li about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"stx":"html","pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;li>ho&lt;/li>"}},"i":0}}]}'>ho</li></dd></dl>
+!! end
+
+!! test
+Nested lists 1
+!! wikitext
+*foo
+**bar
+!! html
+<ul><li>foo
+<ul><li>bar</li></ul></li></ul>
+
+!! end
+
+!! test
+Nested lists 2
+!! wikitext
+**foo
+*bar
+!! html
+<ul><li><ul><li>foo</li></ul></li>
+<li>bar</li></ul>
+
+!! end
+
+!! test
+Nested lists 3 (first element empty)
+!! wikitext
+*
+**bar
+!! html
+<ul><li>
+<ul><li>bar</li></ul></li></ul>
+
+!! end
+
+!! test
+Nested lists 4 (first element empty)
+!! wikitext
+**
+*bar
+!! html
+<ul><li><ul><li></li></ul></li>
+<li>bar</li></ul>
+
+!! end
+
+!! test
+Nested lists 5 (both elements empty)
+!! wikitext
+**
+*
+!! html
+<ul><li><ul><li></li></ul></li>
+<li></li></ul>
+
+!! end
+
+!! test
+Nested lists 6 (both elements empty)
+!! wikitext
+*
+**
+!! html
+<ul><li>
+<ul><li></li></ul></li></ul>
+
+!! end
+
+!! test
+Nested lists 7 (skip initial nesting levels)
+!! wikitext
+***foo
+!! html
+<ul><li><ul><li><ul><li>foo</li></ul></li></ul></li></ul>
+
+!! end
+
+!! test
+Nested lists 8 (multiple nesting transitions)
+!! wikitext
+*foo
+***bar
+**baz
+*boo
+!! html
+<ul><li>foo
+<ul><li><ul><li>bar</li></ul></li>
+<li>baz</li></ul></li>
+<li>boo</li></ul>
+
+!! end
+
+!! test
+Nested lists 9 (extension interaction)
+!! options
+parsoid
+!! wikitext
+*<references />
+!! html/parsoid
+<ul><li data-parsoid='{}'><ol class="mw-references references" typeof="mw:Extension/references" about="#mwt2" data-parsoid='{}' data-mw='{"name":"references","attrs":{}}'></ol></li></ul>
+!! end
+
+!! test
+1. Lists with start-of-line-transparent tokens before bullets: Comments
+!! wikitext
+*foo
+*<!--cmt-->bar
+<!--cmt-->*baz
+!! html
+<ul><li>foo</li>
+<li>bar</li>
+<li>baz</li></ul>
+
+!! end
+
+!! test
+2. Lists with start-of-line-transparent tokens before bullets: Template close
+!! wikitext
+*foo {{echo|bar
+}}*baz
+!! html
+<ul><li>foo bar</li>
+<li>baz</li></ul>
+
+!! end
+
+!! test
+List items are not parsed correctly following a <pre> block (T2785)
+!! wikitext
+*<pre>foo</pre>
+*<pre>bar</pre>
+*zar
+!! html/php
+<ul><li><pre>foo</pre></li>
+<li><pre>bar</pre></li>
+<li>zar</li></ul>
+
+!! html/parsoid
+<ul><li><pre typeof="mw:Extension/pre" about="#mwt2" data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"foo"}}'>foo</pre></li>
+<li><pre typeof="mw:Extension/pre" about="#mwt4" data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"bar"}}'>bar</pre></li>
+<li>zar</li></ul>
+!! end
+
+# FIXME: Might benefit from a html/parsoid since this has a template
+!! test
+List items from template
+!! wikitext
+
+{{inner list}}
+*item 2
+
+*item 0
+{{inner list}}
+*item 2
+
+*item 0
+*notSOL{{inner list}}
+*item 2
+!! html
+<ul><li>item 1</li>
+<li>item 2</li></ul>
+<ul><li>item 0</li>
+<li>item 1</li>
+<li>item 2</li></ul>
+<ul><li>item 0</li>
+<li>notSOL</li>
+<li>item 1</li>
+<li>item 2</li></ul>
+
+!! end
+
+!! test
+List interrupted by empty line or heading
+!! wikitext
+*foo
+
+**bar
+==A heading==
+*Another list item
+!! html
+<ul><li>foo</li></ul>
+<ul><li><ul><li>bar</li></ul></li></ul>
+<h2><span class="mw-headline" id="A_heading">A heading</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: A heading">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<ul><li>Another list item</li></ul>
+
+!!end
+
+!!test
+Multiple list tags generated by templates
+!! wikitext
+{{echo|<li>}}a
+{{echo|<li>}}b
+{{echo|<li>}}c
+!! html
+<li>a
+<li>b
+<li>c</li>
+</li>
+</li>
+
+!! html+tidy
+<li>a
+</li><li>b
+</li><li>c
+</li>
+!! html/parsoid
+<li about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"stx":"html","autoInsertedEnd":true,"dsr":[0,44,null,null],"pi":[[{"k":"1"}],[{"k":"1"}],[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;li>"}},"i":0}},"a\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;li>"}},"i":1}},"b\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;li>"}},"i":2}},"c"]}'>a
+</li><li about="#mwt1">b
+</li><li about="#mwt1" data-parsoid='{"stx":"html","autoInsertedEnd":true,"dsr":[null,44,null,0]}'>c</li>
+!!end
+
+!!test
+Single-comment whitespace lines dont break lists, and neither do multi-comment whitespace lines
+!! wikitext
+*a
+<!--This line will NOT split the list-->
+*b
+ <!--This line will NOT split the list either-->
+*c
+ <!--foo--> <!----> <!--This line NOT split the list either-->
+*d
+!! html
+<ul><li>a</li>
+<li>b</li>
+<li>c</li>
+<li>d</li></ul>
+
+!!end
+
+!!test
+Replacing whitespace with tabs still doesn't break the list (gerrit 78327)
+!! wikitext
+*a
+<!--This line will NOT split the list-->
+*b
+ <!--This line will NOT split the list either-->
+*c
+ <!--foo--> <!----> <!--This line NOT split the list
+ either-->
+*d
+!! html
+<ul><li>a</li>
+<li>b</li>
+<li>c</li>
+<li>d</li></ul>
+
+!!end
+
+# FIXME: Parsoid has a dedicated DOM pass to mimic this Tidy-specific li-hack
+# That pass could possibly be removed.
+!!test
+Test the li-hack (a hack from Tidy days, but doesn't work as advertised with Remex)
+!!options
+parsoid=wt2html,wt2wt
+!! wikitext
+*foo
+*<li>li-hack
+*{{echo|<li>templated li-hack}}
+*<!--foo--><li> unsupported li-hack with preceding comments
+
+<ul>
+<li><li>not a li-hack
+</li>
+</ul>
+!! html+tidy
+<ul><li>foo</li>
+<li class="mw-empty-elt"></li><li>li-hack</li>
+<li class="mw-empty-elt"></li><li>templated li-hack</li>
+<li class="mw-empty-elt"></li><li> unsupported li-hack with preceding comments</li></ul>
+<ul>
+<li class="mw-empty-elt"></li><li>not a li-hack
+</li>
+</ul>
+!! html/parsoid
+<ul><li> foo</li>
+<li data-parsoid='{"stx":"html","autoInsertedEnd":true,"liHackSrc":"*"}'>li-hack</li>
+<li about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"stx":"html","autoInsertedEnd":true,,"pi":[[{"k":"1"}]]}' data-mw='{"parts":["*",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;li>templated li-hack"}},"i":0}}]}'>templated li-hack</li>
+<li data-parsoid='{"autoInsertedEnd":true}'><!--foo--></li><li data-parsoid='{"stx":"html","autoInsertedEnd":true}'>unsupported li-hack with preceding comments</li></ul>
+
+<ul data-parsoid='{"stx":"html"}'>
+<li class="mw-empty-elt" data-parsoid='{"stx":"html","autoInsertedEnd":true}'></li><li data-parsoid='{"stx":"html"}'>not a li-hack
+</li>
+</ul>
+
+!!end
+
+!! test
+Parsoid: Make sure nested lists are serialized on their own line even if HTML contains no newlines
+!! options
+parsoid
+!! wikitext
+#foo
+##bar
+
+*foo
+**bar
+
+:foo
+::bar
+!! html
+<ol>
+<li>foo<ol>
+<li>bar</li>
+</ol></li>
+</ol><ul>
+<li>foo<ul>
+<li>bar</li>
+</ul></li>
+</ul><dl>
+<dd>foo<dl>
+<dd>bar</dd>
+</dl></dd>
+</dl>
+!! end
+
+!! test
+Parsoid: Test of whitespace serialization with Templated bullets
+!! options
+parsoid
+!! wikitext
+* {{bullet}}
+!! html/parsoid
+<ul>
+<li class="mw-empty-elt"> </li><li about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"bullet","href":"./Template:Bullet"},"params":{},"i":0}}]}'> Bar</li>
+</ul>
+!! end
+
+# ------------------------------------------------------------------------
+# The next set of tests are about Parsoid's ability to handle badly nested
+# tags (parse, minimize scope of fixup, and roundtrip back)
+# ------------------------------------------------------------------------
+
+# Remex and Parsoid output stems from list handling diffs because Parsoid & PHP parser.
+# Parsoid's list handling is more aware of block structure.
+!! test
+Unbalanced closing block tags break a list
+!! wikitext
+<div>
+*a</div><div>
+*b</div>
+!! html+tidy
+<div>
+<ul><li>a</li></ul></div><div>
+<li>b</li></div>
+!! html/parsoid
+<div><ul>
+<li>a</li>
+</ul></div>
+<div><ul>
+<li>b</li>
+</ul></div>
+!! end
+
+!! test
+Unbalanced closing non-block tags don't break a list
+!! wikitext
+<span>
+*a</span><span>
+*b</span>
+!! html/php+tidy
+<p><span>
+</span></p>
+<ul><li>a<span></span></li>
+<li>b</li></ul>
+!! html/parsoid
+<span>
+<ul>
+<li>a<span></span></li>
+<li>b</li>
+</ul>
+</span>
+!! end
+
+# Parsoid does some post-dom-building cleanup
+# which is why its output differs from Remex.
+!! test
+Unclosed formatting tags that straddle lists are closed and reopened
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+#<s> a
+#b </s>
+!! html/php+tidy
+<ol><li><s> a</s></li><s>
+</s><li><s>b </s></li></ol>
+!! html/parsoid
+<ol><li><s> a</s></li>
+<li><s>b </s></li></ol>
+!! end
+
+# Output is ugly because of all the misnested tag fixups.
+# Remex is wrapping p-tags around empty elements.
+# Parsoid has special-case handling of this pattern of
+# wrapping lists in formatting tags.
+# FIXME: Should we remove this code from Parsoid? Or add
+# special support in Remex? If the latter, maybe just wait
+# for Parsoid to become the default parser.
+# See T70395.
+!!test
+1. List embedded in a formatting tag
+!! wikitext
+<small>
+*foo
+</small>
+!! html/php+tidy
+<p><small>
+</small></p><small><ul><li>foo</li></ul></small><small></small><p><small></small>
+</p>
+!! html/parsoid
+<small>
+<ul>
+<li>foo</li>
+</ul>
+</small>
+!!end
+
+# Output is ugly because of all the misnested tag fixups
+# Remex is wrapping p-tags around empty elements.
+# Parsoid has code that strips useless p-tags.
+!!test
+2. List embedded in a formatting tag in a misnested way
+!! wikitext
+<small>
+*a
+*b</small>
+!! html/php+tidy
+<p><small>
+</small></p><small></small><ul><small><li>a</li>
+</small><li><small>b</small></li></ul>
+!! html/parsoid
+<small></small>
+<ul><small>
+<li>a</li>
+</small>
+<li><small>b</small></li>
+</ul>
+!!end
+
+!! test
+Table with missing opening <tr> tag
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+<table>
+<td>foo</td>
+</tr>
+</table>
+!! html+tidy
+<table>
+<tbody><tr><td>foo</td>
+</tr>
+</tbody></table>
+!! end
+
+###
+### Magic Words
+###
+
+# Note that the current date is hard-coded as
+# 1970-01-01T00:02:03Z (a Thursday)
+# when running parser tests. The timezone is also fixed to GMT, so
+# local date will be identical to current date.
+
+!! test
+Magic Word: {{CURRENTDAY}}
+!! wikitext
+{{CURRENTDAY}}
+!! html
+<p>1
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTDAY2}}
+!! wikitext
+{{CURRENTDAY2}}
+!! html
+<p>01
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTDAYNAME}}
+!! wikitext
+{{CURRENTDAYNAME}}
+!! html
+<p>Thursday
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTDOW}}
+!! wikitext
+{{CURRENTDOW}}
+!! html
+<p>4
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTMONTH}}
+!! wikitext
+{{CURRENTMONTH}}
+!! html
+<p>01
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTMONTH1}}
+!! wikitext
+{{CURRENTMONTH1}}
+!! html
+<p>1
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTMONTHABBREV}}
+!! wikitext
+{{CURRENTMONTHABBREV}}
+!! html
+<p>Jan
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTMONTHNAME}}
+!! wikitext
+{{CURRENTMONTHNAME}}
+!! html
+<p>January
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTMONTHNAMEGEN}}
+!! wikitext
+{{CURRENTMONTHNAMEGEN}}
+!! html
+<p>January
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTTIME}}
+!! wikitext
+{{CURRENTTIME}}
+!! html
+<p>00:02
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTHOUR}}
+!! wikitext
+{{CURRENTHOUR}}
+!! html
+<p>00
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTWEEK}} (T6594)
+!! wikitext
+{{CURRENTWEEK}}
+!! html
+<p>1
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTYEAR}}
+!! wikitext
+{{CURRENTYEAR}}
+!! html
+<p>1970
+</p>
+!! end
+
+!! test
+Magic Word: {{CURRENTTIMESTAMP}}
+!! wikitext
+{{CURRENTTIMESTAMP}}
+!! html
+<p>19700101000203
+</p>
+!! end
+
+!! test
+Magic Words LOCAL (UTC)
+!! wikitext
+*{{LOCALMONTH}}
+*{{LOCALMONTH1}}
+*{{LOCALMONTHNAME}}
+*{{LOCALMONTHNAMEGEN}}
+*{{LOCALMONTHABBREV}}
+*{{LOCALDAY}}
+*{{LOCALDAY2}}
+*{{LOCALDAYNAME}}
+*{{LOCALYEAR}}
+*{{LOCALTIME}}
+*{{LOCALHOUR}}
+*{{LOCALWEEK}}
+*{{LOCALDOW}}
+*{{LOCALTIMESTAMP}}
+!! html
+<ul><li>01</li>
+<li>1</li>
+<li>January</li>
+<li>January</li>
+<li>Jan</li>
+<li>1</li>
+<li>01</li>
+<li>Thursday</li>
+<li>1970</li>
+<li>00:02</li>
+<li>00</li>
+<li>1</li>
+<li>4</li>
+<li>19700101000203</li></ul>
+
+!! end
+
+!! test
+Magic Word: {{FULLPAGENAME}}
+!! options
+title=[[User:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{FULLPAGENAME}}
+!! html/*
+<p>User:Ævar Arnfjörð Bjarmason
+</p>
+!! end
+
+!! test
+Magic Word: {{FULLPAGENAMEE}}
+!! options
+title=[[User:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{FULLPAGENAMEE}}
+!! html/*
+<p>User:%C3%86var_Arnfj%C3%B6r%C3%B0_Bjarmason
+</p>
+!! end
+
+!! test
+Magic Word: {{TALKSPACE}}
+!! options
+title=[[User:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{TALKSPACE}}
+!! html/*
+<p>User talk
+</p>
+!! end
+
+!! test
+Magic Word: {{TALKSPACE}}, same namespace
+!! options
+title=[[User talk:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{TALKSPACE}}
+!! html/*
+<p>User talk
+</p>
+!! end
+
+!! test
+Magic Word: {{TALKSPACE}}, main namespace
+!! options
+title=[[Parser Test]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{TALKSPACE}}
+!! html/*
+<p>Talk
+</p>
+!! end
+
+!! test
+Magic Word: {{TALKSPACEE}}
+!! options
+title=[[User:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{TALKSPACEE}}
+!! html/*
+<p>User_talk
+</p>
+!! end
+
+!! test
+Magic Word: {{SUBJECTSPACE}}
+!! options
+title=[[User talk:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{SUBJECTSPACE}}
+!! html/*
+<p>User
+</p>
+!! end
+
+!! test
+Magic Word: {{SUBJECTSPACE}}, same namespace
+!! options
+title=[[User:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{SUBJECTSPACE}}
+!! html/*
+<p>User
+</p>
+!! end
+
+!! test
+Magic Word: {{SUBJECTSPACE}}, main namespace
+!! options
+title=[[Parser Test]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{SUBJECTSPACE}}
+!! html/*
+
+!! end
+
+!! test
+Magic Word: {{SUBJECTSPACEE}}
+!! options
+title=[[User talk:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{SUBJECTSPACEE}}
+!! html/*
+<p>User
+</p>
+!! end
+
+!! test
+Magic Word: {{NAMESPACE}}
+!! options
+title=[[User:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{NAMESPACE}}
+!! html/*
+<p>User
+</p>
+!! end
+
+!! test
+Magic Word: {{NAMESPACEE}}
+!! options
+title=[[User:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{NAMESPACEE}}
+!! html/*
+<p>User
+</p>
+!! end
+
+!! test
+Magic Word: {{NAMESPACENUMBER}}
+!! options
+title=[[User:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{NAMESPACENUMBER}}
+!! html/*
+<p>2
+</p>
+!! end
+
+!! test
+Magic Word: {{SUBPAGENAME}}
+!! options
+title=[[Ævar Arnfjörð Bjarmason/sub ö]] subpage
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{SUBPAGENAME}}
+!! html/*
+<p>sub ö
+</p>
+!! end
+
+!! test
+Magic Word: {{SUBPAGENAMEE}}
+!! options
+title=[[Ævar Arnfjörð Bjarmason/sub ö]] subpage
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{SUBPAGENAMEE}}
+!! html/*
+<p>sub_%C3%B6
+</p>
+!! end
+
+!! test
+Magic Word: {{ROOTPAGENAME}}
+!! options
+title=[[Ævar Arnfjörð Bjarmason/sub/sub2]] subpage
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{ROOTPAGENAME}}
+!! html/*
+<p>Ævar Arnfjörð Bjarmason
+</p>
+!! end
+
+!! test
+Magic Word: {{ROOTPAGENAMEE}}
+!! options
+title=[[Ævar Arnfjörð Bjarmason/sub/sub2]] subpage
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{ROOTPAGENAMEE}}
+!! html/*
+<p>%C3%86var_Arnfj%C3%B6r%C3%B0_Bjarmason
+</p>
+!! end
+
+!! test
+Magic Word: {{BASEPAGENAME}}
+!! options
+title=[[Ævar Arnfjörð Bjarmason/sub]] subpage
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{BASEPAGENAME}}
+!! html/*
+<p>Ævar Arnfjörð Bjarmason
+</p>
+!! end
+
+!! test
+Magic Word: {{BASEPAGENAMEE}}
+!! options
+title=[[Ævar Arnfjörð Bjarmason/sub]] subpage
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{BASEPAGENAMEE}}
+!! html/*
+<p>%C3%86var_Arnfj%C3%B6r%C3%B0_Bjarmason
+</p>
+!! end
+
+!! test
+Magic Word: {{TALKPAGENAME}}
+!! options
+title=[[User:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{TALKPAGENAME}}
+!! html/*
+<p>User talk:Ævar Arnfjörð Bjarmason
+</p>
+!! end
+
+!! test
+Magic Word: {{TALKPAGENAMEE}}
+!! options
+title=[[User:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{TALKPAGENAMEE}}
+!! html/*
+<p>User_talk:%C3%86var_Arnfj%C3%B6r%C3%B0_Bjarmason
+</p>
+!! end
+
+!! test
+Magic Word: {{SUBJECTPAGENAME}}
+!! options
+title=[[User talk:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{SUBJECTPAGENAME}}
+!! html/*
+<p>User:Ævar Arnfjörð Bjarmason
+</p>
+!! end
+
+!! test
+Magic Word: {{SUBJECTPAGENAMEE}}
+!! options
+title=[[User talk:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{SUBJECTPAGENAMEE}}
+!! html/*
+<p>User:%C3%86var_Arnfj%C3%B6r%C3%B0_Bjarmason
+</p>
+!! end
+
+!! test
+Magic Word: {{NUMBEROFFILES}}
+!! options
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{NUMBEROFFILES}}
+!! html/*
+<p>7
+</p>
+!! end
+
+!! test
+Magic Word: {{PAGENAME}}
+!! options
+title=[[User:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{PAGENAME}}
+!! html/*
+<p>Ævar Arnfjörð Bjarmason
+</p>
+!! end
+
+!! test
+Magic Word: {{PAGENAME}} with metacharacters
+!! options
+title=[['foo & bar = baz']]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+''{{PAGENAME}}''
+!! html+tidy
+<p><i>&#39;foo &#38; bar &#61; baz&#39;</i>
+</p>
+!! end
+
+!! test
+Magic Word: {{PAGENAME}} with metacharacters (T28781)
+!! options
+title=[[*RFC 1234 http://example.com/]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{PAGENAME}}
+!! html+tidy
+<p>&#42;RFC&#32;1234 http&#58;//example.com/
+</p>
+!! end
+
+!! test
+Magic Word: {{PAGENAMEE}}
+!! options
+title=[[User:Ævar Arnfjörð Bjarmason]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{PAGENAMEE}}
+!! html/*
+<p>%C3%86var_Arnfj%C3%B6r%C3%B0_Bjarmason
+</p>
+!! end
+
+!! test
+Magic Word: {{PAGENAMEE}} with metacharacters (T28781)
+!! options
+title=[[*RFC 1234 http://example.com/]]
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{PAGENAMEE}}
+!! html+tidy
+<p>&#42;RFC_1234_http&#58;//example.com/
+</p>
+!! end
+
+!! test
+Magic Word: {{REVISIONID}}
+!! options
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+showflags
+!! wikitext
+{{REVISIONID}}
+!! html/*
+<p>1337
+</p>
+flags=vary-revision-id
+!! end
+
+!! test
+Magic Word: {{SCRIPTPATH}}
+!! options
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{SCRIPTPATH}}
+!! html/*
+
+!! end
+
+!! test
+Magic Word: {{STYLEPATH}}
+!! options
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{STYLEPATH}}
+!! html/*
+<p>/skins
+</p>
+!! end
+
+!! test
+Magic Word: {{SERVER}}
+!! options
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{SERVER}}
+!! html/*
+<p><a rel="nofollow" class="external free" href="http://example.org">http://example.org</a>
+</p>
+!! end
+
+!! test
+Magic Word: {{SERVERNAME}}
+!! options
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{SERVERNAME}}
+!! html/*
+<p>example.org
+</p>
+!! end
+
+!! test
+Magic Word: {{SITENAME}}
+!! options
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{SITENAME}}
+!! html/*
+<p>MediaWiki
+</p>
+!! end
+
+!! test
+Magic Word: {{PAGELANGUAGE}}
+!! options
+language=fr
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{PAGELANGUAGE}}
+!! html/*
+<p>fr
+</p>
+!! end
+
+!! test
+Magic Word: {{PAGELANGUAGE}} on a page with no explicitly set language
+!! options
+parsoid={ "modes": ["wt2html","wt2wt"], "normalizePhp": true }
+!! wikitext
+{{PAGELANGUAGE}}
+!! html/*
+<p>en
+</p>
+!! end
+
+!! test
+Case-sensitive magic words, when cased differently, should just be template transclusions
+!! wikitext
+{{CurrentMonth}}
+{{currentday}}
+{{cURreNTweEK}}
+{{currentHour}}
+!! html
+<p><a href="/index.php?title=Template:CurrentMonth&amp;action=edit&amp;redlink=1" class="new" title="Template:CurrentMonth (page does not exist)">Template:CurrentMonth</a>
+<a href="/index.php?title=Template:Currentday&amp;action=edit&amp;redlink=1" class="new" title="Template:Currentday (page does not exist)">Template:Currentday</a>
+<a href="/index.php?title=Template:CURreNTweEK&amp;action=edit&amp;redlink=1" class="new" title="Template:CURreNTweEK (page does not exist)">Template:CURreNTweEK</a>
+<a href="/index.php?title=Template:CurrentHour&amp;action=edit&amp;redlink=1" class="new" title="Template:CurrentHour (page does not exist)">Template:CurrentHour</a>
+</p>
+!! end
+
+!! test
+Case-insensitive magic words should still work with weird casing.
+!! wikitext
+{{sErVeRNaMe}}
+{{LCFirst:AOEU}}
+{{ucFIRST:aoeu}}
+{{SERver}}
+!! html
+<p>example.org
+aOEU
+Aoeu
+<a rel="nofollow" class="external free" href="http://example.org">http://example.org</a>
+</p>
+!! end
+
+# From plwiki:PLOS_ONE
+!! test
+Parsoid: Page property magic word with magic word contents
+!! wikitext
+{{DISPLAYTITLE:''{{PAGENAME}}''}}
+!! html/parsoid
+<meta property="mw:PageProp/displaytitle" content="Main Page" about="#mwt3" typeof="mw:ExpandedAttrs" data-parsoid='{"src":"{{DISPLAYTITLE:&#39;&#39;{{PAGENAME}}&#39;&#39;}}"}' data-mw='{"attribs":[[{"txt":"content"},{"html":"DISPLAYTITLE:&lt;i data-parsoid=&#39;{\"dsr\":[15,31,2,2]}&#39;>&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[]],\"dsr\":[17,29,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"PAGENAME\",\"function\":\"pagename\"},\"params\":{},\"i\":0}}]}&#39;>Main Page&lt;/span>&lt;/i>"}]]}'/>
+!! end
+
+# NOTE: mw:ExpandedAttrs is not the best typeof here. mw:Transclusion is better.
+# But, this is a limitation of our representation and is documented in
+# TemplateHandler.js in processSpecialMagicWord
+!! test
+Parsoid: Template-generated DISPLAYTITLE
+!! wikitext
+{{{{echo|DISPLAYTITLE}}:Foo}}
+!! options
+showtitle
+!! config
+wgAllowDisplayTitle=true
+wgRestrictDisplayTitle=false
+!! html/php
+Foo
+
+!! html/parsoid
+<meta property="mw:PageProp/displaytitle" content="Foo" about="#mwt1" typeof="mw:ExpandedAttrs" data-parsoid='{"pi":[[]]}' data-mw='{"attribs":[[{"txt":"content"},{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[2,23,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"DISPLAYTITLE\"}},\"i\":0}}]}&#39;>DISPLAYTITLE&lt;/span>:Foo"}]]}'/>
+!! end
+
+!! test
+Namespace 1 {{ns:1}}
+!! wikitext
+{{ns:1}}
+!! html
+<p>Talk
+</p>
+!! end
+
+!! test
+Namespace 1 {{ns:01}}
+!! wikitext
+{{ns:01}}
+!! html
+<p>Talk
+</p>
+!! end
+
+!! test
+Namespace 0 {{ns:0}} (T6783)
+!! wikitext
+{{ns:0}}
+!! html
+
+!! end
+
+!! test
+Namespace 0 {{ns:00}} (T6783)
+!! wikitext
+{{ns:00}}
+!! html
+
+!! end
+
+!! test
+Namespace -1 {{ns:-1}}
+!! wikitext
+{{ns:-1}}
+!! html
+<p>Special
+</p>
+!! end
+
+!! test
+Namespace User {{ns:User}}
+!! wikitext
+{{ns:User}}
+!! html
+<p>User
+</p>
+!! end
+
+!! test
+Namespace User talk {{ns:User_talk}}
+!! wikitext
+{{ns:User_talk}}
+!! html
+<p>User talk
+</p>
+!! end
+
+!! test
+Namespace User talk {{ns:uSeR tAlK}}
+!! wikitext
+{{ns:uSeR tAlK}}
+!! html
+<p>User talk
+</p>
+!! end
+
+!! test
+Namespace File {{ns:File}}
+!! wikitext
+{{ns:File}}
+!! html
+<p>File
+</p>
+!! end
+
+!! test
+Namespace File {{ns:Image}}
+!! wikitext
+{{ns:Image}}
+!! html
+<p>File
+</p>
+!! end
+
+!! test
+Namespace (lang=de) Benutzer {{ns:User}}
+!! options
+language=de
+!! wikitext
+{{ns:User}}
+!! html
+<p>Benutzer
+</p>
+!! end
+
+!! test
+Namespace (lang=de) Benutzer Diskussion {{ns:3}}
+!! options
+language=de
+!! wikitext
+{{ns:3}}
+!! html
+<p>Benutzer Diskussion
+</p>
+!! end
+
+!! test
+Urlencode
+!! wikitext
+{{urlencode:hi world?!}}
+{{urlencode:hi world?!|WIKI}}
+{{urlencode:hi world?!|PATH}}
+{{urlencode:hi world?!|QUERY}}
+!! html/php
+<p>hi+world%3F%21
+hi_world%3F!
+hi%20world%3F%21
+hi+world%3F%21
+</p>
+!! end
+
+!! test
+Magic Word: prioritize type info over data-parsoid
+!! options
+parsoid=html2wt
+!! html/parsoid
+<meta property="mw:PageProp/forcetoc" data-parsoid='{"magicSrc":"__NOTOC__"}'/>
+!! wikitext
+__FORCETOC__
+!! end
+
+!! test
+Magic Word: serialize on separate line (parsoid)
+!! options
+parsoid=wt2wt,html2wt
+!! wikitext
+foo
+__NOTOC__
+bar
+!! html/parsoid
+foo<meta property="mw:PageProp/notoc"/>bar
+!! end
+
+!! test
+Magic Word: rt non-english wikis
+!! options
+parsoid=wt2wt
+language=de
+!! wikitext
+__NOEDITSECTION__
+!! html/parsoid
+<meta property="mw:PageProp/noeditsection" data-parsoid='{"magicSrc":"__NOEDITSECTION__"}'/>
+!! end
+
+!!test
+__proto__ is treated as normal wikitext (T105997)
+!!wikitext
+__proto__
+!!html
+<p>__proto__
+</p>
+!!end
+
+###
+### Magic links
+###
+!! test
+Magic links: internal link to RFC (T2479)
+!! wikitext
+[[RFC 123]]
+!! html/php
+<p><a href="/index.php?title=RFC_123&amp;action=edit&amp;redlink=1" class="new" title="RFC 123 (page does not exist)">RFC 123</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./RFC_123" title="RFC 123">RFC 123</a></p>
+!! end
+
+!! test
+Magic links: RFC (T2479)
+!! wikitext
+RFC 822
+!! html/php
+<p><a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc822">RFC 822</a>
+</p>
+!! html/parsoid
+<p><a href="https://tools.ietf.org/html/rfc822" rel="mw:ExtLink" class="external text">RFC 822</a></p>
+!! end
+
+!! test
+Magic links: RFC (T67278)
+!! wikitext
+This is RFC 822 but thisRFC 822 is not RFC 822linked.
+!! html/php
+<p>This is <a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc822">RFC 822</a> but thisRFC 822 is not RFC 822linked.
+</p>
+!! html/parsoid
+<p>This is <a href="https://tools.ietf.org/html/rfc822" rel="mw:ExtLink" class="external text">RFC 822</a> but thisRFC 822 is not RFC 822linked.</p>
+!! end
+
+!! test
+Magic links: RFC (w/ non-newline whitespace, T30950/T31025)
+!! wikitext
+RFC &nbsp;&#160;&#0160;&#xA0;&#Xa0; 822
+RFC
+822
+!! html/php
+<p><a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc822">RFC 822</a>
+RFC
+822
+</p>
+!! html/parsoid
+<p><a href="https://tools.ietf.org/html/rfc822" rel="mw:ExtLink" class="external text">RFC <span typeof="mw:Entity" data-parsoid='{"src":"&amp;nbsp;","srcContent":" "}'> </span><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#160;","srcContent":" "}'> </span><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#0160;","srcContent":" "}'> </span><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#xA0;","srcContent":" "}'> </span><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#Xa0;","srcContent":" "}'> </span> 822</a>
+RFC
+822</p>
+!! end
+
+!! test
+Magic links: ISBN (T3937)
+!! wikitext
+ISBN 0-306-40615-2
+!! html/php
+<p><a href="/wiki/Special:BookSources/0306406152" class="internal mw-magiclink-isbn">ISBN 0-306-40615-2</a>
+</p>
+!! html/parsoid
+<p><a href="./Special:BookSources/0306406152" rel="mw:WikiLink">ISBN 0-306-40615-2</a></p>
+!! end
+
+!! test
+Magic links: ISBN (T67278)
+!! wikitext
+This is ISBN 978-0-316-09811-3 but thisISBN 978-0-316-09811-3 is not ISBN 978-0-316-09811-3linked.
+!! html/php
+<p>This is <a href="/wiki/Special:BookSources/9780316098113" class="internal mw-magiclink-isbn">ISBN 978-0-316-09811-3</a> but thisISBN 978-0-316-09811-3 is not ISBN 978-0-316-09811-3linked.
+</p>
+!! html/parsoid
+<p>This is <a href="./Special:BookSources/9780316098113" rel="mw:WikiLink">ISBN 978-0-316-09811-3</a> but thisISBN 978-0-316-09811-3 is not ISBN 978-0-316-09811-3linked.</p>
+!! end
+
+!! test
+Magic links: ISBN (w/ non-newline whitespace, T30950/T31025)
+!! wikitext
+ISBN &nbsp;&#160;&#0160;&#xA0;&#Xa0; 978&nbsp;0&#160;316&#0160;09811&#xA0;3
+ISBN
+9780316098113
+ISBN 978
+0316098113
+!! html/php
+<p><a href="/wiki/Special:BookSources/9780316098113" class="internal mw-magiclink-isbn">ISBN 978 0 316 09811 3</a>
+ISBN
+9780316098113
+ISBN 978
+0316098113
+</p>
+!! html/parsoid
+<p><a href="./Special:BookSources/9780316098113" rel="mw:WikiLink">ISBN <span typeof="mw:Entity" data-parsoid='{"src":"&amp;nbsp;","srcContent":" "}'> </span><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#160;","srcContent":" "}'> </span><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#0160;","srcContent":" "}'> </span><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#xA0;","srcContent":" "}'> </span><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#Xa0;","srcContent":" "}'> </span> 978<span typeof="mw:Entity" data-parsoid='{"src":"&amp;nbsp;","srcContent":" "}'> </span>0<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#160;","srcContent":" "}'> </span>316<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#0160;","srcContent":" "}'> </span>09811<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#xA0;","srcContent":" "}'> </span>3</a>
+ISBN
+9780316098113
+ISBN 978
+0316098113</p>
+!! end
+
+!! test
+Magic links: PMID incorrectly converts space to underscore
+!! wikitext
+PMID 1234
+!! html/php
+<p><a class="external mw-magiclink-pmid" rel="nofollow" href="//www.ncbi.nlm.nih.gov/pubmed/1234?dopt=Abstract">PMID 1234</a>
+</p>
+!! html/parsoid
+<p><a href="//www.ncbi.nlm.nih.gov/pubmed/1234?dopt=Abstract" rel="mw:ExtLink" class="external text">PMID 1234</a></p>
+!! end
+
+!! test
+Magic links: PMID (T67278)
+!! wikitext
+This is PMID 1234 but thisPMID 1234 is not PMID 1234linked.
+!! html/php
+<p>This is <a class="external mw-magiclink-pmid" rel="nofollow" href="//www.ncbi.nlm.nih.gov/pubmed/1234?dopt=Abstract">PMID 1234</a> but thisPMID 1234 is not PMID 1234linked.
+</p>
+!! html/parsoid
+<p>This is <a href="//www.ncbi.nlm.nih.gov/pubmed/1234?dopt=Abstract" rel="mw:ExtLink" class="external text">PMID 1234</a> but thisPMID 1234 is not PMID 1234linked.</p>
+!! end
+
+!! test
+Magic links: PMID (w/ non-newline whitespace, T30950/T31025)
+!! wikitext
+PMID &nbsp;&#160;&#0160;&#xA0;&#Xa0; 1234
+PMID
+1234
+!! html/php
+<p><a class="external mw-magiclink-pmid" rel="nofollow" href="//www.ncbi.nlm.nih.gov/pubmed/1234?dopt=Abstract">PMID 1234</a>
+PMID
+1234
+</p>
+!! html/parsoid
+<p><a href="//www.ncbi.nlm.nih.gov/pubmed/1234?dopt=Abstract" rel="mw:ExtLink" class="external text">PMID <span typeof="mw:Entity" data-parsoid='{"src":"&amp;nbsp;","srcContent":" "}'> </span><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#160;","srcContent":" "}'> </span><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#0160;","srcContent":" "}'> </span><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#xA0;","srcContent":" "}'> </span><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#Xa0;","srcContent":" "}'> </span> 1234</a>
+PMID
+1234</p>
+!! end
+
+# <nowiki> nodes shouldn't be inserted during html2wt by Parsoid,
+# since these are ExtLinkText, not MagicLinkText
+!! test
+Magic links: use appropriate serialization for "almost" magic links.
+!! wikitext
+X[[Special:BookSources/0978739256|foo]]
+
+X[https://tools.ietf.org/html/rfc1234 foo]
+!! html/php
+<p>X<a href="/wiki/Special:BookSources/0978739256" title="Special:BookSources/0978739256">foo</a>
+</p><p>X<a rel="nofollow" class="external text" href="https://tools.ietf.org/html/rfc1234">foo</a>
+</p>
+!! html/parsoid
+<p>X<a rel="mw:WikiLink" href="./Special:BookSources/0978739256" title="Special:BookSources/0978739256">foo</a></p>
+<p>X<a rel="mw:ExtLink" class="external text" href="https://tools.ietf.org/html/rfc1234">foo</a></p>
+!! end
+
+!! test
+Magic links: All disabled (T47942)
+!! options
+wgEnableMagicLinks={"ISBN":false, "PMID":false, "RFC":false}
+!! wikitext
+ISBN 0-306-40615-2
+PMID 1234
+RFC 4321
+!! html/php
+<p>ISBN 0-306-40615-2
+PMID 1234
+RFC 4321
+</p>
+!! end
+
+###
+### Templates
+####
+
+!! test
+Nonexistent template
+!! wikitext
+{{thistemplatedoesnotexist}}
+!! html
+<p><a href="/index.php?title=Template:Thistemplatedoesnotexist&amp;action=edit&amp;redlink=1" class="new" title="Template:Thistemplatedoesnotexist (page does not exist)">Template:Thistemplatedoesnotexist</a>
+</p>
+!! end
+
+!! test
+Template with invalid target containing tags
+!! wikitext
+{{a<b>b</b>|{{echo|foo}}|{{echo|a}}={{echo|b}}|a = b}}
+!! html
+<p>{{a<b>b</b>|foo|a=b|a = b}}
+</p>
+!! end
+
+!! test
+Template with invalid target containing unclosed tag
+!! wikitext
+{{a<b>|{{echo|foo}}|{{echo|a}}={{echo|b}}|a = b}}
+!! html
+<p>{{a<b>|foo|a=b|a = b}}</b>
+</p>
+!! end
+
+!! test
+Template with invalid target containing wikilink
+!! wikitext
+{{[[Main Page]]}}
+!! html/php
+<p>{{<a href="/wiki/Main_Page" title="Main Page">Main Page</a>}}
+</p>
+!! html/parsoid
+<p><span typeof="mw:Transclusion" about="#mwt1" data-mw='{"parts":[{"template":{"target":{"wt":"[[Main Page]]"},"params":{},"i":0}}]}'>{{</span><a rel="mw:WikiLink" href="./Main_Page" about="#mwt1">Main Page</a><span about="#mwt1">}}</span></p>
+!! end
+
+!! test
+Template with just whitespace in it, T70421
+!! wikitext
+{{echo|{{ }}}}
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"{{ }}"}},"i":0}}]}'>{{ }}</p>
+!! end
+
+!! article
+Template:test
+!! text
+This is a test template
+!! endarticle
+
+!! test
+Simple template
+!! wikitext
+{{test}}
+!! html
+<p>This is a test template
+</p>
+!! end
+
+!! test
+Template with explicit namespace
+!! wikitext
+{{Template:test}}
+!! html
+<p>This is a test template
+</p>
+!! end
+
+
+!! article
+Template:paramtest
+!! text
+This is a test template with parameter {{{param}}}
+!! endarticle
+
+!! test
+Template parameter
+!! wikitext
+{{paramtest|param=foo}}
+!! html
+<p>This is a test template with parameter foo
+</p>
+!! end
+
+!! article
+Template:paramtestnum
+!! text
+[[{{{1}}}|{{{2}}}]]
+!! endarticle
+
+!! test
+Template unnamed parameter
+!! wikitext
+{{paramtestnum|Main Page|the main page}}
+!! html
+<p><a href="/wiki/Main_Page" title="Main Page">the main page</a>
+</p>
+!! end
+
+!! article
+Template:templatesimple
+!! text
+(test)
+!! endarticle
+
+!! article
+Template:templateredirect
+!! text
+#redirect [[Template:templatesimple]]
+!! endarticle
+
+!! article
+Template:templateasargtestnum
+!! text
+{{{{{1}}}}}
+!! endarticle
+
+!! article
+Template:templateasargtest
+!! text
+{{template{{{templ}}}}}
+!! endarticle
+
+!! article
+Template:templateasargtest2
+!! text
+{{{{{templ}}}}}
+!! endarticle
+
+!! test
+Template with template name as unnamed argument
+!! wikitext
+{{templateasargtestnum|templatesimple}}
+!! html
+<p>(test)
+</p>
+!! end
+
+!! test
+Template with template name as argument
+!! wikitext
+{{templateasargtest|templ=simple}}
+!! html
+<p>(test)
+</p>
+!! end
+
+!! test
+Template with template name as argument (2)
+!! wikitext
+{{templateasargtest2|templ=templatesimple}}
+!! html
+<p>(test)
+</p>
+!! end
+
+!! article
+Template:templateasargtestdefault
+!! text
+{{{{{templ|templatesimple}}}}}
+!! endarticle
+
+!! article
+Template:templa
+!! text
+'''templ'''
+!! endarticle
+
+!! test
+Template with default value
+!! wikitext
+{{templateasargtestdefault}}
+!! html
+<p>(test)
+</p>
+!! end
+
+!! test
+Template with default value (value set)
+!! wikitext
+{{templateasargtestdefault|templ=templa}}
+!! html
+<p><b>templ</b>
+</p>
+!! end
+
+!! test
+Template redirect
+!! wikitext
+{{templateredirect}}
+!! html/php
+<p>(test)
+</p>
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="./Template:Templatesimple" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"templateredirect","href":"./Template:Templateredirect"},"params":{},"i":0}}]}'/>
+!! end
+
+!! test
+Template with argument in separate line
+!! wikitext
+{{ templateasargtest |
+ templ = simple }}
+!! html
+<p>(test)
+</p>
+!! end
+
+!! test
+Template with complex template as argument
+!! wikitext
+{{paramtest|
+ param ={{ templateasargtest |
+ templ = simple }}}}
+!! html
+<p>This is a test template with parameter (test)
+</p>
+!! end
+
+!! test
+Templates with templated name
+!! wikitext
+{{{{echo|echo}}|foo}}
+{{{{echo|inner list}} }}
+!! html
+<p>foo
+</p>
+<ul><li>item 1</li></ul>
+
+!! html/parsoid
+<p about="#mwt2" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"{{echo|echo}}","href":"./Template:Echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</p>
+<ul about="#mwt4" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"{{echo|inner list}} ","href":"./Template:Inner_list"},"params":{},"i":0}}]}'><li>item 1</li></ul>
+!! end
+
+## Regression test; the output here isn't really that interesting.
+!! test
+Templates with templated name and top level template args
+!! wikitext
+{{1{{2{{{3}}}|4=5}}}}
+!! html/parsoid
+<p about="#mwt2" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"1{{2{{{3}}}|4=5}}"},"params":{},"i":0}}]}'>{{1{{2{{{3}}}|4=5}}}}</p>
+!! end
+
+# Parsoid markup is deliberate "broken". This is an edge case.
+# See long comment in TemplateHandler.js:convertAttribsToString.
+!! test
+Templates with invalid templated targets
+!! wikitext
+{{echo
+{{echo|foo}}
+}}
+!! html/php
+<p>{{echo
+foo
+}}
+</p>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo\n{{echo|foo}}\n"},"params":{},"i":0}}]}'>{{echo
+foo }}</p>
+!! end
+
+!! test
+Template with thumb image (with link in description)
+!! wikitext
+{{paramtest|param=[[Image:noimage.png|thumb|[[no link|link]] [[no link|caption]]]]}}
+!! html/php
+This is a test template with parameter <div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/index.php?title=Special:Upload&amp;wpDestFile=Noimage.png" class="new" title="File:Noimage.png">File:Noimage.png</a> <div class="thumbcaption"><a href="/index.php?title=No_link&amp;action=edit&amp;redlink=1" class="new" title="No link (page does not exist)">link</a> <a href="/index.php?title=No_link&amp;action=edit&amp;redlink=1" class="new" title="No link (page does not exist)">caption</a></div></div></div>
+
+!! html+tidy
+<p>This is a test template with parameter </p><div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/index.php?title=Special:Upload&amp;wpDestFile=Noimage.png" class="new" title="File:Noimage.png">File:Noimage.png</a> <div class="thumbcaption"><a href="/index.php?title=No_link&amp;action=edit&amp;redlink=1" class="new" title="No link (page does not exist)">link</a> <a href="/index.php?title=No_link&amp;action=edit&amp;redlink=1" class="new" title="No link (page does not exist)">caption</a></div></div></div>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"paramtest","href":"./Template:Paramtest"},"params":{"param":{"wt":"[[Image:noimage.png|thumb|[[no link|link]] [[no link|caption]]]]"}},"i":0}}]}'>This is a test template with parameter </p><figure class="mw-default-size" typeof="mw:Error mw:Image/Thumb" about="#mwt1" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Noimage.png" ><img resource="./File:Noimage.png" src="./Special:FilePath/Noimage.png" height="220" width="220"/></a><figcaption><a rel="mw:WikiLink" href="./No_link" title="No link">link</a> <a rel="mw:WikiLink" href="./No_link" title="No link">caption</a></figcaption></figure>
+!! end
+
+!! article
+Template:complextemplate
+!! text
+{{{1}}} {{paramtest|
+ param ={{{param}}}}}
+!! endarticle
+
+!! test
+Template with complex arguments
+!! wikitext
+{{complextemplate|
+ param ={{ templateasargtest |
+ templ = simple }}|[[Template:complextemplate|link]]}}
+!! html
+<p><a href="/wiki/Template:Complextemplate" title="Template:Complextemplate">link</a> This is a test template with parameter (test)
+</p>
+!! end
+
+!! test
+T2553: link with two variables in a piped link
+!! wikitext
+{|
+|[[{{{1}}}|{{{2}}}]]
+|}
+!! html/php
+<table>
+<tr>
+<td>[[{{{1}}}|{{{2}}}]]
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr><td>[[<span about="#mwt5" typeof="mw:Param" data-mw='{"parts":[{"templatearg":{"target":{"wt":"1"},"params":{},"i":0}}]}'>{{{1}}}</span>|<span about="#mwt2" typeof="mw:Param" data-mw='{"parts":[{"templatearg":{"target":{"wt":"2"},"params":{},"i":0}}]}'>{{{2}}}</span>]]</td></tr>
+</tbody></table>
+!! end
+
+# See: T2553
+!! test
+Abort table cell attribute parsing on wikilink
+!! wikitext
+{|
+|testing [[one|two]] |three||four
+|testing one two |three||four
+|testing="[[one|two]]" |three||four
+|}
+!! html/php
+<table>
+<tr>
+<td>testing <a href="/index.php?title=One&amp;action=edit&amp;redlink=1" class="new" title="One (page does not exist)">two</a> |three</td>
+<td>four
+</td>
+<td>three</td>
+<td>four
+</td>
+<td>testing="<a href="/index.php?title=One&amp;action=edit&amp;redlink=1" class="new" title="One (page does not exist)">two</a>" |three</td>
+<td>four
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr data-parsoid='{"autoInsertedEnd":true,"autoInsertedStart":true}'><td data-parsoid='{"autoInsertedEnd":true}'>testing <a rel="mw:WikiLink" href="./One" title="One" data-parsoid='{"stx":"piped","a":{"href":"./One"},"sa":{"href":"one"}}'>two</a> |three</td><td data-parsoid='{"stx":"row","autoInsertedEnd":true}'>four</td>
+<td data-parsoid='{"a":{"testing":null,"one":null,"two":null},"sa":{"testing":"","one":"","two":""},"autoInsertedEnd":true}'>three</td><td data-parsoid='{"stx":"row","autoInsertedEnd":true}'>four</td>
+<td>testing="<a rel="mw:WikiLink" href="./One" title="One" data-parsoid='{"stx":"piped","a":{"href":"./One"},"sa":{"href":"one"}}'>two</a>" |three</td><td data-parsoid='{"stx":"row","autoInsertedEnd":true}'>four</td></tr>
+</tbody></table>
+!! end
+
+!! test
+Don't abort table cell attribute parsing if wikilink is found in template arg
+!! wikitext
+{|
+|Test {{#tag:ref|One two "[[three]]" four}}
+|}
+!! html/parsoid
+<table>
+<tbody><tr><td>Test <ref about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"#tag:ref","function":"tag"},"params":{"1":{"wt":"One two \"[[three]]\" four"}},"i":0}}]}'>One two "<a rel="mw:WikiLink" href="./Three" title="Three">three</a>" four</ref></td></tr>
+</tbody></table>
+!! end
+
+!! test
+Magic variable as template parameter
+!! wikitext
+{{paramtest|param={{SITENAME}}}}
+!! html
+<p>This is a test template with parameter MediaWiki
+</p>
+!! end
+
+!! article
+Template:linktest
+!! text
+[[{{{param}}}|link]]
+!! endarticle
+
+!! test
+Template parameter as link source
+!! wikitext
+{{linktest|param=Main Page}}
+!! html
+<p><a href="/wiki/Main_Page" title="Main Page">link</a>
+</p>
+!! end
+
+!!article
+Template:paramtest2
+!! text
+including another template, {{paramtest|param={{{arg}}}}}
+!! endarticle
+
+!! test
+Template passing argument to another template
+!! wikitext
+{{paramtest2|arg='hmm'}}
+!! html
+<p>including another template, This is a test template with parameter 'hmm'
+</p>
+!! end
+
+!! article
+Template:Linktest2
+!! text
+Main Page
+!! endarticle
+
+!! test
+Template as link source
+!! wikitext
+[[{{linktest2}}]]
+
+[[{{linktest2}}|Main Page]]
+
+[[{{linktest2}}]]Page
+!! html
+<p><a href="/wiki/Main_Page" title="Main Page">Main Page</a>
+</p><p><a href="/wiki/Main_Page" title="Main Page">Main Page</a>
+</p><p><a href="/wiki/Main_Page" title="Main Page">Main Page</a>Page
+</p>
+!! end
+
+
+!! article
+Template:loop1
+!! text
+{{loop2}}
+!! endarticle
+
+!! article
+Template:loop2
+!! text
+{{loop1}}
+!! endarticle
+
+!! test
+Template infinite loop
+!! wikitext
+{{loop1}}
+!! html
+<p><span class="error">Template loop detected: <a href="/wiki/Template:Loop1" title="Template:Loop1">Template:Loop1</a></span>
+</p>
+!! end
+
+!! test
+Template from main namespace
+!! wikitext
+{{:Main Page}}
+!! html
+<p>blah blah
+</p>
+!! end
+
+!! article
+Template:table
+!! text
+{|
+| 1 || 2
+|-
+| 3 || 4
+|}
+!! endarticle
+
+!! test
+T2529: Template with table, not included at beginning of line
+!! wikitext
+foo {{table}}
+!! html
+<p>foo
+</p>
+<table>
+<tr>
+<td>1</td>
+<td>2
+</td></tr>
+<tr>
+<td>3</td>
+<td>4
+</td></tr></table>
+
+!! end
+
+!! test
+T2523: Template shouldn't eat newline (or add an extra one before table)
+!! wikitext
+foo
+{{table}}
+!! html
+<p>foo
+</p>
+<table>
+<tr>
+<td>1</td>
+<td>2
+</td></tr>
+<tr>
+<td>3</td>
+<td>4
+</td></tr></table>
+
+!! end
+
+!! test
+T2041: Template parameters shown as broken links
+!! wikitext
+{{{parameter}}}
+!! html
+<p>{{{parameter}}}
+</p>
+!! end
+
+!! test
+Template with targets containing wikilinks
+!! options
+parsoid=wt2html
+!! wikitext
+{{[[foo]]}}
+
+{{[[{{echo|foo}}]]}}
+
+{{{{echo|[[foo}}]]}}
+!! html/php
+<p>{{<a href="/wiki/Foo" title="Foo">foo</a>}}
+</p><p>{{<a href="/wiki/Foo" title="Foo">foo</a>}}
+</p><p>{{[[foo}}]]
+</p>
+!! html/parsoid
+<p>{{<a rel="mw:WikiLink" href="./Foo" title="Foo">foo</a>}}</p>
+<p>{{<a typeof="mw:ExpandedAttrs" rel="mw:WikiLink" href="./Foo" title="Foo" data-mw='{"attribs":[[{"txt":"href"},{"html":"&lt;span about=\"#mwt3\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[17,29,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"foo\"}},\"i\":0}}]}&#39;>foo&lt;/span>"}]]}'>foo</a>}}</p>
+<p>{{<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[foo}}]]"}},"i":0}}]}'>[[foo}}]]</span></p>
+!! end
+
+!! article
+Template:''
+!! text
+bar
+!! endarticle
+
+!! test
+Templates: Double quotes as template target
+!! wikitext
+foo {{''}} baz
+!! html/php
+<p>foo bar baz
+</p>
+!! html/parsoid
+<p>foo <span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"&#39;&#39;","href":"./Template:&#39;&#39;"},"params":{},"i":0}}]}'>bar</span> baz
+</p>
+!! end
+
+## This test is about making sure Parsoid's data-mw is well formed in the
+## face of multiple templates with intersecting and overlapping ranges. The
+## wikitext itself is wretched.
+!! test
+Templates with intersecting and overlapping ranges
+!! wikitext
+{|{{echo|
+<p>ha</p>}}
+{|{{echo|
+<p>ho</p>}}
+{{echo|{{!}}hi}}
+|}
+!! html/php+tidy
+<p>ha</p><table>
+
+</table><p>ho</p><table>
+
+<tbody><tr>
+<td>hi
+</td></tr></tbody></table>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"stx":"html","autoInsertedEnd":true,"pi":[[{"k":"1"}],[{"k":"1"}],[{"k":"1"}]],"firstWikitextNode":"table"}' data-mw='{"parts":["{|",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"\n&lt;p>ha&lt;/p>"}},"i":0}},"\n","{|",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"\n&lt;p>ho&lt;/p>"}},"i":1}},"\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"{{!}}hi"}},"i":2}},"\n|}"]}'>ha</p><table about="#mwt1" typeof="mw:ExpandedAttrs" data-mw='{"attribs":[[{"txt":"","html":""},{"html":""}]]}'>
+
+</table><p about="#mwt1">ho</p><table about="#mwt1" typeof="mw:ExpandedAttrs" data-mw='{"attribs":[[{"txt":"","html":""},{"html":""}]]}'>
+
+<tbody><tr><td>hi</td></tr>
+</tbody></table>
+!! end
+
+!! article
+Template:MSGNW test
+!! text
+''None'' of '''this''' should be
+* interpreted
+ but rather passed unmodified
+{{test}}
+<gallery>
+File:Foobar.jpg
+</gallery>
+<!-- comment -->
+!! endarticle
+
+# hmm, fix this or just deprecate msgnw and document its behavior?
+!! test
+msgnw keyword
+!! wikitext
+{{msgnw:MSGNW test}}
+!! html/php
+<p>&#39;&#39;None&#39;&#39; of &#39;&#39;&#39;this&#39;&#39;&#39; should be
+&#42; interpreted
+&#32;but rather passed unmodified
+&#123;&#123;test&#125;&#125;
+&#60;gallery&#62;
+File:Foobar.jpg
+&#60;/gallery&#62;
+&#60;!-- comment --&#62;
+</p>
+!! end
+
+!! test
+int keyword
+!! wikitext
+{{int:youhavenewmessages|lots of money|not!}}
+!! html
+<p>You have lots of money (not!).
+</p>
+!! end
+
+!! test
+int keyword - non-existing message
+!! wikitext
+{{int:var}}
+!! html
+<p>⧼var⧽
+</p>
+!! end
+
+!! article
+Template:Includes
+!! text
+Foo<noinclude>zar</noinclude><includeonly>bar</includeonly>
+!! endarticle
+
+!! test
+<includeonly> and <noinclude> being included
+!! wikitext
+{{Includes}}
+!! html
+<p>Foobar
+</p>
+!! end
+
+!! article
+Template:Includes2
+!! text
+<onlyinclude>Foo</onlyinclude>bar
+!! endarticle
+
+!! test
+<onlyinclude> being included
+!! wikitext
+{{Includes2}}
+!! html
+<p>Foo
+</p>
+!! end
+
+
+!! article
+Template:Includes3
+!! text
+<onlyinclude>Foo</onlyinclude>bar<includeonly>zar</includeonly>
+!! endarticle
+
+!! test
+<onlyinclude> and <includeonly> being included
+!! wikitext
+{{Includes3}}
+!! html
+<p>Foo
+</p>
+!! end
+
+!! test
+<includeonly> and <noinclude> on a page
+!! wikitext
+Foo<noinclude>zar</noinclude><includeonly>bar</includeonly>
+!! html
+<p>Foozar
+</p>
+!! end
+
+!! test
+Un-closed <noinclude>
+!! wikitext
+<noinclude>
+!! html
+!! end
+
+!! test
+<onlyinclude> on a page
+!! wikitext
+<onlyinclude>Foo</onlyinclude>bar
+!! html
+<p>Foobar
+</p>
+!! end
+
+!! test
+Un-closed <onlyinclude>
+!! wikitext
+<onlyinclude>
+!! html
+!! end
+
+!!test
+Self-closed noinclude, includeonly, onlyinclude tags
+!! wikitext
+<noinclude />
+<includeonly />
+<onlyinclude />
+!! html
+<p><br />
+</p>
+!!end
+
+!!test
+Unbalanced includeonly and noinclude tags
+!! wikitext
+{|
+|a</noinclude>
+|b</noinclude></noinclude>
+|c</noinclude></includeonly>
+|d</includeonly></includeonly>
+|}
+!! html
+<table>
+<tr>
+<td>a
+</td>
+<td>b
+</td>
+<td>c&lt;/includeonly&gt;
+</td>
+<td>d&lt;/includeonly&gt;&lt;/includeonly&gt;
+</td></tr></table>
+
+!!end
+
+!! article
+Template:Includeonly section
+!! text
+<includeonly>
+==Includeonly section==
+</includeonly>
+==Section T-1==
+!!endarticle
+
+!! test
+T8563: Edit link generation for section shown by <includeonly>
+!! wikitext
+{{includeonly section}}
+!! html
+<h2><span class="mw-headline" id="Includeonly_section">Includeonly section</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Template:Includeonly_section&amp;action=edit&amp;section=T-1" title="Template:Includeonly section">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Section_T-1">Section T-1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Template:Includeonly_section&amp;action=edit&amp;section=T-2" title="Template:Includeonly section">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+# Uses same input as the contents of [[Template:Includeonly section]]
+!! test
+T8563: Section extraction for section shown by <includeonly>
+!! options
+section=T-2
+!! wikitext
+<includeonly>
+==Includeonly section==
+</includeonly>
+==Section T-2==
+!! html
+==Section T-2==
+!! end
+
+!! test
+T8563: Edit link generation for section suppressed by <includeonly>
+!! wikitext
+<includeonly>
+==Includeonly section==
+</includeonly>
+==Section 1==
+!! html
+<h2><span class="mw-headline" id="Section_1">Section 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+!! test
+T8563: Section extraction for section suppressed by <includeonly>
+!! options
+section=1
+!! wikitext
+<includeonly>
+==Includeonly section==
+</includeonly>
+==Section 1==
+!! html
+==Section 1==
+!! end
+
+!! test
+Un-closed <includeonly>
+!! wikitext
+<includeonly>
+!! html/php
+!! html/parsoid
+<meta typeof="mw:Includes/IncludeOnly" data-parsoid='{"src":"&lt;includeonly>"}'/>
+!! end
+
+## We used to, but no longer wt2wt this test since the default serializer
+## will normalize the include directives to serialize on their own line.
+## Selser will take care of preserving formatting in scenarios where they
+## intermingled with other wikitext.
+!! test
+Includes and comments at SOL
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<!-- comment --><noinclude><!-- comment --></noinclude><!-- comment -->==hu==
+
+<noinclude>
+some
+</noinclude>*stuff
+*here
+
+<includeonly>can have stuff</includeonly>===here===
+
+!! html/php
+<h2><span class="mw-headline" id="hu">hu</span></h2>
+<p>some
+</p>
+<ul><li>stuff</li>
+<li>here</li></ul>
+<h3><span class="mw-headline" id="here">here</span></h3>
+
+!! html/parsoid
+<!-- comment --><meta typeof="mw:Includes/NoInclude" data-parsoid='{"src":"&lt;noinclude>"}'/><!-- comment --><meta typeof="mw:Includes/NoInclude/End" data-parsoid='{"src":"&lt;/noinclude>"}'/><!-- comment --><h2 id="hu">hu</h2>
+
+<meta typeof="mw:Includes/NoInclude" data-parsoid='{"src":"&lt;noinclude>"}'/>
+<p>some</p>
+<meta typeof="mw:Includes/NoInclude/End" data-parsoid='{"src":"&lt;/noinclude>"}'/><ul><li>stuff</li>
+<li>here</li></ul>
+
+<meta typeof="mw:Includes/IncludeOnly" data-parsoid='{"src":"&lt;includeonly>can have stuff&lt;/includeonly>"}'/><meta typeof="mw:Includes/IncludeOnly/End" data-parsoid='{"src":""}'/><h3 id="here">here</h3>
+
+!! end
+
+# TODO: test with DOM fragment reuse!
+!! test
+Parsoid: DOM fragment reuse
+!! options
+parsoid=wt2wt,wt2html
+!! wikitext
+a{{echo|b<table></table>c}}d
+
+a{{echo|b
+<table></table>
+c}}d
+
+{{echo|a
+
+<table></table>
+
+b}}
+!! html
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":["a",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"b&lt;table>&lt;/table>c"}},"i":0}},"d"]}' data-parsoid='{"pi":[[{"k":"1"}]]}'>ab</p><table about="#mwt1" data-parsoid='{"stx":"html"}'></table><p about="#mwt1">cd</p>
+
+<p about="#mwt2" typeof="mw:Transclusion" data-mw='{"parts":["a",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"b\n&lt;table>&lt;/table>\nc"}},"i":0}},"d"]}' data-parsoid='{"pi":[[{"k":"1"}]]}'>ab</p><span about="#mwt2">
+</span><table about="#mwt2" data-parsoid='{"stx":"html"}'></table><span about="#mwt2">
+</span><p about="#mwt2">cd</p>
+
+<p about="#mwt3" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"a\n\n&lt;table>&lt;/table>\n\nb"}},"i":0}}]}' data-parsoid='{"pi":[[{"k":"1"}]]}'>a</p><span about="#mwt3">
+
+</span><table about="#mwt3" data-parsoid='{"stx":"html"}'></table><span about="#mwt3">
+
+</span><p about="#mwt3">b</p>
+!! end
+
+!! test
+Parsoid: Merge double tds (T52603)
+!! options
+parsoid
+!! wikitext
+{|
+|{{echo|{{!}} foo}}
+|}
+!! html
+<table><tbody>
+<tr><td about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":["|",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"{{!}} foo"}},"i":0}}]}'> foo</td></tr>
+</tbody></table>
+!! end
+
+!! test
+Parsoid: Merge double tds in nested transclusion content (T52603)
+!! options
+parsoid
+!! wikitext
+{{echo|<div>}}
+{|
+|{{echo|{{!}} foo}}
+|}
+{{echo|</div>}}
+!! html
+<div about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"<div>"}},"i":0}},"\n{|\n|",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"{{!}} foo"}},"i":1}},"\n|}\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"</div>"}},"i":2}}]}'>
+<table><tbody>
+<tr><td data-mw='{"parts":["|"]}'> foo</td></tr>
+</tbody></table>
+</div>
+!! end
+
+###
+### <includeonly> and <noinclude> in attributes
+###
+!!test
+0. includeonly around the entire attribute
+!! wikitext
+<span <includeonly>id="v1"</includeonly><noinclude>id="v2"</noinclude>>bar</span>
+!! html
+<p><span id="v2">bar</span>
+</p>
+!!end
+
+!!test
+1. includeonly in html attr key
+!! wikitext
+<span <noinclude>id</noinclude><includeonly>about</includeonly>="foo">bar</span>
+!! html
+<p><span id="foo">bar</span>
+</p>
+!!end
+
+!!test
+2. includeonly in html attr value
+!! wikitext
+<span id="<noinclude>v1</noinclude><includeonly>v2</includeonly>">bar</span>
+<span id=<noinclude>"v1"</noinclude><includeonly>"v2"</includeonly>>bar</span>
+!! html
+<p><span id="v1">bar</span>
+<span id="v1">bar</span>
+</p>
+!!end
+
+!!test
+3. includeonly in part of an attr value
+!! wikitext
+<span style="color:<noinclude>red</noinclude><includeonly>blue</includeonly>;">bar</span>
+!! html
+<p><span style="color:red;">bar</span>
+</p>
+!!end
+
+!!test
+4. includeonly in table attributes
+!! wikitext
+{|
+|- <noinclude>
+|-
+|a
+</noinclude>
+|- <includeonly>
+|-
+|b
+</includeonly>
+|}
+!! html
+<table>
+
+
+<tr>
+<td>a
+</td></tr>
+</table>
+
+!!end
+
+###
+### Preprocessor precedence tests
+### See: https://www.mediawiki.org/wiki/Preprocessor_ABNF
+###
+##{{[[-{{{{{{[[Foo|bar}}]]}-}}}}}]]
+!! test
+Preprocessor precedence 1: link is rightmost opening
+!! options
+parsoid=wt2html
+!! wikitext
+{{[[Foo|bar}}]]
+
+But close-brace is not a valid character in a link title:
+{{[[Foo}}|bar]]
+
+However, we can still tell this was handled as a link in the preprocessor:
+{{echo|[[Foo}}|bar]]|bat}}
+!! html/php
+<p>{{<a href="/wiki/Foo" title="Foo">bar}}</a>
+</p><p>But close-brace is not a valid character in a link title:
+{{[[Foo}}|bar]]
+</p><p>However, we can still tell this was handled as a link in the preprocessor:
+[[Foo}}|bar]]
+</p>
+!! html/parsoid
+<p>{{<a rel="mw:WikiLink" href="./Foo" title="Foo">bar}}</a></p>
+<p>But close-brace is not a valid character in a link title: {{[[Foo}}|bar]]</p>
+<p>However, we can still tell this was handled as a link in the preprocessor: <span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[Foo}}|bar]]"},"2":{"wt":"bat"}},"i":0}}]}'>[[Foo}}|bar]]</span></p>
+!! end
+
+!! test
+Preprocessor precedence 2: template is rightmost opening
+!! options
+language=zh
+!! wikitext
+-{{echo|foo}-}}-
+!! html/php
+<p>-foo}--
+</p>
+!! html/parsoid
+<p>-<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo}-"}},"i":0}}]}'>foo}-</span>-</p>
+!! end
+
+!! test
+Preprocessor precedence 3: language converter is rightmost opening
+!! options
+language=zh
+parsoid=wt2html
+!! wikitext
+{{echo|hi}}
+
+{{-{R|echo|hi}}}-
+
+[[-{R|raw]]}-
+!! html/php
+<p>hi
+</p><p>{{echo|hi}}
+</p><p>[[raw]]
+</p>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"hi"}},"i":0}}]}'>hi</p>
+<p>{{<span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"echo|hi}}"}}'></span></p>
+<p>[[<span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"raw]]"}}'></span></p>
+!! end
+
+!! test
+Preprocessor precedence 4: left-most angle bracket
+!! options
+language=zh
+!! wikitext
+<!--{raw}-->
+!! html/php
+!! html/parsoid
+<!--{raw}-->
+!! end
+
+!! article
+Template:Precedence5
+!! text
+{{{{{1}}}}}
+!! endarticle
+
+!! test
+Preprocessor precedence 5: tplarg takes precedence over template
+!! wikitext
+{{Precedence5|Bullet}}
+!! html/php
+<ul><li>Bar</li></ul>
+
+!! html/parsoid
+<ul typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"Precedence5","href":"./Template:Precedence5"},"params":{"1":{"wt":"Bullet"}},"i":0}}]}'><li>Bar</li></ul>
+!! end
+
+!! test
+Preprocessor precedence 6: broken link is rightmost opening
+!! options
+parsoid=wt2html
+!! wikitext
+{{echo|[[Foo}}
+
+{{echo|[[Foo|bar|bat=baz}}
+!! html/php
+<p>{{echo|[[Foo}}
+</p><p>{{echo|[[Foo|bar|bat=baz}}
+</p>
+!! html/parsoid
+<p>{{echo|[[Foo}}</p>
+<p>{{echo|[[Foo|bar|bat=baz}}</p>
+!! end
+
+# This next test exposes a difference between PHP and Parsoid:
+# Given [[Foo|{{echo|Bar]]x}}y]]z:
+# 1) Both PHP and Parsoid ignore the `]]` inside the `echo` in the
+# "preprocessor" stage. The `{{echo` extends until the `x}}`, and the
+# outer `[[Foo` extends until the `y]]`
+# 2a) But then the PHP preprocessor emits `[[Foo|Bar]]xy]]z` as an
+# intermediate result (after template expansion), and link processing
+# happens on this intermediate result, which moves the wikilink
+# boundary leftward to `[[Foo|Bar]]`
+# 2b) Parsoid works in a single step, so it's going to keep the
+# wikilink as extending to the `y]]`
+# 3a) Then PHP does linktrail processing which slurps up the trailing
+# `xy` inside the link.
+# 3b) Parsoid will do linktrail processing to slurp up the trailing
+# `z` inside the link.
+# This is "correct" behavior. Parsoid's basic worldview is that the
+# `]]` inside the template shouldn't be allowed to leak out to affect
+# the surrounding wikilink. PHP may match Parsoid (in the future)
+# if you use {{#balance}} (T114445).
+
+!! test
+Preprocessor precedence 7: broken template is rightmost opening
+!! options
+parsoid=wt2html
+!! wikitext
+[[Foo|{{echo|Bar]]
+
+[[Foo|{{echo|Bar]]-x}}-y]]-z
+
+Careful: linktrails can move the end of the wikilink:
+[[Foo|{{echo|y']]a}}l]]l
+!! html/php
+<p><a href="/wiki/Foo" title="Foo">{{echo|Bar</a>
+</p><p><a href="/wiki/Foo" title="Foo">Bar</a>-x-y]]-z
+</p><p>Careful: linktrails can move the end of the wikilink:
+<a href="/wiki/Foo" title="Foo">y'al</a>]]l
+</p>
+!! html/parsoid
+<p>[[Foo|{{echo|Bar]]</p>
+<p><a rel="mw:WikiLink" href="./Foo" title="Foo"><span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"Bar]]-x"}},"i":0}}]}'>Bar]]-x</span>-y</a>-z</p>
+<p>Careful: linktrails can move the end of the wikilink:
+<a rel="mw:WikiLink" href="./Foo" title="Foo"><span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"y&#39;]]a"}},"i":0}}]}'>y']]a</span>ll</a></p>
+!! end
+
+!! test
+Preprocessor precedence 8: broken language converter is rightmost opening
+!! options
+language=zh
+!! wikitext
+[[Foo-{R|raw]]
+!! html
+<p>[[Foo-{R|raw]]
+</p>
+!! end
+
+!! article
+Template:Preprocessor_precedence_9
+!! text
+;4: {{{{1}}}}
+;5: {{{{{2}}}}}
+;6: {{{{{{3}}}}}}
+;7: {{{{{{{4}}}}}}}
+!! endarticle
+
+!! test
+Preprocessor precedence 9: groups of braces
+!! wikitext
+{{Preprocessor precedence 9|Four|Bullet|1|2}}
+!! html/php
+<dl><dt>4</dt>
+<dd>{Four}</dd>
+<dt>5</dt>
+<dd></dd></dl>
+<ul><li>Bar</li></ul>
+<dl><dt>6</dt>
+<dd>Four</dd>
+<dt>7</dt>
+<dd>{Bullet}</dd></dl>
+
+!! html/parsoid
+<dl about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"Preprocessor precedence 9","href":"./Template:Preprocessor_precedence_9"},"params":{"1":{"wt":"Four"},"2":{"wt":"Bullet"},"3":{"wt":"1"},"4":{"wt":"2"}},"i":0}}]}'>
+<dt>4</dt>
+<dd>{Four}</dd>
+<dt>5</dt>
+<dd></dd>
+</dl><ul about="#mwt1">
+<li>Bar</li>
+</ul><span about="#mwt1"> </span><dl about="#mwt1">
+<dt>6</dt>
+<dd>Four</dd>
+<dt>7</dt>
+<dd>{Bullet}</dd>
+</dl>
+!! end
+
+!! article
+Template:Preprocessor_precedence_10
+!! text
+;1: -{R|raw}-
+;2: -{{Bullet}}-
+;3: -{{{1}}}-
+;4: -{{{{2}}}}-
+;5: -{{{{{3}}}}}-
+;6: -{{{{{{4}}}}}}-
+;7: -{{{{{{{5}}}}}}}-
+!! endarticle
+
+!! test
+Preprocessor precedence 10: groups of braces with leading dash
+!! options
+language=zh
+!! wikitext
+{{Preprocessor precedence 10|Three|raw2|Bullet|1|2}}
+!! html/php
+<dl><dt>1</dt>
+<dd>raw</dd>
+<dt>2</dt>
+<dd>-</dd></dl>
+<ul><li>Bar-</li></ul>
+<dl><dt>3</dt>
+<dd>-Three-</dd>
+<dt>4</dt>
+<dd>raw2</dd>
+<dt>5</dt>
+<dd>-</dd></dl>
+<ul><li>Bar-</li></ul>
+<dl><dt>6</dt>
+<dd>-Three-</dd>
+<dt>7</dt>
+<dd>raw2</dd></dl>
+
+!! html/parsoid
+<dl about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"Preprocessor precedence 10","href":"./Template:Preprocessor_precedence_10"},"params":{"1":{"wt":"Three"},"2":{"wt":"raw2"},"3":{"wt":"Bullet"},"4":{"wt":"1"},"5":{"wt":"2"}},"i":0}}]}'>
+<dt>1</dt>
+<dd><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"raw"}}'></span></dd>
+<dt>2</dt>
+<dd>-</dd>
+</dl><ul about="#mwt1">
+<li>Bar-</li>
+</ul><span about="#mwt1"> </span><dl about="#mwt1">
+<dt>3</dt>
+<dd>-Three-</dd>
+<dt>4</dt>
+<dd><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"raw2"}}'></span></dd>
+<dt>5</dt>
+<dd>-</dd>
+</dl><ul about="#mwt1">
+<li>Bar-</li>
+</ul><span about="#mwt1"> </span><dl about="#mwt1">
+<dt>6</dt>
+<dd>-Three-</dd>
+<dt>7</dt>
+<dd><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"raw2"}}'></span></dd>
+</dl>
+!! end
+
+!! test
+Preprocessor precedence 11: found during visual diff testing
+!! wikitext
+{{#tag:span|-{{#tag:span|-{{echo|x}}}}}}
+
+{{echo|-{{echo|-{{echo|x}}}}}}
+
+{{echo|-{{echo|x}}}}
+!! html/php
+<p><span>-<span>-x</span></span>
+</p><p>--x
+</p><p>-x
+</p>
+!! html/parsoid
+<p><span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"#tag:span","function":"tag"},"params":{"1":{"wt":"-{{#tag:span|-{{echo|x}}}}"}},"i":0}}]}'>-<span>-x</span></span></p>
+
+<p about="#mwt4" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"-{{echo|-{{echo|x}}}}"}},"i":0}}]}'>--x</p>
+
+<p about="#mwt7" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"-{{echo|x}}"}},"i":0}}]}'>-x</p>
+!! end
+
+!! test
+Preprocessor precedence 12: broken language converter closed by brace.
+!! options
+parsoid=wt2html
+!! wikitext
+This form breaks the template, which is unfortunate:
+*{{echo|foo-{bar}bat}}
+
+But if the broken language converter markup is inside an extension
+tag, nothing bad happens:
+*<nowiki>foo-{bar}bat</nowiki>
+*{{echo|<nowiki>foo-{bar}bat</nowiki>}}
+*<pre>foo-{bar}bat</pre>
+*{{echo|<pre>foo-{bar}bat</pre>}}
+
+<tag>foo-{bar}bat</tag>
+{{echo|<tag>foo-{bar}bat</tag>}}
+
+!! html/php+tidy
+<p>This form breaks the template, which is unfortunate:
+</p>
+<ul><li>{{echo|foo-{bar}bat}}</li></ul>
+<p>But if the broken language converter markup is inside an extension
+tag, nothing bad happens:
+</p>
+<ul><li>foo-&#123;bar}bat</li>
+<li>foo-&#123;bar}bat</li>
+<li><pre>foo-{bar}bat</pre></li>
+<li><pre>foo-{bar}bat</pre></li></ul>
+<pre>'foo-{bar}bat'
+array (
+)
+</pre>
+<pre>'foo-{bar}bat'
+array (
+)
+</pre>
+!! html/parsoid
+<p>This form breaks the template, which is unfortunate:</p>
+<ul>
+<li>{{echo|foo-{bar}bat}}</li>
+</ul>
+<p>But if the broken language converter markup is inside an extension tag, nothing bad happens:</p>
+<ul>
+<li><span typeof="mw:Nowiki">foo-{bar}bat</span></li>
+<li><span typeof="mw:Transclusion mw:Nowiki" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;nowiki>foo-{bar}bat&lt;/nowiki>"}},"i":0}}]}'>foo-{bar}bat</span></li>
+<li><pre typeof="mw:Extension/pre" data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"foo-{bar}bat"}}'>foo-{bar}bat</pre></li>
+<li><pre typeof="mw:Transclusion mw:Extension/pre" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;pre>foo-{bar}bat&lt;/pre>"}},"i":0}}]}'>foo-{bar}bat</pre></li>
+</ul>
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{},"body":{"extsrc":"foo-{bar}bat"}}'></pre> <pre typeof="mw:Extension/tag mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;tag>foo-{bar}bat&lt;/tag>"}},"i":0}}]}'></pre>
+!! end
+
+!! test
+Preprocessor precedence 13: broken language converter in external link
+!! options
+parsoid=wt2html
+!! wikitext
+*[http://example.com/-{foo Example in URL]
+*[http://example.com Example in -{link} description]
+*{{echo|[http://example.com/-{foo Breaks template, however]}}
+!! html/php+tidy
+<ul><li><a rel="nofollow" class="external text" href="http://example.com/-{foo">Example in URL</a></li>
+<li><a rel="nofollow" class="external text" href="http://example.com">Example in -{link} description</a></li>
+<li>{{echo|<a rel="nofollow" class="external text" href="http://example.com/-{foo">Breaks template, however</a>}}</li></ul>
+!! html/parsoid
+<ul>
+<li><a rel="mw:ExtLink" class="external text" href="http://example.com/-{foo">Example in URL</a></li>
+<li><a rel="mw:ExtLink" class="external text" href="http://example.com">Example in -{link} description</a></li>
+<li>{{echo|<a rel="mw:ExtLink" class="external text" href="http://example.com/-{foo">Breaks template, however</a>}}</li>
+</ul>
+!! end
+
+!! test
+Preprocessor precedence 14: broken language converter in comment
+!! wikitext
+*<!--{{foo}}-->...should be ok
+*<!---{{foo}}-->...extra dashes
+*{{echo|foo<!-- -{bar} -->bat}}...should be ok
+!! html/php+tidy
+<ul><li>...should be ok</li>
+<li>...extra dashes</li>
+<li>foobat...should be ok</li></ul>
+!! html/parsoid
+<ul>
+<li><!--{{foo}}-->...should be ok</li>
+<li><!--&#x2D;{{foo}}-->...extra dashes</li>
+<li><span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo&lt;!-- -{bar} -->bat"}},"i":0}}]}'>foo</span><span about="#mwt1"><!-- &#x2D;{bar} --></span><span about="#mwt1">bat</span>...should be ok</li>
+</ul>
+!! end
+
+!! test
+Preprocessor precedence 15: broken brace markup in headings
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! options
+parsoid=wt2html
+!! wikitext
+__NOTOC__ __NOEDITSECTION__
+===1 foo[bar 1===
+1
+===2 foo[[bar 2===
+2
+===3 foo{bar 3===
+3
+===4 foo{{bar 4===
+4
+===5 foo{{{bar 5===
+5
+===6 foo-{bar 6===
+6
+!! html/php+tidy
+<h3><span id="1_foo.5Bbar_1"></span><span class="mw-headline" id="1_foo[bar_1">1 foo[bar 1</span></h3>
+<p>1
+</p>
+<h3><span id="2_foo.5B.5Bbar_2"></span><span class="mw-headline" id="2_foo[[bar_2">2 foo[[bar 2</span></h3>
+<p>2
+</p>
+<h3><span id="3_foo.7Bbar_3"></span><span class="mw-headline" id="3_foo{bar_3">3 foo{bar 3</span></h3>
+<p>3
+</p>
+<h3><span id="4_foo.7B.7Bbar_4"></span><span class="mw-headline" id="4_foo{{bar_4">4 foo{{bar 4</span></h3>
+<p>4
+</p>
+<h3><span id="5_foo.7B.7B.7Bbar_5"></span><span class="mw-headline" id="5_foo{{{bar_5">5 foo{{{bar 5</span></h3>
+<p>5
+</p>
+<h3><span id="6_foo-.7Bbar_6"></span><span class="mw-headline" id="6_foo-{bar_6">6 foo-{bar 6</span></h3>
+<p>6
+</p>
+!! html/parsoid
+<meta property="mw:PageProp/notoc"/> <meta property="mw:PageProp/noeditsection"/>
+<h3 id="1_foo[bar_1"><span id="1_foo.5Bbar_1" typeof="mw:FallbackId"></span>1 foo[bar 1</h3>
+<p>1</p>
+<h3 id="2_foo[[bar_2"><span id="2_foo.5B.5Bbar_2" typeof="mw:FallbackId"></span>2 foo[[bar 2</h3>
+<p>2</p>
+<h3 id="3_foo{bar_3"><span id="3_foo.7Bbar_3" typeof="mw:FallbackId"></span>3 foo{bar 3</h3>
+<p>3</p>
+<h3 id="4_foo{{bar_4"><span id="4_foo.7B.7Bbar_4" typeof="mw:FallbackId"></span>4 foo{{bar 4</h3>
+<p>4</p>
+<h3 id="5_foo{{{bar_5"><span id="5_foo.7B.7B.7Bbar_5" typeof="mw:FallbackId"></span>5 foo{{{bar 5</h3>
+<p>5</p>
+<h3 id="6_foo-{bar_6"><span id="6_foo-.7Bbar_6" typeof="mw:FallbackId"></span>6 foo-{bar 6</h3>
+<p>6</p>
+!! end
+
+!! test
+Preprocessor precedence 16: matching closing braces to opening braces
+!! options
+language=zh
+parsoid=wt2html
+!! wikitext
+-{{{echo|foo}}bar}-
+!! html/php
+<p>foobar
+</p>
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[2,14,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"foo\"}},\"i\":0}}]}&#39;>foo&lt;/span>bar"}}'></span></p>
+!! end
+
+!! test
+Preprocessor precedence 17: template w/o target shouldn't prevent closing
+!! options
+parsoid=wt2html
+!! wikitext
+{{echo|hi {{}}}}
+!! html/php
+<p>hi {{}}
+</p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"hi {{}}"}},"i":0}}]}'>hi {{}}</p>
+!! end
+
+!! test
+Preprocessor precedence 18: another rightmost wins scenario
+!! options
+parsoid=wt2html
+!! wikitext
+{{ -{{{{1|tplarg}}} }} }-
+!! html/php
+<p>{{ -{tplarg }} }-
+</p>
+!! html/parsoid
+<p>{{ -{<span about="#mwt1" typeof="mw:Param" data-mw='{"parts":[{"templatearg":{"target":{"wt":"1"},"params":{"1":{"wt":"tplarg"}},"i":0}}]}'>tplarg</span> }} }-</p>
+!! end
+
+!! test
+Preprocessor precedence 19: break syntax
+!! options
+parsoid=wt2html
+!! wikitext
+-{{
+!! html/php
+<p>-{{
+</p>
+!! html/parsoid
+<p>-{{</p>
+!! end
+
+###
+### Token Stream Patcher tests
+###
+### These tests won't always pass wt2wt and other modes because
+### on serialization, the table will be output on a new line.
+### For now, we are blacklisting them, and using this to test selser.
+###
+
+!!test
+1. Table tag in SOL posn. should get reparsed correctly with valid TSR
+!!options
+parsoid=wt2html,wt2wt
+!!wikitext
+{{echo|}}{| width = '100%'
+|foo
+|}
+!!html/parsoid
+<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":""}},"i":0}}]}'></span><table width="100%">
+<tbody><tr><td>foo</td></tr>
+</tbody></table>
+!!end
+
+## We used to, but no longer wt2wt this test since the default serializer
+## will normalize the include directives to serialize on their own line.
+## Selser will take care of preserving formatting in scenarios where they
+## intermingled with other wikitext.
+!!test
+2. Table tag in SOL posn. should get reparsed correctly with valid TSR
+!!options
+parsoid=wt2html
+!!wikitext
+<includeonly>a</includeonly>{| {{{b}}}
+|c
+|}
+!!html/parsoid
+<meta typeof="mw:Includes/IncludeOnly" data-parsoid='{"src":"&lt;includeonly>a&lt;/includeonly>"}'/><meta typeof="mw:Includes/IncludeOnly/End" data-parsoid='{"src":""}'/><table about="#mwt2" typeof="mw:ExpandedAttrs" data-parsoid='{"a":{"{{{b}}}":null},"sa":{"{{{b}}}":""}}' data-mw='{"attribs":[[{"txt":"{{{b}}}","html":"&lt;span about=\"#mwt1\" typeof=\"mw:Param\" data-parsoid=&#39;{\"pi\":[[]],\"dsr\":[31,38,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"templatearg\":{\"target\":{\"wt\":\"b\"},\"params\":{},\"i\":0}}]}&#39;>{{{b}}}&lt;/span>"},{"html":""}]]}'>
+<tbody><tr><td>c</td></tr>
+</tbody></table>
+!!end
+
+!! test
+Table wikitext syntax outside wiki-tables
+!! wikitext
+a
+|+ not a caption
+! not a table heading
+|- not a table row
+| not a table cell
+| class="foo bar" | baz
+b
+|}
+|-
+c
+!! html
+<p>a
+|+ not a caption
+! not a table heading
+|- not a table row
+| not a table cell
+| class="foo bar" | baz
+b
+|}
+|-
+c
+</p>
+!! end
+
+###
+### Testing parsing of templates where a template arg
+### has the same name as the template itself.
+###
+
+!! article
+Template:quote
+!! text
+{{{quote|{{{1}}}}}}
+!! endarticle
+
+!!test
+Templates: Template Name/Arg clash: 1. Use of positional param
+!! wikitext
+{{quote|foo}}
+!! html
+<p>foo
+</p>
+!!end
+
+!!test
+Templates: Template Name/Arg clash: 2. Use of named param
+!! wikitext
+{{quote|quote=foo}}
+!! html
+<p>foo
+</p>
+!!end
+
+!!test
+Templates: Template Name/Arg clash: 3. Use of named param with empty input
+!! wikitext
+{{quote|quote}}
+!! html
+<p>quote
+</p>
+!!end
+
+###
+### Parsoid-centric tests to stress Parsoid's ability to RT them unchanged
+###
+
+!!test
+Templates: 1. Simple use
+!! wikitext
+{{echo|Foo}}
+!! html
+<p>Foo
+</p>
+!!end
+
+!!test
+Templates: 2. Inside a block tag
+!! wikitext
+<div>{{echo|Foo}}</div>
+<blockquote>{{echo|Foo}}</blockquote>
+!! html
+<div>Foo</div>
+<blockquote>Foo</blockquote>
+
+!! html+tidy
+<div>Foo</div>
+<blockquote><p>Foo</p></blockquote>
+!!end
+
+!!test
+Templates: P-wrapping: 1a. Templates on consecutive lines
+!! wikitext
+{{echo|Foo}}
+{{echo|bar}}
+!! html
+<p>Foo
+bar
+</p>
+!!end
+
+!!test
+Templates: P-wrapping: 1b. Templates on consecutive lines
+!! wikitext
+Foo
+
+{{echo|bar}}
+{{echo|baz}}
+!! html
+<p>Foo
+</p><p>bar
+baz
+</p>
+!!end
+
+!!test
+Templates: P-wrapping: 1c. Templates on consecutive lines
+!! wikitext
+{{echo|Foo}}
+{{echo|bar}} <div>baz</div>
+!! html
+<p>Foo
+</p>
+bar <div>baz</div>
+
+!! html+tidy
+<p>Foo
+</p><p>
+bar </p><div>baz</div>
+!! end
+
+!!test
+Templates: P-wrapping: 1d. Template preceded by comment-only line
+!!options
+parsoid
+!! wikitext
+<!-- foo -->
+{{echo|Bar}}
+!! html
+<!-- foo -->
+
+<p about="#mwt223" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"Bar"}},"i":0}}]}'>Bar</p>
+!!end
+
+!!test
+Templates: Inline Text: 1. Multiple template uses
+!! wikitext
+{{echo|Foo}}bar{{echo|baz}}
+!! html
+<p>Foobarbaz
+</p>
+!!end
+
+!!test
+Templates: Inline Text: 2. Back-to-back template uses
+!! wikitext
+{{echo|Foo}}{{echo|bar}}
+!! html
+<p>Foobar
+</p>
+!!end
+
+!!test
+Templates: Block Tags: 1. Multiple template uses
+!! wikitext
+{{echo|<div>Foo</div>}}<div>bar</div>{{echo|<div>baz</div>}}
+!! html
+<div>Foo</div><div>bar</div><div>baz</div>
+
+!!end
+
+!!test
+Templates: Block Tags: 2. Back-to-back template uses
+!! wikitext
+{{echo|<div>Foo</div>}}{{echo|<div>bar</div>}}
+!! html
+<div>Foo</div><div>bar</div>
+
+!!end
+
+# This is an edge case relating to paragraph wrapping.
+!!test
+Templates: Correctly encapsulate templates producing </p> tag without a corresponding <p> tag
+!! wikitext
+{{echo|a
+b</p>}}
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"a\nb&lt;/p>"}},"i":0}}]}'>a
+b</p>
+!!end
+
+!!test
+Templates: Links: 1. Simple example
+!! wikitext
+{{echo|[[Foo|bar]]}}
+!! html
+<p><a href="/wiki/Foo" title="Foo">bar</a>
+</p>
+!!end
+
+!!test
+Templates: Links: 2. Generation of link href
+!! wikitext
+[[{{echo|Foo}}|bar]]
+!! html
+<p><a href="/wiki/Foo" title="Foo">bar</a>
+</p>
+!!end
+
+!!test
+Templates: Links: 3. Generation of part of a link href
+!! wikitext
+[[Fo{{echo|o}}|bar]]
+
+[[Foo{{echo|bar}}]]
+
+[[Foo{{echo|bar}}baz]]
+
+[[Foo{{echo|bar}}|bar]]
+
+[[:Foo{{echo|bar}}]]
+
+[[:Foo{{echo|bar}}|bar]]
+!! html
+<p><a href="/wiki/Foo" title="Foo">bar</a>
+</p><p><a href="/index.php?title=Foobar&amp;action=edit&amp;redlink=1" class="new" title="Foobar (page does not exist)">Foobar</a>
+</p><p><a href="/index.php?title=Foobarbaz&amp;action=edit&amp;redlink=1" class="new" title="Foobarbaz (page does not exist)">Foobarbaz</a>
+</p><p><a href="/index.php?title=Foobar&amp;action=edit&amp;redlink=1" class="new" title="Foobar (page does not exist)">bar</a>
+</p><p><a href="/index.php?title=Foobar&amp;action=edit&amp;redlink=1" class="new" title="Foobar (page does not exist)">Foobar</a>
+</p><p><a href="/index.php?title=Foobar&amp;action=edit&amp;redlink=1" class="new" title="Foobar (page does not exist)">bar</a>
+</p>
+!!end
+
+!!test
+Templates: Links: 4. Multiple templates generating link href
+!! wikitext
+[[{{echo|F}}{{echo|o}}ob{{echo|ar}}]]
+!! html
+<p><a href="/index.php?title=Foobar&amp;action=edit&amp;redlink=1" class="new" title="Foobar (page does not exist)">Foobar</a>
+</p>
+!!end
+
+!!test
+Templates: Links: 5. Generation of link text
+!! wikitext
+[[Foo|{{echo|bar}}]]
+!! html
+<p><a href="/wiki/Foo" title="Foo">bar</a>
+</p>
+!!end
+
+!!test
+Templates: Links: 5. Nested templates (only outermost template should be marked)
+!! wikitext
+{{echo|[[{{echo|Foo}}|bar]]}}
+!! html
+<p><a href="/wiki/Foo" title="Foo">bar</a>
+</p>
+!!end
+
+!!test
+Templates: HTML Tag: 1. Generation of HTML attr. key
+!! wikitext
+<div {{echo|style}}="color:red;">foo</div>
+!! html
+<div style="color:red;">foo</div>
+
+!!end
+
+!!test
+Templates: HTML Tag: 2. Generation of HTML attr. value
+!! wikitext
+<div style={{echo|'color:red;'}}>foo</div>
+!! html
+<div style="color:red;">foo</div>
+
+!!end
+
+!!test
+Templates: HTML Tag: 3. Generation of HTML attr key and value
+!! wikitext
+<div {{echo|style}}={{echo|'color:red;'}}>foo</div>
+!! html
+<div style="color:red;">foo</div>
+
+!!end
+
+!!test
+Templates: HTML Tag: 4. Generation of starting piece of HTML attr value
+!! wikitext
+<div title="{{echo|This is a long title}} with just one piece templated">foo</div>
+!! html
+<div title="This is a long title with just one piece templated">foo</div>
+
+!!end
+
+!!test
+Templates: HTML Tag: 5. Generation of middle piece of HTML attr value
+!! wikitext
+<div title="This is a long title with just {{echo|one piece}} templated">foo</div>
+!! html
+<div title="This is a long title with just one piece templated">foo</div>
+
+!!end
+
+!!test
+Templates: HTML Tag: 6. Generation of end piece of HTML attr value
+!! wikitext
+<div title="This is a long title with just one piece {{echo|templated}}">foo</div>
+!! html
+<div title="This is a long title with just one piece templated">foo</div>
+
+!!end
+
+# SSS FIXME: While it is great we added support for all this,
+# do we want to make this part of the spec? Maybe we want to
+# deprecate this kind of usage in the future?
+!!test
+Templates: HTML Tag: 7. Generation of partial attribute key string
+!! wikitext
+<div st{{echo|yle}}="color:red;">foo</div>
+!! html
+<div style="color:red;">foo</div>
+
+!!end
+
+!! test
+Templates: HTML Tag: 8. Template-generated attribute (k=v)
+!! wikitext
+<div {{echo|1=id="v1"}}>bar</div>
+!! html
+<div id="v1">bar</div>
+
+!!end
+
+!! test
+Templates: HTML Tag: 9. Multiple template-generated attributes
+!! wikitext
+<div {{echo|1=id="v1" title="foo"}}>bar</div>
+!! html
+<div id="v1" title="foo">bar</div>
+
+!!end
+
+!! test
+Templates: Support for templates generating attributes and content
+!! wikitext
+{| {{mixed_attr_content_template}}
+|-
+|bar
+|}
+!! html/php
+<table style="color:red;" title="T48811">
+
+<tr>
+<td>foo
+</td></tr>
+<tr>
+<td>bar
+</td></tr></table>
+
+!! html/parsoid
+<table style="color:red;" title="T48811" about="#mwt1" typeof="mw:Transclusion mw:ExpandedAttrs" data-mw='{"parts":["{| ",{"template":{"target":{"wt":"mixed_attr_content_template","href":"./Template:Mixed_attr_content_template"},"params":{},"i":0}},"\n|-\n|bar\n|}"]}'>
+<tbody><tr>
+<td>foo</td></tr>
+<tr>
+<td>bar</td></tr>
+</tbody></table>
+!!end
+
+!! test
+1. Entities and nowikis inside templated attributes should be handled correctly
+!! wikitext
+<div {{echo|style{{=}}"background:&#35;f9f9f9;"}}>foo</div>
+!! html/php
+<div style="background:#f9f9f9;">foo</div>
+
+!! html/parsoid
+<div style="background:#f9f9f9;" about="#mwt3" typeof="mw:ExpandedAttrs" data-parsoid='{"stx":"html"}' data-mw='{"attribs":[[{"txt":"style","html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[5,49,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"style{{=}}\\\"background:&amp;amp;#35;f9f9f9;\\\"\"}},\"i\":0}}]}&#39;>style&lt;/span>&lt;span typeof=\"mw:Nowiki\" about=\"#mwt1\" data-parsoid=\"{}\">=&lt;/span>&lt;span about=\"#mwt1\" data-parsoid=\"{}\">\"background:&lt;/span>&lt;span typeof=\"mw:Entity\" about=\"#mwt1\" data-parsoid=&#39;{\"src\":\"&amp;amp;#35;\",\"srcContent\":\"#\"}&#39;>#&lt;/span>&lt;span about=\"#mwt1\" data-parsoid=\"{}\">f9f9f9;\"&lt;/span>"},{"html":""}]]}'>foo</div>
+!! end
+
+!! test
+2. Entities and nowikis inside templated attributes should be handled correctly
+!! wikitext
+{|
+|{{table_attribs_3}}
+|}
+!! html/php
+<table>
+<tr>
+<td style="background:#f9f9f9;">Foo
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr data-parsoid='{"autoInsertedEnd":true,"autoInsertedStart":true}'><td style="background:#f9f9f9;" typeof="mw:Transclusion" about="#mwt1" data-parsoid='{"autoInsertedEnd":true,"pi":[[]]}' data-mw='{"parts":["|",{"template":{"target":{"wt":"table_attribs_3","href":"./Template:Table_attribs_3"},"params":{},"i":0}}]}'>Foo</td></tr>
+</tbody></table>
+!! end
+
+!! test
+3. Entities and nowikis inside templated attributes should be handled correctly inside templated tables
+!! wikitext
+{{tbl-start}}
+|{{table_attribs_3}}
+{{tbl-end}}
+!! html/php
+<table>
+<tr>
+<td style="background:#f9f9f9;">Foo
+</td></tr></table>
+
+!! html/parsoid
+<table about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[],[],[]]}' data-mw='{"parts":[{"template":{"target":{"wt":"tbl-start","href":"./Template:Tbl-start"},"params":{},"i":0}},"\n|",{"template":{"target":{"wt":"table_attribs_3","href":"./Template:Table_attribs_3"},"params":{},"i":1}},"\n",{"template":{"target":{"wt":"tbl-end","href":"./Template:Tbl-end"},"params":{},"i":2}}]}'>
+<tbody><tr><td style="background:#f9f9f9;">Foo</td></tr>
+</tbody></table>
+!! end
+
+# T107622
+!! test
+4. Entities and nowikis inside templated attributes should be handled correctly inside templated tables
+!! wikitext
+{|
+|{{table_attribs_6}} hi
+|}
+!! html/php
+<table>
+<tr>
+<td style="background: red;">hi
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody><tr><td style="background: red;" typeof="mw:Transclusion" about="#mwt1" data-parsoid='{"autoInsertedEnd":true,"pi":[[]]}' data-mw='{"parts":["|",{"template":{"target":{"wt":"table_attribs_6","href":"./Template:Table_attribs_6"},"params":{},"i":0}}," hi"]}'> hi</td></tr>
+</tbody></table>
+!! end
+
+!!test
+Templates: HTML Tables: 1. Generating start of a HTML table
+!! wikitext
+{{echo|<table><tr><td>foo</td>}}</tr></table>
+!! html
+<table><tr><td>foo</td></tr></table>
+
+!!end
+
+!!test
+Templates: HTML Tables: 2a. Generating middle of a HTML table
+!! wikitext
+<table><tr>{{echo|<td>foo</td>}}</tr></table>
+!! html
+<table><tr><td>foo</td></tr></table>
+
+!!end
+
+!!test
+Templates: HTML Tables: 2b. Generating middle of a HTML table
+!! wikitext
+<table>{{echo|<tr><td>foo</td></tr>}}</table>
+!! html
+<table><tr><td>foo</td></tr></table>
+
+!!end
+
+!!test
+Templates: HTML Tables: 3. Generating end of a HTML table
+!! wikitext
+<table><tr>{{echo|<td>foo</td></tr></table>}}
+!! html
+<table><tr><td>foo</td></tr></table>
+
+!!end
+
+!!test
+Templates: HTML Tables: 4a. Generating a single tag of a HTML table
+!! wikitext
+{{echo|<table>}}<tr><td>foo</td></tr></table>
+!! html
+<table><tr><td>foo</td></tr></table>
+
+!!end
+
+!!test
+Templates: HTML Tables: 4b. Generating a single tag of a HTML table
+!! wikitext
+<table>{{echo|<tr>}}<td>foo</td></tr></table>
+!! html
+<table><tr><td>foo</td></tr></table>
+
+!!end
+
+!!test
+Templates: HTML Tables: 4c. Generating a single tag of a HTML table
+!! wikitext
+<table><tr>{{echo|<td>}}foo</td></tr></table>
+!! html
+<table><tr><td>foo</td></tr></table>
+
+!!end
+
+!!test
+Templates: HTML Tables: 4d. Generating a single tag of a HTML table
+!! wikitext
+<table><tr><td>foo{{echo|</td>}}</tr></table>
+!! html
+<table><tr><td>foo</td></tr></table>
+
+!!end
+
+!!test
+Templates: HTML Tables: 4e. Generating a single tag of a HTML table
+!! wikitext
+<table><tr><td>foo</td>{{echo|</tr>}}</table>
+!! html
+<table><tr><td>foo</td></tr></table>
+
+!!end
+
+!!test
+Templates: HTML Tables: 4f. Generating a single tag of a HTML table
+!! wikitext
+<table><tr><td>foo</td></tr>{{echo|</table>}}
+!! html
+<table><tr><td>foo</td></tr></table>
+
+!!end
+
+!!test
+Templates: HTML Tables: 5. Proper fostering of categories from inside
+!!options
+parsoid=wt2html,wt2wt
+!! wikitext
+<table>[[Category:foo1]]<tr><td>foo</td></tr></table>
+<!--Two categories (T52330)-->
+<table>[[Category:bar1]][[Category:bar2]]<tr><td>foo</td></tr></table>
+!! html
+<link rel="mw:PageProp/Category" href="./Category:Foo1"><table><tbody><tr><td>foo</td></tr></tbody></table>
+<!--Two categories (T52330)-->
+<link rel="mw:PageProp/Category" href="./Category:Bar1"><link rel="mw:PageProp/Category" href="./Category:Bar2"><table><tbody><tr><td>foo</td></tr></tbody></table>
+!!end
+
+!!test
+Templates: Wiki Tables: 1a. Fostering of entire template content
+!! wikitext
+{|
+{{echo|a}}
+|}
+!! html
+<table>
+a
+<tr><td></td></tr></table>
+
+!! html/php+tidy
+
+a
+<table><tbody><tr><td></td></tr></tbody></table>
+!! html/parsoid
+<p about="#mwt2" typeof="mw:Transclusion" data-parsoid='{"fostered":true,"autoInsertedEnd":true,"firstWikitextNode":"TABLE","pi":[[{"k":"1"}]]}' data-mw='{"parts":["{|\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"a"}},"i":0}},"\n|}"]}'>a</p><table about="#mwt2">
+
+</table>
+!! end
+
+!!test
+Templates: Wiki Tables: 1b. Fostering of entire template content
+!! wikitext
+{|
+{{echo|<div>}}
+foo
+{{echo|</div>}}
+|}
+!! html
+<table>
+<div>
+<p>foo
+</p>
+</div>
+<tr><td></td></tr></table>
+
+!! html/php+tidy
+<div>
+<p>foo
+</p>
+</div><table>
+
+<tbody><tr><td></td></tr></tbody></table>
+!! html/parsoid
+<div about="#mwt3" typeof="mw:Transclusion" data-parsoid='{"stx":"html","fostered":true,"autoInsertedEnd":true,"firstWikitextNode":"TABLE","pi":[[{"k":"1"}],[{"k":"1"}]]}' data-mw='{"parts":["{|\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;div>"}},"i":0}},"\nfoo\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;/div>"}},"i":1}},"\n|}"]}'>
+<p>foo</p>
+</div><table about="#mwt3">
+
+</table>
+!! end
+
+!!test
+Templates: Wiki Tables: 2. Fostering of partial template content
+!! wikitext
+{|
+{{echo|a
+<div>b</div>}}
+|}
+!! html
+<table>
+a
+<div>b</div>
+<tr><td></td></tr></table>
+
+!! html/php+tidy
+
+a
+<div>b</div><table>
+<tbody><tr><td></td></tr></tbody></table>
+!! html/parsoid
+<p about="#mwt2" typeof="mw:Transclusion" data-parsoid='{"fostered":true,"autoInsertedEnd":true,"firstWikitextNode":"TABLE","pi":[[{"k":"1"}]]}' data-mw='{"parts":["{|\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"a\n&lt;div>b&lt;/div>"}},"i":0}},"\n|}"]}'>a</p><div about="#mwt2">b</div><table about="#mwt2">
+
+
+</table>
+!! end
+
+!!test
+Templates: Wiki Tables: 3. td-content via multiple templates
+!! wikitext
+{|
+{{echo|{{pipe}}a}}{{echo|b}}
+|}
+!! html
+<table>
+<tr>
+<td>ab
+</td></tr></table>
+
+!!end
+
+!!test
+Templates: Wiki Tables: 4. Templated tags, no content
+!! wikitext
+{{tbl-start}}
+{{tbl-end}}
+!! html
+<table>
+<tr><td></td></tr></table>
+
+!!end
+
+!!test
+Templates: Wiki Tables: 5. Templated tags, regular td-tags
+!! wikitext
+{{tbl-start}}
+|foo
+{{tbl-end}}
+!! html
+<table>
+<tr>
+<td>foo
+</td></tr></table>
+
+!!end
+
+!!test
+Templates: Wiki Tables: 6. Templated tags, templated td-tags
+!! wikitext
+{{tbl-start}}
+{{!}}foo
+{{tbl-end}}
+!! html
+<table>
+<tr>
+<td>foo
+</td></tr></table>
+
+!!end
+
+## This test case is very specific to Parsoid's internals
+## and is hence only tested for Parsoid's code. Parsoid uses
+## a <meta> marker tag for <ref> tags and they are expanded
+## much later. We are verifying that this <meta> tag usage
+## doesn't prevent foster parenting.
+!!test
+Templates: Wiki Tables: 7. Fosterable <ref>s should get fostered
+!!wikitext
+{{PartialTable}}<ref>foo</ref>
+|}
+
+<references />
+!!html/parsoid
+<sup about="#mwt2" class="mw-ref" id="cite_ref-1" rel="dc:references" typeof="mw:Transclusion mw:Extension/ref" data-mw='{"parts":[{"template":{"target":{"wt":"PartialTable","href":"./Template:PartialTable"},"params":{},"i":0}},"&lt;ref>foo&lt;/ref>\n|}"]}'><a href="./Main_Page#cite_note-1"><span class="mw-reflink-text">[1]</span></a></sup><table about="#mwt2">
+<tbody>
+</tbody></table>
+
+<ol class="mw-references references" typeof="mw:Extension/references" about="#mwt4" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-1" id="cite_note-1"><a href="./Main_Page#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text">foo</span></li></ol>
+!!end
+
+!! test
+Templates: Wiki Tables: 8. Fosterable meta-tags should get fostered
+!! wikitext
+{{echo|
+{{{!}}
+{{!}}-}}
+<onlyinclude>
+|foo
+</onlyinclude>
+{{!}}}
+!! html/parsoid
+<span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"\n{{{!}}\n{{!}}-"}},"i":0}},"\n&lt;onlyinclude>\n|foo\n&lt;/onlyinclude>\n{{!}}}"]}'>
+</span><meta typeof="mw:Includes/OnlyInclude" about="#mwt1"/><table about="#mwt1">
+<tbody><tr>
+
+<td>foo
+<meta typeof="mw:Includes/OnlyInclude/End"/></td></tr>
+</tbody></table>
+!! end
+
+!!test
+Templates: Lists: Multi-line list-items via templates
+!! wikitext
+*{{echo|a {{nonexistent|
+unused}}}}
+*{{echo|b {{nonexistent|
+unused}}}}
+!! html
+<ul><li>a <a href="/index.php?title=Template:Nonexistent&amp;action=edit&amp;redlink=1" class="new" title="Template:Nonexistent (page does not exist)">Template:Nonexistent</a></li>
+<li>b <a href="/index.php?title=Template:Nonexistent&amp;action=edit&amp;redlink=1" class="new" title="Template:Nonexistent (page does not exist)">Template:Nonexistent</a></li></ul>
+
+!!end
+
+!!test
+Templates: Ugly nesting: 1. Quotes opened/closed across templates (echo)
+!! wikitext
+{{echo|''a}}{{echo|b''c''d}}{{echo|''e}}
+!! html
+<p><i>ab</i>c<i>d</i>e
+</p>
+!!end
+
+!!test
+Templates: Ugly nesting: 2. Quotes opened/closed across templates (echo_with_span)
+(PHP parser generates misnested html)
+!! wikitext
+{{echo_with_span|''a}}{{echo_with_span|b''c''d}}{{echo_with_span|''e}}
+!! html/parsoid
+<p><span about="#mwt1" typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo_with_span&quot;,&quot;href&quot;:&quot;./Template:Echo_with_span&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;''a&quot;}},&quot;i&quot;:0}}]}"><i>a</i></span><i about="#mwt2" typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo_with_span&quot;,&quot;href&quot;:&quot;./Template:Echo_with_span&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;b''c''d&quot;}},&quot;i&quot;:0}},{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo_with_span&quot;,&quot;href&quot;:&quot;./Template:Echo_with_span&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;''e&quot;}},&quot;i&quot;:1}}]}"><span>b</span></i><span about="#mwt2">c</span><i about="#mwt2">d<span></span></i><span about="#mwt2">e</span></p>
+!!end
+
+!!test
+Templates: Ugly nesting: 3. Quotes opened/closed across templates (echo_with_div)
+(PHP parser generates misnested html; Parsoid html2wt mode adds newlines between {{echo}}s)
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+{{echo_with_div|''a}}{{echo_with_div|b''c''d}}{{echo_with_div|''e}}
+!! html
+<div about="#mwt1" typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo_with_div&quot;,&quot;href&quot;:&quot;./Template:Echo_with_div&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;''a&quot;}},&quot;i&quot;:0}}]}"><i>a</i></div>
+<div about="#mwt2" typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo_with_div&quot;,&quot;href&quot;:&quot;./Template:Echo_with_div&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;b''c''d&quot;}},&quot;i&quot;:0}}]}"><i>b</i>c<i>d</i></div>
+<div about="#mwt3" typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo_with_div&quot;,&quot;href&quot;:&quot;./Template:Echo_with_div&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;''e&quot;}},&quot;i&quot;:0}}]}">e</div>
+!!end
+
+!!test
+Templates: Ugly nesting: 4. Divs opened/closed across templates
+!! wikitext
+a<div>b{{echo|c</div>d}}e
+!! html
+a<div>bc</div>de
+
+!! html+tidy
+<p>a</p><div>bc</div><p>de
+</p>
+!! end
+
+!! test
+Templates: Ugly templates: 3. newline-only template parameter
+!! wikitext
+foo {{echo|
+}}
+!! html
+<p>foo
+</p>
+!! end
+
+# This looks like a bug: a single newline triggers p/br for some reason.
+!! test
+Templates: Ugly templates: 4. newline-only template parameter inconsistency
+!! wikitext
+{{echo|
+}}
+!! html
+<p><br />
+</p>
+!! end
+
+# T66017 -- ugly wikitext with fostered content generates two template ranges that
+# have a true overlap (T1-start - T2-start - T1-end - T2-end).
+!! test
+Templates: Ugly templates: 5. Template encapsulation test: Non-trivial overlap of template ranges is properly handled
+!! wikitext
+{{echo|<table>}}
+{{echo|<div>foo}}
+{{echo|</table>}}
+!! html/parsoid
+<div about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;table>"}},"i":0}},"\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;div>foo"}},"i":1}},"\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;/table>"}},"i":2}}]}' data-parsoid='{"stx":"html","autoInsertedEnd":true,"pi":[[{"k":"1"}],[{"k":"1"}],[{"k":"1"}]]}'>foo
+</div><table about="#mwt1" data-parsoid='{"stx":"html"}'>
+</table>
+!! end
+
+# T66017 -- ugly wikitext with fostered content generates two template ranges
+# that are "identical" and generate nesting cycles in the algorithm
+!! test
+Templates: Ugly templates: 6. Template encapsulation test: Cyclical nesting of template ranges is properly handled
+!! wikitext
+{{echo|<table><tr><td><table>}}
+{{echo|<div>}}
+{{echo|</div>}}
+!! html/parsoid
+<table about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;table>&lt;tr>&lt;td>&lt;table>"}},"i":0}},"\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;div>"}},"i":1}},"\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;/div>"}},"i":2}}]}' data-parsoid='{"stx":"html","autoInsertedEnd":true,"pi":[[{"k":"1"}],[{"k":"1"}],[{"k":"1"}]]}'><tbody><tr data-parsoid='{"stx":"html"}'><td data-parsoid='{"stx":"html"}'><div data-parsoid='{"stx":"html"}'>
+</div><table about="#mwt1" data-parsoid='{"stx":"html"}'>
+</table></td></tr></tbody></table>
+!! end
+
+!! test
+Templates: Parameters substituted at the top-level
+!! wikitext
+{{{foo|''who'' {{echo|me}}? '''never!'''}}}
+
+{{{foo|bar|baz}}}
+!! html/php
+<p><i>who</i> me? <b>never!</b>
+</p><p>bar
+</p>
+!! html/parsoid
+<p about="#mwt2" typeof="mw:Param" data-mw='{"parts":[{"templatearg":{"target":{"wt":"foo"},"params":{"1":{"wt":"&#39;&#39;who&#39;&#39; {{echo|me}}? &#39;&#39;&#39;never!&#39;&#39;&#39;"}},"i":0}}]}'><i>who</i> me? <b>never!</b></p>
+
+<p about="#mwt3" typeof="mw:Param" data-mw='{"parts":[{"templatearg":{"target":{"wt":"foo"},"params":{"1":{"wt":"bar"},"2":{"wt":"baz"}},"i":0}}]}'>bar</p>
+!! end
+
+!! test
+Templates: Param with empty arg in the final position
+!! wikitext
+{{{hi|}}}
+!! html/parsoid
+<span about="#mwt1" typeof="mw:Param" data-mw='{"parts":[{"templatearg":{"target":{"wt":"hi"},"params":{"1":{"wt":""}},"i":0}}]}'></span>
+!! end
+
+!!test
+Parser Functions: 1. Simple example
+!! wikitext
+{{uc:foo}}
+!! html
+<p>FOO
+</p>
+!!end
+
+!!test
+Parser Functions: 2. Nested use (only outermost should be marked up)
+!! wikitext
+{{uc:{{lc:FOO}}}}
+!! html
+<p>FOO
+</p>
+!!end
+
+###
+### Pre-save transform tests
+###
+!! test
+pre-save transform: subst:
+!! options
+pst
+!! wikitext
+{{subst:test}}
+!! html/php
+This is a test template
+!! end
+
+!! test
+pre-save transform: normal template
+!! options
+pst
+!! wikitext
+{{test}}
+!! html/php
+{{test}}
+!! end
+
+!! test
+pre-save transform: nonexistent template
+!! options
+pst
+!! wikitext
+{{thistemplatedoesnotexist}}
+!! html/php
+{{thistemplatedoesnotexist}}
+!! end
+
+!! test
+pre-save transform: subst magic variables
+!! options
+pst
+!! wikitext
+{{subst:SITENAME}}
+!! html/php
+MediaWiki
+!! end
+
+# This is T2089, which I fixed. -- wtm
+!! test
+pre-save transform: subst: templates with parameters
+!! options
+pst
+!! wikitext
+{{subst:paramtest|param="something else"}}
+!! html/php
+This is a test template with parameter "something else"
+!! end
+
+!! article
+Template:nowikitest
+!! text
+<nowiki>'''not wiki'''</nowiki>
+!! endarticle
+
+!! test
+pre-save transform: nowiki in subst (T3188)
+!! options
+pst
+!! wikitext
+{{subst:nowikitest}}
+!! html/php
+<nowiki>'''not wiki'''</nowiki>
+!! end
+
+!! article
+Template:commenttest
+!! text
+This template has <!-- a comment --> in it.
+!! endarticle
+
+!! test
+pre-save transform: comment in subst (T3936)
+!! options
+pst
+!! wikitext
+{{subst:commenttest}}
+!! html/php
+This template has <!-- a comment --> in it.
+!! end
+
+!! test
+pre-save transform: unclosed tag
+!! options
+pst
+!! wikitext
+<nowiki>'''not wiki'''
+!! html/php
+<nowiki>'''not wiki'''
+!! end
+
+!! test
+pre-save transform: mixed tag case
+!! options
+pst
+!! wikitext
+<NOwiki>'''not wiki'''</noWIKI>
+!! html/php
+<NOwiki>'''not wiki'''</noWIKI>
+!! end
+
+!! test
+pre-save transform: unclosed comment in <nowiki>
+!! options
+pst
+!! wikitext
+wiki<nowiki>nowiki<!--nowiki</nowiki>wiki
+!! html/php
+wiki<nowiki>nowiki<!--nowiki</nowiki>wiki
+!!end
+
+# Leading @ in this template definition works around a limitation
+# in parsoid's parserTests which otherwise strips the <span> from the
+# result (confusing it for a template wrapper)
+!! article
+Template:dangerous
+!!text
+@<span onmouseover="alert('crap')">Oh no</span>
+!!endarticle
+
+!!test
+(confirming safety of fix for subst T3936)
+!! wikitext
+{{Template:dangerous}}
+!! html
+<p>@<span>Oh no</span>
+</p>
+!! end
+
+!! test
+pre-save transform: comment containing gallery (T7024)
+!! options
+pst
+!! wikitext
+<!-- <gallery>data</gallery> -->
+!! html/php
+<!-- <gallery>data</gallery> -->
+!!end
+
+!! test
+pre-save transform: comment containing extension
+!! options
+pst
+!! wikitext
+<!-- <tag>data</tag> -->
+!! html/php
+<!-- <tag>data</tag> -->
+!!end
+
+!! test
+pre-save transform: comment containing nowiki
+!! options
+pst
+!! wikitext
+<!-- <nowiki>data</nowiki> -->
+!! html/php
+<!-- <nowiki>data</nowiki> -->
+!!end
+
+!! test
+pre-save transform: <noinclude> in subst (T5298)
+!! options
+pst
+!! wikitext
+{{subst:Includes}}
+!! html/php
+Foobar
+!! end
+
+!! test
+pre-save transform: <onlyinclude> in subst (T5298)
+!! options
+pst
+!! wikitext
+{{subst:Includes2}}
+!! html/php
+Foo
+!! end
+
+!! article
+Template:SubstTest
+!!text
+{{<includeonly>subst:</includeonly>Includes}}
+!! endarticle
+
+!! article
+Template:SafeSubstTest
+!! text
+{{<includeonly>safesubst:</includeonly>Includes}}
+!! endarticle
+
+!! test
+T24297: safesubst: works during PST
+!! options
+pst
+!! wikitext
+{{subst:SafeSubstTest}}{{safesubst:SubstTest}}
+!! html/php
+FoobarFoobar
+!! end
+
+!! test
+T24297: safesubst: works during normal parse
+!! wikitext
+{{SafeSubstTest}}
+!! html
+<p>Foobar
+</p>
+!! end
+
+!! test
+subst: does not work during normal parse
+!! wikitext
+{{SubstTest}}
+!! html
+<p>{{subst:Includes}}
+</p>
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick")
+!! options
+pst
+!! wikitext
+[[Article (context)|]]
+[[Bar:Article|]]
+[[:Bar:Article|]]
+[[Bar:Article (context)|]]
+[[:Bar:Article (context)|]]
+[[|Article]]
+[[|Article (context)]]
+[[Bar:X (Y) Z|]]
+[[:Bar:X (Y) Z|]]
+!! html/php
+[[Article (context)|Article]]
+[[Bar:Article|Article]]
+[[:Bar:Article|Article]]
+[[Bar:Article (context)|Article]]
+[[:Bar:Article (context)|Article]]
+[[Article]]
+[[Article (context)]]
+[[Bar:X (Y) Z|X (Y) Z]]
+[[:Bar:X (Y) Z|X (Y) Z]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with interwiki prefix
+!! options
+pst
+!! wikitext
+[[interwiki:Article|]]
+[[:interwiki:Article|]]
+[[interwiki:Bar:Article|]]
+[[:interwiki:Bar:Article|]]
+!! html/php
+[[interwiki:Article|Article]]
+[[:interwiki:Article|Article]]
+[[interwiki:Bar:Article|Bar:Article]]
+[[:interwiki:Bar:Article|Bar:Article]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with parens in title
+!! options
+pst title=[[Somearticle (context)]]
+!! wikitext
+[[|Article]]
+!! html/php
+[[Article (context)|Article]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with comma in title
+!! options
+pst title=[[Someplace, Somewhere]]
+!! wikitext
+[[|Otherplace]]
+[[Otherplace, Elsewhere|]]
+[[Otherplace, Elsewhere, Anywhere|]]
+!! html/php
+[[Otherplace, Somewhere|Otherplace]]
+[[Otherplace, Elsewhere|Otherplace]]
+[[Otherplace, Elsewhere, Anywhere|Otherplace]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with parens and comma
+!! options
+pst title=[[Someplace (IGNORED), Somewhere]]
+!! wikitext
+[[|Otherplace]]
+[[Otherplace (place), Elsewhere|]]
+!! html/php
+[[Otherplace, Somewhere|Otherplace]]
+[[Otherplace (place), Elsewhere|Otherplace]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with comma and parens
+!! options
+pst title=[[Who, me? (context)]]
+!! wikitext
+[[|Yes, you.]]
+[[Me, Myself, and I (1937 song)|]]
+!! html/php
+[[Yes, you. (context)|Yes, you.]]
+[[Me, Myself, and I (1937 song)|Me, Myself, and I]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with namespace
+!! options
+pst title=[[Ns:Somearticle]]
+!! wikitext
+[[|Article]]
+!! html/php
+[[Ns:Article|Article]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with namespace and parens
+!! options
+pst title=[[Ns:Somearticle (context)]]
+!! wikitext
+[[|Article]]
+!! html/php
+[[Ns:Article (context)|Article]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with namespace and comma
+!! options
+pst title=[[Ns:Somearticle, Context, Whatever]]
+!! wikitext
+[[|Article]]
+!! html/php
+[[Ns:Article, Context, Whatever|Article]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with namespace, comma and parens
+!! options
+pst title=[[Ns:Somearticle, Context (context)]]
+!! wikitext
+[[|Article]]
+!! html/php
+[[Ns:Article (context)|Article]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with namespace, parens and comma
+!! options
+pst title=[[Ns:Somearticle (IGNORED), Context]]
+!! wikitext
+[[|Article]]
+!! html/php
+[[Ns:Article, Context|Article]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with full-width parens and no space (Japanese and Chinese style, T32149)
+!! options
+pst
+!! wikitext
+[[Article(context)|]]
+[[Bar:Article(context)|]]
+[[:Bar:Article(context)|]]
+[[|Article(context)]]
+[[Bar:X(Y)Z|]]
+[[:Bar:X(Y)Z|]]
+!! html/php
+[[Article(context)|Article]]
+[[Bar:Article(context)|Article]]
+[[:Bar:Article(context)|Article]]
+[[Article(context)]]
+[[Bar:X(Y)Z|X(Y)Z]]
+[[:Bar:X(Y)Z|X(Y)Z]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with full-width parens and space (Japanese and Chinese style, T32149)
+!! options
+pst
+!! wikitext
+[[Article (context)|]]
+[[Bar:Article (context)|]]
+[[:Bar:Article (context)|]]
+[[|Article (context)]]
+[[Bar:X (Y) Z|]]
+[[:Bar:X (Y) Z|]]
+!! html/php
+[[Article (context)|Article]]
+[[Bar:Article (context)|Article]]
+[[:Bar:Article (context)|Article]]
+[[Article (context)]]
+[[Bar:X (Y) Z|X (Y) Z]]
+[[:Bar:X (Y) Z|X (Y) Z]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with parens and no space (Korean style, T32149)
+!! options
+pst
+!! wikitext
+[[Article(context)|]]
+[[Bar:Article(context)|]]
+[[:Bar:Article(context)|]]
+[[|Article(context)]]
+[[Bar:X(Y)Z|]]
+[[:Bar:X(Y)Z|]]
+!! html/php
+[[Article(context)|Article]]
+[[Bar:Article(context)|Article]]
+[[:Bar:Article(context)|Article]]
+[[Article(context)]]
+[[Bar:X(Y)Z|X(Y)Z]]
+[[:Bar:X(Y)Z|X(Y)Z]]
+!! end
+
+!! test
+pre-save transform: context links ("pipe trick") with commas (T23660)
+!! options
+pst
+!! wikitext
+[[Article (context), context|]]
+[[Article (context),context|]]
+[[Bar:Article (context), context|]]
+[[Bar:Article (context),context|]]
+[[:Bar:Article (context), context|]]
+[[:Bar:Article (context),context|]]
+!! html/php
+[[Article (context), context|Article]]
+[[Article (context),context|Article]]
+[[Bar:Article (context), context|Article]]
+[[Bar:Article (context),context|Article]]
+[[:Bar:Article (context), context|Article]]
+[[:Bar:Article (context),context|Article]]
+!! end
+
+!! test
+Parsoid: backwards pipe trick
+!! wikitext
+[[|'''bar''']]
+!! html/php
+<p>[[|<b>bar</b>]]
+</p>
+!! html/parsoid
+<p>[[|<b>bar</b>]]</p>
+!! end
+
+!! test
+pre-save transform: trim trailing empty lines
+!! options
+pst
+!! wikitext
+Empty lines are trimmed
+
+
+
+
+!! html/php
+Empty lines are trimmed
+!! end
+
+!! test
+pre-save transform: Signature expansion
+!! options
+pst
+!! wikitext
+* ~~~
+* ~~~~
+* ~~~~~
+* <noinclude>~~~</noinclude>
+* <includeonly>~~~</includeonly>
+* <onlyinclude>~~~</onlyinclude>
+!! html/php
+* [[Special:Contributions/127.0.0.1|127.0.0.1]]
+* [[Special:Contributions/127.0.0.1|127.0.0.1]] 00:02, 1 January 1970 (UTC)
+* 00:02, 1 January 1970 (UTC)
+* <noinclude>[[Special:Contributions/127.0.0.1|127.0.0.1]]</noinclude>
+* <includeonly>[[Special:Contributions/127.0.0.1|127.0.0.1]]</includeonly>
+* <onlyinclude>[[Special:Contributions/127.0.0.1|127.0.0.1]]</onlyinclude>
+!! end
+
+
+!! test
+ParserOutput flags from signature expansion (T84843)
+!! options
+pst
+showflags
+!! wikitext
+~~~~
+!! html/php
+[[Special:Contributions/127.0.0.1|127.0.0.1]] 00:02, 1 January 1970 (UTC)
+flags=user-signature
+!! end
+
+
+!! test
+pre-save transform: Signature expansion in nowiki tags (T2093)
+!! options
+pst disabled
+!! wikitext
+Shall not expand:
+
+<nowiki>~~~~</nowiki>
+
+<includeonly><nowiki>~~~~</nowiki></includeonly>
+
+<noinclude><nowiki>~~~~</nowiki></noinclude>
+
+<onlyinclude><nowiki>~~~~</nowiki></onlyinclude>
+
+{{subst:Foo}} shall be converted to FOO
+
+As well as inside noinclude/onlyinclude
+<noinclude>{{subst:Foo}}</noinclude>
+<onlyinclude>{{subst:Foo}}</onlyinclude>
+
+But not inside includeonly
+<includeonly>{{subst:Foo}}</includeonly>
+!! html/php
+Shall not expand:
+
+<nowiki>~~~~</nowiki>
+
+<includeonly><nowiki>~~~~</nowiki></includeonly>
+
+<noinclude><nowiki>~~~~</nowiki></noinclude>
+
+<onlyinclude><nowiki>~~~~</nowiki></onlyinclude>
+
+FOO shall be converted to FOO
+
+As well as inside noinclude/onlyinclude
+<noinclude>FOO</noinclude>
+<onlyinclude>FOO</onlyinclude>
+
+But not inside includeonly
+<includeonly>{{subst:Foo}}</includeonly>
+!! end
+
+!! test
+Parsoid: Recognize nowiki with trailing space in tags
+!! options
+parsoid=wt2html
+!! wikitext
+<nowiki ><div>[[foo]]</nowiki >
+
+a<nowiki / >b
+
+c<nowiki />d
+
+e<nowiki/ >f
+!! html
+<p><span typeof="mw:Nowiki">&lt;div&gt;[[foo]]</span></p>
+<p>ab</p>
+<p>cd</p>
+<p>ef</p>
+!! end
+
+!! test
+Parsoid: Recognize nowiki with odd capitalization
+!! options
+parsoid=wt2html
+!! wikitext
+<noWikI ><div>[[foo]]</Nowiki >
+!! html
+<p><span typeof="mw:Nowiki">&lt;div&gt;[[foo]]</span></p>
+!! end
+
+
+!! test
+Parsoid: Escape nowiki with trailing space in tags
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>&lt;nowiki &gt; foo &lt/nowiki ></p>
+<p>a&lt;nowiki /&gt;b</p>
+<p>c&lt;nowiki/ &gt;d</p>
+!! wikitext
+&lt;nowiki &gt; foo &lt;/nowiki &gt;
+
+a&lt;nowiki /&gt;b
+
+c&lt;nowiki/ &gt;d
+!! end
+
+!! test
+Parsoid: Escape weird noWikI capitalizations
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>&lt;noWikI &gt; foo &lt/NoWikI ></p>
+!! wikitext
+&lt;noWikI &gt; foo &lt;/NoWikI &gt;
+!! end
+
+###
+### Message transform tests
+###
+!! test
+message transform: magic variables
+!! options
+msg
+!! wikitext
+{{SITENAME}}
+!! html
+MediaWiki
+!! end
+
+!! test
+message transform: should not transform wiki markup
+!! options
+msg
+!! wikitext
+''test''
+!! html
+''test''
+!! end
+
+!! test
+message transform: <noinclude> in transcluded template (T6926)
+!! options
+msg
+!! wikitext
+{{Includes}}
+!! html
+Foobar
+!! end
+
+!! test
+message transform: <onlyinclude> in transcluded template (T6926)
+!! options
+msg
+!! wikitext
+{{Includes2}}
+!! html
+Foo
+!! end
+
+!! test
+{{#special:}} page name, known
+!! options
+msg
+!! wikitext
+{{#special:Recentchanges}}
+!! html
+Special:RecentChanges
+!! end
+
+!! test
+{{#special:}} page name with subpage, known
+!! options
+msg
+!! wikitext
+{{#special:Recentchanges/param}}
+!! html
+Special:RecentChanges/param
+!! end
+
+!! test
+{{#special:}} page name, unknown
+!! options
+msg
+!! wikitext
+{{#special:foobar nonexistent}}
+!! html
+Special:Foobar nonexistent
+!! end
+
+!! test
+{{#speciale:}} page name, known
+!! options
+msg
+!! wikitext
+{{#speciale:Recentchanges}}
+!! html
+Special:RecentChanges
+!! end
+
+!! test
+{{#speciale:}} page name with subpage, known
+!! options
+msg
+!! wikitext
+{{#speciale:Recentchanges/param}}
+!! html
+Special:RecentChanges/param
+!! end
+
+!! test
+{{#speciale:}} page name, unknown
+!! options
+msg
+!! wikitext
+{{#speciale:foobar nonexistent}}
+!! html
+Special:Foobar_nonexistent
+!! end
+
+###
+### Images
+###
+### For Parsoid-specific tests, see
+#### https://www.mediawiki.org/wiki/Parsoid/MediaWiki_DOM_spec#Images
+
+!! test
+Simple image
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[Image:foobar.jpg]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+!! end
+
+!! test
+Serialize simple image with span wrapper
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><span class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+!! wikitext
+[[File:Foobar.jpg]]
+!! end
+
+!! test
+Simple image (using File: namespace, now canonical)
+!! wikitext
+[[File:Foobar.jpg]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+!! end
+
+!! test
+Right-aligned image
+!! wikitext
+[[File:Foobar.jpg|right]]
+!! html/php
+<div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure>
+!! end
+
+!! test
+Image with caption
+!! wikitext
+[[File:Foobar.jpg|right|Caption text]]
+!! html/php
+<div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image" title="Caption text"><img alt="Caption text" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>Caption text</figcaption></figure>
+!! end
+
+!! test
+Image with caption, T55312 #1
+!! wikitext
+[[File:Foobar.jpg|right|Caption page stuff]]
+!! html/php
+<div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image" title="Caption page stuff"><img alt="Caption page stuff" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>Caption page stuff</figcaption></figure>
+!! end
+
+!! test
+Image with caption, T55312 #2
+!! wikitext
+[[File:Foobar.jpg|right|Caption page=]]
+!! html/php
+<div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image" title="Caption page="><img alt="Caption page=" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>Caption page=</figcaption></figure>
+!! end
+
+!! test
+Image with caption, T55312 #3
+!! wikitext
+[[File:Foobar.jpg|right|Caption page=stuff]]
+!! html/php
+<div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image" title="Caption page=stuff"><img alt="Caption page=stuff" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>Caption page=stuff</figcaption></figure>
+!! end
+
+!! test
+Image caption with pipe entity
+!! wikitext
+[[File:Foobar.jpg|thumb|one &#x7C; two]]
+[[File:Foobar.jpg|thumb|one ''two'' &#x7C; three]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>one &#x7c; two</div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>one <i>two</i> &#x7c; three</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>one <span typeof="mw:Entity">|</span> two</figcaption></figure>
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>one <i>two</i> <span typeof="mw:Entity">|</span> three</figcaption></figure>
+!! end
+
+!! test
+Allow empty links in image captions (T62753)
+!! options
+thumbsize=220
+!! wikitext
+[[File:Foobar.jpg|thumb|Caption [[Link1]]
+[[]]
+[[Link2]]
+]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:222px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>Caption <a href="/index.php?title=Link1&amp;action=edit&amp;redlink=1" class="new" title="Link1 (page does not exist)">Link1</a> [[]] <a href="/index.php?title=Link2&amp;action=edit&amp;redlink=1" class="new" title="Link2 (page does not exist)">Link2</a></div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"caption","ak":"Caption [[Link1]]\n[[]]\n[[Link2]]\n"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"25","width":"220"},"sa":{"resource":"File:Foobar.jpg"}}'/></a><figcaption>Caption <a rel="mw:WikiLink" href="./Link1" title="Link1" data-parsoid='{"stx":"simple","a":{"href":"./Link1"},"sa":{"href":"Link1"}}'>Link1</a>
+[[]]
+<a rel="mw:WikiLink" href="./Link2" title="Link2" data-parsoid='{"stx":"simple","a":{"href":"./Link2"},"sa":{"href":"Link2"}}'>Link2</a>
+</figcaption></figure>
+!! end
+
+!! test
+Titles in unlinked images (T23454)
+!! wikitext
+[[File:Foobar.jpg|link=|stuff]]
+!! html/php
+<p><img alt="stuff" src="http://example.com/images/3/3a/Foobar.jpg" title="stuff" width="1941" height="220" />
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"stuff"}'><span><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></span></figure-inline></p>
+!! end
+
+!! test
+Link with empty target
+!! wikitext
+[[]]
+!! html
+<p>[[]]
+</p>
+!! end
+
+!! test
+Image with link trail
+!! wikitext
+Linktrails should not work for images: [[File:Foobar.jpg]]s
+!! html/php
+<p>Linktrails should not work for images: <a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>s
+</p>
+!! html/parsoid
+<p>Linktrails should not work for images: <figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline>s</p>
+!! end
+
+!! test
+Image with empty attribute
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|right||Caption text]]
+!! html/php
+<div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image" title="Caption text"><img alt="Caption text" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>Caption text</figcaption></figure>
+!! end
+
+!! test
+1. Block image with individual attributes from templates
+!! wikitext
+[[File:Foobar.jpg|thumb|{{echo|137px}}|This is a caption]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:139px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/137px-Foobar.jpg" width="137" height="16" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/206px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/274px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>This is a caption</div></div></div>
+
+!! html/parsoid
+<figure typeof="mw:Image/Thumb mw:ExpandedAttrs" about="#mwt2" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"width","ak":"{{echo|137px}}"},{"ck":"caption","ak":"This is a caption"}]}' data-mw='{"attribs":[["thumbnail",{"html":"thumb"}],["width",{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[24,38,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"137px\"}},\"i\":0}}]}&#39;>137px&lt;/span>"}]]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/137px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="16" width="137" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"16","width":"137"},"sa":{"resource":"File:Foobar.jpg"}}'/></a><figcaption>This is a caption</figcaption></figure>
+!! end
+
+!! test
+2. Block Image with individual attributes from templates
+!! wikitext
+[[File:Foobar.jpg|{{echo|thumb}}|{{echo|137px}}|This is a caption]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:139px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/137px-Foobar.jpg" width="137" height="16" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/206px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/274px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>This is a caption</div></div></div>
+
+!! html/parsoid
+<figure typeof="mw:Image/Thumb mw:ExpandedAttrs" about="#mwt3" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"{{echo|thumb}}"},{"ck":"width","ak":"{{echo|137px}}"},{"ck":"caption","ak":"This is a caption"}]}' data-mw='{"attribs":[["thumbnail",{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[18,32,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"thumb\"}},\"i\":0}}]}&#39;>thumb&lt;/span>"}],["width",{"html":"&lt;span about=\"#mwt2\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[33,47,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"137px\"}},\"i\":0}}]}&#39;>137px&lt;/span>"}]]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/137px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="16" width="137" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"16","width":"137"},"sa":{"resource":"File:Foobar.jpg"}}'/></a><figcaption>This is a caption</figcaption></figure>
+!! end
+
+!! test
+3. Inline image with individual attributes from templates
+!! wikitext
+[[File:Foobar.jpg|{{echo|50px}}]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" width="50" height="6" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/75px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/100px-Foobar.jpg 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline typeof="mw:Image mw:ExpandedAttrs" about="#mwt2" data-parsoid='{"optList":[{"ck":"width","ak":"{{echo|50px}}"}]}' data-mw='{"attribs":[["width",{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[18,31,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"50px\"}},\"i\":0}}]}&#39;>50px&lt;/span>"}]]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"6","width":"50"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+## Parsoid does not provide editing support for images where templates produce multiple image attributes.
+## To signal this, we add a 'mw:Placeholder' type to such images. This could change in the future.
+!! test
+Image with multiple attributes from the same template
+!! wikitext
+[[File:Foobar.jpg|{{image_attribs}}]]
+!! html/php
+<div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image" title="Caption text"><img alt="Caption text" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-right" typeof="mw:Image mw:Placeholder"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>Caption text</figcaption></figure>
+!! end
+
+!! test
+Image with link tails
+!! options
+thumbsize=220
+!! wikitext
+123[[File:Foobar.jpg]]456
+123[[File:Foobar.jpg|right]]456
+123[[File:Foobar.jpg|thumb]]456
+!! html/php
+<p>123<a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>456
+</p>
+123<div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div>456
+123<div class="thumb tright"><div class="thumbinner" style="width:222px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div></div></div></div>456
+
+!! html/php+tidy
+<p>123<a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>456
+</p><p>
+123</p><div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div><p>456
+123</p><div class="thumb tright"><div class="thumbinner" style="width:222px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div></div></div></div><p>456
+</p>
+!! html/parsoid
+<p>123<figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline>456</p>
+<p>123</p><figure class="mw-default-size mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure><p>456</p>
+<p>123</p><figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></figure><p>456</p>
+!! end
+
+!! test
+Image with multiple captions -- only last one is accepted
+!! wikitext
+[[File:Foobar.jpg|right|Caption1 - ignored|[[Caption2]] - ignored|Caption3 - accepted]]
+!! html/php
+<div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image" title="Caption3 - accepted"><img alt="Caption3 - accepted" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>Caption3 - accepted</figcaption></figure>
+!! end
+
+!! test
+Image with multiple widths -- use last
+!! wikitext
+[[File:Foobar.jpg|200px|300px|caption]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="caption"><img alt="caption" src="http://example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg" width="300" height="34" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/450px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/600px-Foobar.jpg 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline typeof="mw:Image" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="34" width="300"/></a></figure-inline></p>
+!! end
+
+!! test
+Image with multiple alignments -- use first (T50664)
+!! options
+thumbsize=220
+!! wikitext
+[[File:Foobar.jpg|thumb|left|right|center|caption]]
+
+[[File:Foobar.jpg|middle|text-top|caption]]
+!! html/php
+<div class="thumb tleft"><div class="thumbinner" style="width:222px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>caption</div></div></div>
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="caption"><img alt="caption" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" style="vertical-align: middle" /></a>
+</p>
+!! html/parsoid
+<figure class="mw-default-size mw-halign-left" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>caption</figcaption></figure>
+<p><figure-inline class="mw-default-size mw-valign-middle" typeof="mw:Image" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+!! end
+
+!! test
+Image with width attribute at different positions
+!! wikitext
+[[File:Foobar.jpg|200px|right|Caption]]
+[[File:Foobar.jpg|right|200px|Caption]]
+[[File:Foobar.jpg|right|Caption|200px]]
+!! html/php
+<div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image" title="Caption"><img alt="Caption" src="http://example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" width="200" height="23" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/400px-Foobar.jpg 2x" /></a></div>
+<div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image" title="Caption"><img alt="Caption" src="http://example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" width="200" height="23" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/400px-Foobar.jpg 2x" /></a></div>
+<div class="floatright"><a href="/wiki/File:Foobar.jpg" class="image" title="Caption"><img alt="Caption" src="http://example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" width="200" height="23" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/400px-Foobar.jpg 2x" /></a></div>
+
+!! html/parsoid
+<figure class="mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="23" width="200"/></a><figcaption>Caption</figcaption></figure>
+<figure class="mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="23" width="200"/></a><figcaption>Caption</figcaption></figure>
+<figure class="mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="23" width="200"/></a><figcaption>Caption</figcaption></figure>
+!! end
+
+# a sad bit of backward-compatibility
+!! test
+Image with size specified with pxpx (T15500, T53628)
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|20pxpx]]
+[[File:Foobar.jpg|200x20pxpx]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/20px-Foobar.jpg" width="20" height="2" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/30px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/40px-Foobar.jpg 2x" /></a>
+<a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/177px-Foobar.jpg" width="177" height="20" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/265px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/353px-Foobar.jpg 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/20px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="2" width="20"/></a></figure-inline> <figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/177px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="20" width="177"/></a></figure-inline></p>
+!! end
+
+!! test
+Image with link parameter, wiki target
+!! wikitext
+[[File:Foobar.jpg|link=Main Page]]
+!! html/php
+<p><a href="/wiki/Main_Page" title="Main Page"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><a href="./Main_Page"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+!! end
+
+# parsoid T51293 (part 1)
+!! test
+Image with link parameter, URL target
+!! wikitext
+[[File:Foobar.jpg|link=http://example.com/]]
+!! html/php
+<p><a href="http://example.com/" rel="nofollow"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><a href="http://example.com/"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+!! end
+
+# parsoid T51293 (part 2)
+!! test
+Image with link parameter, protocol-less URL target
+!! wikitext
+[[File:Foobar.jpg|link=//example.com/]]
+!! html/php
+<p><a href="//example.com/" rel="nofollow"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><a href="//example.com/"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+!! end
+
+!! test
+Escaping non-block captions (T107435)
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ ["[typeof~='mw:Image']", "attr", "data-mw", "{\"caption\": \"|\"}"]
+ ]
+}
+!! wikitext
+[[Image:Foobar.jpg|caption]]
+!! wikitext/edited
+[[Image:Foobar.jpg|<nowiki>|</nowiki>]]
+!! end
+
+# wgExternalLinkTarget not supported by Parsoid
+!! test
+Image with link parameter, wgExternalLinkTarget
+!! wikitext
+[[Image:foobar.jpg|link=http://example.com/]]
+!! config
+wgExternalLinkTarget='foobar'
+!! html/php
+<p><a href="http://example.com/" target="foobar" rel="nofollow noreferrer noopener"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! end
+
+!! test
+Image with link parameter, wgNoFollowLinks set to false
+!! wikitext
+[[Image:foobar.jpg|link=http://example.com/]]
+!! config
+wgNoFollowLinks=false
+!! html/php
+<p><a href="http://example.com/"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! end
+
+!! test
+Image with link parameter, wgNoFollowDomainExceptions
+!! wikitext
+[[Image:foobar.jpg|link=http://example.com/]]
+!! config
+wgNoFollowDomainExceptions='example.com'
+!! html/php
+<p><a href="http://example.com/"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! end
+
+# wgExternalLinkTarget not supported by Parsoid
+!! test
+Image with link parameter, wgExternalLinkTarget, unnamed parameter
+!! wikitext
+[[Image:foobar.jpg|link=http://example.com/|Title]]
+!! config
+wgExternalLinkTarget='foobar'
+!! html/php
+<p><a href="http://example.com/" title="Title" target="foobar" rel="nofollow noreferrer noopener"><img alt="Title" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! end
+
+!! test
+Image with empty link parameter
+!! wikitext
+[[File:Foobar.jpg|link=]]
+!! html/php
+<p><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" />
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><span><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></span></figure-inline></p>
+!! end
+
+!! test
+Image with link parameter (wiki target) and unnamed parameter
+!! wikitext
+[[File:Foobar.jpg|link=Main_Page|Title]]
+!! html/php
+<p><a href="/wiki/Main_Page" title="Title"><img alt="Title" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"Title"}'><a href="./Main_Page"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+!! end
+
+!! test
+Image with link parameter (URL target) and unnamed parameter
+!! wikitext
+[[File:Foobar.jpg|link=http://example.com/|Title]]
+!! html/php
+<p><a href="http://example.com/" title="Title" rel="nofollow"><img alt="Title" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"Title"}'><a href="http://example.com/"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+!! end
+
+!! test
+Thumbnail image with link parameter
+!! options
+thumbsize=220
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|thumb|link=http://example.com/|Title]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:222px;"><a href="http://example.com/"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>Title</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="http://example.com/"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>Title</figcaption></figure>
+!! end
+
+!! test
+Manually-specified thumbnail image
+!! options
+thumbsize=220
+!! wikitext
+[[File:Foobar.jpg|thumbnail=Thumb.png|Title]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:137px;"><a href="/wiki/File:Foobar.jpg"><img alt="" src="http://example.com/images/e/ea/Thumb.png" width="135" height="135" class="thumbimage" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>Title</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-mw='{"thumb":"Thumb.png"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/e/ea/Thumb.png" data-file-width="135" data-file-height="135" data-file-type="bitmap" height="135" width="135"/></a><figcaption>Title</figcaption></figure>
+!! end
+
+!! test
+Manually-specified thumbnail image with explicit link to wiki page
+!! options
+thumbsize=220
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|thumb=Thumb.png|link=Main_Page|Title]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:137px;"><a href="/wiki/Main_Page" title="Main Page"><img alt="" src="http://example.com/images/e/ea/Thumb.png" width="135" height="135" class="thumbimage" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>Title</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-mw='{"thumb":"Thumb.png"}'><a href="./Main_Page"><img resource="./File:Foobar.jpg" src="//example.com/images/e/ea/Thumb.png" data-file-width="135" data-file-height="135" data-file-type="bitmap" height="135" width="135"/></a><figcaption>Title</figcaption></figure>
+!! end
+
+!! test
+Manually-specified thumbnail image with explicit link to url
+!! options
+thumbsize=220
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|thumb=Thumb.png|link=http://example.com|Title]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:137px;"><a href="http://example.com"><img alt="" src="http://example.com/images/e/ea/Thumb.png" width="135" height="135" class="thumbimage" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>Title</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-mw='{"thumb":"Thumb.png"}'><a href="http://example.com"><img resource="./File:Foobar.jpg" src="//example.com/images/e/ea/Thumb.png" data-file-width="135" data-file-height="135" data-file-type="bitmap" height="135" width="135"/></a><figcaption>Title</figcaption></figure>
+!! end
+
+!! test
+Manually-specified thumbnail image with explicit no link
+!! options
+thumbsize=220
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|thumb=Thumb.png|link=|Title]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:137px;"><img alt="" src="http://example.com/images/e/ea/Thumb.png" width="135" height="135" class="thumbimage" /> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>Title</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-mw='{"thumb":"Thumb.png"}'><span><img resource="./File:Foobar.jpg" src="//example.com/images/e/ea/Thumb.png" data-file-width="135" data-file-height="135" data-file-type="bitmap" height="135" width="135"/></span><figcaption>Title</figcaption></figure>
+!! end
+
+!! test
+Manually-specified thumbnail image with explicit link and alt text
+!! options
+thumbsize=220
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|thumb=Thumb.png|link=Main_Page|alt=alttext|Title]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:137px;"><a href="/wiki/Main_Page" title="Main Page"><img alt="alttext" src="http://example.com/images/e/ea/Thumb.png" width="135" height="135" class="thumbimage" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>Title</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-mw='{"thumb":"Thumb.png"}'><a href="./Main_Page"><img alt="alttext" resource="./File:Foobar.jpg" src="//example.com/images/e/ea/Thumb.png" data-file-width="135" data-file-height="135" data-file-type="bitmap" height="135" width="135"/></a><figcaption>Title</figcaption></figure>
+!! end
+
+!! test
+Image with frame and link
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|frame|left|This is a test image [[Main Page]]]]
+!! html/php
+<div class="thumb tleft"><div class="thumbinner" style="width:1943px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" class="thumbimage" /></a> <div class="thumbcaption">This is a test image <a href="/wiki/Main_Page" title="Main Page">Main Page</a></div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-left" typeof="mw:Image/Frame"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>This is a test image <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></figcaption></figure>
+!! end
+
+!! test
+Image with frame and link and explicit alt
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[Image:Foobar.jpg|frame|left|This is a test image [[Main Page]]|alt=Altitude]]
+!! html/php
+<div class="thumb tleft"><div class="thumbinner" style="width:1943px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Altitude" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" class="thumbimage" /></a> <div class="thumbcaption">This is a test image <a href="/wiki/Main_Page" title="Main Page">Main Page</a></div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-left" typeof="mw:Image/Frame"><a href="./File:Foobar.jpg"><img alt="Altitude" resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>This is a test image <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></figcaption></figure>
+!! end
+
+!! test
+Image with wiki markup in implicit alt
+!! wikitext
+[[Image:Foobar.jpg|testing '''bold''' in alt]]
+
+[[Image:Foobar.jpg|alt=testing '''bold''' in alt]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="testing bold in alt"><img alt="testing bold in alt" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p><p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="testing bold in alt" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt"}]}' data-mw='{"caption":"testing &lt;b data-parsoid=&#39;{\"dsr\":[27,37,3,3]}&#39;>bold&lt;/b> in alt"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"Image:Foobar.jpg"}}'/></a></figure-inline></p>
+
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"alt","ak":"alt=testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img alt="testing bold in alt" resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"alt":"testing bold in alt","resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"alt":"alt=testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt","resource":"Image:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Alt image option should handle most kinds of wikitext without barfing
+!! wikitext
+[[Image:Foobar.jpg|thumb|This is the image caption|alt=This is a [[link]] and a {{echo|''bold template''}}.]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="This is a link and a bold template." src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>This is the image caption</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb mw:ExpandedAttrs" about="#mwt2" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"caption","ak":"This is the image caption"},{"ck":"alt","ak":"alt=This is a [[link]] and a {{echo|&apos;&apos;bold template&apos;&apos;}}."}]}' data-mw='{"attribs":[["thumbnail",{"html":"thumb"}],["alt",{"html":"alt=This is a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=&apos;{\"stx\":\"simple\",\"a\":{\"href\":\"./Link\"},\"sa\":{\"href\":\"link\"},\"dsr\":[65,73,2,2]}&apos;>link&lt;/a> and a &lt;i about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&apos;{\"dsr\":[80,106,null,null],\"pi\":[[{\"k\":\"1\"}]]}&apos; data-mw=&apos;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"&amp;apos;&amp;apos;bold template&amp;apos;&amp;apos;\"}},\"i\":0}}]}&#39;>bold template&lt;/i>."}]]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img alt="This is a link and a bold template." resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220" data-parsoid='{"a":{"alt":"This is a link and a bold template.","resource":"./File:Foobar.jpg","height":"25","width":"220"},"sa":{"alt":"alt=This is a [[link]] and a {{echo|&#39;&#39;bold template&#39;&#39;}}.","resource":"Image:Foobar.jpg"}}'/></a><figcaption>This is the image caption</figcaption></figure>
+!! end
+
+!! test
+Image with table with attributes in caption
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+[[File:Foobar.jpg|thumb|
+{| class="123" |
+|- class="456" |
+| ha
+|}
+]]
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"caption","ak":"\n{| class=\"123\" |\n|- class=\"456\" |\n| ha\n|}\n"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{"href":"File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"25","width":"220"},"sa":{"resource":"File:Foobar.jpg"}}'/></a><figcaption>
+<table class="123">
+<tbody><tr class="456" data-parsoid='{"startTagSrc":"|-"}'>
+<td> ha</td></tr>
+</tbody></table>
+</figcaption></figure>
+!! end
+
+!! test
+Image with table with rows from templates in caption
+!! wikitext
+[[File:Foobar.jpg|thumb|
+{|
+{{echo|{{!}} hi}}
+|}
+]]
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"caption","ak":"\n{|\n{{echo|{{!}} hi}}\n|}\n"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{"href":"File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"25","width":"220"},"sa":{"resource":"File:Foobar.jpg"}}'/></a><figcaption>
+<table>
+<tbody about="#mwt4" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"{{!}} hi"}},"i":0}},"\n"]}'><tr><td> hi</td></tr>
+</tbody></table>
+</figcaption></figure>
+!! end
+
+!! test
+Image with nested tables in caption
+!! wikitext
+[[File:Foobar.jpg|thumb|Foo<br />
+{|
+|
+{|
+|z
+|}
+|}
+]]
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"caption","ak":"Foo&lt;br/>\n{|\n|\n{|\n|z\n|}\n|}\n"}]}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"25","width":"220"},"sa":{"resource":"File:Foobar.jpg"}}'/></a><figcaption data-parsoid='{"dsr":[null,50,null,null]}'>Foo<br data-parsoid='{"stx":"html","selfClose":true}'/>
+<table>
+<tbody><tr><td>
+<table>
+<tbody><tr><td>z</td></tr>
+</tbody></table></td></tr>
+</tbody></table>
+</figcaption></figure>
+!! end
+
+###################
+# Conflicting image format options.
+# First option specified should 'win'.
+# All three cases in each test should be identical.
+
+!! test
+Image with 'frameless' first.
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|frameless|caption]]
+
+[[File:Foobar.jpg|frameless|frame|caption]]
+
+[[File:Foobar.jpg|frameless|thumb|caption]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="caption"><img alt="caption" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a>
+</p><p><a href="/wiki/File:Foobar.jpg" class="image" title="caption"><img alt="caption" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a>
+</p><p><a href="/wiki/File:Foobar.jpg" class="image" title="caption"><img alt="caption" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></figure-inline></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></figure-inline></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></figure-inline></p>
+!! end
+
+!! test
+Image with 'frame' first.
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|frame|caption]]
+[[File:Foobar.jpg|frame|frameless|caption]]
+[[File:Foobar.jpg|frame|thumb|caption]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:1943px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" class="thumbimage" /></a> <div class="thumbcaption">caption</div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:1943px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" class="thumbimage" /></a> <div class="thumbcaption">caption</div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:1943px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" class="thumbimage" /></a> <div class="thumbcaption">caption</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Frame"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>caption</figcaption></figure>
+<figure class="mw-default-size" typeof="mw:Image/Frame"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>caption</figcaption></figure>
+<figure class="mw-default-size" typeof="mw:Image/Frame"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>caption</figcaption></figure>
+!! end
+
+!! test
+Image with 'thumb' first.
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|thumb|caption]]
+[[File:Foobar.jpg|thumb|frameless|caption]]
+[[File:Foobar.jpg|thumb|frame|caption]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>caption</div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>caption</div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>caption</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>caption</figcaption></figure>
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>caption</figcaption></figure>
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>caption</figcaption></figure>
+!! end
+
+###################
+# Image sizing.
+# See https://www.mediawiki.org/wiki/Help:Images#Size_and_frame
+# and https://phabricator.wikimedia.org/T64258
+# Foobar has actual size of 1941x220
+# 1. Thumbs & frameless always reduce, can't be enlarged unless it's
+# a scalable format.
+# 2. Framed images always ignore size options; always render at default size.
+# 3. "Unspecified format" and border are the only types which can be
+# enlarged.
+
+!! test
+Image: unspecified format and border enlarge
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|2000px]]
+
+[[File:Foobar.jpg|border|2000px]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="2000" height="227" /></a>
+</p><p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="2000" height="227" class="thumbborder" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="227" width="2000"/></a></figure-inline></p>
+<p><figure-inline class="mw-image-border" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="227" width="2000"/></a></figure-inline></p>
+!! end
+
+!! test
+Image: "unspecified format" and border reduce
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|1000px]]
+
+[[File:Foobar.jpg|border|1000px]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/1000px-Foobar.jpg" width="1000" height="113" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/1500px-Foobar.jpg 1.5x, http://example.com/images/3/3a/Foobar.jpg 2x" /></a>
+</p><p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/1000px-Foobar.jpg" width="1000" height="113" class="thumbborder" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/1500px-Foobar.jpg 1.5x, http://example.com/images/3/3a/Foobar.jpg 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/1000px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="113" width="1000"/></a></figure-inline></p>
+<p><figure-inline class="mw-image-border" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/1000px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="113" width="1000"/></a></figure-inline></p>
+!! end
+
+!! test
+Image: thumbs reduce
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|thumb|50px]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:52px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" width="50" height="6" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/75px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/100px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div></div></div></div>
+
+!! html/parsoid
+<figure typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></figure>
+!! end
+
+!! test
+Image: bitmap thumbs can't be enlarged past original size, but vector can.
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|thumb|2000px]]
+
+[[File:Foobar.svg|thumb|2000px]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:1943px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" class="thumbimage" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div></div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:2002px;"><a href="/wiki/File:Foobar.svg" class="image"><img alt="Foobar.svg" src="http://example.com/images/thumb/f/ff/Foobar.svg/2000px-Foobar.svg.png" width="2000" height="1500" class="thumbimage" srcset="http://example.com/images/thumb/f/ff/Foobar.svg/3000px-Foobar.svg.png 1.5x, http://example.com/images/thumb/f/ff/Foobar.svg/4000px-Foobar.svg.png 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.svg" class="internal" title="Enlarge"></a></div></div></div></div>
+
+!! html/parsoid
+<figure typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure>
+<figure typeof="mw:Image/Thumb"><a href="./File:Foobar.svg"><img resource="./File:Foobar.svg" src="//example.com/images/thumb/f/ff/Foobar.svg/2000px-Foobar.svg.png" data-file-width="240" data-file-height="180" data-file-type="drawing" height="1500" width="2000"/></a></figure>
+!! end
+
+!! test
+Image: frameless can reduce in size
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|frameless|50px]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" width="50" height="6" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/75px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/100px-Foobar.jpg 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline typeof="mw:Image/Frameless"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></figure-inline></p>
+!! end
+
+!! test
+Image: bitmap frameless can't be enlarged past original size, but vector can
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|frameless|2000px]]
+
+[[File:Foobar.svg|frameless|2000px]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p><p><a href="/wiki/File:Foobar.svg" class="image"><img alt="Foobar.svg" src="http://example.com/images/thumb/f/ff/Foobar.svg/2000px-Foobar.svg.png" width="2000" height="1500" srcset="http://example.com/images/thumb/f/ff/Foobar.svg/3000px-Foobar.svg.png 1.5x, http://example.com/images/thumb/f/ff/Foobar.svg/4000px-Foobar.svg.png 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline typeof="mw:Image/Frameless"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+<p><figure-inline typeof="mw:Image/Frameless"><a href="./File:Foobar.svg"><img resource="./File:Foobar.svg" src="//example.com/images/thumb/f/ff/Foobar.svg/2000px-Foobar.svg.png" data-file-width="240" data-file-height="180" data-file-type="drawing" height="1500" width="2000"/></a></figure-inline></p>
+!! end
+
+!! test
+Image: framed images are always unscaled.
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|frame]]
+
+[[File:Foobar.jpg|frame|50px]]
+
+[[File:Foobar.jpg|frame|50x50px]]
+
+[[File:Foobar.jpg|frame|2000px]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:1943px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" class="thumbimage" /></a> <div class="thumbcaption"></div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:1943px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" class="thumbimage" /></a> <div class="thumbcaption"></div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:1943px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" class="thumbimage" /></a> <div class="thumbcaption"></div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:1943px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" class="thumbimage" /></a> <div class="thumbcaption"></div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Frame"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure>
+<figure typeof="mw:Image/Frame"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure>
+<figure typeof="mw:Image/Frame"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure>
+<figure typeof="mw:Image/Frame"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure>
+!! end
+
+###################
+
+!! test
+Link to image page- image page normally doesn't exists, hence edit link
+Add test with existing image page
+#<p><a href="/wiki/File:Test" title="Image:Test">Image:test</a>
+!! wikitext
+[[:Image:test]]
+!! html
+<p><a href="/index.php?title=File:Test&amp;action=edit&amp;redlink=1" class="new" title="File:Test (page does not exist)">Image:test</a>
+</p>
+!! end
+
+!! test
+T20784 Link to non-existent image page with caption should use caption as link text
+!! wikitext
+[[:Image:test|caption]]
+!! html
+<p><a href="/index.php?title=File:Test&amp;action=edit&amp;redlink=1" class="new" title="File:Test (page does not exist)">caption</a>
+</p>
+!! end
+
+!! test
+Frameless image caption with a free URL
+!! wikitext
+[[File:Foobar.jpg|http://example.com]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="http://example.com"><img alt="http://example.com" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"http://example.com"}]}' data-mw='{"caption":"&lt;a rel=\"mw:ExtLink\" href=\"http://example.com\" data-parsoid=&#39;{\"stx\":\"url\",\"dsr\":[18,36,0,0]}&#39;>http://example.com&lt;/a>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Thumbnail image caption with a free URL
+!! options
+thumbsize=220
+!! wikitext
+[[File:Foobar.jpg|thumb|http://example.com]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:222px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a></div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption><a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a></figcaption></figure>
+!! end
+
+!! test
+Thumbnail image caption with a free URL and explicit alt
+!! options
+thumbsize=220
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|thumb|http://example.com|alt=Alteration]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:222px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Alteration" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a></div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img alt="Alteration" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption><a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a></figcaption></figure>
+!! end
+
+!! test
+SVG thumbnails with no language set
+!! options
+!! wikitext
+[[File:Foobar.svg|thumb|caption]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.svg" class="image"><img alt="" src="http://example.com/images/thumb/f/ff/Foobar.svg/180px-Foobar.svg.png" width="180" height="135" class="thumbimage" srcset="http://example.com/images/thumb/f/ff/Foobar.svg/270px-Foobar.svg.png 1.5x, http://example.com/images/thumb/f/ff/Foobar.svg/360px-Foobar.svg.png 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.svg" class="internal" title="Enlarge"></a></div>caption</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.svg"><img resource="./File:Foobar.svg" src="//example.com/images/thumb/f/ff/Foobar.svg/220px-Foobar.svg.png" data-file-width="240" data-file-height="180" data-file-type="drawing" height="165" width="220"/></a><figcaption>caption</figcaption></figure>
+!! end
+
+!! test
+SVG thumbnails with language de
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.svg|thumb|caption|lang=de]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/index.php?title=File:Foobar.svg&amp;lang=de" class="image"><img alt="" src="http://example.com/images/thumb/f/ff/Foobar.svg/langde-180px-Foobar.svg.png" width="180" height="135" class="thumbimage" srcset="http://example.com/images/thumb/f/ff/Foobar.svg/langde-270px-Foobar.svg.png 1.5x, http://example.com/images/thumb/f/ff/Foobar.svg/langde-360px-Foobar.svg.png 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.svg" class="internal" title="Enlarge"></a></div>caption</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.svg"><img resource="./File:Foobar.svg" src="//example.com/images/thumb/f/ff/Foobar.svg/220px-Foobar.svg.png" lang="de" data-file-width="240" data-file-height="180" data-file-type="drawing" height="165" width="220"/></a><figcaption>caption</figcaption></figure>
+!! end
+
+!! test
+SVG thumbnails with invalid language code
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.svg|thumb|caption|lang=invalid:language:code]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.svg" class="image"><img alt="" src="http://example.com/images/thumb/f/ff/Foobar.svg/180px-Foobar.svg.png" width="180" height="135" class="thumbimage" srcset="http://example.com/images/thumb/f/ff/Foobar.svg/270px-Foobar.svg.png 1.5x, http://example.com/images/thumb/f/ff/Foobar.svg/360px-Foobar.svg.png 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.svg" class="internal" title="Enlarge"></a></div>lang=invalid:language:code</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.svg"><img resource="./File:Foobar.svg" src="//example.com/images/thumb/f/ff/Foobar.svg/220px-Foobar.svg.png" data-file-width="240" data-file-height="180" data-file-type="drawing" height="165" width="220"/></a><figcaption>lang=invalid:language:code</figcaption></figure>
+!! end
+
+!! test
+T3887: A ISBN with a thumbnail
+!! wikitext
+[[File:Foobar.jpg|thumb|ISBN 1235467890]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div><a href="/wiki/Special:BookSources/1235467890" class="internal mw-magiclink-isbn">ISBN 1235467890</a></div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption><a href="./Special:BookSources/1235467890" rel="mw:WikiLink">ISBN 1235467890</a></figcaption></figure>
+!! end
+
+!! test
+T3887: A RFC with a thumbnail
+!! wikitext
+[[File:Foobar.jpg|thumb|This is RFC 12354]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>This is <a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc12354">RFC 12354</a></div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>This is <a href="https://tools.ietf.org/html/rfc12354" rel="mw:ExtLink" class="external text">RFC 12354</a></figcaption></figure>
+!! end
+
+!! test
+T3887: A mailto link with a thumbnail
+!! wikitext
+[[File:Foobar.jpg|thumb|Please mailto:nobody@example.com]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>Please <a rel="nofollow" class="external free" href="mailto:nobody@example.com">mailto:nobody@example.com</a></div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>Please <a rel="mw:ExtLink" class="external free" href="mailto:nobody@example.com">mailto:nobody@example.com</a></figcaption></figure>
+!! end
+
+# Pending resolution to T2368
+!! test
+T2648: Frameless image caption with a link
+!! wikitext
+[[File:Foobar.jpg|text with a [[link]] in it]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="text with a link in it"><img alt="text with a link in it" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[link]] in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Link\"},\"sa\":{\"href\":\"link\"},\"dsr\":[30,38,2,2]}&#39;>link&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+T2648: Frameless image caption with a link (suffix)
+!! wikitext
+[[File:Foobar.jpg|text with a [[link]]foo in it]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="text with a linkfoo in it"><img alt="text with a linkfoo in it" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[link]]foo in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Link\"},\"sa\":{\"href\":\"link\"},\"dsr\":[30,41,2,5],\"tail\":\"foo\"}&#39;>linkfoo&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+T2648: Frameless image caption with an interwiki link
+!! wikitext
+[[File:Foobar.jpg|text with a [[MeatBall:Link]] in it]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="text with a MeatBall:Link in it"><img alt="text with a MeatBall:Link in it" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[MeatBall:Link]] in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink/Interwiki\" href=\"http://www.usemod.com/cgi-bin/mb.pl?Link\" title=\"meatball:Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"http://www.usemod.com/cgi-bin/mb.pl?Link\"},\"sa\":{\"href\":\"MeatBall:Link\"},\"isIW\":true,\"dsr\":[30,47,2,2]}&#39;>MeatBall:Link&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+T2648: Frameless image caption with a piped interwiki link
+!! wikitext
+[[File:Foobar.jpg|text with a [[MeatBall:Link|link]] in it]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="text with a link in it"><img alt="text with a link in it" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[MeatBall:Link|link]] in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink/Interwiki\" href=\"http://www.usemod.com/cgi-bin/mb.pl?Link\" title=\"meatball:Link\" data-parsoid=&#39;{\"stx\":\"piped\",\"a\":{\"href\":\"http://www.usemod.com/cgi-bin/mb.pl?Link\"},\"sa\":{\"href\":\"MeatBall:Link\"},\"isIW\":true,\"dsr\":[30,52,16,2]}&#39;>link&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+T107474: Frameless image caption with <nowiki>
+!! wikitext
+[[File:Foobar.jpg|<nowiki>text with a [[MeatBall:Link|link]] in it</nowiki>]]
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&lt;nowiki>text with a [[MeatBall:Link|link]] in it&lt;/nowiki>"}]}' data-mw='{"caption":"&lt;span typeof=\"mw:Nowiki\" data-parsoid=&#39;{\"dsr\":[18,75,8,9]}&#39;>text with a [[MeatBall:Link|link]] in it&lt;/span>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Escape HTML special chars in image alt text
+!! wikitext
+[[File:Foobar.jpg|& < > "]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="&amp; &lt; &gt; &quot;"><img alt="&amp; &lt; &gt; &quot;" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp; &lt; > \""}]}' data-mw='{"caption":"&amp;amp; &amp;lt; > \""}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Escape HTML special chars in image alt text with LanguageConverter
+!! options
+language=zh
+!! wikitext
+[[File:Foobar.jpg|& < > "]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="&amp; &lt; &gt; &quot;"><img alt="&amp; &lt; &gt; &quot;" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp; &lt; > \""}]}' data-mw='{"caption":"&amp;amp; &amp;lt; > \""}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Entities in file name and attributes
+!! wikitext
+[[File:7%25 solution.gif|manualthumb=7%25 solution.gif|link=7%25 solution|[[7%25 solution]]]]
+!! html/php
+<p><a href="/index.php?title=Special:Upload&amp;wpDestFile=7%25_solution.gif" class="new" title="File:7% solution.gif">7% solution</a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"bogus","ak":"manualthumb=7%25 solution.gif"},{"ck":"link","ak":"link=7%25 solution"},{"ck":"caption","ak":"[[7%25 solution]]"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&lt;a rel=\"mw:WikiLink\" href=\"./7%25_solution\" title=\"7% solution\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./7%25_solution\"},\"sa\":{\"href\":\"7%25 solution\"},\"dsr\":[74,91,2,2]}&#39;>7% solution&lt;/a>"}'><a href="./7%25_solution" data-parsoid='{"a":{"href":"./7%25_solution"},"sa":{"href":"link=7%25 solution"}}'><img resource="./File:7%25_solution.gif" src="./Special:FilePath/7%25_solution.gif" height="220" width="220" data-parsoid='{"a":{"resource":"./File:7%25_solution.gif","height":"220","width":"220"},"sa":{"resource":"File:7%25 solution.gif"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+T2499: Alt text should have &#1234;, not &amp;1234;
+!! wikitext
+[[File:Foobar.jpg|&#9792;]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="♀"><img alt="♀" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp;#9792;"}]}' data-mw='{"caption":"&lt;span typeof=\"mw:Entity\" data-parsoid=&#39;{\"src\":\"&amp;amp;#9792;\",\"srcContent\":\"♀\",\"dsr\":[18,25,null,null]}&#39;>♀&lt;/span>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Broken image caption with link
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[Image:Foobar.jpg|thumb|This is a broken caption. But [[Main Page|this]] is just an ordinary link.
+!! html/php
+<p>[[Image:Foobar.jpg|thumb|This is a broken caption. But <a href="/wiki/Main_Page" title="Main Page">this</a> is just an ordinary link.
+</p>
+!! html/parsoid
+<p>[[Image:Foobar.jpg|thumb|This is a broken caption. But <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">this</a> is just an ordinary link.</p>
+!! end
+
+!! test
+Image caption containing another image
+!! wikitext
+[[File:Foobar.jpg|thumb|This is a caption with another [[File:Thumb.png|image]] inside it!]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>This is a caption with another <a href="/wiki/File:Thumb.png" class="image" title="image"><img alt="image" src="http://example.com/images/e/ea/Thumb.png" width="135" height="135" /></a> inside it!</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>This is a caption with another <figure-inline class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"image"}'><a href="./File:Thumb.png"><img resource="./File:Thumb.png" src="//example.com/images/e/ea/Thumb.png" data-file-width="135" data-file-height="135" data-file-type="bitmap" height="135" width="135"/></a></figure-inline> inside it!</figcaption></figure>
+!! end
+
+!! test
+Image: caption containing a newline
+!! wikitext
+[[File:Foobar.jpg|This
+*is some text]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="This *is some text"><img alt="This *is some text" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"This\n*is some text"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+!!end
+
+!!test
+Image: caption containing leading space
+(The leading space should not trigger nowiki escaping in wt2wt mode)
+!! wikitext
+[[File:Foobar.jpg|thumb| bar]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>bar</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption> bar</figcaption></figure>
+!!end
+
+# html/php output not have newlines after table, td, th, etc. because
+# Linker::makeThumbLink2() replaces the newlines with spaces since
+# the table is inside a caption.
+# FIXME: Verify if that circa 2004 fix is still required.
+!! test
+Image: caption containing a table
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[Image:Foobar.jpg|thumb|200px|This is an example image thumbnail caption with a table
+{|
+!Foo!!Bar
+|-
+|Foo1||Bar1
+|}
+and some more text.]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:202px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" width="200" height="23" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/400px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>This is an example image thumbnail caption with a table <table> <tr> <th>Foo</th> <th>Bar </th></tr> <tr> <td>Foo1</td> <td>Bar1 </td></tr></table> and some more text.</div></div></div>
+
+!! html/parsoid
+<figure typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="23" width="200"/></a><figcaption>This is an example image thumbnail caption with a table
+<table>
+<tbody>
+<tr><th>Foo</th><th>Bar</th></tr>
+<tr>
+<td>Foo1</td>
+<td>Bar1</td></tr></tbody></table>and some more text.</figcaption></figure>
+!! end
+
+!! test
+T5090: External links other than http: in image captions
+!! wikitext
+[[File:Foobar.jpg|thumb|200x200px|This caption has [irc://example.net irc] and [https://example.com Secure] ext links in it.]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:202px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" width="200" height="23" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/400px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>This caption has <a rel="nofollow" class="external text" href="irc://example.net">irc</a> and <a rel="nofollow" class="external text" href="https://example.com">Secure</a> ext links in it.</div></div></div>
+
+!! html/parsoid
+<figure typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="23" width="200"/></a><figcaption>This caption has <a rel="mw:ExtLink" class="external text" href="irc://example.net">irc</a> and <a rel="mw:ExtLink" class="external text" href="https://example.com">Secure</a> ext links in it.</figcaption></figure>
+!! end
+
+!! test
+Custom class
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[Image:foobar.jpg|a|class=b]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="a"><img alt="a" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" class="b" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size b" typeof="mw:Image" data-mw='{"caption":"a"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+!! end
+
+!! test
+Localized image handling (1).
+!! options
+parsoid=wt2html,wt2wt,html2html
+language=es
+!! wikitext
+[[Archivo:Foobar.jpg|izquierda|enlace=foo|caption]]
+!! html/php
+<div class="floatleft"><a href="/wiki/Foo" title="caption"><img alt="caption" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-left" typeof="mw:Image"><a href="./Foo"><img resource="./Archivo:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>caption</figcaption></figure>
+!! end
+
+!! test
+Localized image handling (2).
+!! options
+thumbsize=220
+parsoid=wt2html,wt2wt,html2html
+language=es
+!! wikitext
+[[Archivo:Foobar.jpg|miniatura|izquierda|enlace=foo|caption]]
+!! html/php
+<div class="thumb tleft"><div class="thumbinner" style="width:222px;"><a href="/wiki/Foo" title="Foo"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/Archivo:Foobar.jpg" class="internal" title="Aumentar"></a></div>caption</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-left" typeof="mw:Image/Thumb"><a href="./Foo"><img resource="./Archivo:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>caption</figcaption></figure>
+!! end
+
+!! test
+Localized image handling (3).
+!! options
+language=fa
+parsoid=html2wt
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></figure>
+!! wikitext
+[[File:Foobar.jpg|بندانگشتی]]
+!! end
+
+!! test
+"border", "frameless" and "class" attributes on an image.
+!! options
+thumbsize=220
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|frameless|border|class=extra|caption]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="caption"><img alt="caption" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="extra thumbborder" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size mw-image-border extra" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></figure-inline></p>
+!! end
+
+# Note that 'right' is the default alignment, despite the misspelled 'righ' below
+!! test
+Invalid image attributes (T64500)
+!! options
+thumbsize=220
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|thumb|float|left|caption]]
+
+[[File:Foobar.jpg|thumb|righ|caption]]
+
+[[File:Foobar.jpg|bogus1|thumb|bogus2|left|bogus3|caption]]
+!! html/php
+<div class="thumb tleft"><div class="thumbinner" style="width:222px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>caption</div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:222px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>caption</div></div></div>
+<div class="thumb tleft"><div class="thumbinner" style="width:222px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>caption</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-left" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>caption</figcaption></figure>
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>caption</figcaption></figure>
+<figure class="mw-default-size mw-halign-left" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>caption</figcaption></figure>
+!! end
+
+!! article
+File:Barfoo.jpg
+!! text
+#REDIRECT [[File:Barfoo.jpg]]
+!! endarticle
+
+# FIXME: Parsoid should run this test -- but we'd need to teach the
+# mockAPI about the redirected Barfoo.jpg image.
+!! test
+Redirected image
+!! wikitext
+[[Image:Barfoo.jpg]]
+!! html/php
+<p><a href="/wiki/File:Barfoo.jpg" class="mw-redirect" title="File:Barfoo.jpg">File:Barfoo.jpg</a>
+</p>
+!! end
+
+!! test
+Missing image with uploads disabled
+!! options
+wgEnableUploads=0
+!! wikitext
+[[File:Foobaz.jpg]]
+!! html/php
+<p><a href="/wiki/File:Foobaz.jpg" title="File:Foobaz.jpg">File:Foobaz.jpg</a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Foobaz.jpg"><img resource="./File:Foobaz.jpg" src="./Special:FilePath/Foobaz.jpg" height="220" width="220"/></a></figure-inline></p>
+!! end
+
+# Parsoid-specific testing for images
+# https://www.mediawiki.org/wiki/Parsoid/MediaWiki_DOM_spec#Images
+# Currently imperfect due to a flaw in the Parsoid testrunner
+# Work in progress
+# THESE TESTS SHOULD BE MOVED UP and merged with the php-specific
+# image tests.
+
+!! test
+Parsoid-specific image handling - simple image with size and middle alignment
+!! wikitext
+[[File:Foobar.jpg|middle|50px]]
+!! html/parsoid
+<p><figure-inline class="mw-valign-middle" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></figure-inline></p>
+!! end
+
+!! test
+Parsoid-specific image handling - simple image with size, middle alignment,
+non-standard namespace alias
+!! options
+parsoid=wt2wt,wt2html,html2html
+!! wikitext
+[[Image:Foobar.jpg|middle|50px]]
+!! html/parsoid
+<p><figure-inline class="mw-valign-middle" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></figure-inline></p>
+!! end
+
+!! test
+Parsoid-specific image handling - simple image with size and middle alignment
+(existing content)
+!! wikitext
+[[File:Foobar.jpg|50px|middle]]
+!! html/parsoid
+<p><figure-inline class="mw-valign-middle" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"width","ak":"50px"},{"ck":"middle","ak":"middle"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"6","width":"50"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Parsoid-specific image handling - simple image with size and middle alignment
+and non-standard namespace name
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[Image:Foobar.jpg|50px|middle]]
+!! html/parsoid
+<p><figure-inline class="mw-valign-middle" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></figure-inline></p>
+!! end
+
+!! test
+Parsoid-specific image handling - simple image with both sizes, a baseline alignment, and a caption
+!! wikitext
+[[File:Foobar.jpg|500x10px|baseline|caption]]
+!! html/parsoid
+<p><figure-inline class="mw-valign-baseline" typeof="mw:Image" data-mw='{"caption":"caption"}' data-parsoid='{"optList":[{"ck":"width","ak":"500x10px"},{"ck":"baseline","ak":"baseline"},{"ck":"caption","ak":"caption"}],"size":"500x10"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/89px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="10" width="89" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"10","width":"89"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Parsoid-specific image handling - simple image with border and size spec
+!! wikitext
+[[File:Foobar.jpg|50px|border|caption]]
+!! html/parsoid
+<p><figure-inline class="mw-image-border" typeof="mw:Image" data-mw='{"caption":"caption"}' data-parsoid='{"optList":[{"ck":"width","ak":"50px"},{"ck":"border","ak":"border"},{"ck":"caption","ak":"caption"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"6","width":"50"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Parsoid-specific image handling - thumbnail with halign, valign, and caption
+!! wikitext
+[[File:Foobar.jpg|left|baseline|thumb|caption content]]
+!! html/parsoid
+<figure class="mw-default-size mw-halign-left mw-valign-baseline" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>caption content</figcaption></figure>
+!! end
+
+!! test
+Parsoid-specific image handling - thumbnail with halign, valign, and caption
+(existing content)
+!! wikitext
+[[File:Foobar.jpg|thumb|left|baseline|caption content]]
+!! html/parsoid
+<figure class="mw-default-size mw-halign-left mw-valign-baseline" typeof="mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"left","ak":"left"},{"ck":"baseline","ak":"baseline"},{"ck":"caption","ak":"caption content"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"25","width":"220"},"sa":{"resource":"File:Foobar.jpg"}}'/></a><figcaption>caption content</figcaption></figure>
+!! end
+
+!! test
+Parsoid-specific image handling - thumbnail with specific size, halign, valign, and caption
+!! wikitext
+[[Image:Foobar.jpg|right|middle|thumb|50x50px|caption]]
+!! html/parsoid
+<figure class="mw-halign-right mw-valign-middle" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a><figcaption>caption</figcaption></figure>
+!! end
+
+!! test
+Parsoid-specific image handling - thumbnail with specific size, halign,
+valign, and caption (existing content)
+!! wikitext
+[[File:Foobar.jpg|thumb|50x50px|right|middle|caption]]
+!! html/parsoid
+<figure class="mw-halign-right mw-valign-middle" typeof="mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"width","ak":"50x50px"},{"ck":"right","ak":"right"},{"ck":"middle","ak":"middle"},{"ck":"caption","ak":"caption"}],"size":"50x50"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"6","width":"50"},"sa":{"resource":"File:Foobar.jpg"}}'/></a><figcaption>caption</figcaption></figure>
+!! end
+
+!! test
+Parsoid-specific image handling - framed image with specific size and caption
+(size is ignored)
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|frame|500x50px|caption]]
+!! html/parsoid
+<figure typeof="mw:Image/Frame"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>caption</figcaption></figure>
+!! end
+
+!! test
+Parsoid-specific image handling - framed image with specific size, halign, valign, and caption
+(size is ignored)
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+[[File:Foobar.jpg|left|baseline|frame|500x50px|caption]]
+!! html/parsoid
+<figure class="mw-halign-left mw-valign-baseline" typeof="mw:Image/Frame"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>caption</figcaption></figure>
+!! end
+
+!! test
+Parsoid-specific image handling - frameless image with specific size, border, and caption
+!! wikitext
+[[File:Foobar.jpg|frameless|442x50px|border|caption]]
+!! html/parsoid
+<p><figure-inline class="mw-image-border" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}' data-parsoid='{"optList":[{"ck":"frameless","ak":"frameless"},{"ck":"width","ak":"442x50px"},{"ck":"border","ak":"border"},{"ck":"caption","ak":"caption"}],"size":"442x50"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/442px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="50" width="442" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"50","width":"442"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Parsoid-specific image handling - simple image with a formatted caption
+!! wikitext
+[[File:Foobar.jpg|<table><tr><td>a</td><td>b</td></tr><tr><td>c</td></tr></table>]]
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&lt;table>&lt;tr>&lt;td>a&lt;/td>&lt;td>b&lt;/td>&lt;/tr>&lt;tr>&lt;td>c&lt;/td>&lt;/tr>&lt;/table>"}]}' data-mw='{"caption":"&lt;table data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[18,81,7,8]}&#39;>&lt;tbody data-parsoid=&#39;{\"dsr\":[25,73,0,0]}&#39;>&lt;tr data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[25,54,4,5]}&#39;>&lt;td data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[29,39,4,5]}&#39;>a&lt;/td>&lt;td data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[39,49,4,5]}&#39;>b&lt;/td>&lt;/tr>&lt;tr data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[54,73,4,5]}&#39;>&lt;td data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[58,68,4,5]}&#39;>c&lt;/td>&lt;/tr>&lt;/tbody>&lt;/table>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Parsoid-specific image handling - caption with a template in it
+!! wikitext
+[[File:Foobar.jpg|thumb|200x23px|This caption has a {{echo|transclusion}} in it.]]
+!! html/parsoid
+<figure typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="23" width="200"></a><figcaption>This caption has a <span about="#mwt1" typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;transclusion&quot;}},&quot;i&quot;:0}}]}">transclusion</span> in it.</figcaption></figure>
+!! end
+
+!! test
+Parsoid-specific image handling - caption with unbalanced tags in it
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+foo
+[[File:Foobar.jpg|thumb|200x200px|This caption has a <center>unbalanced tag in it.]]
+bar
+!! html/parsoid
+<p>foo</p>
+<figure typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="23" width="200"/></a><figcaption>This caption has a <center>unbalanced tag in it.</center></figcaption></figure>
+<p>bar</p>
+!! end
+
+!! test
+Parsoid-specific image handling - empty caption (1)
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+[[File:Foobar.jpg|thumb|]]
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption></figcaption></figure>
+!! end
+
+# empty captions don't get serialized unless we're in the "round trip" case
+!! test
+Parsoid-specific image handling - empty caption (2)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb">
+ <a href="./File:Foobar.jpg">
+ <img resource="./File:Foobar.jpg"
+ src="//example.com/images/3/3a/Foobar.jpg"
+ data-file-width="1941" data-file-height="220" data-file-type="bitmap"
+ height="25" width="220"/>
+ </a>
+ <figcaption></figcaption>
+</figure>
+!! wikitext
+[[File:Foobar.jpg|thumb]]
+!! end
+
+!! test
+Parsoid-specific image handling - whitespace caption
+!! wikitext
+[[File:Foobar.jpg|thumb| ]]
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption> </figcaption></figure>
+!! end
+
+!! test
+Parsoid-specific image handling - lang option
+!! wikitext
+foo
+[[File:Foobar.svg|lang=de|caption]]
+bar
+!! html/parsoid
+<p>foo
+<figure-inline class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"caption"}'><a href="./File:Foobar.svg"><img resource="./File:Foobar.svg" src="//example.com/images/f/ff/Foobar.svg" lang="de" data-file-width="240" data-file-height="180" data-file-type="drawing" height="180" width="240"/></a></figure-inline>
+bar</p>
+!! end
+
+## Edge case bugs in Parsoid from T93580
+!! test
+T93580: 1. Templated <ref> inside block images
+!! wikitext
+[[File:Foobar.jpg|thumb|Caption with templated ref: {{echo|<ref>foo</ref>}}]]
+
+<references />
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"caption","ak":"Caption with templated ref: {{echo|&lt;ref>foo&lt;/ref>}}"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"25","width":"220"},"sa":{"resource":"File:Foobar.jpg"}}'/></a><figcaption>Caption with templated ref: <sup about="#mwt5" class="mw-ref" id="cite_ref-1" rel="dc:references" typeof="mw:Transclusion mw:Extension/ref" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;ref>foo&lt;/ref>"}},"i":0}}]}'><a href="./Main_Page#cite_note-1" style="counter-reset: mw-Ref 1;"><span class="mw-reflink-text">[1]</span></a></sup></figcaption></figure>
+
+<ol class="mw-references references" typeof="mw:Extension/references" about="#mwt6" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-1" id="cite_note-1"><a href="./Main_Page#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text" data-parsoid="{}">foo</span></li></ol>
+!! end
+
+!! test
+T93580: 2. <ref> inside inline images
+!! wikitext
+[[File:Foobar.jpg|Undisplayed caption in inline image with ref: <ref>foo</ref>]]
+
+<references />
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"Undisplayed caption in inline image with ref: &lt;ref>foo&lt;/ref>"}]}' data-mw='{"caption":"Undisplayed caption in inline image with ref: &lt;sup about=\"#mwt2\" class=\"mw-ref\" id=\"cite_ref-1\" rel=\"dc:references\" typeof=\"mw:Extension/ref\" data-parsoid=&#39;{\"dsr\":[64,78,5,6]}&#39; data-mw=&#39;{\"name\":\"ref\",\"body\":{\"id\":\"mw-reference-text-cite_note-1\"},\"attrs\":{}}&#39;>&lt;a href=\"./Main_Page#cite_note-1\" style=\"counter-reset: mw-Ref 1;\" data-parsoid=\"{}\">&lt;span class=\"mw-reflink-text\" data-parsoid=\"{}\">[1]&lt;/span>&lt;/a>&lt;/sup>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{"href":"File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+
+<ol class="mw-references references" typeof="mw:Extension/references" about="#mwt4" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-1" id="cite_note-1"><a href="./Main_Page#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text">foo</span></li></ol>
+!! end
+
+!! test
+T93580: 3. Templated <ref> inside inline images
+!! wikitext
+[[File:Foobar.jpg|Undisplayed caption in inline image with ref: {{echo|<ref>{{echo|foo}}</ref>}}]]
+
+<references />
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"Undisplayed caption in inline image with ref: {{echo|&lt;ref>{{echo|foo}}&lt;/ref>}}"}]}' data-mw='{"caption":"Undisplayed caption in inline image with ref: &lt;sup about=\"#mwt2\" class=\"mw-ref\" id=\"cite_ref-1\" rel=\"dc:references\" typeof=\"mw:Transclusion mw:Extension/ref\" data-parsoid=&#39;{\"dsr\":[64,96,null,null],\"pi\":[[{\"k\":\"1\"}]]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"&amp;lt;ref>{{echo|foo}}&amp;lt;/ref>\"}},\"i\":0}}]}&#39;>&lt;a href=\"./Main_Page#cite_note-1\" style=\"counter-reset: mw-Ref 1;\" data-parsoid=\"{}\">&lt;span class=\"mw-reflink-text\" data-parsoid=\"{}\">[1]&lt;/span>&lt;/a>&lt;/sup>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{"href":"File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+
+<ol class="mw-references references" typeof="mw:Extension/references" about="#mwt6" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-1" id="cite_note-1"><a href="./Main_Page#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text">foo</span></li></ol>
+!! end
+
+###
+### Subpages
+###
+!! article
+Subpage test/subpage
+!! text
+foo
+!! endarticle
+
+!! test
+Subpage link
+!! options
+subpage title=[[Subpage test]]
+!! wikitext
+[[/subpage]]
+!! html
+<p><a href="/wiki/Subpage_test/subpage" title="Subpage test/subpage">/subpage</a>
+</p>
+!! end
+
+!! test
+Subpage noslash link
+!! options
+subpage title=[[Subpage test]]
+!! wikitext
+[[/subpage/]]
+!! html
+<p><a href="/wiki/Subpage_test/subpage" title="Subpage test/subpage">subpage</a>
+</p>
+!! end
+
+!! article
+Subpage test/1/2/subpage
+!! text
+blah
+!! endarticle
+
+!! test
+Relative subpage noslash link
+!! options
+parsoid=wt2wt,wt2html,html2html
+subpage title=[[Subpage test/1/2/3/4]]
+!! wikitext
+[[../../subpage/]]
+
+[[../../subpage]]
+!! html/php
+<p><a href="/wiki/Subpage_test/1/2/subpage" title="Subpage test/1/2/subpage">subpage</a>
+</p><p><a href="/wiki/Subpage_test/1/2/subpage" title="Subpage test/1/2/subpage">Subpage test/1/2/subpage</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Subpage_test/1/2/subpage" title="Subpage test/1/2/subpage">subpage</a></p>
+<p><a rel="mw:WikiLink" href="./Subpage_test/1/2/subpage" title="Subpage test/1/2/subpage">Subpage_test/1/2/subpage</a></p>
+!! end
+
+!! test
+Parsoid: dot-slash prefixed wikilinks
+!! wikitext
+[[./foo]]
+
+[[././bar]]
+
+[[././baz/]]
+!! html/php
+<p>[[./foo]]
+</p><p>[[././bar]]
+</p><p>[[././baz/]]
+</p>
+!! html/parsoid
+<p>[[./foo]]
+</p><p>[[././bar]]
+</p><p>[[././baz/]]
+</p>
+!! end
+
+!! test
+Render invalid page names as plain text (T53090)
+!! wikitext
+[[./../foo|bar]]
+[[foo�|bar]]
+[[foo/.|bar]]
+[[foo/..|bar]]
+[[foo~~~bar]]
+[[foo>bar]]
+[[foo[bar]]
+[[.]]
+[[..]]
+[[foo././bar]]
+[[foo[http://example.com]xyz]]
+
+[[{{echo|./../foo}}|bar]]
+[[{{echo|foo/.}}|bar]]
+[[{{echo|foo/..}}|bar]]
+[[{{echo|foo~~~~bar}}]]
+[[{{echo|foo>bar}}]]
+[[{{echo|foo././bar}}]]
+[[{{echo|foo{bar}}]]
+[[{{echo|foo}bar}}]]
+[[{{echo|foo[bar}}]]
+[[{{echo|foo]bar}}]]
+[[{{echo|foo<bar}}]]
+!!html/php
+<p>[[./../foo|bar]]
+[[foo�|bar]]
+[[foo/.|bar]]
+[[foo/..|bar]]
+[[foo~~~bar]]
+[[foo&gt;bar]]
+[[foo[bar]]
+[[.]]
+[[..]]
+[[foo././bar]]
+[[foo<a rel="nofollow" class="external autonumber" href="http://example.com">[1]</a>xyz]]
+</p><p>[[./../foo|bar]]
+[[foo/.|bar]]
+[[foo/..|bar]]
+[[foo~~~~bar]]
+[[foo&gt;bar]]
+[[foo././bar]]
+[[foo{bar]]
+[[foo}bar]]
+[[foo[bar]]
+[[foo]bar]]
+[[foo&lt;bar]]
+</p>
+!!html/parsoid
+<p>[[./../foo|bar]]
+[[foo�|bar]]
+[[foo/.|bar]]
+[[foo/..|bar]]
+[[foo~~~bar]]
+[[foo>bar]]
+[[foo[bar]]
+[[.]]
+[[..]]
+[[foo././bar]]
+[[foo<a rel="mw:ExtLink" class="external autonumber" href="http://example.com"></a>xyz]]</p>
+
+<p>[[<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"./../foo"}},"i":0}}]}'>./../foo</span>|bar]]
+[[<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo/."}},"i":0}}]}'>foo/.</span>|bar]]
+[[<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo/.."}},"i":0}}]}'>foo/..</span>|bar]]
+[[<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo~~~~bar"}},"i":0}}]}'>foo~~~~bar</span>]]
+[[<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo>bar"}},"i":0}}]}'>foo>bar</span>]]
+[[<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo././bar"}},"i":0}}]}'>foo././bar</span>]]
+[[<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo{bar"}},"i":0}}]}'>foo{bar</span>]]
+[[<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo}bar"}},"i":0}}]}'>foo}bar</span>]]
+[[<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo[bar"}},"i":0}}]}'>foo[bar</span>]]
+[[<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo]bar"}},"i":0}}]}'>foo]bar</span>]]
+[[<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo&lt;bar"}},"i":0}}]}'>foo&lt;bar</span>]]</p>
+!!end
+
+!! test
+Disabled subpages
+!! wikitext
+[[/subpage]]
+!! html
+<p><a href="/index.php?title=/subpage&amp;action=edit&amp;redlink=1" class="new" title="/subpage (page does not exist)">/subpage</a>
+</p>
+!! end
+
+!! test
+T2561: {{/Subpage}}
+!! options
+subpage title=[[Page]]
+!! wikitext
+{{/Subpage}}
+!! html
+<p><a href="/index.php?title=Page/Subpage&amp;action=edit&amp;redlink=1" class="new" title="Page/Subpage (page does not exist)">Page/Subpage</a>
+</p>
+!! end
+
+###
+### Categories
+###
+!! article
+Category:MediaWiki User's Guide
+!! text
+blah
+!! endarticle
+
+!! test
+Link to category
+!! wikitext
+[[:Category:MediaWiki User's Guide]]
+!! html
+<p><a href="/wiki/Category:MediaWiki_User%27s_Guide" title="Category:MediaWiki User&#39;s Guide">Category:MediaWiki User's Guide</a>
+</p>
+!! end
+
+!! test
+Simple category
+!! options
+cat
+!! wikitext
+[[Category:MediaWiki User's Guide]]
+!! html/php
+cat=MediaWiki_User's_Guide sort=
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Category:MediaWiki_User's_Guide" data-parsoid='{"stx":"simple","a":{"href":"./Category:MediaWiki_User&#39;s_Guide"},"sa":{"href":"Category:MediaWiki User&#39;s Guide"}}'/>
+!! end
+
+!! test
+PAGESINCATEGORY invalid title fatal (r33546 fix)
+!! wikitext
+{{PAGESINCATEGORY:<bogus>}}
+!! html
+<p>0
+</p>
+!! end
+
+!! test
+Category with different sort key
+!! options
+cat
+!! wikitext
+[[Category:MediaWiki User's Guide|Foo]]
+!! html/php
+cat=MediaWiki_User's_Guide sort=Foo
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Category:MediaWiki_User's_Guide#Foo" data-parsoid='{"stx":"piped","a":{"href":"./Category:MediaWiki_User&#39;s_Guide"},"sa":{"href":"Category:MediaWiki User&#39;s Guide"}}'/>
+!! end
+
+!! test
+Category with identical sort key
+!! options
+cat
+!! wikitext
+[[Category:MediaWiki User's Guide|MediaWiki User's Guide]]
+!! html/php
+cat=MediaWiki_User's_Guide sort=MediaWiki User's Guide
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Category:MediaWiki_User's_Guide#MediaWiki%20User's%20Guide" data-parsoid='{"stx":"piped","a":{"href":"./Category:MediaWiki_User&#39;s_Guide"},"sa":{"href":"Category:MediaWiki User&#39;s Guide"}}'/>
+!! end
+
+!! test
+Category with empty sort key
+!! options
+cat
+pst
+!! wikitext
+[[Category:MediaWiki User's Guide|]]
+!! html/php
+[[Category:MediaWiki User's Guide|MediaWiki User's Guide]]
+!! end
+
+!! test
+Category with empty sort key and parentheses
+!! options
+cat
+pst
+!! wikitext
+[[Category:Foo (bar)|]]
+!! html/php
+[[Category:Foo (bar)|Foo]]
+!! end
+
+!! test
+Category with link tail
+!! options
+cat
+pst
+!! wikitext
+123[[Category:Foo]]456
+!! html/php
+123[[Category:Foo]]456
+!! end
+
+!! test
+Category with template
+!! options
+cat
+pst
+!! wikitext
+[[Category:{{echo|Foo}}]]
+!! html/php
+[[Category:{{echo|Foo}}]]
+!! end
+
+!! test
+Category with template in sort key
+!! options
+cat
+pst
+!! wikitext
+[[Category:Foo|{{echo|Bar}}]]
+!! html/php
+[[Category:Foo|{{echo|Bar}}]]
+!! end
+
+!! test
+Category with template in sort key and title
+!! options
+cat
+pst
+!! wikitext
+[[Category:{{echo|Foo}}|{{echo|Bar}}]]
+!! html/php
+[[Category:{{echo|Foo}}|{{echo|Bar}}]]
+!! end
+
+## We used to, but no longer wt2wt this test since the default serializer
+## will normalize all categories to serialize on their own line.
+## This wikitext usage is going to be fairly uncommon in production and
+## selser will take care of preserving formatting in those scenarios.
+!! test
+Category / paragraph interactions
+!! options
+parsoid=wt2html
+!! wikitext
+Foo [[Category:Baz]] Bar
+
+Foo [[Category:Baz]]
+Bar
+
+Foo
+[[Category:Baz]]
+Bar
+
+Foo
+[[Category:Baz]] Bar
+
+Foo
+[[Category:Baz]]
+ [[Category:Baz]]
+[[Category:Baz]]
+Bar
+
+[[Category:Baz]]
+ [[Category:Baz]]
+[[Category:Baz]]
+
+[[Category:Baz]]
+ {{echo|[[Category:Baz]]}}
+[[Category:Baz]]
+!! html/php
+<p>Foo Bar
+</p><p>Foo
+Bar
+</p><p>Foo
+Bar
+</p><p>Foo Bar
+</p><p>Foo
+Bar
+</p>
+!! html/parsoid
+<p>Foo <link rel="mw:PageProp/Category" href="./Category:Baz"/> Bar</p>
+<p>Foo <link rel="mw:PageProp/Category" href="./Category:Baz"/> Bar</p>
+<p>Foo <link rel="mw:PageProp/Category" href="./Category:Baz"/> Bar</p>
+<p>Foo <link rel="mw:PageProp/Category" href="./Category:Baz"/> Bar</p>
+<p>Foo <link rel="mw:PageProp/Category" href="./Category:Baz"/> <link rel="mw:PageProp/Category" href="./Category:Baz"/> <link rel="mw:PageProp/Category" href="./Category:Baz"/> Bar</p>
+<link rel="mw:PageProp/Category" href="./Category:Baz"/> <link rel="mw:PageProp/Category" href="./Category:Baz"/> <link rel="mw:PageProp/Category" href="./Category:Baz"/> <link rel="mw:PageProp/Category" href="./Category:Baz"/> <link rel="mw:PageProp/Category" href="./Category:Baz" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[Category:Baz]]"}},"i":0}}]}'/>
+<link rel="mw:PageProp/Category" href="./Category:Baz"/>
+!! end
+
+## We used to, but no longer wt2wt this test since the default serializer
+## will normalize all categories to serialize on their own line.
+## This wikitext usage is going to be fairly uncommon in production and
+## selser will take care of preserving formatting in those scenarios.
+##
+## The whitespace on the empty line is part of the test. Please do not delete
+!! test
+1. Categories and newlines: All preceding newlines should be suppressed (courtesy T2087)
+!! options
+parsoid=wt2html
+!! wikitext
+This
+
+[[Category:Foo]] and this should be part of same paragraph (not an indent-pre)
+
+{{echo|[[Category:Foo]] and so should this!}}
+!! html/php
+<p>This and this should be part of same paragraph (not an indent-pre) and so should this!
+</p>
+!! html/parsoid
+<p>This
+
+<link rel="mw:PageProp/Category" href="./Category:Foo"/> and this should be part of same paragraph (not an indent-pre)
+
+<link rel="mw:PageProp/Category" href="./Category:Foo" about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[Category:Foo]] and so should this!"}},"i":0}}]}'/><span about="#mwt1"> and so should this!</span></p>
+!! end
+
+## Parsoid will not try to wt2wt this while preserving newlines because
+## it suppresses excess newlines within list items -- and we don't want to
+## introduce a special case just for categories, which is, in reality somewhat
+## odd behavior -- categories are unlikely to be used in list items like this
+## in top-level pages and are only likely to show up in template-generated
+## list items where this RT-ing is a non-issue.
+##
+## The whitespace on the empty line is part of the test. Please do not delete
+!! test
+2. Categories and newlines: All preceding newlines should be suppressed (courtesy T2087)
+!! options
+parsoid=wt2html
+!! wikitext
+* This
+
+[[Category:Foo]] and this should be part of the same list item
+* So should this
+
+{{echo|[[Category:Foo]] and this should be part of the same list item}}
+!! html
+<ul><li>This and this should be part of the same list item</li>
+<li>So should this and this should be part of the same list item</li></ul>
+!! html/parsoid
+<ul>
+<li>This <link rel="mw:PageProp/Category" href="./Category:Foo"/> and this should be part of the same list item</li>
+<li>So should this <link rel="mw:PageProp/Category" href="./Category:Foo" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[Category:Foo]] and this should be part of the same list item"}},"i":0}}]}'/><span> and this should be part of the same list item</span></li>
+</ul>
+!! end
+
+## Newlines and categories that follow the last item of a list
+## are treated differently because this (list followed by categories)
+## is an extremely common pattern on wikis.
+!! test
+3. Categories and newlines: newline suppression for last list item should RT properly
+!! wikitext
+* a
+* b
+
+[[Category:Foo]]
+
+[[Category:Bar]]
+[[Category:Baz]]
+!! html/parsoid
+<ul><li> a</li>
+<li> b</li></ul>
+
+<link rel="mw:PageProp/Category" href="./Category:Foo" data-parsoid='{"stx":"simple","a":{"href":"./Category:Foo"},"sa":{"href":"Category:Foo"}}'/>
+
+<link rel="mw:PageProp/Category" href="./Category:Bar" data-parsoid='{"stx":"simple","a":{"href":"./Category:Bar"},"sa":{"href":"Category:Bar"}}'/>
+<link rel="mw:PageProp/Category" href="./Category:Baz" data-parsoid='{"stx":"simple","a":{"href":"./Category:Baz"},"sa":{"href":"Category:Baz"}}'/>
+!! end
+
+!! test
+4. Categories and newlines: newline suppression for last list item should RT properly
+!! wikitext
+* a
+**** b
+
+[[Category:Foo]]
+!! html/parsoid
+<ul><li> a
+<ul><li><ul><li><ul><li> b</li></ul></li></ul></li></ul></li></ul>
+
+<link rel="mw:PageProp/Category" href="./Category:Foo" data-parsoid='{"stx":"simple","a":{"href":"./Category:Foo"},"sa":{"href":"Category:Foo"}}'/>
+!! end
+
+## only wt2html for this to make sure the algo only applies to the rightmost path
+!! test
+5. Categories and newlines: migrateTrailingCategories dom pass should only run on the rightmost path of nested lists
+!! options
+parsoid=wt2html
+!! wikitext
+* a
+** b
+[[Category:Foo]]
+* c
+** d
+[[Category:Foo]]
+!! html/parsoid
+<ul><li> a
+<ul><li> b
+<link rel="mw:PageProp/Category" href="./Category:Foo" data-parsoid='{"stx":"simple","a":{"href":"./Category:Foo"},"sa":{"href":"Category:Foo"}}'/></li></ul></li>
+<li> c
+<ul><li> d</li></ul></li></ul>
+<link rel="mw:PageProp/Category" href="./Category:Foo" data-parsoid='{"stx":"simple","a":{"href":"./Category:Foo"},"sa":{"href":"Category:Foo"}}'/>
+!! end
+
+## We used to, but no longer wt2wt this test since the default serializer
+## will normalize all categories to serialize on their own line.
+## This wikitext usage is going to be fairly uncommon in production and
+## selser will take care of preserving formatting in those scenarios.
+!! test
+6. Categories and newlines: migrateTrailingCategories dom pass should not migrate categories not preceded by newlines
+!! options
+parsoid=wt2html
+!! wikitext
+* a [[Category:Foo]]
+!! html/parsoid
+<ul><li>a <link rel="mw:PageProp/Category" href="./Category:Foo" data-parsoid='{"stx":"simple","a":{"href":"./Category:Foo"},"sa":{"href":"Category:Foo"}}'/></li></ul>
+!! end
+
+# This test also demonstrates because of newline+category tunneling
+# through the list hander, template wrapping doesn't expand to the
+# containing list when the list item swallows the category.
+!! test
+7. Categories and newlines: migrateTrailingCategories dom pass should leave template content alone
+!! wikitext
+* {{echo|a
+[[Category:Foo]]}}
+!! html/parsoid
+<ul><li> <span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"a\n[[Category:Foo]]"}},"i":0}}]}'>a
+</span><link rel="mw:PageProp/Category" href="./Category:Foo" about="#mwt1" data-parsoid='{"stx":"simple","a":{"href":"./Category:Foo"},"sa":{"href":"Category:Foo"}}'/></li></ul>
+!! end
+
+!! test
+8. Categories and newlines: migrateTrailingCategories dom pass should not get tripped by intervening templates
+!! wikitext
+* a
+
+{{echo|[[Category:Foo]]
+[[Category:Bar]]}}
+[[Category:Baz]]
+!! html/parsoid
+<ul><li> a</li></ul>
+
+<link rel="mw:PageProp/Category" href="./Category:Foo" about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"stx":"simple","a":{"href":"./Category:Foo"},"sa":{"href":"Category:Foo"},"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[Category:Foo]]\n[[Category:Bar]]"}},"i":0}}]}'/><span about="#mwt1">
+</span><link rel="mw:PageProp/Category" href="./Category:Bar" about="#mwt1" data-parsoid='{"stx":"simple","a":{"href":"./Category:Bar"},"sa":{"href":"Category:Bar"}}'/>
+<link rel="mw:PageProp/Category" href="./Category:Baz" data-parsoid='{"stx":"simple","a":{"href":"./Category:Baz"},"sa":{"href":"Category:Baz"}}'/>
+!! end
+
+!! test
+9. Categories and newlines: should behave properly with linkprefix (T87753)
+!! options
+language=ar
+!! wikitext
+foo bar
+foo bar
+[[تصنيف:Foo]]
+[[تصنيف:Bar]]
+!! html/php
+<p>foo bar
+foo bar
+</p>
+!! html/parsoid
+<p>foo bar
+foo bar</p>
+<link rel="mw:PageProp/Category" href="./تصنيف:Foo"/>
+<link rel="mw:PageProp/Category" href="./تصنيف:Bar"/>
+!! end
+
+!! test
+10. No regressions on internal links following category (T174639)
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+[[Category:Foo]]<div>a
+
+[[Foo]]</div>
+!! html/php
+<div>a
+<a href="/wiki/Foo" title="Foo">Foo</a></div>
+
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Category:Foo"/><div>a
+
+<a rel="mw:WikiLink" href="./Foo" title="Foo">Foo</a></div>
+!! end
+
+# Note that Parsoid differs slightly from PHP due to T175421
+!! test
+11. Special case where only newlines separate links (T175416)
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+[[Category:Foo]]
+
+[[Foo]][[es:Alimento]]
+
+[[Foo]]
+!! html/php
+<p><br />
+<a href="/wiki/Foo" title="Foo">Foo</a>
+</p><p><a href="/wiki/Foo" title="Foo">Foo</a>
+</p>
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Category:Foo"/>
+
+<p><a rel="mw:WikiLink" href="./Foo" title="Foo">Foo</a></p><link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/Alimento"/>
+
+<p><a rel="mw:WikiLink" href="./Foo" title="Foo">Foo</a></p>
+!! end
+
+!! test
+Category links with multiple namespaces
+!! wikitext
+[[Category:Project:Foo]]
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Category:Project:Foo" />
+!! end
+
+!! test
+Parsoid: Serialize link to category page with colon escape
+!! options
+parsoid
+!! wikitext
+
+[[:Category:Foo]]
+[[:Category:Foo|Bar]]
+!! html
+<p>
+<a rel="mw:WikiLink" href="./Category:Foo" title="Category:Foo">Category:Foo</a>
+<a rel="mw:WikiLink" href="./Category:Foo" title="Category:Foo">Bar</a>
+</p>
+!! end
+
+# We used to, but no longer wt2wt this test since the default serializer
+# will normalize all categories to serialize on their own line.
+# This wikitext usage is going to be fairly uncommon in production and
+# selser will take care of preventing whitespace insertion if this
+# occurs in an article.
+#
+# html2html disabled for the same reason (whitespace insertion between
+# x and y).
+#
+# html2wt disabled because it localizes the "Category" namespace.
+!! test
+Link prefix/suffixes aren't applied to category links
+!! options
+parsoid=wt2html
+language=is
+!! wikitext
+x[[Category:Foo]]y
+!! html/php
+<p>xy
+</p>
+!! html/parsoid
+<p>x<link rel="mw:PageProp/Category" href="./Flokkur:Foo" data-parsoid=""/>y</p>
+!! end
+
+!! test
+Link prefix/suffixes aren't applied to language links
+!! options
+parsoid=wt2html
+language=is
+!! wikitext
+x[[es:Foo]]y
+!! html/php
+<p>xy
+</p>
+!! html/parsoid
+<p>x<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/Foo" data-parsoid=""/>y</p>
+!! end
+
+!! test
+Parsoid: Serialize link to file page with colon escape
+!! options
+parsoid
+!! wikitext
+
+[[:File:Foo.png]]
+[[:File:Foo.png|Bar]]
+!! html
+<p>
+<a rel="mw:WikiLink" href="./File:Foo.png" title="File:Foo.png">File:Foo.png</a>
+<a rel="mw:WikiLink" href="./File:Foo.png" title="File:Foo.png">Bar</a>
+</p>
+!! end
+
+!! test
+Parsoid: Serialize a genuine category link without colon escape
+!! options
+parsoid
+!! wikitext
+[[Category:Foo]]
+[[Category:Foo|Bar]]
+!! html
+<link rel="mw:PageProp/Category" href="./Category:Foo">
+<link rel="mw:PageProp/Category" href="./Category:Foo#Bar">
+!! end
+
+!! test
+Normalize hrefs properly before testing for invalid link targets (T72894)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Category:Toxine_bactérienne"/>
+!! wikitext
+[[Category:Toxine bactérienne]]
+!! end
+
+!! test
+Parsoid: Defaultsort
+!! wikitext
+{{DEFAULTSORT:Foo}}
+!! html/parsoid
+<meta property="mw:PageProp/categorydefaultsort" content="Foo"/>
+!! end
+
+# NOTE: mw:ExpandedAttrs is not the best typeof here. mw:Transclusion is better.
+# But, this is a limitation of our representation and is documented in
+# TemplateHandler.js in processSpecialMagicWord
+!! test
+Parsoid: Defaultsort (template-generated)
+!! wikitext
+{{{{echo|DEFAULTSORT}}:Foo}}
+!! html/parsoid
+<meta property="mw:PageProp/categorydefaultsort" content="Foo" about="#mwt3" typeof="mw:ExpandedAttrs" data-parsoid='{"src":"{{{{echo|DEFAULTSORT}}:Foo}}","dsr":[0,26,null,null]}' data-mw='{"attribs":[[{"txt":"content"},{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[2,22,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"DEFAULTSORT\"}},\"i\":0}}]}&#39;>DEFAULTSORT&lt;/span>:Foo"}]]}'/>
+!! end
+
+###
+### Inter-language links
+###
+!! test
+Interlanguage links
+!! options
+ill
+!! wikitext
+[[es:Alimento]]
+[[fr:Nourriture]]
+[[zh:食品]]
+!! html/php
+es:Alimento fr:Nourriture zh:食品
+!! html/parsoid
+<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/Alimento"/>
+<link rel="mw:PageProp/Language" href="http://fr.wikipedia.org/wiki/Nourriture"/>
+<link rel="mw:PageProp/Language" href="http://zh.wikipedia.org/wiki/食品"/>
+!! end
+
+!! test
+Duplicate interlanguage links (T26502)
+!! options
+ill
+!! wikitext
+[[es:1]]
+[[es:2]]
+[[fr:1]]
+[[fr:2]]
+!! html/php
+es:1 fr:1
+!! html/parsoid
+<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/1"/>
+<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/2"/>
+<link rel="mw:PageProp/Language" href="http://fr.wikipedia.org/wiki/1"/>
+<link rel="mw:PageProp/Language" href="http://fr.wikipedia.org/wiki/2"/>
+!! end
+
+###
+### Sections
+###
+!! test
+Basic section headings
+!! wikitext
+==Headline 1==
+Some text
+
+==Headline 2==
+More
+===Smaller headline===
+Blah blah
+!! html
+<h2><span class="mw-headline" id="Headline_1">Headline 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Headline 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>Some text
+</p>
+<h2><span class="mw-headline" id="Headline_2">Headline 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Headline 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>More
+</p>
+<h3><span class="mw-headline" id="Smaller_headline">Smaller headline</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: Smaller headline">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<p>Blah blah
+</p>
+!! end
+
+!! test
+Section headings with TOC
+!! wikitext
+==Headline 1==
+===Subheadline 1===
+=====Skipping a level=====
+======Skipping a level======
+
+==Headline 2==
+Some text
+===Another headline===
+!! html
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Headline_1"><span class="tocnumber">1</span> <span class="toctext">Headline 1</span></a>
+<ul>
+<li class="toclevel-2 tocsection-2"><a href="#Subheadline_1"><span class="tocnumber">1.1</span> <span class="toctext">Subheadline 1</span></a>
+<ul>
+<li class="toclevel-3 tocsection-3"><a href="#Skipping_a_level"><span class="tocnumber">1.1.1</span> <span class="toctext">Skipping a level</span></a>
+<ul>
+<li class="toclevel-4 tocsection-4"><a href="#Skipping_a_level_2"><span class="tocnumber">1.1.1.1</span> <span class="toctext">Skipping a level</span></a></li>
+</ul>
+</li>
+</ul>
+</li>
+</ul>
+</li>
+<li class="toclevel-1 tocsection-5"><a href="#Headline_2"><span class="tocnumber">2</span> <span class="toctext">Headline 2</span></a>
+<ul>
+<li class="toclevel-2 tocsection-6"><a href="#Another_headline"><span class="tocnumber">2.1</span> <span class="toctext">Another headline</span></a></li>
+</ul>
+</li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Headline_1">Headline 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Headline 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h3><span class="mw-headline" id="Subheadline_1">Subheadline 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Subheadline 1">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<h5><span class="mw-headline" id="Skipping_a_level">Skipping a level</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: Skipping a level">edit</a><span class="mw-editsection-bracket">]</span></span></h5>
+<h6><span class="mw-headline" id="Skipping_a_level_2">Skipping a level</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: Skipping a level">edit</a><span class="mw-editsection-bracket">]</span></span></h6>
+<h2><span class="mw-headline" id="Headline_2">Headline 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: Headline 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>Some text
+</p>
+<h3><span class="mw-headline" id="Another_headline">Another headline</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=6" title="Edit section: Another headline">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+
+!! end
+
+!! test
+TOC anchors don't collide
+!! wikitext
+__FORCETOC__
+==Headline 2==
+==Headline==
+==Headline 2==
+==Headline==
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Headline_2"><span class="tocnumber">1</span> <span class="toctext">Headline 2</span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#Headline"><span class="tocnumber">2</span> <span class="toctext">Headline</span></a></li>
+<li class="toclevel-1 tocsection-3"><a href="#Headline_2_2"><span class="tocnumber">3</span> <span class="toctext">Headline 2</span></a></li>
+<li class="toclevel-1 tocsection-4"><a href="#Headline_3"><span class="tocnumber">4</span> <span class="toctext">Headline</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Headline_2">Headline 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Headline 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Headline">Headline</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Headline">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Headline_2_2">Headline 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: Headline 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Headline_3">Headline</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: Headline">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+# perl -e 'print "="x$_," Level $_ heading","="x$_,"\n" for 1..10'
+# Parsoid html2wt direction adds <nowiki> for level 7 and up.
+!! test
+Handling of sections up to level 6 and beyond
+!! options
+parsoid=wt2html
+!! wikitext
+=Level 1 Heading=
+==Level 2 Heading==
+===Level 3 Heading===
+====Level 4 Heading====
+=====Level 5 Heading=====
+======Level 6 Heading======
+=======Level 7 Heading=======
+========Level 8 Heading========
+=========Level 9 Heading=========
+==========Level 10 Heading==========
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Level_1_Heading"><span class="tocnumber">1</span> <span class="toctext">Level 1 Heading</span></a>
+<ul>
+<li class="toclevel-2 tocsection-2"><a href="#Level_2_Heading"><span class="tocnumber">1.1</span> <span class="toctext">Level 2 Heading</span></a>
+<ul>
+<li class="toclevel-3 tocsection-3"><a href="#Level_3_Heading"><span class="tocnumber">1.1.1</span> <span class="toctext">Level 3 Heading</span></a>
+<ul>
+<li class="toclevel-4 tocsection-4"><a href="#Level_4_Heading"><span class="tocnumber">1.1.1.1</span> <span class="toctext">Level 4 Heading</span></a>
+<ul>
+<li class="toclevel-5 tocsection-5"><a href="#Level_5_Heading"><span class="tocnumber">1.1.1.1.1</span> <span class="toctext">Level 5 Heading</span></a>
+<ul>
+<li class="toclevel-6 tocsection-6"><a href="#Level_6_Heading"><span class="tocnumber">1.1.1.1.1.1</span> <span class="toctext">Level 6 Heading</span></a></li>
+<li class="toclevel-6 tocsection-7"><a href="#.3DLevel_7_Heading.3D"><span class="tocnumber">1.1.1.1.1.2</span> <span class="toctext">=Level 7 Heading=</span></a></li>
+<li class="toclevel-6 tocsection-8"><a href="#.3D.3DLevel_8_Heading.3D.3D"><span class="tocnumber">1.1.1.1.1.3</span> <span class="toctext">==Level 8 Heading==</span></a></li>
+<li class="toclevel-6 tocsection-9"><a href="#.3D.3D.3DLevel_9_Heading.3D.3D.3D"><span class="tocnumber">1.1.1.1.1.4</span> <span class="toctext">===Level 9 Heading===</span></a></li>
+<li class="toclevel-6 tocsection-10"><a href="#.3D.3D.3D.3DLevel_10_Heading.3D.3D.3D.3D"><span class="tocnumber">1.1.1.1.1.5</span> <span class="toctext">====Level 10 Heading====</span></a></li>
+</ul>
+</li>
+</ul>
+</li>
+</ul>
+</li>
+</ul>
+</li>
+</ul>
+</li>
+</ul>
+</div>
+
+<h1><span class="mw-headline" id="Level_1_Heading">Level 1 Heading</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Level 1 Heading">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<h2><span class="mw-headline" id="Level_2_Heading">Level 2 Heading</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Level 2 Heading">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h3><span class="mw-headline" id="Level_3_Heading">Level 3 Heading</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: Level 3 Heading">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<h4><span class="mw-headline" id="Level_4_Heading">Level 4 Heading</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: Level 4 Heading">edit</a><span class="mw-editsection-bracket">]</span></span></h4>
+<h5><span class="mw-headline" id="Level_5_Heading">Level 5 Heading</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: Level 5 Heading">edit</a><span class="mw-editsection-bracket">]</span></span></h5>
+<h6><span class="mw-headline" id="Level_6_Heading">Level 6 Heading</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=6" title="Edit section: Level 6 Heading">edit</a><span class="mw-editsection-bracket">]</span></span></h6>
+<h6><span class="mw-headline" id=".3DLevel_7_Heading.3D">=Level 7 Heading=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=7" title="Edit section: =Level 7 Heading=">edit</a><span class="mw-editsection-bracket">]</span></span></h6>
+<h6><span class="mw-headline" id=".3D.3DLevel_8_Heading.3D.3D">==Level 8 Heading==</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=8" title="Edit section: ==Level 8 Heading==">edit</a><span class="mw-editsection-bracket">]</span></span></h6>
+<h6><span class="mw-headline" id=".3D.3D.3DLevel_9_Heading.3D.3D.3D">===Level 9 Heading===</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=9" title="Edit section: ===Level 9 Heading===">edit</a><span class="mw-editsection-bracket">]</span></span></h6>
+<h6><span class="mw-headline" id=".3D.3D.3D.3DLevel_10_Heading.3D.3D.3D.3D">====Level 10 Heading====</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=10" title="Edit section: ====Level 10 Heading====">edit</a><span class="mw-editsection-bracket">]</span></span></h6>
+
+!! html/parsoid
+<h1 id="Level_1_Heading" data-parsoid='{}'>Level 1 Heading</h1>
+<h2 id="Level_2_Heading" data-parsoid='{}'>Level 2 Heading</h2>
+<h3 id="Level_3_Heading" data-parsoid='{}'>Level 3 Heading</h3>
+<h4 id="Level_4_Heading" data-parsoid='{}'>Level 4 Heading</h4>
+<h5 id="Level_5_Heading" data-parsoid='{}'>Level 5 Heading</h5>
+<h6 id="Level_6_Heading" data-parsoid='{}'>Level 6 Heading</h6>
+<h6 id="=Level_7_Heading=" data-parsoid='{}'><span id=".3DLevel_7_Heading.3D" typeof="mw:FallbackId"></span>=Level 7 Heading=</h6>
+<h6 id="==Level_8_Heading==" data-parsoid='{}'><span id=".3D.3DLevel_8_Heading.3D.3D" typeof="mw:FallbackId"></span>==Level 8 Heading==</h6>
+<h6 id="===Level_9_Heading===" data-parsoid='{}'><span id=".3D.3D.3DLevel_9_Heading.3D.3D.3D" typeof="mw:FallbackId"></span>===Level 9 Heading===</h6>
+<h6 id="====Level_10_Heading====" data-parsoid='{}'><span id=".3D.3D.3D.3DLevel_10_Heading.3D.3D.3D.3D" typeof="mw:FallbackId"></span>====Level 10 Heading====</h6>
+!! end
+
+!! test
+TOC regression (T11764)
+!! wikitext
+==title 1==
+===title 1.1===
+====title 1.1.1====
+===title 1.2===
+==title 2==
+===title 2.1===
+!! html
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#title_1"><span class="tocnumber">1</span> <span class="toctext">title 1</span></a>
+<ul>
+<li class="toclevel-2 tocsection-2"><a href="#title_1.1"><span class="tocnumber">1.1</span> <span class="toctext">title 1.1</span></a>
+<ul>
+<li class="toclevel-3 tocsection-3"><a href="#title_1.1.1"><span class="tocnumber">1.1.1</span> <span class="toctext">title 1.1.1</span></a></li>
+</ul>
+</li>
+<li class="toclevel-2 tocsection-4"><a href="#title_1.2"><span class="tocnumber">1.2</span> <span class="toctext">title 1.2</span></a></li>
+</ul>
+</li>
+<li class="toclevel-1 tocsection-5"><a href="#title_2"><span class="tocnumber">2</span> <span class="toctext">title 2</span></a>
+<ul>
+<li class="toclevel-2 tocsection-6"><a href="#title_2.1"><span class="tocnumber">2.1</span> <span class="toctext">title 2.1</span></a></li>
+</ul>
+</li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="title_1">title 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: title 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h3><span class="mw-headline" id="title_1.1">title 1.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: title 1.1">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<h4><span class="mw-headline" id="title_1.1.1">title 1.1.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: title 1.1.1">edit</a><span class="mw-editsection-bracket">]</span></span></h4>
+<h3><span class="mw-headline" id="title_1.2">title 1.2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: title 1.2">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<h2><span class="mw-headline" id="title_2">title 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: title 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h3><span class="mw-headline" id="title_2.1">title 2.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=6" title="Edit section: title 2.1">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+
+!! end
+
+!! test
+TOC for heading containing <span id="..."></span> (T96153)
+!! wikitext
+__FORCETOC__
+==<span id="old-anchor"></span>New title==
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#New_title"><span class="tocnumber">1</span> <span class="toctext">New title</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="New_title"><span id="old-anchor"></span>New title</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: New title">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+!! test
+TOC with wgMaxTocLevel=3 (T8204)
+!! options
+wgMaxTocLevel=3
+!! wikitext
+==title 1==
+===title 1.1===
+====title 1.1.1====
+===title 1.2===
+==title 2==
+===title 2.1===
+!! html
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#title_1"><span class="tocnumber">1</span> <span class="toctext">title 1</span></a>
+<ul>
+<li class="toclevel-2 tocsection-2"><a href="#title_1.1"><span class="tocnumber">1.1</span> <span class="toctext">title 1.1</span></a></li>
+<li class="toclevel-2 tocsection-4"><a href="#title_1.2"><span class="tocnumber">1.2</span> <span class="toctext">title 1.2</span></a></li>
+</ul>
+</li>
+<li class="toclevel-1 tocsection-5"><a href="#title_2"><span class="tocnumber">2</span> <span class="toctext">title 2</span></a>
+<ul>
+<li class="toclevel-2 tocsection-6"><a href="#title_2.1"><span class="tocnumber">2.1</span> <span class="toctext">title 2.1</span></a></li>
+</ul>
+</li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="title_1">title 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: title 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h3><span class="mw-headline" id="title_1.1">title 1.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: title 1.1">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<h4><span class="mw-headline" id="title_1.1.1">title 1.1.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: title 1.1.1">edit</a><span class="mw-editsection-bracket">]</span></span></h4>
+<h3><span class="mw-headline" id="title_1.2">title 1.2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: title 1.2">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<h2><span class="mw-headline" id="title_2">title 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: title 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h3><span class="mw-headline" id="title_2.1">title 2.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=6" title="Edit section: title 2.1">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+
+!! end
+
+!! test
+TOC with wgMaxTocLevel=3 and two level four headings (T8204)
+!! options
+wgMaxTocLevel=3
+!! wikitext
+==Section 1==
+===Section 1.1===
+====Section 1.1.1====
+====Section 1.1.1.1====
+==Section 2==
+!! html
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Section_1"><span class="tocnumber">1</span> <span class="toctext">Section 1</span></a>
+<ul>
+<li class="toclevel-2 tocsection-2"><a href="#Section_1.1"><span class="tocnumber">1.1</span> <span class="toctext">Section 1.1</span></a></li>
+</ul>
+</li>
+<li class="toclevel-1 tocsection-5"><a href="#Section_2"><span class="tocnumber">2</span> <span class="toctext">Section 2</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Section_1">Section 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h3><span class="mw-headline" id="Section_1.1">Section 1.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Section 1.1">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<h4><span class="mw-headline" id="Section_1.1.1">Section 1.1.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: Section 1.1.1">edit</a><span class="mw-editsection-bracket">]</span></span></h4>
+<h4><span class="mw-headline" id="Section_1.1.1.1">Section 1.1.1.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: Section 1.1.1.1">edit</a><span class="mw-editsection-bracket">]</span></span></h4>
+<h2><span class="mw-headline" id="Section_2">Section 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+
+!! test
+Resolving duplicate section names
+!! wikitext
+==Foo bar==
+==Foo bar==
+!! html
+<h2><span class="mw-headline" id="Foo_bar">Foo bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Foo bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Foo_bar_2">Foo bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Foo bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+!! test
+Resolving duplicate section names with differing case (T12721)
+!! wikitext
+==Foo bar==
+==Foo Bar==
+!! html
+<h2><span class="mw-headline" id="Foo_bar">Foo bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Foo bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Foo_Bar_2">Foo Bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Foo Bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+!! article
+Template:sections
+!! text
+===Section 1===
+==Section 2==
+!! endarticle
+
+!! test
+Template with sections, __NOTOC__
+!! wikitext
+__NOTOC__
+==Section 0==
+{{sections}}
+==Section 4==
+!! html
+<h2><span class="mw-headline" id="Section_0">Section 0</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Section 0">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h3><span class="mw-headline" id="Section_1">Section 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Template:Sections&amp;action=edit&amp;section=T-1" title="Template:Sections">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<h2><span class="mw-headline" id="Section_2">Section 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Template:Sections&amp;action=edit&amp;section=T-2" title="Template:Sections">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Section_4">Section 4</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Section 4">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+!! test
+__NOEDITSECTION__ keyword
+!! wikitext
+__NOEDITSECTION__
+==Section 1==
+==Section 2==
+!! html
+<h2><span class="mw-headline" id="Section_1">Section 1</span></h2>
+<h2><span class="mw-headline" id="Section_2">Section 2</span></h2>
+
+!! end
+
+!! test
+Link inside a section heading
+!! wikitext
+==Section with a [[Main Page|link]] in it==
+!! html
+<h2><span class="mw-headline" id="Section_with_a_link_in_it">Section with a <a href="/wiki/Main_Page" title="Main Page">link</a> in it</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Section with a link in it">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+!! test
+TOC regression (T14077)
+!! wikitext
+__TOC__
+==title 1==
+===title 1.1===
+==title 2==
+!! html
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#title_1"><span class="tocnumber">1</span> <span class="toctext">title 1</span></a>
+<ul>
+<li class="toclevel-2 tocsection-2"><a href="#title_1.1"><span class="tocnumber">1.1</span> <span class="toctext">title 1.1</span></a></li>
+</ul>
+</li>
+<li class="toclevel-1 tocsection-3"><a href="#title_2"><span class="tocnumber">2</span> <span class="toctext">title 2</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="title_1">title 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: title 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h3><span class="mw-headline" id="title_1.1">title 1.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: title 1.1">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<h2><span class="mw-headline" id="title_2">title 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: title 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+!! test
+T3219 URL next to image (good)
+!! wikitext
+http://example.com [[File:Foobar.jpg]]
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a> <a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a> <figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+!!end
+
+# Parsoid doesn't wt2wt this cleanly because it adds <nowiki>s.
+!! test
+Short headings with trailing space should match behavior of Parser::doHeadings (T21910)
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+===
+The line above must have a trailing space!
+=== <!--
+--> <!-- -->
+But just in case it doesn't...
+!! html/php
+<h1><span class="mw-headline" id=".3D">=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: =">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<p>The line above must have a trailing space!
+</p>
+<h1><span class="mw-headline" id=".3D_2">=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: =">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<p>But just in case it doesn't...
+</p>
+!! html/parsoid
+<h1 id="="><span id=".3D" typeof="mw:FallbackId"></span>=</h1>
+<p>The line above must have a trailing space!</p>
+<h1 id="=_2"><span id=".3D_2" typeof="mw:FallbackId"></span>=</h1> <!--
+--> <!-- -->
+<p>But just in case it doesn't...</p>
+!! end
+
+!! test
+Header with special characters (T27462)
+!! wikitext
+The tooltips shall not show entities to the user (ie. be double escaped)
+
+==text > text==
+section 1
+
+==text < text==
+section 2
+
+==text & text==
+section 3
+
+==text ' text==
+section 4
+
+==text " text==
+section 5
+!! html/php
+<p>The tooltips shall not show entities to the user (ie. be double escaped)
+</p>
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#text_.3E_text"><span class="tocnumber">1</span> <span class="toctext">text &gt; text</span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#text_.3C_text"><span class="tocnumber">2</span> <span class="toctext">text &lt; text</span></a></li>
+<li class="toclevel-1 tocsection-3"><a href="#text_.26_text"><span class="tocnumber">3</span> <span class="toctext">text &amp; text</span></a></li>
+<li class="toclevel-1 tocsection-4"><a href="#text_.27_text"><span class="tocnumber">4</span> <span class="toctext">text ' text</span></a></li>
+<li class="toclevel-1 tocsection-5"><a href="#text_.22_text"><span class="tocnumber">5</span> <span class="toctext">text " text</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="text_.3E_text">text &gt; text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: text &gt; text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>section 1
+</p>
+<h2><span class="mw-headline" id="text_.3C_text">text &lt; text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: text &lt; text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>section 2
+</p>
+<h2><span class="mw-headline" id="text_.26_text">text &amp; text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: text &amp; text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>section 3
+</p>
+<h2><span class="mw-headline" id="text_.27_text">text ' text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: text &#039; text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>section 4
+</p>
+<h2><span class="mw-headline" id="text_.22_text">text " text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: text &quot; text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>section 5
+</p>
+!! html/parsoid
+<p>The tooltips shall not show entities to the user (ie. be double escaped)</p>
+
+<h2 id="text_>_text"><span id="text_.3E_text" typeof="mw:FallbackId"></span>text > text</h2>
+<p>section 1</p>
+
+<h2 id="text_&lt;_text"><span id="text_.3C_text" typeof="mw:FallbackId"></span>text &lt; text</h2>
+<p>section 2</p>
+
+<h2 id="text_&amp;_text"><span id="text_.26_text" typeof="mw:FallbackId"></span>text &amp; text</h2>
+<p>section 3</p>
+
+<h2 id="text_'_text"><span id="text_.27_text" typeof="mw:FallbackId"></span>text ' text</h2>
+<p>section 4</p>
+
+<h2 id='text_"_text'><span id="text_.22_text" typeof="mw:FallbackId"></span>text " text</h2>
+<p>section 5</p>
+!! end
+
+!! test
+Header with space, plus and underscore as entity
+!! wikitext
+Id should not contain + for spaces
+
+==Space between Text==
+section 1
+
+==Space-Entity&#32;between&#32;Text==
+section 2
+
+==Plus+between+Text==
+section 3
+
+==Plus-Entity&#43;between&#43;Text==
+section 4
+
+==Underscore_between_Text==
+section 5
+
+==Underscore-Entity&#95;between&#95;Text==
+section 6
+
+[[#Space between Text]]
+[[#Space-Entity&#32;between&#32;Text]]
+[[#Plus+between+Text]]
+[[#Plus-Entity&#43;between&#43;Text]]
+[[#Underscore_between_Text]]
+[[#Underscore-Entity&#95;between&#95;Text]]
+!! html/php
+<p>Id should not contain + for spaces
+</p>
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Space_between_Text"><span class="tocnumber">1</span> <span class="toctext">Space between Text</span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#Space-Entity_between_Text"><span class="tocnumber">2</span> <span class="toctext">Space-Entity&#32;between&#32;Text</span></a></li>
+<li class="toclevel-1 tocsection-3"><a href="#Plus.2Bbetween.2BText"><span class="tocnumber">3</span> <span class="toctext">Plus+between+Text</span></a></li>
+<li class="toclevel-1 tocsection-4"><a href="#Plus-Entity.2Bbetween.2BText"><span class="tocnumber">4</span> <span class="toctext">Plus-Entity&#43;between&#43;Text</span></a></li>
+<li class="toclevel-1 tocsection-5"><a href="#Underscore_between_Text"><span class="tocnumber">5</span> <span class="toctext">Underscore_between_Text</span></a></li>
+<li class="toclevel-1 tocsection-6"><a href="#Underscore-Entity_between_Text"><span class="tocnumber">6</span> <span class="toctext">Underscore-Entity&#95;between&#95;Text</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Space_between_Text">Space between Text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Space between Text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>section 1
+</p>
+<h2><span class="mw-headline" id="Space-Entity_between_Text">Space-Entity&#32;between&#32;Text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Space-Entity between Text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>section 2
+</p>
+<h2><span class="mw-headline" id="Plus.2Bbetween.2BText">Plus+between+Text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: Plus+between+Text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>section 3
+</p>
+<h2><span class="mw-headline" id="Plus-Entity.2Bbetween.2BText">Plus-Entity&#43;between&#43;Text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: Plus-Entity+between+Text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>section 4
+</p>
+<h2><span class="mw-headline" id="Underscore_between_Text">Underscore_between_Text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: Underscore between Text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>section 5
+</p>
+<h2><span class="mw-headline" id="Underscore-Entity_between_Text">Underscore-Entity&#95;between&#95;Text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=6" title="Edit section: Underscore-Entity_between_Text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>section 6
+</p><p><a href="#Space_between_Text">#Space between Text</a>
+<a href="#Space-Entity_between_Text">#Space-Entity&#32;between&#32;Text</a>
+<a href="#Plus.2Bbetween.2BText">#Plus+between+Text</a>
+<a href="#Plus-Entity.2Bbetween.2BText">#Plus-Entity&#43;between&#43;Text</a>
+<a href="#Underscore_between_Text">#Underscore_between_Text</a>
+<a href="#Underscore-Entity_between_Text">#Underscore-Entity&#95;between&#95;Text</a>
+</p>
+!! html/parsoid
+<p>Id should not contain + for spaces</p>
+
+<h2 id="Space_between_Text">Space between Text</h2>
+<p>section 1</p>
+
+<h2 id="Space-Entity_between_Text">Space-Entity<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#32;","srcContent":" "}'> </span>between<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#32;","srcContent":" "}'> </span>Text</h2>
+<p>section 2</p>
+
+<h2 id="Plus+between+Text"><span id="Plus.2Bbetween.2BText" typeof="mw:FallbackId"></span>Plus+between+Text</h2>
+<p>section 3</p>
+
+<h2 id="Plus-Entity+between+Text"><span id="Plus-Entity.2Bbetween.2BText" typeof="mw:FallbackId"></span>Plus-Entity<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#43;","srcContent":"+"}'>+</span>between<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#43;","srcContent":"+"}'>+</span>Text</h2>
+<p>section 4</p>
+
+<h2 id="Underscore_between_Text">Underscore_between_Text</h2>
+<p>section 5</p>
+
+<h2 id="Underscore-Entity_between_Text">Underscore-Entity<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#95;","srcContent":"_"}'>_</span>between<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#95;","srcContent":"_"}'>_</span>Text</h2>
+<p>section 6</p>
+
+<p><a rel="mw:WikiLink" href="./Main_Page#Space_between_Text" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Space_between_Text"},"sa":{"href":"#Space between Text"}}'>#Space between Text</a>
+<a rel="mw:WikiLink" href="./Main_Page#Space-Entity_between_Text" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Space-Entity_between_Text"},"sa":{"href":"#Space-Entity&amp;#32;between&amp;#32;Text"}}'>#Space-Entity between Text</a>
+<a rel="mw:WikiLink" href="./Main_Page#Plus+between+Text" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Plus+between+Text"},"sa":{"href":"#Plus+between+Text"}}'>#Plus+between+Text</a>
+<a rel="mw:WikiLink" href="./Main_Page#Plus-Entity+between+Text" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Plus-Entity+between+Text"},"sa":{"href":"#Plus-Entity&amp;#43;between&amp;#43;Text"}}'>#Plus-Entity+between+Text</a>
+<a rel="mw:WikiLink" href="./Main_Page#Underscore_between_Text" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Underscore_between_Text"},"sa":{"href":"#Underscore_between_Text"}}'>#Underscore_between_Text</a>
+<a rel="mw:WikiLink" href="./Main_Page#Underscore-Entity_between_Text" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Underscore-Entity_between_Text"},"sa":{"href":"#Underscore-Entity&amp;#95;between&amp;#95;Text"}}'>#Underscore-Entity_between_Text</a></p>
+!! end
+
+# Parsoid html2wt disabled because it adds padding spaces around =
+!! test
+Headers with excess '=' characters
+(Are similar tests necessary beyond the 1st level?)
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+=foo==
+==foo=
+=''italic'' heading==
+==''italic'' heading=
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#foo.3D"><span class="tocnumber">1</span> <span class="toctext">foo=</span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#.3Dfoo"><span class="tocnumber">2</span> <span class="toctext">=foo</span></a></li>
+<li class="toclevel-1 tocsection-3"><a href="#italic_heading.3D"><span class="tocnumber">3</span> <span class="toctext"><i>italic</i> heading=</span></a></li>
+<li class="toclevel-1 tocsection-4"><a href="#.3Ditalic_heading"><span class="tocnumber">4</span> <span class="toctext">=<i>italic</i> heading</span></a></li>
+</ul>
+</div>
+
+<h1><span class="mw-headline" id="foo.3D">foo=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: foo=">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<h1><span class="mw-headline" id=".3Dfoo">=foo</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: =foo">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<h1><span class="mw-headline" id="italic_heading.3D"><i>italic</i> heading=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: italic heading=">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<h1><span class="mw-headline" id=".3Ditalic_heading">=<i>italic</i> heading</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: =italic heading">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+
+!! html/parsoid
+<h1 id="foo="><span id="foo.3D" typeof="mw:FallbackId"></span>foo=</h1>
+<h1 id="=foo"><span id=".3Dfoo" typeof="mw:FallbackId"></span>=foo</h1>
+<h1 id="italic_heading="><span id="italic_heading.3D" typeof="mw:FallbackId"></span><i>italic</i> heading=</h1>
+<h1 id="=italic_heading"><span id=".3Ditalic_heading" typeof="mw:FallbackId"></span>=<i>italic</i> heading</h1>
+!! end
+
+!! test
+HTML headers vs TOC (T25393)
+(__NOEDITSECTION__ for clearer output, doesn't matter here)
+!! wikitext
+<h1>Header 1</h1>
+==Header 1.1==
+==Header 1.2==
+
+<h1>Header 2
+</h1>
+==Header 2.1==
+==Header 2.2==
+__NOEDITSECTION__
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1"><a href="#Header_1"><span class="tocnumber">1</span> <span class="toctext">Header 1</span></a>
+<ul>
+<li class="toclevel-2 tocsection-1"><a href="#Header_1.1"><span class="tocnumber">1.1</span> <span class="toctext">Header 1.1</span></a></li>
+<li class="toclevel-2 tocsection-2"><a href="#Header_1.2"><span class="tocnumber">1.2</span> <span class="toctext">Header 1.2</span></a></li>
+</ul>
+</li>
+<li class="toclevel-1"><a href="#Header_2"><span class="tocnumber">2</span> <span class="toctext">Header 2</span></a>
+<ul>
+<li class="toclevel-2 tocsection-3"><a href="#Header_2.1"><span class="tocnumber">2.1</span> <span class="toctext">Header 2.1</span></a></li>
+<li class="toclevel-2 tocsection-4"><a href="#Header_2.2"><span class="tocnumber">2.2</span> <span class="toctext">Header 2.2</span></a></li>
+</ul>
+</li>
+</ul>
+</div>
+
+<h1><span class="mw-headline" id="Header_1">Header 1</span></h1>
+<h2><span class="mw-headline" id="Header_1.1">Header 1.1</span></h2>
+<h2><span class="mw-headline" id="Header_1.2">Header 1.2</span></h2>
+<h1><span class="mw-headline" id="Header_2">Header 2
+</span></h1>
+<h2><span class="mw-headline" id="Header_2.1">Header 2.1</span></h2>
+<h2><span class="mw-headline" id="Header_2.2">Header 2.2</span></h2>
+
+!! html/parsoid
+<h1 id="Header_1" data-parsoid='{"stx":"html"}'>Header 1</h1>
+<h2 id="Header_1.1" data-parsoid='{}'>Header 1.1</h2>
+<h2 id="Header_1.2" data-parsoid='{}'>Header 1.2</h2>
+
+<h1 id="Header_2" data-parsoid='{"stx":"html"}'>Header 2
+</h1>
+<h2 id="Header_2.1" data-parsoid='{}'>Header 2.1</h2>
+<h2 id="Header_2.2" data-parsoid='{}'>Header 2.2</h2>
+<meta property="mw:PageProp/noeditsection"/>
+!! end
+
+!! test
+Single-line or multiline-comments can follow headings
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+==foo==<!---->
+==bar==<!--c1-->
+==baz==<!--
+c2
+c3-->
+!! html/php
+<h2><span class="mw-headline" id="foo">foo</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: foo">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="bar">bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="baz">baz</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: baz">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<h2 id="foo">foo</h2><!---->
+<h2 id="bar">bar</h2><!--c1-->
+<h2 id="baz">baz</h2><!--
+c2
+c3-->
+!! end
+
+!! test
+T3219 URL next to image (broken)
+!! wikitext
+http://example.com[[File:Foobar.jpg]]
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a><figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+!!end
+
+!! test
+T3186 news: in the middle of text
+!! wikitext
+http://en.wikinews.org/wiki/Wikinews:Workplace
+!! html
+<p><a rel="nofollow" class="external free" href="http://en.wikinews.org/wiki/Wikinews:Workplace">http://en.wikinews.org/wiki/Wikinews:Workplace</a>
+</p>
+!!end
+
+
+!! test
+Namespaced link must have a title
+!! wikitext
+[[Project:]]
+!! html
+<p>[[Project:]]
+</p>
+!!end
+
+!! test
+Namespaced link must have a title (bad fragment version)
+!! wikitext
+[[Project:#fragment]]
+!! html
+<p>[[Project:#fragment]]
+</p>
+!!end
+
+
+###
+### HTML tags and HTML attributes
+###
+
+!! test
+div with no attributes
+!! wikitext
+<div>HTML rocks</div>
+!! html
+<div>HTML rocks</div>
+
+!! end
+
+!! test
+div with double-quoted attribute
+!! wikitext
+<div id="rock">HTML rocks</div>
+!! html
+<div id="rock">HTML rocks</div>
+
+!! end
+
+!! test
+div with single-quoted attribute
+!! wikitext
+<div id='rock'>HTML rocks</div>
+!! html
+<div id="rock">HTML rocks</div>
+
+!! end
+
+!! test
+div with unquoted attribute
+!! wikitext
+<div id=rock>HTML rocks</div>
+!! html
+<div id="rock">HTML rocks</div>
+
+!! end
+
+!! test
+div with illegal double attributes
+!! wikitext
+<div id="a" id="b">HTML rocks</div>
+!! html
+<div id="b">HTML rocks</div>
+
+!!end
+
+!! test
+div with empty attribute value, space before equals
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<div class =>HTML rocks</div>
+!! html/php
+<div class="">HTML rocks</div>
+
+!! html/parsoid
+<div class="" data-parsoid='{"stx":"html"}'>HTML rocks</div>
+!! end
+
+!! test
+div with multiple empty attribute values
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<div id= title=>HTML rocks</div>
+!! html/php
+<div id="title=">HTML rocks</div>
+
+!! html/parsoid
+<div id="title=" data-parsoid='{"stx":"html"}'>HTML rocks</div>
+!! end
+
+# FIXME Parsoid doesn't actually match PHP here.
+# Probably we should use the synthetic <foo /> or <indicator>
+# extensions for this test, which are enabled when running parser tests.
+!! test
+Extension tag in attribute value
+!! wikitext
+<span title="<translate>123</translate>">ok</span>
+!! html/php+disabled
+<p>&lt;span title="&lt;translate&gt;123&lt;/translate&gt;"&gt;ok&lt;/span&gt;
+</p>
+!! html/parsoid
+<p><span title="123" about="#mwt4" typeof="mw:ExpandedAttrs" data-parsoid='{"stx":"html","a":{"title":"123"},"sa":{"title":"&lt;translate>123&lt;/translate>"}}' data-mw='{"attribs":[[{"txt":"title"},{"html":"&lt;translate typeof=\"mw:Extension/translate\" about=\"#mwt3\" data-parsoid=&apos;{\"dsr\":[13,39,2,2]}&apos; data-mw=&apos;{\"name\":\"translate\",\"attrs\":{},\"body\":{\"extsrc\":\"123\"}}&apos;>123&lt;/translate>"}]]}'>ok</span></p>
+!! end
+
+!! test
+table with multiple empty attribute values
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+{| title= id=
+|hi
+|}
+!! html/php
+<table title="id=">
+<tr>
+<td>hi
+</td></tr></table>
+
+!! html/parsoid
+<table title="id=">
+<tbody><tr><td>hi</td></tr>
+</tbody></table>
+!! end
+
+!! test
+div with braces in attribute value
+!! wikitext
+<div title="{}">Foo</div>
+!! html/php
+<div title="&#123;&#125;">Foo</div>
+
+!! html/parsoid
+<div title="{}">Foo</div>
+!! end
+
+!! test
+div with empty attribute value, no space before equals
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<div class=>HTML rocks</div>
+!! html/php
+<div class="">HTML rocks</div>
+
+!! html/parsoid
+<div class="">HTML rocks</div>
+!! end
+
+!! test
+HTML multiple attributes correction
+!! wikitext
+<p class="error" class="awesome">Awesome!</p>
+!! html
+<p class="awesome">Awesome!</p>
+
+!!end
+
+!! test
+Table multiple attributes correction
+!! wikitext
+{|
+!+ class="error" class="awesome"|status
+|}
+!! html
+<table>
+<tr>
+<th class="awesome">status
+</th></tr></table>
+
+!!end
+
+!! test
+DIV IN UPPERCASE
+!! wikitext
+<DIV ID="x">HTML ROCKS</DIV>
+!! html
+<div id="x">HTML ROCKS</div>
+
+!!end
+
+!! test
+Non-ASCII pseudo-tags are rendered as text
+!! wikitext
+<khyô>
+!! html
+<p>&lt;khyô&gt;
+</p>
+!! end
+
+!! test
+Pseudo-tag with URL 'name' renders as url link
+!! wikitext
+<http://example.com/>
+!! html
+<p>&lt;<a rel="nofollow" class="external free" href="http://example.com/">http://example.com/</a>&gt;
+</p>
+!! end
+
+!! test
+text with amp in the middle of nowhere
+!! wikitext
+Remember AT&T?
+!! html
+<p>Remember AT&amp;T?
+</p>
+!! end
+
+!! test
+text with character entity: eacute
+!! wikitext
+I always thought &eacute; was a cute letter.
+!! html+tidy
+<p>I always thought &#233; was a cute letter.
+</p>
+!! end
+
+!! test
+text with entity-escaped character entity-like string: eacute
+!! wikitext
+I always thought &amp;eacute; was a cute letter.
+!! html
+<p>I always thought &amp;eacute; was a cute letter.
+</p>
+!! end
+
+!! test
+text with undefined character entity: xacute
+!! wikitext
+I always thought &xacute; was a cute letter.
+!! html
+<p>I always thought &amp;xacute; was a cute letter.
+</p>
+!! end
+
+!! test
+HTML5 tags
+!! wikitext
+<data value="5">five</data>
+<time datetime="2000-01-01T00:00Z">The new millenium started</time>
+<mark>This highlighted text</mark>
+!! html
+<p><data value="5">five</data>
+<time datetime="2000-01-01T00:00Z">The new millenium started</time>
+<mark>This highlighted text</mark>
+</p>
+!! end
+
+!! test
+HTML tag with leading space is parsed as text
+!! wikitext
+< div>foo< /div>
+!! html
+<p>&lt; div&gt;foo&lt; /div&gt;
+</p>
+!! end
+
+## Don't expect Parsoid and PHP to match, since PHP isn't exactly following
+## the HTML5 parsing spec.
+!! test
+Element with broken attribute syntax
+!! options
+parsoid=wt2html
+!! wikitext
+<div style=" style="123">hi</div>
+<div =>ho</div>
+!! html/php
+<div style="123">hi</div>
+<div>ho</div>
+
+!! html/parsoid
+<div style=" style=" data-parsoid='{"stx":"html","a":{"123\"":null},"sa":{"123\"":""}}'>hi</div>
+<div data-parsoid='{"stx":"html","a":{"=":null},"sa":{"=":""}}'>ho</div>
+!! end
+
+###
+### Nesting tests (see T43545, T52604, T53081)
+###
+
+# This test case is fixed in Parsoid by domino 1.0.12. (T52604)
+# Note that html2wt is considerably more difficult if we use <b> in
+# the test case, instead of <small>
+!! test
+Ensure that HTML adoption agency algorithm is properly implemented.
+!! wikitext
+<small>X<small>Y</small>Z</small>
+!! html
+<p><small>X<small>Y</small>Z</small>
+</p>
+!! end
+
+# This was T43545 in the PHP parser.
+!! test
+Nesting of <kbd>
+!! wikitext
+<kbd>X<kbd>Y</kbd>Z</kbd>
+!! html+tidy
+<p><kbd>X<kbd>Y</kbd>Z</kbd>
+</p>
+!! end
+
+# The following cases were T53081 in the PHP parser.
+# Note that there are some other nestable tags (b, i, etc) which are
+# not covered; see T53081 for discussion.
+
+!! test
+Nesting of <em>
+!! wikitext
+<em>X<em>Y</em>Z</em>
+!! html+tidy
+<p><em>X<em>Y</em>Z</em>
+</p>
+!! end
+
+!! test
+Nesting of <strong>
+!! wikitext
+<strong>X<strong>Y</strong>Z</strong>
+!! html+tidy
+<p><strong>X<strong>Y</strong>Z</strong>
+</p>
+!! end
+
+!! test
+Nesting of <q>
+!! wikitext
+<q>X<q>Y</q>Z</q>
+!! html+tidy
+<p><q>X<q>Y</q>Z</q>
+</p>
+!! end
+
+!! test
+Nesting of <ruby>
+!! wikitext
+<ruby>X<ruby>Y</ruby>Z</ruby>
+!! html
+<p><ruby>X<ruby>Y</ruby>Z</ruby>
+</p>
+!! end
+
+!! test
+Nesting of <bdo>
+!! wikitext
+<bdo>X<bdo>Y</bdo>Z</bdo>
+!! html
+<p><bdo>X<bdo>Y</bdo>Z</bdo>
+</p>
+!! end
+
+
+###
+### Media links
+###
+
+!! test
+Media link
+!! wikitext
+[[Media:Foobar.jpg]]
+[[Media:Video.ogv]]
+[[:Media:Video.ogv]]
+!! html/php
+<p><a href="http://example.com/images/3/3a/Foobar.jpg" class="internal" title="Foobar.jpg">Media:Foobar.jpg</a>
+<a href="http://example.com/images/0/00/Video.ogv" class="internal" title="Video.ogv">Media:Video.ogv</a>
+<a href="http://example.com/images/0/00/Video.ogv" class="internal" title="Video.ogv">Media:Video.ogv</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:MediaLink" href="//example.com/images/3/3a/Foobar.jpg" title="Foobar.jpg">Media:Foobar.jpg</a>
+<a rel="mw:MediaLink" href="//example.com/images/0/00/Video.ogv" title="Video.ogv">Media:Video.ogv</a>
+<a rel="mw:MediaLink" href="//example.com/images/0/00/Video.ogv" title="Video.ogv" data-parsoid='{"a":{"namespace":"Media"},"sa":{"namespace":":Media"}}'>Media:Video.ogv</a></p>
+!! end
+
+!! test
+Media link with text
+!! wikitext
+[[Media:Foobar.jpg|A neat file to look at]]
+!! html/php
+<p><a href="http://example.com/images/3/3a/Foobar.jpg" class="internal" title="Foobar.jpg">A neat file to look at</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:MediaLink" href="//example.com/images/3/3a/Foobar.jpg" title="Foobar.jpg">A neat file to look at</a></p>
+!! end
+
+# FIXME: this is still bad HTML tag nesting
+# FIXME: doBlockLevels won't wrap this in a paragraph because it contains a div
+# Parsoid & Remex fix the p-wrapping since they operate on the DOM.
+!! test
+Media link with nasty text
+!! wikitext
+[[Media:Foobar.jpg|Safe Link<div style=display:none>" onmouseover="alert(document.cookie)" onfoo="</div>]]
+!! html/php
+<a href="http://example.com/images/3/3a/Foobar.jpg" class="internal" title="Foobar.jpg">Safe Link&lt;div style="display:none"&gt;" onmouseover="alert(document.cookie)" onfoo="&lt;/div&gt;</a>
+
+!! html/php+tidy
+<p><a href="http://example.com/images/3/3a/Foobar.jpg" class="internal" title="Foobar.jpg">Safe Link</a></p><a href="http://example.com/images/3/3a/Foobar.jpg" class="internal" title="Foobar.jpg"><div style="display:none">" onmouseover="alert(document.cookie)" onfoo="</div></a>
+!! html/parsoid
+<p><a rel="mw:MediaLink" href="//example.com/images/3/3a/Foobar.jpg" title="Foobar.jpg" data-parsoid='{"autoInsertedEnd":true}'>Safe Link</a></p><div style="display:none" data-parsoid='{"stx":"html"}'><a rel="mw:MediaLink" href="//example.com/images/3/3a/Foobar.jpg" title="Foobar.jpg" data-parsoid='{"autoInsertedEnd":true,"autoInsertedStart":true}'>" onmouseover="alert(document.cookie)" onfoo="</a></div>
+
+!! end
+
+!! test
+Media link to nonexistent file (T3702)
+!! wikitext
+[[Media:No such.jpg]]
+[[Media:No_such file.jpg]]
+!! html/php
+<p><a href="/index.php?title=Special:Upload&amp;wpDestFile=No_such.jpg" class="new" title="No such.jpg">Media:No such.jpg</a>
+<a href="/index.php?title=Special:Upload&amp;wpDestFile=No_such_file.jpg" class="new" title="No such file.jpg">Media:No_such file.jpg</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:MediaLink" href="./Special:FilePath/No_such.jpg" title="No such.jpg" typeof="mw:Error" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}' data-parsoid='{"a":{"fileName":"No_such.jpg"},"sa":{"fileName":"No such.jpg"}}'>Media:No such.jpg</a>
+<a rel="mw:MediaLink" href="./Special:FilePath/No_such_file.jpg" title="No such file.jpg" typeof="mw:Error" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}' data-parsoid='{"a":{"fileName":"No_such_file.jpg"},"sa":{"fileName":"No_such file.jpg"}}'>Media:No_such file.jpg</a></p>
+!! end
+
+!! test
+Image link to nonexistent file (T3850 - good)
+!! wikitext
+[[File:No_such.jpg]]
+!! html/php
+<p><a href="/index.php?title=Special:Upload&amp;wpDestFile=No_such.jpg" class="new" title="File:No such.jpg">File:No such.jpg</a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:No_such.jpg"><img resource="./File:No_such.jpg" src="./Special:FilePath/No_such.jpg" height="220" width="220"/></a></figure-inline></p>
+!! end
+
+!! test
+:Image link to nonexistent file (T3850 - bad)
+!! wikitext
+[[:Image:No such.jpg]]
+!! html/php
+<p><a href="/index.php?title=File:No_such.jpg&amp;action=edit&amp;redlink=1" class="new" title="File:No such.jpg (page does not exist)">Image:No such.jpg</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./File:No_such.jpg" title="File:No such.jpg">Image:No such.jpg</a></p>
+!! end
+
+!! test
+Character reference normalization in link text (T3938)
+!! wikitext
+[[Main Page|this&that]]
+!! html
+<p><a href="/wiki/Main_Page" title="Main Page">this&amp;that</a>
+</p>
+!!end
+
+!! article
+אַ
+!! text
+Test for unicode normalization
+
+The page's name is U+05d0 U+05b7, with non-canonical form U+FB2E
+!! endarticle
+
+!! test
+(T21451) Links should refer to the normalized form.
+!! wikitext
+[[&#xFB2E;]]
+[[&#x5d0;&#x5b7;]]
+[[&#x5d0;ַ]]
+[[א&#x5b7;]]
+[[אַ]]
+!! html
+<p><a href="/wiki/%D7%90%D6%B7" title="אַ">&#xfb2e;</a>
+<a href="/wiki/%D7%90%D6%B7" title="אַ">&#x5d0;&#x5b7;</a>
+<a href="/wiki/%D7%90%D6%B7" title="אַ">&#x5d0;ַ</a>
+<a href="/wiki/%D7%90%D6%B7" title="אַ">א&#x5b7;</a>
+<a href="/wiki/%D7%90%D6%B7" title="אַ">אַ</a>
+</p>
+!! end
+
+!! test
+Empty attribute crash test (T4067)
+!! wikitext
+<font color="">foo</font>
+!! html
+<p><font color="">foo</font>
+</p>
+!! end
+
+!! test
+Empty attribute crash test single-quotes (T4067)
+!! wikitext
+<font color=''>foo</font>
+!! html
+<p><font color="">foo</font>
+</p>
+!! end
+
+!! test
+Attribute test: equals, then nothing
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<font color=>foo</font>
+!! html/php
+<p><font color="">foo</font>
+</p>
+!! html/parsoid
+<p><font color="" data-parsoid='{"stx":"html"}'>foo</font></p>
+!! end
+
+!! test
+Attribute test: unquoted value
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<font color=x>foo</font>
+!! html/php
+<p><font color="x">foo</font>
+</p>
+!! html/parsoid
+<p><font color="x" data-parsoid='{"stx":"html"}'>foo</font></p>
+!! end
+
+!! test
+Attribute test: unquoted but illegal value (hash)
+!! wikitext
+<font color=#x>foo</font>
+!! html
+<p><font color="#x">foo</font>
+</p>
+!! end
+
+# Parsoid does not serialize to empty attribute syntax,
+# so wt2wt and html2wt cases are skipped
+!! test
+Attribute test: no value (T54330)
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<font color>foo</font>
+!! html/php
+<p><font color="">foo</font>
+</p>
+!! html/parsoid
+<p><font color="">foo</font></p>
+!! end
+
+!! test
+T4095: link with three closing brackets
+!! wikitext
+[[Main Page]]]
+!! html/php
+<p><a href="/wiki/Main_Page" title="Main Page">Main Page</a>]
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a>]</p>
+!! end
+
+!! test
+T4095: link with pipe and three closing brackets
+!! wikitext
+[[Main Page|link]]]
+!! html/php
+<p><a href="/wiki/Main_Page" title="Main Page">link</a>]
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page" title="Main Page">link</a>]</p>
+!! end
+
+!! test
+T4095: link with pipe and three closing brackets, version 2
+!! wikitext
+[[Main Page|[http://example.com/]]]
+!! html/php
+<p><a href="/wiki/Main_Page" title="Main Page">[http://example.com/]</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page" title="Main Page">[http://example.com/]</a></p>
+!! end
+
+
+###
+### Safety
+###
+
+!! article
+Template:Dangerous attribute
+!! text
+" onmouseover="alert(document.cookie)
+!! endarticle
+
+!! article
+Template:Dangerous style attribute
+!! text
+border-size: expression(alert(document.cookie))
+!! endarticle
+
+!! article
+Template:Div style
+!! text
+<div style="float: right; {{{1}}}">Magic div</div>
+!! endarticle
+
+!! test
+T4304: HTML attribute safety (safe template; regression T4309)
+!! wikitext
+<div title="{{test}}"></div>
+!! html/php
+<div title="This is a test template"></div>
+
+!! html/parsoid
+<div title="This is a test template" about="#mwt2" typeof="mw:ExpandedAttrs" data-parsoid='{"stx":"html","a":{"title":"This is a test template"},"sa":{"title":"{{test}}"}}' data-mw='{"attribs":[[{"txt":"title"},{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[]],\"dsr\":[12,20,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"test\",\"href\":\"./Template:Test\"},\"params\":{},\"i\":0}}]}&#39;>This is a test template&lt;/span>"}]]}'></div>
+!! end
+
+# Parsoid has enough context to handle this case
+!! test
+T4304: HTML attribute safety (dangerous template; 2309)
+!! wikitext
+<div title="{{dangerous attribute}}"></div>
+!! html/php
+<div title=""></div>
+
+!! html/parsoid
+<div title='" onmouseover="alert(document.cookie)' about="#mwt2" typeof="mw:ExpandedAttrs" data-parsoid='{"stx":"html","a":{"title":"\" onmouseover=\"alert(document.cookie)"},"sa":{"title":"{{dangerous attribute}}"}}' data-mw='{"attribs":[[{"txt":"title"},{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[]],\"dsr\":[12,35,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"dangerous attribute\",\"href\":\"./Template:Dangerous_attribute\"},\"params\":{},\"i\":0}}]}&#39;>\" onmouseover=\"alert(document.cookie)&lt;/span>"}]]}'></div>
+!! end
+
+!! test
+T4304: HTML attribute safety (dangerous style template; 2309)
+!! wikitext
+<div style="{{dangerous style attribute}}"></div>
+!! html/php
+<div style="/* insecure input */"></div>
+
+!! html/parsoid
+<div style="/* insecure input */" about="#mwt2" typeof="mw:ExpandedAttrs" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"{{dangerous style attribute}}"}}' data-mw='{"attribs":[[{"txt":"style"},{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[]],\"dsr\":[12,41,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"dangerous style attribute\",\"href\":\"./Template:Dangerous_style_attribute\"},\"params\":{},\"i\":0}}]}&#39;>border-size: expression(alert(document.cookie))&lt;/span>"}]]}'></div>
+!! end
+
+!! test
+T4304: HTML attribute safety (safe parameter; 2309)
+!! wikitext
+{{div style|width: 200px}}
+!! html/php
+<div style="float: right; width: 200px">Magic div</div>
+
+!! html/parsoid
+<div style="float: right; width: 200px" about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"stx":"html","a":{"style":"float: right; width: 200px"},"sa":{"style":"float: right; {{{1}}}"},"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"div style","href":"./Template:Div_style"},"params":{"1":{"wt":"width: 200px"}},"i":0}}]}'>Magic div</div>
+!! end
+
+!! test
+T4304: HTML attribute safety (unsafe parameter; 2309)
+!! wikitext
+{{div style|width: expression(alert(document.cookie))}}
+!! html/php
+<div style="/* insecure input */">Magic div</div>
+
+!! html/parsoid
+<div style="/* insecure input */" about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"float: right; {{{1}}}"},"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"div style","href":"./Template:Div_style"},"params":{"1":{"wt":"width: expression(alert(document.cookie))"}},"i":0}}]}'>Magic div</div>
+!! end
+
+## Parsoid output here differs; needs investigation.
+!! test
+T4304: HTML attribute safety (unsafe breakout parameter; 2309)
+!! wikitext
+{{div style|"><script>alert(document.cookie)</script>}}
+!! html
+<div style="float: right;">&lt;script&gt;alert(document.cookie)&lt;/script&gt;"&gt;Magic div</div>
+
+!! end
+
+## Parsoid output here differs; needs investigation.
+!! test
+T4304: HTML attribute safety (unsafe breakout parameter 2; 2309)
+!! wikitext
+{{div style|" ><script>alert(document.cookie)</script>}}
+!! html
+<div style="float: right;">&lt;script&gt;alert(document.cookie)&lt;/script&gt;"&gt;Magic div</div>
+
+!! end
+
+!! test
+T4304: HTML attribute safety (link)
+!! wikitext
+<div title="[[Main Page]]"></div>
+!! html/php
+<div title="&#91;&#91;Main Page&#93;&#93;"></div>
+
+!! html/parsoid
+<div title="[[Main Page]]"></div>
+!! end
+
+!! test
+T4304: HTML attribute safety (italics)
+!! wikitext
+<div title="''foobar''"></div>
+!! html
+<div title="&#39;&#39;foobar&#39;&#39;"></div>
+
+!! end
+
+!! test
+T4304: HTML attribute safety (bold)
+!! wikitext
+<div title="'''foobar'''"></div>
+!! html
+<div title="&#39;&#39;&#39;foobar&#39;&#39;&#39;"></div>
+
+!! end
+
+!! test
+T4304: HTML attribute safety (ISBN)
+!! wikitext
+<div title="ISBN 1234567890"></div>
+!! html
+<div title="&#73;SBN 1234567890"></div>
+
+!! end
+
+!! test
+T4304: HTML attribute safety (RFC)
+!! wikitext
+<div title="RFC 1234"></div>
+!! html
+<div title="&#82;FC 1234"></div>
+
+!! end
+
+!! test
+T4304: HTML attribute safety (PMID)
+!! wikitext
+<div title="PMID 1234567890"></div>
+!! html
+<div title="&#80;MID 1234567890"></div>
+
+!! end
+
+!! test
+T4304: HTML attribute safety (web link)
+!! wikitext
+<div title="http://example.com/"></div>
+!! html
+<div title="http&#58;//example.com/"></div>
+
+!! end
+
+!! test
+T4304: HTML attribute safety (named web link)
+!! wikitext
+<div title="[http://example.com/ link]"></div>
+!! html/php
+<div title="&#91;http&#58;//example.com/ link&#93;"></div>
+
+!! html/parsoid
+<div title="[http://example.com/ link]"></div>
+!! end
+
+!! test
+T5244: HTML attribute safety (extension; safe)
+!! wikitext
+<div style="<nowiki>background:blue</nowiki>"></div>
+!! html/php
+<div style="background:blue"></div>
+
+!! html/parsoid
+<div style="background:blue" data-parsoid='{"stx":"html","a":{"style":"background:blue"},"sa":{"style":"&lt;nowiki>background:blue&lt;/nowiki>"}}'></div>
+!! end
+
+!! test
+T5244: HTML attribute safety (extension; unsafe)
+!! wikitext
+<div style="<nowiki>border-left:expression(alert(document.cookie))</nowiki>"></div>
+!! html/php
+<div style="/* insecure input */"></div>
+
+!! html/parsoid
+<div style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"&lt;nowiki>border-left:expression(alert(document.cookie))&lt;/nowiki>"}}'></div>
+!! end
+
+# More MSIE fun discovered by Tom Gilder
+
+!! test
+MSIE CSS safety test: spurious slash
+!! wikitext
+<div style="background-image:u\rl(javascript:alert('boo'))">evil</div>
+!! html/php
+<div style="/* insecure input */">evil</div>
+
+!! html/parsoid
+<div style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"background-image:u\\rl(javascript:alert(&#39;boo&#39;))"}}'>evil</div>
+!! end
+
+!! test
+MSIE CSS safety test: hex code
+!! wikitext
+<div style="background-image:u\72l(javascript:alert('boo'))">evil</div>
+!! html/php
+<div style="/* insecure input */">evil</div>
+
+!! html/parsoid
+<div style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"background-image:u\\72l(javascript:alert(&#39;boo&#39;))"}}'>evil</div>
+!! end
+
+!! test
+MSIE CSS safety test: comment in url
+!! wikitext
+<div style="background-image:u/**/rl(javascript:alert('boo'))">evil</div>
+!! html/php
+<div style="background-image:u rl(javascript:alert(&#39;boo&#39;))">evil</div>
+
+!! html/parsoid
+<div style="background-image:u rl(javascript:alert('boo'))" data-parsoid='{"stx":"html","a":{"style":"background-image:u rl(javascript:alert(&#39;boo&#39;))"},"sa":{"style":"background-image:u/**/rl(javascript:alert(&#39;boo&#39;))"}}'>evil</div>
+!! end
+
+!! test
+MSIE CSS safety test: comment in expression
+!! wikitext
+<div style="background-image:expres/**/sion(alert('boo4'))">evil4</div>
+!! html/php
+<div style="background-image:expres sion(alert(&#39;boo4&#39;))">evil4</div>
+
+!! html/parsoid
+<div style="background-image:expres sion(alert('boo4'))" data-parsoid='{"stx":"html","a":{"style":"background-image:expres sion(alert(&#39;boo4&#39;))"},"sa":{"style":"background-image:expres/**/sion(alert(&#39;boo4&#39;))"}}'>evil4</div>
+!! end
+
+!! test
+CSS safety test (all browsers): vertical tab (T57332 / CVE-2013-4567)
+!! wikitext
+<p style="font-size: 100px; background-image:url\b(https://www.google.com/images/srpr/logo6w.png)">A</p>
+!! html/php
+<p style="/* invalid control char */">A</p>
+
+!! html/parsoid
+<p style="/* invalid control char */" data-parsoid='{"stx":"html","a":{"style":"/* invalid control char */"},"sa":{"style":"font-size: 100px; background-image:url\\b(https://www.google.com/images/srpr/logo6w.png)"}}'>A</p>
+!! end
+
+!! test
+MSIE 6 CSS safety test: Fullwidth (T57332)
+!! wikitext
+<p style="font-size: 100px; color: expression((title='XSSed'),'red')">A</p>
+<div style="top:EXPRESSION(alert())">B</div>
+!! html/php
+<p style="/* insecure input */">A</p>
+<div style="/* insecure input */">B</div>
+
+!! html/parsoid
+<p style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"font-size: 100px; color: expression((title=&#39;XSSed&#39;),&#39;red&#39;)"}}'>A</p>
+<div style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"top:EXPRESSION(alert())"}}'>B</div>
+!! end
+
+!! test
+MSIE 6 CSS safety test: IPA extensions (T57332)
+!! wikitext
+<div style="background-image:uʀʟ(javascript:alert())">A</div>
+<p style="font-size: 100px; color: expʀessɪoɴ((title='XSSed'),'red')">B</p>
+!! html/php
+<div style="/* insecure input */">A</div>
+<p style="/* insecure input */">B</p>
+
+!! html/parsoid
+<div style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"background-image:uʀʟ(javascript:alert())"}}'>A</div>
+<p style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"font-size: 100px; color: expʀessɪoɴ((title=&#39;XSSed&#39;),&#39;red&#39;)"}}'>B</p>
+!! end
+
+!! test
+MSIE 6 CSS safety test: sup/sub script (T57332)
+!! wikitext
+<div style="background-image:url⁽javascript:alert())">A</div>
+<div style="background-image:url₍javascript:alert())">B</div>
+<p style="font-size: 100px; color: expressioⁿ((title='XSSed'),'red')">C</p>
+!! html/php
+<div style="/* insecure input */">A</div>
+<div style="/* insecure input */">B</div>
+<p style="/* insecure input */">C</p>
+
+!! html/parsoid
+<div style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"background-image:url⁽javascript:alert())"}}'>A</div>
+<div style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"background-image:url₍javascript:alert())"}}'>B</div>
+<p style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"font-size: 100px; color: expressioⁿ((title=&#39;XSSed&#39;),&#39;red&#39;)"}}'>C</p>
+!! end
+
+!! test
+Opera -o-link CSS
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<div
+title="&#100;&#97;&#116;&#97;&#58;&#116;&#101;&#120;&#116;&#47;&#104;&#116;&#109;&#108;&#44;&#60;&#105;&#109;&#103;&#32;&#115;&#114;&#99;&#61;&#49;&#32;&#111;&#110;&#101;&#114;&#114;&#111;&#114;&#61;&#97;&#108;&#101;&#114;&#116;&#40;&#49;&#41;&#62;"
+style="-o-link:attr(title);-o-link-source:current">X</div>
+!! html/php
+<div title="data:text/html,&lt;img src=1 onerror=alert(1)&gt;" style="/* insecure input */">X</div>
+
+!! html/parsoid
+<div title="data:text/html,&lt;img src=1 onerror=alert(1)>" style="/* insecure input */" data-parsoid='{"stx":"html","a":{"title":"data:text/html,&lt;img src=1 onerror=alert(1)>","style":"/* insecure input */"},"sa":{"title":"&amp;#100;&amp;#97;&amp;#116;&amp;#97;&amp;#58;&amp;#116;&amp;#101;&amp;#120;&amp;#116;&amp;#47;&amp;#104;&amp;#116;&amp;#109;&amp;#108;&amp;#44;&amp;#60;&amp;#105;&amp;#109;&amp;#103;&amp;#32;&amp;#115;&amp;#114;&amp;#99;&amp;#61;&amp;#49;&amp;#32;&amp;#111;&amp;#110;&amp;#101;&amp;#114;&amp;#114;&amp;#111;&amp;#114;&amp;#61;&amp;#97;&amp;#108;&amp;#101;&amp;#114;&amp;#116;&amp;#40;&amp;#49;&amp;#41;&amp;#62;","style":"-o-link:attr(title);-o-link-source:current"}}'>X</div>
+!! end
+
+!! test
+MSIE 6 CSS safety test: Repetition markers (T57332)
+!! wikitext
+<p style="font-size: 100px; color: expres〱ion((title='XSSed'),'red')">A</p>
+<p style="font-size: 100px; color: expresゝion((title='XSSed'),'red')">B</p>
+<p style="font-size: 100px; color: expresーion((title='XSSed'),'red')">C</p>
+<p style="font-size: 100px; color: expresヽion((title='XSSed'),'red')">D</p>
+<p style="font-size: 100px; color: expresﹽion((title='XSSed'),'red')">E</p>
+<p style="font-size: 100px; color: expresﹼion((title='XSSed'),'red')">F</p>
+<p style="font-size: 100px; color: expresーion((title='XSSed'),'red')">G</p>
+!! html/php
+<p style="/* insecure input */">A</p>
+<p style="/* insecure input */">B</p>
+<p style="/* insecure input */">C</p>
+<p style="/* insecure input */">D</p>
+<p style="/* insecure input */">E</p>
+<p style="/* insecure input */">F</p>
+<p style="/* insecure input */">G</p>
+
+!! html/parsoid
+<p style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"font-size: 100px; color: expres〱ion((title=&#39;XSSed&#39;),&#39;red&#39;)"}}'>A</p>
+<p style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"font-size: 100px; color: expresゝion((title=&#39;XSSed&#39;),&#39;red&#39;)"}}'>B</p>
+<p style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"font-size: 100px; color: expresーion((title=&#39;XSSed&#39;),&#39;red&#39;)"}}'>C</p>
+<p style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"font-size: 100px; color: expresヽion((title=&#39;XSSed&#39;),&#39;red&#39;)"}}'>D</p>
+<p style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"font-size: 100px; color: expresﹽion((title=&#39;XSSed&#39;),&#39;red&#39;)"}}'>E</p>
+<p style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"font-size: 100px; color: expresﹼion((title=&#39;XSSed&#39;),&#39;red&#39;)"}}'>F</p>
+<p style="/* insecure input */" data-parsoid='{"stx":"html","a":{"style":"/* insecure input */"},"sa":{"style":"font-size: 100px; color: expresーion((title=&#39;XSSed&#39;),&#39;red&#39;)"}}'>G</p>
+!! end
+
+!! test
+Table attribute legitimate extension
+!! wikitext
+{|
+!+ style="<nowiki>color:blue</nowiki>"|status
+|}
+!! html
+<table>
+<tr>
+<th style="color:blue">status
+</th></tr></table>
+
+!!end
+
+!! test
+Table attribute safety
+!! wikitext
+{|
+!+ style="<nowiki>border-width:expression(0+alert(document.cookie))</nowiki>"|status
+|}
+!! html
+<table>
+<tr>
+<th style="/* insecure input */">status
+</th></tr></table>
+
+!! end
+
+!! test
+CSS line continuation 1
+!! wikitext
+<div style="background-image: u\&#10;rl(test.jpg);"></div>
+!! html
+<div style="/* insecure input */"></div>
+
+!! end
+
+!! test
+CSS line continuation 2
+!! wikitext
+<div style="background-image: u\&#13;rl(test.jpg); "></div>
+!! html
+<div style="/* invalid control char */"></div>
+
+!! end
+
+!! article
+Template:Identity
+!! text
+{{{1}}}
+!! endarticle
+
+!! test
+Expansion of multi-line templates in attribute values (T8255)
+!! wikitext
+<div style="background: {{identity|#00FF00}}">-</div>
+!! html
+<div style="background: #00FF00">-</div>
+
+!! end
+
+!! test
+Expansion of multi-line templates in attribute values (T8255 sanity check)
+!! wikitext
+<div style="background:
+#00FF00">-</div>
+!! html/php
+<div style="background: #00FF00">-</div>
+
+!! html/parsoid
+<div style="background:
+#00FF00">-</div>
+!! end
+
+!! test
+Expansion of multi-line templates in attribute values (T8255 sanity check 2)
+!! wikitext
+<div style="background: &#10;#00FF00">-</div>
+!! html
+<div style="background: &#10;#00FF00">-</div>
+
+!! end
+
+!! test
+Tags which are hidden from tidiers cannot pass through the Sanitizer
+!! wikitext
+<mw:toc><script>alert();</script></mw:toc>
+!! html+tidy
+<p>&lt;mw:toc&gt;&lt;script&gt;alert();&lt;/script&gt;&lt;/mw:toc&gt;
+</p>
+!! end
+
+###
+### Parser hooks (see tests/parser/parserTestsParserHook.php for the <tag> extension)
+###
+
+!! test
+Parser hook: empty input
+!! wikitext
+<tag></tag>
+!! html/php
+<pre>
+''
+array (
+)
+</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{},"body":{"extsrc":""}}' data-parsoid='{}' about="#mwt2"></pre>
+!! end
+
+## Don't expect parsoid to rt this form.
+!! test
+Parser hook: empty input using terminated empty elements
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<tag/>
+!! html/php
+<pre>
+NULL
+array (
+)
+</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{},"body":null}' data-parsoid='{}' about="#mwt2"></pre>
+!! end
+
+!! test
+Parser hook: empty input using terminated empty elements (space before)
+!! wikitext
+<tag />
+!! html/php
+<pre>
+NULL
+array (
+)
+</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{},"body":null}' data-parsoid='{}' about="#mwt2"></pre>
+!! end
+
+!! test
+Parser hook: basic input
+!! wikitext
+<tag>input</tag>
+!! html/php
+<pre>
+'input'
+array (
+)
+</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{},"body":{"extsrc":"input"}}' data-parsoid='{}' about="#mwt2"></pre>
+!! end
+
+## Don't expect parsoid to rt this form.
+!! test
+Parser hook: case insensitive
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<TAG>input</TAG>
+!! html/php
+<pre>
+'input'
+array (
+)
+</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{},"body":{"extsrc":"input"}}' data-parsoid='{}' about="#mwt2"></pre>
+!! end
+
+## Don't expect parsoid to rt this form.
+!! test
+Parser hook: case insensitive, redux
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<TaG>input</TAg>
+!! html/php
+<pre>
+'input'
+array (
+)
+</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{},"body":{"extsrc":"input"}}' data-parsoid='{}' about="#mwt2"></pre>
+!! end
+
+!! test
+Parser hook: nested tags
+!! wikitext
+<tag><tag></tag></tag>
+!! html/php
+<pre>
+'<tag>'
+array (
+)
+</pre>&lt;/tag&gt;
+
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{},"body":{"extsrc":"&lt;tag>"}}' data-parsoid='{}' about="#mwt2"></pre>&lt;/tag>
+!! end
+
+!! test
+Parser hook: basic arguments
+!! wikitext
+<tag width="200" height="100" depth="50" square=""></tag>
+!! html/php
+<pre>
+''
+array (
+ 'width' => '200',
+ 'height' => '100',
+ 'depth' => '50',
+ 'square' => '',
+)
+</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{"width":"200","height":"100","depth":"50","square":""},"body":{"extsrc":""}}' data-parsoid='{}' about="#mwt2"></pre>
+!! end
+
+## Don't expect parsoid to rt this form.
+!! test
+Parser hook: basic arguments, variations
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<tag width=200 height = "100" depth = '50' square></tag>
+!! html/php
+<pre>
+''
+array (
+ 'width' => '200',
+ 'height' => '100',
+ 'depth' => '50',
+ 'square' => '',
+)
+</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{"width":"200","height":"100","depth":"50","square":""},"body":{"extsrc":""}}' data-parsoid='{}' about="#mwt2"></pre>
+!! end
+
+!! test
+Parser hook: argument containing a forward slash (T7344)
+!! wikitext
+<tag filename="/tmp/bla"></tag>
+!! html/php
+<pre>
+''
+array (
+ 'filename' => '/tmp/bla',
+)
+</pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{"filename":"/tmp/bla"},"body":{"extsrc":""}}' data-parsoid='{}' about="#mwt2"></pre>
+!! end
+
+## Don't expect parsoid to rt this form.
+!! test
+Parser hook: empty input using terminated empty elements (T4374)
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<tag foo=bar/>text
+!! html/php
+<pre>
+NULL
+array (
+ 'foo' => 'bar',
+)
+</pre>text
+
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{"foo":"bar"},"body":null}' data-parsoid='{}' about="#mwt2"></pre>text
+!! end
+
+## </tag> should be output literally since there is no matching tag that begins it
+## Don't expect parsoid to rt this form.
+!! test
+Parser hook: basic arguments using terminated empty elements (T4374)
+!! options
+parsoid=wt2html
+!! wikitext
+<tag width=200 height = "100" depth = '50' square/>
+other stuff
+</tag>
+!! html/php
+<pre>
+NULL
+array (
+ 'width' => '200',
+ 'height' => '100',
+ 'depth' => '50',
+ 'square' => '',
+)
+</pre>
+<p>other stuff
+&lt;/tag&gt;
+</p>
+!! html/parsoid
+<pre typeof="mw:Extension/tag" data-mw='{"name":"tag","attrs":{"width":"200","height":"100","depth":"50","square":""},"body":null}' about="#mwt2"></pre><p>other stuff
+&lt;/tag></p>
+!! end
+
+## Don't expect parsoid to rt this form.
+!! test
+Parser hook: Don't allow unclosed extension tags
+!! options
+parsoid=wt2html
+!! wikitext
+test <tag>123
+
+this is a '''test'''
+!! html/php
+<p>test &lt;tag&gt;123
+</p><p>this is a <b>test</b>
+</p>
+!! html/parsoid
+<p>test &lt;tag>123</p>
+
+<p>this is a <b>test</b></p>
+!! end
+
+!! test
+Parser hook: horizontal rule inside extension tag that outputs <pre>
+!! wikitext
+<tag>
+Hello
+<hr/>
+Goodbye
+</tag>
+!! html/php
+<pre>
+'
+Hello
+<hr/>
+Goodbye
+'
+array (
+)
+</pre>
+
+!! end
+
+###
+### (see tests/parser/parserTestsParserHook.php for the <statictag> extension)
+###
+
+!! test
+Parser hook: static parser hook not inside a comment
+!! wikitext
+<statictag>hello, world</statictag>
+
+<statictag action="flush" />
+!! html/php
+<p><br />
+hello, world
+</p>
+!! html/parsoid
+<p><span typeof="mw:Extension/statictag" data-mw='{"name":"statictag","attrs":{},"body":{"extsrc":"hello, world"}}' data-parsoid='{}' about="#mwt2"></span></p>
+<p typeof="mw:Extension/statictag" data-mw='{"name":"statictag","attrs":{"action":"flush"},"body":null}' data-parsoid='{}' about="#mwt4">hello, world</p>
+!! end
+
+!! test
+Parser hook: static parser hook inside a comment
+!! wikitext
+<!-- <statictag>hello, world</statictag> -->
+<statictag action="flush" />
+!! html/php
+<p><br />
+</p>
+!! html/parsoid
+<!-- <statictag&#x3E;hello, world</statictag&#x3E; -->
+<p typeof='mw:Extension/statictag' data-mw='{"name":"statictag","attrs":{"action":"flush"},"body":null}' data-parsoid='{}' about='#mwt2'></p>
+!! end
+
+# Nested template calls; this case was broken by Parser.php rev 1.506,
+# since reverted.
+
+!! article
+Template:One-parameter
+!! text
+(My parameter is: {{{1}}})
+!! endarticle
+
+!! article
+Template:Map-one-parameter
+!! text
+{{{{{1}}}|{{{2}}}}}
+!! endarticle
+
+!! test
+Nested template calls
+!! wikitext
+{{Map-one-parameter|One-parameter|param}}
+!! html
+<p>(My parameter is: param)
+</p>
+!! end
+
+
+###
+### Sanitizer
+###
+
+# Remex wraps empty tag runs with p-tags.
+# Parsoid strips them out during p-wrapping.
+!! test
+Sanitizer: Closing of open tags
+!! wikitext
+<s></s><table></table>
+!! html/php+tidy
+<p><s></s></p><table></table>
+!! html/parsoid
+<s></s><table></table>
+!! end
+
+!! test
+Sanitizer: Closing of open but not closed tags
+!! wikitext
+<s>foo
+!! html
+<p><s>foo</s>
+</p>
+!! end
+
+!! test
+Sanitizer: Closing of closed but not open tags
+!! options
+parsoid=wt2html
+!! wikitext
+</s>
+!! html/php+tidy
+<p class="mw-empty-elt">
+</p>
+!! html/parsoid
+!! end
+
+!! test
+Sanitizer: Closing of closed but not open table tags
+!! options
+parsoid=wt2html
+!! wikitext
+Table not started</td></tr></table>
+!! html+tidy
+<p>Table not started
+</p>
+!! end
+
+!! test
+Sanitizer: Escaping of spaces, multibyte characters, colons & other stuff in id=""
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+<span id="æ: v">byte</span>[[#æ: v|backlink]]
+!! html/php
+<p><span id="æ:_v">byte</span><a href="#æ:_v">backlink</a>
+</p>
+!! html/parsoid
+<p><span id="æ:_v" data-parsoid='{"stx":"html","a":{"id":"æ:_v"},"sa":{"id":"æ: v"}}'>byte</span><a rel="mw:WikiLink" href="./Main_Page#æ:_v" data-parsoid='{"stx":"piped","a":{"href":"./Main_Page#æ:_v"},"sa":{"href":"#æ: v"}}'>backlink</a></p>
+!! end
+
+!! test
+Sanitizer: Escaping of spaces, multibyte characters, colons & other stuff in id="" (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
+!! wikitext
+<span id="æ: v">byte</span>[[#æ: v|backlink]]
+!! html/php
+<p><span id=".C3.A6:_v">byte</span><a href="#.C3.A6:_v">backlink</a>
+</p>
+!! end
+
+# In HTML5, the restrictions are that id must contain at least one character,
+# and must not contain any space characters.
+!! test
+Sanitizer: Validating the contents of the id attribute (T6515)
+!! options
+disabled
+!! wikitext
+<br id="" /><br id="a space" />
+!! html
+Something ...
+!! end
+
+# In HTML5, id must be unique amongst all the ids in the element's home subtree.
+!! test
+Sanitizer: Validating id attribute uniqueness (T6515, T8301)
+!! options
+disabled
+!! wikitext
+<br id="foo" /><br id="foo" />
+!! html
+Something need to be done. foo-2 ?
+!! end
+
+!! test
+Sanitizer: Validating that <meta> and <link> work, but only for Microdata
+!! wikitext
+<div itemscope>
+ <meta itemprop="hello" content="world">
+ <meta http-equiv="refresh" content="5">
+ <meta itemprop="hello" http-equiv="refresh" content="5">
+ <link itemprop="hello" href="{{SERVER}}">
+ <link rel="stylesheet" href="{{SERVER}}">
+ <link rel="stylesheet" itemprop="hello" href="{{SERVER}}">
+</div>
+!! html
+<div itemscope="">
+<p> <meta itemprop="hello" content="world" />
+ &lt;meta http-equiv="refresh" content="5"&gt;
+ <meta itemprop="hello" content="5" />
+ <link itemprop="hello" href="http&#58;//example.org" />
+ &lt;link rel="stylesheet" href="<a rel="nofollow" class="external free" href="http://example.org">http://example.org</a>"&gt;
+ <link itemprop="hello" href="http&#58;//example.org" />
+</p>
+</div>
+
+!! end
+
+!! test
+Sanitizer: Strip comments from CSS attributes
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+<span style="margin:/*negate mbox-text padding */-0.125em -0.45em; /*rainbow*/rgba(255, 0, 0, 0.3)">2013</span>
+!! html/php
+<p><span style="margin: -0.125em -0.45em; rgba(255, 0, 0, 0.3)">2013</span>
+</p>
+!! html/parsoid
+<p><span style="margin: -0.125em -0.45em; rgba(255, 0, 0, 0.3)">2013</span></p>
+!! end
+
+!! test
+Sanitizer: Avoid unnecessary percent encoded characters in interwiki links
+!! wikitext
+[[meatball:Soft"Security]]
+!! html/php
+<p><a href="http://www.usemod.com/cgi-bin/mb.pl?Soft%22Security" class="extiw" title="meatball:Soft&quot;Security">meatball:Soft"Security</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink/Interwiki" href='http://www.usemod.com/cgi-bin/mb.pl?Soft"Security' title='meatball:Soft"Security'>meatball:Soft"Security</a></p>
+!! end
+
+!! test
+Sanitizer: angle brackets are invalid, even in interwiki links (T182338)
+!! wikitext
+[[meatball:Foo<Bar]]
+[[meatball:Foo>Bar]]
+[[meatball:Foo&lt;bar]]
+[[meatball:Foo&gt;bar]]
+!! html/php
+<p>[[meatball:Foo&lt;Bar]]
+[[meatball:Foo&gt;Bar]]
+[[meatball:Foo&lt;bar]]
+[[meatball:Foo&gt;bar]]
+</p>
+!! html/parsoid
+<p>[[meatball:Foo&lt;Bar]]
+[[meatball:Foo>Bar]]
+[[meatball:Foo<span typeof="mw:Entity" data-parsoid='{"src":"&amp;lt;","srcContent":"&lt;"}'>&lt;</span>bar]]
+[[meatball:Foo<span typeof="mw:Entity" data-parsoid='{"src":"&amp;gt;","srcContent":">"}'>></span>bar]]</p>
+!! end
+
+!! test
+Language converter: output gets cut off unexpectedly (T7757)
+!! options
+language=zh
+!! wikitext
+this bit is safe: }-
+
+but if we add a conversion instance: -{zh-cn:xxx;zh-tw:yyy}-
+
+then we get cut off here: }-
+
+all additional text is vanished
+!! html/php
+<p>this bit is safe: }-
+</p><p>but if we add a conversion instance: xxx
+</p><p>then we get cut off here: }-
+</p><p>all additional text is vanished
+</p>
+!! html/parsoid
+<p>this bit is safe: }-</p>
+<p>but if we add a conversion instance: <span typeof="mw:LanguageVariant" data-parsoid='{"tSp":[6]}' data-mw-variant='{"twoway":[{"l":"zh-cn","t":"xxx"},{"l":"zh-tw","t":"yyy"}]}'></span></p>
+<p>then we get cut off here: }-</p>
+<p>all additional text is vanished</p>
+!! end
+
+!! test
+Language converter glossary rules inside attributes (T119158)
+!! options
+language=sr variant=sr-el
+!! wikitext
+-{H|foAjrjvi=>sr-el:" onload="alert(1)" data-foo="}-
+
+[[File:Foobar.jpg|alt=-{}-foAjrjvi-{}-]]
+!! html/php
+<p>
+</p><p><a href="/wiki/%D0%94%D0%B0%D1%82%D0%BE%D1%82%D0%B5%D0%BA%D0%B0:Foobar.jpg" class="image"><img alt="&quot; onload=&quot;alert(1)&quot; data-foo=&quot;" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"oneway":[{"f":"foAjrjvi","l":"sr-el","t":"\" onload=\"alert(1)\" data-foo=\""}]}'/></p>
+
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><a href="./Датотека:Foobar.jpg"><img alt="foAjrjvi" resource="./Датотека:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"alt":"foAjrjvi","resource":"./Датотека:Foobar.jpg","height":"220","width":"1941"},"sa":{"alt":"alt=-{}-foAjrjvi-{}-","resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Self closed html pairs (T7487)
+!! wikitext
+<center><font id="bug" />Centered text</center>
+<div><font id="bug2" />In div text</div>
+!! html+tidy
+<center><font id="bug"></font>Centered text</center>
+<div><font id="bug2"></font>In div text</div>
+!! end
+
+!! test
+Punctuation: nbsp before exclamation
+!! wikitext
+C'est grave !
+!! html
+<p>C'est grave&#160;!
+</p>
+!! end
+
+!! test
+Punctuation: CSS !important (T13874)
+!! wikitext
+<div style="width:50% !important">important</div>
+!! html
+<div style="width:50% !important">important</div>
+
+!!end
+
+!! test
+Punctuation: CSS ! important (T13874; with space after)
+!! wikitext
+<div style="width:50% ! important">important</div>
+!! html
+<div style="width:50% ! important">important</div>
+
+!!end
+
+!! test
+HTML bullet list, closed tags (T7497)
+!! wikitext
+<ul>
+<li>One</li>
+<li>Two</li>
+</ul>
+!! html/php
+<ul>
+<li>One</li>
+<li>Two</li>
+</ul>
+
+!! html/parsoid
+<ul data-parsoid='{"stx":"html"}'>
+<li data-parsoid='{"stx":"html"}'>One</li>
+<li data-parsoid='{"stx":"html"}'>Two</li>
+</ul>
+
+!! end
+
+!! test
+HTML bullet list, unclosed tags (T7497)
+!! wikitext
+<ul>
+<li>One
+<li>Two
+</ul>
+!! html/php+tidy
+<ul>
+<li>One
+</li><li>Two
+</li></ul>
+!! html/parsoid
+<ul data-parsoid='{"stx":"html"}'>
+<li data-parsoid='{"stx":"html","autoInsertedEnd":true}'>One</li>
+<li data-parsoid='{"stx":"html","autoInsertedEnd":true}'>Two</li>
+</ul>
+
+!! end
+
+!! test
+HTML ordered list, closed tags (T7497)
+!! wikitext
+<ol>
+<li>One</li>
+<li>Two</li>
+</ol>
+!! html/php
+<ol>
+<li>One</li>
+<li>Two</li>
+</ol>
+
+!! html/parsoid
+<ol data-parsoid='{"stx":"html"}'>
+<li data-parsoid='{"stx":"html"}'>One</li>
+<li data-parsoid='{"stx":"html"}'>Two</li>
+</ol>
+
+!! end
+
+!! test
+HTML ordered list, unclosed tags (T7497)
+!! options
+!! wikitext
+<ol>
+<li>One
+<li>Two
+</ol>
+!! html/php+tidy
+<ol>
+<li>One
+</li><li>Two
+</li></ol>
+!! html/parsoid
+<ol data-parsoid='{"stx":"html"}'>
+<li data-parsoid='{"stx":"html","autoInsertedEnd":true}'>One</li>
+<li data-parsoid='{"stx":"html","autoInsertedEnd":true}'>Two</li>
+</ol>
+
+!! end
+
+!! test
+HTML nested bullet list, closed tags (T7497)
+!! wikitext
+<ul>
+<li>One</li>
+<li>Two:
+<ul>
+<li>Sub-one</li>
+<li>Sub-two</li>
+</ul>
+</li>
+</ul>
+!! html/php
+<ul>
+<li>One</li>
+<li>Two:
+<ul>
+<li>Sub-one</li>
+<li>Sub-two</li>
+</ul>
+</li>
+</ul>
+
+!! html/parsoid
+<ul data-parsoid='{"stx":"html"}'>
+<li data-parsoid='{"stx":"html"}'>One</li>
+<li data-parsoid='{"stx":"html"}'>Two:
+<ul data-parsoid='{"stx":"html"}'>
+<li data-parsoid='{"stx":"html"}'>Sub-one</li>
+<li data-parsoid='{"stx":"html"}'>Sub-two</li>
+</ul>
+</li>
+</ul>
+!! end
+
+!! test
+HTML nested bullet list, open tags (T7497)
+!! wikitext
+<ul>
+<li>One
+<li>Two:
+<ul>
+<li>Sub-one
+<li>Sub-two
+</ul>
+</ul>
+!! html+tidy
+<ul>
+<li>One
+</li><li>Two:
+<ul>
+<li>Sub-one
+</li><li>Sub-two
+</li></ul>
+</li></ul>
+!! end
+
+!! test
+HTML nested ordered list, closed tags (T7497)
+!! wikitext
+<ol>
+<li>One</li>
+<li>Two:
+<ol>
+<li>Sub-one</li>
+<li>Sub-two</li>
+</ol>
+</li>
+</ol>
+!! html
+<ol>
+<li>One</li>
+<li>Two:
+<ol>
+<li>Sub-one</li>
+<li>Sub-two</li>
+</ol>
+</li>
+</ol>
+
+!! end
+
+!! test
+HTML nested ordered list, open tags (T7497)
+!! wikitext
+<ol>
+<li>One
+<li>Two:
+<ol>
+<li>Sub-one
+<li>Sub-two
+</ol>
+</ol>
+!! html/php
+<ol>
+<li>One
+<li>Two:
+<ol>
+<li>Sub-one
+<li>Sub-two
+</ol>
+</ol>
+
+!! html/parsoid
+<ol>
+<li>One
+</li>
+<li>Two:
+<ol>
+<li>Sub-one
+</li>
+<li>Sub-two
+</li>
+</ol>
+</li>
+</ol>
+
+!! end
+
+!! test
+HTML ordered list item with parameters oddity
+!! wikitext
+<ol><li id="fragment">One</li>
+</ol>
+!! html
+<ol><li id="fragment">One</li>
+</ol>
+
+!! end
+
+# parsoid doesn't explicitly mark autonumbered links, see T55505
+!!test
+T7918: autonumbering
+!! wikitext
+[http://first/] [http://second] [ftp://ftp]
+
+ftp://inlineftp
+
+[mailto:enclosed@mail.tld With target]
+
+[mailto:enclosed@mail.tld]
+
+mailto:inline@mail.tld
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="http://first/">[1]</a> <a rel="nofollow" class="external autonumber" href="http://second">[2]</a> <a rel="nofollow" class="external autonumber" href="ftp://ftp">[3]</a>
+</p><p><a rel="nofollow" class="external free" href="ftp://inlineftp">ftp://inlineftp</a>
+</p><p><a rel="nofollow" class="external text" href="mailto:enclosed@mail.tld">With target</a>
+</p><p><a rel="nofollow" class="external autonumber" href="mailto:enclosed@mail.tld">[4]</a>
+</p><p><a rel="nofollow" class="external free" href="mailto:inline@mail.tld">mailto:inline@mail.tld</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="http://first/"></a> <a rel="mw:ExtLink" class="external autonumber" href="http://second"></a> <a rel="mw:ExtLink" class="external autonumber" href="ftp://ftp"></a></p>
+<p><a rel="mw:ExtLink" class="external free" href="ftp://inlineftp">ftp://inlineftp</a></p>
+<p><a rel="mw:ExtLink" class="external text" href="mailto:enclosed@mail.tld">With target</a></p>
+<p><a rel="mw:ExtLink" class="external autonumber" href="mailto:enclosed@mail.tld"></a></p>
+<p><a rel="mw:ExtLink" class="external free" href="mailto:inline@mail.tld">mailto:inline@mail.tld</a></p>
+!! end
+
+
+#
+# Security and HTML correctness
+# From Nick Jenkins' fuzz testing
+#
+
+!! test
+Fuzz testing: Parser13
+!! wikitext
+{|
+| http://a|
+!! html
+<table>
+<tr>
+<td>
+</td>
+</tr>
+</table>
+
+!! end
+
+# Note that Parsoid output differs from the PHP parser here: the PHP
+# parser breaks the URL for the magic word, while in Parsoid the URL
+# production takes precedence.
+!! test
+Fuzz testing: Parser14
+!! wikitext
+==onmouseover===
+http://__TOC__
+!! html/php
+<h2><span class="mw-headline" id="onmouseover.3D">onmouseover=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: onmouseover=">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+http://<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#onmouseover.3D"><span class="tocnumber">1</span> <span class="toctext">onmouseover=</span></a></li>
+</ul>
+</div>
+
+
+!! html/php+tidy
+<h2><span class="mw-headline" id="onmouseover.3D">onmouseover=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: onmouseover=">edit</a><span class="mw-editsection-bracket">]</span></span></h2><p>
+http://</p><div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#onmouseover.3D"><span class="tocnumber">1</span> <span class="toctext">onmouseover=</span></a></li>
+</ul>
+</div>
+!! html/parsoid
+<h2 id="onmouseover="><span id="onmouseover.3D" typeof="mw:FallbackId"></span>onmouseover=</h2>
+<p><a rel="mw:ExtLink" class="external free" href="http://__TOC__" data-parsoid='{"stx":"url"}'>http://__TOC__</a></p>
+!! end
+
+!! test
+Fuzz testing: Parser14-table
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+==a==
+{| STYLE=__TOC__
+!! html
+<h2><span class="mw-headline" id="a">a</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: a">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<table style="&#95;_TOC&#95;_">
+<tr><td></td></tr>
+</table>
+
+!! html+tidy
+<h2><span class="mw-headline" id="a">a</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: a">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<table style="__TOC__">
+<tr>
+<td></td>
+</tr>
+</table>
+!! html/parsoid
+<h2 id="a">a</h2>
+<table style="__TOC__"></table>
+!! end
+
+# Known to produce bogus xml (extra </td>)
+# Don't add the html/php section since it generates broken HTML
+!! test
+Fuzz testing: Parser16
+!! wikitext
+{|
+!https://||||||
+!! html+tidy
+<table>
+<tbody><tr>
+<th>https://</th>
+<th></th>
+<th></th>
+<th>
+
+</th></tr>
+</tbody></table>
+!! end
+
+!! test
+Fuzz testing: Parser21
+!! wikitext
+{|
+!irc://{{ftp://a" onmouseover="alert('hello world');"
+|
+!! html
+<table>
+<tr>
+<th><a rel="nofollow" class="external free" href="irc://{{ftp://a">irc://{{ftp://a</a>" onmouseover="alert('hello world');"
+</th>
+<td>
+</td>
+</tr>
+</table>
+
+!! end
+
+!! test
+Fuzz testing: Parser22
+!! wikitext
+http://===r:::https://b
+
+{|
+!! html
+<p><a rel="nofollow" class="external free" href="http://===r:::https://b">http://===r:::https://b</a>
+</p>
+<table>
+<tr><td></td></tr>
+</table>
+
+!! end
+
+# Known to produce bad XML for now
+!! test
+Fuzz testing: Parser24
+!! options
+parsoid=wt2html
+!! wikitext
+{|
+{{{|
+<u CLASS=
+| {{{{SSSll!!!!!!!VVVV)]]][[Special:*xxxxxxx--><noinclude>}}}} >
+<br style="onmouseover='alert(document.cookie);' " />
+
+MOVE YOUR MOUSE CURSOR OVER THIS TEXT
+|
+!! html/php
+<table>
+{{{|
+<u class="&#124;">}}}} &gt;
+<br style="onmouseover=&#39;alert(document.cookie);&#39;" />
+
+MOVE YOUR MOUSE CURSOR OVER THIS TEXT
+<tr>
+<td></u>
+</td>
+</tr>
+</table>
+
+!! html/parsoid
+<p data-parsoid='{"fostered":true,"autoInsertedEnd":true}'>{{{|
+<u class="|" data-parsoid='{"stx":"html","a":{"{{{{SSSll!!!!!!!VVVV)]]][[Special:*xxxxxxx--":null},"sa":{"{{{{SSSll!!!!!!!VVVV)]]][[Special:*xxxxxxx--":""},"autoInsertedEnd":true}'><meta typeof="mw:Includes/NoInclude" data-parsoid='{"src":"&lt;noinclude>"}'/>}}}} >
+<br style="onmouseover='alert(document.cookie);' " data-parsoid='{"stx":"html","selfClose":true}'/></u></p><p data-parsoid='{"fostered":true,"autoInsertedEnd":true}'><u class="|" data-parsoid='{"stx":"html","a":{"{{{{SSSll!!!!!!!VVVV)]]][[Special:*xxxxxxx--":null},"sa":{"{{{{SSSll!!!!!!!VVVV)]]][[Special:*xxxxxxx--":""},"autoInsertedEnd":true,"autoInsertedStart":true}'>MOVE YOUR MOUSE CURSOR OVER THIS TEXT</u></p><table data-parsoid='{"autoInsertedEnd":true}'>
+
+
+
+<tbody><tr data-parsoid='{"autoInsertedEnd":true,"autoInsertedStart":true}'><td data-parsoid='{"autoInsertedEnd":true}'></td></tr></tbody></table>
+!! end
+
+# Note: the current result listed for this is not what the original one was,
+# but the original bug was JavaScript injection, which is fixed in any case.
+# It's not clear that the original result listed was any more correct than the
+# current one. Original result:
+# <p>{{{|
+# </p>
+# <li class="&#124;&#124;">
+# }}}blah" onmouseover="alert('hello world');" align="left"<b>MOVE MOUSE CURSOR OVER HERE</b>
+!!test
+Fuzz testing: Parser25 (T8055)
+!! wikitext
+{{{
+|
+<LI CLASS=||
+ >
+}}}blah" onmouseover="alert('hello world');" align="left"'''MOVE MOUSE CURSOR OVER HERE
+!! html/php
+<p>&lt;LI CLASS=blah" onmouseover="alert('hello world');" align="left"<b>MOVE MOUSE CURSOR OVER HERE</b>
+</p>
+!! html/parsoid
+<span about="#mwt1" typeof="mw:Param" data-parsoid='{"pi":[[{"k":"1"},{"k":"2"},{"k":"3"}]]}' data-mw='{"parts":[{"templatearg":{"target":{"wt":"\n"},"params":{"1":{"wt":" \n&lt;LI CLASS="},"2":{"wt":""},"3":{"wt":"\n >\n"}},"i":0}},"blah\" onmouseover=\"alert(&#39;hello world&#39;);\" align=\"left\"&#39;&#39;&#39;MOVE MOUSE CURSOR OVER HERE"]}'>
+</span><p about="#mwt1">&lt;LI CLASS=blah" onmouseover="alert('hello world');" align="left"<b>MOVE MOUSE CURSOR OVER HERE</b></p>
+!! end
+
+!!test
+Fuzz testing: URL adjacent extension (with space, clean)
+!! wikitext
+http://example.com <nowiki>junk</nowiki>
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a> junk
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a> <span typeof="mw:Nowiki">junk</span></p>
+!! end
+
+!!test
+Fuzz testing: URL adjacent extension (no space, dirty; nowiki)
+!! wikitext
+http://example.com<nowiki>junk</nowiki>
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>junk
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a><span typeof="mw:Nowiki">junk</span></p>
+!! end
+
+!! test
+Fuzz testing: URL adjacent extension (no space, dirty; pre)
+!! wikitext
+http://example.com<pre>junk</pre>
+!! html/php
+<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a><pre>junk</pre>
+
+!! html/php+tidy
+<p><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a></p><pre>junk</pre>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a></p><pre typeof="mw:Extension/pre" about="#mwt2" data-mw='{"name":"pre","attrs":{},"body":{"extsrc":"junk"}}'>junk</pre>
+!! end
+
+!! test
+Fuzz testing: image with bogus manual thumbnail
+!! wikitext
+[[Image:foobar.jpg|thumbnail= ]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;">Error creating thumbnail: <div class="thumbcaption"></div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Error mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"manualthumb","ak":"thumbnail= "}]}' data-mw='{"errors":[{"key":"apierror-invalidtitle","message":"Invalid thumbnail title.","params":{"name":""}}],"thumb":""}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{"href":"Image:foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="./Special:FilePath/Foobar.jpg" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"220"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></figure>
+!! end
+
+# Parsoid will emit the newline literally in wt2wt; see next test case.
+!! test
+Fuzz testing: encoded newline in generated HTML replacements (T8577)
+!! options
+parsoid=wt2html
+!! wikitext
+<pre dir="&#10;"></pre>
+!! html/php
+<pre dir="&#10;"></pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/pre" about="#mwt2" dir="
+" data-mw='{"name":"pre","attrs":{"dir":"\n"},"body":{"extsrc":""}}'></pre>
+!! end
+
+!! test
+Fuzz testing: encoded newline in generated HTML replacements, html2wt (T8577)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<pre typeof="mw:Extension/pre" about="#mwt2" dir="
+" data-mw='{"name":"pre","attrs":{"dir":"\n"},"body":{"extsrc":""}}'></pre>
+!! wikitext
+<pre dir="
+"></pre>
+!! html/php
+<pre dir=""></pre>
+
+!! end
+
+!! test
+Templates in extension attributes are not expanded
+!! wikitext
+<pre dir="{{echo|ltr}}"></pre>
+!! html/php
+<pre dir="{{echo|ltr}}"></pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/pre" about="#mwt2" dir="{{echo|ltr}}" data-mw='{"name":"pre","attrs":{"dir":"{{echo|ltr}}"},"body":{"extsrc":""}}'></pre>
+!! end
+
+!! test
+Parsing optional HTML elements (T8171)
+!! options
+!! wikitext
+<table>
+ <tr>
+ <td> Some tabular data</td>
+ <td> More tabular data ...
+ <td> And yet som tabular data</td>
+ </tr>
+</table>
+!! html
+<table>
+ <tr>
+ <td> Some tabular data</td>
+ <td> More tabular data ...
+ </td><td> And yet som tabular data</td>
+ </tr>
+</table>
+
+!! end
+
+!! test
+Correct handling of <td>, <tr> (T8171)
+!! options
+!! wikitext
+<table>
+ <tr>
+ <td> Some tabular data</td>
+ <td> More tabular data ...</td>
+ <td> And yet som tabular data</td>
+ </tr>
+</table>
+!! html
+<table>
+ <tr>
+ <td> Some tabular data</td>
+ <td> More tabular data ...</td>
+ <td> And yet som tabular data</td>
+ </tr>
+</table>
+
+!! end
+
+
+!! test
+Parsing crashing regression (fr:JavaScript)
+!! wikitext
+</body></x>
+!! html
+<p>&lt;/body&gt;&lt;/x&gt;
+</p>
+!! end
+
+!! test
+Inline wiki vs wiki block nesting
+!! wikitext
+'''Bold paragraph
+
+New wiki paragraph
+!! html
+<p><b>Bold paragraph</b>
+</p><p>New wiki paragraph
+</p>
+!! end
+
+# FIXME: The current php output is documented
+# and desired output is the parsoid target.
+!! test
+Inline HTML vs wiki block nesting
+!! wikitext
+<b>Bold paragraph
+
+New wiki paragraph
+!! html/php
+<p><b>Bold paragraph
+</p><p>New wiki paragraph</b>
+</p>
+!! html/parsoid
+<p><b>Bold paragraph</b>
+</p><p>New wiki paragraph
+</p>
+!! end
+
+# Original result was this:
+# <p><b>bold</b><b>bold<i>bolditalics</i></b>
+# </p>
+# While that might be marginally more intuitive, maybe, the six-apostrophe
+# construct is clearly pathological and the result stated here (which is what
+# the parser actually does) is about as reasonable as anything.
+!!test
+Mixing markup for italics and bold
+!! options
+!! wikitext
+'''bold''''''bold''bolditalics'''''
+!! html
+<p>'<i>bold'</i><b>bold<i>bolditalics</i></b>
+</p>
+!! end
+
+
+!! article
+Xyzzyx
+!! text
+Article for special page transclusion test
+!! endarticle
+
+!! test
+Special page transclusion
+!! options
+!! wikitext
+{{Special:Prefixindex/Xyzzyx}}
+!! html
+<ul class="mw-prefixindex-list"><li><a href="/wiki/Xyzzyx" title="Xyzzyx">Xyzzyx</a></li>
+</ul>
+
+!! end
+
+!! test
+Special page transclusion twice (T7021)
+!! options
+!! wikitext
+{{Special:Prefixindex/Xyzzyx}}
+{{Special:Prefixindex/Xyzzyx}}
+!! html
+<ul class="mw-prefixindex-list"><li><a href="/wiki/Xyzzyx" title="Xyzzyx">Xyzzyx</a></li>
+</ul>
+<ul class="mw-prefixindex-list"><li><a href="/wiki/Xyzzyx" title="Xyzzyx">Xyzzyx</a></li>
+</ul>
+
+!! end
+
+!! test
+Transclusion of default MediaWiki message
+!! wikitext
+{{MediaWiki:Mainpage}}
+!! html
+<p>Main Page
+</p>
+!! end
+
+!! test
+Transclusion of nonexistent MediaWiki message
+!! wikitext
+{{MediaWiki:Mainpagexxx}}
+!! html
+<p><a href="/index.php?title=MediaWiki:Mainpagexxx&amp;action=edit&amp;redlink=1" class="new" title="MediaWiki:Mainpagexxx (page does not exist)">MediaWiki:Mainpagexxx</a>
+</p>
+!! end
+
+!! test
+Transclusion of MediaWiki message with underscore
+!! wikitext
+{{MediaWiki:history_short}}
+!! html
+<p>History
+</p>
+!! end
+
+!! test
+Transclusion of MediaWiki message with space
+!! wikitext
+{{MediaWiki:history short}}
+!! html
+<p>History
+</p>
+!! end
+
+!! test
+Invalid header with following text
+!! wikitext
+= x = y
+!! html
+<p>= x = y
+</p>
+!! end
+
+
+!! test
+Section extraction test (section 0)
+!! options
+section=0
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+start
+!! end
+
+!! test
+Section extraction test (section 1)
+!! options
+section=1
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+==a==
+===aa===
+====aaa====
+!! end
+
+!! test
+Section extraction test (section 2)
+!! options
+section=2
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+===aa===
+====aaa====
+!! end
+
+!! test
+Section extraction test (section 3)
+!! options
+section=3
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+====aaa====
+!! end
+
+!! test
+Section extraction test (section 4)
+!! options
+section=4
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+!! end
+
+!! test
+Section extraction test (section 5)
+!! options
+section=5
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+===ba===
+!! end
+
+!! test
+Section extraction test (section 6)
+!! options
+section=6
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+===bb===
+====bba====
+!! end
+
+!! test
+Section extraction test (section 7)
+!! options
+section=7
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+====bba====
+!! end
+
+!! test
+Section extraction test (section 8)
+!! options
+section=8
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+===bc===
+!! end
+
+!! test
+Section extraction test (section 9)
+!! options
+section=9
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+==c==
+===ca===
+!! end
+
+!! test
+Section extraction test (section 10)
+!! options
+section=10
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+===ca===
+!! end
+
+!! test
+Section extraction test (nonexistent section 11)
+!! options
+section=11
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+!! end
+
+!! test
+Section extraction test with bogus heading (section 1)
+!! options
+section=1
+!! wikitext
+==a==
+==bogus== not a legal section
+==b==
+!! html/php
+==a==
+==bogus== not a legal section
+!! end
+
+!! test
+Section extraction test with bogus heading (section 2)
+!! options
+section=2
+!! wikitext
+==a==
+==bogus== not a legal section
+==b==
+!! html/php
+==b==
+!! end
+
+!! test
+Section extraction test with comment after heading (section 1)
+!! options
+section=1
+!! wikitext
+==a==
+==b== <!-- -->
+==c==
+!! html/php
+==a==
+!! end
+
+!! test
+Section extraction test with comment after heading (section 2)
+!! options
+section=2
+!! wikitext
+==a==
+==b== <!-- -->
+==c==
+!! html/php
+==b== <!-- -->
+!! end
+
+!! test
+Section extraction test with bogus <nowiki> heading (section 1)
+!! options
+section=1
+!! wikitext
+==a==
+==bogus== <nowiki>not a legal section</nowiki>
+==b==
+!! html/php
+==a==
+==bogus== <nowiki>not a legal section</nowiki>
+!! end
+
+!! test
+Section extraction test with bogus <nowiki> heading (section 2)
+!! options
+section=2
+!! wikitext
+==a==
+==bogus== <nowiki>not a legal section</nowiki>
+==b==
+!! html/php
+==b==
+!! end
+
+# Formerly testing for T4587, now resolved by the use of unmarked sections
+# instead of respecting commented sections
+!! test
+Section extraction prefixed by comment (section 1)
+!! options
+section=1
+!! wikitext
+<!-- -->==sec1==
+==sec2==
+!! html/php
+==sec2==
+!!end
+
+!! test
+Section extraction prefixed by comment (section 2)
+!! options
+section=2
+!! wikitext
+<!-- -->==sec1==
+==sec2==
+!! html/php
+
+!!end
+
+# Formerly testing for T4607, now resolved by the use of unmarked sections
+# instead of respecting HTML-style headings
+!! test
+Section extraction, mixed wiki and html (section 1)
+!! options
+section=1
+!! wikitext
+<h2>unmarked</h2>
+unmarked
+==1==
+one
+==2==
+two
+!! html/php
+==1==
+one
+!! end
+
+!! test
+Section extraction, mixed wiki and html (section 2)
+!! options
+section=2
+!! wikitext
+<h2>unmarked</h2>
+unmarked
+==1==
+one
+==2==
+two
+!! html/php
+==2==
+two
+!! end
+
+
+# Formerly testing for T5342
+!! test
+Section extraction, heading surrounded by <noinclude>
+!! options
+section=1
+!! wikitext
+<noinclude>==unmarked==</noinclude>
+==marked==
+!! html/php
+==marked==
+!!end
+
+# Test behavior of T21910
+!! test
+Sectiion with all-equals
+!! options
+section=2
+!! wikitext
+===
+The line above must have a trailing space
+=== <!--
+--> <!-- -->
+But just in case it doesn't...
+!! html/php
+=== <!--
+--> <!-- -->
+But just in case it doesn't...
+!! end
+
+!! test
+Section replacement test (section 0)
+!! options
+replace=0,"xxx"
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+xxx
+
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! end
+
+!! test
+Section replacement test (section 1)
+!! options
+replace=1,"xxx"
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+start
+xxx
+
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! end
+
+!! test
+Section replacement test (section 2)
+!! options
+replace=2,"xxx"
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+start
+==a==
+xxx
+
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! end
+
+!! test
+Section replacement test (section 3)
+!! options
+replace=3,"xxx"
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+start
+==a==
+===aa===
+xxx
+
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! end
+
+!! test
+Section replacement test (section 4)
+!! options
+replace=4,"xxx"
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+start
+==a==
+===aa===
+====aaa====
+xxx
+
+==c==
+===ca===
+!! end
+
+!! test
+Section replacement test (section 5)
+!! options
+replace=5,"xxx"
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+start
+==a==
+===aa===
+====aaa====
+==b==
+xxx
+
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! end
+
+!! test
+Section replacement test (section 6)
+!! options
+replace=6,"xxx"
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+xxx
+
+===bc===
+==c==
+===ca===
+!! end
+
+!! test
+Section replacement test (section 7)
+!! options
+replace=7,"xxx"
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+xxx
+
+===bc===
+==c==
+===ca===
+!! end
+
+!! test
+Section replacement test (section 8)
+!! options
+replace=8,"xxx"
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+xxx
+
+==c==
+===ca===
+!!end
+
+!! test
+Section replacement test (section 9)
+!! options
+replace=9,"xxx"
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+xxx
+!! end
+
+!! test
+Section replacement test (section 10)
+!! options
+replace=10,"xxx"
+!! wikitext
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+===ca===
+!! html/php
+start
+==a==
+===aa===
+====aaa====
+==b==
+===ba===
+===bb===
+====bba====
+===bc===
+==c==
+xxx
+!! end
+
+!! test
+Section replacement test with initial whitespace (T15728)
+!! options
+replace=2,"xxx"
+!! wikitext
+ Preformatted initial line
+==a==
+===a===
+!! html/php
+ Preformatted initial line
+==a==
+xxx
+!! end
+
+
+!! test
+Section extraction, heading followed by pre with 20 spaces (T8398)
+!! options
+section=1
+!! wikitext
+==a==
+ a
+!! html/php
+==a==
+ a
+!! end
+
+!! test
+Section extraction, heading followed by pre with 19 spaces (T8398 sanity check)
+!! options
+section=1
+!! wikitext
+==a==
+ a
+!! html/php
+==a==
+ a
+!! end
+
+
+!! test
+Section extraction, <pre> around bogus header (T12309)
+!! options
+section=2
+!! wikitext
+== Section One ==
+<pre>
+=======
+</pre>
+
+== Section Two ==
+stuff
+!! html/php
+== Section Two ==
+stuff
+!! end
+
+!! test
+Section replacement, <pre> around bogus header (T12309)
+!! options
+replace=2,"xxx"
+!! wikitext
+== Section One ==
+<pre>
+=======
+</pre>
+
+== Section Two ==
+stuff
+!! html/php
+== Section One ==
+<pre>
+=======
+</pre>
+
+xxx
+!! end
+
+!! test
+Handling of &#x0A; in URLs
+!! wikitext
+*irc://&#x0A;a
+!! html/php
+<ul><li><a rel="nofollow" class="external free" href="irc://%0Aa">irc://%0Aa</a></li></ul>
+
+!! html/parsoid
+<ul><li><a rel="mw:ExtLink" class="external free" href="irc://%0Aa" data-parsoid='{"stx":"url","a":{"href":"irc://%0Aa"},"sa":{"href":"irc://&amp;#x0A;a"}}'>irc://%0Aa</a></li></ul>
+!! end
+
+!! test
+Handling of %0A in URLs
+!! wikitext
+*irc://%0Aa
+!! html/php
+<ul><li><a rel="nofollow" class="external free" href="irc://%0Aa">irc://%0Aa</a></li></ul>
+
+!! html/parsoid
+<ul><li><a rel="mw:ExtLink" class="external free" href="irc://%0Aa">irc://%0Aa</a></li></ul>
+!! end
+
+# The PHP parser strips the empty tags out for giggles; parsoid doesn't.
+!! test
+5 quotes, code coverage +1 line
+!! options
+parsoid=wt2html
+!! wikitext
+'''''
+!! html/php
+!! html/parsoid
+<b><i></i></b>
+!! end
+
+# same html as previous, but wikitext adjusted to match parsoid html2wt
+# note that wt2html and html2html will put the <i> before the <b>
+!! test
+5 quotes, code coverage +1 line w/ nowiki (1)
+!! options
+parsoid=wt2wt,html2wt
+!! wikitext
+'''''<nowiki/>'''''
+!! html/php
+<p><i></i>
+</p>
+!! html/parsoid
+<p><b><i></i></b></p>
+!! end
+
+# same as previous, just swapping the <i> and <b>
+!! test
+5 quotes, code coverage +1 line w/ nowiki (2)
+!! wikitext
+'''''<nowiki/>'''''
+!! html/php
+<p><i></i>
+</p>
+!! html/parsoid
+<p><i><b></b></i></p>
+!! end
+
+!! test
+Special:Search page linking.
+!! wikitext
+{{Special:search}}
+!! html
+<p><a href="/wiki/Special:Search" title="Special:Search">Special:Search</a>
+</p>
+!! end
+
+!! test
+{{!}} is a magic word
+!! wikitext
+{{!}} is a magic word there and {{!}} is still a magic word here
+| is not a magic word here but {{!}} is still a magic word here
+!! html/php
+<p>| is a magic word there and | is still a magic word here
+| is not a magic word here but | is still a magic word here
+</p>
+!! html/parsoid
+<p><span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[]]}' data-mw='{"parts":[{"template":{"target":{"wt":"!","function":"!"},"params":{},"i":0}}]}'>|</span> is a magic word there and <span about="#mwt2" typeof="mw:Transclusion" data-parsoid='{"pi":[[]]}' data-mw='{"parts":[{"template":{"target":{"wt":"!","function":"!"},"params":{},"i":0}}]}'>|</span> is still a magic word here
+| is not a magic word here but <span about="#mwt3" typeof="mw:Transclusion" data-parsoid='{"pi":[[]]}' data-mw='{"parts":[{"template":{"target":{"wt":"!","function":"!"},"params":{},"i":0}}]}'>|</span> is still a magic word here</p>
+!! end
+
+!! test
+Say the magic word
+!! options
+title=[[Parser test]]
+!! wikitext
+*{{PAGENAME}}
+*{{PAGENAMEE}}
+*{{FULLPAGENAME}}
+*{{FULLPAGENAMEE}}
+*{{BASEPAGENAME}}
+*{{BASEPAGENAMEE}}
+*{{SUBPAGENAME}}
+*{{SUBPAGENAMEE}}
+*{{ROOTPAGENAME}}
+*{{ROOTPAGENAMEE}}
+*{{TALKPAGENAME}}
+*{{TALKPAGENAMEE}}
+*{{SUBJECTPAGENAME}}
+*{{SUBJECTPAGENAMEE}}
+*{{NAMESPACEE}}
+*{{NAMESPACE}}
+*{{NAMESPACENUMBER}}
+*{{TALKSPACE}}
+*{{TALKSPACEE}}
+*{{SUBJECTSPACE}}
+*{{SUBJECTSPACEE}}
+*{{Dynamic|{{NUMBEROFUSERS}}|{{NUMBEROFPAGES}}|{{CURRENTVERSION}}|{{CONTENTLANGUAGE}}|{{DIRECTIONMARK}}|{{CURRENTTIMESTAMP}}|{{NUMBEROFARTICLES}}}}
+!! html
+<ul><li>Parser test</li>
+<li>Parser_test</li>
+<li>Parser test</li>
+<li>Parser_test</li>
+<li>Parser test</li>
+<li>Parser_test</li>
+<li>Parser test</li>
+<li>Parser_test</li>
+<li>Parser test</li>
+<li>Parser_test</li>
+<li>Talk:Parser test</li>
+<li>Talk:Parser_test</li>
+<li>Parser test</li>
+<li>Parser_test</li>
+<li></li>
+<li></li>
+<li>0</li>
+<li>Talk</li>
+<li>Talk</li>
+<li></li>
+<li></li>
+<li><a href="/index.php?title=Template:Dynamic&amp;action=edit&amp;redlink=1" class="new" title="Template:Dynamic (page does not exist)">Template:Dynamic</a></li></ul>
+
+!! end
+### Note: Above tests excludes the "{{NUMBEROFADMINS}}" magic word because it generates a MySQL error when included.
+
+!! test
+Gallery with valid attributes
+!! wikitext
+<gallery type="123" summary="345">
+File:File:Foobar.jpg
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional" type="123">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="height: 150px;">File:Foobar.jpg</div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" type="123" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{"type":"123","summary":"345"},"body":{"extsrc":"\nFile:File:Foobar.jpg\n"}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:File:Foobar.jpg"><img resource="./File:File:Foobar.jpg" src="./Special:FilePath/File:Foobar.jpg" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+</ul>
+!! end
+
+## Parsoid thinks the "centre" here is a property, not a caption.
+!! test
+Gallery
+!! options
+parsoid={
+ "modes": ["wt2html"],
+ "nativeGallery": true
+}
+!! wikitext
+<gallery>
+image1.png |
+image2.gif|||||
+
+image3|
+image4 |300px| centre
+ image5.svg| http://///////
+[[x|xx]]]]
+* image6
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="height: 150px;">Image1.png</div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="height: 150px;">Image2.gif</div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="height: 150px;">Image3</div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="height: 150px;">Image4</div>
+ <div class="gallerytext">
+<pre>centre
+</pre>
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="height: 150px;">Image5.svg</div>
+ <div class="gallerytext">
+<p><a rel="nofollow" class="external free" href="http://///////">http://///////</a>
+</p>
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="height: 150px;">* image6</div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt3" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Image1.png"><img resource="./File:Image1.png" src="./Special:FilePath/Image1.png" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Image2.gif"><img resource="./File:Image2.gif" src="./Special:FilePath/Image2.gif" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Image3"><img resource="./File:Image3" src="./Special:FilePath/Image3" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Image4"><img resource="./File:Image4" src="./Special:FilePath/Image4" height="300" width="300"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Image5.svg"><img resource="./File:Image5.svg" src="./Special:FilePath/Image5.svg" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"> <a rel="mw:ExtLink" class="external free" href="http://///////">http://///////</a></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:*_image6"><img resource="./File:*_image6" src="./Special:FilePath/*_image6" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+</ul>
+!! end
+
+!! test
+Gallery (with options, html)
+!! options
+parsoid={
+ "modes": ["wt2html", "html2html"],
+ "nativeGallery": true
+}
+!! wikitext
+<gallery widths="70px" heights="40px" perrow="2" caption="Foo [[Main Page]]">
+File:Nonexistent.jpg|caption
+File:Nonexistent.jpg
+image:foobar.jpg|some '''caption''' [[Main Page]]
+image:foobar.jpg
+image:foobar.jpg|Blabla|alt=This is a foo-bar.|blabla.
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional" style="max-width: 226px;_width: 226px;">
+ <li class='gallerycaption'>Foo <a href="/wiki/Main_Page" title="Main Page">Main Page</a></li>
+ <li class="gallerybox" style="width: 105px"><div style="width: 105px">
+ <div class="thumb" style="height: 70px;">Nonexistent.jpg</div>
+ <div class="gallerytext">
+<p>caption
+</p>
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 105px"><div style="width: 105px">
+ <div class="thumb" style="height: 70px;">Nonexistent.jpg</div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 105px"><div style="width: 105px">
+ <div class="thumb" style="width: 100px;"><div style="margin:31px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" width="70" height="8" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/105px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/140px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p>some <b>caption</b> <a href="/wiki/Main_Page" title="Main Page">Main Page</a>
+</p>
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 105px"><div style="width: 105px">
+ <div class="thumb" style="width: 100px;"><div style="margin:31px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" width="70" height="8" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/105px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/140px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 105px"><div style="width: 105px">
+ <div class="thumb" style="width: 100px;"><div style="margin:31px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="This is a foo-bar." src="http://example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" width="70" height="8" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/105px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/140px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p>blabla.
+</p>
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" style="max-width: 226px; _width: 226px;" typeof="mw:Extension/gallery" about="#mwt3" data-mw='{"name":"gallery","attrs":{"widths":"70px","heights":"40px","perrow":"2"},"body":{}}'>
+<li class="gallerycaption">Foo <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="40" width="70"/></a></figure-inline></div><div class="gallerytext">caption</div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="40" width="70"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext">some <b>caption</b> <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="This is a foo-bar." resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext">blabla.</div></li>
+</ul>
+!! end
+
+!! test
+Gallery (with options, extsrc)
+!! options
+parsoid={
+ "nativeGallery": false
+}
+!! wikitext
+<gallery widths="70px" heights="40px" perrow="2" caption="Foo [[Main Page]]">
+File:Nonexistent.jpg|caption
+File:Nonexistent.jpg
+image:foobar.jpg|some '''caption''' [[Main Page]]
+image:foobar.jpg
+image:foobar.jpg|Blabla|alt=This is a foo-bar.|blabla.
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional" style="max-width: 226px;_width: 226px;">
+ <li class='gallerycaption'>Foo <a href="/wiki/Main_Page" title="Main Page">Main Page</a></li>
+ <li class="gallerybox" style="width: 105px"><div style="width: 105px">
+ <div class="thumb" style="height: 70px;">Nonexistent.jpg</div>
+ <div class="gallerytext">
+<p>caption
+</p>
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 105px"><div style="width: 105px">
+ <div class="thumb" style="height: 70px;">Nonexistent.jpg</div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 105px"><div style="width: 105px">
+ <div class="thumb" style="width: 100px;"><div style="margin:31px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" width="70" height="8" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/105px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/140px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p>some <b>caption</b> <a href="/wiki/Main_Page" title="Main Page">Main Page</a>
+</p>
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 105px"><div style="width: 105px">
+ <div class="thumb" style="width: 100px;"><div style="margin:31px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" width="70" height="8" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/105px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/140px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 105px"><div style="width: 105px">
+ <div class="thumb" style="width: 100px;"><div style="margin:31px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="This is a foo-bar." src="http://example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" width="70" height="8" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/105px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/140px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p>blabla.
+</p>
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" style="max-width: 226px; _width: 226px;" typeof="mw:Extension/gallery" about="#mwt3" data-mw='{"name":"gallery","attrs":{"widths":"70px","heights":"40px","perrow":"2","caption":"Foo [[Main Page]]"},"body":{"extsrc":"\nFile:Nonexistent.jpg|caption\nFile:Nonexistent.jpg\nimage:foobar.jpg|some &#39;&#39;&#39;caption&#39;&#39;&#39; [[Main Page]]\nimage:foobar.jpg\nimage:foobar.jpg|Blabla|alt=This is a foo-bar.|blabla.\n"}}'>
+<li class="gallerycaption">Foo <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="40" width="70"/></a></figure-inline></div><div class="gallerytext">caption</div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="40" width="70"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext">some <b>caption</b> <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="This is a foo-bar." resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext">blabla.</div></li>
+</ul>
+!! end
+
+!! test
+Gallery (without px units)
+!! wikitext
+<gallery widths="70" heights="40">
+File:Foobar.jpg
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 105px"><div style="width: 105px">
+ <div class="thumb" style="width: 100px;"><div style="margin:31px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" width="70" height="8" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/105px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/140px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{"widths":"70","heights":"40"},"body":{"extsrc":"\nFile:Foobar.jpg\n"}}'>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext"></div></li>
+</ul>
+!! end
+
+!! test
+Gallery (with invalid units)
+!! wikitext
+<gallery widths="70em" heights="40em">
+File:Foobar.jpg
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{"widths":"70em","heights":"40em"},"body":{"extsrc":"\nFile:Foobar.jpg\n"}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+</ul>
+!! end
+
+!! test
+Gallery with link that has fragment
+!! options
+parsoid={
+ "modes": ["wt2html", "html2html"],
+ "nativeGallery": true
+}
+!! wikitext
+<gallery>
+image:foobar.jpg|link=Main_Page
+image:foobar.jpg|link=Main_Page#section
+image:foobar.jpg|link=Main Page#section|caption
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/Main_Page"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/Main_Page#section"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/Main_Page#section"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p>caption
+</p>
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./Main_Page"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./Main_Page#section"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./Main_Page#section"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext">caption</div></li>
+</ul>
+!! end
+
+## Whoops, Parsoid shouldn't be parsing templates in the attribute caption!
+!! test
+Gallery with template inside caption
+!! options
+parsoid={
+ "nativeGallery": true
+}
+!! wikitext
+<gallery caption="{{echo|hi}}">
+File:Foobar.jpg|{{echo|ho}}
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class='gallerycaption'>{{echo|hi}}</li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p>ho
+</p>
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt6" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
+<li class="gallerycaption"><span about="#mwt3" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"hi"}},"i":0}}]}'>hi</span></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><span about="#mwt5" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"ho"}},"i":0}}]}'>ho</span></div></li>
+</ul>
+!! end
+
+!! test
+Gallery with wikitext inside caption
+!! options
+parsoid={
+ "nativeGallery": true
+}
+!! wikitext
+<gallery>
+File:Foobar.jpg|alt=galleryalt|[[File:Foobar.jpg|alt=inneralt|20x20px|desc]]
+File:Foobar.jpg|alt=galleryalt|{{Test|unamedParam|alt=param}}
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="galleryalt" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="desc"><img alt="inneralt" src="http://example.com/images/thumb/3/3a/Foobar.jpg/20px-Foobar.jpg" width="20" height="2" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/30px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/40px-Foobar.jpg 2x" /></a>
+</p>
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="galleryalt" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p>This is a test template
+</p>
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt6" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><figure-inline typeof="mw:Image" data-mw='{"caption":"desc"}'><a href="./File:Foobar.jpg"><img alt="inneralt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/20px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="2" width="20"/></a></figure-inline></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><span about="#mwt4" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"Test","href":"./Template:Test"},"params":{"1":{"wt":"unamedParam"},"alt":{"wt":"param"}},"i":0}}]}'>This is a test template</span></div></li>
+</ul>
+!! end
+
+!! test
+Gallery (with showfilename option)
+!! options
+parsoid={
+ "nativeGallery": true
+}
+!! wikitext
+<gallery showfilename="">
+File:Nonexistent.jpg|caption
+File:Nonexistent.jpg
+File:Foobar.jpg|some '''caption''' [[Main Page]]
+File:Foobar.jpg
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="height: 150px;">Nonexistent.jpg</div>
+ <div class="gallerytext">
+<p><a href="/wiki/File:Nonexistent.jpg" class="galleryfilename galleryfilename-truncate" title="File:Nonexistent.jpg">Nonexistent.jpg</a>
+caption
+</p>
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="height: 150px;">Nonexistent.jpg</div>
+ <div class="gallerytext">
+<p><a href="/wiki/File:Nonexistent.jpg" class="galleryfilename galleryfilename-truncate" title="File:Nonexistent.jpg">Nonexistent.jpg</a>
+</p>
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p><a href="/wiki/File:Foobar.jpg" class="galleryfilename galleryfilename-truncate" title="File:Foobar.jpg">Foobar.jpg</a>
+some <b>caption</b> <a href="/wiki/Main_Page" title="Main Page">Main Page</a>
+</p>
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p><a href="/wiki/File:Foobar.jpg" class="galleryfilename galleryfilename-truncate" title="File:Foobar.jpg">Foobar.jpg</a>
+</p>
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt3" data-mw='{"name":"gallery","attrs":{"showfilename":""},"body":{}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"><a href="./File:Nonexistent.jpg" class="galleryfilename galleryfilename-truncate" title="File:Nonexistent.jpg">File:Nonexistent.jpg</a>caption</div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"><a href="./File:Nonexistent.jpg" class="galleryfilename galleryfilename-truncate" title="File:Nonexistent.jpg">File:Nonexistent.jpg</a></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><a href="./File:Foobar.jpg" class="galleryfilename galleryfilename-truncate" title="File:Foobar.jpg">File:Foobar.jpg</a>some <b>caption</b> <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><a href="./File:Foobar.jpg" class="galleryfilename galleryfilename-truncate" title="File:Foobar.jpg">File:Foobar.jpg</a></div></li>
+</ul>
+!! end
+
+## Should Parsoid be preserving these variations? See T151367
+!! test
+Gallery (with namespace-less filenames)
+!! options
+parsoid={
+ "modes": ["wt2html", "html2html"],
+ "nativeGallery": true
+}
+!! wikitext
+<gallery>
+File:Nonexistent.jpg
+Nonexistent.jpg
+image:foobar.jpg
+foobar.jpg
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="height: 150px;">Nonexistent.jpg</div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="height: 150px;">Nonexistent.jpg</div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+</ul>
+!! end
+
+!! test
+Gallery override link with wikilink (T36852)
+!! options
+parsoid={
+ "nativeGallery": true
+}
+!! wikitext
+<gallery>
+File:Foobar.jpg|alt=galleryalt|link=Wikilink
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/Wikilink"><img alt="galleryalt" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./Wikilink"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+</ul>
+!! end
+
+!! test
+Gallery override link with absolute external link (T36852)
+!! options
+parsoid={
+ "nativeGallery": true
+}
+!! wikitext
+<gallery>
+File:Foobar.jpg|alt=galleryalt|link=http://www.example.org
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="http://www.example.org"><img alt="galleryalt" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="http://www.example.org"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+</ul>
+!! end
+
+!! test
+Gallery override link with absolute external link with LanguageConverter
+!! options
+language=zh
+!! wikitext
+<gallery>
+File:foobar.jpg|caption|alt=galleryalt|link=http://www.example.org
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="http://www.example.org"><img alt="galleryalt" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p>caption
+</p>
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{},"body":{"extsrc":"\nFile:foobar.jpg|caption|alt=galleryalt|link=http://www.example.org\n"}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="http://www.example.org"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext">caption</div></li>
+</ul>
+!! end
+
+!! test
+Gallery override link with malicious javascript (T36852)
+!! options
+parsoid={
+ "modes": ["wt2html", "html2html"],
+ "nativeGallery": true
+}
+!! wikitext
+<gallery>
+File:Foobar.jpg|alt=galleryalt|link=" onclick="alert('malicious javascript code!');
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/%22_onclick%3D%22alert(%27malicious_javascript_code!%27);"><img alt="galleryalt" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./%22_onclick=%22alert('malicious_javascript_code!');"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+</ul>
+!! end
+
+# Note that parsoid uses the invalid link as a caption, PHP does not.
+!! test
+Gallery with invalid title as link (T45964)
+!! options
+parsoid={
+ "modes": ["wt2html", "html2html"],
+ "nativeGallery": true
+}
+!! wikitext
+<gallery>
+File:Foobar.jpg|link=<
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext">link=&lt;</div></li>
+</ul>
+!! end
+
+!! test
+Serialize gallery without attrs in data-mw
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "nativeGallery": true
+}
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","body":{}}'>
+<li class="gallerycaption">123</li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span>File:Test.png</span></div><div class="gallerytext"></div></li>
+</ul>
+!! wikitext
+<gallery caption="123">
+File:Test.png
+</gallery>
+!! end
+
+!! test
+Gallery with class and style attributes
+!! options
+parsoid={
+ "nativeGallery": true
+}
+!! wikitext
+<gallery class="center" style="text-align: center;">
+File:Foobar.jpg
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional center" style="text-align: center;">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional center" style="text-align: center;" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{"class":"center","style":"text-align: center;"},"body":{}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+</ul>
+!! end
+
+!! test
+Gallery in slideshow mode
+!! options
+parsoid={
+ "nativeGallery": true
+}
+!! wikitext
+<gallery mode="slideshow" showthumbnails="">
+File:Foobar.jpg
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-slideshow" data-showthumbnails="1">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-slideshow" data-showthumbnails="1" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{"mode":"slideshow","showthumbnails":""},"body":{}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+</ul>
+!! end
+
+!! test
+HTML Hex character encoding (spells the word "JavaScript")
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+&#x4A;&#x061;&#x0076;&#x00061;&#x000053;&#x0000063;&#114;&#x0000069;&#00000112;&#x0000000074;
+!! html/php
+<p>&#x4a;&#x61;&#x76;&#x61;&#x53;&#x63;&#114;&#x69;&#112;&#x74;
+</p>
+!! html/parsoid
+<p><span typeof="mw:Entity">J</span><span typeof="mw:Entity">a</span><span typeof="mw:Entity">v</span><span typeof="mw:Entity">a</span><span typeof="mw:Entity">S</span><span typeof="mw:Entity">c</span><span typeof="mw:Entity">r</span><span typeof="mw:Entity">i</span><span typeof="mw:Entity">p</span><span typeof="mw:Entity">t</span></p>
+!! end
+
+!! test
+HTML Hex character encoding bogus encoding (T28437 regression check)
+!! wikitext
+&#xsee;&#XSEE;
+!! html
+<p>&amp;#xsee;&amp;#XSEE;
+</p>
+!! end
+
+!! test
+HTML Hex character encoding mixed case
+!! options
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+&#xEE;&#Xee;
+!! html/php
+<p>&#xee;&#xee;
+</p>
+!! html/parsoid
+<p><span typeof="mw:Entity">î</span><span typeof="mw:Entity">î</span></p>
+!! end
+
+# See: https://www.w3.org/TR/html5/syntax.html#character-references
+# Note that U+000C (form feed) is not a valid XML character, so
+# it is banned even though allowed in HTML5.
+!! test
+Illegal character references (T106578)
+!! wikitext
+; Null: &#00;
+; FF: &#xC;
+; CR: &#xD;
+; Control (low): &#8;
+; Control (high): &#x7F; &#x9F;
+; Surrogate: &#xD83D;&#xDCA9;
+; This is an okay astral character: &#x1F4A9;
+!! html+tidy
+<dl><dt>Null</dt>
+<dd>&amp;#00;</dd>
+<dt>FF</dt>
+<dd>&amp;#xC;</dd>
+<dt>CR</dt>
+<dd>&amp;#xD;</dd>
+<dt>Control (low)</dt>
+<dd>&amp;#8;</dd>
+<dt>Control (high)</dt>
+<dd>&amp;#x7F; &amp;#x9F;</dd>
+<dt>Surrogate</dt>
+<dd>&amp;#xD83D;&amp;#xDCA9;</dd>
+<dt>This is an okay astral character</dt>
+<dd>&#x1f4a9;</dd></dl>
+!! end
+
+!! test
+__FORCETOC__ override
+!! wikitext
+__NEWSECTIONLINK__
+__FORCETOC__
+!! html/php
+<p><br />
+</p>
+!! end
+
+!! test
+ISBN code coverage
+!! wikitext
+ISBN 978-0-1234-56&#x20;789
+!! html/php
+<p><a href="/wiki/Special:BookSources/9780123456" class="internal mw-magiclink-isbn">ISBN 978-0-1234-56</a>&#x20;789
+</p>
+!! html/parsoid
+<p><a href="./Special:BookSources/9780123456" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 978-0-1234-56</a><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#x20;","srcContent":" "}'> </span>789</p>
+!! end
+
+!! test
+ISBN followed by 5 spaces
+!! wikitext
+ISBN
+!! html
+<p>ISBN
+</p>
+!! end
+
+!! test
+Double ISBN
+!! wikitext
+ISBN ISBN 1234567890
+!! html/php
+<p>ISBN <a href="/wiki/Special:BookSources/1234567890" class="internal mw-magiclink-isbn">ISBN 1234567890</a>
+</p>
+!! html/parsoid
+<p>ISBN <a href="./Special:BookSources/1234567890" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 1234567890</a></p>
+!! end
+
+# Uppercase X and lowercase x as well
+!! test
+ISBN with an X
+!! wikitext
+ISBN 3-462-04561-X
+ISBN 3-462-04561-x
+ISBN 080442957X
+ISBN 080442957x
+ISBN 978080442957X
+ISBN 978080442957x
+!! html/php
+<p><a href="/wiki/Special:BookSources/346204561X" class="internal mw-magiclink-isbn">ISBN 3-462-04561-X</a>
+<a href="/wiki/Special:BookSources/346204561X" class="internal mw-magiclink-isbn">ISBN 3-462-04561-x</a>
+<a href="/wiki/Special:BookSources/080442957X" class="internal mw-magiclink-isbn">ISBN 080442957X</a>
+<a href="/wiki/Special:BookSources/080442957X" class="internal mw-magiclink-isbn">ISBN 080442957x</a>
+<a href="/wiki/Special:BookSources/978080442957X" class="internal mw-magiclink-isbn">ISBN 978080442957X</a>
+<a href="/wiki/Special:BookSources/978080442957X" class="internal mw-magiclink-isbn">ISBN 978080442957x</a>
+</p>
+!! html/parsoid
+<p><a href="./Special:BookSources/346204561X" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 3-462-04561-X</a>
+<a href="./Special:BookSources/346204561X" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 3-462-04561-x</a>
+<a href="./Special:BookSources/080442957X" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 080442957X</a>
+<a href="./Special:BookSources/080442957X" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 080442957x</a>
+<a href="./Special:BookSources/978080442957X" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 978080442957X</a>
+<a href="./Special:BookSources/978080442957X" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 978080442957x</a></p>
+!! end
+
+!! test
+ISBN with empty prefix (parsoid test)
+!! wikitext
+ISBN 1234567890
+!! html/php
+<p><a href="/wiki/Special:BookSources/1234567890" class="internal mw-magiclink-isbn">ISBN 1234567890</a>
+</p>
+!! html/parsoid
+<p><a href="./Special:BookSources/1234567890" rel="mw:WikiLink">ISBN 1234567890</a></p>
+!! end
+
+!! test
+T24905: <abbr> followed by ISBN followed by </a>
+!! wikitext
+<abbr>(fr)</abbr> ISBN 2753300917 [http://www.example.com example.com]
+!! html/php
+<p><abbr>(fr)</abbr> <a href="/wiki/Special:BookSources/2753300917" class="internal mw-magiclink-isbn">ISBN 2753300917</a> <a rel="nofollow" class="external text" href="http://www.example.com">example.com</a>
+</p>
+!! html/parsoid
+<p><abbr data-parsoid='{"stx":"html"}'>(fr)</abbr> <a href="./Special:BookSources/2753300917" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 2753300917</a> <a rel="mw:ExtLink" class="external text" href="http://www.example.com">example.com</a></p>
+!! end
+
+!! test
+Double RFC
+!! wikitext
+RFC RFC 1234
+!! html
+<p>RFC <a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc1234">RFC 1234</a>
+</p>
+!! end
+
+!! test
+Double RFC with a wiki link
+!! wikitext
+RFC [[RFC 1234]]
+!! html
+<p>RFC <a href="/index.php?title=RFC_1234&amp;action=edit&amp;redlink=1" class="new" title="RFC 1234 (page does not exist)">RFC 1234</a>
+</p>
+!! end
+
+!! test
+RFC code coverage
+!! wikitext
+RFC 983&#x20;987
+!! html/php
+<p><a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc983">RFC 983</a>&#x20;987
+</p>
+!! html/parsoid
+<p><a href="https://tools.ietf.org/html/rfc983" rel="mw:ExtLink" class="external text" data-parsoid='{"stx":"magiclink"}'>RFC 983</a><span typeof="mw:Entity" data-parsoid='{"src":"&amp;#x20;","srcContent":" "}'> </span>987</p>
+!! end
+
+!! test
+Centre-aligned image
+!! wikitext
+[[Image:foobar.jpg|centre]]
+!! html/php
+<div class="center"><div class="floatnone"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-center" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"center","ak":"centre"}]}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></figure>
+!! end
+
+!! test
+None-aligned image
+!! wikitext
+[[Image:foobar.jpg|none]]
+!! html/php
+<div class="floatnone"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></div>
+
+!! html/parsoid
+<figure class="mw-default-size mw-halign-none" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></figure>
+!! end
+
+!! test
+Width + Height sized image (using px) (height is ignored)
+!! wikitext
+[[Image:foobar.jpg|640x480px]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/640px-Foobar.jpg" width="640" height="73" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/960px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/1280px-Foobar.jpg 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline typeof="mw:Image" data-parsoid='{"optList":[{"ck":"width","ak":"640x480px"}]}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/640px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="73" width="640" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"73","width":"640"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Width-sized image (using px, no following whitespace)
+!! wikitext
+[[Image:foobar.jpg|640px]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/640px-Foobar.jpg" width="640" height="73" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/960px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/1280px-Foobar.jpg 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline typeof="mw:Image" data-parsoid='{"optList":[{"ck":"width","ak":"640px"}]}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/640px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="73" width="640" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"73","width":"640"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Width-sized image (using px, with following whitespace - test regression from r39467)
+!! wikitext
+[[Image:foobar.jpg|640px ]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/640px-Foobar.jpg" width="640" height="73" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/960px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/1280px-Foobar.jpg 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline typeof="mw:Image" data-parsoid='{"optList":[{"ck":"width","ak":"640px "}]}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/640px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="73" width="640" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"73","width":"640"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></figure-inline></p>
+!!end
+
+!! test
+Width-sized image (using px, with preceding whitespace - test regression from r39467)
+!! wikitext
+[[Image:foobar.jpg| 640px]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/640px-Foobar.jpg" width="640" height="73" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/960px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/1280px-Foobar.jpg 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline typeof="mw:Image" data-parsoid='{"optList":[{"ck":"width","ak":" 640px"}]}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/640px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="73" width="640" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"73","width":"640"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Image with page parameter
+!! options
+djvu
+!! wikitext
+[[File:LoremIpsum.djvu|page=2]]
+!! html/php
+<p><a href="/index.php?title=File:LoremIpsum.djvu&amp;page=2" class="image"><img alt="LoremIpsum.djvu" src="http://example.com/images/thumb/5/5f/LoremIpsum.djvu/page2-2480px-LoremIpsum.djvu.jpg" width="2480" height="3508" srcset="http://example.com/images/thumb/5/5f/LoremIpsum.djvu/page2-3720px-LoremIpsum.djvu.jpg 1.5x, http://example.com/images/thumb/5/5f/LoremIpsum.djvu/page2-4960px-LoremIpsum.djvu.jpg 2x" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"page","ak":"page=2"}]}' data-mw='{"page":"2"}'><a href="./File:LoremIpsum.djvu" data-parsoid='{"a":{"href":"./File:LoremIpsum.djvu"},"sa":{"href":"File:LoremIpsum.djvu"}}'><img resource="./File:LoremIpsum.djvu" src="//example.com/images/5/5f/LoremIpsum.djvu" data-file-width="2480" data-file-height="3508" data-file-type="bitmap" height="3508" width="2480" data-parsoid='{"a":{"resource":"./File:LoremIpsum.djvu","height":"3508","width":"2480"},"sa":{"resource":"File:LoremIpsum.djvu"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Another italics / bold test
+!! wikitext
+ ''' ''x'
+!! html
+<pre>'<i> </i>x'
+</pre>
+!!end
+
+# FIXME: The php output seems broken. It's interleaving some open/close tags.
+!! test
+dt/dd/dl test
+!! wikitext
+:;;;::
+!! html/php
+<dl><dd><dl><dt><dl><dt><dl><dt><dl><dd><dl><dd></dt></dl></dd></dl></dd></dl></dd></dl></dd></dl></dd></dl>
+
+!! html/parsoid
+<dl><dd><dl><dt><dl><dt><dl><dt><dl><dd><dl><dd></dd></dl></dd></dl></dt></dl></dt></dl></dt></dl></dd></dl>
+
+!!end
+
+# Images with the "|" character in external URLs in comment tags; Eats half the comment, leaves unmatched "</a>" tag.
+!! test
+Images with the "|" character in the comment
+!! wikitext
+[[File:Foobar.jpg|thumb|An [http://test/?param1=|left|&param2=|x external] URL]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>An <a rel="nofollow" class="external text" href="http://test/?param1=%7Cleft%7C&amp;param2=%7Cx">external</a> URL</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>An <a rel="mw:ExtLink" class="external text" href="http://test/?param1=%7Cleft%7C&amp;param2=%7Cx" data-parsoid='{"a":{"href":"http://test/?param1=%7Cleft%7C&amp;param2=%7Cx"},"sa":{"href":"http://test/?param1=|left|&amp;param2=|x"}}'>external</a> URL</figcaption></figure>
+!! end
+
+!! test
+[Before] HTML without raw HTML enabled ($wgRawHtml==false)
+!! wikitext
+<html><script>alert(1);</script></html>
+!! html
+<p>&lt;html&gt;&lt;script&gt;alert(1);&lt;/script&gt;&lt;/html&gt;
+</p>
+!! end
+
+!! test
+HTML with raw HTML ($wgRawHtml==true)
+!! options
+wgRawHtml=1
+!! wikitext
+<html><script>alert(1);</script></html>
+!! html/php
+<p><script>alert(1);</script>
+</p>
+!! end
+
+!! test
+Parents of subpages, one level up
+!! options
+subpage title=[[Subpage test/L1/L2/L3]]
+!! wikitext
+[[../|L2]]
+!! html
+<p><a href="/index.php?title=Subpage_test/L1/L2&amp;action=edit&amp;redlink=1" class="new" title="Subpage test/L1/L2 (page does not exist)">L2</a>
+</p>
+!! end
+
+
+!! test
+Parents of subpages, one level up, not named
+!! options
+subpage title=[[Subpage test/L1/L2/L3]]
+!! wikitext
+[[../]]
+!! html
+<p><a href="/index.php?title=Subpage_test/L1/L2&amp;action=edit&amp;redlink=1" class="new" title="Subpage test/L1/L2 (page does not exist)">Subpage test/L1/L2</a>
+</p>
+!! end
+
+
+
+!! test
+Parents of subpages, two levels up
+!! options
+subpage title=[[Subpage test/L1/L2/L3]]
+!! wikitext
+[[../../|L1]]2
+
+[[../../|L1]]l
+!! html
+<p><a href="/index.php?title=Subpage_test/L1&amp;action=edit&amp;redlink=1" class="new" title="Subpage test/L1 (page does not exist)">L1</a>2
+</p><p><a href="/index.php?title=Subpage_test/L1&amp;action=edit&amp;redlink=1" class="new" title="Subpage test/L1 (page does not exist)">L1l</a>
+</p>
+!! end
+
+!! test
+Parents of subpages, two levels up, without trailing slash or name.
+!! options
+subpage title=[[Subpage test/L1/L2/L3]]
+!! wikitext
+[[../..]]
+!! html
+<p>[[../..]]
+</p>
+!! end
+
+!! test
+Parents of subpages, two levels up, with lots of extra trailing slashes.
+!! options
+subpage title=[[Subpage test/L1/L2/L3]]
+!! wikitext
+[[../../////]]
+!! html
+<p><a href="/index.php?title=Subpage_test/L1&amp;action=edit&amp;redlink=1" class="new" title="Subpage test/L1 (page does not exist)">Subpage test/L1</a>
+</p>
+!! end
+
+!! article
+Subpage test/L1/L2/L3Sibling
+!! text
+Sibling article
+!! endarticle
+
+!! test
+Transclusion of a sibling page (one level up)
+!! options
+subpage title=[[Subpage test/L1/L2/L3]]
+!! wikitext
+{{../L3Sibling}}
+!! html
+<p>Sibling article
+</p>
+!! end
+
+!! test
+Transclusion of a child page
+!! options
+subpage title=[[Subpage test/L1/L2]]
+!! wikitext
+{{/L3Sibling}}
+!! html
+<p>Sibling article
+</p>
+!! end
+
+# This is wt2html only in Parsoid because we add <nowiki>
+# because of {{..}} and we don't expect to fix that to
+# eliminate the nowikis selective for {{..}} markup.
+!! test
+Non-transclusion because of too many up levels
+!! options
+subpage title=[[Subpage test/L1/L2/L3]]
+parsoid=wt2html
+!! wikitext
+{{../../../../More than parent}}
+!! html/php
+<p>{{../../../../More than parent}}
+</p>
+!! html/parsoid
+<p>{{../../../../More than parent}}</p>
+!! end
+
+!! test
+Definition list code coverage
+!! wikitext
+;title : def
+;title : def
+;title: def
+!! html/php
+<dl><dt>title &#160;</dt>
+<dd>def</dd>
+<dt>title&#160;</dt>
+<dd>def</dd>
+<dt>title</dt>
+<dd>def</dd></dl>
+
+!! html/parsoid
+<dl><dt>title <span typeof="mw:Placeholder"> </span></dt><dd> def</dd>
+<dt>title<span typeof="mw:Placeholder"> </span></dt><dd> def</dd>
+<dt>title</dt><dd> def</dd></dl>
+!! end
+
+!! test
+Don't fall for the self-closing div
+!! wikitext
+<div>hello world</div/>
+!! html
+<div>hello world</div>
+
+!! end
+
+!! test
+MSGNW magic word
+!! wikitext
+{{MSGNW:msg}}
+!! html/php
+<p>&#91;&#91;:Template:Msg&#93;&#93;
+</p>
+!! end
+
+!! test
+RAW magic word
+!! wikitext
+{{RAW:QUERTY}}
+!! html
+<p><a href="/index.php?title=Template:QUERTY&amp;action=edit&amp;redlink=1" class="new" title="Template:QUERTY (page does not exist)">Template:QUERTY</a>
+</p>
+!! end
+
+# This isn't needed for XHTML conformance, but would be handy as a fallback security measure
+!! test
+Always escape literal '>' in output, not just after '<'
+!! wikitext
+><>
+!! html
+<p>&gt;&lt;&gt;
+</p>
+!! end
+
+!! test
+Template caching
+!! wikitext
+{{Test}}
+{{Test}}
+!! html
+<p>This is a test template
+This is a test template
+</p>
+!! end
+
+
+!! article
+MediaWiki:Fake
+!! text
+==header==
+!! endarticle
+
+!! test
+Inclusion of !userCanEdit() content
+!! wikitext
+{{MediaWiki:Fake}}
+!! html
+<h2><span class="mw-headline" id="header">header</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=MediaWiki:Fake&amp;action=edit&amp;section=T-1" title="MediaWiki:Fake">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+
+!! test
+Out-of-order TOC heading levels
+!! wikitext
+==2==
+======6======
+===3===
+=1=
+=====5=====
+==2==
+!! html
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#2"><span class="tocnumber">1</span> <span class="toctext">2</span></a>
+<ul>
+<li class="toclevel-2 tocsection-2"><a href="#6"><span class="tocnumber">1.1</span> <span class="toctext">6</span></a></li>
+<li class="toclevel-2 tocsection-3"><a href="#3"><span class="tocnumber">1.2</span> <span class="toctext">3</span></a></li>
+</ul>
+</li>
+<li class="toclevel-1 tocsection-4"><a href="#1"><span class="tocnumber">2</span> <span class="toctext">1</span></a>
+<ul>
+<li class="toclevel-2 tocsection-5"><a href="#5"><span class="tocnumber">2.1</span> <span class="toctext">5</span></a></li>
+<li class="toclevel-2 tocsection-6"><a href="#2_2"><span class="tocnumber">2.2</span> <span class="toctext">2</span></a></li>
+</ul>
+</li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="2">2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h6><span class="mw-headline" id="6">6</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: 6">edit</a><span class="mw-editsection-bracket">]</span></span></h6>
+<h3><span class="mw-headline" id="3">3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: 3">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<h1><span class="mw-headline" id="1">1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: 1">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<h5><span class="mw-headline" id="5">5</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: 5">edit</a><span class="mw-editsection-bracket">]</span></span></h5>
+<h2><span class="mw-headline" id="2_2">2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=6" title="Edit section: 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+
+!! test
+ISBN with a dummy number
+!! wikitext
+ISBN ---
+!! html
+<p>ISBN ---
+</p>
+!! end
+
+
+!! test
+ISBN with space-delimited number
+!! wikitext
+ISBN 92 9017 032 8
+!! html/php
+<p><a href="/wiki/Special:BookSources/9290170328" class="internal mw-magiclink-isbn">ISBN 92 9017 032 8</a>
+</p>
+!! html/parsoid
+<p data-parsoid='{"dsr":[0,18,0,0]}'><a href="./Special:BookSources/9290170328" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink","dsr":[0,18,2,2]}'>ISBN 92 9017 032 8</a></p>
+!! end
+
+
+!! test
+ISBN with multiple spaces, no number
+!! wikitext
+ISBN foo
+!! html
+<p>ISBN foo
+</p>
+!! end
+
+
+!! test
+ISBN length
+!! wikitext
+ISBN 123456789
+
+ISBN 1234567890
+
+ISBN 12345678901
+!! html/php
+<p>ISBN 123456789
+</p><p><a href="/wiki/Special:BookSources/1234567890" class="internal mw-magiclink-isbn">ISBN 1234567890</a>
+</p><p>ISBN 12345678901
+</p>
+!! html/parsoid
+<p>ISBN 123456789</p>
+
+<p><a href="./Special:BookSources/1234567890" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 1234567890</a></p>
+
+<p>ISBN 12345678901</p>
+!! end
+
+
+!! test
+ISBN with trailing year (T9110)
+!! wikitext
+ISBN 1-234-56789-0 - 2006
+
+ISBN 1 234 56789 0 - 2006
+!! html/php
+<p><a href="/wiki/Special:BookSources/1234567890" class="internal mw-magiclink-isbn">ISBN 1-234-56789-0</a> - 2006
+</p><p><a href="/wiki/Special:BookSources/1234567890" class="internal mw-magiclink-isbn">ISBN 1 234 56789 0</a> - 2006
+</p>
+!! html/parsoid
+<p><a href="./Special:BookSources/1234567890" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 1-234-56789-0</a> - 2006</p>
+
+<p><a href="./Special:BookSources/1234567890" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 1 234 56789 0</a> - 2006</p>
+!! end
+
+
+!! test
+anchorencode
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+{{anchorencode:foo bar©#%n}}
+!! html/php
+<p>foo_bar©#%n
+</p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode:foo bar©#%n","function":"anchorencode"},"params":{},"i":0}}]}'>foo_bar©#%n</p>
+!! end
+
+!! test
+anchorencode (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
+!! wikitext
+{{anchorencode:foo bar©#%n}}
+!! html/php
+<p>foo_bar.C2.A9.23.25n
+</p>
+!! end
+
+!! test
+anchorencode trims spaces
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+{{anchorencode: __pretty__please__}}
+!! html/php
+<p>pretty_please
+</p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode: __pretty__please__","function":"anchorencode"},"params":{},"i":0}}]}'>pretty_please</p>
+!! end
+
+!! test
+anchorencode deals with links
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+{{anchorencode: [[hello|world]] [[hi]]}}
+!! html/php
+<p>world_hi
+</p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode: [[hello|world]] [[hi]]","function":"anchorencode"},"params":{},"i":0}}]}'>world_hi</p>
+!! end
+
+!! test
+anchorencode deals with templates
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+{{anchorencode: {{Foo}} x}}
+!! html/php
+<p>FOO_x
+</p>
+!! html/parsoid
+<p about="#mwt2" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode: {{Foo}} x","function":"anchorencode"},"params":{},"i":0}}]}'>FOO_x</p>
+!! end
+
+!! test
+anchorencode encodes like the TOC generator: (T20431)
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+===_ +:.3A%3A _ &&amp;]] x===
+{{anchorencode: _ +:.3A%3A _ &&amp;]] x}}
+__NOEDITSECTION__
+!! html/php
+<h3><span id=".2B:.3A.253A_.26.26.5D.5D_x"></span><span class="mw-headline" id="+:.3A%3A_&amp;&amp;]]_x">_ +:.3A%3A _ &amp;&amp;]] x</span></h3>
+<p>+:.3A%3A_&amp;&amp;&#93;&#93;_x
+</p>
+!! html/parsoid
+<h3 id="+:.3A%3A_&amp;&amp;]]_x"><span id=".2B:.3A.253A_.26.26.5D.5D_x" typeof="mw:FallbackId"></span>_ +:.3A%3A _ &amp;<span typeof="mw:Entity" data-parsoid='{"src":"&amp;amp;","srcContent":"&amp;","dsr":[18,23,null,null]}'>&amp;</span>]] x</h3>
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode: _ +:.3A%3A _ &amp;&amp;amp;]] x","function":"anchorencode"},"params":{},"i":0}}]}'>+:.3A%3A_&amp;&amp;<span typeof="mw:Entity">]</span><span typeof="mw:Entity">]</span>_x</p>
+<meta property="mw:PageProp/noeditsection"/>
+!! end
+
+!! test
+anchorencode encodes like the TOC generator: (T20431) (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
+!! wikitext
+===_ +:.3A%3A&&amp;]]===
+{{anchorencode: _ +:.3A%3A&&amp;]] }}
+__NOEDITSECTION__
+!! html/php
+<h3><span class="mw-headline" id=".2B:.3A.253A.26.26.5D.5D">_ +:.3A%3A&amp;&amp;]]</span></h3>
+<p>.2B:.3A.253A.26.26.5D.5D
+</p>
+!! end
+
+!! test
+T8200: blockquotes and paragraph formatting
+!! wikitext
+<blockquote>
+foo
+</blockquote>
+
+bar
+
+ baz
+!! html
+<blockquote>
+<p>foo
+</p>
+</blockquote>
+<p>bar
+</p>
+<pre>baz
+</pre>
+!! end
+
+!! test
+T10293: Use of center tag ruins paragraph formatting
+!! wikitext
+<center>
+foo
+</center>
+
+bar
+
+ baz
+!! html
+<center>
+<p>foo
+</p>
+</center>
+<p>bar
+</p>
+<pre>baz
+</pre>
+!! end
+
+!!test
+Parsing of overlapping (improperly nested) inline html tags
+!! wikitext
+<span><s>x</span></s>
+!! html/php
+<p><span><s>x&lt;/span&gt;</s></span>
+</p>
+!! html/parsoid
+<p><span><s>x</s></span>
+</p>
+!!end
+
+###
+### Language variants related tests
+###
+
+# Parsoid does not mark self-links.
+# Parsoid does not convert links; PHP will do any necessary redirects.
+
+!! test
+Self-link in language variants
+!! options
+title=[[Dunav]] language=sr
+!! wikitext
+Both [[Dunav]] and [[Дунав]] are names for this river.
+!! html/php
+<p>Both <a class="mw-selflink selflink">Dunav</a> and <a class="mw-selflink selflink">Дунав</a> are names for this river.
+</p>
+!! html/parsoid
+<p>Both <a rel="mw:WikiLink" href="./Dunav" title="Dunav">Dunav</a> and <a rel="mw:WikiLink" href="./Дунав" title="Дунав">Дунав</a> are names for this river.</p>
+!! end
+
+!! article
+Дуна
+!! text
+content
+!! endarticle
+
+!! test
+Link to another existing title shouldn't be parsed as self-link even if it's a variant of this title
+!! options
+title=[[Duna]] language=sr
+!! wikitext
+[[Дуна]] is not a self-link while [[Duna]] and [[Dуна]] are still self-links.
+!! html/php
+<p><a href="/wiki/%D0%94%D1%83%D0%BD%D0%B0" title="Дуна">Дуна</a> is not a self-link while <a class="mw-selflink selflink">Duna</a> and <a class="mw-selflink selflink">Dуна</a> are still self-links.
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Дуна" title="Дуна">Дуна</a> is not a self-link while <a rel="mw:WikiLink" href="./Duna" title="Duna">Duna</a> and <a rel="mw:WikiLink" href="./Dуна" title="Dуна">Dуна</a> are still self-links.</p>
+!! end
+
+!! test
+Link to a section of a variant of this title shouldn't be parsed as self-link
+!! options
+title=[[Duna]] language=sr
+!! wikitext
+[[Dуна]] is a self-link while [[Dunа#Foo]] and [[Dуна#Foo]] are not self-links.
+!! html/php
+<p><a class="mw-selflink selflink">Dуна</a> is a self-link while <a href="/wiki/%D0%94%D1%83%D0%BD%D0%B0" title="Дуна">Dunа#Foo</a> and <a href="/wiki/%D0%94%D1%83%D0%BD%D0%B0" title="Дуна">Dуна#Foo</a> are not self-links.
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Dуна" title="Dуна">Dуна</a> is a self-link while <a rel="mw:WikiLink" href="./Dunа#Foo" title="Dunа">Dunа#Foo</a> and <a rel="mw:WikiLink" href="./Dуна#Foo" title="Dуна">Dуна#Foo</a> are not self-links.</p>
+!! end
+
+!! test
+Link to pages in language variants
+!! options
+language=sr
+!! wikitext
+Main Page can be written as [[Маин Паге]]
+!! html/php
+<p>Main Page can be written as <a href="/wiki/Main_Page" title="Main Page">Маин Паге</a>
+</p>
+!! html/parsoid
+<p>Main Page can be written as <a rel="mw:WikiLink" href="./Маин_Паге" title="Маин Паге">Маин Паге</a></p>
+!! end
+
+
+!! test
+Multiple links to pages in language variants
+!! options
+language=sr
+!! wikitext
+[[Main Page]] can be written as [[Маин Паге]] same as [[Маин Паге]].
+!! html/php
+<p><a href="/wiki/Main_Page" title="Main Page">Main Page</a> can be written as <a href="/wiki/Main_Page" title="Main Page">Маин Паге</a> same as <a href="/wiki/Main_Page" title="Main Page">Маин Паге</a>.
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a> can be written as <a rel="mw:WikiLink" href="./Маин_Паге" title="Маин Паге">Маин Паге</a> same as <a rel="mw:WikiLink" href="./Маин_Паге" title="Маин Паге">Маин Паге</a>.</p>
+!! end
+
+
+!! test
+Simple template in language variants
+!! options
+language=sr
+!! wikitext
+{{тест}}
+!! html/php
+<p>This is a test template
+</p>
+!! end
+
+
+!! test
+Template with explicit namespace in language variants
+!! options
+language=sr
+!! wikitext
+{{Template:тест}}
+!! html/php
+<p>This is a test template
+</p>
+!! end
+
+
+!! test
+Basic test for template parameter in language variants
+!! options
+language=sr
+!! wikitext
+{{парамтест|param=foo}}
+!! html/php
+<p>This is a test template with parameter foo
+</p>
+!! end
+
+!! test
+Simple category in language variants
+!! options
+language=sr cat
+!! wikitext
+[[Category:МедиаWики Усер'с Гуиде]]
+!! html/php
+cat=МедиаWики_Усер'с_Гуиде sort=
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Категорија:МедиаWики_Усер'с_Гуиде" data-parsoid='{"stx":"simple","a":{"href":"./Категорија:МедиаWики_Усер&#39;с_Гуиде"},"sa":{"href":"Category:МедиаWики Усер&#39;с Гуиде"}}'/>
+!! end
+
+!! article
+Category:分类
+!! text
+blah
+!! endarticle
+
+!! article
+Category:分類
+!! text
+blah
+!! endarticle
+
+## We used to, but no longer wt2wt this test since the default serializer
+## will normalize all categories to serialize on their own line.
+## This wikitext usage is going to be fairly uncommon in production and
+## selser will take care of preserving formatting in those scenarios.
+!! test
+Don't convert blue categorylinks to another variant (T35210)
+!! options
+cat
+language=zh
+parsoid=wt2html
+!! wikitext
+[[A]][[Category:分类]]
+!! html/php
+cat=分类 sort=
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./A" title="A">A</a></p>
+<link rel="mw:PageProp/Category" href="./Category:分类"/>
+!! end
+
+!! test
+Stripping -{}- tags (language variants)
+!! options
+language=sr
+!! wikitext
+Latin proverb: -{Ne nuntium necare}-
+!! html/php
+<p>Latin proverb: Ne nuntium necare
+</p>
+!! html/parsoid
+<p>Latin proverb: <span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"Ne nuntium necare"}}'></span></p>
+!! end
+
+
+!! test
+Prevent conversion with -{}- tags (language variants)
+!! options
+language=sr variant=sr-ec
+!! wikitext
+Latinski: -{Ne nuntium necare}-
+!! html/php
+<p>Латински: Ne nuntium necare
+</p>
+!! html/parsoid
+<p>Latinski: <span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"Ne nuntium necare"}}'></span></p>
+!! end
+
+
+!! test
+Prevent conversion of text with -{}- tags (language variants)
+!! options
+language=sr variant=sr-ec
+!! wikitext
+Latinski: -{Ne nuntium necare}-
+!! html/php
+<p>Латински: Ne nuntium necare
+</p>
+!! html/parsoid
+<p>Latinski: <span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"Ne nuntium necare"}}'></span></p>
+!! end
+
+
+!! test
+Prevent conversion of links with -{}- tags (language variants)
+!! options
+language=sr variant=sr-ec
+!! wikitext
+-{[[Main Page]]}-
+!! html/php
+<p><a href="/wiki/Main_Page" title="Main Page">Main Page</a>
+</p>
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"&lt;a rel=\"mw:WikiLink\" href=\"./Main_Page\" title=\"Main Page\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Main_Page\"},\"sa\":{\"href\":\"Main Page\"},\"dsr\":[2,15,2,2]}&#39;>Main Page&lt;/a>"}}'></span></p>
+!! end
+
+
+!! test
+-{}- tags within headlines (within html for parserConvert())
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! options
+language=sr variant=sr-ec
+!! wikitext
+==-{Naslov}-==
+
+Note that even an unprotected headline ID is not affected by language
+conversion:
+
+==Latinski==
+!! html/php
+<h2><span id="-.7BNaslov.7D-"></span><span class="mw-headline" id="-{Naslov}-">Naslov</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Уреди одељак „Naslov“">уреди</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>Ноте тхат евен ан унпротецтед хеадлине ИД ис нот аффецтед бy лангуаге
+цонверсион:
+</p>
+<h2><span class="mw-headline" id="Latinski">Латински</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Уреди одељак „Латински“">уреди</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<h2 id="-{Naslov}-"><span id="-.7BNaslov.7D-" typeof="mw:FallbackId"></span><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"Naslov"}}'></span></h2>
+
+<p>Note that even an unprotected headline ID is not affected by language
+conversion:</p>
+
+<h2 id="Latinski">Latinski</h2>
+!! end
+
+!! test
+Explicit definition of language variant alternatives
+!! options
+language=zh variant=zh-tw
+!! wikitext
+-{zh:China;zh-tw:Taiwan}-, not China
+!! html/php
+<p>Taiwan, not China
+</p>
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-parsoid='{"tSp":[6]}' data-mw-variant='{"twoway":[{"l":"zh","t":"China"},{"l":"zh-tw","t":"Taiwan"}]}'></span>, not China</p>
+!! end
+
+!! test
+Filter syntax for language variants
+!! options
+language=zh variant=zh-tw
+!! wikitext
+foo-{zh;zh-hans;zh-hant|blog, WEBJOURNAL, WEBLOG}-quux
+!! html/php
+<p>fooblog, WEBJOURNAL, WEBLOGquux
+</p>
+!! html/parsoid
+<p>foo<span typeof="mw:LanguageVariant" data-mw-variant='{"filter":{"l":["zh","zh-hans","zh-hant"],"t":"blog, WEBJOURNAL, WEBLOG"}}'></span>quux</p>
+!! end
+
+# Note that Parsoid post-processing for language variants needs to
+# update the `title` attribute here, based on the mw:ExpandedAttrs property
+!! test
+Conversion around HTML tags
+!! options
+language=sr variant=sr-ec
+!! wikitext
+-{H|span=>sr-ec:script;title=>sr-ec:src}-
+<span title="La-{sr-el:L;sr-ec:C}-tin">ski</span>
+!! html/php
+<p>
+<span title="ЛаCтин">ски</span>
+</p>
+!! html/parsoid
+<p><meta typeof="mw:LanguageVariant" data-parsoid='{"tSp":[8]}' data-mw-variant='{"add":true,"oneway":[{"f":"span","l":"sr-ec","t":"script"},{"f":"title","l":"sr-ec","t":"src"}]}'/>
+<span title="Latin" typeof="mw:ExpandedAttrs" data-mw='{"attribs":[[{"txt":"title"},{"html":"La&lt;span typeof=\"mw:LanguageVariant\" data-mw-variant=&#39;{\"twoway\":[{\"l\":\"sr-el\",\"t\":\"L\"},{\"l\":\"sr-ec\",\"t\":\"C\"}]}&#39; data-parsoid=&#39;{\"fl\":[],\"tSp\":[6],\"dsr\":[57,76,null,2]}&#39;>&lt;/span>tin"}]]}'>ski</span></p>
+!! end
+
+!! test
+Explicit session-wise two-way language variant mapping (A flag and - flag)
+!! options
+language=zh variant=zh-tw
+!! wikitext
+This is -{zh:China; zh-tw:Taiwan}-, but we'll forget that now.
+
+Taiwan is not China.
+
+But -{A|zh:China; zh-tw:Taiwan}- is China,
+
+(This-{-|zh:China; zh-tw:Taiwan}- should be stripped!)
+
+and -{China}- is China.
+!! html/php
+<p>This is Taiwan, but we'll forget that now.
+</p><p>Taiwan is not China.
+</p><p>But Taiwan is Taiwan,
+</p><p>(This should be stripped!)
+</p><p>and China is China.
+</p>
+!! html/parsoid
+<p>This is <span typeof="mw:LanguageVariant" data-mw-variant='{"twoway":[{"l":"zh","t":"China"},{"l":"zh-tw","t":"Taiwan"}]}'></span>, but we'll forget that now.</p>
+<p>Taiwan is not China.</p>
+<p>But <span typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"twoway":[{"l":"zh","t":"China"},{"l":"zh-tw","t":"Taiwan"}]}'></span> is China,</p>
+<p>(This<meta typeof="mw:LanguageVariant" data-mw-variant='{"remove":true,"twoway":[{"l":"zh","t":"China"},{"l":"zh-tw","t":"Taiwan"}]}'/> should be stripped!)</p>
+<p>and <span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"China"}}'></span> is China.</p>
+!! end
+
+!! test
+Explicit session-wise one-way language variant mapping (A flag and - flag)
+!! options
+language=zh variant=zh-tw
+!! wikitext
+This is -{COUNTRY=>zh:China;COUNTRY=>zh-tw:Taiwan}-, but we'll forget that now.
+
+COUNTRY is China or Taiwan.
+
+But -{A|COUNTRY=>zh:China;COUNTRY=>zh-tw:Taiwan}- is COUNTRY,
+
+(This-{-|COUNTRY=>zh:China;COUNTRY=>zh-tw:Taiwan}- should be stripped!)
+
+and -{COUNTRY}- is COUNTRY.
+!! html/php
+<p>This is Taiwan, but we'll forget that now.
+</p><p>COUNTRY is China or Taiwan.
+</p><p>But Taiwan is Taiwan,
+</p><p>(This should be stripped!)
+</p><p>and COUNTRY is COUNTRY.
+</p>
+!! html/parsoid
+<p>This is <span typeof="mw:LanguageVariant" data-mw-variant='{"oneway":[{"f":"COUNTRY","l":"zh","t":"China"},{"f":"COUNTRY","l":"zh-tw","t":"Taiwan"}]}'></span>, but we'll forget that now.</p>
+<p>COUNTRY is China or Taiwan.</p>
+<p>But <span typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"oneway":[{"f":"COUNTRY","l":"zh","t":"China"},{"f":"COUNTRY","l":"zh-tw","t":"Taiwan"}]}'></span> is COUNTRY,</p>
+<p>(This<meta typeof="mw:LanguageVariant" data-mw-variant='{"oneway":[{"f":"COUNTRY","l":"zh","t":"China"},{"f":"COUNTRY","l":"zh-tw","t":"Taiwan"}],"remove":true}'/> should be stripped!)</p>
+<p>and <span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"COUNTRY"}}'></span> is COUNTRY.</p>
+!! end
+
+!! test
+Explicit session-wise two-way language variant mapping (H flag for hide)
+!! options
+language=zh variant=zh-tw
+!! wikitext
+(This-{H|zh:China;zh-tw:Taiwan}- should be stripped!)
+
+Taiwan is China.
+!! html/php
+<p>(This should be stripped!)
+</p><p>Taiwan is Taiwan.
+</p>
+!! html/parsoid
+<p>(This<meta typeof="mw:LanguageVariant" data-parsoid='{"tSp":[6]}' data-mw-variant='{"add":true,"twoway":[{"l":"zh","t":"China"},{"l":"zh-tw","t":"Taiwan"}]}'/> should be stripped!)</p>
+<p>Taiwan is China.</p>
+!! end
+
+!! test
+Explicit session-wise one-way language variant mapping (H flag for hide)
+!! options
+language=zh variant=zh-tw
+!! wikitext
+(This-{H|COUNTRY=>zh:China;COUNTRY=>zh-tw:Taiwan}- should be stripped!)
+
+COUNTRY is Taiwan or China.
+!! html/php
+<p>(This should be stripped!)
+</p><p>Taiwan is Taiwan or China.
+</p>
+!! html/parsoid
+<p>(This<meta typeof="mw:LanguageVariant" data-parsoid='{"tSp":[8]}' data-mw-variant='{"add":true,"oneway":[{"f":"COUNTRY","l":"zh","t":"China"},{"f":"COUNTRY","l":"zh-tw","t":"Taiwan"}]}'/> should be stripped!)</p>
+<p>COUNTRY is Taiwan or China.</p>
+!! end
+
+## Note that parsoid test runner does not support 'showtitle' option.
+!! test
+Adding explicit conversion rule for title (T flag)
+!! options
+language=zh variant=zh-tw showtitle
+!! wikitext
+Should be stripped-{T|zh:China;zh-tw:Taiwan}-!
+
+Taiwan is China.
+!! html/php
+Taiwan
+<p>Should be stripped!
+</p><p>Taiwan is China.
+</p>
+!! html/parsoid
+<p>Should be stripped<meta typeof="mw:LanguageVariant" data-parsoid='{"tSp":[6]}' data-mw-variant='{"title":true,"twoway":[{"l":"zh","t":"China"},{"l":"zh-tw","t":"Taiwan"}]}'/>!</p>
+<p>Taiwan is China.</p>
+!! end
+
+!! test
+Code coverage: T combined with H flag
+!! options
+language=zh variant=zh-tw showtitle
+!! wikitext
+Should be stripped-{T;H|zh:China; zh-tw:Taiwan}-!
+
+Taiwan is China.
+!! html/php
+Taiwan
+<p>Should be stripped!
+</p><p>Taiwan is Taiwan.
+</p>
+!! html/parsoid
+<p>Should be stripped<meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"title":true,"twoway":[{"l":"zh","t":"China"},{"l":"zh-tw","t":"Taiwan"}]}'/>!</p>
+<p>Taiwan is China.</p>
+!! end
+
+!! test
+Code coverage: T with no variants
+!! options
+language=zh variant=zh-tw showtitle
+!! wikitext
+-{H|zh:China; zh-tw:Taiwan}-
+Taiwan is China.-{T|Taiwan is China}-
+!! html/php
+Taiwan is China
+<p>
+Taiwan is Taiwan.
+</p>
+!! html/parsoid
+<p><meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"twoway":[{"l":"zh","t":"China"},{"l":"zh-tw","t":"Taiwan"}]}'/>
+Taiwan is China.<meta typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"Taiwan is China"},"title":true}'/></p>
+!! end
+
+!! test
+Code coverage: rules with no variants
+!! options
+language=zh variant=zh-tw
+!! wikitext
+-{H|zh:China; zh-tw:Taiwan}-
+Taiwan is China.
+-{H|China}-
+Taiwan is China.
+!! html/php
+<p>
+Taiwan is Taiwan.
+
+Taiwan is China.
+</p>
+!! html/parsoid
+<p><meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"twoway":[{"l":"zh","t":"China"},{"l":"zh-tw","t":"Taiwan"}]}'/>
+Taiwan is China.
+<meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"twoway":[{"l":"*","t":"China"}]}'/>
+Taiwan is China.</p>
+!! end
+
+
+!! test
+Code coverage: D flag for conversion rule
+!! options
+language=zh variant=zh-tw
+!! wikitext
+-{D|zh-cn:XA; zh-tw:YA}-
+-{A;D|zh-cn:XB; zh-tw:YB}-
+-{D;H|zh-cn:XC; zh-tw:YC}-
+
+-{D;H|FOO=>zh-tw:BAR;FOO=>zh-cn:BAT}-
+
+-{D|0=>zh-tw:1}-
+-{A;D|2=>zh-tw:3}-
+-{D;H|4=>zh-tw:5}-
+
+XA XB XC YA YB YC FOO BAR BAT 012345
+!! html/php
+<p>大陆:XA;台灣:YA;
+
+大陆:XC;台灣:YC;
+</p><p>FOO⇒台灣:BAR;FOO⇒大陆:BAT;
+</p><p>0⇒台灣:1;
+
+4⇒台灣:5;
+</p><p>XA YB YC YA YB YC BAR BAR BAT 013355
+</p>
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"describe":true,"twoway":[{"l":"zh-cn","t":"XA"},{"l":"zh-tw","t":"YA"}]}'></span>
+<meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"describe":true,"twoway":[{"l":"zh-cn","t":"XB"},{"l":"zh-tw","t":"YB"}]}'/>
+<span typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"describe":true,"twoway":[{"l":"zh-cn","t":"XC"},{"l":"zh-tw","t":"YC"}]}'></span></p>
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"describe":true,"oneway":[{"f":"FOO","l":"zh-tw","t":"BAR"},{"f":"FOO","l":"zh-cn","t":"BAT"}]}'></span></p>
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"describe":true,"oneway":[{"f":"0","l":"zh-tw","t":"1"}]}'></span>
+<meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"describe":true,"oneway":[{"f":"2","l":"zh-tw","t":"3"}]}'/>
+<span typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"describe":true,"oneway":[{"f":"4","l":"zh-tw","t":"5"}]}'></span></p>
+<p>XA XB XC YA YB YC FOO BAR BAT 012345</p>
+!! end
+
+!! test
+Code coverage: N flag for conversion rule
+!! options
+language=zh variant=zh-cn
+!! wikitext
+-{N|zh-cn}-
+
+-{N|zh-tw}-
+
+-{N|sr-ec}-
+!! html/php
+<p>大陆
+</p><p>台灣
+</p><p>српски (ћирилица)‎
+</p>
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"name":{"t":"zh-cn"}}'></span></p>
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"name":{"t":"zh-tw"}}'></span></p>
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"name":{"t":"sr-ec"}}'></span></p>
+!! end
+
+# html2wt suppresses the bogus 'D' flag, so this is wt2html only
+!! test
+Code coverage: N flag for conversion rule (wt2html only)
+!! options
+language=zh variant=zh-cn
+parsoid=wt2html,html2html
+!! wikitext
+-{D;N|en}-
+!! html/php
+<p>English
+</p>
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"name":{"t":"en"}}' data-parsoid='{"fl":["D","N"]}'></span></p>
+!! end
+
+!! test
+Testing that changing the language variant here in the tests actually works
+!! options
+language=zh variant=zh showtitle
+!! wikitext
+Should be stripped-{T|zh:China; zh-tw:Taiwan}-!
+!! html/php
+China
+<p>Should be stripped!
+</p>
+!! html/parsoid
+<p>Should be stripped<meta typeof="mw:LanguageVariant" data-mw-variant='{"title":true,"twoway":[{"l":"zh","t":"China"},{"l":"zh-tw","t":"Taiwan"}]}'/>!</p>
+!! end
+
+!! test
+Recursive conversion of alt and title attrs shouldn't clear converter state
+!! options
+language=zh variant=zh-cn
+showtitle
+!! wikitext
+-{H|zh-cn:Exclamation; zh-tw:exclamation}-
+Should be stripped-{T|zh-cn:China; zh-tw:Taiwan}-<span title="exclamation">!</span>
+!! html/php
+China
+<p>
+Should be stripped<span title="Exclamation">!</span>
+</p>
+!! html/parsoid
+<p><meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"twoway":[{"l":"zh-cn","t":"Exclamation"},{"l":"zh-tw","t":"exclamation"}]}'/>
+Should be stripped<meta typeof="mw:LanguageVariant" data-mw-variant='{"title":true,"twoway":[{"l":"zh-cn","t":"China"},{"l":"zh-tw","t":"Taiwan"}]}'/><span title="exclamation">!</span></p>
+!! end
+
+!! test
+T26072: more test on conversion rule for title
+!! options
+language=zh variant=zh-tw showtitle
+!! wikitext
+This should be stripped-{T|zh:China; zh-tw:Taiwan}-!
+
+This won't take interferes with the title rule-{H|zh:Beijing; zh-tw:Taipei}-.
+!! html/php
+Taiwan
+<p>This should be stripped!
+</p><p>This won't take interferes with the title rule.
+</p>
+!! html/parsoid
+<p>This should be stripped<meta typeof="mw:LanguageVariant" data-mw-variant='{"title":true,"twoway":[{"l":"zh","t":"China"},{"l":"zh-tw","t":"Taiwan"}]}'/>!</p>
+<p>This won't take interferes with the title rule<meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"twoway":[{"l":"zh","t":"Beijing"},{"l":"zh-tw","t":"Taipei"}]}'/>.</p>
+!! end
+
+!! test
+Partly disable title conversion if variant == main language code
+!! options
+language=zh variant=zh title=[[ZH]] showtitle
+!! wikitext
+-{T|zh-cn:CN;zh-tw:TW}-
+!! html/php
+ZH
+<p>
+</p>
+!! html/parsoid
+<p><meta typeof="mw:LanguageVariant" data-parsoid='{"tSp":[6]}' data-mw-variant='{"title":true,"twoway":[{"l":"zh-cn","t":"CN"},{"l":"zh-tw","t":"TW"}]}'/></p>
+!! end
+
+!! test
+Partly disable title conversion if variant == main language code, more
+!! options
+language=zh variant=zh title=[[ZH]] showtitle
+!! wikitext
+-{T|TW}-
+!! html/php
+ZH
+<p>
+</p>
+!! html/parsoid
+<p><meta typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"TW"},"title":true}'/></p>
+!! end
+
+!! test
+Raw output of variant escape tags (R flag)
+!! options
+language=zh variant=zh-tw
+!! wikitext
+Raw: -{R|zh:China;zh-tw:Taiwan}-
+!! html/php
+<p>Raw: zh:China;zh-tw:Taiwan
+</p>
+!! html/parsoid
+<p>Raw: <span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"zh:China;zh-tw:Taiwan"}}'></span></p>
+!! end
+
+# html2wt suppresses the bogus 'D' flags, so this is wt2html only
+!! test
+Raw output of variant escape tags (R flag) (wt2html only)
+!! options
+language=zh variant=zh-tw
+parsoid=wt2html,html2html
+!! wikitext
+-{Variant}- -{D|syntax}- -{D;R|options}-
+!! html/php
+<p>Variant syntax options
+</p>
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"Variant"}}'></span> <span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"syntax"}}'></span> <span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"options"}}'></span></p>
+!! end
+
+!! test
+Nested markup inside raw output of variant escape tags (R flag)
+!! options
+language=zh variant=zh-tw
+!! wikitext
+Nested raw: -{R|nested -{zh:China;zh-tw:Taiwan}- nested}-
+!! html/php
+<p>Nested raw: nested Taiwan nested
+</p>
+!! html/parsoid
+<p>Nested raw: <span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"nested &lt;span typeof=\"mw:LanguageVariant\" data-mw-variant=&#39;{\"twoway\":[{\"l\":\"zh\",\"t\":\"China\"},{\"l\":\"zh-tw\",\"t\":\"Taiwan\"}]}&#39; data-parsoid=&#39;{\"fl\":[],\"tSp\":[6],\"dsr\":[23,48,null,2]}&#39;>&lt;/span> nested"}}'></span></p>
+!! end
+
+!! test
+Nested markup and spaces inside raw output of variant escape tags (R flag)
+!! options
+language=zh variant=zh-tw
+!! wikitext
+X-{ outer -{ inner }- outer }-X
+!! html/php
+<p>X outer inner outer X
+</p>
+!! html/parsoid
+<p>X<span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":" outer &lt;span typeof=\"mw:LanguageVariant\" data-mw-variant=&#39;{\"disabled\":{\"t\":\" inner \"}}&#39; data-parsoid=&#39;{\"fl\":[],\"dsr\":[10,21,null,2]}&#39;>&lt;/span> outer "}}'></span>X</p>
+!! end
+
+!! test
+Templates inside raw output of variant escape tags (R flag)
+!! options
+language=zh variant=zh-tw
+!! wikitext
+Nested raw: -{R|nested {{echo|hi}} templates}-
+!! html/php
+<p>Nested raw: nested hi templates
+</p>
+!! html/parsoid
+<p>Nested raw: <span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"nested &lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[23,34,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"hi\"}},\"i\":0}}]}&#39;>hi&lt;/span> templates"}}'></span></p>
+!! end
+
+!! test
+Strings evaluating false shouldn't be ignored by Language converter (T51072)
+!! options
+language=zh variant=zh-cn
+!! wikitext
+-{zh-cn:0;zh-sg:1;zh-tw:2;zh-hk:3}-
+!! html/php
+<p>0
+</p>
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-parsoid='{"tSp":[12]}' data-mw-variant='{"twoway":[{"l":"zh-cn","t":"0"},{"l":"zh-sg","t":"1"},{"l":"zh-tw","t":"2"},{"l":"zh-hk","t":"3"}]}'></span></p>
+!! end
+
+!! test
+Conversion rules from [numeric-only string] to [something else] (T48634)
+!! options
+language=zh variant=zh-cn
+!! wikitext
+-{H|0=>zh-cn:B}--{H|0=>zh-cn:C;0=>zh-cn:D}--{H|0=>zh-hans:A}-012345-{A|zh-tw:0;zh-cn:E;}-012345
+!! html/php
+<p>D12345EE12345
+</p>
+!! html/parsoid
+<p><meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"oneway":[{"f":"0","l":"zh-cn","t":"B"}]}'/><meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"oneway":[{"f":"0","l":"zh-cn","t":"C"},{"f":"0","l":"zh-cn","t":"D"}]}'/><meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"oneway":[{"f":"0","l":"zh-hans","t":"A"}]}'/>012345<span typeof="mw:LanguageVariant" data-parsoid='{"fl":["A"],"tSp":[7]}' data-mw-variant='{"add":true,"twoway":[{"l":"zh-tw","t":"0"},{"l":"zh-cn","t":"E"}]}'></span>012345</p>
+!! end
+
+!! test
+Two-way converter rule entries with an empty value should be ignored (T53551)
+!! options
+language=zh variant=zh-cn
+!! wikitext
+-{H|zh-cn:foo;zh-tw:;}-foobar
+!! html/php
+<p>foobar
+</p>
+!! html/parsoid
+<p><meta typeof="mw:LanguageVariant" data-parsoid='{"tSp":[7]}' data-mw-variant='{"add":true,"twoway":[{"l":"zh-cn","t":"foo"},{"l":"zh-tw","t":""}]}'/>foobar</p>
+!! end
+
+!! test
+One-way converter rule entries with an empty "from" string should be ignored (T53551)
+!! options
+language=zh variant=zh-cn
+!! wikitext
+-{H|=>zh-cn:foo;}-foobar
+!! html/php
+<p>foobar
+</p>
+!! html/parsoid
+<p><meta typeof="mw:LanguageVariant" data-parsoid='{"tSp":[5]}' data-mw-variant='{"add":true,"oneway":[{"f":"","l":"zh-cn","t":"foo"}]}'/>foobar</p>
+!! end
+
+!! test
+Empty converter rule entries shouldn't be inserted into the conversion table (T53551)
+!! options
+language=zh variant=zh-cn
+!! wikitext
+-{H|}-foobar
+!! html/php
+<p>foobar
+</p>
+!! html/parsoid
+<p><meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"twoway":[{"l":"*","t":""}]}'/>foobar</p>
+!! end
+
+!! test
+Nested using of manual convert syntax
+!! options
+language=zh variant=zh-hk
+!! wikitext
+Nested: -{zh-hans:Hi -{zh-cn:China;zh-sg:Singapore;}-;zh-hant:Hello -{zh-tw:Taiwan;zh-hk:H-{ong}- K-{}-ong;}-;}-!
+!! html/php
+<p>Nested: Hello Hong Kong!
+</p>
+!! html/parsoid
+<p>Nested: <span typeof="mw:LanguageVariant" data-parsoid='{"tSp":[7]}' data-mw-variant='{"twoway":[{"l":"zh-hans","t":"Hi &lt;span typeof=\"mw:LanguageVariant\" data-mw-variant=&apos;{\"twoway\":[{\"l\":\"zh-cn\",\"t\":\"China\"},{\"l\":\"zh-sg\",\"t\":\"Singapore\"}]}&apos; data-parsoid=&apos;{\"fl\":[],\"tSp\":[7],\"dsr\":[21,53,null,2]}&apos;>&lt;/span>"},{"l":"zh-hant","t":"Hello &lt;span typeof=\"mw:LanguageVariant\" data-mw-variant=&apos;{\"twoway\":[{\"l\":\"zh-tw\",\"t\":\"Taiwan\"},{\"l\":\"zh-hk\",\"t\":\"H&amp;lt;span typeof=\\\"mw:LanguageVariant\\\" data-mw-variant=&amp;apos;{\\\"disabled\\\":{\\\"t\\\":\\\"ong\\\"}}&amp;apos; data-parsoid=&amp;apos;{\\\"fl\\\":[],\\\"dsr\\\":[90,97,null,2]}&amp;apos;>&amp;lt;/span> K&amp;lt;span typeof=\\\"mw:LanguageVariant\\\" data-mw-variant=&amp;apos;{\\\"disabled\\\":{\\\"t\\\":\\\"\\\"}}&amp;apos; data-parsoid=&amp;apos;{\\\"fl\\\":[],\\\"dsr\\\":[99,103,null,2]}&amp;apos;>&amp;lt;/span>ong\"}]}&apos; data-parsoid=&apos;{\"fl\":[],\"tSp\":[7],\"dsr\":[68,109,null,2]}&apos;>&lt;/span>"}]}'></span>!</p>
+!! end
+
+!! test
+HTML markups with conversion syntax in attribs, nested in other conversion blocks
+!! options
+language=zh variant=zh-cn
+!! wikitext
+-{zh;zh-hans;zh-hant|<span title="-{X}-">A</span>}-
+!! html/php
+<p><span title="X">A</span>
+</p>
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"filter":{"l":["zh","zh-hans","zh-hant"],"t":"&lt;span title=\"\" about=\"#mwt1\" typeof=\"mw:ExpandedAttrs\" data-parsoid=&#39;{\"stx\":\"html\",\"a\":{\"title\":\"\"},\"sa\":{\"title\":\"-{X}-\"},\"dsr\":[21,49,20,7]}&#39; data-mw=&#39;{\"attribs\":[[{\"txt\":\"title\"},{\"html\":\"&amp;lt;span typeof=\\\"mw:LanguageVariant\\\" data-mw-variant=&amp;apos;{\\\"disabled\\\":{\\\"t\\\":\\\"X\\\"}}&amp;apos; data-parsoid=&amp;apos;{\\\"fl\\\":[],\\\"dsr\\\":[34,39,null,2]}&amp;apos;>&amp;lt;/span>\"}]]}&#39;>A&lt;/span>"}}'></span></p>
+!! end
+
+!! test
+HTML markups with conversion syntax in attribs, nested in other conversion blocks (not working yet in PHP parser)
+!! options
+language=zh variant=zh-cn
+!! wikitext
+-{<span title="-{X}-">A</span>}-
+!! html/php+disabled
+<p><span title="X">A</span>
+</p>
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"&lt;span title=\"\" about=\"#mwt1\" typeof=\"mw:ExpandedAttrs\" data-parsoid=&#39;{\"stx\":\"html\",\"a\":{\"title\":\"\"},\"sa\":{\"title\":\"-{X}-\"},\"dsr\":[2,30,20,7]}&#39; data-mw=&#39;{\"attribs\":[[{\"txt\":\"title\"},{\"html\":\"&amp;lt;span typeof=\\\"mw:LanguageVariant\\\" data-mw-variant=&amp;apos;{\\\"disabled\\\":{\\\"t\\\":\\\"X\\\"}}&amp;apos; data-parsoid=&amp;apos;{\\\"fl\\\":[],\\\"dsr\\\":[15,20,null,2]}&amp;apos;>&amp;lt;/span>\"}]]}&#39;>A&lt;/span>"}}'></span></p>
+!! end
+
+# Parsoid and PHP disagree on how to parse this example: Parsoid
+# insists that the content of a language converter element be a valid
+# DOM fragment or attribute string
+!! test
+Language converter markup with block content
+!! options
+language=zh variant=zh-cn
+!! wikitext
+<span>a-{b<div>c}-d
+
+<span>a-{zh;zh-hans;zh-hant|b<div>c}-d
+
+<span>a-{H|0=>zh-cn:x<span>y;0=>zh-tw:b<div>c}-d
+!! html/php+tidy
+<span>ab<div>cd
+<span>ab<div>cd
+<span>ad
+</span></div></span></div></span>
+!! html/parsoid
+<p><span data-parsoid='{"stx":"html","autoInsertedEnd":true}'>a</span></p><div typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"b&lt;div data-parsoid=&#39;{\"stx\":\"html\",\"autoInsertedEnd\":true,\"dsr\":[10,16,5,0]}&#39;>c&lt;/div>"}}'></div><p>d</p>
+
+<p><span data-parsoid='{"stx":"html","autoInsertedEnd":true}'>a</span></p><div typeof="mw:LanguageVariant" data-mw-variant='{"filter":{"l":["zh","zh-hans","zh-hant"],"t":"b&lt;div data-parsoid=&#39;{\"stx\":\"html\",\"autoInsertedEnd\":true,\"dsr\":[50,56,5,0]}&#39;>c&lt;/div>"}}'></div><p>d</p>
+
+<p><span data-parsoid='{"stx":"html","autoInsertedEnd":true}'>a<meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"oneway":[{"f":"0","l":"zh-cn","t":"x&lt;span data-parsoid=&#39;{\"stx\":\"html\",\"autoInsertedEnd\":true,\"dsr\":[82,89,6,0]}&#39;>y&lt;/span>"},{"f":"0","l":"zh-tw","t":"b&lt;div data-parsoid=&#39;{\"stx\":\"html\",\"autoInsertedEnd\":true,\"dsr\":[100,106,5,0]}&#39;>c&lt;/div>"}]}'/>d</span></p>
+!! end
+
+!! test
+LanguageConverter selser (1)
+!! options
+language=zh variant=zh-cn
+parsoid={
+ "modes": ["wt2wt", "selser"],
+ "changes": [
+ ["span[typeof]", "attr", "data-mw-variant", "{\"disabled\":{\"t\":\"edited\"}}"]
+ ]
+}
+!! wikitext
+-{raw}-
+!! wikitext/edited
+-{edited}-
+!! end
+
+!! test
+LanguageConverter selser (2)
+!! options
+language=zh variant=zh-cn
+parsoid={
+ "modes": ["wt2wt", "selser"],
+ "changes": [
+ ["span[class='x']", "contents", "text", "-{foo}-"],
+ ["a", "contents", "text", "-{"],
+ ["span[typeof]", "attr", "data-mw", "{\"parts\":[{\"template\":{\"target\":{\"wt\":\"1x\",\"href\":\"./Template:1x\"},\"params\":{\"1\":{\"wt\":\"-{\"}},\"i\":0}}]}"]
+ ]
+}
+!! wikitext
+<span class="x">TEXT1</span>
+[http://example.com TEXT2]
+[[Foo|TEXT3]]
+{{echo|TEXT4}}
+!! wikitext/edited
+<span class="x"><nowiki>-{foo}-</nowiki></span>
+[http://example.com -{]
+[[Foo|<nowiki>-{</nowiki>]]
+{{1x|<nowiki>-{</nowiki>}}
+!! end
+
+# Tests LanguageVariantText in ConstrainedText
+!! test
+LanguageConverter selser (3)
+!! options
+language=zh variant=zh-cn
+parsoid={
+ "modes": ["wt2wt", "selser"],
+ "changes": [
+ ["td > span", "attr", "typeof", "mw:LanguageVariant"],
+ ["td > span", "attr", "data-mw-variant", "{\"disabled\":{\"t\":\"edited\"}}"]
+ ]
+}
+!! wikitext
+{|
+|-
+|<span>Foo</span>
+|}
+!! wikitext/edited
+{|
+|-
+|<nowiki/>-{edited}-
+|}
+!! end
+
+# Tests LanguageVariantText._fromSelSer
+!! test
+LanguageConverter selser (4)
+!! options
+language=zh variant=zh-cn
+parsoid={
+ "modes": ["wt2wt", "selser"],
+ "changes": [
+ ["td > span.x", "remove"]
+ ]
+}
+!! wikitext
+{|
+|-
+|<span class="x">Foo</span>-{Bar}-
+||<span class="x">Foo</span>-{Bar}-
+|}
+!! wikitext/edited
+{|
+|-
+|<nowiki/>-{Bar}-
+||-{Bar}-
+|}
+!! end
+
+# Since Parsoid is starting to emit canonical wikitext for links,
+# [http://example.com http://example.com] will not RT back to that
+# form anymore.
+# Parsoid does not language-convert links (it is done in a
+# post-processing step)
+!! test
+Proper conversion of text in external links
+!! options
+language=sr variant=sr-ec
+parsoid=wt2html
+!! wikitext
+http://www.google.com
+gopher://www.google.com
+[http://www.google.com http://www.google.com]
+[gopher://www.google.com gopher://www.google.com]
+[https://www.google.com irc://www.google.com]
+[ftp://www.google.com www.google.com/ftp://dir]
+[//www.google.com www.google.com]
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://www.google.com">http://www.google.com</a>
+<a rel="nofollow" class="external free" href="gopher://www.google.com">gopher://www.google.com</a>
+<a rel="nofollow" class="external text" href="http://www.google.com">http://www.google.com</a>
+<a rel="nofollow" class="external text" href="gopher://www.google.com">gopher://www.google.com</a>
+<a rel="nofollow" class="external text" href="https://www.google.com">irc://www.google.com</a>
+<a rel="nofollow" class="external text" href="ftp://www.google.com">www.гоогле.цом/фтп://дир</a>
+<a rel="nofollow" class="external text" href="//www.google.com">www.гоогле.цом</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="http://www.google.com">http://www.google.com</a>
+<a rel="mw:ExtLink" class="external free" href="gopher://www.google.com">gopher://www.google.com</a>
+<a rel="mw:ExtLink" class="external free" href="http://www.google.com">http://www.google.com</a>
+<a rel="mw:ExtLink" class="external free" href="gopher://www.google.com">gopher://www.google.com</a>
+<a rel="mw:ExtLink" class="external text" href="https://www.google.com">irc://www.google.com</a>
+<a rel="mw:ExtLink" class="external text" href="ftp://www.google.com">www.google.com/ftp://dir</a>
+<a rel="mw:ExtLink" class="external text" href="//www.google.com">www.google.com</a></p>
+!! end
+
+!! test
+Do not convert roman numbers to language variants
+!! options
+language=sr variant=sr-ec
+!! wikitext
+Fridrih IV je car.
+!! html/php
+<p>Фридрих IV је цар.
+</p>
+!! html/parsoid
+<p>Fridrih IV je car.</p>
+!! end
+
+!! test
+Unclosed language converter markup "-{"
+!! options
+language=sr
+!! wikitext
+-{T|hello
+!! html
+<p>-{T|hello
+</p>
+!! end
+
+!! test
+Don't convert raw rule "-{R|=&gt;}-" to "=>"
+!! options
+language=sr
+!! wikitext
+-{R|=&gt;}-
+!! html/php
+<p>=&gt;
+</p>
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"=&lt;span typeof=\"mw:Entity\" data-parsoid=&#39;{\"src\":\"&amp;amp;gt;\",\"srcContent\":\">\",\"dsr\":[5,9,null,null]}&#39;>>&lt;/span>"}}'></span></p>
+!!end
+
+!! test
+Don't break link parsing if language converter markup is in the caption.
+!! options
+language=sr variant=sr-ec
+!! wikitext
+[[Main Page|-{R|main page}-]]
+!! html/php
+<p><a href="/wiki/Main_Page" title="Маин Паге">main page</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page" title="Main Page"><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"main page"}}' data-parsoid='{"fl":["R"]}'></span></a></p>
+!! end
+
+!! test
+T146304: Don't break template parsing if language converter markup is in the parameter.
+!! options
+language=sr variant=sr-ec
+!! wikitext
+{{echo|-{R|foo}-}}
+!! html/php
+<p>foo
+</p>
+!! html/parsoid
+<p><span typeof="mw:Transclusion mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"foo"}}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Шаблон:Echo"},"params":{"1":{"wt":"-{R|foo}-"}},"i":0}}]}'></span></p>
+!! end
+
+!! test
+T146305: Don't break image parsing if language converter markup is in the caption.
+!! options
+language=sr
+!! wikitext
+[[Датотека:Foobar.jpg|thumb|-{R|caption:}-]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/%D0%94%D0%B0%D1%82%D0%BE%D1%82%D0%B5%D0%BA%D0%B0:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/%D0%94%D0%B0%D1%82%D0%BE%D1%82%D0%B5%D0%BA%D0%B0:Foobar.jpg" class="internal" title="Повећај"></a></div>caption:</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"}]}'><a href="./Датотека:Foobar.jpg"><img resource="./Датотека:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"caption:"}}' data-parsoid='{"fl":["R"]}'></span></figcaption></figure>
+!! end
+
+!! test
+T146305: Don't break image parsing if nested language converter markup is in the caption.
+!! options
+language=zh variant=zh-cn
+!! wikitext
+[[File:Foobar.jpg|thumb|-{|zh-cn:blog (hk: -{zh-hans|WEBJOURNAL}-, tw: -{zh-hans|WEBLOG}-)}-]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="放大"></a></div>blog (hk: WEBJOURNAL, tw: WEBLOG)</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"}]}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption><span typeof="mw:LanguageVariant" data-mw-variant='{"twoway":[{"l":"zh-cn","t":"blog (hk: &lt;span typeof=\"mw:LanguageVariant\" data-mw-variant=&#39;{\"filter\":{\"l\":[\"zh-hans\"],\"t\":\"WEBJOURNAL\"}}&#39; data-parsoid=&#39;{\"fl\":[\"zh-hans\"],\"dsr\":[43,65,null,2]}&#39;>&lt;/span>, tw: &lt;span typeof=\"mw:LanguageVariant\" data-mw-variant=&#39;{\"filter\":{\"l\":[\"zh-hans\"],\"t\":\"WEBLOG\"}}&#39; data-parsoid=&#39;{\"fl\":[\"zh-hans\"],\"dsr\":[71,89,null,2]}&#39;>&lt;/span>)"}]}'></span></figcaption></figure>
+!! end
+
+# XXX html2wt disabled because rich markup in alt is not preserved.
+!! test
+Don't break gallery if language converter markup is inside.
+!! options
+language=zh
+!! wikitext
+<gallery>
+File:foobar.jpg|[[File:foobar.jpg|20px|desc|alt=-{R|foo}-|-{R|bar}-]]|alt=-{R|bat}-
+File:foobar.jpg|{{Test|unamedParam|alt=-{R|param}-}}|alt=galleryalt
+</gallery>
+!! html/php
+<ul class="gallery mw-gallery-traditional">
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="bat" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="bar"><img alt="foo" src="http://example.com/images/thumb/3/3a/Foobar.jpg/20px-Foobar.jpg" width="20" height="2" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/30px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/40px-Foobar.jpg 2x" /></a>
+</p>
+ </div>
+ </div></li>
+ <li class="gallerybox" style="width: 155px"><div style="width: 155px">
+ <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="galleryalt" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+ <div class="gallerytext">
+<p>This is a test template
+</p>
+ </div>
+ </div></li>
+</ul>
+
+!! html/parsoid
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt6" data-mw='{"name":"gallery","attrs":{},"body":{"extsrc":"\nFile:foobar.jpg|[[File:foobar.jpg|20px|desc|alt=-{R|foo}-|-{R|bar}-]]|alt=-{R|bat}-\nFile:foobar.jpg|{{Test|unamedParam|alt=-{R|param}-}}|alt=galleryalt\n"}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><figure-inline typeof="mw:Image" data-mw='{"caption":"&lt;span typeof=\"mw:LanguageVariant\" data-mw-variant=&#39;{\"disabled\":{\"t\":\"bar\"}}&#39; data-parsoid=&#39;{\"fl\":[\"R\"],\"dsr\":[68,77,null,2]}&#39;>&lt;/span>"}'><a href="./File:Foobar.jpg"><img alt="" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/20px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="2" width="20"/></a></figure-inline></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><span about="#mwt4" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"Test","href":"./Template:Test"},"params":{"1":{"wt":"unamedParam"},"alt":{"wt":"-{R|param}-"}},"i":0}}]}'>This is a test template</span></div></li>
+</ul>
+!! end
+
+!! test
+T153135: Don't break list handling if language converter markup is in the item.
+!! options
+language=zh variant=zh-cn
+!! wikitext
+;-{zh-cn:AAA;zh-tw:BBB}-
+;-{R|foo:bar}-
+!! html/php
+<dl><dt>AAA</dt>
+<dt>foo:bar</dt></dl>
+
+!! html/parsoid
+<dl><dt data-parsoid='{"dsr":[0,24,1,0]}'><span typeof="mw:LanguageVariant" data-parsoid='{"tSp":[6]}' data-mw-variant='{"twoway":[{"l":"zh-cn","t":"AAA"},{"l":"zh-tw","t":"BBB"}]}'></span></dt>
+<dt data-parsoid='{"dsr":[25,39,1,0]}'><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"foo:bar"}}'></span></dt>
+</dl>
+!! end
+
+// Note that parsoid does not protect colons unless language converter
+// markup is properly nested, because it is a backtracking parser.
+!! test
+T153135: Unclosed markup in definition list (code coverage)
+!! options
+language=zh variant=zh-cn
+!! wikitext
+;<b>foo:bar
+;-{zh-cn:AAA
+!! html/php+tidy
+<dl><dt><b>foo:bar</b></dt><b>
+<dt>-{zh-cn:AAA</dt></b></dl><p><b>
+</b></p>
+!! html/parsoid
+<dl><dt data-parsoid='{"dsr":[0,11,1,0]}'><b data-parsoid='{"stx":"html","autoInsertedEnd":true}'>foo:bar</b></dt><b data-parsoid='{"stx":"html","autoInsertedEnd":true,"autoInsertedStart":true}'>
+<dt data-parsoid='{"dsr":[12,20,1,0]}'>-{zh-cn</dt>
+<dd data-parsoid='{"stx":"row","dsr":[20,24,1,0]}'>AAA</dd>
+</b></dl>
+!! end
+
+!! test
+T153135: Nested language converter markup in definition list (code coverage)
+!! options
+language=zh variant=zh-cn
+!! wikitext
+;-{|zh-cn:AAA -{zh-hans|foo:bar}- -{R|bat:baz}-}-:def
+!! html/php
+<dl><dt>AAA foo:bar bat:baz</dt>
+<dd>def</dd></dl>
+
+!! html/parsoid
+<dl><dt data-parsoid='{"dsr":[0,49,1,0]}'><span typeof="mw:LanguageVariant" data-mw-variant='{"twoway":[{"l":"zh-cn","t":"AAA &lt;span typeof=\"mw:LanguageVariant\" data-mw-variant=&#39;{\"filter\":{\"l\":[\"zh-hans\"],\"t\":\"foo:bar\"}}&#39; data-parsoid=&#39;{\"fl\":[\"zh-hans\"],\"dsr\":[14,33,null,2]}&#39;>&lt;/span> &lt;span typeof=\"mw:LanguageVariant\" data-mw-variant=&#39;{\"disabled\":{\"t\":\"bat:baz\"}}&#39; data-parsoid=&#39;{\"fl\":[\"R\"],\"dsr\":[34,47,null,2]}&#39;>&lt;/span>"}]}'></span></dt>
+<dd data-parsoid='{"stx":"row","dsr":[49,53,1,0]}'>def</dd>
+</dl>
+!! end
+
+# html2wt mode disabled due to <nowiki> insertion.
+!! test
+T153140: Don't break table handling if language converter markup is in the cell.
+!! options
+language=sr variant=sr-ec
+parsoid=wt2html,wt2wt,html2html
+!! wikitext
+{|
+|-
+| -{R|B}-
+|}
+!! html/php
+<table>
+
+<tr>
+<td>B
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tbody>
+<tr>
+<td><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"B"}}'></span></td>
+</tr>
+</tbody>
+</table>
+!! end
+
+!! test
+Language converter tricky html2wt cases (1)
+!! options
+language=sr
+parsoid=html2wt,wt2wt
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"}-"}}'></span></p>
+!! wikitext
+-{<nowiki>}-</nowiki>}-
+!! html/php
+<p>&#125;-
+</p>
+!! end
+
+!! test
+Language converter tricky html2wt cases (2)
+!! options
+language=sr
+parsoid=html2wt,wt2wt
+!! html/parsoid
+<p>-{foo}-</p>
+!! wikitext
+<nowiki>-{foo}-</nowiki>
+!! html/php
+<p>-&#123;foo&#125;-
+</p>
+!! end
+
+!! test
+Language converter tricky html2wt cases (3)
+!! options
+language=sr
+parsoid=html2wt,wt2wt
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"|"}}'></span></p>
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"R|raw"}}'></span></p>
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"-{foo}-"}}'></span></p>
+!! wikitext
+-{R||}-
+
+-{R|R|raw}-
+
+-{<nowiki>-{foo}-</nowiki>}-
+!! html/php
+<p>|
+</p><p>R|raw
+</p><p>-&#123;foo&#125;-
+</p>
+!! end
+
+!! test
+Language converter tricky html2wt cases (4)
+!! options
+language=sr
+parsoid=html2wt,wt2wt
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[2,14,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"hey\"}},\"i\":0}}]}&#39;>hey&lt;/span>"}}'></span></p>
+!! wikitext
+-{R|{{echo|hey}}}-
+!! html/php
+<p>hey
+</p>
+!! end
+
+# Note that the <nowiki> escaping added by parsoid for source text,
+# destination text, and language names only works on the PHP side
+# for *destination text*. (HTML entity escaping wouldn't work
+# any better.) This is probably a bug, at least for source texts.
+# (For language names PHP uses a precise regexp based on the languages
+# it currently knows have variants, which is fragile since this set
+# can grow/shrink over time.)
+!! test
+Language converter tricky html2wt cases (5)
+!! options
+language=zh variant=zh-cn
+!! html/parsoid
+<p><meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"oneway":[{"f":"a:b=>c","l":"zh-cn","t":"x;foo=>zh-cn:boo"},{"f":"bar","l":"zh-cn","t":"bat;xyz=>zh-cn:abc"}]}'/>foobar</p>
+<p><meta typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"oneway":[{"f":"A","l":"bo:g;us","t":"B"}]}'/></p>
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"add":true,"twoway":[{"l":"zh-tw","t":"xyz"},{"l":"zh-cn","t":"0;zh-tw:bar"}]}'></span></p>
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"twoway":[{"l":"bo:g;us","t":"xyz"},{"l":"zh-cn","t":"abc"}]}'></span></p>
+<p>a:b=>c xyz</p>
+!! wikitext
+-{H|<nowiki>a:b=>c</nowiki>=>zh-cn:<nowiki>x;foo=>zh-cn:boo</nowiki>;bar=>zh-cn:<nowiki>bat;xyz=>zh-cn:abc</nowiki>}-foobar
+
+-{H|A=><nowiki>bo:g;us</nowiki>:B}-
+
+-{A|zh-tw:xyz; zh-cn:<nowiki>0;zh-tw:bar</nowiki>}-
+
+-{<nowiki>bo:g;us</nowiki>:xyz; zh-cn:abc}-
+
+a:b=>c xyz
+!! html/php+disabled
+<p>foobat;xyz=&gt;zh-cn:abc
+</p><p>A
+</p><p>0;zh-tw:bar
+</p><p>abc
+</p><p>a:b=&gt;c 0;zh-tw:bar
+</p>
+!! end
+
+!! test
+T179579: Nowiki and lc interaction
+!! options
+parsoid=wt2html
+language=sr
+!! wikitext
+-{</nowiki>123}-
+
+-{123<nowiki>|</nowiki>456}-
+!! html/parsoid
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"&amp;lt;/nowiki>123"}}' data-parsoid='{"fl":[],"src":"-{&lt;/nowiki>123}-"}'></span></p>
+
+<p><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"123&lt;span typeof=\"mw:Nowiki\" data-parsoid=&#39;{\"dsr\":[23,41,8,9]}&#39;>|&lt;/span>456"}}' data-parsoid='{"fl":[],"src":"-{123&lt;nowiki>|&lt;/nowiki>456}-"}'></span></p>
+!! end
+
+!! test
+T2529: Uncovered bullet
+!! wikitext
+*Foo {{bullet}}
+!! html
+<ul><li>Foo</li>
+<li>Bar</li></ul>
+
+!! end
+
+!! test
+T2529: Uncovered bullet in a deeply nested list
+!! wikitext
+*******Foo {{bullet}}
+!! html
+<ul><li><ul><li><ul><li><ul><li><ul><li><ul><li><ul><li>Foo</li></ul></li></ul></li></ul></li></ul></li></ul></li></ul></li>
+<li>Bar</li></ul>
+
+!! end
+
+!! test
+T2529: Uncovered table already at line-start
+!! wikitext
+x
+
+{{table}}
+y
+!! html
+<p>x
+</p>
+<table>
+<tr>
+<td>1</td>
+<td>2
+</td></tr>
+<tr>
+<td>3</td>
+<td>4
+</td></tr></table>
+<p>y
+</p>
+!! end
+
+!! test
+T2529: Uncovered bullet in parser function result
+!! wikitext
+*Foo {{lc:{{bullet}} }}
+!! html
+<ul><li>Foo</li>
+<li>bar</li></ul>
+
+!! end
+
+!! test
+T7678: Double-parsed template argument
+!! wikitext
+{{lc:{{{1}}}|hello}}
+!! html
+<p>{{{1}}}
+</p>
+!! end
+
+!! test
+T7678: Double-parsed template invocation
+!! wikitext
+{{lc:{{paramtest {{!}} param = hello }} }}
+!! html
+<p>{{paramtest | param = hello }}
+</p>
+!! end
+
+!! test
+Case insensitivity of parser functions for non-ASCII characters (T10143)
+!! options
+language=cs
+title=[[Main Page]]
+!! wikitext
+{{PRVNÍVELKÉ:ěščř}}
+{{prvnívelké:ěščř}}
+{{PRVNÍMALÉ:ěščř}}
+{{prvnímalé:ěščř}}
+{{MALÁ:ěščř}}
+{{malá:ěščř}}
+{{VELKÁ:ěščř}}
+{{velká:ěščř}}
+!! html
+<p>Ěščř
+Ěščř
+ěščř
+ěščř
+ěščř
+ěščř
+ĚŠČŘ
+ĚŠČŘ
+</p>
+!! end
+
+!! test
+Morwen/13: Unclosed link followed by heading
+!! wikitext
+[[link
+==heading==
+!! html
+<p>[[link
+</p>
+<h2><span class="mw-headline" id="heading">heading</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: heading">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+!! test
+HHP2.1: Heuristics for headings in preprocessor parenthetical structures
+!! wikitext
+{{foo|
+=heading=
+!! html
+<p>{{foo|
+</p>
+<h1><span class="mw-headline" id="heading">heading</span></h1>
+
+!! end
+
+!! test
+HHP2.2: Heuristics for headings in preprocessor parenthetical structures
+!! wikitext
+{{foo|
+==heading==
+!! html
+<p>{{foo|
+</p>
+<h2><span class="mw-headline" id="heading">heading</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: heading">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+!! test
+Tildes in comments
+!! options
+pst
+!! wikitext
+<!-- ~~~~ -->
+!! html/php
+<!-- ~~~~ -->
+!! end
+
+!! test
+Paragraphs inside divs (no extra line breaks)
+!! wikitext
+<div>Line one
+
+Line two</div>
+!! html
+<div>Line one
+Line two</div>
+
+!! end
+
+!! test
+Paragraphs inside divs (extra line break on open)
+!! wikitext
+<div>
+Line one
+
+Line two</div>
+!! html
+<div>
+<p>Line one
+</p>
+Line two</div>
+
+!! end
+
+!! test
+Paragraphs inside divs (extra line break on close)
+!! wikitext
+<div>Line one
+
+Line two
+</div>
+!! html
+<div>Line one
+<p>Line two
+</p>
+</div>
+
+!! end
+
+!! test
+Paragraphs inside divs (extra line break on open and close)
+!! wikitext
+<div>
+Line one
+
+Line two
+</div>
+!! html
+<div>
+<p>Line one
+</p><p>Line two
+</p>
+</div>
+
+!! end
+
+# doBlockLevels screws up this output and Remex cleans up as much as it can.
+# Parsoid seems to do a better job here since its p-wrapper is probably smarter.
+!! test
+Nesting tags, paragraphs on lines which begin with <div>
+!! wikitext
+<div></div><strong>A
+B</strong>
+!! html/php+tidy
+<div></div><p><strong>A
+</strong></p><strong></strong><p><strong>B</strong>
+</p>
+!! html/parsoid
+<div></div>
+<p><strong>A
+B</strong>
+</p>
+!! end
+
+# T8200: <blockquote> should behave like <div> with respect to line breaks
+!! test
+T8200: paragraphs inside blockquotes (no extra line breaks)
+!! wikitext
+<blockquote>Line one
+
+Line two</blockquote>
+!! html
+<blockquote>Line one
+Line two</blockquote>
+
+!! html+tidy
+<blockquote><p>Line one
+Line two</p></blockquote>
+!! end
+
+!! test
+T8200: paragraphs inside blockquotes (extra line break on open)
+!! wikitext
+<blockquote>
+Line one
+
+Line two</blockquote>
+!! html
+<blockquote>
+<p>Line one
+</p>
+Line two</blockquote>
+
+!! html+tidy
+<blockquote>
+<p>Line one
+</p><p>
+Line two</p></blockquote>
+!! end
+
+# Parsoid's output is broken on this because of Tidy-compatibility cruft
+!! test
+T8200: paragraphs inside blockquotes (extra line break on close)
+!! wikitext
+<blockquote>Line one
+
+Line two
+</blockquote>
+!! html
+<blockquote>Line one
+<p>Line two
+</p>
+</blockquote>
+
+!! html+tidy
+<blockquote><p>Line one
+</p><p>Line two
+</p>
+</blockquote>
+!! end
+
+!! test
+T8200: paragraphs inside blockquotes (extra line break on open and close)
+!! wikitext
+<blockquote>
+Line one
+
+Line two
+</blockquote>
+!! html
+<blockquote>
+<p>Line one
+</p><p>Line two
+</p>
+</blockquote>
+
+!! end
+
+# FIXME: Why does/should the blockquote+div combo suppress p-wrapping here?
+!! test
+Paragraphs inside blockquotes/divs (no extra line breaks)
+!! wikitext
+<blockquote><div>Line one
+
+Line two</div></blockquote>
+!! html
+<blockquote><div>Line one
+Line two</div></blockquote>
+
+!! end
+
+!! test
+Paragraphs inside blockquotes/divs (extra line break on open)
+!! wikitext
+<blockquote><div>
+Line one
+
+Line two</div></blockquote>
+!! html
+<blockquote><div>
+<p>Line one
+</p>
+Line two</div></blockquote>
+
+!! end
+
+!! test
+Paragraphs inside blockquotes/divs (extra line break on close)
+!! wikitext
+<blockquote><div>Line one
+
+Line two
+</div></blockquote>
+!! html
+<blockquote><div>Line one
+<p>Line two
+</p>
+</div></blockquote>
+
+!! end
+
+!! test
+Paragraphs inside blockquotes/divs (extra line break on open and close)
+!! wikitext
+<blockquote><div>
+Line one
+
+Line two
+</div></blockquote>
+!! html
+<blockquote><div>
+<p>Line one
+</p><p>Line two
+</p>
+</div></blockquote>
+
+!! end
+
+!! test
+Interwiki links trounced by replaceExternalLinks after early LinkHolderArray expansion
+!! options
+wgLinkHolderBatchSize=0
+!! wikitext
+[[meatball:1]]
+[[meatball:2]]
+[[meatball:3]]
+!! html
+<p><a href="http://www.usemod.com/cgi-bin/mb.pl?1" class="extiw" title="meatball:1">meatball:1</a>
+<a href="http://www.usemod.com/cgi-bin/mb.pl?2" class="extiw" title="meatball:2">meatball:2</a>
+<a href="http://www.usemod.com/cgi-bin/mb.pl?3" class="extiw" title="meatball:3">meatball:3</a>
+</p>
+!! end
+
+!! test
+Free external link invading image caption
+!! wikitext
+[[Image:Foobar.jpg|thumb|http://x|hello]]
+!! html/php
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>hello</div></div></div>
+
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"bogus","ak":"http://x"},{"ck":"caption","ak":"hello"}]}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"25","width":"220"},"sa":{"resource":"Image:Foobar.jpg"}}'/></a><figcaption>hello</figcaption></figure>
+!! end
+
+!! test
+T17196: localised external link numbers
+!! options
+language=fa
+!! wikitext
+[http://en.wikipedia.org/]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="http://en.wikipedia.org/">[۱]</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="http://en.wikipedia.org/"></a></p>
+!! end
+
+!! test
+Multibyte character in padleft
+!! wikitext
+{{padleft:-Hello|7|Æ}}
+!! html/php
+<p>Æ-Hello
+</p>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"padleft:-Hello","function":"padleft"},"params":{"1":{"wt":"7"},"2":{"wt":"Æ"}},"i":0}}]}'>Æ-Hello</p>
+!! end
+
+!! test
+Multibyte character in padright
+!! wikitext
+{{padright:Hello-|7|Æ}}
+!! html/php
+<p>Hello-Æ
+</p>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"padright:Hello-","function":"padright"},"params":{"1":{"wt":"7"},"2":{"wt":"Æ"}},"i":0}}]}'>Hello-Æ</p>
+!! end
+
+!!test
+formatdate parser function
+!! wikitext
+{{#formatdate:2009-03-24}}
+!! html
+<p><span class="mw-formatted-date" title="2009-03-24">2009-03-24</span>
+</p>
+!! end
+
+!!test
+formatdate parser function, with default format
+!! wikitext
+{{#formatdate:2009-03-24|mdy}}
+!! html
+<p><span class="mw-formatted-date" title="2009-03-24">March 24, 2009</span>
+</p>
+!! end
+
+!! test
+Spacing of numbers in formatted dates
+!! wikitext
+{{#formatdate:January 15}}
+!! html
+<p><span class="mw-formatted-date" title="01-15">January 15</span>
+</p>
+!! end
+
+!! test
+formatdate parser function, with default format and on a page of which the content language is always English and different from the wiki content language
+!! options
+language=nl title=[[MediaWiki:Common.css]]
+!! wikitext
+{{#formatdate:2009-03-24|dmy}}
+!! html
+<p><span class="mw-formatted-date" title="2009-03-24">24 March 2009</span>
+</p>
+!! end
+
+#
+#
+#
+
+#
+# Edit comments
+#
+
+!! test
+Edit comment with link
+!! options
+comment
+!! wikitext
+I like the [[Main Page]] a lot
+!! html/php
+I like the <a href="/wiki/Main_Page" title="Main Page">Main Page</a> a lot
+!!end
+
+!! test
+Edit comment with link and link text
+!! options
+comment
+!! wikitext
+I like the [[Main Page|best pages]] a lot
+!! html/php
+I like the <a href="/wiki/Main_Page" title="Main Page">best pages</a> a lot
+!!end
+
+!! test
+Edit comment with link and link text with suffix
+!! options
+comment
+!! wikitext
+I like the [[Main Page|best page]]s a lot
+!! html/php
+I like the <a href="/wiki/Main_Page" title="Main Page">best pages</a> a lot
+!!end
+
+!! test
+Edit comment with section link (non-local, eg in history list)
+!! options
+comment title=[[Main Page]]
+!! wikitext
+/* External links */ removed bogus entries
+!! html/php
+<a href="/wiki/Main_Page#External_links" title="Main Page">→</a>‎<span dir="auto"><span class="autocomment">External links: </span> removed bogus entries</span>
+!!end
+
+!! test
+Edit comment with section link and text before it (non-local, eg in history list)
+!! options
+comment title=[[Main Page]]
+!! wikitext
+pre-comment text /* External links */ removed bogus entries
+!! html/php
+pre-comment text <a href="/wiki/Main_Page#External_links" title="Main Page">→</a>‎<span dir="auto"><span class="autocomment">External links: </span> removed bogus entries</span>
+!!end
+
+!! test
+Edit comment with section link (local, eg in diff view)
+!! options
+comment local title=[[Main Page]]
+!! wikitext
+/* External links */ removed bogus entries
+!! html/php
+<a href="#External_links">→</a>‎<span dir="auto"><span class="autocomment">External links: </span> removed bogus entries</span>
+!!end
+
+!! test
+Edit comment with subpage link (T16080)
+!! options
+comment
+subpage
+title=[[Subpage test]]
+!! wikitext
+Poked at a [[/subpage]] here...
+!! html/php
+Poked at a <a href="/wiki/Subpage_test/subpage" title="Subpage test/subpage">/subpage</a> here...
+!!end
+
+!! test
+Edit comment with subpage link and link text (T16080)
+!! options
+comment
+subpage
+title=[[Subpage test]]
+!! wikitext
+Poked at a [[/subpage|neat little page]] here...
+!! html/php
+Poked at a <a href="/wiki/Subpage_test/subpage" title="Subpage test/subpage">neat little page</a> here...
+!!end
+
+!! test
+Edit comment with bogus subpage link in non-subpage NS (T16080)
+!! options
+comment
+title=[[Subpage test]]
+!! wikitext
+Poked at a [[/subpage]] here...
+!! html/php
+Poked at a <a href="/index.php?title=/subpage&amp;action=edit&amp;redlink=1" class="new" title="/subpage (page does not exist)">/subpage</a> here...
+!!end
+
+!! test
+Edit comment with bare anchor link (local, as on diff)
+!! options
+comment
+local
+title=[[Main Page]]
+!! wikitext
+[[#section]]
+!! html/php
+<a href="#section">#section</a>
+!! end
+
+!! test
+Edit comment with bare anchor link (non-local, as on history)
+!! options
+comment
+title=[[Main Page]]
+!! wikitext
+[[#section]]
+!! html/php
+<a href="/wiki/Main_Page#section" title="Main Page">#section</a>
+!! end
+
+!! test
+Anchor starting with underscore
+!! options
+title=[[Foo]]
+!! wikitext
+[[#_ref|One]]
+!! html/php
+<p><a href="#_ref">One</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Foo#_ref" data-parsoid='{"stx":"piped","a":{"href":"./Foo#_ref"},"sa":{"href":"#_ref"}}'>One</a></p>
+!! end
+
+!! test
+Id starting with underscore
+!! wikitext
+<div id="_ref"></div>
+!! html/*
+<div id="_ref"></div>
+
+!! end
+
+!! test
+Edit comment with link with more than one pipe (T99346)
+!! options
+comment
+!! wikitext
+[[Main Page|Many|pipes]]
+!! html/php
+<a href="/wiki/Main_Page" title="Main Page">Many|pipes</a>
+!! end
+
+!! test
+Complex edit comment with link with more than one pipe (T99346)
+!! options
+comment
+!! wikitext
+Created page with "<noinclude>[[Category:Requests for permissions/Bot|{{subst:#titleparts:{{subst:PAGENAME}}|1|3}}]]</noinclude> === [[User:MineoBot|]] 8=== {{Request for permissions/links|Mineo..."
+!! html/php
+Created page with &quot;&lt;noinclude&gt;<a href="/index.php?title=Category:Requests_for_permissions/Bot&amp;action=edit&amp;redlink=1" class="new" title="Category:Requests for permissions/Bot (page does not exist)">{{subst:#titleparts:{{subst:PAGENAME}}|1|3}}</a>&lt;/noinclude&gt; === <a href="/index.php?title=User:MineoBot&amp;action=edit&amp;redlink=1" class="new" title="User:MineoBot (page does not exist)">User:MineoBot</a> 8=== {{Request for permissions/links|Mineo...&quot;
+!! end
+
+!! test
+Space normalisation on autocomment (T24784)
+!! options
+comment
+title=[[Main Page]]
+!! wikitext
+/* __hello__world__ */
+!! html/php
+<a href="/wiki/Main_Page#hello_world" title="Main Page">→</a>‎<span dir="auto"><span class="autocomment">__hello__world__</span></span>
+!! end
+
+!! test
+percent-encoding and + signs in comments (T28410)
+!! options
+comment
+!! wikitext
+[[ABC%33D% ++]] [[ABC%33D% ++|+%20]]
+!! html/php
+<a href="/index.php?title=ABC3D%25_%2B%2B&amp;action=edit&amp;redlink=1" class="new" title="ABC3D% ++ (page does not exist)">ABC3D% ++</a> <a href="/index.php?title=ABC3D%25_%2B%2B&amp;action=edit&amp;redlink=1" class="new" title="ABC3D% ++ (page does not exist)">+%20</a>
+!! end
+
+# Parsoid doesn't support this yet: see T75581
+# but it *should* omit the 'src' attribute if the image is bad.
+# PHP side of tests was disabled in
+# mediawiki/core:6bd31e7d95161a6e88fa86df60871051da997c3c
+# because of issues in the PHP parserTests infrastructure
+# (but the output below is indeed what the PHP side emits)
+!! test
+Bad images - basic functionality
+!! wikitext
+[[File:Bad.jpg]]
+!! html/php+disabled
+!! html/parsoid
+<p><span class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"bad-image","message":"This image is blacklisted in this context."}]}'><a href="./File:Bad.jpg"><img resource="./File:Bad.jpg" height="220" width="220"/></a></span></p>
+!! end
+
+!! test
+Bad images - T18039: text after bad image disappears
+!! wikitext
+Foo bar
+[[File:Bad.jpg]]
+Bar foo
+!! html/php+disabled
+<p>Foo bar
+</p><p>Bar foo
+</p>
+!! html/parsoid
+<p>Foo bar
+<span class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"bad-image","message":"This image is blacklisted in this context."}]}'><a href="./File:Bad.jpg"><img resource="./File:Bad.jpg" height="220" width="220"/></a></span>
+Bar foo</p>
+!! end
+
+!! test
+Verify that displaytitle works (T24501) no displaytitle
+!! options
+showtitle
+!! config
+wgAllowDisplayTitle=true
+wgRestrictDisplayTitle=false
+!! wikitext
+this is not the the title
+!! html/php
+Parser test
+<p>this is not the the title
+</p>
+!! end
+
+!! test
+Verify that displaytitle works (T24501) RestrictDisplayTitle=false
+!! options
+showtitle
+title=[[Screen]]
+!! config
+wgAllowDisplayTitle=true
+wgRestrictDisplayTitle=false
+!! wikitext
+this is not the the title
+{{DISPLAYTITLE:whatever}}
+!! html/php
+whatever
+<p>this is not the the title
+</p>
+!! end
+
+!! test
+Verify that displaytitle works (T24501) RestrictDisplayTitle=true mismatch
+!! options
+showtitle
+title=[[Screen]]
+!! config
+wgAllowDisplayTitle=true
+wgRestrictDisplayTitle=true
+!! wikitext
+this is not the the title
+{{DISPLAYTITLE:whatever}}
+!! html/php
+Screen
+<p>this is not the the title
+</p>
+!! end
+
+!! test
+Verify that displaytitle works (T24501) RestrictDisplayTitle=true matching
+!! options
+showtitle
+title=[[Screen]]
+!! config
+wgAllowDisplayTitle=true
+wgRestrictDisplayTitle=true
+!! wikitext
+this is not the the title
+{{DISPLAYTITLE:screen}}
+!! html/php
+screen
+<p>this is not the the title
+</p>
+!! end
+
+!! test
+Verify that displaytitle works (T24501) AllowDisplayTitle=false
+!! options
+showtitle
+title=[[Screen]]
+!! config
+wgAllowDisplayTitle=false
+!! wikitext
+this is not the the title
+{{DISPLAYTITLE:screen}}
+!! html/php
+Screen
+<p>this is not the the title
+<a href="/index.php?title=Template:DISPLAYTITLE:screen&amp;action=edit&amp;redlink=1" class="new" title="Template:DISPLAYTITLE:screen (page does not exist)">Template:DISPLAYTITLE:screen</a>
+</p>
+!! end
+
+!! test
+Verify that displaytitle works (T24501) AllowDisplayTitle=false no DISPLAYTITLE
+!! options
+showtitle
+title=[[Screen]]
+!! config
+wgAllowDisplayTitle=false
+!! wikitext
+this is not the the title
+!! html/php
+Screen
+<p>this is not the the title
+</p>
+!! end
+
+!! test
+Verify that displaytitle handles inline CSS styles (T28547) - rejected value
+!! options
+showtitle
+title=[[Screen]]
+!! config
+wgAllowDisplayTitle=true
+wgRestrictDisplayTitle=true
+!! wikitext
+this is not the the title
+{{DISPLAYTITLE:<span style="display: none;">s</span>creen}}
+!! html/php
+<span style="/* attempt to bypass $wgRestrictDisplayTitle */">s</span>creen
+<p>this is not the the title
+</p>
+!! end
+
+!! test
+Verify that displaytitle handles inline CSS styles (T28547) - accepted value
+!! options
+showtitle
+title=[[Screen]]
+!! config
+wgAllowDisplayTitle=true
+wgRestrictDisplayTitle=true
+!! wikitext
+this is not the the title
+{{DISPLAYTITLE:<span style="color: red;">s</span>creen}}
+!! html/php
+<span style="color: red;">s</span>creen
+<p>this is not the the title
+</p>
+!! end
+
+!! test
+Page status indicators: Empty name is invalid
+!! options
+showindicators
+!! wikitext
+<indicator name=" "></indicator>
+<indicator></indicator>
+!! html/php
+<p><span class="error"><strong>Error:</strong> Page status indicators' <code>name</code> attribute must not be empty.</span>
+<span class="error"><strong>Error:</strong> Page status indicators' <code>name</code> attribute must not be empty.</span>
+</p>
+!! end
+
+!! test
+Page status indicators: Weird syntaxes that are okay
+!! options
+showindicators
+!! wikitext
+<indicator name="empty" />
+<indicator name="name"></indicator>
+!! html/php
+empty=
+name=
+<p><br />
+</p>
+!! end
+
+!! test
+Page status indicators: Torture test
+!! options
+showindicators
+!! wikitext
+<indicator name="01">hello world</indicator>
+<indicator name="02">[[Main Page]]</indicator>
+<indicator name="03">[[File:Foobar.jpg|25px|link=]]</indicator>
+<indicator name="04">[[File:Foobar.jpg|25px]]</indicator>
+<indicator name="05">*foo
+*bar</indicator>
+<indicator name="06"><nowiki>foo</nowiki></indicator>
+<indicator name="07"> Preformatted</indicator>
+<indicator name="08"><div>Broken tag</indicator>
+<indicator name="09">{| class=wikitable
+|cell
+|}</indicator>
+<indicator name="10">Two
+
+paragraphs</indicator>
+!! html/php
+01=hello world
+02=<a href="/wiki/Main_Page" title="Main Page">Main Page</a>
+03=<img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/25px-Foobar.jpg" width="25" height="3" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/38px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg 2x" />
+04=<a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/25px-Foobar.jpg" width="25" height="3" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/38px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg 2x" /></a>
+05=<ul><li>foo</li>
+<li>bar</li></ul>
+
+06=foo
+07=<pre>Preformatted
+</pre>
+08=<div>Broken tag</div>
+
+09=<table class="wikitable">
+<tr>
+<td>cell
+</td></tr></table>
+
+10=<p>Two
+</p><p>paragraphs
+</p>
+<p><br />
+</p><p><br />
+</p><p><br />
+</p><p><br />
+</p><p><br />
+</p>
+!! end
+
+!! test
+preload: check <noinclude> and <includeonly>
+!! options
+preload
+!! wikitext
+Hello <noinclude>cruel</noinclude><includeonly>kind</includeonly> world.
+!! html/php
+Hello kind world.
+!! end
+
+!! test
+preload: check <onlyinclude>
+!! options
+preload
+!! wikitext
+Goodbye <onlyinclude>Hello world</onlyinclude>
+!! html/php
+Hello world
+!! end
+
+!! test
+preload: can pass tags through if we want to
+!! options
+preload
+!! wikitext
+<includeonly><</includeonly>includeonly>Hello world<includeonly><</includeonly>/includeonly>
+!! html/php
+<includeonly>Hello world</includeonly>
+!! end
+
+!! test
+preload: check that it doesn't try to do tricks
+!! options
+preload
+!! wikitext
+* <!-- Hello --> ''{{world}}'' {{<includeonly>subst:</includeonly>How are you}}{{ {{{|safesubst:}}} #if:1|2|3}}
+!! html/php
+* <!-- Hello --> ''{{world}}'' {{subst:How are you}}{{ {{{|safesubst:}}} #if:1|2|3}}
+!! end
+
+!! test
+Play a bit with r67090 and T5158
+!! wikitext
+<div style="width:50% !important">&nbsp;</div>
+<div style="width:50%&nbsp;!important">&nbsp;</div>
+<div style="width:50%&#160;!important">&nbsp;</div>
+<div style="border : solid;">&nbsp;</div>
+!! html/php
+<div style="width:50% !important">&#160;</div>
+<div style="width:50% !important">&#160;</div>
+<div style="width:50% !important">&#160;</div>
+<div style="border&#160;: solid;">&#160;</div>
+
+!! html/parsoid
+<div style="width:50% !important" data-parsoid='{"stx":"html"}'><span typeof="mw:Entity" data-parsoid='{"srcContent":" "}'> </span></div>
+<div style="width:50% !important" data-parsoid='{"stx":"html","a":{"style":"width:50% !important"},"sa":{"style":"width:50%&amp;nbsp;!important"}}'><span typeof="mw:Entity" data-parsoid='{"srcContent":" "}'> </span></div>
+<div style="width:50% !important" data-parsoid='{"stx":"html","a":{"style":"width:50% !important"},"sa":{"style":"width:50%&amp;#160;!important"}}'><span typeof="mw:Entity" data-parsoid='{"srcContent":" "}'> </span></div>
+<div style="border : solid;" data-parsoid='{"stx":"html"}'><span typeof="mw:Entity" data-parsoid='{"srcContent":" "}'> </span></div>
+
+!! end
+
+!! test
+HTML5 data attributes
+!! wikitext
+<span data-foo="bar">Baz</span>
+<p data-abc-def_hij="">Quuz</p>
+!! html/php
+<p><span data-foo="bar">Baz</span>
+</p>
+<p data-abc-def_hij="">Quuz</p>
+
+!! html/parsoid
+<p><span data-foo="bar" data-parsoid='{"stx":"html"}'>Baz</span></p>
+<p data-abc-def_hij="" data-parsoid='{"stx":"html"}'>Quuz</p>
+!! end
+
+!! test
+Strip reserved data attributes
+!! wikitext
+<div data-mw="foo" data-parsoid="bar" data-mw-someext="baz" data-ok="fred" data-ooui="xyzzy" data-bad:ns="ns">d</div>
+!! html/php
+<div data-ok="fred">d</div>
+
+!! html/parsoid
+<div data-x-data-mw="foo" data-x-data-parsoid="bar" data-x-data-mw-someext="baz" data-ok="fred" data-parsoid='{"stx":"html","a":{"data-ooui":null,"data-bad:ns":null},"sa":{"data-ooui":"xyzzy","data-bad:ns":"ns"}}'>d</div>
+!! end
+
+!! test
+percent-encoding and + signs in internal links (T28410)
+!! wikitext
+[[User:+%]] [[Page+title%]]
+[[%+]] [[%+|%20]] [[%+ ]] [[%+r]]
+[[%]] [[+]] [[File:%+abc%39|foo|[[bar]]]]
+[[%33%45]] [[%33%45+]]
+!! html/php
+<p><a href="/index.php?title=User:%2B%25&amp;action=edit&amp;redlink=1" class="new" title="User:+% (page does not exist)">User:+%</a> <a href="/index.php?title=Page%2Btitle%25&amp;action=edit&amp;redlink=1" class="new" title="Page+title% (page does not exist)">Page+title%</a>
+<a href="/index.php?title=%25%2B&amp;action=edit&amp;redlink=1" class="new" title="%+ (page does not exist)">%+</a> <a href="/index.php?title=%25%2B&amp;action=edit&amp;redlink=1" class="new" title="%+ (page does not exist)">%20</a> <a href="/index.php?title=%25%2B&amp;action=edit&amp;redlink=1" class="new" title="%+ (page does not exist)">%+ </a> <a href="/index.php?title=%25%2Br&amp;action=edit&amp;redlink=1" class="new" title="%+r (page does not exist)">%+r</a>
+<a href="/index.php?title=%25&amp;action=edit&amp;redlink=1" class="new" title="% (page does not exist)">%</a> <a href="/index.php?title=%2B&amp;action=edit&amp;redlink=1" class="new" title="+ (page does not exist)">+</a> <a href="/index.php?title=Special:Upload&amp;wpDestFile=%25%2Babc9" class="new" title="File:%+abc9">bar</a>
+<a href="/index.php?title=3E&amp;action=edit&amp;redlink=1" class="new" title="3E (page does not exist)">3E</a> <a href="/index.php?title=3E%2B&amp;action=edit&amp;redlink=1" class="new" title="3E+ (page does not exist)">3E+</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./User:+%25" title="User:+%" data-parsoid='{"stx":"simple","a":{"href":"./User:+%25"},"sa":{"href":"User:+%"}}'>User:+%</a> <a rel="mw:WikiLink" href="./Page+title%25" title="Page+title%" data-parsoid='{"stx":"simple","a":{"href":"./Page+title%25"},"sa":{"href":"Page+title%"}}'>Page+title%</a>
+<a rel="mw:WikiLink" href="./%25+" title="%+" data-parsoid='{"stx":"simple","a":{"href":"./%25+"},"sa":{"href":"%+"}}'>%+</a> <a rel="mw:WikiLink" href="./%25+" title="%+" data-parsoid='{"stx":"piped","a":{"href":"./%25+"},"sa":{"href":"%+"}}'>%20</a> <a rel="mw:WikiLink" href="./%25+" title="%+" data-parsoid='{"stx":"simple","a":{"href":"./%25+"},"sa":{"href":"%+ "}}'>%+ </a> <a rel="mw:WikiLink" href="./%25+r" title="%+r" data-parsoid='{"stx":"simple","a":{"href":"./%25+r"},"sa":{"href":"%+r"}}'>%+r</a>
+<a rel="mw:WikiLink" href="./%25" title="%" data-parsoid='{"stx":"simple","a":{"href":"./%25"},"sa":{"href":"%"}}'>%</a> <a rel="mw:WikiLink" href="./+" title="+" data-parsoid='{"stx":"simple","a":{"href":"./+"},"sa":{"href":"+"}}'>+</a> <figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"bogus","ak":"foo"},{"ck":"caption","ak":"[[bar]]"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&lt;a rel=\"mw:WikiLink\" href=\"./Bar\" title=\"Bar\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Bar\"},\"sa\":{\"href\":\"bar\"},\"dsr\":[94,101,2,2]}&#39;>bar&lt;/a>"}'><a href="./File:%25+abc9" data-parsoid='{"a":{"href":"./File:%25+abc9"},"sa":{}}'><img resource="./File:%25+abc9" src="./Special:FilePath/%25+abc9" height="220" width="220" data-parsoid='{"a":{"resource":"./File:%25+abc9","height":"220","width":"220"},"sa":{"resource":"File:%+abc%39"}}'/></a></figure-inline>
+<a rel="mw:WikiLink" href="./3E" title="3E" data-parsoid='{"stx":"simple","a":{"href":"./3E"},"sa":{"href":"%33%45"}}'>3E</a> <a rel="mw:WikiLink" href="./3E+" title="3E+" data-parsoid='{"stx":"simple","a":{"href":"./3E+"},"sa":{"href":"%33%45+"}}'>3E+</a></p>
+!! end
+
+!! test
+Special characters in embedded file links (T29679)
+!! wikitext
+[[File:Contains & ampersand.jpg]]
+[[File:Does not exist.jpg|Title with & ampersand]]
+!! html/php
+<p><a href="/index.php?title=Special:Upload&amp;wpDestFile=Contains_%26_ampersand.jpg" class="new" title="File:Contains &amp; ampersand.jpg">File:Contains &amp; ampersand.jpg</a>
+<a href="/index.php?title=Special:Upload&amp;wpDestFile=Does_not_exist.jpg" class="new" title="File:Does not exist.jpg">Title with &amp; ampersand</a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Contains_&amp;_ampersand.jpg"><img resource="./File:Contains_&amp;_ampersand.jpg" src="./Special:FilePath/Contains_&amp;_ampersand.jpg" height="220" width="220"/></a></figure-inline>
+<figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"Title with &amp;amp; ampersand"}'><a href="./File:Does_not_exist.jpg"><img resource="./File:Does_not_exist.jpg" src="./Special:FilePath/Does_not_exist.jpg" height="220" width="220"/></a></figure-inline></p>
+!! end
+
+!! test
+Confirm that 'apos' named character reference doesn't make it to output (not legal in HTML 4)
+!! wikitext
+Text&apos;s been normalized?
+!! html
+<p>Text&#39;s been normalized?
+</p>
+!! end
+
+!! test
+T21052 U+3000 IDEOGRAPHIC SPACE should terminate free external links
+!! wikitext
+http://www.example.org/ <-- U+3000 (vim: ^Vu3000)
+!! html
+<p><a rel="nofollow" class="external free" href="http://www.example.org/">http://www.example.org/</a> &lt;-- U+3000 (vim: ^Vu3000)
+</p>
+!! end
+
+!! test
+T21052 U+3000 IDEOGRAPHIC SPACE should terminate bracketed external links
+!! wikitext
+[http://www.example.org/ ideograms]
+!! html
+<p><a rel="nofollow" class="external text" href="http://www.example.org/">ideograms</a>
+</p>
+!! end
+
+!! test
+T21052 U+3000 IDEOGRAPHIC SPACE should terminate external images links
+!! wikitext
+http://www.example.org/pic.png <-- U+3000 (vim: ^Vu3000)
+!! html
+<p><img src="http://www.example.org/pic.png" alt="pic.png"/> &lt;-- U+3000 (vim: ^Vu3000)
+</p>
+!! end
+
+!! article
+Mediawiki:loop1
+!! text
+{{Identical|A}}
+!! endarticle
+
+!! article
+Mediawiki:loop2
+!! text
+{{Identical|B}}
+!! endarticle
+
+!! article
+Template:Identical
+!! text
+{{int:loop1}}
+{{int:loop2}}
+!! endarticle
+
+!! test
+T33098 Template which includes system messages which includes the template
+!! wikitext
+{{Identical}}
+!! html
+<p><span class="error">Template loop detected: <a href="/wiki/Template:Identical" title="Template:Identical">Template:Identical</a></span>
+<span class="error">Template loop detected: <a href="/wiki/Template:Identical" title="Template:Identical">Template:Identical</a></span>
+</p>
+!! end
+
+!! test
+T33490 Turkish: ucfirst 'blah'
+!! options
+language=tr
+!! wikitext
+{{ucfirst:blah}}
+!! html
+<p>Blah
+</p>
+!! end
+
+!! test
+T33490 Turkish: ucfirst 'ix'
+!! options
+language=tr
+!! wikitext
+{{ucfirst:ix}}
+!! html
+<p>İx
+</p>
+!! end
+
+!! test
+T33490 Turkish: lcfirst 'BLAH'
+!! options
+language=tr
+!! wikitext
+{{lcfirst:BLAH}}
+!! html
+<p>bLAH
+</p>
+!! end
+
+!! test
+T33490 Turkish: ucfırst (with a dotless i)
+!! options
+language=tr
+!! wikitext
+{{ucfırst:blah}}
+!! html
+<p><a href="/index.php?title=%C5%9Eablon:Ucf%C4%B1rst:blah&amp;action=edit&amp;redlink=1" class="new" title="Şablon:Ucfırst:blah (sayfa mevcut değil)">Şablon:Ucfırst:blah</a>
+</p>
+!! end
+
+!! test
+T33490 ucfırst (with a dotless i) with English language
+!! options
+language=en
+!! wikitext
+{{ucfırst:blah}}
+!! html
+<p><a href="/index.php?title=Template:Ucf%C4%B1rst:blah&amp;action=edit&amp;redlink=1" class="new" title="Template:Ucfırst:blah (page does not exist)">Template:Ucfırst:blah</a>
+</p>
+!! end
+
+# Note that Parsoid doesn't emit an explicit TOC.
+# Note also that the html2wt direction tends to emit an extra newline
+# between the __TOC__ magicword and the first heading unless *both*
+# the <meta> and the <h2> have a data-parsoid attribute set (even if
+# it's "{}").
+
+!! test
+T28375: TOC with italics
+!! options
+title=[[Main Page]]
+!! wikitext
+__TOC__
+==''Lost'' episodes==
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Lost_episodes"><span class="tocnumber">1</span> <span class="toctext"><i>Lost</i> episodes</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Lost_episodes"><i>Lost</i> episodes</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Main_Page&amp;action=edit&amp;section=1" title="Edit section: Lost episodes">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<meta property="mw:PageProp/toc" data-parsoid='{}'/>
+<h2 id="Lost_episodes" data-parsoid='{}'><i>Lost</i> episodes</h2>
+!! end
+
+!! test
+T28375: TOC with bold
+!! options
+title=[[Main Page]]
+!! wikitext
+__TOC__
+=='''should be bold''' then normal text==
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#should_be_bold_then_normal_text"><span class="tocnumber">1</span> <span class="toctext"><b>should be bold</b> then normal text</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="should_be_bold_then_normal_text"><b>should be bold</b> then normal text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Main_Page&amp;action=edit&amp;section=1" title="Edit section: should be bold then normal text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<meta property="mw:PageProp/toc" data-parsoid='{}'/>
+<h2 id="should_be_bold_then_normal_text" data-parsoid='{}'><b>should be bold</b> then normal text</h2>
+!! end
+
+!! test
+T35845: Headings become cursive in TOC when they contain an image
+!! options
+title=[[Main Page]]
+!! wikitext
+__TOC__
+==Image [[Image:foobar.jpg]]==
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Image"><span class="tocnumber">1</span> <span class="toctext">Image</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Image">Image <a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Main_Page&amp;action=edit&amp;section=1" title="Edit section: Image">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<meta property="mw:PageProp/toc" data-parsoid='{}'/>
+<h2 id="Image" data-parsoid='{}'>Image <figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></figure-inline></h2>
+!! end
+
+!! test
+T35845 (2): Headings become bold in TOC when they contain a blockquote
+!! options
+title=[[Main Page]]
+!! wikitext
+__TOC__
+==<blockquote>Quote</blockquote>==
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Quote"><span class="tocnumber">1</span> <span class="toctext">Quote</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Quote"><blockquote>Quote</blockquote></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Main_Page&amp;action=edit&amp;section=1" title="Edit section: Quote">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/php+tidy
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Quote"><span class="tocnumber">1</span> <span class="toctext">Quote</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Quote"><blockquote><p>Quote</p></blockquote></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Main_Page&amp;action=edit&amp;section=1" title="Edit section: Quote">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+!! html/parsoid
+<meta property="mw:PageProp/toc" data-parsoid='{}'/>
+<h2 id="Quote" data-parsoid='{}'><blockquote>Quote</blockquote></h2>
+!! end
+
+!! test
+Unclosed tags in TOC
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! options
+title=[[Main Page]]
+!! wikitext
+__TOC__
+==Proof: 2 < 3==
+<small>Hanc marginis exiguitas non caperet.</small>
+QED
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Proof:_2_&lt;_3"><span class="tocnumber">1</span> <span class="toctext">Proof: 2 &lt; 3</span></a></li>
+</ul>
+</div>
+
+<h2><span id="Proof:_2_.3C_3"></span><span class="mw-headline" id="Proof:_2_&lt;_3">Proof: 2 &lt; 3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Main_Page&amp;action=edit&amp;section=1" title="Edit section: Proof: 2 &lt; 3">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p><small>Hanc marginis exiguitas non caperet.</small>
+QED
+</p>
+!! html/parsoid
+<meta property="mw:PageProp/toc" data-parsoid='{}'/>
+<h2 id="Proof:_2_&lt;_3" data-parsoid='{}'><span id="Proof:_2_.3C_3" typeof="mw:FallbackId"></span>Proof: 2 &lt; 3</h2>
+<p><small>Hanc marginis exiguitas non caperet.</small>
+QED</p>
+!! end
+
+!! test
+Multiple tags in TOC
+!! wikitext
+__TOC__
+==<i>Foo</i> <b>Bar</b>==
+
+==<i>Foo</i> <blockquote>Bar</blockquote>==
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Foo_Bar"><span class="tocnumber">1</span> <span class="toctext"><i>Foo</i> <b>Bar</b></span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#Foo_Bar_2"><span class="tocnumber">2</span> <span class="toctext"><i>Foo</i> Bar</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Foo_Bar"><i>Foo</i> <b>Bar</b></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Foo Bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Foo_Bar_2"><i>Foo</i> <blockquote>Bar</blockquote></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Foo Bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/php+tidy
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Foo_Bar"><span class="tocnumber">1</span> <span class="toctext"><i>Foo</i> <b>Bar</b></span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#Foo_Bar_2"><span class="tocnumber">2</span> <span class="toctext"><i>Foo</i> Bar</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Foo_Bar"><i>Foo</i> <b>Bar</b></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Foo Bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Foo_Bar_2"><i>Foo</i> <blockquote><p>Bar</p></blockquote></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Foo Bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+!! html/parsoid
+<meta property="mw:PageProp/toc" data-parsoid='{}'/>
+<h2 id="Foo_Bar" data-parsoid='{}'><i data-parsoid='{"stx":"html"}'>Foo</i> <b data-parsoid='{"stx":"html"}'>Bar</b></h2>
+
+<h2 id="Foo_Bar_2" data-parsoid='{}'><i data-parsoid='{"stx":"html"}'>Foo</i> <blockquote>Bar</blockquote></h2>
+!! end
+
+# Don't expect Parsoid to roundtrip this until the php parser comes closer to
+# html5 tag parsing.
+!! test
+Tags with parameters in TOC
+!! options
+parsoid=wt2html
+!! wikitext
+__TOC__
+==<sup class="in-h2">Hello</sup>==
+
+==<sup class="a > b">Evilbye</sup>==
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Hello"><span class="tocnumber">1</span> <span class="toctext"><sup>Hello</sup></span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#b.22.3EEvilbye"><span class="tocnumber">2</span> <span class="toctext"><sup> b"&gt;Evilbye</sup></span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Hello"><sup class="in-h2">Hello</sup></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Hello">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="b.22.3EEvilbye"><sup class="a"> b"&gt;Evilbye</sup></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: b&quot;&gt;Evilbye">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<meta property="mw:PageProp/toc" />
+<h2 id="Hello"><sup class="in-h2" data-parsoid='{"stx":"html"}'>Hello</sup></h2>
+
+<h2 id='b">Evilbye'><span id="b.22.3EEvilbye" typeof="mw:FallbackId"></span><sup class="a " data-parsoid='{"stx":"html"}'> b">Evilbye</sup></h2>
+!! end
+
+!! test
+span tags with directionality in TOC
+!! wikitext
+__TOC__
+==<span dir="ltr">C++</span>==
+
+==<span dir="rtl">זבנג!</span>==
+
+==<span style="font-style: italic">The attributes on these span tags must be deleted from the TOC</span>==
+
+==<span style="font-style: italic" dir="ltr">All attributes on these span tags must be deleted from the TOC</span>==
+
+==<span dir="ltr" style="font-style: italic">Attributes after dir on these span tags must be deleted from the TOC</span>==
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#C.2B.2B"><span class="tocnumber">1</span> <span class="toctext"><span dir="ltr">C++</span></span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#.D7.96.D7.91.D7.A0.D7.92.21"><span class="tocnumber">2</span> <span class="toctext"><span dir="rtl">זבנג!</span></span></a></li>
+<li class="toclevel-1 tocsection-3"><a href="#The_attributes_on_these_span_tags_must_be_deleted_from_the_TOC"><span class="tocnumber">3</span> <span class="toctext"><span>The attributes on these span tags must be deleted from the TOC</span></span></a></li>
+<li class="toclevel-1 tocsection-4"><a href="#All_attributes_on_these_span_tags_must_be_deleted_from_the_TOC"><span class="tocnumber">4</span> <span class="toctext"><span>All attributes on these span tags must be deleted from the TOC</span></span></a></li>
+<li class="toclevel-1 tocsection-5"><a href="#Attributes_after_dir_on_these_span_tags_must_be_deleted_from_the_TOC"><span class="tocnumber">5</span> <span class="toctext"><span dir="ltr">Attributes after dir on these span tags must be deleted from the TOC</span></span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="C.2B.2B"><span dir="ltr">C++</span></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: C++">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id=".D7.96.D7.91.D7.A0.D7.92.21"><span dir="rtl">זבנג!</span></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: זבנג!">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="The_attributes_on_these_span_tags_must_be_deleted_from_the_TOC"><span style="font-style: italic">The attributes on these span tags must be deleted from the TOC</span></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: The attributes on these span tags must be deleted from the TOC">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="All_attributes_on_these_span_tags_must_be_deleted_from_the_TOC"><span style="font-style: italic" dir="ltr">All attributes on these span tags must be deleted from the TOC</span></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: All attributes on these span tags must be deleted from the TOC">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Attributes_after_dir_on_these_span_tags_must_be_deleted_from_the_TOC"><span dir="ltr" style="font-style: italic">Attributes after dir on these span tags must be deleted from the TOC</span></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: Attributes after dir on these span tags must be deleted from the TOC">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<meta property="mw:PageProp/toc" data-parsoid='{}'/>
+<h2 id="C++" data-parsoid='{}'><span id="C.2B.2B" typeof="mw:FallbackId"></span><span dir="ltr">C++</span></h2>
+<h2 id="זבנג!"><span id=".D7.96.D7.91.D7.A0.D7.92.21" typeof="mw:FallbackId"></span><span dir="rtl">זבנג!</span></h2>
+<h2 id="The_attributes_on_these_span_tags_must_be_deleted_from_the_TOC"><span style="font-style: italic">The attributes on these span tags must be deleted from the TOC</span></h2>
+<h2 id="All_attributes_on_these_span_tags_must_be_deleted_from_the_TOC"><span style="font-style: italic" dir="ltr">All attributes on these span tags must be deleted from the TOC</span></h2>
+<h2 id="Attributes_after_dir_on_these_span_tags_must_be_deleted_from_the_TOC"><span dir="ltr" style="font-style: italic">Attributes after dir on these span tags must be deleted from the TOC</span></h2>
+!! end
+
+!! test
+T74884: bdi element in ToC
+!! wikitext
+__TOC__
+==<bdi>test</bdi>==
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#test"><span class="tocnumber">1</span> <span class="toctext"><bdi>test</bdi></span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="test"><bdi>test</bdi></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: test">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<meta property="mw:PageProp/toc" data-parsoid='{}'/>
+<h2 id="test" data-parsoid='{}'><bdi>test</bdi></h2>
+!! end
+
+!! test
+T35715: s/strike element in ToC
+!! wikitext
+__TOC__
+==<s>test</s> test <strike>test</strike>==
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#test_test_test"><span class="tocnumber">1</span> <span class="toctext"><s>test</s> test <strike>test</strike></span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="test_test_test"><s>test</s> test <strike>test</strike></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: test test test">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<meta property="mw:PageProp/toc" data-parsoid='{}'/>
+<h2 id="test_test_test" data-parsoid='{}'><s>test</s> test <strike>test</strike></h2>
+!! end
+
+!! test
+Empty <p> tag in TOC, removed by Sanitizer (T92892)
+!! wikitext
+__TOC__
+==x==
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#x"><span class="tocnumber">1</span> <span class="toctext">x</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="x">x</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: x">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<meta property="mw:PageProp/toc" data-parsoid='{}'/>
+<h2 id="x" data-parsoid='{}'>x</h2>
+!! end
+
+!! article
+MediaWiki:T34057
+!! text
+== {{int:headline_sample}} ==
+!! endarticle
+
+!! test
+T34057: Title needed when expanding <h> nodes.
+!! options
+title=[[Main Page]]
+!! wikitext
+{{int:T34057}}
+!! html
+<h2><span class="mw-headline" id="Headline_text">Headline text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Main_Page&amp;action=edit&amp;section=1" title="Edit section: Headline text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+!! test
+Strip marker in urlencode
+!! wikitext
+{{urlencode:x<nowiki/>y}}
+{{urlencode:x<nowiki/>y|wiki}}
+{{urlencode:x<nowiki/>y|path}}
+{{urlencode:x<pre id="one">two</pre>y}}
+!! html/php
+<p>xy
+xy
+xy
+xy
+</p>
+!! end
+
+!! test
+Strip marker in lc
+!! wikitext
+{{lc:x<nowiki/>y}}
+!! html
+<p>xy
+</p>
+!! end
+
+!! test
+Strip marker in uc
+!! wikitext
+{{uc:x<nowiki/>y}}
+!! html
+<p>XY
+</p>
+!! end
+
+!! test
+Strip marker in formatNum
+!! wikitext
+{{formatnum:1<nowiki/>2}}
+{{formatnum:1<nowiki/>2|R}}
+!! html
+<p>12
+12
+</p>
+!! end
+
+!! test
+Check noCommafy in formatNum
+!! options
+language=be-tarask
+!! wikitext
+{{formatnum:123456.78}}
+{{formatnum:123456.78|NOSEP}}
+!! html
+<p>123 456,78
+123456.78
+</p>
+!! end
+
+!! test
+Wrong option for formatNum (T58199)
+!! wikitext
+{{formatnum:1,234.56|Random}}
+{{formatnum:1,234.56|EVERYTHING}}
+{{formatnum:1234.56|any argument that has the string 'NOSEP'}}
+!! html
+<p>1,234.56
+1,234.56
+1,234.56
+</p>
+!! end
+
+!! test
+Strip marker in grammar
+!! options
+language=fi
+!! wikitext
+{{grammar:elative|foo<nowiki/>bar}}
+!! html
+<p>foobarista
+</p>
+!! end
+
+!! test
+Strip marker in padleft
+!! wikitext
+{{padleft:|2|x<nowiki/>y}}
+!! html
+<p>xy
+</p>
+!! end
+
+!! test
+Strip marker in padright
+!! wikitext
+{{padright:|2|x<nowiki/>y}}
+!! html
+<p>xy
+</p>
+!! end
+
+!! test
+Strip marker in anchorencode
+!! wikitext
+{{anchorencode:x<nowiki/>y}}
+!! html/php
+<p>xy
+</p>
+!! html/parsoid
+<p about="#mwt2" typeof="mw:Transclusion" data-parsoid='{"pi":[[]]}' data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode:x&lt;nowiki/>y","function":"anchorencode"},"params":{},"i":0}}]}'>xy</p>
+!! end
+
+!! test
+nowiki inside link inside heading (T20295)
+!! wikitext
+==[[foo|x<nowiki>y</nowiki>z]]==
+!! html
+<h2><span class="mw-headline" id="xyz"><a href="/wiki/Foo" title="Foo">xyz</a></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: xyz">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+!! test
+new support for bdi element (T33817)
+!! wikitext
+<p dir="rtl" lang="he">ולדימיר לנין (ברוסית: <bdi lang="ru">Владимир Ленин</bdi>, 24 באפריל 1870–22 בינואר 1924) הוא מנהיג פוליטי קומוניסטי רוסי.</p>
+!! html
+<p dir="rtl" lang="he">ולדימיר לנין (ברוסית: <bdi lang="ru">Владимир Ленин</bdi>, 24 באפריל 1870–22 בינואר 1924) הוא מנהיג פוליטי קומוניסטי רוסי.</p>
+
+!!end
+
+!! test
+Ignore pipe between table row attributes
+!! wikitext
+{|
+|quux
+|- id=foo | style='color: red'
+|bar
+|}
+!! html
+<table>
+<tr>
+<td>quux
+</td></tr>
+<tr id="foo" style="color: red">
+<td>bar
+</td></tr></table>
+
+!! end
+
+!!test
+Language parser function
+!! wikitext
+{{#language:ar}}
+!! html
+<p>العربية
+</p>
+!! end
+
+!!test
+Padleft and padright (default 0-padding)
+!! wikitext
+{{padleft:xyz|5}}
+{{padright:xyz|5}}
+!! html/php
+<p>00xyz
+xyz00
+</p>
+!! html/parsoid
+<p><span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"padleft:xyz","function":"padleft"},"params":{"1":{"wt":"5"}},"i":0}}]}'>00xyz</span>
+<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"padright:xyz","function":"padright"},"params":{"1":{"wt":"5"}},"i":0}}]}'>xyz00</span></p>
+!! end
+
+!!test
+Padleft and padright (partial fill)
+!! wikitext
+{{padleft:xyz|6|ab}}
+{{padright:xyz|6|ab}}
+!! html/php
+<p>abaxyz
+xyzaba
+</p>
+!! html/parsoid
+<p><span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"padleft:xyz","function":"padleft"},"params":{"1":{"wt":"6"},"2":{"wt":"ab"}},"i":0}}]}'>abaxyz</span>
+<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"padright:xyz","function":"padright"},"params":{"1":{"wt":"6"},"2":{"wt":"ab"}},"i":0}}]}'>xyzaba</span></p>
+!! end
+
+!!test
+Padleft and padright as substr
+!! wikitext
+{{padleft:|3|abcde}}
+{{padright:|3|abcde}}
+!! html/php
+<p>abc
+abc
+</p>
+!! html/parsoid
+<p><span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"padleft:","function":"padleft"},"params":{"1":{"wt":"3"},"2":{"wt":"abcde"}},"i":0}}]}'>abc</span>
+<span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"padright:","function":"padright"},"params":{"1":{"wt":"3"},"2":{"wt":"abcde"}},"i":0}}]}'>abc</span></p>
+!! end
+
+!! test
+Padleft and padright with non-numerical length (T180403)
+!! wikitext
+{{padleft:abcdef|junk}}
+{{padright:abcdef|junk}}
+!! html/php
+<p>abcdef
+abcdef
+</p>
+!! end
+
+!!test
+Special parser function
+!! wikitext
+{{#special:RandomPage}}
+{{#special:BaDtItLe}}
+{{#special:Foobar}}
+!! html
+<p>Special:Random
+Special:Badtitle
+Special:Foobar
+</p>
+!! end
+
+!!test
+T36939 - Case insensitive link parsing ([HttP://])
+!! wikitext
+[HttP://MediaWiki.Org/]
+!! html/php
+<p><a rel="nofollow" class="external autonumber" href="HttP://MediaWiki.Org/">[1]</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external autonumber" href="HttP://MediaWiki.Org/"></a></p>
+!! end
+
+!!test
+T36939 - Case insensitive link parsing ([HttP:// title])
+!! wikitext
+[HttP://MediaWiki.Org/ MediaWiki]
+!! html
+<p><a rel="nofollow" class="external text" href="HttP://MediaWiki.Org/">MediaWiki</a>
+</p>
+!! end
+
+!!test
+T36939 - Case insensitive link parsing (HttP://)
+!! wikitext
+HttP://MediaWiki.Org/
+!! html/php
+<p><a rel="nofollow" class="external free" href="HttP://MediaWiki.Org/">HttP://MediaWiki.Org/</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external free" href="HttP://MediaWiki.Org/">HttP://MediaWiki.Org/</a></p>
+!! end
+
+!!test
+Disable TOC
+!! options
+notoc
+!! wikitext
+Lead
+==Section 1==
+==Section 2==
+==Section 3==
+==Section 4==
+==Section 5==
+!! html
+<p>Lead
+</p>
+
+<h2><span class="mw-headline" id="Section_1">Section 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Section_2">Section 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Section_3">Section 3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Section_4">Section 4</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: Section 4">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Section_5">Section 5</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: Section 5">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! end
+
+
+###
+### Parsoid-specific tests
+### Parsoid-PHP parser incompatibilities
+###
+!!test
+1. SOL-sensitive wikitext tokens as template-args
+!!options
+parsoid=wt2html,wt2wt
+!! wikitext
+{{echo|*a}}
+{{echo|#a}}
+{{echo|:a}}
+!! html
+<span about="#mwt1" typeof="mw:Transclusion">
+</span><ul about="#mwt1"><li>a</li>
+</ul>
+<span about="#mwt2" typeof="mw:Transclusion">
+</span><ol about="#mwt2"><li>a</li>
+</ol>
+<span about="#mwt3" typeof="mw:Transclusion">
+</span><dl about="#mwt3"><dd>a</dd>
+</dl>
+!!end
+
+#### -----------------------------------------------------------------
+#### Parsoid-specific functionality tests
+#### -----------------------------------------------------------------
+
+# T65642/T68749: Formatting elt fixup around images is cleaned up.
+# We know wt2wt will fail, but we expect selser to pass.
+# Due to the nature of our testing, wt2wt and selser tests will enter the
+# blacklist and we'll catch selser regressions based on changes to the
+# blacklist entries for selser tests.
+!! test
+1. Bad treebuilder fixup of formatting elt is cleaned up
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+{|
+|
+<small>
+[[Image:Foobar.jpg|right|Test]]
+</small>
+|}
+!! html/parsoid
+<table>
+<tbody><tr><td>
+<small>
+<figure class="mw-default-size mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a><figcaption>Test</figcaption></figure>
+</small>
+</td></tr>
+</tbody></table>
+!! end
+
+!! test
+2. Bad treebuilder fixup of formatting elt is cleaned up
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+'''foo[[File:Foobar.jpg|thumb|caption]]bar'''
+
+<small>[[Image:Foobar.jpg|right|300px]]</small>
+!! html/parsoid
+
+<p><b>foo</b></p>
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption><b>caption</b></figcaption></figure>
+<p><b>bar</b></p>
+<small><figure class="mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="34" width="300"/></a></figure></small>
+!! end
+
+!! test
+3. Bad treebuilder fixup of formatting elt is cleaned up
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+<small>'''foo[[File:Foobar.jpg|thumb|caption]]bar'''</small>
+!! html/parsoid
+<p><small><b>foo</b></small></p>
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption><small><b>caption</b></small></figcaption></figure>
+<p><small><b>bar</b></small></p>
+!! end
+
+!! test
+4. Bad treebuilder fixup of formatting elt is cleaned up: formatting tags around captionless images are ignored
+!! options
+parsoid=wt2html,wt2wt
+!! wikitext
+'''<small>[[Image:Foobar.jpg|right|300px]]</small>'''
+!! html/parsoid
+<b><small><figure class="mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="34" width="300"/></a></figure></small></b>
+!! end
+
+#### ----------------------------------------------------------------
+#### Parsoid-only testing of Parsoid's impl of LST
+#### Not implemented yet, see
+#### https://www.mediawiki.org/wiki/Parsoid/HTML_based_LST
+#### ----------------------------------------------------------------
+
+## We still need to support serializing the older format while content is stored.
+!! test
+LST Sections: Backwards compatibility
+!! options
+parsoid={
+ "suppressErrors": true,
+ "modes": ["html2wt"]
+}
+!! wikitext
+<section begin="2011-05-16" />
+<section end="2014-04-10 (MW 1.23wmf22)" />
+!! html/parsoid
+<p><meta typeof="mw:Extension/LabeledSectionTransclusion/begin" content="2011-05-16"/>
+<meta typeof="mw:Extension/LabeledSectionTransclusion/end" content="2014-04-10 (MW 1.23wmf22)"/></p>
+!! end
+
+!! test
+LST Sections: Newfangled approach
+!! wikitext
+<section begin="2011-05-16" />
+<section end="2014-04-10 (MW 1.23wmf22)" />
+!! html/parsoid
+<p><span typeof="mw:Extension/section" about="#mwt4" data-mw='{"name":"section","attrs":{"begin":"2011-05-16"},"body":null}'>
+</span>
+<span typeof="mw:Extension/section" about="#mwt6" data-mw='{"name":"section","attrs":{"end":"2014-04-10 (MW 1.23wmf22)"},"body":null}'>
+</span></p>
+!! end
+
+#--------- Test stripping of empty nodes in template content ----------
+
+!! test
+Empty LI and TR nodes should be stripped from template content
+!! wikitext
+{{EmptyLITest}}
+{{EmptyTRTest}}
+!! html/parsoid
+<ul about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"EmptyLITest","href":"./Template:EmptyLITest"},"params":{},"i":0}}]}'>
+<li>a</li>
+<li>b</li>
+</ul>
+<table about="#mwt2" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"EmptyTRTest","href":"./Template:EmptyTRTest"},"params":{},"i":0}}]}'>
+<tbody>
+<tr>
+<td>foo</td>
+</tr>
+<tr>
+<td>bar</td>
+</tr>
+</tbody>
+</table>
+!! end
+
+!! test
+Empty LI and TR nodes should not be stripped from top-level content
+!! wikitext
+* a
+*
+* b
+
+{|
+|-
+|-
+|foo
+|}
+!! html/parsoid
+<ul>
+<li> a</li>
+<li class='mw-empty-elt'></li>
+<li> b</li>
+</ul>
+<table>
+<tbody>
+<tr class='mw-empty-elt'></tr>
+<tr>
+<td>foo</td>
+</tr>
+</tbody>
+</table>
+!! end
+
+!! test
+Empty TR nodes should not be stripped if they have any attributes set
+!! wikitext
+{{EmptyTRWithHTMLAttrTest}}
+!! html/parsoid
+<table about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"EmptyTRWithHTMLAttrTest","href":"./Template:EmptyTRWithHTMLAttrTest"},"params":{},"i":0}}]}'>
+<tr align='center'></tr>
+<tr><td>foo</td></tr>
+<tr align='center'></tr>
+<tr><td>bar</td></tr>
+</table>
+!! end
+
+#### ----------------------------------------------------------------
+#### The following section of tests are primarily to test
+#### wikitext escaping capabilities of Parsoid. Given that
+#### escaping can be done any number of ways, the wikitext (input)
+#### is always adjusted to reflect how Parsoid adds nowiki
+#### escape tags.
+####
+#### We are marking several tests as parsoid-only since the
+#### HTML in the result section is different from what the
+#### PHP parser generates for it.
+#### ----------------------------------------------------------------
+
+
+#### --------------- Headings ---------------
+#### 0. Unnested
+#### 1. Nested inside html <h1>=foo=</h1>
+#### 2. Outside heading nest on a single line <h1>foo</h1>*bar
+#### 3. Nested inside html with wikitext split by html tags
+#### 4. No escape needed
+#### 5. Empty headings <h1></h1>
+#### 6. Heading chars in SOL context
+#### ----------------------------------------
+!! test
+Headings: 0. Unnested
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>=foo=</p>
+
+<p> =foo=
+<!--cmt-->
+=foo=</p>
+
+<p>=foo<i>a</i>=</p>
+!! wikitext
+<nowiki>=foo=</nowiki>
+
+<nowiki> </nowiki>=foo=
+<!--cmt-->
+<nowiki>=foo=</nowiki>
+
+=foo''a''<nowiki>=</nowiki>
+!!end
+
+# New headings and existing headings are handled differently
+!! test
+Headings: 1. Nested inside html
+!! options
+parsoid=html2wt
+!! html/parsoid
+<h1>=foo=</h1>
+<h2>=foo=</h2>
+<h3>=foo=</h3>
+
+<h1 data-parsoid=''>=foo=</h1>
+<h2 data-parsoid=''>=foo=</h2>
+<h3 data-parsoid=''>=foo=</h3>
+<h4 data-parsoid=''>=foo=</h4>
+<h5 data-parsoid=''>=foo=</h5>
+<h6 data-parsoid=''>=foo=</h6>
+!! wikitext
+= =foo= =
+
+== =foo= ==
+
+=== =foo= ===
+
+=<nowiki>=foo=</nowiki>=
+==<nowiki>=foo=</nowiki>==
+===<nowiki>=foo=</nowiki>===
+====<nowiki>=foo=</nowiki>====
+=====<nowiki>=foo=</nowiki>=====
+======<nowiki>=foo=</nowiki>======
+
+!!end
+
+!! test
+Headings: 2. Outside heading nest on a single line <h1>foo</h1>*bar
+!! options
+parsoid=html2wt
+!! html/parsoid
+<h1>foo</h1>*bar
+<h1>foo</h1>=bar
+<h1>foo</h1>=bar=
+!! wikitext
+= foo =
+<nowiki>*</nowiki>bar
+
+= foo =
+=bar
+
+= foo =
+<nowiki>=bar=</nowiki>
+!!end
+
+!! test
+Headings: 3. Nested inside html with wikitext split by html tags
+!! options
+parsoid=html2wt
+!! html/parsoid
+<h1>=<b>bold</b>foo=</h1>
+!! wikitext
+= ='''bold'''foo= =
+!!end
+
+!! test
+Headings: 4a. No escaping needed (testing just h1 and h2)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<h1>=foo</h1>
+<h1>foo=</h1>
+<h1> =foo= </h1>
+<h1>=foo= bar</h1>
+<h2>=foo</h2>
+<h2>foo=</h2>
+<h1>=</h1>
+<h1><i>=</i>foo=</h1>
+!! wikitext
+= =foo =
+
+= foo= =
+
+= =foo= =
+
+= =foo= bar =
+
+== =foo ==
+
+== foo= ==
+
+= = =
+
+= ''=''foo= =
+!!end
+
+!! test
+Headings: 4b. No escaping needed (inside p-tags)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>=foo= x
+=foo= <s></s>
+</p>
+!! wikitext
+=foo= x
+=foo= <s></s>
+!! html/php
+<p>=foo= x
+=foo= <s></s>
+</p>
+!!end
+
+!! test
+Headings: 4c. Short headings (1)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>===
+</p>
+!! wikitext
+<nowiki>===</nowiki>
+!! html/php
+<p>===
+</p>
+!! end
+
+# in the html2wt direction we emit '= = =' or '=<nowiki>=</nowiki>='
+!! test
+Headings: 4d. Short headings (2)
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+=
+==
+===
+====
+=====
+!! html/php
+<p>=
+==
+</p>
+<h1><span class="mw-headline" id=".3D">=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: =">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<h1><span class="mw-headline" id=".3D.3D">==</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: ==">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<h2><span class="mw-headline" id=".3D_2">=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: =">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<p>=
+==</p>
+<h1 id="="><span id=".3D" typeof="mw:FallbackId"></span>=</h1>
+<h1 id="=="><span id=".3D.3D" typeof="mw:FallbackId"></span>==</h1>
+<h2 id="=_2"><span id=".3D_2" typeof="mw:FallbackId"></span>=</h2>
+!! end
+
+!! test
+Headings: 5. Empty headings
+!! options
+parsoid=html2wt
+!! html/parsoid
+<h1 data-parsoid='{}'></h1>
+
+<h2 data-parsoid='{}'></h2>
+
+<h3 data-parsoid='{}'></h3>
+
+<h4 data-parsoid='{}'></h4>
+
+<h5 data-parsoid='{}'></h5>
+
+<h6 data-parsoid='{}'></h6>
+!! wikitext
+=<nowiki/>=
+
+==<nowiki/>==
+
+===<nowiki/>===
+
+====<nowiki/>====
+
+=====<nowiki/>=====
+
+======<nowiki/>======
+!!end
+
+!! test
+Headings: 6a. Heading chars in SOL context (with trailing spaces)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>=a=</p>
+
+<p>=a=</p>
+
+<p>=a=</p>
+!! wikitext
+<nowiki>=a=</nowiki>
+
+<nowiki>=a=</nowiki>
+
+<nowiki>=a=</nowiki>
+!!end
+
+!! test
+Headings: 6b. Heading chars in SOL context (with trailing newlines)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>=a=
+b</p>
+
+<p>=a=
+b</p>
+
+<p>=a=
+b</p>
+!! wikitext
+<nowiki>=a=</nowiki>
+b
+
+<nowiki>=a=</nowiki>
+b
+
+<nowiki>=a=</nowiki>
+b
+!!end
+
+!! test
+Headings: 6c. Heading chars in SOL context (leading newline break)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>a
+=b=</p>
+!! wikitext
+a
+<nowiki>=b=</nowiki>
+!!end
+
+!! test
+Headings: 6d. Heading chars in SOL context (with interspersed comments)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<!--c0--><p>=a=</p>
+
+<!--c1--><p>=a=</p> <!--c2--> <!--c3-->
+!! wikitext
+<!--c0--><nowiki>=a=</nowiki>
+
+<!--c1--><nowiki>=a=</nowiki> <!--c2--> <!--c3-->
+!!end
+
+!! test
+Headings: 6d. Heading chars in SOL context (No escaping needed)
+!! options
+parsoid=html2wt
+!! html/parsoid
+=a=<div>b</div>
+!! wikitext
+=a=<div>b</div>
+!!end
+
+!! test
+Headings: 7. Insert a newline between new content and headings
+!! options
+parsoid=html2wt
+!! html/parsoid
+<h2>NEW</h2>
+<p>new</p>
+<h2 data-parsoid='{}'>A</h2>
+<p data-parsoid='{}'>a</p>
+!! wikitext
+== NEW ==
+new
+
+==A==
+a
+
+!! end
+
+!! test
+Headings: Used as horizontal rule
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! options
+parsoid=wt2html
+!! wikitext
+===============
+!! html/php
+<h6><span id=".3D.3D.3D"></span><span class="mw-headline" id="===">===</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: ===">edit</a><span class="mw-editsection-bracket">]</span></span></h6>
+
+!! html/parsoid
+<h6 id="==="><span id=".3D.3D.3D" typeof="mw:FallbackId"></span>===</h6>
+!! end
+
+#### --------------- Lists ---------------
+#### 0. Outside nests (*foo, etc.)
+#### 1. Nested inside html <ul><li>*foo</li></ul>
+#### 2. Inside definition lists
+#### 3. Only bullets at start should be escaped
+#### 4. No escapes needed
+#### 5. No unnecessary escapes
+#### 6. Escape bullets in SOL position
+#### 7. Escape bullets in a multi-line context
+#### ----------------------------------------
+
+!! test
+Lists: 0. Outside nests
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>*foo</p>
+
+<p>#foo</p>
+
+<p>;Foo:bar</p>
+!! wikitext
+<nowiki>*</nowiki>foo
+
+<nowiki>#</nowiki>foo
+
+<nowiki>;</nowiki>Foo<nowiki>:</nowiki>bar
+!!end
+
+## Making these next 3 tests Parsoid-only since they are html2wt tests
+## to test wikitext escaping, and insignificant whitespace diffs
+## cause PHP parser tests to barf
+!! test
+Lists: 1. Nested inside html (No unnecessary escapes)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<ul>
+<li>*foo</li>
+<li>#foo</li>
+<li>:foo</li>
+<li>;foo</li>
+<li data-parsoid='{}'>*foo</li>
+<li data-parsoid='{}'>#foo</li>
+<li data-parsoid='{}'>:foo</li>
+<li data-parsoid='{}'>;foo</li>
+</ul>
+
+<ol>
+<li>*foo</li>
+<li>#foo</li>
+<li>:foo</li>
+<li>;foo</li>
+<li data-parsoid='{}'>*foo</li>
+<li data-parsoid='{}'>#foo</li>
+<li data-parsoid='{}'>:foo</li>
+<li data-parsoid='{}'>;foo</li>
+</ol>
+!! wikitext
+* *foo
+* #foo
+* :foo
+* ;foo
+*<nowiki>*foo</nowiki>
+*<nowiki>#foo</nowiki>
+*<nowiki>:foo</nowiki>
+*<nowiki>;foo</nowiki>
+
+# *foo
+# #foo
+# :foo
+# ;foo
+#<nowiki>*foo</nowiki>
+#<nowiki>#foo</nowiki>
+#<nowiki>:foo</nowiki>
+#<nowiki>;foo</nowiki>
+!!end
+
+!! test
+Lists: 2. Inside definition lists
+!! options
+parsoid=html2wt
+!! html/parsoid
+<dl><dt>;foo</dt></dl>
+<dl><dt>:foo</dt></dl>
+<dl><dt>:foo</dt>
+<dd>bar</dd></dl>
+<dl><dd>:foo</dd></dl>
+!! wikitext
+; ;foo
+
+; <nowiki>:foo</nowiki>
+
+; <nowiki>:foo</nowiki>
+: bar
+
+: :foo
+!!end
+
+!! test
+Lists: 3. Only bullets at start of text in wikitext-generated HTML should be escaped
+!! options
+parsoid=html2wt
+!! html/parsoid
+<ul>
+<li>*foo*bar</li>
+<li data-parsoid='{}'>*foo<i>it</i>*bar</li>
+</ul>
+!! wikitext
+* *foo*bar
+*<nowiki>*foo</nowiki>''it''*bar
+!!end
+
+!! test
+Lists: 4. No escapes needed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<ul>
+<li>foo*bar
+</li>
+</ul>
+<ul>
+<li><i>foo</i>*bar
+</li>
+</ul>
+<ul>
+<li><a rel="mw:WikiLink" href="Foo" title="Foo">Foo</a>: bar
+</li>
+</ul>
+<ul>
+<li><a rel="mw:WikiLink" href="Foo" title="Foo">Foo</a>*bar
+</li>
+</ul>
+!! wikitext
+*foo*bar
+
+*''foo''*bar
+
+*[[Foo]]: bar
+
+*[[Foo]]*bar
+!!end
+
+!! test
+Lists: 5. No unnecessary escapes
+!! options
+parsoid=html2wt
+!! html/parsoid
+<ul><li> bar <span>[[foo]]</span></li></ul>
+<ul><li> =bar <span>[[foo]]</span></li></ul>
+<ul><li> [[bar <span>[[foo]]</span></li></ul>
+<ul><li> ]]bar <span>[[foo]]</span></li></ul>
+<ul><li> =bar <span>foo]]</span>=</li></ul>
+<ul><li> <s></s>: a</li></ul>
+<ul><li> <i>* foo</i></li></ul>
+
+!! wikitext
+* bar <span><nowiki>[[foo]]</nowiki></span>
+
+* =bar <span><nowiki>[[foo]]</nowiki></span>
+
+* [[bar <span><nowiki>[[foo]]</nowiki></span>
+
+* ]]bar <span><nowiki>[[foo]]</nowiki></span>
+
+* =bar <span>foo]]</span>=
+
+* <s></s>: a
+
+* ''* foo''
+!!end
+
+!! test
+Lists: 6. Escape bullets in SOL position
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><!--cmt-->*foo</p>
+!! wikitext
+<!--cmt--><nowiki>*</nowiki>foo
+!!end
+
+!! test
+Lists: 7. Escape bullets in a multi-line context
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>a
+*b
+</p>
+!! wikitext
+a
+<nowiki>*</nowiki>b
+!!end
+
+!! test
+Lists: 8. Escape colons only if not present in tags
+!! options
+parsoid=html2wt
+!! html/parsoid
+<dl><dt>a:b<i>c:d</i></dt></dl>
+!! wikitext
+; <nowiki>a:b</nowiki>''c:d''
+!! end
+
+#### --------------- HRs ---------------
+#### 1. Single line
+#### -----------------------------------
+
+!! test
+HRs: 1. Single line
+!! options
+parsoid=html2wt
+!! html/parsoid
+<hr />----
+<hr />=foo=
+<hr />*foo
+!! wikitext
+----<nowiki>----</nowiki>
+----=foo=
+----*foo
+!! end
+
+#### --------------- Tables ---------------
+#### 1a. Simple example
+#### 1b. No escaping needed (!foo)
+#### 1c. No escaping needed (|foo)
+#### 1d. No escaping needed (|}foo)
+####
+#### 2a. Nested in td (<td>foo|bar</td>)
+#### 2b. Nested in td (<td>foo||bar</td>)
+#### 2c. Nested in td -- no escaping needed(<td>foo!!bar</td>)
+####
+#### 3a. Nested in th (<th>foo!bar</th>)
+#### 3b. Nested in th (<th>foo!!bar</th>)
+#### 3c. Nested in th -- no escaping needed(<th>foo||bar</th>)
+####
+#### 4a. Escape -
+#### 4b. Escape +
+#### 4c. No escaping needed
+#### --------------------------------------
+
+!! test
+Tables: 1a. Simple example
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>{|
+|}
+</p>
+!! wikitext
+<nowiki>{|</nowiki>
+|}
+!! end
+
+!! test
+Tables: 1b. No escaping needed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>!foo
+</p>
+!! wikitext
+!foo
+!! end
+
+!! test
+Tables: 1c. No escaping needed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>|foo
+</p>
+!! wikitext
+|foo
+!! end
+
+!! test
+Tables: 1d. No escaping needed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>|}foo
+</p>
+!! wikitext
+|}foo
+!! end
+
+!! test
+Tables: 2a. Nested in td
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table><tbody><tr>
+<td>foo|bar</td></tr>
+<tr><td>x<div>a|b</div></td>
+</tbody></table>
+!! wikitext
+{|
+|<nowiki>foo|bar</nowiki>
+|-
+|x<div><nowiki>a|b</nowiki></div>
+|}
+!! html/php+tidy
+<table>
+<tbody><tr>
+<td>foo|bar
+</td></tr>
+<tr>
+<td>x<div>a|b</div>
+</td></tr></tbody></table>
+!! end
+
+!! test
+Tables: 2b. Nested in td
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table><tbody><tr>
+<td>foo||bar</td>
+<td>a<i>b||c</i></td>
+<td>a<i><div>b||c</div></i></td>
+</tr></tbody></table>
+!! wikitext
+{|
+|<nowiki>foo||bar</nowiki>
+|a''<nowiki>b||c</nowiki>''
+|a''<div><nowiki>b||c</nowiki></div>''
+|}
+!! html/php
+<table>
+<tr>
+<td>foo||bar
+</td>
+<td>a<i>b||c</i>
+</td>
+<td>a<i><div>b||c</div></i>
+</td></tr></table>
+
+!! end
+
+!! test
+Tables: 2c. Nested in td -- no escaping needed
+!! options
+parsoid=html2wt
+!! html/*
+<table>
+<tr>
+<td>foo!!bar
+</td></tr></table>
+
+!! wikitext
+{|
+|foo!!bar
+|}
+!! end
+
+!! test
+Tables: 3a. Nested in th
+!! options
+parsoid=html2wt
+!! html/*
+<table>
+<tr>
+<th>foo!bar
+</th></tr></table>
+
+!! wikitext
+{|
+!foo!bar
+|}
+!! end
+
+!! test
+Tables: 3b. Nested in th
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table><tbody>
+<tr><th>foo!!bar</th>
+<th><i>foo|bar</i></th>
+<th><i>foo!!bar</i></th>
+<th><i><span>foo!!bar</span></i></th>
+</tr></tbody></table>
+!! wikitext
+{|
+!<nowiki>foo!!bar</nowiki>
+!''<nowiki>foo|bar</nowiki>''
+!''<nowiki>foo!!bar</nowiki>''
+!''<span><nowiki>foo!!bar</nowiki></span>''
+|}
+!! html/php
+<table>
+<tr>
+<th>foo!!bar
+</th>
+<th><i>foo|bar</i>
+</th>
+<th><i>foo!!bar</i>
+</th>
+<th><i><span>foo!!bar</span></i>
+</th></tr></table>
+
+!! end
+
+!! test
+Tables: 3c. Nested in th
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table><tbody>
+<tr><th>foo||bar</th>
+<th><span typeof="mw:Nowiki">foo||bar</span></th>
+</tr></tbody></table>
+!! wikitext
+{|
+!<nowiki>foo||bar</nowiki>
+!<nowiki>foo||bar</nowiki>
+|}
+!! html/php
+<table>
+<tr>
+<th>foo||bar
+</th>
+<th>foo||bar
+</th></tr></table>
+
+!! end
+
+!! test
+Tables: 4a. Escape -
+!! options
+parsoid=html2wt
+!! html/*
+<table>
+<tr>
+<th>-bar
+</th></tr>
+<tr>
+<td>-bar
+</td></tr></table>
+
+!! wikitext
+{|
+!-bar
+|-
+|<nowiki>-bar</nowiki>
+|}
+!! end
+
+!! test
+Tables: 4b. Escape +
+!! options
+parsoid=html2wt
+!! html/*
+<table>
+<tr>
+<th>+bar
+</th></tr>
+<tr>
+<td>+bar
+</td></tr></table>
+
+!! wikitext
+{|
+!+bar
+|-
+|<nowiki>+bar</nowiki>
+|}
+!! end
+
+!! test
+Tables: 4c. No escaping needed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table><tbody>
+<tr><td>foo-bar</td><td>foo+bar</td></tr>
+<tr><td><i>foo</i>-bar</td><td><i>foo</i>+bar</td></tr>
+<tr><td>foo
+<p>bar|baz
++bar
+-bar</p></td></tr>
+<tr><td>x
+<div>a|b</div></td>
+</tbody></table>
+!! wikitext
+{|
+|foo-bar
+|foo+bar
+|-
+|''foo''-bar
+|''foo''+bar
+|-
+|foo
+bar|baz
++bar
+-bar
+|-
+|x
+<div>a|b</div>
+|}
+!! html/php
+<table>
+<tr>
+<td>foo-bar
+</td>
+<td>foo+bar
+</td></tr>
+<tr>
+<td><i>foo</i>-bar
+</td>
+<td><i>foo</i>+bar
+</td></tr>
+<tr>
+<td>foo
+<p>bar|baz
++bar
+-bar
+</p>
+</td></tr>
+<tr>
+<td>x
+<div>a|b</div>
+</td></tr></table>
+
+!! end
+
+!! test
+Tables: 4d. No escaping needed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table>
+<tbody><tr><td><a rel="mw:WikiLink" href="./Foo" title="Foo">Foo</a>-bar</td>
+<td data-parsoid='{"startTagSrc":"|","attrSepSrc":"|"}'>+1</td>
+<td data-parsoid='{"startTagSrc":"|","attrSepSrc":"|"}'>-2</td></tr>
+</tbody></table>
+!! wikitext
+{|
+|[[Foo]]-bar
+||+1
+||-2
+|}
+!! html/php
+<table>
+<tr>
+<td><a href="/wiki/Foo" title="Foo">Foo</a>-bar
+</td>
+<td>+1
+</td>
+<td>-2
+</td></tr></table>
+
+!! end
+
+!! test
+T97430: Don't emit empty nowiki pairs around marker meta tags
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>*This is a long sentence here that will make the nowiki algo split up the nowikis into multiple pairs
+|** Make this another long long long sentence forcing the nowiki algo to split up the nowikis.</p>
+!! wikitext
+<nowiki>*</nowiki>This is a long sentence here that will make the nowiki algo split up the nowikis into multiple pairs
+|** Make this another long long long sentence forcing the nowiki algo to split up the nowikis.
+!! end
+
+!! test
+Unclosed xmlish element in table line shouldn't eat end delimiters
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table>
+<tbody><tr><td> &lt;foo</td>
+<td> bar></td></tr>
+</tbody></table>
+!! wikitext
+{|
+| <foo
+| bar>
+|}
+!! html/php
+<table>
+<tr>
+<td>&lt;foo
+</td>
+<td>bar&gt;
+</td></tr></table>
+
+!! end
+
+#### --------------- Links ----------------
+#### 1. Quote marks in link text
+#### 2. Wikilinks: Escapes needed
+#### 3. Wikilinks: No escapes needed
+#### 4. Extlinks: Escapes needed
+#### 5. Extlinks: No escapes needed
+#### --------------------------------------
+!! test
+Links 1. WikiLinks: No escapes needed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="Foo" title="Foo">Foo<i>boo</i></a>
+<a rel="mw:WikiLink" href="Foo" title="Foo">[Foobar]</a>
+<a rel="mw:WikiLink" href="Foo" title="Foo">x [Foobar] x</a></p>
+!! wikitext
+[[Foo|Foo''boo'']]
+[[Foo|[Foobar]]]
+[[Foo|x [Foobar] x]]
+!! html/php
+<p><a href="/wiki/Foo" title="Foo">Foo<i>boo</i></a>
+<a href="/wiki/Foo" title="Foo">[Foobar]</a>
+<a href="/wiki/Foo" title="Foo">x [Foobar] x</a>
+</p>
+!! end
+
+!! test
+Links 2. WikiLinks: Escapes needed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<a href="Foo" rel="mw:WikiLink">Foobar]</a>
+<a href="Foo" rel="mw:WikiLink">x [http://google.com g] x</a>
+<a href="Foo" rel="mw:WikiLink">[[Bar]]</a>
+<a href="Foo" rel="mw:WikiLink">x [[Bar]] x</a>
+<a href="Foo" rel="mw:WikiLink">|Bar</a>
+<a href="Foo" rel="mw:WikiLink">]]bar</a>
+<a href="Foo" rel="mw:WikiLink">[[bar</a>
+<a href="Foo" rel="mw:WikiLink">x [[ y</a>
+<a href="Foo" rel="mw:WikiLink">x ]] y</a>
+<a href="Foo" rel="mw:WikiLink">x ]] y [[ z</a>
+!! wikitext
+[[Foo|<nowiki>Foobar]</nowiki>]]
+[[Foo|x <nowiki>[http://google.com g]</nowiki> x]]
+[[Foo|<nowiki>[[Bar]]</nowiki>]]
+[[Foo|<nowiki>x [[Bar]] x</nowiki>]]
+[[Foo|<nowiki>|Bar</nowiki>]]
+[[Foo|<nowiki>]]bar</nowiki>]]
+[[Foo|<nowiki>[[bar</nowiki>]]
+[[Foo|<nowiki>x [[ y</nowiki>]]
+[[Foo|<nowiki>x ]] y</nowiki>]]
+[[Foo|<nowiki>x ]] y [[ z</nowiki>]]
+!! html/php
+<p><a href="/wiki/Foo" title="Foo">Foobar]</a>
+<a href="/wiki/Foo" title="Foo">x [http://google.com g] x</a>
+<a href="/wiki/Foo" title="Foo">[[Bar]]</a>
+<a href="/wiki/Foo" title="Foo">x [[Bar]] x</a>
+<a href="/wiki/Foo" title="Foo">|Bar</a>
+<a href="/wiki/Foo" title="Foo">]]bar</a>
+<a href="/wiki/Foo" title="Foo">[[bar</a>
+<a href="/wiki/Foo" title="Foo">x [[ y</a>
+<a href="/wiki/Foo" title="Foo">x ]] y</a>
+<a href="/wiki/Foo" title="Foo">x ]] y [[ z</a>
+</p>
+!! end
+
+!! test
+Links 3. WikiLinks: No escapes needed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="Foo">[Foobar</a>
+<a rel="mw:WikiLink" href="Foo" title="Foo">foo|bar</a></p>
+!! wikitext
+[[Foo|[Foobar]]
+[[Foo|foo|bar]]
+!! html/php
+<p><a href="/wiki/Foo" title="Foo">[Foobar</a>
+<a href="/wiki/Foo" title="Foo">foo|bar</a>
+</p>
+!! end
+
+!! test
+Links 4. ExtLinks: Escapes needed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a rel="mw:ExtLink" href="http://google.com">[google]</a>
+<a rel="mw:ExtLink" href="http://google.com">google]</a>
+<a rel="mw:ExtLink" href="http://google.com">goog] le</a></p>
+<p>[http://google.com]</p>
+<p>[http://google.com google]</p>
+<p>[<a rel="mw:ExtLink" href="http://google.com">http://google.com</a>]</p>
+<p>[<a rel="mw:ExtLink" href="http://google.com" about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"http://google.com"}},"i":0}}]}'>http://google.com</a>]</p>
+!! wikitext
+[http://google.com <nowiki>[google]</nowiki>]
+[http://google.com <nowiki>google]</nowiki>]
+[http://google.com <nowiki>goog] le</nowiki>]
+
+<nowiki>[http://google.com]</nowiki>
+
+<nowiki>[http://google.com google]</nowiki>
+
+[http://google.com<nowiki>]</nowiki>
+
+[{{echo|http://google.com}}<nowiki>]</nowiki>
+!! html/php
+<p><a rel="nofollow" class="external text" href="http://google.com">[google]</a>
+<a rel="nofollow" class="external text" href="http://google.com">google]</a>
+<a rel="nofollow" class="external text" href="http://google.com">goog] le</a>
+</p><p>[http://google.com]
+</p><p>[http://google.com google]
+</p><p>[<a rel="nofollow" class="external free" href="http://google.com">http://google.com</a>]
+</p><p>[<a rel="nofollow" class="external free" href="http://google.com">http://google.com</a>]
+</p>
+!! end
+
+!! test
+Links 5. ExtLinks: No escapes needed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a rel="mw:ExtLink" href="http://google.com">[google</a></p>
+<p>[<a ref="mw:ExtLink" href="http://google.com"></a>]</p>
+!! wikitext
+[http://google.com [google]
+
+[[http://google.com]]
+!! html/php
+<p><a rel="nofollow" class="external text" href="http://google.com">[google</a>
+</p><p>[<a rel="nofollow" class="external autonumber" href="http://google.com">[1]</a>]
+</p>
+!! end
+
+!! test
+Links 6. Add <nowiki/>s between text-nodes and url-links when required (T66300)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>x<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>y
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>?x
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>&amp;x
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>'x
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>,x
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>.x
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>;x
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>:x
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>;x
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>!x
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>=x
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>(x)
+<a rel="mw:ExtLink" href="http://example.com(x" data-parsoid='{"stx":"url"}'>http://example.com(x</a>)
+</p>
+!! wikitext
+x<nowiki/>http://example.com<nowiki/>y
+http://example.com<nowiki/>?x
+http://example.com<nowiki/>&x
+http://example.com<nowiki/>'x
+http://example.com<nowiki/>,x
+http://example.com<nowiki/>.x
+http://example.com<nowiki/>;x
+http://example.com<nowiki/>:x
+http://example.com<nowiki/>;x
+http://example.com<nowiki/>!x
+http://example.com<nowiki/>=x
+http://example.com<nowiki/>(x)
+http://example.com(x<nowiki/>)
+!! end
+
+!! test
+Links 7a. Don't add spurious <nowiki/>s between text-nodes and url-links (T66300)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>x
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>
+y
+"<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>"
+(<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>)
+(<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>) foo
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>,
+<a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>, foo
+</p>
+!! wikitext
+x
+http://example.com
+y
+"http://example.com"
+(http://example.com)
+(http://example.com) foo
+http://example.com,
+http://example.com, foo
+!! html/php
+<p>x
+<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>
+y
+"<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>"
+(<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>)
+(<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>) foo
+<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>,
+<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>, foo
+</p>
+!! end
+
+!! test
+Links 7b. Don't add spurious <nowiki/>s between text-nodes and url-links (T66300)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a rel="mw:ExtLink" href="http://example.com" data-parsoid='{"stx":"url"}'>http://example.com</a>.,;:!?\
+-<a rel="mw:ExtLink" href="http://example.com">http://example.com</a>:</p>
+!! wikitext
+http://example.com.,;:!?\
+-http://example.com:
+!! html/php
+<p><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>.,;:!?\
+-<a rel="nofollow" class="external free" href="http://example.com">http://example.com</a>:
+</p>
+!! end
+
+!! test
+Links 8. Add <nowiki/>s between text-nodes and RFC-links when required (T66300)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a href="https://tools.ietf.org/html/rfc123" rel="mw:ExtLink" data-parsoid='{"stx":"magiclink"}'>RFC 123</a>4
+<a href="https://tools.ietf.org/html/rfc123" rel="mw:ExtLink" data-parsoid='{"stx":"magiclink"}'>RFC 123</a>y
+X<a href="https://tools.ietf.org/html/rfc123" rel="mw:ExtLink" data-parsoid='{"stx":"magiclink"}'>RFC 123</a>y</p>
+!! wikitext
+RFC 123<nowiki/>4
+RFC 123<nowiki/>y
+X<nowiki/>RFC 123<nowiki/>y
+!! end
+
+!! test
+Links 9. Don't add spurious <nowiki/>s between text-nodes and RFC-links (T66300)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a href="https://tools.ietf.org/html/rfc123" rel="mw:ExtLink" data-parsoid='{"stx":"magiclink"}'>RFC 123</a>?foo
+<a href="https://tools.ietf.org/html/rfc123" rel="mw:ExtLink" data-parsoid='{"stx":"magiclink"}'>RFC 123</a>&amp;foo
+-<a href="https://tools.ietf.org/html/rfc123" rel="mw:ExtLink" data-parsoid='{"stx":"magiclink"}'>RFC 123</a>-
+</p>
+!! wikitext
+RFC 123?foo
+RFC 123&foo
+-RFC 123-
+!! html/php
+<p><a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc123">RFC 123</a>?foo
+<a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc123">RFC 123</a>&amp;foo
+-<a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc123">RFC 123</a>-
+</p>
+!! end
+
+!! test
+Links 10. Add <nowiki/>s between text-nodes and PMID-links when required (T66300)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a href="//www.ncbi.nlm.nih.gov/pubmed/123?dopt=Abstract" rel="mw:ExtLink" data-parsoid='{"stx":"magiclink"}'>PMID 123</a>4
+<a href="//www.ncbi.nlm.nih.gov/pubmed/123?dopt=Abstract" rel="mw:ExtLink" data-parsoid='{"stx":"magiclink"}'>PMID 123</a>y
+X<a href="//www.ncbi.nlm.nih.gov/pubmed/123?dopt=Abstract" rel="mw:ExtLink" data-parsoid='{"stx":"magiclink"}'>PMID 123</a>y
+!! wikitext
+PMID 123<nowiki/>4
+PMID 123<nowiki/>y
+X<nowiki/>PMID 123<nowiki/>y
+!! end
+
+!! test
+Links 11. Don't add spurious <nowiki/>s between text-nodes and PMID-links (T66300)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a href="//www.ncbi.nlm.nih.gov/pubmed/123?dopt=Abstract" rel="mw:ExtLink" data-parsoid='{"stx":"magiclink"}'>PMID 123</a>?foo
+<a href="//www.ncbi.nlm.nih.gov/pubmed/123?dopt=Abstract" rel="mw:ExtLink" data-parsoid='{"stx":"magiclink"}'>PMID 123</a>&foo
+-<a href="//www.ncbi.nlm.nih.gov/pubmed/123?dopt=Abstract" rel="mw:ExtLink" data-parsoid='{"stx":"magiclink"}'>PMID 123</a>-
+</p>
+!! wikitext
+PMID 123?foo
+PMID 123&foo
+-PMID 123-
+!! html/php
+<p><a class="external mw-magiclink-pmid" rel="nofollow" href="//www.ncbi.nlm.nih.gov/pubmed/123?dopt=Abstract">PMID 123</a>?foo
+<a class="external mw-magiclink-pmid" rel="nofollow" href="//www.ncbi.nlm.nih.gov/pubmed/123?dopt=Abstract">PMID 123</a>&amp;foo
+-<a class="external mw-magiclink-pmid" rel="nofollow" href="//www.ncbi.nlm.nih.gov/pubmed/123?dopt=Abstract">PMID 123</a>-
+</p>
+!! end
+
+!! test
+Links 12. Add <nowiki/>s between text-nodes and ISBN-links when required (T66300)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a href="./Special:BookSources/1234567890" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 1234567890</a>1
+<a href="./Special:BookSources/1234567890" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 1234567890</a>x
+a<a href="./Special:BookSources/1234567890" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 1234567890</a>b
+</p>
+!! wikitext
+ISBN 1234567890<nowiki/>1
+ISBN 1234567890<nowiki/>x
+a<nowiki/>ISBN 1234567890<nowiki/>b
+!! end
+
+!! test
+Links 13. Don't add spurious <nowiki/>s between text-nodes and ISBN-links (T66300)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>-<a href="./Special:BookSources/1234567890" rel="mw:WikiLink" data-parsoid='{"stx":"magiclink"}'>ISBN 1234567890</a>'s
+!! wikitext
+-ISBN 1234567890's
+!! html/php
+<p>-<a href="/wiki/Special:BookSources/1234567890" class="internal mw-magiclink-isbn">ISBN 1234567890</a>'s
+</p>
+!! end
+
+!! test
+Links 14. Protect link-like plain text. (Parsoid bug T78425)
+!! options
+parsoid=html2wt
+!! html/*
+<p>this is not a link: http://example.com
+</p>
+!! wikitext
+this is not a link: <nowiki>http://example.com</nowiki>
+!! end
+
+!! test
+Links 15. Link trails can't become link prefixes.
+!! options
+language=is
+parsoid=html2wt
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="Söfnuður" title="Söfnuður" data-parsoid='{"stx":"simple","tail":"-"}'>Söfnuður-</a><a rel="mw:WikiLink" href="00" title="00">00</a></p>
+!! wikitext
+[[Söfnuður]]-[[00]]
+!! html/php
+<p><a href="/wiki/S%C3%B6fnu%C3%B0ur" title="Söfnuður">Söfnuður-</a><a href="/wiki/00" title="00">00</a>
+</p>
+!! end
+
+#### --------------- Quotes ---------------
+#### 1. Quotes inside <b> and <i>
+#### 2. Link fragments separated by <i> and <b> tags
+#### 3. Link fragments inside <i> and <b>
+#### 4. No escaping needed
+#### --------------------------------------
+!! test
+1a. Quotes inside <b> and <i>
+!! options
+parsoid=html2wt
+!! html/*
+<p><i>'foo'</i>
+<i>''foo''</i>
+<i>'''foo'''</i>
+<i>foo</i>'s
+<b>'foo'</b>
+<b>''foo''</b>
+<b>'''foo'''</b>
+<b>foo'<i>bar'</i>baz</b>
+<b>foo</b>'s
+'<i>foo</i>
+<i>foo</i>'
+<i>foo'</i>'
+'<i>foo</i>'
+'<b>foo</b>
+<b>foo</b>'
+'<b>foo</b>'
+<i>fools'<span> errand</span></i>
+<i><span>fool</span>'s errand</i>
+'<i>foo</i> bar '<i>baz</i>
+a|!*#-:;+-~[]{}b'<i>x</i>
+</p>
+!! wikitext
+''<nowiki/>'foo'''
+''<nowiki>''foo''</nowiki>''
+''<nowiki>'''foo'''</nowiki>''
+''foo''<nowiki/>'s
+'''<nowiki/>'foo''''
+'''<nowiki>''foo''</nowiki>'''
+'''<nowiki>'''foo'''</nowiki>'''
+'''foo'<nowiki/>''bar'<nowiki/>''baz'''
+'''foo'''<nowiki/>'s
+'''foo''
+''foo''<nowiki/>'
+''foo'''<nowiki/>'
+'''foo''<nowiki/>'
+''''foo'''
+'''foo'''<nowiki/>'
+''''foo'''<nowiki/>'
+''fools'<span> errand</span>''
+''<span>fool</span>'s errand''
+'<nowiki/>''foo'' bar '''baz''
+a|!*#-:;+-~[]{}b'''x''
+!! end
+
+!! test
+1b. Quotes inside <b> and <i> with other tags on same line
+!! options
+parsoid=html2wt
+!! html/parsoid
+'<i>a</i> foo <i><a rel="mw:WikiLink" href="Bar" title="Bar">bar</a></i>
+<i>a'</i> foo <i><a rel="mw:WikiLink" href="Bar" title="Bar">bar</a></i>
+<i>a'</i> foo <b><a rel="mw:WikiLink" href="Bar" title="Bar" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[bar]]"}},"i":0}}]}'>bar</a></b>
+<a rel="mw:WikiLink" href="Foo" title="Foo">foo</a> x'<i><a href="Bar" rel="mw:WikiLink" title="Bar">bar</a></i>
+'<i>foo</i> <span class="mw-ref" id="cite_ref-1-0" rel="dc:references" typeof="mw:Extension/ref" data-mw='{"name":"ref","body":{"id":"mw-reference-text-cite_note-1"},"attrs":{}}'><a href="./Main_Page#cite_note-1"><span class="mw-reflink-text">[1]</span></a></span>
+'<i>foo</i> <div title="name">test</div>
+'<i>foo</i> and <br data-parsoid='{"stx":"html","noClose":true}'/> bar
+<ol class="mw-references references" typeof="mw:Extension/references" about="#mwt5" data-mw='{"name":"references","attrs":{}}'>
+<li about="#cite_note-1" id="cite_note-1"><span rel="mw:referencedBy"><a href="./Main_Page#cite_ref-1">↑</a></span> <span id="mw-reference-text-cite_note-1" class="mw-reference-text" data-parsoid="{}">test</span></li>
+</ol>
+!! wikitext
+'''a'' foo ''[[bar]]''
+''a''' foo ''[[bar]]''
+''a''' foo '''{{echo|[[bar]]}}'''
+[[foo]] x'''[[bar]]''
+'''foo'' <ref>test</ref>
+'''foo'' <div title="name">test</div>
+'''foo'' and <br> bar
+<references />
+!! end
+
+!! test
+2. Link fragments separated by <i> and <b> tags
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>[[<i>foo</i>hello]]</p>
+<p>[[<b>foo</b>hello]]</p>
+!! wikitext
+[[''foo''<nowiki>hello]]</nowiki>
+
+[['''foo'''<nowiki>hello]]</nowiki>
+!! end
+
+# FIXME: Escaping one or both of [[ and ]] is also acceptable --
+# this is one of the shortcomings of this format
+!! test
+3. Link fragments inside <i> and <b>
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><i>[[foo</i>]]</p>
+<p><b>[[foo</b>]]</p>
+!! wikitext
+''[[foo''<nowiki>]]</nowiki>
+
+'''[[foo'''<nowiki>]]</nowiki>
+!! end
+
+!! test
+4. No escaping needed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>'<span><i>bar</i></span>'
+'<span><b>bar</b></span>'
+'a:b'foo
+</p>
+!! wikitext
+'<span>''bar''</span>'
+'<span>'''bar'''</span>'
+'a:b'foo
+!! end
+
+#### ----------- Paragraphs ---------------
+#### 1. No unnecessary escapes
+#### --------------------------------------
+
+!! test
+1. No unnecessary escapes
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>bar <span>[[foo]]</span>
+</p><p>=bar <span>[[foo]]</span>
+</p><p>[[bar <span>[[foo]]</span>
+</p><p>]]bar <span>[[foo]]</span>
+</p><p>=bar <span>foo]]</span>=
+</p>
+!! wikitext
+bar <span><nowiki>[[foo]]</nowiki></span>
+
+=bar <span><nowiki>[[foo]]</nowiki></span>
+
+[[bar <span><nowiki>[[foo]]</nowiki></span>
+
+]]bar <span><nowiki>[[foo]]</nowiki></span>
+
+=bar <span>foo]]</span><nowiki>=</nowiki>
+!!end
+
+#### ----------------------- PRE --------------------------
+#### 1. Leading whitespace in SOL context should be escaped
+#### ------------------------------------------------------
+!! test
+1. Leading whitespace in SOL context should be escaped
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p> a</p>
+
+<p> a</p>
+
+<p> a(tab)</p>
+
+<p> a
+<!--cmt-->
+ a</p>
+
+<p>a
+ b</p>
+
+<p>a
+ b</p>
+
+<p>a
+ b</p>
+!! wikitext
+<nowiki> </nowiki>a
+
+<nowiki> </nowiki> a
+
+ a(tab)
+
+<nowiki> </nowiki> a
+<!--cmt-->
+<nowiki> </nowiki>a
+
+a
+<nowiki> </nowiki>b
+
+a
+ b
+
+a
+ b
+!! html/php
+<p> a
+</p><p> a
+</p><p> a(tab)
+</p><p> a
+ a
+</p><p>a
+ b
+</p><p>a
+ b
+</p><p>a
+ b
+</p>
+!! end
+
+!! test
+2. Leading whitespace in non-indent-pre contexts should not be escaped
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>foo <span about="#mwt2" class="mw-ref" id="cite_ref-1" rel="dc:references" typeof="mw:Extension/ref" data-mw='{"name":"ref","body":{"id":"mw-reference-text-cite_note-1"},"attrs":{}}'><a href="./Main_Page#cite_note-1"><span class="mw-reflink-text">[1]</span></a></span></p>
+<ol class="mw-references references" typeof="mw:Extension/references" about="#mwt4" data-mw='{"name":"references","attrs":{}}'>
+<li about="#cite_note-1" id="cite_note-1"><a href="./Main_Page#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text"><i>a</i>
+ b</span></li>
+</ol>
+!! wikitext
+foo <ref>''a''
+ b</ref>
+<references />
+!! end
+
+!! test
+3. Leading whitespace in indent-pre suppressing contexts should not be escaped
+!! options
+parsoid=html2wt
+!! html/parsoid
+<blockquote>
+<p>
+ a
+ <span>b</span>
+ c</p>
+</blockquote>
+!! wikitext
+<blockquote>
+ a
+ <span>b</span>
+ c
+</blockquote>
+!! end
+
+!! test
+4. Leading whitespace in indent-pre suppressing contexts should not be escaped
+!! options
+parsoid=html2wt
+!! html/parsoid
+ <figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>caption</figcaption></figure>
+!! wikitext
+ [[File:Foobar.jpg|thumb|caption]]
+!! end
+
+!! test
+5. Nowiki escaping should account for indent-pres
+!! options
+parsoid=html2wt
+!! html/parsoid
+<pre>==foo==</pre>
+!! wikitext
+ ==foo==
+!! end
+
+!!test
+T95794: nowiki escaping should account for leading space at start-of-line in an indent-pre block
+!! options
+parsoid=html2wt
+!! html/parsoid
+<pre>
+* foo
+* bar
+</pre>
+!! wikitext
+ * foo
+ * bar
+!! end
+
+#### --------------- Behavior Switches --------------------
+
+!! test
+1. Valid behavior switches should be escaped
+!! options
+parsoid=html2wt
+!! html/parsoid
+__TOC__
+<i>__TOC__</i>
+!! wikitext
+<nowiki>__TOC__</nowiki>
+''<nowiki>__TOC__</nowiki>''
+!! end
+
+!! test
+2. Invalid behavior switches should not be escaped
+!! options
+parsoid=html2wt
+!! html/parsoid
+__TOO__
+__|__
+!! wikitext
+__TOO__
+__|__
+!! end
+
+# We use indent-pre as an indirect way to test for sol-transparent behavior.
+!! test
+Behavior switches should be SOL-transparent
+!! options
+parsoid=html2wt
+!! html/parsoid
+ <meta property="mw:PageProp/toc" />
+
+ <!-- this one's bogus -->
+<pre>__TOO__</pre>
+
+<pre data-parsoid='{}'><meta property="mw:PageProp/toc" data-parsoid='{"src":"__TOC__","magicSrc":"__TOC__"}'/> foo</pre>
+
+<meta property="mw:PageProp/toc" data-parsoid='{"src":"__TOC__","magicSrc":"__TOC__"}'/><pre data-parsoid='{}'>bar</pre>
+!! wikitext
+ __TOC__
+
+ <!-- this one's bogus -->
+ __TOO__
+
+ __TOC__ foo
+
+__TOC__
+ bar
+!! end
+
+#### --------------- HTML tags ---------------
+#### 1. a tags
+#### 2. other tags
+#### 3. multi-line html tag
+#### 4. extension tags
+#### -----------------------------------------
+!! test
+1. a tags
+!! options
+parsoid=html2wt
+!! html/parsoid
+&lt;a href=&quot;http://google.com&quot;&gt;google&lt;/a&gt;
+!! wikitext
+<a href="http://google.com">google</a>
+!! end
+
+!! test
+2. other tags
+!! options
+parsoid=html2wt
+!! html/parsoid
+<ul><li> &lt;div&gt;foo&lt;/div&gt;</li>
+<li> &lt;div style=&quot;color:red&quot;&gt;foo&lt;/div&gt;</li>
+<li> &lt;td&gt;</li></ul>
+
+!! wikitext
+* <nowiki><div>foo</div></nowiki>
+* <nowiki><div style="color:red">foo</div></nowiki>
+* <nowiki><td></nowiki>
+!! end
+
+!! test
+3. multi-line html tag
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>&lt;div
+&gt;foo&lt;/div
+&gt;
+</p>
+!! wikitext
+<nowiki><div
+>foo</div
+></nowiki>
+!! end
+
+!! test
+4. extension tags
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>&lt;ref&gt;foo&lt;/ref&gt;
+</p><p>&lt;ref&gt;bar
+</p><p>baz&lt;/ref&gt;
+</p>
+!! wikitext
+<nowiki><ref>foo</ref></nowiki>
+
+<nowiki><ref>bar</nowiki>
+
+baz<nowiki></ref></nowiki>
+!! end
+
+#### --------------- Others ---------------
+!! test
+Escaping nowikis
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>&lt;nowiki&gt;foo&lt;/nowiki&gt;
+</p>
+!! wikitext
+&lt;nowiki&gt;foo&lt;/nowiki&gt;
+!! end
+
+## The quote-char in the input is necessary for triggering the bug
+!! test
+(T54035) Nowiki-escaping should not get tripped by " :" in text
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>foo's bar :</p>
+!! wikitext
+foo's bar :
+!! end
+
+#----------- End of wikitext escaping tests --------------
+
+!! test
+
+Tag-like HTML structures are passed through as text
+!! wikitext
+<x y>
+
+<x.y>
+
+<x-y>
+
+1>2
+
+x<y
+
+a>b
+
+1<d e>f
+!! html
+<p>&lt;x y&gt;
+</p><p>&lt;x.y&gt;
+</p><p>&lt;x-y&gt;
+</p><p>1&gt;2
+</p><p>x&lt;y
+</p><p>a&gt;b
+</p><p>1&lt;d e&gt;f
+</p>
+!! end
+
+!! test
+HTML tag with necessary entities in attributes
+!! wikitext
+<span title="&amp;amp;">foo</span>
+!! html
+<p><span title="&amp;amp;">foo</span>
+</p>
+!! end
+
+!! test
+HTML tag with 'unnecessary' entity encoding in attributes
+!! wikitext
+<span title="&amp;">foo</span>
+!! html
+<p><span title="&amp;">foo</span>
+</p>
+!! end
+
+!! test
+HTML tag with broken attribute value quoting
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<span title="Hello world>Foo</span>
+!! html/php
+<p><span title="Hello world">Foo</span>
+</p>
+!! html/parsoid
+<p><span title="Hello world">Foo</span></p>
+!! end
+
+!! test
+Self-closed tag with broken attribute value quoting
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+<div title="Hello world />Foo
+!! html/php+tidy
+<div title="Hello world"></div><p>Foo
+</p>
+!! html/parsoid
+<div title="Hello world " data-parsoid='{"stx":"html","selfClose":true}'></div><p>Foo</p>
+!! end
+
+!! test
+Table with broken attribute value quoting
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+{|
+| title="Hello world|Foo
+|}
+!! html/php
+<table>
+<tr>
+<td title="Hello world">Foo
+</td></tr></table>
+
+!! html/parsoid
+<table>
+<tr>
+<td title="Hello world">Foo
+</td></tr></table>
+
+!! end
+
+!! test
+Table with broken attribute value quoting on consecutive lines
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+{|
+| title="Hello world|Foo
+| style="color:red|Bar
+|}
+!! html/php
+<table>
+<tr>
+<td title="Hello world">Foo
+</td>
+<td style="color:red">Bar
+</td></tr></table>
+
+!! html/parsoid
+<table><tbody>
+<tr>
+<td title="Hello world">Foo
+</td><td style="color: red">Bar
+</td></tr></tbody></table>
+
+!! end
+
+!!test
+Accept empty td cell attribute
+!! wikitext
+{|
+| align="center" |foo|| |
+|}
+!! html
+<table>
+<tr>
+<td align="center">foo</td>
+<td>
+</td></tr></table>
+
+!!end
+
+!!test
+Non-empty attributes in th-cells
+!! wikitext
+{|
+!Foo!! style="color: red" |Bar
+|}
+!! html
+<table>
+<tr>
+<th>Foo</th>
+<th style="color: red">Bar
+</th></tr></table>
+
+!!end
+
+!!test
+Accept empty attributes in th-cells
+!! wikitext
+{|
+!|foo!!|bar
+|}
+!! html
+<table>
+<tr>
+<th>foo</th>
+<th>bar
+</th></tr></table>
+
+!!end
+
+!!test
+Empty table rows go away
+!! wikitext
+{|
+|Hello
+|there
+|- class="foo"
+|-
+|}
+!! html
+<table>
+<tr>
+<td>Hello
+</td>
+<td>there
+</td></tr>
+
+</table>
+
+!! end
+
+###
+### Parsoid-centric tests for testing RTing of inter-element separators
+### Edge cases not tested by existing parser tests and specific to
+### Parsoid-specific serialization strategies.
+###
+
+!!test
+RT-ed inter-element separators should be valid separators
+!! wikitext
+{|
+|- [[foo]]
+|}
+!! html/php
+<table>
+
+</table>
+
+!! html/parsoid
+<table>
+<tbody><tr class='mw-empty-elt' data-parsoid='{"startTagSrc":"|-","a":{"[[foo]]":null},"sa":{"[[foo]]":""},"autoInsertedEnd":true}'></tr>
+</tbody></table>
+!!end
+
+# Parsoid-only test of a DOM pass
+!!test
+Trailing newlines in a deep dom-subtree that ends a wikitext line should be migrated out
+!! wikitext
+{|
+|<small>foo
+bar
+|}
+
+{|
+|<small>foo<small>
+|}
+!! html/parsoid
+<table>
+<tbody><tr data-parsoid='{"autoInsertedEnd":true,"autoInsertedStart":true}'><td data-parsoid='{"autoInsertedEnd":true}'><small data-parsoid='{"stx":"html","autoInsertedEnd":true}'>foo
+<p>bar</p></small></td></tr>
+</tbody></table>
+
+<table>
+<tbody><tr data-parsoid='{"autoInsertedEnd":true,"autoInsertedStart":true}'><td data-parsoid='{"autoInsertedEnd":true}'><small data-parsoid='{"stx":"html","autoInsertedEnd":true}'>foo<small data-parsoid='{"stx":"html","autoInsertedEnd":true}'></small></small></td></tr>
+</tbody></table>
+!!end
+
+# Note that the "style" attribute is really a template parameter here.
+# The = would have to be {{=}} if you wanted the literal.
+!!test
+Empty TD followed by TD with tpl-generated attribute
+!! wikitext
+{|
+|-
+|
+|{{echo|style='color:red'}}|foo
+|}
+!! html
+<table>
+
+<tr>
+<td>
+</td>
+<td>foo
+</td></tr></table>
+
+!!end
+
+!!test
+Indented table with an empty td
+!! wikitext
+ {|
+ |-
+ |
+ |foo
+ |}
+!! html
+<table>
+
+<tr>
+<td>
+</td>
+<td>foo
+</td></tr></table>
+
+!!end
+
+## We have some newline diffs RT-ing this edge case
+## and it is not important enough -- we seem to be emitting
+## at most 2 newlines after a </tr> and this is unrelated to
+## the issue from T85627 that this is testing.
+!!test
+Indented table with blank lines in between (T85627)
+!! options
+parsoid=wt2html
+!! wikitext
+ {|
+ |foo
+
+
+ |}
+!! html
+<table>
+
+<tr>
+<td>foo
+</td></tr></table>
+
+!!end
+
+!!test
+Indented block & table
+!! wikitext
+ <div>foo</div>
+ {|
+ |foo
+ |}
+!! html/php
+ <div>foo</div>
+<table>
+<tr>
+<td>foo
+</td></tr></table>
+
+!! html/parsoid
+ <div data-parsoid='{"stx":"html"}'>foo</div>
+ <table><tbody>
+ <tr data-parsoid='{"autoInsertedEnd":true,"autoInsertedStart":true}'><td data-parsoid='{"autoInsertedEnd":true}'>foo</td></tr>
+ </tbody></table>
+!!end
+
+!! test
+Indent and comment before table row
+!! wikitext
+{|
+ <!--hi-->|-
+ |there
+|}
+!! html/php
+<table>
+
+<tr>
+<td>there
+</td></tr></table>
+
+!! html/parsoid
+<table>
+ <!--hi--><tbody><tr data-parsoid='{"startTagSrc":"|-","autoInsertedEnd":true}'>
+ <td data-parsoid='{"autoInsertedEnd":true}'> there</td></tr>
+</tbody></table>
+!! end
+
+# Parsoid-specific since PHP parser doesn't handle this mixed tbl-wikitext
+!!test
+Empty TR followed by a template-generated TR
+!!options
+parsoid
+!! wikitext
+{|
+|-
+{{echo|<tr><td>foo</td></tr>}}
+|}
+!! html
+<table>
+<tbody>
+<tr class='mw-empty-elt'></tr>
+<tr about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"<tr><td>foo</td></tr>"}},"i":0}}]}'>
+<td>foo</td></tr>
+</tbody></table>
+!!end
+
+## PHP and parsoid output differ for this, and since this is primarily
+## for testing Parsoid's serializer, marking this Parsoid only
+!!test
+Empty TR followed by mixed-ws-comment line should RT correctly
+!!options
+parsoid
+!! wikitext
+{|
+|-
+ <!--c-->
+|-
+<!--c--> <!--d-->
+|}
+!! html
+<table>
+<tbody>
+<tr class='mw-empty-elt'></tr>
+ <!--c-->
+<tr>
+<!--c--> </tr><!--d-->
+</tbody></table>
+
+!!end
+
+!!test
+Multi-line image caption generated by templates with/without trailing newlines
+!! wikitext
+[[File:Foobar.jpg|thumb|300x300px|foo\n{{echo|A}}\n{{echo|B}}\n{{echo|C}}]]
+[[File:Foobar.jpg|thumb|300x300px|foo\n{{echo|A}}\n{{echo|B}}\n{{echo|C}}\n\n]]
+!! html/parsoid
+<figure typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="34" width="300"/></a><figcaption>foo\n<span about="#mwt9" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"A"}},"i":0}}]}'>A</span>\n<span about="#mwt10" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"B"}},"i":0}}]}'>B</span>\n<span about="#mwt11" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"C"}},"i":0}}]}'>C</span></figcaption></figure>
+<figure typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="34" width="300"/></a><figcaption>foo\n<span about="#mwt12" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"A"}},"i":0}}]}'>A</span>\n<span about="#mwt13" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"B"}},"i":0}}]}'>B</span>\n<span about="#mwt14" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"C"}},"i":0}}]}'>C</span>\n\n</figcaption></figure>
+!!end
+
+!! test
+New element inserted (without intervening newlines) after an old sol-transparent node should serialize correctly
+!! options
+parsoid=html2wt
+!! html/parsoid
+<meta typeof="mw:Includes/IncludeOnly" data-parsoid='{"src":"&lt;includeonly>foo&lt;/includeonly>"}'/><meta typeof="mw:Includes/IncludeOnly/End" data-parsoid='{"src":""}'/><p>new para</p>
+
+<link rel="mw:PageProp/Category" href="./Category:Foo" data-parsoid='{}'/><h1>new heading</h1>
+!! wikitext
+<includeonly>foo</includeonly>
+new para
+
+[[Category:Foo]]
+
+= new heading =
+!! end
+
+## PHP emits broken html for this, and since this is primarily
+## a Parsoid serializer test, marking this Parsoid only
+!!test
+Improperly nested inline or quotes tags with whitespace in between
+!! wikitext
+<span> <s>x</span> </s>
+''' ''x''' ''
+!! html/parsoid
+<p><span> <s>x</s></span><s> </s>
+<b> <i>x</i></b><i> </i>
+</p>
+!!end
+
+!!test
+Encapsulate protected attributes from wt
+!! wikitext
+<div typeof="mw:placeholder stuff" data-mw="whoo" data-parsoid="weird" data-parsoid-other="no" about="time" rel="mw:true">foo</div>
+
+{| typeof="mw:placeholder stuff" data-mw="whoo" data-parsoid="weird" data-parsoid-other="no" about="time" rel="mw:true"
+| ok
+|}
+!! html/parsoid
+<div data-x-typeof="mw:placeholder stuff" data-x-data-mw="whoo" data-x-data-parsoid="weird" data-x-data-parsoid-other="no" data-x-about="time" data-x-rel="mw:true">foo</div>
+
+<table data-x-typeof="mw:placeholder stuff" data-x-data-mw="whoo" data-x-data-parsoid="weird" data-x-data-parsoid-other="no" data-x-about="time" data-x-rel="mw:true">
+<tbody><tr><td data-parsoid='{"autoInsertedEnd":true}'> ok</td></tr>
+</tbody></table>
+!!end
+
+## Currently the p-wrapper is fragile in how it adds / removes transformations.
+## Having nested or stray pre tags results in the attempt to add duplicates,
+## causing an assertion fail. This test tries to prevent that situation.
+!!test
+Ensure ParagraphWrapper can deal with stray closing pre tags
+!!options
+parsoid=wt2html
+!! wikitext
+plain text</pre>
+!! html/parsoid
+plain text
+!!end
+
+!!test
+1. Ensure fostered text content is wrapped in element nodes
+!!options
+parsoid=wt2html
+!! wikitext
+<table>hi</table><table>ho</table>
+!! html/parsoid
+<p>hi</p>
+<table></table>
+<p>ho</p>
+<table></table>
+!!end
+
+!!test
+2. Ensure fostered text content is wrapped in element nodes (traps regressions around fostered marker on the element getting lost)
+!!options
+parsoid=wt2html,wt2wt
+!! wikitext
+<table>
+<tr> || ||
+<td> a
+</table>
+!! html/parsoid
+<p> || ||
+</p><table>
+<tbody><tr><td> a</td></tr>
+</tbody></table>
+!!end
+
+!!test
+Encapsulation properly handles null DSR information from foster box
+!!options
+parsoid=wt2html,wt2wt
+!! wikitext
+{{echo|<table>foo<tr><td>bar</td></tr></table>}}
+!! html/parsoid
+<span typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;<table>foo<tr><td>bar</td></tr></table>&quot;}},&quot;i&quot;:0}}]}">foo</span><table><tbody><tr><td>bar</td></tr></tbody></table>
+!!end
+
+!!test
+1. Encapsulate foster-parented transclusion content
+!!options
+parsoid=wt2wt,wt2html
+!! wikitext
+<table>{{echo|foo<tr><td>bar</td></tr>}}</table>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[&quot;<table>&quot;,{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;foo<tr><td>bar</td></tr>&quot;}},&quot;i&quot;:0}},&quot;</table>&quot;]}">foo</p><table>
+<tbody>
+<tr>
+<td>bar</td>
+</tr>
+</tbody>
+</table>
+!!end
+
+!!test
+2. Encapsulate foster-parented transclusion content
+!!options
+parsoid=wt2wt,wt2html
+!! wikitext
+<table><div>{{echo|foo}}</div><tr><td>bar</td></tr></table>
+!! html/parsoid
+<div typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[&quot;<table><div>&quot;,{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;foo&quot;}},&quot;i&quot;:0}},&quot;</div><tr><td>bar</td></tr></table>&quot;]}">foo</div>
+<table>
+<tbody>
+<tr>
+<td>bar</td>
+</tr>
+</tbody>
+</table>
+!!end
+
+!!test
+3. Encapsulate foster-parented transclusion content
+!!options
+parsoid=wt2wt,wt2html
+!! wikitext
+<table><div><p>{{echo|foo</p></div><tr><td>}}bar</td></tr></table>
+!! html/parsoid
+<div typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[&quot;<table><div><p>&quot;,{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;foo</p></div><tr><td>&quot;}},&quot;i&quot;:0}},&quot;bar</td></tr></table>&quot;]}">
+<p>foo</p>
+</div>
+<table>
+<tbody>
+<tr>
+<td>bar</td>
+</tr>
+</tbody>
+</table>
+!!end
+
+!!test
+4. Encapsulate foster-parented transclusion content
+!!options
+parsoid=wt2wt,wt2html
+!! wikitext
+<table><div><p>{{echo|foo</p></div><tr><td>}}bar</td></tr></table>
+!! html/parsoid
+<div typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[&quot;<table><div><p>&quot;,{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;foo</p></div><tr><td>&quot;}},&quot;i&quot;:0}},&quot;bar</td></tr></table>&quot;]}">
+<p>foo</p>
+</div>
+<table>
+<tbody>
+<tr>
+<td>bar</td>
+</tr>
+</tbody>
+</table>
+!!end
+
+!!test
+5. Encapsulate foster-parented transclusion content
+!!options
+parsoid=wt2wt,wt2html
+!! wikitext
+<table><tr><td><div><p>{{echo|foo</p></div></td>foo}}</tr></table>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[&quot;<table><tr><td><div><p>&quot;,{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;foo</p></div></td>foo&quot;}},&quot;i&quot;:0}},&quot;</tr></table>&quot;]}">foo</p>
+<table>
+<tbody>
+<tr>
+<td>
+<div>
+<p>foo</p>
+</div>
+</td>
+</tr>
+</tbody>
+</table>
+!!end
+
+!!test
+6. Encapsulate foster-parented transclusion content
+!!options
+parsoid=wt2wt,wt2html
+!! wikitext
+<table><tr><td><div><p>{{echo|foo</p></div></td>foo</tr></table>}}<p>ok</p>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[&quot;<table><tr><td><div><p>&quot;,{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;foo</p></div></td>foo</tr></table>&quot;}},&quot;i&quot;:0}}]}">foo</p>
+<table>
+<tbody>
+<tr>
+<td>
+<div>
+<p>foo</p>
+</div>
+</td>
+</tr>
+</tbody>
+</table>
+<p>ok</p>
+!!end
+
+!!test
+7. Encapsulate foster-parented transclusion content
+!!options
+parsoid=wt2wt,wt2html
+!! wikitext
+<table>{{echo|<p>foo</p>}}<td>bar</td></table>
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw="{&quot;parts&quot;:[&quot;<table>&quot;,{&quot;template&quot;:{&quot;target&quot;:{&quot;wt&quot;:&quot;echo&quot;,&quot;href&quot;:&quot;./Template:Echo&quot;},&quot;params&quot;:{&quot;1&quot;:{&quot;wt&quot;:&quot;<p>foo</p>&quot;}},&quot;i&quot;:0}},&quot;<td>bar</td></table>&quot;]}">foo</p>
+<table>
+<tbody>
+<tr>
+<td>bar</td>
+</tr>
+</tbody>
+</table>
+!!end
+
+# Note that the wt is broken on purpose: the = should be {{=}} if you
+# don't want it to be a template parameter key.
+!!test
+8. Encapsulate foster-parented transclusion content
+!!options
+parsoid=wt2wt,wt2html
+!! wikitext
+{{echo|a
+}}{|{{echo|style='color:red'}}
+|-
+|b
+|}
+!! html/parsoid
+<p typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"a\n"}},"i":0}}]}'>a</p>
+<span> </span>
+<p typeof="mw:Transclusion" data-mw='{"parts":["{|",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"style":{"wt":"&#39;color:red&#39;"}},"i":0}},"\n|-\n|b\n|}"]}'>{{{1}}}</p>
+<table>
+<tbody>
+<tr>
+<td>b</td>
+</tr>
+</tbody>
+</table>
+!!end
+
+!!test
+9. Encapsulate foster-parented transclusion content
+!!options
+parsoid=wt2wt,wt2html
+!! wikitext
+<table>{{echo|hi</table>hello}}
+!! html/parsoid
+<p about="#mwt2" typeof="mw:Transclusion" data-mw='{"parts":["&lt;table>",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"hi&lt;/table>hello"}},"i":0}}]}' data-parsoid='{"fostered":true,"autoInsertedEnd":true,"autoInsertedStart":true,"pi":[[{"k":"1"}]]}'>hi</p><table about="#mwt2" data-parsoid='{"stx":"html"}'></table><p about="#mwt2">hello</p>
+!!end
+
+!!test
+Table in fosterable position
+!!options
+parsoid=wt2html
+!! wikitext
+{{OpenTable}}
+<div>
+{|
+|}
+</div>
+|}
+!! html/parsoid
+<div about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"OpenTable","href":"./Template:OpenTable"},"params":{},"i":0}},"\n&lt;div>\n"]}' data-parsoid='{"stx":"html","autoInsertedEnd":true,"pi":[[]]}'></div><span about="#mwt1">
+</span>
+<table about="#mwt1" data-parsoid='{"autoInsertedEnd":true}'></table>
+
+<table>
+</table>
+!!end
+
+# Parsoid only for T66747
+!! test
+Properly encapsulate empty-content transclusions in fosterable positions
+!! wikitext
+<table>
+{{#if:|
+<td>foo</td>
+}}
+</table>
+!! html/parsoid
+<table about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":["&lt;table>\n",{"template":{"target":{"wt":"#if:","function":"if"},"params":{"1":{"wt":"\n&lt;td>foo&lt;/td>\n"}},"i":0}},"\n&lt;/table>"]}' data-parsoid='{"stx":"html","pi":[[{"k":"1"}]]}'>
+
+</table>
+!! end
+
+!! test
+Always encapsulate foster box when template range is expanded to table
+!! options
+parsoid=wt2wt
+!! wikitext
+{|
+hello
+{{OpenTable}}
+|}
+!! html/parsoid
+
+!! end
+
+!! test
+T115289: Unclosed table
+!! wikitext
+{{echo|<table>}}<!--c-->[[Category:Two]]
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Category:Two" about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"stx":"simple","a":{"href":"./Category:Two"},"sa":{"href":"Category:Two"},"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;table>"}},"i":0}},"&lt;!--c-->[[Category:Two]]"]}'/><table about="#mwt1" data-parsoid='{"stx":"html","autoInsertedEnd":true}'><!--c--></table>
+!! end
+
+!! test
+T115289: Don't migrate newlines out of tables with fostered content
+!! wikitext
+<table><td></td>{{echo|<tr>[[Category:One]]}}<!--c-->[[Category:Two]]
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Category:One" about="#mwt2" typeof="mw:Transclusion" data-parsoid='{"stx":"simple","a":{"href":"./Category:One"},"sa":{"href":"Category:One"},"fostered":true,"pi":[[{"k":"1"}]]}' data-mw='{"parts":["&lt;table>&lt;td>&lt;/td>",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;tr>[[Category:One]]"}},"i":0}},"&lt;!--c-->[[Category:Two]]"]}'/><link rel="mw:PageProp/Category" href="./Category:Two" about="#mwt2"/><table about="#mwt2" data-parsoid='{"stx":"html","autoInsertedEnd":true}'><tbody><tr><td></td></tr><tr><!--c--></tr></tbody></table>
+!! end
+
+!! test
+T73074: More fostering fun
+!! wikitext
+<table><td></td>{{echo|<tr>}}<!--c-->[[Category:Two]]
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Category:Two" data-parsoid='{"stx":"simple","a":{"href":"./Category:Two"},"sa":{"href":"Category:Two"},"fostered":true}'/><table data-parsoid='{"stx":"html","autoInsertedEnd":true}'><tbody><tr data-parsoid='{"autoInsertedEnd":true,"autoInsertedStart":true}'><td data-parsoid='{"stx":"html"}'></td></tr><tr about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"stx":"html","autoInsertedEnd":true,"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;tr>"}},"i":0}},"&lt;!--c-->[[Category:Two]]"]}'><!--c--></tr></tbody></table>
+!! end
+
+!!test
+Support <object> element with .data attribute
+!!options
+parsoid=html2wt
+!! html/parsoid
+<object data="test.swf"></object>
+!! wikitext
+<object data="test.swf"></object>
+!!end
+
+!! test
+Don't block XML namespace declaration
+!! wikitext
+<span xmlns:dct="http://purl.org/dc/terms/" property="dct:title">MediaWiki</span>
+!! html/php
+<p><span xmlns:dct="http&#58;//purl.org/dc/terms/" property="dct:title">MediaWiki</span>
+</p>
+!! html/parsoid
+<p><span xmlns:dct="http://purl.org/dc/terms/" data-x-property="dct:title" data-parsoid='{"stx":"html"}'>MediaWiki</span></p>
+!! end
+
+# -----------------------------------------------------------------
+# The following section of tests are primarily to spec requirements
+# around Parsoid's serialization (old, new, edited content)
+#
+# All these tests are marked Parsoid html2wt and html2html only
+# ----------------------------------------------------------------
+
+!! test
+Ignore rel attribute in a-tags during serialization to url-links
+!! options
+parsoid=html2wt
+!! html/parsoid
+<a href='http://en.wikipedia.org/wiki/Foobar'>http://en.wikipedia.org/wiki/Foobar</a>
+<a href='http://en.wikipedia.org/wiki/Foobar' rel='mw:ExtLink'>http://en.wikipedia.org/wiki/Foobar</a>
+<a href='http://en.wikipedia.org/wiki/Foobar' rel='mw:WikiLink'>http://en.wikipedia.org/wiki/Foobar</a>
+!! wikitext
+http://en.wikipedia.org/wiki/Foobar
+http://en.wikipedia.org/wiki/Foobar
+http://en.wikipedia.org/wiki/Foobar
+!! end
+
+# 'mi' is a localinterwiki prefix as well as a language
+!! test
+Serialize interwiki links pointing to the current wiki as plain wiki links (T67869)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a rel="mw:ExtLink" href="http://mi.wikipedia.org/wiki/Foo">Foo</a></p>
+!! wikitext
+[[Foo]]
+!! end
+
+!! test
+Parsoid should accept interwiki shortcuts
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a rel='mw:WikiLink' href='./fr:Foo'>Foo</a>
+<a rel='mw:ExtLink' href='./fr:Foo'>Foo</a>
+<a href='./fr:Foo'>Foo</a></p>
+<p><a rel='mw:WikiLink' href='fr%3AFoo'>Foo</a>
+<a rel='mw:ExtLink' href='fr%3AFoo'>Foo</a>
+<a href='fr%3AFoo'>Foo</a></p>
+<p><a href='FR%3AFoo'>Foo</a>
+<a href='./FR:Foo'>Foo</a></p>
+!! wikitext
+[[:fr:Foo|Foo]]
+[[:fr:Foo|Foo]]
+[[:fr:Foo|Foo]]
+
+[[:fr:Foo|Foo]]
+[[:fr:Foo|Foo]]
+[[:fr:Foo|Foo]]
+
+[[:fr:Foo|Foo]]
+[[:fr:Foo|Foo]]
+!! end
+
+!! test
+Parsoid should not accept invalid interwiki shortcuts
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a rel='mw:WikiLink' href='news:Foo'>Foo</a>
+<a rel='mw:ExtLink' href='news:Foo'>Foo</a>
+<a href='news:Foo'>Foo</a></p>
+!! wikitext
+[news:Foo Foo]
+[news:Foo Foo]
+[news:Foo Foo]
+!! end
+
+# See T93839
+!! test
+New wikilinks should be serialized properly
+!! options
+parsoid=html2wt
+!! html/parsoid
+<a rel="mw:WikiLink" href="./Foo" title="Foo" data-parsoid='{}'>Foo</a>
+<a rel="mw:WikiLink" href="./Foo" title="Foo">Foo</a>
+<a href="//en.wikipedia.org/wiki/Foo">//en.wikipedia.org/wiki/Foo</a>
+<a href="http://en.wikipedia.org/wiki/Foo">http://en.wikipedia.org/wiki/Foo</a>
+<a href="//en.wikipedia.org/wiki/Foo_bar">//en.wikipedia.org/wiki/Foo bar</a>
+!! wikitext
+[[Foo]]
+[[Foo]]
+[[:en:Foo|//en.wikipedia.org/wiki/Foo]]
+http://en.wikipedia.org/wiki/Foo
+[[:en:Foo_bar|//en.wikipedia.org/wiki/Foo bar]]
+!! end
+
+!! test
+New wiki links (href variations)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<a rel="mw:WikiLink" href="./Foo_bar">Foo_bar</a>
+<a rel="mw:WikiLink" href="Foo_bar">Foo_bar</a>
+<a rel="mw:WikiLink" href="Foo bar">Foo_bar</a>
+<a rel="mw:WikiLink" href="./Toxine_bact%C3%A9rienne">Toxine bactérienne</a>
+!! wikitext
+[[Foo_bar]]
+[[Foo_bar]]
+[[Foo_bar]]
+[[Toxine bactérienne]]
+!! end
+
+!! test
+New wiki links (content string variations)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<a rel="mw:WikiLink" href="./Foo_bar">Foo_bar</a>
+<a rel="mw:WikiLink" href="./Foo_bar">Foo bar</a>
+<a rel="mw:WikiLink" href="./Foo_bar">./Foo_bar</a>
+!! wikitext
+[[Foo_bar]]
+[[Foo bar]]
+[[Foo_bar|./Foo_bar]]
+!! end
+
+!! test
+New category links (href variations)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<link rel="mw:PageProp/Category" href="./Category:Toxine_bactérienne" />
+<link rel="mw:PageProp/Category" href="./Category:Toxine_bact%C3%A9rienne" />
+<link rel="mw:PageProp/Category" href="Category:Toxine_bact%C3%A9rienne" />
+!! wikitext
+[[Category:Toxine bactérienne]]
+[[Category:Toxine bactérienne]]
+[[Category:Toxine bactérienne]]
+!! end
+
+!! test
+New sol transparent links don't need indent-pre nowiki protection
+!! options
+parsoid=html2wt
+language=de
+!! html/parsoid
+ <link rel="mw:PageProp/redirect" href="./Main_Page">
+<!-- this is good --> <link rel="mw:PageProp/Category" href="./Category:Good" />
+<!-- this is great --> <link rel="mw:PageProp/Category" href="./Kategorie:Great" />
+!! wikitext
+ #WEITERLEITUNG [[Main Page]]
+<!-- this is good --> [[Category:Good]]
+<!-- this is great --> [[Kategorie:Great]]
+!! end
+
+!! test
+New interlanguage links (href variations)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/Toxine bactérienne" />
+<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/Toxine_bactérienne" />
+<link rel="mw:PageProp/Language" href="http://es.wikipedia.org/wiki/Toxine_bact%C3%A9rienne" />
+!! wikitext
+[[es:Toxine bactérienne]]
+[[es:Toxine_bactérienne]]
+[[es:Toxine_bactérienne]]
+!! end
+
+!! test
+Image: Modifying size of an image (1)
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ ["img[height]", "attr", "height", "22"],
+ ["img[width]", "attr", "width", "200"]
+ ]
+}
+!! wikitext
+[[Image:Foobar.jpg|230x230px]]
+!! wikitext/edited
+[[Image:Foobar.jpg|200x200px]]
+!!end
+
+!! test
+Image: Modifying size of an image (2)
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ ["img[height]", "attr", "height", "100"],
+ ["img[width]", "attr", "width", "500"]
+ ]
+}
+!! wikitext
+[[Image:Foobar.jpg|230x230px]]
+!! wikitext/edited
+[[Image:Foobar.jpg|500x500px]]
+!!end
+
+# Change in size is ignored so long as class='mw-default-size'
+!! test
+Image: Modifying size of an image (3)
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ ["figure[class]", "removeClass", "mw-default-size"],
+ ["figure img", "attr", "height", "19"],
+ ["figure img", "attr", "width", "170"]
+ ]
+}
+!! wikitext
+[[Image:Foobar.jpg|thumb]]
+!! wikitext/edited
+[[Image:Foobar.jpg|thumb|170x170px]]
+!!end
+
+!! test
+Image: Modifying alignment of an image (T50665)
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ ["figure[class]", "removeClass", "mw-halign-right"],
+ ["figure[class]", "addClass", "mw-halign-left"]
+ ]
+}
+!! wikitext
+[[Image:Foobar.jpg|thumb|caption|right]]
+!! wikitext/edited
+[[Image:Foobar.jpg|thumb|caption|left]]
+!! end
+
+!! test
+Image: Modifying mw-default-size of an frameless image (T64805)
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ ["figure.mw-default-size", "removeClass", "mw-default-size"]
+ ]
+}
+!! wikitext
+[[Image:Foobar.jpg|frameless|right]]
+!! wikitext/edited
+[[Image:Foobar.jpg|frameless|right|220x220px]]
+!! end
+
+!! test
+Image: Modifying valign of an image (T51221)
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ ["*[typeof=\"mw:Image\"]", "removeClass", "mw-valign-middle"],
+ ["*[typeof=\"mw:Image\"]", "addClass", "mw-valign-text-top"]
+ ]
+}
+!! wikitext
+[[File:Foobar.jpg|20px|middle]]
+!! wikitext/edited
+[[File:Foobar.jpg|20px|text-top]]
+!! end
+
+!! test
+Image: Modifying alt attribute of an image (T58400)
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ ["img[alt]", "attr", "alt", "some alternate edited text"]
+ ]
+}
+!! wikitext
+[[File:Foobar.jpg|thumb|some caption|alt=some alternate text]]
+!! wikitext/edited
+[[File:Foobar.jpg|thumb|some caption|alt=some alternate edited text]]
+!!end
+
+!! test
+Image: Modifying caption of an image
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ ["figcaption", "text", "new caption"]
+ ]
+}
+!! wikitext
+[[Image:Foobar.jpg|thumb|original caption]]
+!! wikitext/edited
+[[Image:Foobar.jpg|thumb|new caption]]
+!!end
+
+!! test
+Image: empty alt attribute (T50924)
+!! options
+parsoid
+!! wikitext
+[[File:Foobar.jpg|thumb|alt=|bar]]
+!! html
+<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"alt","ak":"alt="},{"ck":"caption","ak":"bar"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img alt="" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220" data-parsoid='{"a":{"alt":"","resource":"./File:Foobar.jpg","height":"25","width":"220"},"sa":{"alt":"alt=","resource":"File:Foobar.jpg"}}'/></a><figcaption>bar</figcaption></figure>
+!! end
+
+!! test
+Image: new attributes should be serialized in wiki's language for RTL languages (T53852)
+!! options
+parsoid=html2wt
+language=ar
+disabled
+!! html/parsoid
+<figure class="mw-default-size mw-halign-right" typeof="mw:Image/Thumb"><a href="./Imagen:Foobar.jpg"><img resource="./Imagen:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="20" width="180"/></a></figure>
+!! wikitext
+[[Imagen:Foobar.jpg|derecha|miniaturadeimagen]]
+!! end
+
+!! test
+Image: Block level image should have \n before and after
+!! wikitext
+123
+[[File:Foobar.jpg|right|thumb|150x150px]]
+456
+!! html/parsoid
+<p>123</p>
+<figure class="mw-halign-right" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/150px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="17" width="150"/></a></figure>
+<p>456</p>
+!! end
+
+!! test
+Image: New block level image should have \n before and after (existing content)
+!! wikitext
+123
+[[File:Foobar.jpg|right|thumb|150x150px]]
+456
+!! html/parsoid
+<p>123</p>
+<figure class="mw-halign-right" typeof="mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"right","ak":"right"},{"ck":"thumbnail","ak":"thumb"},{"ck":"width","ak":"150x150px"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/150px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="17" width="150" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"17","width":"150"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure>
+<p>456</p>
+!! end
+
+!! test
+Image: upright option (parsoid)
+!! wikitext
+[[File:Foobar.jpg|thumb|upright|caption]]
+[[File:Foobar.jpg|thumb|upright=0.5|caption]]
+[[File:Foobar.jpg|thumb|500x500px|upright=0.5|caption]]
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/170px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="19" width="170"/></a><figcaption>caption</figcaption></figure>
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/110px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="12" width="110"/></a><figcaption>caption</figcaption></figure>
+<figure typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/500px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="57" width="500"/></a><figcaption>caption</figcaption></figure>
+!! end
+
+!! test
+Image: upright option is ignored on inline and frame images (parsoid)
+!! wikitext
+[[File:Foobar.jpg|500x500px|upright=0.5|caption]]
+!! html/parsoid
+<p><figure-inline typeof="mw:Image" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/500px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="57" width="500"/></a></figure-inline></p>
+!! end
+
+!! test
+Image: in template parameter with empty parameter
+!! wikitext
+{{echo|[[File:Foobar.jpg|link=]]}}
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Transclusion mw:Image" about="#mwt1" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[File:Foobar.jpg|link=]]"}},"i":0}}]}'><span><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></span></figure-inline></p>
+!! end
+
+!! test
+Image: from basic HTML (1)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<span typeof="mw:Image">
+ <img src="./File:Foobar.jpg" width=100 height=100 alt="Alt">
+</span>
+!! wikitext
+[[File:Foobar.jpg|link=|alt=Alt|100x100px]]
+!! end
+
+!! test
+Image: from basic HTML (2)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<img src="./File:Foobar.jpg" width=100 height=100 alt="Alt">
+!! wikitext
+[[File:Foobar.jpg|link=|alt=Alt|100x100px]]
+!! end
+
+!! test
+Image: from basic HTML (3)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<a href="Main"><img src="./File:Foobar.jpg" width=100 height=100 alt="Alt"></a>
+!! wikitext
+[[File:Foobar.jpg|link=Main|alt=Alt|100x100px]]
+!! end
+
+!! test
+Image: from basic HTML (4)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<img src="./File:Foobar.jpg">
+!! wikitext
+[[File:Foobar.jpg|link=]]
+!! end
+
+!! test
+Image: Invalid title as link
+!! wikitext
+[[File:Foobar.jpg|link=<]]
+!! html/php
+<p><a href="/wiki/File:Foobar.jpg" class="image" title="link=&lt;"><img alt="link=&lt;" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
+</p>
+!! html/parsoid
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"link","ak":"link=&lt;"}]}' data-mw='{"caption":"link=&amp;lt;"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
+!! end
+
+!! test
+Lists: Serialize correctly even when list content is wrapped in p-tags (like VE does)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<ul>
+<li><p>foo</p></li>
+</ul>
+!! wikitext
+* foo
+!! end
+
+!! test
+Lists: Serialize correctly even when list tags has unneeded whitespace between tags
+!! options
+parsoid=html2wt
+!! html/parsoid
+<ul> <li>foo</li></ul>
+!! wikitext
+* foo
+!! end
+
+!! test
+Don't strip leading whitespace when handling indent-pre suppressing tags
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table>
+ <tr><td> indented row</td></tr>
+</table>
+<blockquote><p>
+ <b>This is very bold of you!</b>
+</p>
+<table><tr><td>
+ indented cell (no pre-wrapping!)
+</td></tr></table>
+</blockquote>
+<p>foo</p>
+ <div>bar</div>
+!! wikitext
+{|
+ | indented row
+|}
+<blockquote>
+ '''This is very bold of you!'''
+
+{|
+|
+ indented cell (no pre-wrapping!)
+|}
+</blockquote>
+foo
+ <div>bar</div>
+!! end
+
+!! test
+Nowiki-wrap leading whitespace when handling indent-pre inducing tags
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>foo</p>
+ <span>bar</span>
+
+<span>foo2
+ </span>bar2
+
+<div>foo</div>
+ <span>bar</span>
+
+<div>
+ <span>foo</span>
+</div>
+!! wikitext
+foo
+
+<span>bar</span>
+
+<span>foo2
+<nowiki> </nowiki></span>bar2
+
+<div>foo</div>
+<nowiki> </nowiki><span>bar</span>
+
+<div>
+<nowiki> </nowiki><span>foo</span>
+</div>
+!! end
+
+!! test
+Lists: Dont insert newlines in a serialized list item.
+!! options
+parsoid=html2wt
+!! html/parsoid
+<ul><li>a<br>b</li><li>c</li></ul>
+!! wikitext
+* a<br />b
+* c
+!! end
+
+!! test
+1. Headings: Force sol-transparent links and behavior switches to serialize before/after
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": false
+}
+!! html/parsoid
+<h2>hello there<link href="./Category:A1" rel="mw:PageProp/Category" /></h2>
+<h2><link href="./Category:A2" rel="mw:PageProp/Category" />hi pal</h2>
+
+<h2><!--foo--> <link href="./Category:A3" rel="mw:PageProp/Category" /> how goes it</h2>
+<h2>it goes well <link href="./Category:A4" rel="mw:PageProp/Category" /> <!--bar--></h2>
+
+<h2 data-parsoid='{}'>howdy<link href="./Category:A5" rel="mw:PageProp/Category" /></h2>
+
+<h2><meta property="mw:PageProp/toc" /> ok</h2>
+!! wikitext
+== hello there [[Category:A1]] ==
+
+== [[Category:A2]] hi pal ==
+
+== <!--foo--> [[Category:A3]] how goes it ==
+
+== it goes well [[Category:A4]] <!--bar--> ==
+
+==howdy [[Category:A5]]==
+
+== __TOC__ ok ==
+!! end
+
+!! test
+2. Headings: Force sol-transparent links and behavior switches to serialize before/after
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<h2>hello there<link href="./Category:A1" rel="mw:PageProp/Category" /></h2>
+<h2><link href="./Category:A2" rel="mw:PageProp/Category" />hi pal</h2>
+
+<h2><!--foo--> <link href="./Category:A3" rel="mw:PageProp/Category" /> how goes it</h2>
+<h2>it goes well <link href="./Category:A4" rel="mw:PageProp/Category" /> <!--bar--></h2>
+
+<h2><meta property="mw:PageProp/toc" /> ok</h2>
+!! wikitext
+== hello there ==
+[[Category:A1]]
+[[Category:A2]]
+
+== hi pal ==
+
+<!--foo--> [[Category:A3]]
+
+== how goes it ==
+
+== it goes well ==
+[[Category:A4]] <!--bar-->
+
+__TOC__
+
+== ok ==
+!! end
+
+!! test
+Headings: Don't hoist metas that come from templates
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<h2><span about="#mwt1" typeof="mw:Transclusion" data-parsoid="{}" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"foo [[Category:Foo]]"}},"i":0}}]}'>foo </span><link rel="mw:PageProp/Category" href="./Category:Foo" about="#mwt1" data-parsoid="{}" /></h2>
+!! wikitext
+== {{echo|foo [[Category:Foo]]}} ==
+!! end
+
+!! test
+Headings: Category in ref isn't hoisted
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<h2> foo <span about="#mwt2" class="mw-ref" id="cite_ref-1" rel="dc:references" typeof="mw:Extension/ref" data-mw='{"name":"ref","body":{"id":"mw-reference-text-cite_note-1"},"attrs":{}}'><a href="./Main_Page#cite_note-1"><span class="mw-reflink-text">[1]</span></a></span> </h2>
+
+<ol class="references" typeof="mw:Extension/references" about="#mwt3" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-1" id="cite_note-1"><span rel="mw:referencedBy"><a href="./Main_Page#cite_ref-1">↑</a></span> <span id="mw-reference-text-cite_note-1" class="mw-reference-text">bar <link rel="mw:PageProp/Category" href="./Category:Baz" /> </span></li></ol>
+!! wikitext
+== foo <ref>bar
+[[Category:Baz]] </ref> ==
+
+<references />
+!! end
+
+!! test
+Parsoid: Serialize positional parameters with = in them as named parameter
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion"
+data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"f=oo"}},"i":0}}]}'>foo</p>
+
+<p about="#mwt1" typeof="mw:Transclusion"
+data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"f=oo"}, "2":{"wt":"bar"}},"i":0}}]}'>foo</p>
+
+<!--Orig params with data-parsoid has heuristics for handling = chars-->
+<!--FIXME: But maybe the heuristic needs fixing to apply to new params as well-->
+<p data-parsoid='{"pi":[[{"k":"1"},{"k":"2"}]]}' about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"f=oo"},"2":{"wt":"bar"}},"i":0}}]}'>foo</p>
+!! wikitext
+{{echo|1=f=oo}}
+
+{{echo|1=f=oo|2=bar}}
+
+<!--Orig params with data-parsoid has heuristics for handling = chars-->
+<!--FIXME: But maybe the heuristic needs fixing to apply to new params as well-->
+{{echo|<nowiki>f=oo</nowiki>|bar}}
+!! end
+
+!! test
+Parsoid: Serialize positional parameters with = in extlink as named parameter
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a rel="mw:ExtLink" href="http://stuff?is=ok" about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"http://stuff?is=ok"}},"i":0}}]}'>http://stuff?is=ok</a></p>
+!! wikitext
+{{echo|1=http://stuff?is=ok}}
+!! end
+
+!! test
+Parsoid: Correctly serialize block-node children when they are a combination of text and p-nodes
+!! options
+parsoid=html2wt
+!! html/parsoid
+<div>a<p>b</p></div>
+<div>a
+<p>b</p></div>
+<div>
+a
+<p>b</p></div>
+!! wikitext
+<div>a
+b
+</div>
+<div>a
+b
+</div>
+<div>
+a
+
+b
+</div>
+!! end
+
+!! test
+Substrings resembling wikitext in hrefs should not get nowiki escapes
+!! options
+parsoid=html2wt
+!! html/parsoid
+<a rel="mw:WikiLink" href="./Foo''bar''baz">Foo''bar''baz</a>
+!! wikitext
+[[Foo''bar''baz]]
+!! end
+
+!! test
+Enforce single-line context in the serializer
+!! options
+parsoid=html2wt
+!! html/parsoid
+<h2>testing
+123</h2>
+
+<h2> hi <span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"bogus","href":"./Template:Bogus"},"params":{"1":{"wt":"there\nyou"}},"i":0}}]}'>there</span><span about="#mwt1">
+</span><span about="#mwt1">you</span> </h2>
+
+<h2> foo <span about="#mwt2" class="mw-ref" id="cite_ref-1" rel="dc:references" typeof="mw:Extension/ref" data-mw='{"name":"ref","body":{"id":"mw-reference-text-cite_note-1"},"attrs":{}}'><a href="./Main_Page#cite_note-1"><span class="mw-reflink-text">[1]</span></a></span> </h2>
+
+<ol class="references" typeof="mw:Extension/references" about="#mwt3" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-1" id="cite_note-1"><span rel="mw:referencedBy"><a href="./Main_Page#cite_ref-1">↑</a></span> <span id="mw-reference-text-cite_note-1" class="mw-reference-text" data-parsoid="{}">hello
+there</span></li></ol>
+
+<ul><li>asd
+sdf</li></ul>
+
+<ul><li>foo
+bar
+baz</li>
+<li>foo <b>bar</b>
+baz</li></ul>
+
+<dl><dt>hi
+ho </dt><dd data-parsoid='{"stx":"row"}'> hi
+ho</dd></dl>
+
+<dl><dd> <table>
+<tbody><tr><td> ha
+ha
+ha</td></tr>
+</tbody></table></dd></dl>
+!! wikitext
+== testing 123 ==
+
+== hi {{bogus|there
+you}} ==
+
+== foo <ref>hello
+there</ref> ==
+
+<references />
+
+* asd sdf
+
+* foo bar baz
+* foo '''bar''' baz
+
+; hi ho : hi ho
+
+: {|
+| ha
+ha
+ha
+|}
+!! end
+
+!! test
+Serialize new placeholder space without spans
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>foo<span typeof="mw:Placeholder"> </span>: bar</p>
+
+<p>foo<span typeof="mw:DisplaySpace mw:Placeholder" data-parsoid='{"src":" ","isDisplayHack":true}'> </span>: bar</p>
+
+<span typeof="mw:Extension/ref" data-mw="{&quot;name&quot;:&quot;ref&quot;,&quot;body&quot;:{&quot;html&quot;:&quot;foo<span typeof=\&quot;mw:Placeholder\&quot;>&amp;nbsp;</span>: bar&quot;}}"><sup>[1]</sup></span>ok</p>
+!! wikitext
+foo : bar
+
+foo : bar
+
+<ref>foo : bar</ref>ok
+!! end
+
+
+#-----------------------
+# Tag minimization tests
+#-----------------------
+
+!! test
+1. I/B quote minimization: wikitext-only tags should be combined
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><i>A</i><i>B</i></p>
+<p><b>A</b><b>B</b></p>
+<p><i>A</i><b><i>B</i></b></p>
+<p><b>A</b><i><b>B</b></i></p>
+<p><b>A</b><i><b>B</b><b>C</b></i><b>D</b></p>
+<p><i><b>A</b></i><i><b>B</b></i></p>
+<p><i><b>A</b></i><b><i>B</i></b></p>
+<p><b><i>A</i></b><i><b>B</b></i></p>
+!! wikitext
+''AB''
+
+'''AB'''
+
+''A'''B'''''
+
+'''A''B'''''
+
+'''A''BC''D'''
+
+'''''AB'''''
+
+'''''AB'''''
+
+'''''AB'''''
+!! end
+
+!! test
+2. I/B quote minimization: wikitext and html tags should not be combined
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><i>A</i><i data-parsoid='{"stx":"html"}'>B</i></p>
+<p><i>A</i><b><i data-parsoid='{"stx":"html"}'>B</i></b></p>
+!! wikitext
+''A''<i>B</i>
+
+''A''<nowiki/>'''<i>B</i>'''
+!! end
+
+!! test
+3. I/B quote minimization: templated content stops minimization
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><i>A</i><i about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&#39;&#39;B&#39;&#39;"}},"i":0}}]}'>B</i>
+<p><i>A</i><b about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&#39;&#39;&#39;&#39;&#39;B&#39;&#39;&#39;&#39;&#39;"}},"i":0}}]}'><i>B</i></b>
+!! wikitext
+''A''{{echo|''B''}}
+
+''A''{{echo|'''''B'''''}}
+!! end
+
+!! test
+4. I/B quote minimization: new content should be mimimized with adjacent old content
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><i>A</i><i>B</i></p>
+<p><b>A</b><b>B</b></p>
+<p><i>A</i><b><i>B</i></b></p>
+!! wikitext
+''AB''
+
+'''AB'''
+
+''A'''B'''''
+!! end
+
+!! test
+5a. Merge adjacent quote nodes if they've been edited
+!! options
+parsoid={
+ "modes": ["wt2wt", "selser"],
+ "changes": [
+ ["p", "contents", "remove", ":contains('b')"]
+ ]
+}
+!! wikitext
+''a''b''c''
+!! wikitext/edited
+''ac''
+!! end
+
+!! test
+5b. Merge adjacent quote nodes if they've been edited
+!! options
+parsoid={
+ "modes": ["wt2wt", "selser"],
+ "changes": [
+ ["#x", "remove"]
+ ]
+}
+!! wikitext
+''a''<span id="x">b</span>''c''
+!! wikitext/edited
+''ac''
+!! end
+
+!! test
+1. Merge adjacent link nodes as long as at least one element is new
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<a rel="mw:WikiLink" href="./Football">Foot</a><a rel="mw:WikiLink" href="./Football">ball</a>
+<a data-parsoid="{}" rel="mw:WikiLink" href="./Football">Foot</a><a rel="mw:WikiLink" href="./Football">ball</a>
+<a data-parsoid="{}" rel="mw:WikiLink" href="./Football">Foot</a><a data-parsoid="{}" rel="mw:WikiLink" href="./Football">ball</a>
+!! wikitext
+[[Football]]
+[[Football]]
+[[Football|Foot]][[Football|ball]]
+!! end
+
+!! test
+2. Merge adjacent link nodes and enable additional normalizations
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<a rel="mw:WikiLink" href="./Football"><i>Foot</i></a><a rel="mw:WikiLink" href="./Football"><i>ball</i></a>
+!! wikitext
+[[Football|''Football'']]
+!! end
+
+!! test
+3. Don't merge adjacent link nodes if scrubWikitext is false
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": false
+}
+!! html/parsoid
+<a rel="mw:WikiLink" href="./Football">Foot</a><a rel="mw:WikiLink" href="./Football">ball</a>
+!! wikitext
+[[Football|Foot]][[Football|ball]]
+!! end
+
+#------------------------------
+# End of tag minimization tests
+#------------------------------
+
+!!test
+T56262: New entities
+!! options
+parsoid=html2wt
+!! html/parsoid
+<span typeof="mw:Entity">&nbsp;</span>
+!! wikitext
+&nbsp;
+!! end
+
+## Note that there is no wikitext output for 'unknownproperty' ##
+## Unknown magic words are silently dropped ##
+
+!! test
+Magic words
+!! options
+parsoid=html2wt
+!! html/parsoid
+<meta property='mw:PageProp/toc' />
+<meta property='mw:PageProp/notoc' />
+<meta property='mw:PageProp/forcetoc' />
+<meta property='mw:PageProp/index' />
+<meta property='mw:PageProp/noindex' />
+<meta property='mw:PageProp/nogallery' />
+<meta property='mw:PageProp/noeditsection' />
+<meta property='mw:PageProp/notitleconvert' />
+<meta property='mw:PageProp/nocontentconvert' />
+<meta property='mw:PageProp/unknownproperty' />
+!! wikitext
+__TOC__
+__NOTOC__
+__FORCETOC__
+__INDEX__
+__NOINDEX__
+__NOGALLERY__
+__NOEDITSECTION__
+__NOTITLECONVERT__
+__NOCONTENTCONVERT__
+!! end
+
+!! test
+Consecutive <pre>s should not get merged
+!! options
+parsoid=html2wt,html2html
+!! html/parsoid
+<pre>a</pre><pre>b</pre>
+
+<pre>c
+</pre><pre>
+d</pre>
+
+<pre>e
+
+</pre><pre>
+
+f</pre>
+!! wikitext
+ a
+
+ b
+
+ c
+
+ d
+
+ e
+
+
+
+ f
+!! end
+
+!! test
+Edited ISBN links not serializable as ISBN links should serialize as wikilinks
+!! options
+parsoid=html2wt
+!! html/parsoid
+<a href="./Special:BookSources/1234567890" rel="mw:ExtLink">ISBN 1234567895</a>
+!! wikitext
+[[Special:BookSources/1234567890|ISBN 1234567895]]
+!! end
+
+!! test
+Edited RFC links not serializable as RFC links should serialize as extlinks
+!! options
+parsoid=html2wt
+!! html/parsoid
+<a href="https://tools.ietf.org/html/rfc123" rel="mw:ExtLink">New RFC</a>
+!! wikitext
+[https://tools.ietf.org/html/rfc123 New RFC]
+!! end
+
+!! test
+Edited PMID links not serializable as PMID links should serialize as extlinks
+!! options
+parsoid=html2wt
+!! html/parsoid
+<a href="//www.ncbi.nlm.nih.gov/pubmed/123?dopt=Abstract" rel="mw:ExtLink">New PMID</a>
+!! wikitext
+[//www.ncbi.nlm.nih.gov/pubmed/123?dopt=Abstract New PMID]
+!! end
+
+!! test
+WTS of autolinks with trailing/surrounding context
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a href="http://cscott.net">http://cscott.net</a><b>foo</b></p>
+<p><a href="http://cscott.net">http://cscott.net</a><b data-parsoid='{"stx":"html"}'>foo</b></p>
+<p><b><a href="http://cscott.net">http://cscott.net</a></b></p>
+<p><b><a href="http://cscott.net">http://cscott.net</a> </b></p>
+<p><b><a href="http://cscott.net">http://cscott.net</a>x</b></p>
+<p><a href="http://cscott.net">http://cscott.net</a>x</p>
+!! wikitext
+http://cscott.net'''foo'''
+
+http://cscott.net<b>foo</b>
+
+'''http://cscott.net'''
+
+'''http://cscott.net '''
+
+'''http://cscott.net<nowiki/>x'''
+
+http://cscott.net<nowiki/>x
+!! end
+
+!! test
+WTS of autolinks with nowikis (round-trip)
+!! wikitext
+x<nowiki/>http://cscott.net<nowiki/>x
+!! html/parsoid
+<p>x<a rel="mw:ExtLink" class="external free" href="http://cscott.net">http://cscott.net</a>x</p>
+!! end
+
+# this is the "easy" test because it leaves in place all the
+# data-parsoid information indicating this is an autolink
+!! test
+WTS of autolinks with escapes (editing)
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ [ "span", "remove" ]
+ ]
+}
+!! wikitext
+x<nowiki/>http://cscott.net<nowiki/>x
+!! wikitext/edited
+x<nowiki/>http://cscott.net<nowiki/>x
+!! end
+
+!! test
+WTS of edited autolink-like text (T103364)
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ [ "span[typeof]", "removeAttr", "typeof" ]
+ ]
+}
+!! wikitext
+Not a link: <nowiki>http://example.com</nowiki>.
+!! wikitext/edited
+Not a link: <span><nowiki>http://example.com</nowiki></span>.
+!! end
+
+!! test
+WTS of newly-authored autolink-like text (T103364)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>http://example.com is not a link.</p>
+!! wikitext
+<nowiki>http://example.com</nowiki> is not a link.
+!! end
+
+!! test
+WTS of autolink-like text after an autolink (T108563)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p><a rel="mw:ExtLink" href="http://example.com">http://example.com</a> http://example.com is not a link.</p>
+!! wikitext
+http://example.com <nowiki>http://example.com</nowiki> is not a link.
+!! end
+
+!! test
+Magic links inside links (not autolinked)
+!! wikitext
+[[Foo|http://example.com]]
+[[Foo|RFC 1234]]
+[[Foo|PMID 1234]]
+[[Foo|ISBN 123456789x]]
+
+[http://foo.com http://example.com]
+[http://foo.com RFC 1234]
+[http://foo.com PMID 1234]
+[http://foo.com ISBN 123456789x]
+!! html+tidy
+<p><a href="/wiki/Foo" title="Foo">http://example.com</a>
+<a href="/wiki/Foo" title="Foo">RFC 1234</a>
+<a href="/wiki/Foo" title="Foo">PMID 1234</a>
+<a href="/wiki/Foo" title="Foo">ISBN 123456789x</a>
+</p><p><a rel="nofollow" class="external text" href="http://foo.com">http://example.com</a>
+<a rel="nofollow" class="external text" href="http://foo.com">RFC 1234</a>
+<a rel="nofollow" class="external text" href="http://foo.com">PMID 1234</a>
+<a rel="nofollow" class="external text" href="http://foo.com">ISBN 123456789x</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Foo" title="Foo">http://example.com</a>
+<a rel="mw:WikiLink" href="./Foo" title="Foo">RFC 1234</a>
+<a rel="mw:WikiLink" href="./Foo" title="Foo">PMID 1234</a>
+<a rel="mw:WikiLink" href="./Foo" title="Foo">ISBN 123456789x</a></p>
+
+<p><a rel="mw:ExtLink" class="external text" href="http://foo.com">http://example.com</a>
+<a rel="mw:ExtLink" class="external text" href="http://foo.com">RFC 1234</a>
+<a rel="mw:ExtLink" class="external text" href="http://foo.com">PMID 1234</a>
+<a rel="mw:ExtLink" class="external text" href="http://foo.com">ISBN 123456789x</a></p>
+!! end
+
+!! test
+Magic links inside image captions (autolinked)
+!! wikitext
+[[File:Foobar.jpg|thumb|http://example.com]]
+[[File:Foobar.jpg|thumb|RFC 1234]]
+[[File:Foobar.jpg|thumb|PMID 1234]]
+[[File:Foobar.jpg|thumb|ISBN 123456789x]]
+!! html+tidy
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a></div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div><a class="external mw-magiclink-rfc" rel="nofollow" href="https://tools.ietf.org/html/rfc1234">RFC 1234</a></div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div><a class="external mw-magiclink-pmid" rel="nofollow" href="//www.ncbi.nlm.nih.gov/pubmed/1234?dopt=Abstract">PMID 1234</a></div></div></div>
+<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div><a href="/wiki/Special:BookSources/123456789X" class="internal mw-magiclink-isbn">ISBN 123456789x</a></div></div></div>
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption><a rel="mw:ExtLink" class="external free" href="http://example.com">http://example.com</a></figcaption></figure>
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption><a href="https://tools.ietf.org/html/rfc1234" rel="mw:ExtLink" class="external text">RFC 1234</a></figcaption></figure>
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption><a href="//www.ncbi.nlm.nih.gov/pubmed/1234?dopt=Abstract" rel="mw:ExtLink" class="external text">PMID 1234</a></figcaption></figure>
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption><a href="./Special:BookSources/123456789X" rel="mw:WikiLink">ISBN 123456789x</a></figcaption></figure>
+!! end
+
+!! test
+WTS of magic word text (T109371)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>RFC 1234</p>
+<p><a href="http://foo.com" rel="mw:ExtLink">RFC 1234</a></p>
+<p><a href="./Foo" rel="mw:WikiLink">RFC 1234</a></p>
+!! wikitext
+<nowiki>RFC 1234</nowiki>
+
+[http://foo.com RFC 1234]
+
+[[Foo|RFC 1234]]
+!! end
+
+!! test
+Edited Redirect link should emit a non-piped wikitext link
+!! options
+parsoid=html2wt
+!! html/parsoid
+<link rel="mw:PageProp/redirect" href="Bar" data-parsoid='{"a":{"href":"./Foo"},"sa":{"href":"Foo"}}'>
+!! wikitext
+#REDIRECT [[Bar]]
+!! end
+
+!! test
+T75121: Infer extension name from typeOf if data-mw is not present
+!! options
+parsoid={ "modes": ["html2wt"], "suppressErrors": true }
+!! html/parsoid
+<div typeOf="mw:Extension/foo"></div>
+!! wikitext
+<foo />
+!! end
+
+# Note that the <p> wrapping isn't present in PHP parser output
+# The important thing for this test is that P-wrapping doesn't
+# interfere with the <nowiki> protection for leading - in <td>
+# (which isn't necessary for <th>).
+!! test
+T88318: p-wrapped dash in table.
+!! options
+parsoid=html2wt,wt2wt
+!! html/parsoid
+<table><tbody>
+<tr><th><p>-</p></th><th><p>- </p></th></tr>
+<tr><td><p>-</p></td><td><p>- </p></td></tr>
+<tr><td><small>-</small></td><td><br/><p>-</p></td><td><br/>-</td></tr>
+</tbody></table>
+!! wikitext
+{|
+!-
+!-
+|-
+|<nowiki>-</nowiki>
+|<nowiki>- </nowiki>
+|-
+|<small>-</small>
+|<br />
+-
+|<br />
+-
+|}
+!! html/php+tidy
+<table>
+<tbody><tr>
+<th>-
+</th>
+<th>-
+</th></tr>
+<tr>
+<td>-
+</td>
+<td>-
+</td></tr>
+<tr>
+<td><small>-</small>
+</td>
+<td><br />
+<p>-
+</p>
+</td>
+<td><br />
+<p>-
+</p>
+</td></tr></tbody></table>
+!! end
+
+!! test
+T149209: WTS: Handle newlines in table cells properly
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table>
+<tbody>
+<tr><td>a
+b
+</td><td data-parsoid='{"stx":"row"}'>c</td></tr>
+<tr><td><p>x</p>
+</td><td data-parsoid='{"stx":"row", "startTagSrc": "{{!}}{{!}}"}'>y</td></tr>
+</tbody></table>
+<table>
+<tbody>
+<tr><th>a
+b
+</th><th data-parsoid='{"stx":"row"}'>c</th></tr>
+<tr><th><p>x</h>
+</th><th data-parsoid='{"stx":"row"}'>y</th></tr>
+</tbody></table>
+!! wikitext
+{|
+|a
+b
+|c
+|-
+|x
+{{!}}y
+|}
+{|
+!a
+b
+!c
+|-
+!x
+!y
+|}
+!! end
+
+!! test
+T149209: Selser: Handle newlines in table cells properly
+!! options
+parsoid={
+ "modes": ["selser"],
+ "changes": [
+ [ "#h1", "html", "a\nb\n" ],
+ [ "#h2", "html", "a\nb\n" ],
+ [ "#c1", "html", "a\nb\n" ],
+ [ "#c2", "html", "<p>a</p>" ],
+ [ "#c3", "html", "<p>a</p>" ],
+ [ "#c4", "html", "edit-me<p>a</p>" ]
+ ]
+}
+!! wikitext
+{|
+! id="h1" |edit-me!!1
+|-
+! id="h2" |edit-me||2
+|-
+| id="c1" |edit-me||3
+|-
+| id="c2" |edit-me||4
+|-
+| id="c3" |edit-me||p||q||r
+|-
+| id="c4" |edit-me||p||q||r
+|}
+!! wikitext/edited
+{|
+! id="h1" |a
+b
+!1
+|-
+! id="h2" |a
+b
+!2
+|-
+| id="c1" |a
+b
+|3
+|-
+| id="c2" |a
+|4
+|-
+| id="c3" |a
+|p||q||r
+|-
+| id="c4" |edit-me
+a
+|p||q||r
+|}
+!! end
+
+!! test
+HTML id attribute with Parsoid-like element ids should not be serialized to wikitext
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table id='mwAb'>
+<td id='mwAc'>foo</td>
+<td id='serialize-this'>bar</td>
+</table>
+!! wikitext
+{|
+|foo
+| id="serialize-this" |bar
+|}
+!! end
+
+!! test
+Parsoid-like element ids should not be serialized to wikitext unless shadowed
+!! options
+parsoid=html2wt
+!! html/parsoid
+<div id="mwAQ" data-parsoid='{"stx":"html","a":{"id":"mwAQ"},"sa":{"id":"hello"}}'>ok</div>
+!! wikitext
+<div id="hello">ok</div>
+!! end
+
+!! test
+WTS change modes
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "changes": [
+ [ "#xyz", "before", "<b>before</b> stuff " ],
+ [ "#xyz", "after", " stuff <i>after</i>" ],
+ [ "#xyz", "html", "x <b>y</b> z" ]
+ ]
+}
+!! wikitext
+<span id="xyz">hello</span>
+!! wikitext/edited
+'''before''' stuff <span id="xyz">x '''y''' z</span> stuff ''after''
+!! end
+
+!! test
+Never serialize a-tag as html, regardless of what data-parsoid has to say
+!! options
+parsoid={ "modes": ["html2wt"], "suppressErrors": true }
+!! html/parsoid
+<a rel="mw:WikiLink" href="./Foo" title="Foo" data-parsoid='{"stx":"html"}'>Foo</a>
+!! wikitext
+[[Foo]]
+!! end
+
+## SSS FIXME: This is broken output nevertheless.
+## What might be a reasonable non-broken output for this?
+## This is an edge case unlikely to be seen in production
+## that I am not wasting more time on this right now.
+!! test
+Never serialize a-tag as html, no matter what attributes it has
+!! options
+parsoid={ "modes": ["html2wt"], "suppressErrors": true }
+!! html/parsoid
+<a bad='true' href='http://boo.org'><img src='http://boohoo.org' /></a>
+!! wikitext
+[http://boo.org http://boohoo.org]
+!! end
+
+# Misnested is an indication that selser can reuse the source but these have
+# shown to sneak through on occasion. See T101768.
+# The original wikitext here is: [http://test.com [[one]] two three]
+!! test
+Strip span tags added to mark misnested links
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p data-parsoid='{}'><a rel="mw:ExtLink" href="http://test.com" data-parsoid='{"targetOff":17,"contentOffsets":[17,34]}'></a><a rel="mw:WikiLink" href="./One" title="One" data-parsoid='{"stx":"simple","a":{"href":"./One"},"sa":{"href":"one"},"misnested":true}'>one</a><span data-parsoid='{"misnested":true}'> two three</span></p>
+!! wikitext
+[http://test.com][[one]] two three
+!! end
+
+!! test
+Catch regression when unpacking misnested links
+!! options
+parsoid=wt2html
+!! wikitext
+{{echo|hi}}[http://example.com [[ho]]]
+!! html/parsoid
+<p><span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"hi"}},"i":0}}]}'>hi</span><a rel="mw:ExtLink" class="external autonumber" href="http://example.com"></a><a rel="mw:WikiLink" href="./Ho" title="Ho" data-parsoid='{"misnested":true}'>ho</a></p>
+!! end
+
+!! test
+Catch regression when unpacking with trailing content
+!! wikitext
+{{echo|Foo <references/> bar}}
+!! html/parsoid
+<p about="#mwt2" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"Foo &lt;references/> bar"}},"i":0}}]}'>Foo </p><ol class="mw-references references" typeof="mw:Extension/references" about="#mwt2" data-mw='{"name":"references","attrs":{}}'></ol><p about="#mwt2"> bar</p>
+!! end
+
+!! test
+Use data-parsoid.firstWikitextNode to compute newline constraints for template content
+!! options
+parsoid=html2wt
+!! html/parsoid
+<span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"a"}},"i":0}}]}'>a</span><table about="#mwt2" typeof="mw:Transclusion mw:ExpandedAttrs" data-parsoid='{"a":{"{{echo|c\n{{!}}d\n}}":null},"sa":{"{{echo|c\n{{!}}d\n}}":""},"firstWikitextNode":"table","pi":[[{"k":"1"}]]}' data-mw='{"parts":["{|",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"c\n{{!}}d\n"}},"i":0}},"\n|}"]}'>
+<tbody><tr><td>d
+</td></tr>
+</tbody></table>
+!! wikitext
+{{echo|a}}
+{|{{echo|c
+{{!}}d
+}}
+|}
+!! end
+
+## This test verifies the presence and computation of this attribute indirectly
+## by making an edit and ensuring that the serialization is correct (which it would be
+## only if firstWikitextNode is properly set).
+!! test
+data-parsoid.firstWikitextNode should be computed properly in the presence of fostered content
+!! options
+parsoid= {
+ "modes": ["wt2wt"],
+ "changes": [
+ [ "div#x", "remove" ],
+ [ "div", "before", "<div>new</div>" ]
+ ]
+}
+!! wikitext
+<div id="x">foo</div>
+{|
+{{echo|<div>boo</div>
+{{!}}b}}
+|c
+|}
+!! wikitext/edited
+
+<div>new</div>
+{|
+{{echo|<div>boo</div>
+{{!}}b}}
+|c
+|}
+!! end
+
+# --------------------------------------------
+# Tests spec'ing wikitext serialization norms |
+# --------------------------------------------
+
+!! test
+Serialize multi-line indent-pre starting with wikitext syntax
+!! options
+parsoid=html2wt
+!! html/parsoid
+<pre>* 1
+** 2
+* 3</pre>
+!! wikitext
+ * 1
+ ** 2
+ * 3
+!! end
+
+!! test
+1. Categories should always be serialized on their own line
+!! options
+parsoid=html2wt
+!! html/parsoid
+foo<link rel="mw:PageProp/Category" href="./Category:Foo">bar
+!! wikitext
+foo
+[[Category:Foo]]
+bar
+!! end
+
+!! test
+2. Categories that are part of templates should not introduce a line break
+!! wikitext
+foo {{echo|<span>bar</span> [[Category:baz]]}} bar
+!! html/parsoid
+<p>foo <span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;span>bar&lt;/span> [[Category:baz]]"}},"i":0}}]}'>bar</span><span about="#mwt1"> </span><link rel="mw:PageProp/Category" href="./Category:Baz" about="#mwt1" data-parsoid='{"stx":"simple","a":{"href":"./Category:Baz"},"sa":{"href":"Category:baz"}}'/> bar</p>
+!! end
+
+# Careful while editing these next 2 tests. There are \u200f characters
+# before and after the <link> tags in the HTML and following some
+# of the categories in wikitext
+# Do not remove these characters in edits.
+#
+# As part of the serialization, these bidi characters will get stripped.
+!! test
+RTL (\u200f) and LTR (\u200e) markers around category tags should be stripped
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<p>‏<link rel="mw:PageProp/Category" href="./קטגוריה:טקסים" />‏
+‏<link rel="mw:PageProp/Category" href="./קטגוריה:_שיטות_משפט" />‏</p>
+!! wikitext
+[[קטגוריה:טקסים]]
+[[קטגוריה: שיטות משפט]]
+!! end
+
+!! test
+RTL (\u200f) and LTR (\u200e) markers should not be stripped if followed by a text node
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<p>‏<link rel="mw:PageProp/Category" href="./קטגוריה:טקסים" />‏y</p>
+!! wikitext
+[[קטגוריה:טקסים]]
+‏y
+!! end
+
+!! test
+Lists: Add space after bullets
+!! options
+parsoid=html2wt
+!! html/parsoid
+<ul>
+<li>foo</li>
+<li> bar</li>
+<li><span> baz</span></li>
+</ul>
+!! wikitext
+* foo
+* bar
+* <span> baz</span>
+!! end
+
+!! test
+1. Headings: Add space before/after == (T53744)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<h2>foo</h2>
+<h2> bar</h2>
+<h2>baz </h2>
+<h2><span> baz</span></h2>
+!! wikitext
+== foo ==
+
+== bar ==
+
+== baz ==
+
+== <span> baz</span> ==
+!! end
+
+!! test
+2. Headings: Add space before/after == even after hoisted content
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<h2> <link href="./Category:A2" rel="mw:PageProp/Category" />ok</h2>
+!! wikitext
+ [[Category:A2]]
+
+== ok ==
+!! end
+
+!! test
+1. Headings: suppress newly created empty headings
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<h2></h2>
+!! wikitext
+!! end
+
+!! test
+2. Headings: don't suppress empty headings if scrubWikitext is false
+!! options
+parsoid=html2wt
+!! html/parsoid
+<h2></h2>
+!! wikitext
+==<nowiki/>==
+!! end
+
+!! test
+3. Headings: suppress empty headings on edits
+!! options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "#x", "remove"]
+ ]
+}
+!! wikitext
+==<span id="x">foo</span>==
+!! wikitext/edited
+!! end
+
+!! test
+Headings: Replace <br/> with a single whitespace char (when scrubWikitext = true)
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<h2>foo<br/>bar</h2>
+<h2>foo <span><br/>bar</span> baz</h2>
+!! wikitext
+== foo bar ==
+
+== foo <span> bar</span> baz ==
+!! end
+
+!! test
+Headings: Replace <br/> with a single whitespace char (when scrubWikitext = false)
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": false
+}
+!! html/parsoid
+<h2>foo<br/>bar</h2>
+!! wikitext
+== foo<br /> bar ==
+!! end
+
+!! test
+1. WT Quote Tags: suppress newly created empty style tags
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<i></i><b></b>
+!! wikitext
+!! end
+
+!! test
+2. WT Quote Tags: don't suppress empty style tags if scrubWikitext is false
+!! options
+parsoid=html2wt
+!! html/parsoid
+<i></i><b></b>
+!! wikitext
+''<nowiki/>'''''<nowiki/>'''
+!! end
+
+!! test
+3. WT Quote Tags: suppress empty style tags on edits
+!! options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "#x", "remove"]
+ ]
+}
+!! wikitext
+'''<span id="x">foo</span>'''
+!! wikitext/edited
+!! end
+
+!! test
+1. Anchors: suppress newly created empty anchors
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<a rel="mw:WikiLink" href="./Test" title="Test"></a>
+!! wikitext
+!! end
+
+!! test
+2. Anchors: don't suppress empty anchors if scrubWikitext is false
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": false
+}
+!! html/parsoid
+<a rel="mw:WikiLink" href="./Test" title="Test"></a>
+!! wikitext
+[[Test|<nowiki/>]]
+!! end
+
+!! test
+3. Anchors: suppress empty anchors on edits
+!! options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "#x", "remove"]
+ ]
+}
+!! wikitext
+[[Test|<span id="x">foo</span>]]
+!! wikitext/edited
+!! end
+
+!! test
+3a. Anchors: do not suppress numbered extlinks
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "scrubWikitext": true
+}
+!! wikitext
+[http://foo.com]
+!! html/parsoid
+<a rel="mw:ExtLink" href="http://foo.com"></a>
+!! end
+
+!! test
+3b. Anchors: do not suppress numbered extlinks
+!! options
+parsoid={
+ "modes": ["wt2wt"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "#x", "remove"]
+ ]
+}
+!! wikitext
+[http://foo.com <span id="x">foo</span>]
+!! wikitext/edited
+[http://foo.com]
+!! end
+
+!!test
+Normalizations should be restricted to edited content
+!!options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "h1", "before", "<i></i>"]
+ ]
+}
+!!wikitext
+a
+= =
+b
+!!wikitext/edited
+a
+= =
+b
+!!end
+
+!! test
+1. Multiple normalizations (html2wt)
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html
+<h2><i></i></h2>
+<p><a href='Foo' rel='mw:WikiLink'>foo<i></i>
+ </a><b><i></i></b>x</p>
+!! wikitext
+
+[[foo]]
+x
+
+!! end
+
+!! test
+2. Multiple normalizations (selser)
+!! options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "#x", "after", "<h1><i></i></h1>\n<p> x<b></b></p>"]
+ ]
+}
+!! wikitext
+<span id="x">foo</span>
+!! wikitext/edited
+<span id="x">foo</span>
+
+x
+!! end
+
+!! test
+1. Indent Pre Nowiki: suppress whitespace at the start of new paragraph
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<p> hi</p>
+<p> hello</p>
+!! wikitext
+hi
+
+hello
+!! end
+
+!! test
+2. Indent Pre Nowiki: don't suppress whitespace at the start of new paragraph if scrubWikitext is false
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p> hi</p>
+<p> hello</p>
+!! wikitext
+<nowiki> </nowiki>hi
+
+<nowiki> </nowiki> hello
+!! end
+
+!! test
+3. Indent Pre Nowiki: suppress whitespace after newlines in new paragraph or table cell
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<p>Foo
+ bar
+baz</p>
+
+<table><tr><td>Foo
+ bar
+ baz bang</td></tr></table>
+
+<p><!--boo--> foo
+ bar</p>
+
+<p> foo
+ bar<span>boo</span></p>
+!! wikitext
+Foo
+bar
+baz
+
+{|
+|Foo
+bar
+baz bang
+|}
+
+<!--boo-->foo
+bar
+
+foo
+bar<span>boo</span>
+!! end
+
+!! test
+4. Indent Pre Nowiki: suppress leading whitespace in edited paragraphs
+!! options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "p", "html", " a\n b" ]
+ ]
+}
+!! wikitext
+xyz
+!! wikitext/edited
+a
+b
+!! end
+
+!! test
+1. New links that end in spaces
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": false
+}
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Berlin" title="Berlin">Berlin </a>is the capital of Germany.</p>
+<p><a rel="mw:WikiLink" href="./Foo" title="Foo">Foo </a><b>bar</b></p>
+<p><a rel="mw:WikiLink" href="./Boston" title="Boston">Boston </a> is a city.</p>
+!! wikitext
+[[Berlin ]]<nowiki/>is the capital of Germany.
+
+[[Foo ]]'''bar'''
+
+[[Boston ]] is a city.
+!! end
+
+!! test
+2. New links that end in spaces
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Berlin" title="Berlin">Berlin </a>is the capital of Germany.</p>
+<p><a rel="mw:WikiLink" href="./Foo" title="Foo">Foo </a><b>bar</b></p>
+<p><a rel="mw:WikiLink" href="./Boston" title="Boston">Boston </a> is a city.</p>
+!! wikitext
+[[Berlin]] is the capital of Germany.
+
+[[Foo]] '''bar'''
+
+[[Boston]] is a city.
+!! end
+
+!! test
+1. Table cells with escapable prefixes
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": false
+}
+!! html
+<table>
+<tr><td>a</td></tr>
+<tr><td>-</td></tr>
+<tr><td>+</td></tr>
+</table>
+!! wikitext
+{|
+|a
+|-
+|<nowiki>-</nowiki>
+|-
+|<nowiki>+</nowiki>
+|}
+!! end
+
+!! test
+2. Table cells with escapable prefixes
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html
+<table>
+<tr><td>a</td></tr>
+<tr><td>-</td></tr>
+<tr><td>+</td></tr>
+</table>
+!! wikitext
+{|
+|a
+|-
+| -
+|-
+| +
+|}
+!! end
+
+!! test
+3a. Table cells with escapable prefixes after edits
+!! options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "table tbody tr:first-child td:first-child", "remove"]
+ ]
+}
+!! wikitext
+{|
+|a||-
+|}
+!! wikitext/edited
+{|
+| -
+|}
+!! end
+
+!! test
+3b. Table cells with escapable prefixes after edits
+!! options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "table tbody tr:first-child td:first-child", "html", "-" ],
+ [ "#x", "remove" ]
+ ]
+}
+!! wikitext
+{|
+|pqr
+|<span id="x">foo</span>+
+|}
+!! wikitext/edited
+{|
+| -
+| +
+|}
+!! end
+
+# FIXME: This test will fail because
+# normalization doesn't realize that the id attribute
+# will eliminate the escapable scenario
+!! test
+4a. Table cells without escapable prefixes after edits
+!! options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "#x", "html", "-" ]
+ ]
+}
+!! wikitext
+{|
+| id="x" |abcd
+|}
+!! wikitext/edited
+{|
+| id="x" |-
+|}
+!! end
+
+## This tests normalizer's ability to discriminate between
+## cells having identical content.
+!! test
+4b. Table cells without escapable prefixes after edits
+!! options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "td", "html", "-" ]
+ ]
+}
+!! wikitext
+{|
+|a||b
+|}
+!! wikitext/edited
+{|
+| -||-
+|}
+!! end
+
+## This tests normalizer's ability to not be tripped by
+## comments (and whitespace)
+!! test
+4c. Table cells without escapable prefixes after edits
+!! options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "table tbody tr td:first-child", "remove" ]
+ ]
+}
+!! wikitext
+{|
+|-
+<!--foo--> |a||-
+|}
+!! wikitext/edited
+{|
+|-
+<!--foo--> | -
+|}
+!! end
+
+## This tests normalizer's ability to handle HTML cells
+!! test
+4d. Table cells without escapable prefixes after edits
+!! options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "td", "html", "-" ]
+ ]
+}
+!! wikitext
+<table>
+<tr><td>a</td></tr>
+</table>
+!! wikitext/edited
+<table>
+<tr><td>-</td></tr>
+</table>
+!! end
+
+## T111151 Remove font elements without attributes
+!! test
+5a. font tags without attributes should be dropped in scrubWikitext mode
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": true
+}
+!! html
+<font>foo</font>
+<font><font>bar</font></font>
+<font class="x">boo</font>
+!! wikitext
+foo
+bar
+<font class="x">boo</font>
+!! end
+
+!! test
+5b. font tags should not be dropped without scrubWikitext being enabled
+!! options
+parsoid={
+ "modes": ["html2wt"],
+ "scrubWikitext": false
+}
+!! html
+<font>foo</font>
+!! wikitext
+<font>foo</font>
+!! end
+
+!! test
+Escape nowiki DOM elements
+!! options
+parsoid=html2wt
+!! html/parsoid
+<nowiki><i>foo</i></nowiki>
+!! wikitext
+&lt;nowiki&gt;''foo''&lt;/nowiki&gt;
+!! end
+
+# This is meant to be an interim fix while we go about figuring out
+# how to not introduce these trailing <nowiki/>s in the first place.
+!! test
+T115717: Strip trailing <nowiki/>s (without affecting valid uses)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>x<meta typeof="mw:Placeholder" data-parsoid='{"src":"&lt;nowiki/>"}'/><meta typeof="mw:Placeholder" data-parsoid='{"src":"&lt;nowiki/>"}'/>
+y</p>
+<p><span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1","named":true,"spc":["\n"," "," ",""]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;nowiki/>"}},"i":0}}]}'></span></p>
+<p><span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1","named":true,"spc":["\n"," "," ","\n"]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;nowiki/>"}},"i":0}}]}'></span></p>
+!! wikitext
+x
+y
+
+{{echo|
+1 = <nowiki/>}}
+
+{{echo|
+1 = <nowiki/>
+}}
+!! end
+
+!! test
+New list is serialized on newlines
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>The quick brown fox jumps over the lazy dog.</p><ul>
+<li>Yesterday</li>
+<li>Today</li>
+<li>Tomorrow</li>
+</ul><p>The quick onyx goblin jumps over the lazy dwarf.</p>
+!! wikitext
+The quick brown fox jumps over the lazy dog.
+
+* Yesterday
+* Today
+* Tomorrow
+
+The quick onyx goblin jumps over the lazy dwarf.
+!! end
+
+!! test
+New lists in formatting elements serialized w/o newlines
+!! options
+parsoid=html2wt
+!! html/parsoid
+<small>
+
+<ul>
+<li>123</li>
+</ul>
+
+</small>
+
+<small><ul><li>hi</li></ul></small>
+!! wikitext
+<small>
+* 123
+</small>
+
+<small>
+* hi
+</small>
+!! end
+
+!! test
+New list in table doesn't need newlines
+!! options
+parsoid=html2wt
+!! html/parsoid
+<table><tr><td><ul><li>test</li><li>123</li></td></tr></table>
+!! wikitext
+{|
+|
+* test
+* 123
+|}
+!! end
+
+# ---------------------------------------------------
+# End of tests spec'ing wikitext serialization norms |
+# ---------------------------------------------------
+
+# T104032
+!! test
+Bare inline nodes not wrapped inside p-tags should be treated as p-wrapped
+!! options
+parsoid=html2wt
+!! html/parsoid
+a<p>b</p>
+<b>c</b><p>d</p>
+<table><tr>
+<td>a<p>b</p></td>
+<td><b>c</b><p>d</p></td>
+</tr></table>
+!! wikitext
+a
+
+b
+
+'''c'''
+
+d
+{|
+|a
+b
+|'''c'''
+d
+|}
+!! end
+
+!! test
+Anchor without href scenarios
+!! options
+parsoid={ "modes": ["html2wt"], "suppressErrors": true }
+!! html/parsoid
+<a class="bc"></a>
+<a class="no">dice</a>
+<a name="foo"></a>
+!! wikitext
+
+dice
+<span name="foo"></span>
+!! end
+
+!! test
+New transclusion added after a list should be serialized after the list
+!! options
+parsoid=html2wt
+!! html/parsoid
+<ul><li>a</li></ul><span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:echo"},"params":{"1":{"wt":"foo"}},"i":0}}]}'>foo</span>
+!! wikitext
+* a
+{{echo|foo}}
+!! end
+
+# -----------------------------------------------------------------
+# End of section for Parsoid-only html2wt tests for serialization
+# of new content
+# -----------------------------------------------------------------
+
+# -----------------------------------------------------------------
+# The following section of tests are primarily to spec behavior of
+# the selective serializer. All these tests have manual selser
+# changes. The automated selser changes for all tests handle the
+# wide variation of changes, but these tests here capture specs
+# deterministically.
+# ----------------------------------------------------------------
+
+## T90517
+!! test
+Selser: New comments should not be lost
+!! options
+parsoid={
+ "modes": ["selser"],
+ "changes": [
+ [ "#a", "after", "<!--c1-->" ],
+ [ "#b", "before", "<!--c2-->" ]
+ ]
+}
+!! wikitext
+<span id="a">a</span>
+
+<span id="b">b</span>
+!! wikitext/edited
+<span id="a">a</span><!--c1-->
+
+<!--c2--><span id="b">b</span>
+!! end
+
+## T89383
+!! test
+Selser: Check for validity of DSR before using it
+!! options
+parsoid={
+ "modes": ["selser"],
+ "changes": [
+ [ "#a", "before", "<meta property='mw:PageProp/displaytitle' content='foo'>" ]
+ ]
+}
+!! wikitext
+<span id="a">a</span>
+!! wikitext/edited
+{{DISPLAYTITLE:foo}}
+<span id="a">a</span>
+!! end
+
+!! test
+1. DOMDiff: Changes to <ref> content should be looked up using id
+!! options
+parsoid={
+ "modes": ["selser"],
+ "changes": [
+ ["#X", "after", "bar"],
+ ["#Y", "after", "baz"]
+ ]
+}
+!! wikitext
+X <ref><span id="X">foo</span></ref>
+Y <ref name="a" />
+<references>
+<ref name="a"><span id="Y">foo</span></ref>
+</references>
+!! wikitext/edited
+X <ref><span id="X">foo</span>bar</ref>
+Y <ref name="a" />
+<references>
+<ref name="a"><span id="Y">foo</span>baz</ref>
+</references>
+!! end
+
+!! test
+2. DOMDiff: Changes to <ref> content should be looked up using id
+!! options
+parsoid={
+ "modes": ["selser"],
+ "changes": [
+ ["#Z", "after", "bar"]
+ ]
+}
+!! wikitext
+A <ref>foo bar for a</ref>
+B <ref group="X" name="b" />
+
+<references />
+
+<references group="X">
+<ref name="b"><span id="Z">foo</span></ref>
+</references>
+!! wikitext/edited
+A <ref>foo bar for a</ref>
+B <ref group="X" name="b" />
+
+<references />
+
+<references group="X">
+<ref name="b"><span id="Z">foo</span>bar</ref>
+</references>
+!! end
+
+!! test
+DOMDiff: Edits to content nested in elements with templated attributes should not be lost (T139388)
+!! options
+parsoid={
+ "modes": ["selser"],
+ "changes": [
+ [ "div:first-child", "text", "bar" ]
+ ]
+}
+!! wikitext
+<div style="{{1x|color:red;}}%">foo</div>
+!! wikitext/edited
+<div style="{{1x|color:red;}}%">bar</div>
+!! end
+
+!! test
+Empty LI (T49673)
+!! wikitext
+*a
+*
+*
+*b
+!! html+tidy
+<ul><li>a</li>
+<li class="mw-empty-elt"></li>
+<li class="mw-empty-elt"></li>
+<li>b</li></ul>
+!! end
+
+!! test
+Thumbnail output
+!! wikitext
+[[File:Thumb.png|thumb]]
+!! html/php+tidy
+<div class="thumb tright"><div class="thumbinner" style="width:137px;"><a href="/wiki/File:Thumb.png" class="image"><img alt="Thumb.png" src="http://example.com/images/e/ea/Thumb.png" width="135" height="135" class="thumbimage" /></a> <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Thumb.png" class="internal" title="Enlarge"></a></div></div></div></div>
+!! html/parsoid
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Thumb.png"><img resource="./File:Thumb.png" src="//example.com/images/e/ea/Thumb.png" data-file-width="135" data-file-height="135" data-file-type="bitmap" height="135" width="135"/></a></figure>
+!! end
+
+!! test
+unclosed internal link XSS (T137264)
+!! wikitext
+[[#%3Cscript%3Ealert(1)%3C/script%3E|
+!! html/php
+<p>[[#&lt;script&gt;alert(1)&lt;/script&gt;|
+</p>
+!! html/parsoid
+<p>[[#%3Cscript%3Ealert(1)%3C/script%3E|</p>
+!! end
+
+!! test
+Validating that <style> isn't eaten by tidy (T167349)
+!! options
+styletag=1
+!! wikitext
+<div class="foo">
+<style>.foo::before { content: "<foo>"; }</style>
+<style data-mw-foobar="baz">.foo::after { content: "<bar>"; }</style>
+</div>
+!! html/php+tidy
+<div class="foo">
+<style>.foo::before { content: "<foo>"; }</style>
+<style data-mw-foobar="baz">.foo::after { content: "<bar>"; }</style>
+</div>
+!! end
+
+!! test
+Validating that <style> isn't wrapped in a paragraph (T186965)
+!! options
+styletag=1
+!! wikitext
+A style tag, by itself or with other style/link tags, shouldn't be wrapped in a paragraph
+
+<style>.foo::before { content: "<foo>"; }</style>
+
+<style>.foo::before { content: "<foo>"; }</style> <link rel="foo" href="bar"/><style>.foo::before { content: "<foo>"; }</style>
+
+But if it's on a line with other content, let it be wrapped.
+
+<style>.foo::before { content: "<foo>"; }</style> bar
+
+foo <style>.foo::before { content: "<foo>"; }</style>
+
+foo <style>.foo::before { content: "<foo>"; }</style> bar
+
+And the same if we have non-paragraph-breaking whitespace
+
+foo
+<style>.foo::before { content: "<foo>"; }</style>
+bar
+!! html/php
+<p>A style tag, by itself or with other style/link tags, shouldn't be wrapped in a paragraph
+</p>
+<style>.foo::before { content: "<foo>"; }</style>
+<style>.foo::before { content: "<foo>"; }</style> <link rel="foo" href="bar"/><style>.foo::before { content: "<foo>"; }</style>
+<p>But if it's on a line with other content, let it be wrapped.
+</p><p><style>.foo::before { content: "<foo>"; }</style> bar
+</p><p>foo <style>.foo::before { content: "<foo>"; }</style>
+</p><p>foo <style>.foo::before { content: "<foo>"; }</style> bar
+</p><p>And the same if we have non-paragraph-breaking whitespace
+</p><p>foo
+<style>.foo::before { content: "<foo>"; }</style>
+bar
+</p>
+!! end
+
+!! test
+Validating that <link> isn't wrapped in a paragraph (T186965)
+!! options
+styletag=1
+!! wikitext
+A link tag, by itself or with other style/link tags, shouldn't be wrapped in a paragraph
+
+<link rel="foo" href="bar"/>
+
+<link rel="foo" href="bar"/> <style>.foo::before { content: "<foo>"; }</style><link rel="foo" href="bar"/>
+
+But if it's on a line with other content, let it be wrapped.
+
+<link rel="foo" href="bar"/> bar
+
+foo <link rel="foo" href="bar"/>
+
+foo <link rel="foo" href="bar"/> bar
+
+And the same if we have non-paragraph-breaking whitespace
+
+foo
+<link rel="foo" href="bar"/>
+bar
+!! html/php
+<p>A link tag, by itself or with other style/link tags, shouldn't be wrapped in a paragraph
+</p>
+<link rel="foo" href="bar"/>
+<link rel="foo" href="bar"/> <style>.foo::before { content: "<foo>"; }</style><link rel="foo" href="bar"/>
+<p>But if it's on a line with other content, let it be wrapped.
+</p><p><link rel="foo" href="bar"/> bar
+</p><p>foo <link rel="foo" href="bar"/>
+</p><p>foo <link rel="foo" href="bar"/> bar
+</p><p>And the same if we have non-paragraph-breaking whitespace
+</p><p>foo
+<link rel="foo" href="bar"/>
+bar
+</p>
+!! end
+
+!! test
+Decoding of HTML entities in headings and links for IDs and link fragments (T103714)
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+==A&B&amp;C&amp;amp;D&amp;amp;amp;E==
+[[#A&B&amp;C&amp;amp;D&amp;amp;amp;E]]
+!! html/php
+<h2><span id="A.26B.26C.26amp.3BD.26amp.3Bamp.3BE"></span><span class="mw-headline" id="A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E">A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p><a href="#A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E">#A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E</a>
+</p>
+!! html/parsoid
+<h2 id="A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E"><span id="A.26B.26C.26amp.3BD.26amp.3Bamp.3BE" typeof="mw:FallbackId" data-parsoid="{}"></span>A&amp;B<span typeof="mw:Entity" data-parsoid='{"src":"&amp;amp;","srcContent":"&amp;"}'>&amp;</span>C<span typeof="mw:Entity" data-parsoid='{"src":"&amp;amp;","srcContent":"&amp;"}'>&amp;</span>amp;D<span typeof="mw:Entity" data-parsoid='{"src":"&amp;amp;","srcContent":"&amp;"}'>&amp;</span>amp;amp;E</h2>
+<p><a rel="mw:WikiLink" href="./Main_Page#A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E"},"sa":{"href":"#A&amp;B&amp;amp;C&amp;amp;amp;D&amp;amp;amp;amp;E"}}'>#A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E</a></p>
+!! end
+
+!! test
+Decoding of HTML entities in headings and links for IDs and link fragments (T103714) (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
+!! wikitext
+==A&B&amp;C&amp;amp;D&amp;amp;amp;E==
+[[#A&B&amp;C&amp;amp;D&amp;amp;amp;E]]
+!! html/php
+<h2><span class="mw-headline" id="A.26B.26C.26amp.3BD.26amp.3Bamp.3BE">A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p><a href="#A.26B.26C.26amp.3BD.26amp.3Bamp.3BE">#A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E</a>
+</p>
+!! end
+
+!! test
+Decoding of HTML entities in embedded HTML tags
+!! wikitext
+<table class="1&2&amp;3&amp;amp;4&amp;amp;amp;5"><tr><td>x</td></tr></table>
+!! html/php
+<table class="1&amp;2&amp;3&amp;amp;4&amp;amp;amp;5"><tr><td>x</td></tr></table>
+
+!! html/parsoid
+<table class="1&amp;2&amp;3&amp;amp;4&amp;amp;amp;5" data-parsoid='{"stx":"html","a":{"class":"1&amp;2&amp;3&amp;amp;4&amp;amp;amp;5"},"sa":{"class":"1&amp;2&amp;amp;3&amp;amp;amp;4&amp;amp;amp;amp;5"}}'><tbody><tr data-parsoid='{"stx":"html"}'><td data-parsoid='{"stx":"html"}'>x</td></tr></tbody></table>
+!! end
+
+!! test
+Decoding of HTML entities in indicator names for IDs (T104196)
+!! options
+parsoid=wt2html,html2html
+showindicators
+!! wikitext
+<indicator name="1&2&amp;3&amp;amp;4&amp;amp;amp;5">Indicator</indicator>
+!! html/php
+1&2&3&amp;4&amp;amp;5=Indicator
+
+!! html/parsoid
+<p><span typeof="mw:Extension/indicator" about="#mwt3" data-mw='{"name":"indicator","attrs":{"name":"1&amp;2&amp;3&amp;amp;4&amp;amp;amp;5"},"body":{"extsrc":"Indicator"}}'></span></p>
+!! end
+
+# this version of the test strips out the ambiguity so Parsoid rts cleanly
+!! test
+Decoding of HTML entities in indicator names for IDs (unambiguous) (T104196)
+!! options
+showindicators
+!! wikitext
+<indicator name="1&2&3&amp;amp;4&amp;amp;amp;5">Indicator</indicator>
+!! html/php
+1&2&3&amp;4&amp;amp;5=Indicator
+
+!! html/parsoid
+<p><span typeof="mw:Extension/indicator" about="#mwt3" data-mw='{"name":"indicator","attrs":{"name":"1&amp;2&amp;3&amp;amp;4&amp;amp;amp;5"},"body":{"extsrc":"Indicator"}}'></span></p>
+!! end
+
+# This fragment mode is what Parsoid supports.
+!! test
+HTML5 ids: fallback to legacy
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+==Foo bar==
+
+==foo Bar==
+
+==Тест==
+
+==Тест==
+
+==тест==
+
+==Hey < # " > % : '==
+[[#Foo bar]] [[#foo Bar]] [[#Тест]] [[#тест]] [[#Hey < # " > % : ']]
+
+{{anchorencode:💩}} <span id="{{anchorencode:💩}}"></span>
+
+<!-- These two links should produce identical HTML -->
+[[#啤酒]] [[#%E5%95%A4%E9%85%92]]
+
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Foo_bar"><span class="tocnumber">1</span> <span class="toctext">Foo bar</span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#foo_Bar_2"><span class="tocnumber">2</span> <span class="toctext">foo Bar</span></a></li>
+<li class="toclevel-1 tocsection-3"><a href="#Тест"><span class="tocnumber">3</span> <span class="toctext">Тест</span></a></li>
+<li class="toclevel-1 tocsection-4"><a href="#Тест_2"><span class="tocnumber">4</span> <span class="toctext">Тест</span></a></li>
+<li class="toclevel-1 tocsection-5"><a href="#тест"><span class="tocnumber">5</span> <span class="toctext">тест</span></a></li>
+<li class="toclevel-1 tocsection-6"><a href="#Hey_&lt;_#_&quot;_&gt;_%_:_&#39;"><span class="tocnumber">6</span> <span class="toctext">Hey &lt; # " &gt;&#160;%&#160;: '</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Foo_bar">Foo bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Foo bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="foo_Bar_2">foo Bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: foo Bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span id=".D0.A2.D0.B5.D1.81.D1.82"></span><span class="mw-headline" id="Тест">Тест</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: Тест">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span id=".D0.A2.D0.B5.D1.81.D1.82_2"></span><span class="mw-headline" id="Тест_2">Тест</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: Тест">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span id=".D1.82.D0.B5.D1.81.D1.82"></span><span class="mw-headline" id="тест">тест</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: тест">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span id="Hey_.3C_.23_.22_.3E_.25_:_.27"></span><span class="mw-headline" id="Hey_&lt;_#_&quot;_&gt;_%_:_'">Hey &lt; # " &gt;&#160;%&#160;: '</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=6" title="Edit section: Hey &lt; # &quot; &gt; % : &#039;">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p><a href="#Foo_bar">#Foo bar</a> <a href="#foo_Bar">#foo Bar</a> <a href="#Тест">#Тест</a> <a href="#тест">#тест</a> <a href="#Hey_&lt;_#_&quot;_&gt;_%_:_&#39;">#Hey &lt; # " &gt;&#160;%&#160;: '</a>
+</p><p>💩 <span id="💩"></span>
+</p><p><a href="#啤酒">#啤酒</a> <a href="#啤酒">#啤酒</a>
+</p>
+!! html/parsoid
+<h2 id="Foo_bar">Foo bar</h2>
+
+<h2 id="foo_Bar_2">foo Bar</h2>
+
+<h2 id="Тест"><span id=".D0.A2.D0.B5.D1.81.D1.82" typeof="mw:FallbackId"></span>Тест</h2>
+
+<h2 id="Тест_2"><span id=".D0.A2.D0.B5.D1.81.D1.82_2" typeof="mw:FallbackId"></span>Тест</h2>
+
+<h2 id="тест"><span id=".D1.82.D0.B5.D1.81.D1.82" typeof="mw:FallbackId"></span>тест</h2>
+
+<h2 id="Hey_&lt;_#_&quot;_>_%_:_'"><span id="Hey_.3C_.23_.22_.3E_.25_:_.27" typeof="mw:FallbackId"></span>Hey &lt; # " > %<span typeof="mw:DisplaySpace mw:Placeholder" data-parsoid='{"src":" ","isDisplayHack":true}'> </span>: '</h2>
+<p><a rel="mw:WikiLink" href="./Main_Page#Foo_bar">#Foo bar</a> <a rel="mw:WikiLink" href="./Main_Page#foo_Bar">#foo Bar</a> <a rel="mw:WikiLink" href="./Main_Page#Тест">#Тест</a> <a rel="mw:WikiLink" href="./Main_Page#тест">#тест</a> <a rel="mw:WikiLink" href="./Main_Page#Hey_&lt;_#_&quot;_>_%_:_'" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Hey_&lt;_#_\"_>_%_:_&#39;"},"sa":{"href":"#Hey &lt; # \" > % : &#39;"}}'>#Hey &lt; # " > % : '</a></p>
+
+<p><span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode:💩","function":"anchorencode"},"params":{},"i":0}}]}'>💩</span> <span id="💩" about="#mwt3" typeof="mw:ExpandedAttrs" data-mw='{"attribs":[[{"txt":"id"},{"html":"&lt;span about=\"#mwt2\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[]],\"dsr\":[178,197,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"anchorencode:💩\",\"function\":\"anchorencode\"},\"params\":{},\"i\":0}}]}&#39;>💩&lt;/span>"}]]}'></span></p>
+
+<!-- These two links should produce identical HTML -->
+<p><a rel="mw:WikiLink" href="./Main_Page#啤酒">#啤酒</a> <a rel="mw:WikiLink" href="./Main_Page#啤酒" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#啤酒"},"sa":{"href":"#%E5%95%A4%E9%85%92"}}'>#啤酒</a></p>
+!! end
+
+# Parsoid doesn't support this mode
+!! test
+HTML5 ids: legacy with a fallback to modern
+!! config
+wgFragmentMode=[ 'legacy', 'html5' ]
+!! wikitext
+==Foo bar==
+
+==foo Bar==
+
+==Тест==
+
+==Тест==
+
+==тест==
+
+==Hey < # " > % : '==
+[[#Foo bar]] [[#foo Bar]] [[#Тест]] [[#тест]] [[#Hey < # " > % : ']]
+
+{{anchorencode:💩}} <span id="{{anchorencode:💩}}"></span>
+
+<!-- These two links should produce identical HTML -->
+[[#啤酒]] [[#%E5%95%A4%E9%85%92]]
+
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Foo_bar"><span class="tocnumber">1</span> <span class="toctext">Foo bar</span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#foo_Bar_2"><span class="tocnumber">2</span> <span class="toctext">foo Bar</span></a></li>
+<li class="toclevel-1 tocsection-3"><a href="#.D0.A2.D0.B5.D1.81.D1.82"><span class="tocnumber">3</span> <span class="toctext">Тест</span></a></li>
+<li class="toclevel-1 tocsection-4"><a href="#.D0.A2.D0.B5.D1.81.D1.82_2"><span class="tocnumber">4</span> <span class="toctext">Тест</span></a></li>
+<li class="toclevel-1 tocsection-5"><a href="#.D1.82.D0.B5.D1.81.D1.82"><span class="tocnumber">5</span> <span class="toctext">тест</span></a></li>
+<li class="toclevel-1 tocsection-6"><a href="#Hey_.3C_.23_.22_.3E_.25_:_.27"><span class="tocnumber">6</span> <span class="toctext">Hey &lt; # " &gt;&#160;%&#160;: '</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Foo_bar">Foo bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Foo bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="foo_Bar_2">foo Bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: foo Bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span id="Тест"></span><span class="mw-headline" id=".D0.A2.D0.B5.D1.81.D1.82">Тест</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: Тест">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span id="Тест_2"></span><span class="mw-headline" id=".D0.A2.D0.B5.D1.81.D1.82_2">Тест</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: Тест">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span id="тест"></span><span class="mw-headline" id=".D1.82.D0.B5.D1.81.D1.82">тест</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: тест">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span id="Hey_&lt;_#_&quot;_&gt;_%_:_'"></span><span class="mw-headline" id="Hey_.3C_.23_.22_.3E_.25_:_.27">Hey &lt; # " &gt;&#160;%&#160;: '</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=6" title="Edit section: Hey &lt; # &quot; &gt; % : &#039;">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p><a href="#Foo_bar">#Foo bar</a> <a href="#foo_Bar">#foo Bar</a> <a href="#.D0.A2.D0.B5.D1.81.D1.82">#Тест</a> <a href="#.D1.82.D0.B5.D1.81.D1.82">#тест</a> <a href="#Hey_.3C_.23_.22_.3E_.25_:_.27">#Hey &lt; # " &gt;&#160;%&#160;: '</a>
+</p><p>.F0.9F.92.A9 <span id=".F0.9F.92.A9"></span>
+</p><p><a href="#.E5.95.A4.E9.85.92">#啤酒</a> <a href="#.E5.95.A4.E9.85.92">#啤酒</a>
+</p>
+!! end
+
+# Parsoid doesn't support this mode.
+!! test
+HTML5 ids: no legacy
+!! config
+wgFragmentMode=[ 'html5' ]
+!! wikitext
+==Foo bar==
+
+==foo Bar==
+
+==Тест==
+
+==Тест==
+
+==тест==
+
+==Hey < # " > % : '==
+[[#Foo bar]] [[#foo Bar]] [[#Тест]] [[#тест]] [[#Hey < # " > % : ']]
+
+{{anchorencode:💩}} <span id="{{anchorencode:💩}}"></span>
+
+<!-- These two links should produce identical HTML -->
+[[#啤酒]] [[#%E5%95%A4%E9%85%92]]
+
+!! html/php
+<div id="toc" class="toc"><div class="toctitle" lang="en" dir="ltr"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Foo_bar"><span class="tocnumber">1</span> <span class="toctext">Foo bar</span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#foo_Bar_2"><span class="tocnumber">2</span> <span class="toctext">foo Bar</span></a></li>
+<li class="toclevel-1 tocsection-3"><a href="#Тест"><span class="tocnumber">3</span> <span class="toctext">Тест</span></a></li>
+<li class="toclevel-1 tocsection-4"><a href="#Тест_2"><span class="tocnumber">4</span> <span class="toctext">Тест</span></a></li>
+<li class="toclevel-1 tocsection-5"><a href="#тест"><span class="tocnumber">5</span> <span class="toctext">тест</span></a></li>
+<li class="toclevel-1 tocsection-6"><a href="#Hey_&lt;_#_&quot;_&gt;_%_:_&#39;"><span class="tocnumber">6</span> <span class="toctext">Hey &lt; # " &gt;&#160;%&#160;: '</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Foo_bar">Foo bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Foo bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="foo_Bar_2">foo Bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: foo Bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Тест">Тест</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: Тест">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Тест_2">Тест</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: Тест">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="тест">тест</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: тест">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Hey_&lt;_#_&quot;_&gt;_%_:_'">Hey &lt; # " &gt;&#160;%&#160;: '</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=6" title="Edit section: Hey &lt; # &quot; &gt; % : &#039;">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p><a href="#Foo_bar">#Foo bar</a> <a href="#foo_Bar">#foo Bar</a> <a href="#Тест">#Тест</a> <a href="#тест">#тест</a> <a href="#Hey_&lt;_#_&quot;_&gt;_%_:_&#39;">#Hey &lt; # " &gt;&#160;%&#160;: '</a>
+</p><p>💩 <span id="💩"></span>
+</p><p><a href="#啤酒">#啤酒</a> <a href="#啤酒">#啤酒</a>
+</p>
+!! end
+
+!! test
+T90902: Normalize weird characters in section IDs
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+==Foo&nbsp;bar==
+[[#Foo&nbsp;bar]]
+
+!! html/php
+<h2><span class="mw-headline" id="Foo_bar">Foo&#160;bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Foo bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p><a href="#Foo_bar">#Foo&#160;bar</a>
+</p>
+!! html/parsoid
+<h2 id="Foo_bar"> Foo<span typeof="mw:Entity" data-parsoid='{"src":"&amp;nbsp;","srcContent":" "}'> </span>bar </h2>
+<p><a rel="mw:WikiLink" href="./Main_Page#Foo_bar" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Foo_bar"},"sa":{"href":"#Foo&amp;nbsp;bar"}}'>#Foo bar</a></p>
+!! end
+
+!! test
+T51672: Test for brackets in attributes of elements in external link texts
+!! wikitext
+[http://example.com/ link <span title="title with [brackets]">span</span>]
+[http://example.com/ link <span title="title with &#91;brackets&#93;">span</span>]
+
+!! html/php
+<p><a rel="nofollow" class="external text" href="http://example.com/">link <span title="title with &#91;brackets&#93;">span</span></a>
+<a rel="nofollow" class="external text" href="http://example.com/">link <span title="title with &#91;brackets&#93;">span</span></a>
+</p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" class="external text" href="http://example.com/">link <span title="title with [brackets]">span</span></a>
+<a rel="mw:ExtLink" class="external text" href="http://example.com/">link <span title="title with [brackets]" data-parsoid='{"stx":"html","a":{"title":"title with [brackets]"},"sa":{"title":"title with &amp;#91;brackets&amp;#93;"}}'>span</span></a></p>
+!! end
+
+!! test
+T72875: Test for brackets in attributes of elements in internal link texts
+!! wikitext
+[[Foo|link <span title="title with [[double brackets]]">span</span>]]
+[[Foo|link <span title="title with &#91;&#91;double brackets&#93;&#93;">span</span>]]
+
+!! html/php
+<p><a href="/wiki/Foo" title="Foo">link <span title="title with &#91;&#91;double brackets&#93;&#93;">span</span></a>
+<a href="/wiki/Foo" title="Foo">link <span title="title with &#91;&#91;double brackets&#93;&#93;">span</span></a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Foo" title="Foo">link <span title="title with [[double brackets]]">span</span></a>
+<a rel="mw:WikiLink" href="./Foo" title="Foo">link <span title="title with [[double brackets]]" data-parsoid='{"stx":"html","a":{"title":"title with [[double brackets]]"},"sa":{"title":"title with &amp;#91;&amp;#91;double brackets&amp;#93;&amp;#93;"}}'>span</span></a></p>
+!! end
+
+!! test
+T179544: {{anchorencode:}} output should be always usable in links
+!! config
+wgFragmentMode=[ 'html5' ]
+!! wikitext
+<span id="{{anchorencode:[foo]}}"></span>[[#{{anchorencode:[foo]}}]]
+!! html/php
+<p><span id="&#91;foo&#93;"></span><a href="#[foo]">#&#91;foo&#93;</a>
+</p>
+!! html/parsoid
+<p><span id="[foo]" about="#mwt3" typeof="mw:ExpandedAttrs" data-parsoid='{"stx":"html","a":{"id":"[foo]"},"sa":{"id":"{{anchorencode:[foo]}}"}}' data-mw='{"attribs":[[{"txt":"id"},{"html":"&lt;span typeof=\"mw:Transclusion mw:Entity\" about=\"#mwt1\" data-parsoid=&apos;{\"srcContent\":\"[\",\"dsr\":[10,32,null,null],\"pi\":[[]]}&apos; data-mw=&apos;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"anchorencode:[foo]\",\"function\":\"anchorencode\"},\"params\":{},\"i\":0}}]}&apos;>[&lt;/span>&lt;span about=\"#mwt1\" data-parsoid=\"{}\">foo&lt;/span>&lt;span typeof=\"mw:Entity\" about=\"#mwt1\" data-parsoid=&apos;{\"src\":\"&amp;amp;#x5D;\",\"srcContent\":\"]\"}&apos;>]&lt;/span>"}]]}'></span><a typeof="mw:ExpandedAttrs" about="#mwt4" rel="mw:WikiLink" href="./Main_Page#[foo]" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#[foo]"},"sa":{"href":"#{{anchorencode:[foo]}}"}}' data-mw='{"attribs":[[{"txt":"href"},{"html":"#&lt;span typeof=\"mw:Transclusion mw:Entity\" about=\"#mwt2\" data-parsoid=&apos;{\"srcContent\":\"[\",\"dsr\":[44,66,null,null],\"pi\":[[]]}&apos; data-mw=&apos;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"anchorencode:[foo]\",\"function\":\"anchorencode\"},\"params\":{},\"i\":0}}]}&apos;>[&lt;/span>&lt;span about=\"#mwt2\" data-parsoid=\"{}\">foo&lt;/span>&lt;span typeof=\"mw:Entity\" about=\"#mwt2\" data-parsoid=&apos;{\"src\":\"&amp;amp;#x5D;\",\"srcContent\":\"]\"}&apos;>]&lt;/span>"}]]}'>#[foo]</a></p>
+!! end
+
+## ------------------------------
+## Parsoid section-wrapping tests
+## ------------------------------
+!! test
+Section wrapping for well-nested sections (no leading content)
+!! options
+parsoid={
+ "wrapSections": true
+}
+!! wikitext
+=1=
+a
+
+=2=
+b
+
+==2.1==
+c
+
+==2.2==
+d
+
+===2.2.1===
+e
+
+=3=
+f
+!! html/parsoid
+<section data-mw-section-id="0"></section><section data-mw-section-id="1"><h1 id="1">1</h1>
+<p>a</p>
+
+</section><section data-mw-section-id="2"><h1 id="2">2</h1>
+<p>b</p>
+
+<section data-mw-section-id="3"><h2 id="2.1">2.1</h2>
+<p>c</p>
+
+</section><section data-mw-section-id="4"><h2 id="2.2">2.2</h2>
+<p>d</p>
+
+<section data-mw-section-id="5"><h3 id="2.2.1">2.2.1</h3>
+<p>e</p>
+
+</section></section></section><section data-mw-section-id="6"><h1 id="3">3</h1>
+<p>f</p>
+
+</section>
+!! end
+
+!! test
+Section wrapping for well-nested sections (with leading content)
+!! options
+parsoid={
+ "wrapSections": true
+}
+!! wikitext
+Para 1.
+
+Para 2 with a <div>nested in it</div>
+
+Para 3.
+
+=1=
+a
+
+=2=
+b
+
+==2.1==
+c
+!! html/parsoid
+<section data-mw-section-id="0"><p>Para 1.</p>
+
+<p>Para 2 with a </p><div>nested in it</div>
+
+<p>Para 3.</p>
+
+</section><section data-mw-section-id="1"><h1 id="1">1</h1>
+<p>a</p>
+
+</section><section data-mw-section-id="2"><h1 id="2">2</h1>
+<p>b</p>
+
+<section data-mw-section-id="3"><h2 id="2.1">2.1</h2>
+<p>c</p>
+
+</section></section>
+!! end
+
+!! test
+Section wrapping with template-generated sections (good nesting 1)
+!! options
+parsoid={
+ "wrapSections": true
+}
+!! wikitext
+=1=
+a
+
+{{echo|1=
+==1.1==
+b
+}}
+
+==1.2==
+c
+
+=2=
+d
+!! html/parsoid
+<section data-mw-section-id="0"></section><section data-mw-section-id="1"><h1 id="1">1</h1>
+<p>a</p>
+
+<section data-mw-section-id="-1"><h2 about="#mwt1" typeof="mw:Transclusion" id="1.1" data-parsoid='{"dsr":[9,33,null,null],"pi":[[{"k":"1","named":true,"spc":["","","\n","\n"]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"==1.1==\nb"}},"i":0}}]}'>1.1</h2><span about="#mwt1">
+</span><p about="#mwt1">b</p>
+</section><section data-mw-section-id="3"><h2 id="1.2">1.2</h2>
+<p>c</p>
+
+</section></section><section data-mw-section-id="4"><h1 id="2">2</h1>
+<p>d</p></section>
+!! end
+
+# In this example, the template scope is mildly expanded to incorporate the
+# trailing newline after the transclusion since that is part of section 1.1.1
+!! test
+Section wrapping with template-generated sections (good nesting 2)
+!! options
+parsoid={
+ "wrapSections": true,
+ "modes": ["wt2html", "wt2wt"]
+}
+!! wikitext
+=1=
+a
+
+{{echo|1=
+==1.1==
+b
+===1.1.1===
+d
+}}
+=2=
+e
+!! html/parsoid
+<section data-mw-section-id="0"></section><section data-mw-section-id="1"><h1 id="1">1</h1>
+<p>a</p>
+
+<section data-mw-section-id="-1"><h2 about="#mwt1" typeof="mw:Transclusion" id="1.1" data-parsoid='{"dsr":[9,50,null,null],"pi":[[{"k":"1","named":true,"spc":["","","\n","\n"]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"==1.1==\nb\n===1.1.1===\nd"}},"i":0}},"\n"]}'>1.1</h2><span about="#mwt1">
+</span><p about="#mwt1">b</p><span about="#mwt1">
+</span><section data-mw-section-id="-1" about="#mwt1"><h3 about="#mwt1" id="1.1.1">1.1.1</h3><span about="#mwt1">
+</span><p about="#mwt1">d</p><span about="#mwt1">
+</span></section></section></section><section data-mw-section-id="4" data-parsoid="{}"><h1 id="2">2</h1>
+<p>e</p></section>
+!! end
+
+# In this example, the template scope is mildly expanded to incorporate the
+# trailing newline after the transclusion since that is part of section 1.2.1
+!! test
+Section wrapping with template-generated sections (good nesting 3)
+!! options
+parsoid={
+ "wrapSections": true,
+ "modes": ["wt2html", "wt2wt"]
+}
+!! wikitext
+=1=
+a
+
+{{echo|1=
+x
+==1.1==
+b
+==1.2==
+c
+===1.2.1===
+d
+}}
+=2=
+e
+!! html/parsoid
+<section data-mw-section-id="0"></section><section data-mw-section-id="1" data-parsoid="{}"><h1 id="1"> 1 </h1>
+<p>a</p>
+
+<p about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"dsr":[9,60,0,0],"pi":[[{"k":"1","named":true,"spc":["","","\n","\n"]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"x\n==1.1==\nb\n==1.2==\nc\n===1.2.1===\nd"}},"i":0}},"\n"]}'>x</p><span about="#mwt1">
+</span><section data-mw-section-id="-1" about="#mwt1"><h2 about="#mwt1" id="1.1">1.1</h2><span about="#mwt1">
+</span><p about="#mwt1">b</p><span about="#mwt1">
+</span></section><section data-mw-section-id="-1" about="#mwt1"><h2 about="#mwt1" id="1.2">1.2</h2><span about="#mwt1">
+</span><p about="#mwt1">c</p><span about="#mwt1">
+</span><section data-mw-section-id="-1" about="#mwt1"><h3 about="#mwt1" id="1.2.1">1.2.1</h3><span about="#mwt1">
+</span><p about="#mwt1">d</p><span about="#mwt1">
+</span></section></section></section><section data-mw-section-id="5"><h1 id="2">2</h1>
+<p>e</p></section>
+!! end
+
+# Because of section-wrapping and template-wrapping interactions,
+# the scope of the template is expanded so that the template markup
+# is valid in the presence of <section> tags.
+# This exercises the s1 is null scenario in the wrapSections code
+!! test
+Section wrapping with template-generated sections (bad nesting 1)
+!! options
+parsoid={
+ "wrapSections": true
+}
+!! wikitext
+<div>
+a
+
+{{echo|
+=1=
+b
+}}
+
+c
+</div>
+!! html/parsoid
+<section data-mw-section-id="-1"></section><section data-mw-section-id="-2"><div data-parsoid='{"stx":"html"}'>
+<p>a</p>
+
+<span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"\n=1=\nb\n"}},"i":0}},"\n\nc\n"]}'>
+</span><section data-mw-section-id="-1" about="#mwt1"><h1 about="#mwt1" id="1">1</h1><span about="#mwt1">
+</span><p about="#mwt1">b
+</p><span about="#mwt1">
+
+</span><p about="#mwt1">c</p><span about="#mwt1">
+</span></section></div></section>
+!! end
+
+# Because of section-wrapping and template-wrapping interactions,
+# the scope of the template is expanded so that the template markup
+# is valid in the presence of <section> tags.
+# This exercises the s1 is ancestor of s2 scenario in the wrapSections code
+!! test
+Section wrapping with template-generated sections (bad nesting 2)
+!! options
+parsoid={
+ "wrapSections": true
+}
+!! wikitext
+=1=
+a
+
+{{echo|1=
+=2=
+b
+==2.1==
+c
+}}
+
+d
+
+=3=
+e
+!! html/parsoid
+<section data-mw-section-id="0"></section><section data-mw-section-id="1"><h1 id="1">1</h1>
+<p>a</p>
+
+</section><section data-mw-section-id="-1"><h1 about="#mwt1" typeof="mw:Transclusion" id="2" data-parsoid='{"dsr":[9,45,null,null],"pi":[[{"k":"1","named":true,"spc":["","","\n","\n"]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"=2=\nb\n==2.1==\nc"}},"i":0}},"\n\nd\n\n"]}'>2</h1><span about="#mwt1">
+</span><p about="#mwt1">b</p><span about="#mwt1">
+</span><section data-mw-section-id="-1" about="#mwt1"><h2 about="#mwt1" id="2.1">2.1</h2><span about="#mwt1">
+</span><p about="#mwt1">c</p><span about="#mwt1">
+
+</span><p about="#mwt1">d</p><span about="#mwt1">
+
+</span></section></section><section data-mw-section-id="4"><h1 id="3">3</h1>
+<p>e</p></section>
+!! end
+
+# Because of section-wrapping and template-wrapping interactions,
+# additional template wrappers are added to <section> tags
+# so that template wrapping semantics are valid whether section
+# tags are retained or stripped. But, the template scope can expand
+# greatly when accounting for section tags.
+# This exercises the s1 and s2 are in different subtrees scenario
+!! test
+Section wrapping with template-generated sections (bad nesting 3)
+!! options
+parsoid={
+ "wrapSections": true,
+ "modes": ["wt2html", "wt2wt"]
+}
+!! wikitext
+=1=
+a
+
+{{echo|1=
+==1.2==
+b
+=2=
+c
+}}
+
+d
+
+=3=
+e
+!! html/parsoid
+<section data-mw-section-id="0"></section><section data-mw-section-id="1" about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":["=1=\na\n\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"==1.2==\nb\n=2=\nc"}},"i":0}},"\n\nd\n\n"]}'><h1 id="1">1</h1>
+<p>a</p>
+
+<section data-mw-section-id="-1"><h2 about="#mwt1" typeof="mw:Transclusion" id="1.2" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"==1.2==\nb\n=2=\nc"}},"i":0}}]}'>1.2</h2><span about="#mwt1">
+</span><p about="#mwt1">b</p><span about="#mwt1">
+</span></section></section><section data-mw-section-id="-1" about="#mwt1"><h1 about="#mwt1" id="2">2</h1><span about="#mwt1">
+</span><p about="#mwt1">c</p>
+
+<p>d</p>
+</section><section data-mw-section-id="4" data-parsoid="{}"><h1 id="3">3</h1>
+<p>e</p></section>
+!! end
+
+!! test
+Section wrapping with uneditable lead section + div wrapping multiple sections
+!! options
+parsoid={
+ "wrapSections": true
+}
+!! wikitext
+foo
+
+<div style="border:1px solid red;">
+=1=
+a
+
+==1.1==
+b
+
+=2=
+c
+</div>
+
+=3=
+d
+
+==3.1==
+e
+!! html/parsoid
+<section data-mw-section-id="-1"><p>foo</p>
+
+</section><section data-mw-section-id="-2"><div style="border:1px solid red;">
+<section data-mw-section-id="1"><h1 id="1">1</h1>
+<p>a</p>
+
+<section data-mw-section-id="2"><h2 id="1.1">1.1</h2>
+<p>b</p>
+
+</section></section><section data-mw-section-id="-1"><h1 id="2">2</h1>
+<p>c</p>
+</section></div>
+
+</section><section data-mw-section-id="4"><h1 id="3">3</h1>
+<p>d</p>
+
+<section data-mw-section-id="5"><h2 id="3.1">3.1</h2>
+<p>e</p>
+</section></section>
+!! end
+
+!! test
+Section wrapping with editable lead section + div overlapping multiple sections
+!! options
+parsoid={
+ "wrapSections": true
+}
+!! wikitext
+foo
+
+=1=
+a
+<div style="border:1px solid red;">
+b
+
+==1.1==
+c
+
+=2=
+d
+</div>
+e
+
+=3=
+f
+
+==3.1==
+g
+!! html/parsoid
+<section data-mw-section-id="0"><p>foo</p>
+
+</section><section data-mw-section-id="-1"><h1 id="1">1</h1>
+<p>a</p>
+</section><section data-mw-section-id="-2"><div style="border:1px solid red;">
+<p>b</p>
+
+<section data-mw-section-id="2"><h2 id="1.1">1.1</h2>
+<p>c</p>
+
+</section><section data-mw-section-id="-1"><h1 id="2">2</h1>
+<p>d</p>
+</section></div>
+<p>e</p>
+
+</section><section data-mw-section-id="4"><h1 id="3">3</h1>
+<p>f</p>
+
+<section data-mw-section-id="5"><h2 id="3.1">3.1</h2>
+<p>g</p>
+</section></section>
+!! end
+
+!! test
+HTML header tags should not be wrapped in section tags
+!! options
+parsoid={
+ "wrapSections": true
+}
+!! wikitext
+foo
+
+<h1>a</h1>
+
+=b=
+
+<h1>c</h1>
+
+=d=
+!! html/parsoid
+<section data-mw-section-id="0"><p>foo</p>
+
+<h1 id="a" data-parsoid='{"stx":"html"}'>a</h1>
+
+</section><section data-mw-section-id="1"><h1 id="b">b</h1>
+
+<h1 id="c" data-parsoid='{"stx":"html"}'>c</h1>
+
+</section><section data-mw-section-id="2"><h1 id="d">d</h1></section>
+!! end
+
+!! test
+Lead section containing only whitespace and comments.
+!! options
+parsoid={
+ "wrapSections": true
+}
+!! wikitext
+
+<!-- this is a comment, presumably significant to editors -->
+=1=
+a
+
+=2=
+b
+!! html/parsoid
+<section data-mw-section-id="0" data-parsoid="{}">
+<!-- this is a comment, presumably significant to editors -->
+</section><section data-mw-section-id="1"><h1 id="1">1</h1>
+<p>a</p>
+
+</section><section data-mw-section-id="2"><h1 id="2">2</h1>
+<p>b</p></section>
+!! end
+
+!! test
+Pseudo-sections emitted by templates should have id -2
+!! options
+parsoid={
+ "wrapSections": true
+}
+!! wikitext
+foo
+{{echo|<div>
+==a==
+==b==
+</div>
+}}
+!! html/parsoid
+<section data-mw-section-id="-1"><p>foo</p>
+</section><section data-mw-section-id="-2"><div about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"stx":"html","pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;div>\n==a==\n==b==\n&lt;/div>\n"}},"i":0}}]}'>
+<section data-mw-section-id="-1"><h2 id="a">a</h2>
+</section><section data-mw-section-id="-1"><h2 id="b">b</h2>
+</section></div><span about="#mwt1">
+</span></section>
+!! end
+
+##########################################################################
+Tests demonstrating white-space insensitivity in input wikitext
+for wikitext headings, wikitext list items, and wikitext table captions,
+headings, and cells. HTML versions of the same should preserve whitespace.
+##########################################################################
+!! test
+Trim whitespace in wikitext headings, list items, table captions, headings, and cells
+!! wikitext
+__NOTOC__
+== <!--c1--> <!--c2--> Spaces <!--c3--> <!--c4--> ==
+== <!--c2--> <!--c2--> Tabs <!--c3--><!--c4--> ==
+* <!--c1--> <!--c2--> List item <!--c3--> <!--c4-->
+; <!--term to define--> term : <!--term's definition--> definition
+{|
+|+ <!--c1--> <!--c2--> Table Caption <!--c3--> <!--c4-->
+|-
+! <!--c1--> <!--c2--> Table Heading 1 <!--c3--> <!--c4--> !! Table Heading 2 <!--c5-->
+|-
+| <!--c1--> <!--c2--> Table Cell 1 <!--c3--> <!--c4--> || Table Cell 2 <!--c5-->
+|-
+| class="foo" || <!--c1--> <!--c2--> Table Cell 3 <!--c3--> <!--c4-->
+|-
+| <!--c1--> testing [[one|two]] <!--c2--> | <!--c3--> some content
+|}
+: {|
+ | <!--c1--> <!--c2--> Table Cell 1 <!--c3--> <!--c4--> || Table Cell 2 <!--c5-->
+ |} foo <!--c1-->
+!! html/php+tidy
+<h2><span class="mw-headline" id="Spaces">Spaces</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: Spaces">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="Tabs">Tabs</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Tabs">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<ul><li>List item</li></ul>
+<dl><dt>term&#160;</dt>
+<dd>definition</dd></dl>
+<table>
+<caption>Table Caption
+</caption>
+<tbody><tr>
+<th>Table Heading 1</th>
+<th>Table Heading 2
+</th></tr>
+<tr>
+<td>Table Cell 1</td>
+<td>Table Cell 2
+</td></tr>
+<tr>
+<td>class="foo"</td>
+<td>Table Cell 3
+</td></tr>
+<tr>
+<td>testing <a href="/index.php?title=One&amp;action=edit&amp;redlink=1" class="new" title="One (page does not exist)">two</a> | some content
+</td></tr></tbody></table>
+<dl><dd><table>
+<tbody><tr>
+<td>Table Cell 1</td>
+<td>Table Cell 2
+</td></tr></tbody></table> foo</dd></dl>
+!! end
+
+# Looks like <caption> is not accepted in HTML
+!! test
+Do not trim whitespace in HTML headings, list items, table captions, headings, and cells
+!! wikitext
+__NOTOC__
+<h2> <!--c1--> <!--c2--> Heading <!--c3--> <!--c4--> </h2>
+<ul><li> <!--c1--> <!--c2--> List item <!--c3--> <!--c4--> </li></ul>
+<table>
+<tr><th> <!--c1--> <!--c2--> Table Heading <!--c3--> <!--c4--> <th></tr>
+<tr><td> <!--c1--> <!--c2--> Table Cell <!--c3--> <!--c4--> <th></tr>
+</table>
+!! html/php+tidy
+<h2><span class="mw-headline" id="Heading"> Heading </span></h2>
+<ul><li> List item </li></ul>
+<table>
+<tbody><tr><th> Table Heading </th><th></th></tr>
+<tr><td> Table Cell </td><th></th></tr>
+</tbody></table>
+!! end
+
+!! test
+Do not trim whitespace in links and quotes
+!! wikitext
+foo '' <!--c1--> italic <!--c2--> '' and ''' <!--c3--> bold <!--c4--> '''
+[[Foo| some text ]]
+!! html/php+tidy
+<p>foo <i> italic </i> and <b> bold </b>
+<a href="/wiki/Foo" title="Foo"> some text </a>
+</p>
+!! end
+
+!! test
+Remove p tags surrounding a single element in a figcaption
+!! options
+parsoid=html2wt
+!! wikitext
+[[File:Foobar.jpg|right|200x200px|Caption]]
+!! html/parsoid
+<figure class="mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="23" width="200"/></a><figcaption><p>Caption</p></figcaption></figure>
+!! end
+
+!! test
+Selser preserves lack of newline before list and allows newline after the list
+!! options
+parsoid={
+ "modes": ["selser"],
+ "scrubWikitext": true,
+ "changes": [
+ [ "ul", "after", "<p>footer</p>" ]
+ ]
+}
+!! wikitext
+header
+*foo
+*bar
+!! wikitext/edited
+header
+*foo
+*bar
+
+footer
+!! end
+
+
+!! test
+Selser does not introduce newlines between unedited paragraph preceding the list
+!! options
+parsoid={
+ "modes": ["selser"],
+ "changes": [
+ [ "table tbody tr td p:last-child", "empty" ]
+ ]
+}
+!! wikitext
+{|
+|
+header
+*foo
+*bar
+footer
+|}
+!! wikitext/edited
+{|
+|
+header
+*foo
+*bar
+
+|}
+!! end
+
+!! test
+Selser does not introduce newlines between unedited paragraph following the list
+!! options
+parsoid={
+ "modes": ["selser"],
+ "changes": [
+ [ "table tbody tr td p:first-child", "empty" ]
+ ]
+}
+!! wikitext
+{|
+|
+header
+*foo
+*bar
+footer
+|}
+!! wikitext/edited
+{|
+|
+
+*foo
+*bar
+footer
+|}
+!! end
+
+!! test
+Remove a list item but do not insert newline above list
+!! options
+parsoid={
+ "modes": ["selser"],
+ "changes": [
+ [ "ul li:last-child", "remove" ]
+ ]
+}
+!! wikitext
+header
+*foo
+*bar
+footer
+!! wikitext/edited
+header
+*foo
+footer
+!! end
diff --git a/www/wiki/tests/parser/preprocess/All_system_messages.expected b/www/wiki/tests/parser/preprocess/All_system_messages.expected
new file mode 100644
index 00000000..286a1a4f
--- /dev/null
+++ b/www/wiki/tests/parser/preprocess/All_system_messages.expected
@@ -0,0 +1,5604 @@
+<root><template><title>int:57dbe26a</title></template>
+
+&lt;table border=1 width=100%&gt;&lt;tr&gt;&lt;td&gt;
+'''Name'''
+&lt;/td&gt;&lt;td&gt;
+'''Default text'''
+&lt;/td&gt;&lt;td&gt;
+'''Current text'''
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f9cca05b&amp;action=edit f9cca05b]&lt;br&gt;
+[[MediaWiki_talk:f9cca05b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+$1 moved to $2
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f9cca05b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ed065216&amp;action=edit ed065216]&lt;br&gt;
+[[MediaWiki_talk:ed065216|Talk]]
+&lt;/td&gt;&lt;td&gt;
+/* edit this file to customize the monobook skin for the entire site */
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ed065216</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6b21fb79&amp;action=edit 5780daf6]&lt;br&gt;
+[[MediaWiki_talk:6b21fb79|Talk]]
+&lt;/td&gt;&lt;td&gt;
+About
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6b21fb79</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:54f19e13&amp;action=edit 4bd9b804]&lt;br&gt;
+[[MediaWiki_talk:54f19e13|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary:About
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:54f19e13</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8e17cc1b&amp;action=edit 7be96c69]&lt;br&gt;
+[[MediaWiki_talk:8e17cc1b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+About Wiktionary
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8e17cc1b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4b7f0428&amp;action=edit 69f5ae1e]&lt;br&gt;
+[[MediaWiki_talk:4b7f0428|Talk]]
+&lt;/td&gt;&lt;td&gt;
++
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4b7f0428</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b18a7fba&amp;action=edit ba8c9426]&lt;br&gt;
+[[MediaWiki_talk:b18a7fba|Talk]]
+&lt;/td&gt;&lt;td&gt;
+n
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b18a7fba</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3cfd08b4&amp;action=edit 098256f5]&lt;br&gt;
+[[MediaWiki_talk:3cfd08b4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3cfd08b4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d00706c5&amp;action=edit 7638fc38]&lt;br&gt;
+[[MediaWiki_talk:d00706c5|Talk]]
+&lt;/td&gt;&lt;td&gt;
+a
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d00706c5</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7bbcdfc9&amp;action=edit 840afed8]&lt;br&gt;
+[[MediaWiki_talk:7bbcdfc9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+v
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7bbcdfc9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0750ed4b&amp;action=edit 9703e6d9]&lt;br&gt;
+[[MediaWiki_talk:0750ed4b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;amp;lt;accesskey-contributions&amp;amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0750ed4b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:602dda6f&amp;action=edit 7e13f963]&lt;br&gt;
+[[MediaWiki_talk:602dda6f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;amp;lt;accesskey-currentevents&amp;amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:602dda6f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a395260e&amp;action=edit be42f966]&lt;br&gt;
+[[MediaWiki_talk:a395260e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+d
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a395260e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f89faca3&amp;action=edit 89888a71]&lt;br&gt;
+[[MediaWiki_talk:f89faca3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+e
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f89faca3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bc7a3e78&amp;action=edit 7b2ee991]&lt;br&gt;
+[[MediaWiki_talk:bc7a3e78|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;amp;lt;accesskey-emailuser&amp;amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bc7a3e78</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9e9d3613&amp;action=edit fe788279]&lt;br&gt;
+[[MediaWiki_talk:9e9d3613|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;amp;lt;accesskey-help&amp;amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9e9d3613</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7ea0e322&amp;action=edit 4bb7a2e4]&lt;br&gt;
+[[MediaWiki_talk:7ea0e322|Talk]]
+&lt;/td&gt;&lt;td&gt;
+h
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7ea0e322</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4204d3db&amp;action=edit 725cb6bf]&lt;br&gt;
+[[MediaWiki_talk:4204d3db|Talk]]
+&lt;/td&gt;&lt;td&gt;
+o
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4204d3db</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2a92e37a&amp;action=edit a1de2049]&lt;br&gt;
+[[MediaWiki_talk:2a92e37a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+o
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2a92e37a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:68d388ec&amp;action=edit 0542623d]&lt;br&gt;
+[[MediaWiki_talk:68d388ec|Talk]]
+&lt;/td&gt;&lt;td&gt;
+z
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:68d388ec</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:18fe1121&amp;action=edit e3f25b72]&lt;br&gt;
+[[MediaWiki_talk:18fe1121|Talk]]
+&lt;/td&gt;&lt;td&gt;
+i
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:18fe1121</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6d15983f&amp;action=edit c9d212d3]&lt;br&gt;
+[[MediaWiki_talk:6d15983f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+m
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6d15983f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ecaba7f4&amp;action=edit ac57178f]&lt;br&gt;
+[[MediaWiki_talk:ecaba7f4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+y
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ecaba7f4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:711aec5d&amp;action=edit 6d3ae9a7]&lt;br&gt;
+[[MediaWiki_talk:711aec5d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+n
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:711aec5d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9905f56f&amp;action=edit 0a376cab]&lt;br&gt;
+[[MediaWiki_talk:9905f56f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;amp;lt;accesskey-portal&amp;amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9905f56f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9305eef3&amp;action=edit e72912be]&lt;br&gt;
+[[MediaWiki_talk:9305eef3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;amp;lt;accesskey-preferences&amp;amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9305eef3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:186ee8a4&amp;action=edit cef51de6]&lt;br&gt;
+[[MediaWiki_talk:186ee8a4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+p
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:186ee8a4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:676f28e9&amp;action=edit 0f10afb5]&lt;br&gt;
+[[MediaWiki_talk:676f28e9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+=
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:676f28e9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1a09f43e&amp;action=edit 46a4e82c]&lt;br&gt;
+[[MediaWiki_talk:1a09f43e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+x
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1a09f43e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1306607d&amp;action=edit 025b667f]&lt;br&gt;
+[[MediaWiki_talk:1306607d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+r
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1306607d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e14390c4&amp;action=edit 600e8a44]&lt;br&gt;
+[[MediaWiki_talk:e14390c4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+c
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e14390c4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:59b75a10&amp;action=edit 0fde75cd]&lt;br&gt;
+[[MediaWiki_talk:59b75a10|Talk]]
+&lt;/td&gt;&lt;td&gt;
+s
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:59b75a10</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0b6fd89e&amp;action=edit 5163ba5b]&lt;br&gt;
+[[MediaWiki_talk:0b6fd89e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+f
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0b6fd89e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ba9e0fc4&amp;action=edit f70dbcff]&lt;br&gt;
+[[MediaWiki_talk:ba9e0fc4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;amp;lt;accesskey-sitesupport&amp;amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ba9e0fc4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b932fee9&amp;action=edit 8949be8d]&lt;br&gt;
+[[MediaWiki_talk:b932fee9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;amp;lt;accesskey-specialpage&amp;amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b932fee9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1ac10275&amp;action=edit 59a5e487]&lt;br&gt;
+[[MediaWiki_talk:1ac10275|Talk]]
+&lt;/td&gt;&lt;td&gt;
+q
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1ac10275</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:116fd1b0&amp;action=edit a83f2193]&lt;br&gt;
+[[MediaWiki_talk:116fd1b0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+t
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:116fd1b0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ec06f1a7&amp;action=edit 5894e42e]&lt;br&gt;
+[[MediaWiki_talk:ec06f1a7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+d
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ec06f1a7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7f99a8c2&amp;action=edit 2a2a9d13]&lt;br&gt;
+[[MediaWiki_talk:7f99a8c2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+w
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7f99a8c2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:903549e8&amp;action=edit 3a1dcde8]&lt;br&gt;
+[[MediaWiki_talk:903549e8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+u
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:903549e8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8f696cc0&amp;action=edit be76a8c2]&lt;br&gt;
+[[MediaWiki_talk:8f696cc0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8f696cc0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:613ebbad&amp;action=edit e467bdec]&lt;br&gt;
+[[MediaWiki_talk:613ebbad|Talk]]
+&lt;/td&gt;&lt;td&gt;
+e
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:613ebbad</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f598b5d6&amp;action=edit 8bbdd8ad]&lt;br&gt;
+[[MediaWiki_talk:f598b5d6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+w
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f598b5d6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:59863979&amp;action=edit f8563593]&lt;br&gt;
+[[MediaWiki_talk:59863979|Talk]]
+&lt;/td&gt;&lt;td&gt;
+l
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:59863979</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:00143391&amp;action=edit 016415ff]&lt;br&gt;
+[[MediaWiki_talk:00143391|Talk]]
+&lt;/td&gt;&lt;td&gt;
+b
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:00143391</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d4dce921&amp;action=edit c90b0565]&lt;br&gt;
+[[MediaWiki_talk:d4dce921|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The Password for &amp;#39;$1&amp;#39; has been sent to $2.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d4dce921</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9e6cd678&amp;action=edit 05cb31f3]&lt;br&gt;
+[[MediaWiki_talk:9e6cd678|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Password sent.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9e6cd678</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:37186ec6&amp;action=edit 6703566b]&lt;br&gt;
+[[MediaWiki_talk:37186ec6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Action complete
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:37186ec6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2bacba53&amp;action=edit 9a954e94]&lt;br&gt;
+[[MediaWiki_talk:2bacba53|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Added to watchlist
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2bacba53</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b28893b8&amp;action=edit cb101aa3]&lt;br&gt;
+[[MediaWiki_talk:b28893b8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The page &amp;quot;$1&amp;quot; has been added to your &amp;#91;&amp;#91;Special:Watchlist&amp;#124;watchlist]].
+Future changes to this page and its associated Talk page will be listed there,
+and the page will appear &amp;#39;&amp;#39;&amp;#39;bolded&amp;#39;&amp;#39;&amp;#39; in the &amp;#91;&amp;#91;Special:Recentchanges&amp;#124;list of recent changes]] to
+make it easier to pick out.
+
+&amp;lt;p&amp;gt;If you want to remove the page from your watchlist later, click &amp;quot;Stop watching&amp;quot; in the sidebar.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b28893b8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:291bfe3c&amp;action=edit 788205f7]&lt;br&gt;
+[[MediaWiki_talk:291bfe3c|Talk]]
+&lt;/td&gt;&lt;td&gt;
++
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:291bfe3c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0d4d418a&amp;action=edit 69189a95]&lt;br&gt;
+[[MediaWiki_talk:0d4d418a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary:Administrators
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0d4d418a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1b4ceeda&amp;action=edit f61e4837]&lt;br&gt;
+[[MediaWiki_talk:1b4ceeda|Talk]]
+&lt;/td&gt;&lt;td&gt;
+I affirm that the copyright holder of this file
+agrees to license it under the terms of the $1.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1b4ceeda</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6a720856&amp;action=edit d87c4480]&lt;br&gt;
+[[MediaWiki_talk:6a720856|Talk]]
+&lt;/td&gt;&lt;td&gt;
+all
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6a720856</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f2fab435&amp;action=edit a6623c77]&lt;br&gt;
+[[MediaWiki_talk:f2fab435|Talk]]
+&lt;/td&gt;&lt;td&gt;
+All system messages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f2fab435</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e57c77b8&amp;action=edit 57dbe26a]&lt;br&gt;
+[[MediaWiki_talk:e57c77b8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This is a list of all system messages available in the MediaWiki: namespace.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e57c77b8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ff8db74d&amp;action=edit bf1dccf6]&lt;br&gt;
+[[MediaWiki_talk:ff8db74d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+All pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ff8db74d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:89c23e53&amp;action=edit 1ee05de8]&lt;br&gt;
+[[MediaWiki_talk:89c23e53|Talk]]
+&lt;/td&gt;&lt;td&gt;
+$1 to $2
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:89c23e53</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8550125b&amp;action=edit 0dc174ae]&lt;br&gt;
+[[MediaWiki_talk:8550125b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;font color=red&amp;gt;&amp;lt;b&amp;gt;User $1, you are already logged in!&amp;lt;/b&amp;gt;&amp;lt;/font&amp;gt;&amp;lt;br /&amp;gt;
+
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8550125b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3f7be8a8&amp;action=edit 3f1bd6a1]&lt;br&gt;
+[[MediaWiki_talk:3f7be8a8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Cannot rollback last edit of &amp;#91;&amp;#91;$1]]
+by &amp;#91;&amp;#91;User:$2&amp;#124;$2]] (&amp;#91;&amp;#91;User talk:$2&amp;#124;Talk]]); someone else has edited or rolled back the page already.
+
+Last edit was by &amp;#91;&amp;#91;User:$3&amp;#124;$3]] (&amp;#91;&amp;#91;User talk:$3&amp;#124;Talk]]).
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3f7be8a8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:49a4df39&amp;action=edit 4f70712f]&lt;br&gt;
+[[MediaWiki_talk:49a4df39|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Oldest pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:49a4df39</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a01e33f4&amp;action=edit cffa50a3]&lt;br&gt;
+[[MediaWiki_talk:a01e33f4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+and
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a01e33f4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:20cb482e&amp;action=edit 801db13e]&lt;br&gt;
+[[MediaWiki_talk:20cb482e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Talk for this IP
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:20cb482e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5bbc19f4&amp;action=edit 07575f81]&lt;br&gt;
+[[MediaWiki_talk:5bbc19f4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+----&amp;#39;&amp;#39;This is the discussion page for an anonymous user who has not created an account yet or who does not use it. We therefore have to use the numerical &amp;#91;&amp;#91;IP address]] to identify him/her. Such an IP address can be shared by several users. If you are an anonymous user and feel that irrelevant comments have been directed at you, please &amp;#91;&amp;#91;Special:Userlogin&amp;#124;create an account or log in]] to avoid future confusion with other anonymous users.&amp;#39;&amp;#39;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5bbc19f4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9bed5104&amp;action=edit 0a92fab3]&lt;br&gt;
+[[MediaWiki_talk:9bed5104|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Anonymous user(s) of Wiktionary
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9bed5104</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4360c2dc&amp;action=edit 565cecd7]&lt;br&gt;
+[[MediaWiki_talk:4360c2dc|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Content page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4360c2dc</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d3ee4a57&amp;action=edit ac8af25b]&lt;br&gt;
+[[MediaWiki_talk:d3ee4a57|Talk]]
+&lt;/td&gt;&lt;td&gt;
+A page of that name already exists, or the
+name you have chosen is not valid.
+Please choose another name.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d3ee4a57</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:494f1af2&amp;action=edit 01d643b6]&lt;br&gt;
+[[MediaWiki_talk:494f1af2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View content page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:494f1af2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dc93382e&amp;action=edit 4e529571]&lt;br&gt;
+[[MediaWiki_talk:dc93382e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+SQL query
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:dc93382e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d12f6023&amp;action=edit 47551563]&lt;br&gt;
+[[MediaWiki_talk:d12f6023|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Use the form below to make a direct query of the
+database.
+Use single quotes (&amp;#39;like this&amp;#39;) to delimit string literals.
+This can often add considerable load to the server, so please use
+this function sparingly.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d12f6023</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f36efd21&amp;action=edit d8f0b5e0]&lt;br&gt;
+[[MediaWiki_talk:f36efd21|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Autoblocked because you share an IP address with &amp;quot;$1&amp;quot;. Reason &amp;quot;$2&amp;quot;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f36efd21</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9503e2b1&amp;action=edit 100ce8a2]&lt;br&gt;
+[[MediaWiki_talk:9503e2b1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This action cannot be performed on this page.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9503e2b1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f146770f&amp;action=edit 5c50b102]&lt;br&gt;
+[[MediaWiki_talk:f146770f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Image name has been changed to &amp;quot;$1&amp;quot;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f146770f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a87ee981&amp;action=edit fe89c3de]&lt;br&gt;
+[[MediaWiki_talk:a87ee981|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;quot;.$1&amp;quot; is not a recommended image file format.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a87ee981</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0222775a&amp;action=edit c7623eeb]&lt;br&gt;
+[[MediaWiki_talk:0222775a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Invalid IP address
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0222775a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:feabd786&amp;action=edit 798bc46a]&lt;br&gt;
+[[MediaWiki_talk:feabd786|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Badly formed search query
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:feabd786</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7e82f04b&amp;action=edit e5493056]&lt;br&gt;
+[[MediaWiki_talk:7e82f04b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+We could not process your query.
+This is probably because you have attempted to search for a
+word fewer than three letters long, which is not yet supported.
+It could also be that you have mistyped the expression, for
+example &amp;quot;fish and and scales&amp;quot;.
+Please try another query.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7e82f04b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:36ad01d4&amp;action=edit c36e32c1]&lt;br&gt;
+[[MediaWiki_talk:36ad01d4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The passwords you entered do not match.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:36ad01d4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ab570b90&amp;action=edit 5c0f9f2b]&lt;br&gt;
+[[MediaWiki_talk:ab570b90|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Bad title
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ab570b90</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:73766845&amp;action=edit e9ac7510]&lt;br&gt;
+[[MediaWiki_talk:73766845|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The requested page title was invalid, empty, or
+an incorrectly linked inter-language or inter-wiki title.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:73766845</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ef0a17b1&amp;action=edit ec00742f]&lt;br&gt;
+[[MediaWiki_talk:ef0a17b1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(Main)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ef0a17b1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2fe89b37&amp;action=edit 0756a2f3]&lt;br&gt;
+[[MediaWiki_talk:2fe89b37|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Your user name or IP address has been blocked by $1.
+The reason given is this:&amp;lt;br /&amp;gt;&amp;#39;&amp;#39;$2&amp;#39;&amp;#39;&amp;lt;p&amp;gt;You may contact $1 or one of the other
+&amp;#91;&amp;#91;Wiktionary:Administrators&amp;#124;administrators]] to discuss the block.
+
+Note that you may not use the &amp;quot;email this user&amp;quot; feature unless you have a valid email address registered in your &amp;#91;&amp;#91;Special:Preferences&amp;#124;user preferences]].
+
+Your IP address is $3. Please include this address in any queries you make.
+
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2fe89b37</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4153985a&amp;action=edit ee09eebe]&lt;br&gt;
+[[MediaWiki_talk:4153985a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+User is blocked
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4153985a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f8225753&amp;action=edit 387c304e]&lt;br&gt;
+[[MediaWiki_talk:f8225753|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Block user
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f8225753</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:178b4021&amp;action=edit d19404ef]&lt;br&gt;
+[[MediaWiki_talk:178b4021|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Block succeeded
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:178b4021</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c9aa5295&amp;action=edit 8c464806]&lt;br&gt;
+[[MediaWiki_talk:c9aa5295|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;quot;$1&amp;quot; has been blocked.
+&amp;lt;br /&amp;gt;See &amp;#91;&amp;#91;Special:Ipblocklist&amp;#124;IP block list]] to review blocks.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c9aa5295</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d79d9fe6&amp;action=edit ec372bf2]&lt;br&gt;
+[[MediaWiki_talk:d79d9fe6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Use the form below to block write access
+from a specific IP address or username.
+This should be done only only to prevent vandalism, and in
+accordance with &amp;#91;&amp;#91;Wiktionary:Policy&amp;#124;policy]].
+Fill in a specific reason below (for example, citing particular
+pages that were vandalized).
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d79d9fe6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9a96cfdc&amp;action=edit 1c6c7aa2]&lt;br&gt;
+[[MediaWiki_talk:9a96cfdc|Talk]]
+&lt;/td&gt;&lt;td&gt;
+block
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9a96cfdc</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b81f5cad&amp;action=edit b821b758]&lt;br&gt;
+[[MediaWiki_talk:b81f5cad|Talk]]
+&lt;/td&gt;&lt;td&gt;
+$1, $2 blocked $3 (expires $4)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b81f5cad</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0871a19a&amp;action=edit 9be87d66]&lt;br&gt;
+[[MediaWiki_talk:0871a19a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+blocked &amp;quot;$1&amp;quot; with an expiry time of $2
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0871a19a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:31534d45&amp;action=edit 4bcce96c]&lt;br&gt;
+[[MediaWiki_talk:31534d45|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Block_log
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:31534d45</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f4872c71&amp;action=edit b9902a3c]&lt;br&gt;
+[[MediaWiki_talk:f4872c71|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This is a log of user blocking and unblocking actions. Automatically
+blocked IP addresses are not be listed. See the &amp;#91;&amp;#91;Special:Ipblocklist&amp;#124;IP block list]] for
+the list of currently operational bans and blocks.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f4872c71</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d8ae34f5&amp;action=edit e9a0daa2]&lt;br&gt;
+[[MediaWiki_talk:d8ae34f5|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Bold text
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d8ae34f5</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:58086558&amp;action=edit 02320399]&lt;br&gt;
+[[MediaWiki_talk:58086558|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Bold text
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:58086558</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1d546a7c&amp;action=edit 9bd576b3]&lt;br&gt;
+[[MediaWiki_talk:1d546a7c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Book sources
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1d546a7c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:809e6557&amp;action=edit 72f7a5ba]&lt;br&gt;
+[[MediaWiki_talk:809e6557|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Below is a list of links to other sites that
+sell new and used books, and may also have further information
+about books you are looking for.Wiktionary is not affiliated with any of these businesses, and
+this list should not be construed as an endorsement.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:809e6557</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:40925079&amp;action=edit fe673f57]&lt;br&gt;
+[[MediaWiki_talk:40925079|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Broken Redirects
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:40925079</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3953d564&amp;action=edit 283e89cc]&lt;br&gt;
+[[MediaWiki_talk:3953d564|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The following redirects link to a non-existing pages.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3953d564</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f12ee4ee&amp;action=edit 741bd9a7]&lt;br&gt;
+[[MediaWiki_talk:f12ee4ee|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Bug reports
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f12ee4ee</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1e9054cf&amp;action=edit 7cc56699]&lt;br&gt;
+[[MediaWiki_talk:1e9054cf|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary:Bug_reports
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1e9054cf</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cfea7660&amp;action=edit eaac0dcf]&lt;br&gt;
+[[MediaWiki_talk:cfea7660|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Bureaucrat_log
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cfea7660</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:04cf1ba3&amp;action=edit cc1544ab]&lt;br&gt;
+[[MediaWiki_talk:04cf1ba3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Rights for user &amp;quot;$1&amp;quot; set &amp;quot;$2&amp;quot;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:04cf1ba3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a0750047&amp;action=edit 5eb1e911]&lt;br&gt;
+[[MediaWiki_talk:a0750047|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The action you have requested can only be
+performed by sysops with &amp;quot;bureaucrat&amp;quot; status.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a0750047</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4837977b&amp;action=edit f523b504]&lt;br&gt;
+[[MediaWiki_talk:4837977b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Bureaucrat access required
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4837977b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:590988e2&amp;action=edit f06de085]&lt;br&gt;
+[[MediaWiki_talk:590988e2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+by date
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:590988e2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c08c3f0d&amp;action=edit e51a8413]&lt;br&gt;
+[[MediaWiki_talk:c08c3f0d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+by name
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c08c3f0d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ede279e9&amp;action=edit 43ba766a]&lt;br&gt;
+[[MediaWiki_talk:ede279e9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+by size
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ede279e9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e1ad9b35&amp;action=edit 075fc8df]&lt;br&gt;
+[[MediaWiki_talk:e1ad9b35|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The following is a cached copy of the requested page, and may not be up to date.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e1ad9b35</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:77dfd213&amp;action=edit 4fd0653c]&lt;br&gt;
+[[MediaWiki_talk:77dfd213|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Cancel
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:77dfd213</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:03442eec&amp;action=edit ee57c22e]&lt;br&gt;
+[[MediaWiki_talk:03442eec|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Could not delete the page or image specified. (It may have already been deleted by someone else.)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:03442eec</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:27b55ed3&amp;action=edit 4b739ac2]&lt;br&gt;
+[[MediaWiki_talk:27b55ed3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Cannot revert edit; last contributor is only author of this page.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:27b55ed3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6ccb6007&amp;action=edit 50b9e781]&lt;br&gt;
+[[MediaWiki_talk:6ccb6007|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Categories
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6ccb6007</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a3c686e7&amp;action=edit 5ccbf9c9]&lt;br&gt;
+[[MediaWiki_talk:a3c686e7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+category
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a3c686e7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f2ff5f46&amp;action=edit 7245f61e]&lt;br&gt;
+[[MediaWiki_talk:f2ff5f46|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Articles in category &amp;quot;$1&amp;quot;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f2ff5f46</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cc60b2e2&amp;action=edit 3dfd4581]&lt;br&gt;
+[[MediaWiki_talk:cc60b2e2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Change password
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cc60b2e2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8aa57de6&amp;action=edit 49a04ba4]&lt;br&gt;
+[[MediaWiki_talk:8aa57de6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+changes
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8aa57de6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cf723c59&amp;action=edit 4f1b1dbe]&lt;br&gt;
+[[MediaWiki_talk:cf723c59|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Columns
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cf723c59</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2a4f8ff8&amp;action=edit 99be507a]&lt;br&gt;
+[[MediaWiki_talk:2a4f8ff8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+ (comment)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2a4f8ff8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9833df65&amp;action=edit 978cce5f]&lt;br&gt;
+[[MediaWiki_talk:9833df65|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Compare selected versions
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9833df65</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:04a21221&amp;action=edit d0c4047c]&lt;br&gt;
+[[MediaWiki_talk:04a21221|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Confirm
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:04a21221</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b8e469fe&amp;action=edit bb4bf8de]&lt;br&gt;
+[[MediaWiki_talk:b8e469fe|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Yes, I really want to delete this.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b8e469fe</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7773ad82&amp;action=edit 16805d57]&lt;br&gt;
+[[MediaWiki_talk:7773ad82|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Confirm delete
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7773ad82</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:87358bf0&amp;action=edit 872e01c0]&lt;br&gt;
+[[MediaWiki_talk:87358bf0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You are about to permanently delete a page
+or image along with all of its history from the database.
+Please confirm that you intend to do this, that you understand the
+consequences, and that you are doing this in accordance with
+&amp;#91;&amp;#91;Wiktionary:Policy]].
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:87358bf0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b46f1463&amp;action=edit 96a48c11]&lt;br&gt;
+[[MediaWiki_talk:b46f1463|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Confirm protection
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b46f1463</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e382b883&amp;action=edit e76ab37d]&lt;br&gt;
+[[MediaWiki_talk:e382b883|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Do you really want to protect this page?
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e382b883</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:33be1711&amp;action=edit 306661e6]&lt;br&gt;
+[[MediaWiki_talk:33be1711|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Confirm unprotection
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:33be1711</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2d82e05f&amp;action=edit 4df1abe3]&lt;br&gt;
+[[MediaWiki_talk:2d82e05f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Do you really want to unprotect this page?
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2d82e05f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7f051e88&amp;action=edit 0858695c]&lt;br&gt;
+[[MediaWiki_talk:7f051e88|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Characters of context per line
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7f051e88</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7127b581&amp;action=edit e9d81e50]&lt;br&gt;
+[[MediaWiki_talk:7127b581|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Lines to show per hit
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7127b581</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9850ceab&amp;action=edit df5b918c]&lt;br&gt;
+[[MediaWiki_talk:9850ceab|Talk]]
+&lt;/td&gt;&lt;td&gt;
+contribs
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9850ceab</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b688a4d7&amp;action=edit ab48ec14]&lt;br&gt;
+[[MediaWiki_talk:b688a4d7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+For $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b688a4d7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:aa11023f&amp;action=edit 9d5b6e5e]&lt;br&gt;
+[[MediaWiki_talk:aa11023f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+User contributions
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:aa11023f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a420abf6&amp;action=edit 521307dd]&lt;br&gt;
+[[MediaWiki_talk:a420abf6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Content is available under $1.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a420abf6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fc8c1b42&amp;action=edit 5327fdcf]&lt;br&gt;
+[[MediaWiki_talk:fc8c1b42|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary:Copyrights
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:fc8c1b42</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:52a98e51&amp;action=edit f6652583]&lt;br&gt;
+[[MediaWiki_talk:52a98e51|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary copyright
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:52a98e51</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:25a1afd7&amp;action=edit 731cc8a6]&lt;br&gt;
+[[MediaWiki_talk:25a1afd7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Please note that all contributions to Wiktionary are
+considered to be released under the GNU Free Documentation License
+(see $1 for details).
+If you don&amp;#39;t want your writing to be edited mercilessly and redistributed
+at will, then don&amp;#39;t submit it here.&amp;lt;br /&amp;gt;
+You are also promising us that you wrote this yourself, or copied it from a
+public domain or similar free resource.
+&amp;lt;strong&amp;gt;DO NOT SUBMIT COPYRIGHTED WORK WITHOUT PERMISSION!&amp;lt;/strong&amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:25a1afd7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:465ca4b8&amp;action=edit e04b3a96]&lt;br&gt;
+[[MediaWiki_talk:465ca4b8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Couldn&amp;#39;t remove item &amp;#39;$1&amp;#39;...
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:465ca4b8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3724dfa6&amp;action=edit 19fd5658]&lt;br&gt;
+[[MediaWiki_talk:3724dfa6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Create new account
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3724dfa6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a4a1f0cb&amp;action=edit b10d6306]&lt;br&gt;
+[[MediaWiki_talk:a4a1f0cb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+by email
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a4a1f0cb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:07f81f3c&amp;action=edit dce81611]&lt;br&gt;
+[[MediaWiki_talk:07f81f3c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+cur
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:07f81f3c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:42b921f8&amp;action=edit 81c8f458]&lt;br&gt;
+[[MediaWiki_talk:42b921f8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Current events
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:42b921f8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:78892831&amp;action=edit 23418566]&lt;br&gt;
+[[MediaWiki_talk:78892831|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Current revision
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:78892831</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e4838ed0&amp;action=edit 66409316]&lt;br&gt;
+[[MediaWiki_talk:e4838ed0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Database error
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e4838ed0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:507be676&amp;action=edit 3b538bdf]&lt;br&gt;
+[[MediaWiki_talk:507be676|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Date format
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:507be676</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0bfa85bb&amp;action=edit 34c01d3c]&lt;br&gt;
+[[MediaWiki_talk:0bfa85bb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+A database query syntax error has occurred.
+This could be because of an illegal search query (see $5),
+or it may indicate a bug in the software.
+The last attempted database query was:
+&amp;lt;blockquote&amp;gt;&amp;lt;tt&amp;gt;$1&amp;lt;/tt&amp;gt;&amp;lt;/blockquote&amp;gt;
+from within function &amp;quot;&amp;lt;tt&amp;gt;$2&amp;lt;/tt&amp;gt;&amp;quot;.
+MySQL returned error &amp;quot;&amp;lt;tt&amp;gt;$3: $4&amp;lt;/tt&amp;gt;&amp;quot;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0bfa85bb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:39d82941&amp;action=edit f6e1bcbd]&lt;br&gt;
+[[MediaWiki_talk:39d82941|Talk]]
+&lt;/td&gt;&lt;td&gt;
+A database query syntax error has occurred.
+The last attempted database query was:
+&amp;quot;$1&amp;quot;
+from within function &amp;quot;$2&amp;quot;.
+MySQL returned error &amp;quot;$3: $4&amp;quot;.
+
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:39d82941</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ae14da43&amp;action=edit d4234aad]&lt;br&gt;
+[[MediaWiki_talk:ae14da43|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Dead-end pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ae14da43</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bd604d99&amp;action=edit 32faaeca]&lt;br&gt;
+[[MediaWiki_talk:bd604d99|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Debug
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bd604d99</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6f349a89&amp;action=edit f674a4a1]&lt;br&gt;
+[[MediaWiki_talk:6f349a89|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Search in these namespaces by default:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6f349a89</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:801b725b&amp;action=edit 93c2c32b]&lt;br&gt;
+[[MediaWiki_talk:801b725b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary e-mail
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:801b725b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f6fdbe48&amp;action=edit 9485989f]&lt;br&gt;
+[[MediaWiki_talk:f6fdbe48|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Delete
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f6fdbe48</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:728e102f&amp;action=edit 070ad01c]&lt;br&gt;
+[[MediaWiki_talk:728e102f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Reason for deletion
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:728e102f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:784f094b&amp;action=edit abb03e0b]&lt;br&gt;
+[[MediaWiki_talk:784f094b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+deleted &amp;quot;$1&amp;quot;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:784f094b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b40dc398&amp;action=edit 81545b85]&lt;br&gt;
+[[MediaWiki_talk:b40dc398|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;quot;$1&amp;quot; has been deleted.
+See $2 for a record of recent deletions.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b40dc398</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:64a8bf46&amp;action=edit 6f4d03ee]&lt;br&gt;
+[[MediaWiki_talk:64a8bf46|Talk]]
+&lt;/td&gt;&lt;td&gt;
+del
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:64a8bf46</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3316ac85&amp;action=edit c423c282]&lt;br&gt;
+[[MediaWiki_talk:3316ac85|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Delete page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3316ac85</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a2496a13&amp;action=edit a3173eab]&lt;br&gt;
+[[MediaWiki_talk:a2496a13|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(Deleting &amp;quot;$1&amp;quot;)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a2496a13</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9d817726&amp;action=edit b93901eb]&lt;br&gt;
+[[MediaWiki_talk:9d817726|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Delete this page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9d817726</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:49653e1b&amp;action=edit 58f0b919]&lt;br&gt;
+[[MediaWiki_talk:49653e1b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+deletion log
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:49653e1b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d73442e4&amp;action=edit c5ee36a7]&lt;br&gt;
+[[MediaWiki_talk:d73442e4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Deletion_log
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d73442e4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2349bb58&amp;action=edit 6526e633]&lt;br&gt;
+[[MediaWiki_talk:2349bb58|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Below is a list of the most recent deletions.
+All times shown are server time (UTC).
+&amp;lt;ul&amp;gt;
+&amp;lt;/ul&amp;gt;
+
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2349bb58</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:381bedfc&amp;action=edit 015693e1]&lt;br&gt;
+[[MediaWiki_talk:381bedfc|Talk]]
+&lt;/td&gt;&lt;td&gt;
+For developer use only
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:381bedfc</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:52713d3d&amp;action=edit 9c6f4cd5]&lt;br&gt;
+[[MediaWiki_talk:52713d3d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The action you have requested can only be
+performed by users with &amp;quot;developer&amp;quot; status.
+See $1.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:52713d3d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6cca6111&amp;action=edit 59afe6b0]&lt;br&gt;
+[[MediaWiki_talk:6cca6111|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Developer access required
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6cca6111</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d23a1c9f&amp;action=edit 75a0ee1b]&lt;br&gt;
+[[MediaWiki_talk:d23a1c9f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+diff
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d23a1c9f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b16f1f66&amp;action=edit 48d53c6e]&lt;br&gt;
+[[MediaWiki_talk:b16f1f66|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(Difference between revisions)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b16f1f66</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3c1c1596&amp;action=edit 657b3530]&lt;br&gt;
+[[MediaWiki_talk:3c1c1596|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary:General_disclaimer
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3c1c1596</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2cbbc29e&amp;action=edit 774706d2]&lt;br&gt;
+[[MediaWiki_talk:2cbbc29e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Disclaimers
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2cbbc29e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ac57c500&amp;action=edit c06b805b]&lt;br&gt;
+[[MediaWiki_talk:ac57c500|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Double Redirects
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ac57c500</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ba6ba737&amp;action=edit 49eadc1d]&lt;br&gt;
+[[MediaWiki_talk:ba6ba737|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;b&amp;gt;Attention:&amp;lt;/b&amp;gt; This list may contain false positives. That usually means there is additional text with links below the first #REDIRECT.&amp;lt;br /&amp;gt;
+Each row contains links to the first and second redirect, as well as the first line of the second redirect text, usually giving the &amp;quot;real&amp;quot; target page, which the first redirect should point to.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ba6ba737</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5301648d&amp;action=edit 9ead47a8]&lt;br&gt;
+[[MediaWiki_talk:5301648d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Edit
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5301648d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:299ca80d&amp;action=edit 74940f72]&lt;br&gt;
+[[MediaWiki_talk:299ca80d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The edit comment was: &amp;quot;&amp;lt;i&amp;gt;$1&amp;lt;/i&amp;gt;&amp;quot;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:299ca80d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1af657fb&amp;action=edit 3b56e95b]&lt;br&gt;
+[[MediaWiki_talk:1af657fb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Edit conflict: $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1af657fb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8b46c7e0&amp;action=edit 10d395b9]&lt;br&gt;
+[[MediaWiki_talk:8b46c7e0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Edit the current version of this page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8b46c7e0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:47f4389a&amp;action=edit a1524e37]&lt;br&gt;
+[[MediaWiki_talk:47f4389a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Editing help
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:47f4389a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:56815513&amp;action=edit 7072deb5]&lt;br&gt;
+[[MediaWiki_talk:56815513|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Help:Editing
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:56815513</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3b9ba1a2&amp;action=edit f34946be]&lt;br&gt;
+[[MediaWiki_talk:3b9ba1a2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Editing $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3b9ba1a2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5e6bd9f3&amp;action=edit bd55ff2e]&lt;br&gt;
+[[MediaWiki_talk:5e6bd9f3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;strong&amp;gt;WARNING: You are editing an out-of-date
+revision of this page.
+If you save it, any changes made since this revision will be lost.&amp;lt;/strong&amp;gt;
+
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5e6bd9f3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:51905619&amp;action=edit 965b4116]&lt;br&gt;
+[[MediaWiki_talk:51905619|Talk]]
+&lt;/td&gt;&lt;td&gt;
+edit
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:51905619</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2138f524&amp;action=edit 8bc15909]&lt;br&gt;
+[[MediaWiki_talk:2138f524|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Edit this page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2138f524</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:96c0b2c5&amp;action=edit 4a27c333]&lt;br&gt;
+[[MediaWiki_talk:96c0b2c5|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Disable e-mail from other users
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:96c0b2c5</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8a275a27&amp;action=edit e4573eb4]&lt;br&gt;
+[[MediaWiki_talk:8a275a27|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Fields marked with a star (*) are optional. Storing an email address enables people to contact you through the website without you having to reveal your
+email address to them, and it can be used to send you a new password if you forget it.&amp;lt;br /&amp;gt;&amp;lt;br /&amp;gt;Your real name, if you choose to provide it, will be used for giving you attribution for your work.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8a275a27</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:34a573ac&amp;action=edit b29c18eb]&lt;br&gt;
+[[MediaWiki_talk:34a573ac|Talk]]
+&lt;/td&gt;&lt;td&gt;
+From
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:34a573ac</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1653aeb3&amp;action=edit 8d8c0edf]&lt;br&gt;
+[[MediaWiki_talk:1653aeb3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Message
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1653aeb3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ac5cfb8e&amp;action=edit a9b033ab]&lt;br&gt;
+[[MediaWiki_talk:ac5cfb8e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+E-mail user
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ac5cfb8e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e3fc4fe2&amp;action=edit eb6bf1bb]&lt;br&gt;
+[[MediaWiki_talk:e3fc4fe2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+If this user has entered a valid e-mail address in
+his or her user preferences, the form below will send a single message.
+The e-mail address you entered in your user preferences will appear
+as the &amp;quot;From&amp;quot; address of the mail, so the recipient will be able
+to reply.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e3fc4fe2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:145da553&amp;action=edit 05072b51]&lt;br&gt;
+[[MediaWiki_talk:145da553|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Send
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:145da553</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:833a22dc&amp;action=edit b1d3f3e4]&lt;br&gt;
+[[MediaWiki_talk:833a22dc|Talk]]
+&lt;/td&gt;&lt;td&gt;
+E-mail sent
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:833a22dc</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e3935836&amp;action=edit 2effa7aa]&lt;br&gt;
+[[MediaWiki_talk:e3935836|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Your e-mail message has been sent.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e3935836</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c8dba338&amp;action=edit 275a0d68]&lt;br&gt;
+[[MediaWiki_talk:c8dba338|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Subject
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c8dba338</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c35740f4&amp;action=edit 88b0fd50]&lt;br&gt;
+[[MediaWiki_talk:c35740f4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+To
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c35740f4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ac5cfb8e&amp;action=edit a9b033ab]&lt;br&gt;
+[[MediaWiki_talk:ac5cfb8e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+E-mail this user
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ac5cfb8e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6b0a234b&amp;action=edit 698a308a]&lt;br&gt;
+[[MediaWiki_talk:6b0a234b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Enter a reason for the lock, including an estimate
+of when the lock will be released
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6b0a234b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7f2f6a15&amp;action=edit 11f9578d]&lt;br&gt;
+[[MediaWiki_talk:7f2f6a15|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Error
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7f2f6a15</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:53ee1378&amp;action=edit aa2d1eba]&lt;br&gt;
+[[MediaWiki_talk:53ee1378|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Error
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:53ee1378</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8aedeece&amp;action=edit a1c634a7]&lt;br&gt;
+[[MediaWiki_talk:8aedeece|Talk]]
+&lt;/td&gt;&lt;td&gt;
+content before blanking was:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8aedeece</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:42abde88&amp;action=edit eba6d64f]&lt;br&gt;
+[[MediaWiki_talk:42abde88|Talk]]
+&lt;/td&gt;&lt;td&gt;
+page was empty
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:42abde88</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dd028a5c&amp;action=edit fe80d230]&lt;br&gt;
+[[MediaWiki_talk:dd028a5c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+content was:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:dd028a5c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b7845dfb&amp;action=edit 1a7999fa]&lt;br&gt;
+[[MediaWiki_talk:b7845dfb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Someone else has changed this page since you
+started editing it.
+The upper text area contains the page text as it currently exists.
+Your changes are shown in the lower text area.
+You will have to merge your changes into the existing text.
+&amp;lt;b&amp;gt;Only&amp;lt;/b&amp;gt; the text in the upper text area will be saved when you
+press &amp;quot;Save page&amp;quot;.
+&amp;lt;p&amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b7845dfb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f3e4fadb&amp;action=edit 51713409]&lt;br&gt;
+[[MediaWiki_talk:f3e4fadb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Export pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f3e4fadb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b68aeee1&amp;action=edit bf364325]&lt;br&gt;
+[[MediaWiki_talk:b68aeee1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Include only the current revision, not the full history
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b68aeee1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7e884d79&amp;action=edit eddfb839]&lt;br&gt;
+[[MediaWiki_talk:7e884d79|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You can export the text and editing history of a particular
+page or set of pages wrapped in some XML; this can then be imported into another
+wiki running MediaWiki software, transformed, or just kept for your private
+amusement.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7e884d79</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a34d80e8&amp;action=edit 8f95a409]&lt;br&gt;
+[[MediaWiki_talk:a34d80e8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+http&amp;#58;//www.example.com link title
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a34d80e8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:117651f0&amp;action=edit 481904c0]&lt;br&gt;
+[[MediaWiki_talk:117651f0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+External link (remember http&amp;#58;// prefix)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:117651f0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f11042a4&amp;action=edit e75bc045]&lt;br&gt;
+[[MediaWiki_talk:f11042a4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+FAQ
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f11042a4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2e891e10&amp;action=edit 5b772c96]&lt;br&gt;
+[[MediaWiki_talk:2e891e10|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary:FAQ
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2e891e10</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:86edbc13&amp;action=edit 62f8af98]&lt;br&gt;
+[[MediaWiki_talk:86edbc13|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Feed:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:86edbc13</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2727dd90&amp;action=edit 6c916412]&lt;br&gt;
+[[MediaWiki_talk:2727dd90|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Could not copy file &amp;quot;$1&amp;quot; to &amp;quot;$2&amp;quot;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2727dd90</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e60a2c42&amp;action=edit d393dbbc]&lt;br&gt;
+[[MediaWiki_talk:e60a2c42|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Could not delete file &amp;quot;$1&amp;quot;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e60a2c42</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d7b25eeb&amp;action=edit 6dace2d5]&lt;br&gt;
+[[MediaWiki_talk:d7b25eeb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Summary
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d7b25eeb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a3cbb98d&amp;action=edit 08deae8d]&lt;br&gt;
+[[MediaWiki_talk:a3cbb98d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Filename
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a3cbb98d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c8f6c94d&amp;action=edit 35c4ded6]&lt;br&gt;
+[[MediaWiki_talk:c8f6c94d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Could not find file &amp;quot;$1&amp;quot;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c8f6c94d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b9b56972&amp;action=edit 6d195b75]&lt;br&gt;
+[[MediaWiki_talk:b9b56972|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Could not rename file &amp;quot;$1&amp;quot; to &amp;quot;$2&amp;quot;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b9b56972</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0a6a1eb6&amp;action=edit 1ffce53a]&lt;br&gt;
+[[MediaWiki_talk:0a6a1eb6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Source
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0a6a1eb6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0f0e70a0&amp;action=edit 040e2ba8]&lt;br&gt;
+[[MediaWiki_talk:0f0e70a0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Copyright status
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0f0e70a0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:45eaa53f&amp;action=edit 79423d38]&lt;br&gt;
+[[MediaWiki_talk:45eaa53f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+File &amp;quot;$1&amp;quot; uploaded successfully.
+Please follow this link: $2 to the description page and fill
+in information about the file, such as where it came from, when it was
+created and by whom, and anything else you may know about it.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:45eaa53f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a303ff06&amp;action=edit 416cf9e4]&lt;br&gt;
+[[MediaWiki_talk:a303ff06|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Error: could not submit form
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a303ff06</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dc08787f&amp;action=edit ada66d8e]&lt;br&gt;
+[[MediaWiki_talk:dc08787f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+From Wiktionary
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:dc08787f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c4c83db8&amp;action=edit f8d783cd]&lt;br&gt;
+[[MediaWiki_talk:c4c83db8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+fetching image list
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c4c83db8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2e0b45f2&amp;action=edit 1ec558a6]&lt;br&gt;
+[[MediaWiki_talk:2e0b45f2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Go
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2e0b45f2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:749351b4&amp;action=edit 49a50bdf]&lt;br&gt;
+[[MediaWiki_talk:749351b4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+
+&amp;lt;!-- SiteSearch Google --&amp;gt;
+&amp;lt;FORM method=GET action=&amp;quot;http&amp;#58;//www.google.com/search&amp;quot;&amp;gt;
+&amp;lt;TABLE bgcolor=&amp;quot;#FFFFFF&amp;quot;&amp;gt;&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;
+&amp;lt;A HREF=&amp;quot;http&amp;#58;//www.google.com/&amp;quot;&amp;gt;
+&amp;lt;IMG SRC=&amp;quot;http&amp;#58;//www.google.com/logos/Logo_40wht.gif&amp;quot;
+border=&amp;quot;0&amp;quot; ALT=&amp;quot;Google&amp;quot;&amp;gt;&amp;lt;/A&amp;gt;
+&amp;lt;/td&amp;gt;
+&amp;lt;td&amp;gt;
+&amp;lt;INPUT TYPE=text name=q size=31 maxlength=255 value=&amp;quot;$1&amp;quot;&amp;gt;
+&amp;lt;INPUT type=submit name=btnG VALUE=&amp;quot;Google Search&amp;quot;&amp;gt;
+&amp;lt;font size=-1&amp;gt;
+&amp;lt;input type=hidden name=domains value=&amp;quot;http&amp;#58;//tl.wiktionary.org&amp;quot;&amp;gt;&amp;lt;br /&amp;gt;&amp;lt;input type=radio name=sitesearch value=&amp;quot;&amp;quot;&amp;gt; WWW &amp;lt;input type=radio name=sitesearch value=&amp;quot;http&amp;#58;//tl.wiktionary.org&amp;quot; checked&amp;gt; http&amp;#58;//tl.wiktionary.org &amp;lt;br /&amp;gt;
+&amp;lt;input type=&amp;#39;hidden&amp;#39; name=&amp;#39;ie&amp;#39; value=&amp;#39;$2&amp;#39;&amp;gt;
+&amp;lt;input type=&amp;#39;hidden&amp;#39; name=&amp;#39;oe&amp;#39; value=&amp;#39;$2&amp;#39;&amp;gt;
+&amp;lt;/font&amp;gt;
+&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&amp;lt;/TABLE&amp;gt;
+&amp;lt;/FORM&amp;gt;
+&amp;lt;!-- SiteSearch Google --&amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:749351b4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:489b474b&amp;action=edit 8da95a41]&lt;br&gt;
+[[MediaWiki_talk:489b474b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Fill in from browser
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:489b474b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:237cf168&amp;action=edit 7f401fbb]&lt;br&gt;
+[[MediaWiki_talk:237cf168|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Headline text
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:237cf168</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:91b3bc72&amp;action=edit c4eef2f5]&lt;br&gt;
+[[MediaWiki_talk:91b3bc72|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Level 2 headline
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:91b3bc72</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c47ae153&amp;action=edit 92005ecf]&lt;br&gt;
+[[MediaWiki_talk:c47ae153|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Help
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c47ae153</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:56203224&amp;action=edit 9ca36083]&lt;br&gt;
+[[MediaWiki_talk:56203224|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Help:Contents
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:56203224</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:34d8b60f&amp;action=edit 93c8c96b]&lt;br&gt;
+[[MediaWiki_talk:34d8b60f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+hide
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:34d8b60f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9aa39fe3&amp;action=edit 1cc77a14]&lt;br&gt;
+[[MediaWiki_talk:9aa39fe3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+hide
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9aa39fe3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4d9810c0&amp;action=edit 56714843]&lt;br&gt;
+[[MediaWiki_talk:4d9810c0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+hist
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4d9810c0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f37ab91a&amp;action=edit 4e7121e9]&lt;br&gt;
+[[MediaWiki_talk:f37ab91a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Diff selection: mark the radio boxes of the versions to compare and hit enter or the button at the bottom.&amp;lt;br/&amp;gt;
+Legend: (cur) = difference with current version,
+(last) = difference with preceding version, M = minor edit.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f37ab91a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:90ccd649&amp;action=edit 66f79d8a]&lt;br&gt;
+[[MediaWiki_talk:90ccd649|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Page history
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:90ccd649</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:15a13ace&amp;action=edit a937e036]&lt;br&gt;
+[[MediaWiki_talk:15a13ace|Talk]]
+&lt;/td&gt;&lt;td&gt;
+History
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:15a13ace</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e02c3587&amp;action=edit 6079f80a]&lt;br&gt;
+[[MediaWiki_talk:e02c3587|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Warning: The page you are about to delete has a history:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e02c3587</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5741ad8f&amp;action=edit 48849a80]&lt;br&gt;
+[[MediaWiki_talk:5741ad8f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Horizontal line (use sparingly)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5741ad8f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7e18602a&amp;action=edit d874ec59]&lt;br&gt;
+[[MediaWiki_talk:7e18602a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Ignore warning and save file anyway.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7e18602a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5bf1efaa&amp;action=edit a98182df]&lt;br&gt;
+[[MediaWiki_talk:5bf1efaa|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Show all images with names matching
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5bf1efaa</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8e7a51d5&amp;action=edit f8288ad8]&lt;br&gt;
+[[MediaWiki_talk:8e7a51d5|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Search
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8e7a51d5</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:133971b3&amp;action=edit be19a728]&lt;br&gt;
+[[MediaWiki_talk:133971b3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Example.jpg
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:133971b3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2a73e0c3&amp;action=edit d103e97d]&lt;br&gt;
+[[MediaWiki_talk:2a73e0c3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Embedded image
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2a73e0c3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:353c260c&amp;action=edit 3414ac48]&lt;br&gt;
+[[MediaWiki_talk:353c260c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Image links
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:353c260c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:affc6aca&amp;action=edit 4c06ba77]&lt;br&gt;
+[[MediaWiki_talk:affc6aca|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Image list
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:affc6aca</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ade85019&amp;action=edit 2e8294bd]&lt;br&gt;
+[[MediaWiki_talk:ade85019|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Below is a list of $1 images sorted $2.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ade85019</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7277ee94&amp;action=edit a152014b]&lt;br&gt;
+[[MediaWiki_talk:7277ee94|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View image page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7277ee94</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c783375c&amp;action=edit b1d4cc4c]&lt;br&gt;
+[[MediaWiki_talk:c783375c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Revert to earlier version was successful.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c783375c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c6f52751&amp;action=edit 6656e4f4]&lt;br&gt;
+[[MediaWiki_talk:c6f52751|Talk]]
+&lt;/td&gt;&lt;td&gt;
+del
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c6f52751</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3c281ed6&amp;action=edit a1adca28]&lt;br&gt;
+[[MediaWiki_talk:3c281ed6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+desc
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3c281ed6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:186e5ca1&amp;action=edit de786597]&lt;br&gt;
+[[MediaWiki_talk:186e5ca1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Legend: (cur) = this is the current image, (del) = delete
+this old version, (rev) = revert to this old version.
+&amp;lt;br /&amp;gt;&amp;lt;i&amp;gt;Click on date to see image uploaded on that date&amp;lt;/i&amp;gt;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:186e5ca1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8d78c0d7&amp;action=edit d9305ede]&lt;br&gt;
+[[MediaWiki_talk:8d78c0d7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Image history
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8d78c0d7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:86260b9f&amp;action=edit a46e05c1]&lt;br&gt;
+[[MediaWiki_talk:86260b9f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Legend: (desc) = show/edit image description.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:86260b9f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d6fbc9d2&amp;action=edit 62fdfbd5]&lt;br&gt;
+[[MediaWiki_talk:d6fbc9d2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Import pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d6fbc9d2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bac6ed75&amp;action=edit 85d2877a]&lt;br&gt;
+[[MediaWiki_talk:bac6ed75|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Import failed: $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bac6ed75</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f4dee51e&amp;action=edit f74f664b]&lt;br&gt;
+[[MediaWiki_talk:f4dee51e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Conflicting history revision exists (may have imported this page before)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f4dee51e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1d461354&amp;action=edit ff881471]&lt;br&gt;
+[[MediaWiki_talk:1d461354|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Empty or no text
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1d461354</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5b910f21&amp;action=edit e2781bd1]&lt;br&gt;
+[[MediaWiki_talk:5b910f21|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Import succeeded!
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5b910f21</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3d58d609&amp;action=edit 965243c5]&lt;br&gt;
+[[MediaWiki_talk:3d58d609|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Please export the file from the source wiki using the Special:Export utility, save it to your disk and upload it here.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3d58d609</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:31be67da&amp;action=edit 176bd169]&lt;br&gt;
+[[MediaWiki_talk:31be67da|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Click a button to get an example text
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:31be67da</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2854823a&amp;action=edit 6de0a6d1]&lt;br&gt;
+[[MediaWiki_talk:2854823a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Please enter the text you want to be formatted.\n It will be shown in the infobox for copy and pasting.\nExample:\n$1\nwill become:\n$2
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2854823a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:103c360a&amp;action=edit e90e9e1c]&lt;br&gt;
+[[MediaWiki_talk:103c360a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Internal error
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:103c360a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:148008a6&amp;action=edit 919b03e5]&lt;br&gt;
+[[MediaWiki_talk:148008a6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Interlanguage links
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:148008a6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:07586557&amp;action=edit 5c2ff182]&lt;br&gt;
+[[MediaWiki_talk:07586557|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Invalid IP range.
+
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:07586557</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:22f9ff50&amp;action=edit 1f99aaad]&lt;br&gt;
+[[MediaWiki_talk:22f9ff50|Talk]]
+&lt;/td&gt;&lt;td&gt;
+IP Address/username
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:22f9ff50</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f8994552&amp;action=edit 91b35c2d]&lt;br&gt;
+[[MediaWiki_talk:f8994552|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Expiry time invalid.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f8994552</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2147c662&amp;action=edit 524cfd7e]&lt;br&gt;
+[[MediaWiki_talk:2147c662|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Expiry
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2147c662</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9cb7e6ee&amp;action=edit 503153e9]&lt;br&gt;
+[[MediaWiki_talk:9cb7e6ee|Talk]]
+&lt;/td&gt;&lt;td&gt;
+List of blocked IP addresses and usernames
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9cb7e6ee</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6774dfa8&amp;action=edit 1ecdad25]&lt;br&gt;
+[[MediaWiki_talk:6774dfa8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Reason
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6774dfa8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:62218118&amp;action=edit e29a20a2]&lt;br&gt;
+[[MediaWiki_talk:62218118|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Block this user
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:62218118</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7863d305&amp;action=edit 73ecf10c]&lt;br&gt;
+[[MediaWiki_talk:7863d305|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Unblock this address
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7863d305</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ba95215c&amp;action=edit 5fc7f411]&lt;br&gt;
+[[MediaWiki_talk:ba95215c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;quot;$1&amp;quot; unblocked
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ba95215c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8e89a827&amp;action=edit 9aa403ad]&lt;br&gt;
+[[MediaWiki_talk:8e89a827|Talk]]
+&lt;/td&gt;&lt;td&gt;
+ISBN
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8e89a827</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5b017ff1&amp;action=edit abdf987b]&lt;br&gt;
+[[MediaWiki_talk:5b017ff1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+redirect page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5b017ff1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:208e76ed&amp;action=edit 95f02073]&lt;br&gt;
+[[MediaWiki_talk:208e76ed|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Italic text
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:208e76ed</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7e5211e9&amp;action=edit 3f1c7185]&lt;br&gt;
+[[MediaWiki_talk:7e5211e9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Italic text
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7e5211e9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bcce0a8a&amp;action=edit 0e29818a]&lt;br&gt;
+[[MediaWiki_talk:bcce0a8a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Problem with item &amp;#39;$1&amp;#39;, invalid name...
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bcce0a8a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:996c231b&amp;action=edit 5dd7fd8c]&lt;br&gt;
+[[MediaWiki_talk:996c231b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+It is recommended that images not exceed 100k in size.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:996c231b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d1c69a85&amp;action=edit 213ed3ea]&lt;br&gt;
+[[MediaWiki_talk:d1c69a85|Talk]]
+&lt;/td&gt;&lt;td&gt;
+last
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d1c69a85</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:26d03483&amp;action=edit 1d0be5cf]&lt;br&gt;
+[[MediaWiki_talk:26d03483|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This page was last modified $1.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:26d03483</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d8b6a1ce&amp;action=edit b4c7424e]&lt;br&gt;
+[[MediaWiki_talk:d8b6a1ce|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This page was last modified $1 by $2.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d8b6a1ce</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b5fb75c3&amp;action=edit 7aab91e5]&lt;br&gt;
+[[MediaWiki_talk:b5fb75c3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Line $1:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b5fb75c3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a1a27fd2&amp;action=edit 4c4ec68a]&lt;br&gt;
+[[MediaWiki_talk:a1a27fd2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Link title
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a1a27fd2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:28f8c928&amp;action=edit e0ee37d8]&lt;br&gt;
+[[MediaWiki_talk:28f8c928|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Internal link
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:28f8c928</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:003058f7&amp;action=edit c692b683]&lt;br&gt;
+[[MediaWiki_talk:003058f7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(List of links)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:003058f7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6508e12f&amp;action=edit ce30384f]&lt;br&gt;
+[[MediaWiki_talk:6508e12f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The following pages link to here:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6508e12f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f6dbd59d&amp;action=edit 50b839a3]&lt;br&gt;
+[[MediaWiki_talk:f6dbd59d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The following pages link to this image:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f6dbd59d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:eb68781a&amp;action=edit 743236e7]&lt;br&gt;
+[[MediaWiki_talk:eb68781a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+/^(&amp;#91;a-z]+)(.*)$/sD
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:eb68781a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a8ca6811&amp;action=edit 069b38c0]&lt;br&gt;
+[[MediaWiki_talk:a8ca6811|Talk]]
+&lt;/td&gt;&lt;td&gt;
+list
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a8ca6811</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9bf82beb&amp;action=edit aabbb062]&lt;br&gt;
+[[MediaWiki_talk:9bf82beb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+User list
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9bf82beb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:61a6ed55&amp;action=edit 1b4ae4f9]&lt;br&gt;
+[[MediaWiki_talk:61a6ed55|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Loading page history
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:61a6ed55</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bf19b1de&amp;action=edit b6bb9fa5]&lt;br&gt;
+[[MediaWiki_talk:bf19b1de|Talk]]
+&lt;/td&gt;&lt;td&gt;
+loading revision for diff
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bf19b1de</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:43678846&amp;action=edit f25bccd7]&lt;br&gt;
+[[MediaWiki_talk:43678846|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Local time display
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:43678846</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:31dcaa22&amp;action=edit 62c50181]&lt;br&gt;
+[[MediaWiki_talk:31dcaa22|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Lock database
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:31dcaa22</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9dc82fa2&amp;action=edit 5199ac8e]&lt;br&gt;
+[[MediaWiki_talk:9dc82fa2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Yes, I really want to lock the database.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9dc82fa2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fef93b9b&amp;action=edit 4f29ae0a]&lt;br&gt;
+[[MediaWiki_talk:fef93b9b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Lock database
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:fef93b9b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b4abc4bb&amp;action=edit e73c06d7]&lt;br&gt;
+[[MediaWiki_talk:b4abc4bb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Database lock succeeded
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b4abc4bb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b6fcfec5&amp;action=edit 88c6fb22]&lt;br&gt;
+[[MediaWiki_talk:b6fcfec5|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The database has been locked.
+&amp;lt;br /&amp;gt;Remember to remove the lock after your maintenance is complete.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b6fcfec5</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:459bf648&amp;action=edit 070ff9ae]&lt;br&gt;
+[[MediaWiki_talk:459bf648|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Locking the database will suspend the ability of all
+users to edit pages, change their preferences, edit their watchlists, and
+other things requiring changes in the database.
+Please confirm that this is what you intend to do, and that you will
+unlock the database when your maintenance is done.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:459bf648</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2727a733&amp;action=edit 8a890d0a]&lt;br&gt;
+[[MediaWiki_talk:2727a733|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You did not check the confirmation box.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2727a733</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4e5a2893&amp;action=edit 2736fab2]&lt;br&gt;
+[[MediaWiki_talk:4e5a2893|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Log in
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4e5a2893</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fcfc7549&amp;action=edit e6f9a4e2]&lt;br&gt;
+[[MediaWiki_talk:fcfc7549|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Login error
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:fcfc7549</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4113f724&amp;action=edit 36f843a7]&lt;br&gt;
+[[MediaWiki_talk:4113f724|Talk]]
+&lt;/td&gt;&lt;td&gt;
+User login
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4113f724</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7a6963a6&amp;action=edit d23ee6a8]&lt;br&gt;
+[[MediaWiki_talk:7a6963a6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;b&amp;gt;There has been a problem with your login.&amp;lt;/b&amp;gt;&amp;lt;br /&amp;gt;Try again!
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7a6963a6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bbf56890&amp;action=edit 221d44a4]&lt;br&gt;
+[[MediaWiki_talk:bbf56890|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You must have cookies enabled to log in to Wiktionary.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bbf56890</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:75749962&amp;action=edit ee8446ea]&lt;br&gt;
+[[MediaWiki_talk:75749962|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You must &amp;#91;&amp;#91;special:Userlogin&amp;#124;login]] to view other pages.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:75749962</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c779400b&amp;action=edit a90049e8]&lt;br&gt;
+[[MediaWiki_talk:c779400b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Login Required
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c779400b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:753694e0&amp;action=edit a5607b10]&lt;br&gt;
+[[MediaWiki_talk:753694e0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You are now logged in to Wiktionary as &amp;quot;$1&amp;quot;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:753694e0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:73eb6767&amp;action=edit 5c2a05be]&lt;br&gt;
+[[MediaWiki_talk:73eb6767|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Login successful
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:73eb6767</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e43d612e&amp;action=edit 55525e1b]&lt;br&gt;
+[[MediaWiki_talk:e43d612e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Log out
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e43d612e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a8455b1c&amp;action=edit 50310460]&lt;br&gt;
+[[MediaWiki_talk:a8455b1c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You are now logged out.
+You can continue to use Wiktionary anonymously, or you can log in
+again as the same or as a different user. Note that some pages may
+continue to be displayed as if you were still logged in, until you clear
+your browser cache
+
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a8455b1c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cd48f4e7&amp;action=edit 8f9db4e5]&lt;br&gt;
+[[MediaWiki_talk:cd48f4e7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+User logout
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cd48f4e7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:916f5569&amp;action=edit 92ab2259]&lt;br&gt;
+[[MediaWiki_talk:916f5569|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Orphaned pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:916f5569</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9cdfa115&amp;action=edit 38996948]&lt;br&gt;
+[[MediaWiki_talk:9cdfa115|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Long pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9cdfa115</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b91ee293&amp;action=edit 09b5b0a2]&lt;br&gt;
+[[MediaWiki_talk:b91ee293|Talk]]
+&lt;/td&gt;&lt;td&gt;
+WARNING: This page is $1 kilobytes long; some
+browsers may have problems editing pages approaching or longer than 32kb.
+Please consider breaking the page into smaller sections.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b91ee293</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1ca3c8a2&amp;action=edit 2b82fce3]&lt;br&gt;
+[[MediaWiki_talk:1ca3c8a2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Error sending mail: $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1ca3c8a2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:97681e3e&amp;action=edit 669d145f]&lt;br&gt;
+[[MediaWiki_talk:97681e3e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Mail me a new password
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:97681e3e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8646515d&amp;action=edit 874a6660]&lt;br&gt;
+[[MediaWiki_talk:8646515d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+No send address
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8646515d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f8116e36&amp;action=edit ce0442ed]&lt;br&gt;
+[[MediaWiki_talk:f8116e36|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You must be &amp;lt;a href=&amp;quot;{{localurl:Special:Userlogin&amp;quot;&amp;gt;logged in&amp;lt;/a&amp;gt;
+and have a valid e-mail address in your &amp;lt;a href=&amp;quot;/wiki/Special:Preferences&amp;quot;&amp;gt;preferences&amp;lt;/a&amp;gt;
+to send e-mail to other users.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f8116e36</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:95989ab3&amp;action=edit 6ad3db9a]&lt;br&gt;
+[[MediaWiki_talk:95989ab3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Main Page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:95989ab3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:216e0fe3&amp;action=edit 19d499cf]&lt;br&gt;
+[[MediaWiki_talk:216e0fe3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Please see &amp;#91;http&amp;#58;//meta.wikipedia.org/wiki/MediaWiki_i18n documentation on customizing the interface]
+and the &amp;#91;http&amp;#58;//meta.wikipedia.org/wiki/MediaWiki_User%27s_Guide User&amp;#39;s Guide] for usage and configuration help.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:216e0fe3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:29c07aac&amp;action=edit 30186460]&lt;br&gt;
+[[MediaWiki_talk:29c07aac|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiki software successfully installed.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:29c07aac</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:94de303b&amp;action=edit 5b30e2c5]&lt;br&gt;
+[[MediaWiki_talk:94de303b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Maintenance page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:94de303b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b98df751&amp;action=edit aa734abd]&lt;br&gt;
+[[MediaWiki_talk:b98df751|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Back to Maintenance Page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b98df751</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5e830e7c&amp;action=edit ff589b21]&lt;br&gt;
+[[MediaWiki_talk:5e830e7c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This page includes several handy tools for everyday maintenance. Some of these functions tend to stress the database, so please do not hit reload after every item you fixed ;-)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5e830e7c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:147d840b&amp;action=edit 192a7baa]&lt;br&gt;
+[[MediaWiki_talk:147d840b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Make a user into a sysop
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:147d840b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3e1272dd&amp;action=edit c857a847]&lt;br&gt;
+[[MediaWiki_talk:3e1272dd|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;b&amp;gt;User &amp;quot;$1&amp;quot; could not be made into a sysop. (Did you enter the name correctly?)&amp;lt;/b&amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3e1272dd</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f942827d&amp;action=edit 4ae2de91]&lt;br&gt;
+[[MediaWiki_talk:f942827d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Name of the user:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f942827d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8933e97e&amp;action=edit 1138d88d]&lt;br&gt;
+[[MediaWiki_talk:8933e97e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;b&amp;gt;User &amp;quot;$1&amp;quot; is now a sysop&amp;lt;/b&amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8933e97e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ffde53f7&amp;action=edit 51a3d81a]&lt;br&gt;
+[[MediaWiki_talk:ffde53f7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Make this user into a sysop
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ffde53f7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6135d20c&amp;action=edit 9014f0fd]&lt;br&gt;
+[[MediaWiki_talk:6135d20c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This form is used by bureaucrats to turn ordinary users into administrators.
+Type the name of the user in the box and press the button to make the user an administrator
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6135d20c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:40537c23&amp;action=edit 9d7a92cc]&lt;br&gt;
+[[MediaWiki_talk:40537c23|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Make a user into a sysop
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:40537c23</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b00f5f1f&amp;action=edit f2f4e13e]&lt;br&gt;
+[[MediaWiki_talk:b00f5f1f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The query &amp;quot;$1&amp;quot; matched $2 page titles
+and the text of $3 pages.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b00f5f1f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3edf0df4&amp;action=edit 7a488390]&lt;br&gt;
+[[MediaWiki_talk:3edf0df4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Rendering math
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3edf0df4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:78c6cb06&amp;action=edit d9b8688c]&lt;br&gt;
+[[MediaWiki_talk:78c6cb06|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Can&amp;#39;t write to or create math output directory
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:78c6cb06</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f83fe947&amp;action=edit be21263f]&lt;br&gt;
+[[MediaWiki_talk:f83fe947|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Can&amp;#39;t write to or create math temp directory
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f83fe947</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f8cf40ba&amp;action=edit 53e1c013]&lt;br&gt;
+[[MediaWiki_talk:f8cf40ba|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Failed to parse
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f8cf40ba</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7b3e958f&amp;action=edit 7082c48f]&lt;br&gt;
+[[MediaWiki_talk:7b3e958f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+PNG conversion failed; check for correct installation of latex, dvips, gs, and convert
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7b3e958f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d6a158de&amp;action=edit 41e6fe2b]&lt;br&gt;
+[[MediaWiki_talk:d6a158de|Talk]]
+&lt;/td&gt;&lt;td&gt;
+lexing error
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d6a158de</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8109168a&amp;action=edit 20ec4685]&lt;br&gt;
+[[MediaWiki_talk:8109168a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Missing texvc executable; please see math/README to configure.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8109168a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:41b65279&amp;action=edit 3e8b5972]&lt;br&gt;
+[[MediaWiki_talk:41b65279|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Insert formula here
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:41b65279</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5cbab860&amp;action=edit d5667f6b]&lt;br&gt;
+[[MediaWiki_talk:5cbab860|Talk]]
+&lt;/td&gt;&lt;td&gt;
+syntax error
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5cbab860</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7e756feb&amp;action=edit 0baadf18]&lt;br&gt;
+[[MediaWiki_talk:7e756feb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Mathematical formula (LaTeX)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7e756feb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fb4d261d&amp;action=edit 5e0c970a]&lt;br&gt;
+[[MediaWiki_talk:fb4d261d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+unknown error
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:fb4d261d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:26558f91&amp;action=edit a0577d1d]&lt;br&gt;
+[[MediaWiki_talk:26558f91|Talk]]
+&lt;/td&gt;&lt;td&gt;
+unknown function
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:26558f91</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:63e94059&amp;action=edit 704093ed]&lt;br&gt;
+[[MediaWiki_talk:63e94059|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Example.mp3
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:63e94059</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8e4baaa8&amp;action=edit 77fbb90b]&lt;br&gt;
+[[MediaWiki_talk:8e4baaa8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Media file link
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8e4baaa8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cca18055&amp;action=edit 61350cd2]&lt;br&gt;
+[[MediaWiki_talk:cca18055|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Image names must be at least three letters.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cca18055</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7f8c4ff3&amp;action=edit 3dd77123]&lt;br&gt;
+[[MediaWiki_talk:7f8c4ff3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This is a minor edit
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7f8c4ff3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ce5828a8&amp;action=edit 3c37ba2f]&lt;br&gt;
+[[MediaWiki_talk:ce5828a8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+M
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ce5828a8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1e3a3f5e&amp;action=edit abf0b01a]&lt;br&gt;
+[[MediaWiki_talk:1e3a3f5e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Pages with misspellings
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1e3a3f5e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:18c50601&amp;action=edit 4841b1be]&lt;br&gt;
+[[MediaWiki_talk:18c50601|Talk]]
+&lt;/td&gt;&lt;td&gt;
+List of common misspellings
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:18c50601</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ff661e66&amp;action=edit 20eeb250]&lt;br&gt;
+[[MediaWiki_talk:ff661e66|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The following pages contain a common misspelling, which are listed on $1. The correct spelling might be given (like this).
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ff661e66</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:77dd649d&amp;action=edit 28d8d2f3]&lt;br&gt;
+[[MediaWiki_talk:77dd649d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The database did not find the text of a page
+that it should have found, named &amp;quot;$1&amp;quot;.
+
+&amp;lt;p&amp;gt;This is usually caused by following an outdated diff or history link to a
+page that has been deleted.
+
+&amp;lt;p&amp;gt;If this is not the case, you may have found a bug in the software.
+Please report this to an administrator, making note of the URL.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:77dd649d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:43bf0acd&amp;action=edit d6472ac8]&lt;br&gt;
+[[MediaWiki_talk:43bf0acd|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;b&amp;gt;Missing image&amp;lt;/b&amp;gt;&amp;lt;br /&amp;gt;&amp;lt;i&amp;gt;$1&amp;lt;/i&amp;gt;
+
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:43bf0acd</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:75c0518a&amp;action=edit f433e9c8]&lt;br&gt;
+[[MediaWiki_talk:75c0518a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Missing Language Links
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:75c0518a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5ef61b91&amp;action=edit a4a9fdcd]&lt;br&gt;
+[[MediaWiki_talk:5ef61b91|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Find missing language links for
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5ef61b91</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f5affad8&amp;action=edit e46ff038]&lt;br&gt;
+[[MediaWiki_talk:f5affad8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+These pages do &amp;lt;i&amp;gt;not&amp;lt;/i&amp;gt; link to their counterpart in $1. Redirects and subpages are &amp;lt;i&amp;gt;not&amp;lt;/i&amp;gt; shown.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f5affad8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:22e2c957&amp;action=edit b43c02b9]&lt;br&gt;
+[[MediaWiki_talk:22e2c957|Talk]]
+&lt;/td&gt;&lt;td&gt;
+More...
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:22e2c957</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:76cdb950&amp;action=edit 379d6ce9]&lt;br&gt;
+[[MediaWiki_talk:76cdb950|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Move
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:76cdb950</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:31d22872&amp;action=edit d55a3c2a]&lt;br&gt;
+[[MediaWiki_talk:31d22872|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Move page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:31d22872</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fb280ed2&amp;action=edit 0bd0c880]&lt;br&gt;
+[[MediaWiki_talk:fb280ed2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+moved to
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:fb280ed2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8ddc20a0&amp;action=edit 7c041d6e]&lt;br&gt;
+[[MediaWiki_talk:8ddc20a0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Not logged in
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8ddc20a0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:75985d0e&amp;action=edit e479574b]&lt;br&gt;
+[[MediaWiki_talk:75985d0e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You must be a registered user and &amp;lt;a href=&amp;quot;/wiki/Special:Userlogin&amp;quot;&amp;gt;logged in&amp;lt;/a&amp;gt;
+to move a page.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:75985d0e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:addffb42&amp;action=edit 0f05ab2b]&lt;br&gt;
+[[MediaWiki_talk:addffb42|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Move page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:addffb42</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6f9e8dfc&amp;action=edit 0311d79b]&lt;br&gt;
+[[MediaWiki_talk:6f9e8dfc|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Move page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6f9e8dfc</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:993d5ce8&amp;action=edit 53ab3d1c]&lt;br&gt;
+[[MediaWiki_talk:993d5ce8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The associated talk page, if any, will be automatically moved along with it &amp;#39;&amp;#39;&amp;#39;unless:&amp;#39;&amp;#39;&amp;#39;
+*You are moving the page across namespaces,
+*A non-empty talk page already exists under the new name, or
+*You uncheck the box below.
+
+In those cases, you will have to move or merge the page manually if desired.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:993d5ce8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ce6bc0ee&amp;action=edit a363312c]&lt;br&gt;
+[[MediaWiki_talk:ce6bc0ee|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Using the form below will rename a page, moving all
+of its history to the new name.
+The old title will become a redirect page to the new title.
+Links to the old page title will not be changed; be sure to
+&amp;#91;&amp;#91;Special:Maintenance&amp;#124;check]] for double or broken redirects.
+You are responsible for making sure that links continue to
+point where they are supposed to go.
+
+Note that the page will &amp;#39;&amp;#39;&amp;#39;not&amp;#39;&amp;#39;&amp;#39; be moved if there is already
+a page at the new title, unless it is empty or a redirect and has no
+past edit history. This means that you can rename a page back to where
+it was just renamed from if you make a mistake, and you cannot overwrite
+an existing page.
+
+&amp;lt;b&amp;gt;WARNING!&amp;lt;/b&amp;gt;
+This can be a drastic and unexpected change for a popular page;
+please be sure you understand the consequences of this before
+proceeding.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ce6bc0ee</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e0a05db0&amp;action=edit 7bd87d2d]&lt;br&gt;
+[[MediaWiki_talk:e0a05db0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Move &amp;quot;talk&amp;quot; page as well, if applicable.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e0a05db0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:689ff1e7&amp;action=edit 2119d3ee]&lt;br&gt;
+[[MediaWiki_talk:689ff1e7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Move this page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:689ff1e7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0dc37cdb&amp;action=edit 12b6caf0]&lt;br&gt;
+[[MediaWiki_talk:0dc37cdb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+My contributions
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0dc37cdb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:51a7215d&amp;action=edit 5d558678]&lt;br&gt;
+[[MediaWiki_talk:51a7215d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+My page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:51a7215d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fbe8f485&amp;action=edit 49886539]&lt;br&gt;
+[[MediaWiki_talk:fbe8f485|Talk]]
+&lt;/td&gt;&lt;td&gt;
+My talk
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:fbe8f485</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cf03cf2e&amp;action=edit ad831792]&lt;br&gt;
+[[MediaWiki_talk:cf03cf2e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Navigation
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cf03cf2e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b5b13ae8&amp;action=edit e75caf8a]&lt;br&gt;
+[[MediaWiki_talk:b5b13ae8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+$1 bytes
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b5b13ae8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bee99a5f&amp;action=edit 3d7d513a]&lt;br&gt;
+[[MediaWiki_talk:bee99a5f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+$1 changes
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bee99a5f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:654df301&amp;action=edit 06b1c460]&lt;br&gt;
+[[MediaWiki_talk:654df301|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(New)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:654df301</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f1622d18&amp;action=edit b90d5eb0]&lt;br&gt;
+[[MediaWiki_talk:f1622d18|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You&amp;#39;ve followed a link to a page that doesn&amp;#39;t exist yet.
+To create the page, start typing in the box below
+(see the &amp;#91;&amp;#91;Wiktionary:Help&amp;#124;help page]] for more info).
+If you are here by mistake, just click your browser&amp;#39;s &amp;#39;&amp;#39;&amp;#39;back&amp;#39;&amp;#39;&amp;#39; button.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f1622d18</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:780ce01b&amp;action=edit 0b08523d]&lt;br&gt;
+[[MediaWiki_talk:780ce01b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You have $1.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:780ce01b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e09d8ffe&amp;action=edit 1f028736]&lt;br&gt;
+[[MediaWiki_talk:e09d8ffe|Talk]]
+&lt;/td&gt;&lt;td&gt;
+new messages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e09d8ffe</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ce656abe&amp;action=edit d68c7e3c]&lt;br&gt;
+[[MediaWiki_talk:ce656abe|Talk]]
+&lt;/td&gt;&lt;td&gt;
+New page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ce656abe</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b886426f&amp;action=edit d081a481]&lt;br&gt;
+[[MediaWiki_talk:b886426f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+N
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b886426f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2adf1ae7&amp;action=edit eeadf049]&lt;br&gt;
+[[MediaWiki_talk:2adf1ae7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+New pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2adf1ae7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:393f8bca&amp;action=edit f2c57870]&lt;br&gt;
+[[MediaWiki_talk:393f8bca|Talk]]
+&lt;/td&gt;&lt;td&gt;
+New password
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:393f8bca</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fa56bbd9&amp;action=edit a104cc01]&lt;br&gt;
+[[MediaWiki_talk:fa56bbd9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+To new title
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:fa56bbd9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2a57c83c&amp;action=edit 41af2ba5]&lt;br&gt;
+[[MediaWiki_talk:2a57c83c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+ (new users only)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2a57c83c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bc981983&amp;action=edit edee9402]&lt;br&gt;
+[[MediaWiki_talk:bc981983|Talk]]
+&lt;/td&gt;&lt;td&gt;
+next
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bc981983</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5e067f51&amp;action=edit e0bd4ddb]&lt;br&gt;
+[[MediaWiki_talk:5e067f51|Talk]]
+&lt;/td&gt;&lt;td&gt;
+next $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5e067f51</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:61c11c45&amp;action=edit 2b45e9af]&lt;br&gt;
+[[MediaWiki_talk:61c11c45|Talk]]
+&lt;/td&gt;&lt;td&gt;
+$1 links
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:61c11c45</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e307257f&amp;action=edit f6f5e28d]&lt;br&gt;
+[[MediaWiki_talk:e307257f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You must affirm that your upload does not violate
+any copyrights.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e307257f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:335462de&amp;action=edit 2658d031]&lt;br&gt;
+[[MediaWiki_talk:335462de|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(There is currently no text in this page)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:335462de</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:46716843&amp;action=edit 68326cbc]&lt;br&gt;
+[[MediaWiki_talk:46716843|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You must supply a reason for the block.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:46716843</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8fa787f6&amp;action=edit 5d122d51]&lt;br&gt;
+[[MediaWiki_talk:8fa787f6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Sorry! The wiki is experiencing some technical difficulties, and cannot contact the database server.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8fa787f6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f60d1a6d&amp;action=edit b88f305b]&lt;br&gt;
+[[MediaWiki_talk:f60d1a6d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+No changes were found matching these criteria.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f60d1a6d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9d931b8c&amp;action=edit de736886]&lt;br&gt;
+[[MediaWiki_talk:9d931b8c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary uses cookies to log in users. You have cookies disabled. Please enable them and try again.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9d931b8c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e4a19fc8&amp;action=edit 71c8d192]&lt;br&gt;
+[[MediaWiki_talk:e4a19fc8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The user account was created, but you are not logged in. Wiktionary uses cookies to log in users. You have cookies disabled. Please enable them, then log in with your new username and password.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e4a19fc8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6fbb6d3a&amp;action=edit cc61a719]&lt;br&gt;
+[[MediaWiki_talk:6fbb6d3a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Creative Commons RDF metadata disabled for this server.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6fbb6d3a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e0dd32fc&amp;action=edit 5ed4cf16]&lt;br&gt;
+[[MediaWiki_talk:e0dd32fc|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Could not select database $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e0dd32fc</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:067ee3e9&amp;action=edit 3a58322b]&lt;br&gt;
+[[MediaWiki_talk:067ee3e9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Dublin Core RDF metadata disabled for this server.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:067ee3e9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:325a917f&amp;action=edit 4c8d93d2]&lt;br&gt;
+[[MediaWiki_talk:325a917f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+There is no e-mail address recorded for user &amp;quot;$1&amp;quot;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:325a917f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:deb172c1&amp;action=edit f8bace82]&lt;br&gt;
+[[MediaWiki_talk:deb172c1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This user has not specified a valid e-mail address,
+or has chosen not to receive e-mail from other users.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:deb172c1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6bd33d89&amp;action=edit a158d61f]&lt;br&gt;
+[[MediaWiki_talk:6bd33d89|Talk]]
+&lt;/td&gt;&lt;td&gt;
+No e-mail address
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6bd33d89</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e68327b0&amp;action=edit 36552107]&lt;br&gt;
+[[MediaWiki_talk:e68327b0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+No page with this exact title exists, trying full text search.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e68327b0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:90c1625a&amp;action=edit 8d231ce4]&lt;br&gt;
+[[MediaWiki_talk:90c1625a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+There is no edit history for this page.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:90c1625a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:13e13fa2&amp;action=edit e63b6d19]&lt;br&gt;
+[[MediaWiki_talk:13e13fa2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+No pages link to here.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:13e13fa2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5c3c99a8&amp;action=edit 1e827a30]&lt;br&gt;
+[[MediaWiki_talk:5c3c99a8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+There are no pages that link to this image.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5c3c99a8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d9ff75a4&amp;action=edit e21bfc14]&lt;br&gt;
+[[MediaWiki_talk:d9ff75a4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You have not specified a valid user name.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d9ff75a4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f7d27b0c&amp;action=edit 5db654d1]&lt;br&gt;
+[[MediaWiki_talk:f7d27b0c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;strong&amp;gt;Note&amp;lt;/strong&amp;gt;: unsuccessful searches are
+often caused by searching for common words like &amp;quot;have&amp;quot; and &amp;quot;from&amp;quot;,
+which are not indexed, or by specifying more than one search term (only pages
+containing all of the search terms will appear in the result).
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f7d27b0c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d5f565dd&amp;action=edit aaaac807]&lt;br&gt;
+[[MediaWiki_talk:d5f565dd|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You have requested a special page that is not
+recognized by the wiki.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d5f565dd</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:247c2db2&amp;action=edit 273b8154]&lt;br&gt;
+[[MediaWiki_talk:247c2db2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+No such action
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:247c2db2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0e2f696c&amp;action=edit e8773306]&lt;br&gt;
+[[MediaWiki_talk:0e2f696c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The action specified by the URL is not
+recognized by the wiki
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0e2f696c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bd0d7ac6&amp;action=edit b98c7c10]&lt;br&gt;
+[[MediaWiki_talk:bd0d7ac6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+No such special page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bd0d7ac6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:22012b0a&amp;action=edit f542883d]&lt;br&gt;
+[[MediaWiki_talk:22012b0a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+There is no user by the name &amp;quot;$1&amp;quot;.
+Check your spelling, or use the form below to create a new user account.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:22012b0a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:982342c7&amp;action=edit f4909824]&lt;br&gt;
+[[MediaWiki_talk:982342c7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The wiki server can&amp;#39;t provide data in a format your client can read.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:982342c7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:272cfb97&amp;action=edit cdb5d3a9]&lt;br&gt;
+[[MediaWiki_talk:272cfb97|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Not a content page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:272cfb97</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8ccaecd6&amp;action=edit 42534913]&lt;br&gt;
+[[MediaWiki_talk:8ccaecd6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You have not specified a target page or user
+to perform this function on.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8ccaecd6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4532ec15&amp;action=edit dff62a20]&lt;br&gt;
+[[MediaWiki_talk:4532ec15|Talk]]
+&lt;/td&gt;&lt;td&gt;
+No target
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4532ec15</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2c924e30&amp;action=edit c51048b7]&lt;br&gt;
+[[MediaWiki_talk:2c924e30|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;strong&amp;gt;Note:&amp;lt;/strong&amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2c924e30</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:51c2043b&amp;action=edit 879701e9]&lt;br&gt;
+[[MediaWiki_talk:51c2043b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+No page text matches
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:51c2043b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6f3befe0&amp;action=edit 5a56ca1b]&lt;br&gt;
+[[MediaWiki_talk:6f3befe0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+No page title matches
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6f3befe0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:219a05e4&amp;action=edit 02bcadd3]&lt;br&gt;
+[[MediaWiki_talk:219a05e4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Not logged in
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:219a05e4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:28b54fd2&amp;action=edit ba736b7f]&lt;br&gt;
+[[MediaWiki_talk:28b54fd2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You have no items on your watchlist.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:28b54fd2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a78319d8&amp;action=edit 2398990d]&lt;br&gt;
+[[MediaWiki_talk:a78319d8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Insert non-formatted text here
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a78319d8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:20d39be1&amp;action=edit cf8602ad]&lt;br&gt;
+[[MediaWiki_talk:20d39be1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Ignore wiki formatting
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:20d39be1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dee84866&amp;action=edit 7a6336e0]&lt;br&gt;
+[[MediaWiki_talk:dee84866|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Category
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:dee84866</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a3e0d95e&amp;action=edit 16b32116]&lt;br&gt;
+[[MediaWiki_talk:a3e0d95e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Help
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a3e0d95e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:911dff1f&amp;action=edit 081e450a]&lt;br&gt;
+[[MediaWiki_talk:911dff1f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Image
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:911dff1f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:931f9736&amp;action=edit 5b9c503a]&lt;br&gt;
+[[MediaWiki_talk:931f9736|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Article
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:931f9736</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6da2f6ae&amp;action=edit 86e5f16d]&lt;br&gt;
+[[MediaWiki_talk:6da2f6ae|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Media
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6da2f6ae</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:53504d48&amp;action=edit 368d5d22]&lt;br&gt;
+[[MediaWiki_talk:53504d48|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Message
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:53504d48</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:14d4daef&amp;action=edit 34a2cba3]&lt;br&gt;
+[[MediaWiki_talk:14d4daef|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Special
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:14d4daef</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ed2e8b27&amp;action=edit a1024e18]&lt;br&gt;
+[[MediaWiki_talk:ed2e8b27|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Template
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ed2e8b27</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:31ebc74b&amp;action=edit 313f5ee2]&lt;br&gt;
+[[MediaWiki_talk:31ebc74b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+User page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:31ebc74b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a8d28daa&amp;action=edit 0611a13e]&lt;br&gt;
+[[MediaWiki_talk:a8d28daa|Talk]]
+&lt;/td&gt;&lt;td&gt;
+About
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a8d28daa</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b0a98216&amp;action=edit 7a85f476]&lt;br&gt;
+[[MediaWiki_talk:b0a98216|Talk]]
+&lt;/td&gt;&lt;td&gt;
+OK
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b0a98216</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e081cf87&amp;action=edit 23ace733]&lt;br&gt;
+[[MediaWiki_talk:e081cf87|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Old password
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e081cf87</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:db6998a7&amp;action=edit dc894908]&lt;br&gt;
+[[MediaWiki_talk:db6998a7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+orig
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:db6998a7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cb5dc4a4&amp;action=edit 89f56e51]&lt;br&gt;
+[[MediaWiki_talk:cb5dc4a4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Orphaned pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cb5dc4a4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:51caf0b1&amp;action=edit e6287b24]&lt;br&gt;
+[[MediaWiki_talk:51caf0b1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Based on work by $1.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:51caf0b1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:838fda53&amp;action=edit f953cc13]&lt;br&gt;
+[[MediaWiki_talk:838fda53|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Other languages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:838fda53</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8b80dc12&amp;action=edit 06b2b863]&lt;br&gt;
+[[MediaWiki_talk:8b80dc12|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Move succeeded
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8b80dc12</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:67c1c9b9&amp;action=edit 6df06888]&lt;br&gt;
+[[MediaWiki_talk:67c1c9b9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Page &amp;quot;&amp;#91;&amp;#91;$1]]&amp;quot; moved to &amp;quot;&amp;#91;&amp;#91;$2]]&amp;quot;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:67c1c9b9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0488d9f9&amp;action=edit ca0a1736]&lt;br&gt;
+[[MediaWiki_talk:0488d9f9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+$1 - Wiktionary
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0488d9f9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:724220c3&amp;action=edit 00c46482]&lt;br&gt;
+[[MediaWiki_talk:724220c3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Someone (probably you, from IP address $1)
+requested that we send you a new Wiktionary login password.
+The password for user &amp;quot;$2&amp;quot; is now &amp;quot;$3&amp;quot;.
+You should log in and change your password now.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:724220c3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:67675177&amp;action=edit 9943fd1d]&lt;br&gt;
+[[MediaWiki_talk:67675177|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Password reminder from Wiktionary
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:67675177</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:feea022e&amp;action=edit 52c6d21a]&lt;br&gt;
+[[MediaWiki_talk:feea022e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+A new password has been sent to the e-mail address
+registered for &amp;quot;$1&amp;quot;.
+Please log in again after you receive it.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:feea022e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d89b33a4&amp;action=edit 6148b748]&lt;br&gt;
+[[MediaWiki_talk:d89b33a4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The following data is cached and may not be completely up to date:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d89b33a4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7c3d6ba1&amp;action=edit edb94b6f]&lt;br&gt;
+[[MediaWiki_talk:7c3d6ba1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Sorry! This feature has been temporarily disabled
+because it slows the database down to the point that no one can use
+the wiki.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7c3d6ba1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ba8fb63e&amp;action=edit 7971fbbc]&lt;br&gt;
+[[MediaWiki_talk:ba8fb63e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Here&amp;#39;s a saved copy from $1:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ba8fb63e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1f9d5196&amp;action=edit faae8244]&lt;br&gt;
+[[MediaWiki_talk:1f9d5196|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Personal tools
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1f9d5196</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b55223c7&amp;action=edit 23f3fd77]&lt;br&gt;
+[[MediaWiki_talk:b55223c7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Community portal
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b55223c7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b6100630&amp;action=edit d69501d7]&lt;br&gt;
+[[MediaWiki_talk:b6100630|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary:Community Portal
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b6100630</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:83c6e160&amp;action=edit 7ce546d1]&lt;br&gt;
+[[MediaWiki_talk:83c6e160|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Post a comment
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:83c6e160</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f715eef0&amp;action=edit 03d7f055]&lt;br&gt;
+[[MediaWiki_talk:f715eef0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary is powered by &amp;#91;http&amp;#58;//www.mediawiki.org/ MediaWiki], an open source wiki engine.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f715eef0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5f86f380&amp;action=edit fe586261]&lt;br&gt;
+[[MediaWiki_talk:5f86f380|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Search
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5f86f380</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:02360031&amp;action=edit 7c50040c]&lt;br&gt;
+[[MediaWiki_talk:02360031|Talk]]
+&lt;/td&gt;&lt;td&gt;
+
+Search in namespaces :&amp;lt;br /&amp;gt;
+$1&amp;lt;br /&amp;gt;
+$2 List redirects &amp;amp;nbsp; Search for $3 $9
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:02360031</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9dfd349e&amp;action=edit dcedb31d]&lt;br&gt;
+[[MediaWiki_talk:9dfd349e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Preferences
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9dfd349e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6b97fde2&amp;action=edit 4d381b11]&lt;br&gt;
+[[MediaWiki_talk:6b97fde2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+* &amp;lt;strong&amp;gt;Real name&amp;lt;/strong&amp;gt; (optional): if you choose to provide it this will be used for giving you attribution for your work.&amp;lt;br/&amp;gt;
+* &amp;lt;strong&amp;gt;Email&amp;lt;/strong&amp;gt; (optional): Enables people to contact you through the website without you having to reveal your
+email address to them, and it can be used to send you a new password if you forget it.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6b97fde2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:30cafb20&amp;action=edit 4413aea7]&lt;br&gt;
+[[MediaWiki_talk:30cafb20|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Misc settings
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:30cafb20</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:58796ee5&amp;action=edit 79de347d]&lt;br&gt;
+[[MediaWiki_talk:58796ee5|Talk]]
+&lt;/td&gt;&lt;td&gt;
+User data
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:58796ee5</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e055ac90&amp;action=edit b8a6f738]&lt;br&gt;
+[[MediaWiki_talk:e055ac90|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Recent changes and stub display
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e055ac90</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0603b5a9&amp;action=edit 3b8a7d0e]&lt;br&gt;
+[[MediaWiki_talk:0603b5a9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You are logged in as &amp;quot;$1&amp;quot;.
+Your internal ID number is $2.
+
+See &amp;#91;&amp;#91;Wiktionary:User preferences help]] for help deciphering the options.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0603b5a9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2366fb91&amp;action=edit f2475be5]&lt;br&gt;
+[[MediaWiki_talk:2366fb91|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Not logged in
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2366fb91</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0389a76a&amp;action=edit 69cb02c9]&lt;br&gt;
+[[MediaWiki_talk:0389a76a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You must be &amp;lt;a href=&amp;quot;/wiki/Special:Userlogin&amp;quot;&amp;gt;logged in&amp;lt;/a&amp;gt;
+to set user preferences.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0389a76a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e6216751&amp;action=edit 2b688ff4]&lt;br&gt;
+[[MediaWiki_talk:e6216751|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Preferences have been reset from storage.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e6216751</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f1fbb2b4&amp;action=edit 1aa787fe]&lt;br&gt;
+[[MediaWiki_talk:f1fbb2b4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Preview
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f1fbb2b4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7357cd58&amp;action=edit 353820b9]&lt;br&gt;
+[[MediaWiki_talk:7357cd58|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This preview reflects the text in the upper
+text editing area as it will appear if you choose to save.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7357cd58</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f0bd6ebe&amp;action=edit 2281018c]&lt;br&gt;
+[[MediaWiki_talk:f0bd6ebe|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Remember that this is only a preview, and has not yet been saved!
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f0bd6ebe</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c7db0778&amp;action=edit 8b3bb669]&lt;br&gt;
+[[MediaWiki_talk:c7db0778|Talk]]
+&lt;/td&gt;&lt;td&gt;
+previous $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c7db0778</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0a67b813&amp;action=edit e1a919ba]&lt;br&gt;
+[[MediaWiki_talk:0a67b813|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Printable version
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0a67b813</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dc3b6f21&amp;action=edit d4d3cccd]&lt;br&gt;
+[[MediaWiki_talk:dc3b6f21|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(From http&amp;#58;//tl.wiktionary.org)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:dc3b6f21</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:016ac2dc&amp;action=edit 145969f1]&lt;br&gt;
+[[MediaWiki_talk:016ac2dc|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Protect
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:016ac2dc</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:073135b4&amp;action=edit bf7f9e49]&lt;br&gt;
+[[MediaWiki_talk:073135b4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Reason for protecting
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:073135b4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3ee691ce&amp;action=edit 1f880b64]&lt;br&gt;
+[[MediaWiki_talk:3ee691ce|Talk]]
+&lt;/td&gt;&lt;td&gt;
+protected &amp;#91;&amp;#91;$1]]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3ee691ce</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a44b308c&amp;action=edit 7afa7fea]&lt;br&gt;
+[[MediaWiki_talk:a44b308c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Protected page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a44b308c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0017a4f5&amp;action=edit 962032da]&lt;br&gt;
+[[MediaWiki_talk:0017a4f5|Talk]]
+&lt;/td&gt;&lt;td&gt;
+WARNING: This page has been locked so that only
+users with sysop privileges can edit it. Be sure you are following the
+&amp;lt;a href=&amp;#39;/w/wiki.phtml/Wiktionary:Protected_page_guidelines&amp;#39;&amp;gt;protected page
+guidelines&amp;lt;/a&amp;gt;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0017a4f5</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cf2a914e&amp;action=edit 561f00bf]&lt;br&gt;
+[[MediaWiki_talk:cf2a914e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This page has been locked to prevent editing; there are
+a number of reasons why this may be so, please see
+&amp;#91;&amp;#91;Wiktionary:Protected page]].
+
+You can view and copy the source of this page:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cf2a914e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bb915483&amp;action=edit 85888484]&lt;br&gt;
+[[MediaWiki_talk:bb915483|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Protection_log
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bb915483</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:061ec7fa&amp;action=edit 197cfa0d]&lt;br&gt;
+[[MediaWiki_talk:061ec7fa|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Below is a list of page locks/unlocks.
+See &amp;#91;&amp;#91;Wiktionary:Protected page]] for more information.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:061ec7fa</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d2ae1354&amp;action=edit 33c2c02c]&lt;br&gt;
+[[MediaWiki_talk:d2ae1354|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Protect page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d2ae1354</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c0e9bbaf&amp;action=edit 5cbc043a]&lt;br&gt;
+[[MediaWiki_talk:c0e9bbaf|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(give a reason)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c0e9bbaf</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:23176a41&amp;action=edit 24a81acc]&lt;br&gt;
+[[MediaWiki_talk:23176a41|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(Protecting &amp;quot;$1&amp;quot;)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:23176a41</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:884b47b3&amp;action=edit 77ca39fa]&lt;br&gt;
+[[MediaWiki_talk:884b47b3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Protect this page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:884b47b3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0ebe1928&amp;action=edit 11599708]&lt;br&gt;
+[[MediaWiki_talk:0ebe1928|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Proxy blocker
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0ebe1928</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0ccb1a72&amp;action=edit f4482395]&lt;br&gt;
+[[MediaWiki_talk:0ccb1a72|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Your IP address has been blocked because it is an open proxy. Please contact your Internet service provider or tech support and inform them of this serious security problem.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0ccb1a72</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:88af6e64&amp;action=edit 01b6671f]&lt;br&gt;
+[[MediaWiki_talk:88af6e64|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Done.
+
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:88af6e64</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b1594c4a&amp;action=edit 596b17aa]&lt;br&gt;
+[[MediaWiki_talk:b1594c4a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Browse
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b1594c4a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:25b61f80&amp;action=edit 9e11e13b]&lt;br&gt;
+[[MediaWiki_talk:25b61f80|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Edit
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:25b61f80</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e1a9ed9d&amp;action=edit cc717307]&lt;br&gt;
+[[MediaWiki_talk:e1a9ed9d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Find
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e1a9ed9d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:24074cfc&amp;action=edit a40d0b3f]&lt;br&gt;
+[[MediaWiki_talk:24074cfc|Talk]]
+&lt;/td&gt;&lt;td&gt;
+My pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:24074cfc</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:af83fbba&amp;action=edit 8f794a0f]&lt;br&gt;
+[[MediaWiki_talk:af83fbba|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Context
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:af83fbba</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c8fff0e7&amp;action=edit 20fec244]&lt;br&gt;
+[[MediaWiki_talk:c8fff0e7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c8fff0e7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5a6ec2af&amp;action=edit 2dfd6121]&lt;br&gt;
+[[MediaWiki_talk:5a6ec2af|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Quickbar settings
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5a6ec2af</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8eda832f&amp;action=edit e97e9088]&lt;br&gt;
+[[MediaWiki_talk:8eda832f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Special pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8eda832f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ce1c6b9a&amp;action=edit dc17545e]&lt;br&gt;
+[[MediaWiki_talk:ce1c6b9a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Submit query
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ce1c6b9a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:342970ff&amp;action=edit 7f2e7314]&lt;br&gt;
+[[MediaWiki_talk:342970ff|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Query successful
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:342970ff</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c2300ac1&amp;action=edit 08f4cb5c]&lt;br&gt;
+[[MediaWiki_talk:c2300ac1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Random page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c2300ac1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9f84f8de&amp;action=edit 0da9559a]&lt;br&gt;
+[[MediaWiki_talk:9f84f8de|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The sysop ability to create range blocks is disabled.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9f84f8de</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f65142b8&amp;action=edit 65c24302]&lt;br&gt;
+[[MediaWiki_talk:f65142b8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+in $4 form; $1 minor edits; $2 secondary namespaces; $3 multiple edits.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f65142b8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:78b9278a&amp;action=edit 96bcbd6a]&lt;br&gt;
+[[MediaWiki_talk:78b9278a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Show last $1 changes in last $2 days&amp;lt;br /&amp;gt;$3
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:78b9278a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ead9cd8b&amp;action=edit 69cdd5ad]&lt;br&gt;
+[[MediaWiki_talk:ead9cd8b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Show new changes starting from $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ead9cd8b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bad8b81d&amp;action=edit f13491ba]&lt;br&gt;
+[[MediaWiki_talk:bad8b81d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+; $1 edits from logged in users
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bad8b81d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:58a7c0de&amp;action=edit ced7752e]&lt;br&gt;
+[[MediaWiki_talk:58a7c0de|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Loading recent changes
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:58a7c0de</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c3fd1aca&amp;action=edit d259fbf6]&lt;br&gt;
+[[MediaWiki_talk:c3fd1aca|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(to pages linked from &amp;quot;$1&amp;quot;)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c3fd1aca</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2c0a654c&amp;action=edit 15ea8401]&lt;br&gt;
+[[MediaWiki_talk:2c0a654c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Below are the last &amp;lt;strong&amp;gt;$1&amp;lt;/strong&amp;gt; changes in last &amp;lt;strong&amp;gt;$2&amp;lt;/strong&amp;gt; days.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2c0a654c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0a00aaba&amp;action=edit c516366b]&lt;br&gt;
+[[MediaWiki_talk:0a00aaba|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Below are the changes since &amp;lt;b&amp;gt;$2&amp;lt;/b&amp;gt; (up to &amp;lt;b&amp;gt;$1&amp;lt;/b&amp;gt; shown).
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0a00aaba</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1d920fff&amp;action=edit 9a277182]&lt;br&gt;
+[[MediaWiki_talk:1d920fff|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Database locked
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1d920fff</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:64743780&amp;action=edit e5990e81]&lt;br&gt;
+[[MediaWiki_talk:64743780|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The database is currently locked to new
+entries and other modifications, probably for routine database maintenance,
+after which it will be back to normal.
+The administrator who locked it offered this explanation:
+&amp;lt;p&amp;gt;$1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:64743780</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8c9d6af6&amp;action=edit 74bcbeed]&lt;br&gt;
+[[MediaWiki_talk:8c9d6af6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+WARNING: The database has been locked for maintenance,
+so you will not be able to save your edits right now. You may wish to cut-n-paste
+the text into a text file and save it for later.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8c9d6af6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4d75dd33&amp;action=edit 51734654]&lt;br&gt;
+[[MediaWiki_talk:4d75dd33|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Recent changes
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4d75dd33</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:40f1d259&amp;action=edit 44d93957]&lt;br&gt;
+[[MediaWiki_talk:40f1d259|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Number of titles in recent changes
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:40f1d259</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:312aafe1&amp;action=edit b5822b16]&lt;br&gt;
+[[MediaWiki_talk:312aafe1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Related changes
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:312aafe1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2f453993&amp;action=edit 049f8c5f]&lt;br&gt;
+[[MediaWiki_talk:2f453993|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Track the most recent changes to the wiki on this page.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2f453993</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7124fa4a&amp;action=edit 43d741c1]&lt;br&gt;
+[[MediaWiki_talk:7124fa4a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(Redirected from $1)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7124fa4a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:54d89323&amp;action=edit 4eef1c9f]&lt;br&gt;
+[[MediaWiki_talk:54d89323|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Remember my password across sessions.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:54d89323</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:537f5507&amp;action=edit bfa5dc98]&lt;br&gt;
+[[MediaWiki_talk:537f5507|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Remove checked items from watchlist
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:537f5507</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:78e82769&amp;action=edit eeadf87c]&lt;br&gt;
+[[MediaWiki_talk:78e82769|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Removed from watchlist
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:78e82769</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ad711aa8&amp;action=edit d9807612]&lt;br&gt;
+[[MediaWiki_talk:ad711aa8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The page &amp;quot;$1&amp;quot; has been removed from your watchlist.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ad711aa8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:48b0bcb6&amp;action=edit 7d083ee5]&lt;br&gt;
+[[MediaWiki_talk:48b0bcb6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Removing requested items from watchlist...
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:48b0bcb6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2f022894&amp;action=edit 4b81718e]&lt;br&gt;
+[[MediaWiki_talk:2f022894|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Reset preferences
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2f022894</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bc47acaf&amp;action=edit 8f8f7d13]&lt;br&gt;
+[[MediaWiki_talk:bc47acaf|Talk]]
+&lt;/td&gt;&lt;td&gt;
+$1 deleted edits
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bc47acaf</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6add8c15&amp;action=edit 8f0c68f0]&lt;br&gt;
+[[MediaWiki_talk:6add8c15|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Hits to show per page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6add8c15</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6f54af5b&amp;action=edit a5e2f101]&lt;br&gt;
+[[MediaWiki_talk:6f54af5b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Retrieved from &amp;quot;$1&amp;quot;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6f54af5b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3dcff1b0&amp;action=edit 58f19667]&lt;br&gt;
+[[MediaWiki_talk:3dcff1b0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Return to $1.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3dcff1b0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b9ac79c6&amp;action=edit 2b9171b6]&lt;br&gt;
+[[MediaWiki_talk:b9ac79c6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Retype new password
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b9ac79c6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b7ae8f64&amp;action=edit a3eee606]&lt;br&gt;
+[[MediaWiki_talk:b7ae8f64|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Re-upload
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b7ae8f64</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cd783da0&amp;action=edit d7ba5bcb]&lt;br&gt;
+[[MediaWiki_talk:cd783da0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Return to the upload form.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cd783da0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c73c43f3&amp;action=edit f322de9a]&lt;br&gt;
+[[MediaWiki_talk:c73c43f3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Reverted to earlier revision
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c73c43f3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:18591a4b&amp;action=edit 0d86ed82]&lt;br&gt;
+[[MediaWiki_talk:18591a4b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+rev
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:18591a4b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b24ef4f1&amp;action=edit 8ce494ea]&lt;br&gt;
+[[MediaWiki_talk:b24ef4f1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Reverted edit of $2, changed back to last version by $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b24ef4f1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:96e64350&amp;action=edit 949a77c7]&lt;br&gt;
+[[MediaWiki_talk:96e64350|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Revision history
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:96e64350</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0c299dc7&amp;action=edit 3338672b]&lt;br&gt;
+[[MediaWiki_talk:0c299dc7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Revision as of $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0c299dc7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:159f321a&amp;action=edit d567812b]&lt;br&gt;
+[[MediaWiki_talk:159f321a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Revision not found
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:159f321a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:955fec48&amp;action=edit 4060f114]&lt;br&gt;
+[[MediaWiki_talk:955fec48|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The old revision of the page you asked for could not be found.
+Please check the URL you used to access this page.
+
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:955fec48</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b2f04988&amp;action=edit e8b606c2]&lt;br&gt;
+[[MediaWiki_talk:b2f04988|Talk]]
+&lt;/td&gt;&lt;td&gt;
+http&amp;#58;//www.faqs.org/rfcs/rfc$1.html
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b2f04988</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:db94ff6b&amp;action=edit 1407cb23]&lt;br&gt;
+[[MediaWiki_talk:db94ff6b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Rights:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:db94ff6b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f28daee2&amp;action=edit ff3a6f3b]&lt;br&gt;
+[[MediaWiki_talk:f28daee2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Roll back edits
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f28daee2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2aaec24c&amp;action=edit 5f0fa7e7]&lt;br&gt;
+[[MediaWiki_talk:2aaec24c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Rollback
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2aaec24c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:54d37a4c&amp;action=edit 73c685e6]&lt;br&gt;
+[[MediaWiki_talk:54d37a4c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Rollback failed
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:54d37a4c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b82a8f42&amp;action=edit 1a9fae49]&lt;br&gt;
+[[MediaWiki_talk:b82a8f42|Talk]]
+&lt;/td&gt;&lt;td&gt;
+rollback
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b82a8f42</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:52d0b352&amp;action=edit 6c30d261]&lt;br&gt;
+[[MediaWiki_talk:52d0b352|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Rows
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:52d0b352</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5a43014e&amp;action=edit 1308cde0]&lt;br&gt;
+[[MediaWiki_talk:5a43014e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Save page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5a43014e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0e53fdc8&amp;action=edit 5f6543d0]&lt;br&gt;
+[[MediaWiki_talk:0e53fdc8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Your preferences have been saved.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0e53fdc8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e1415b15&amp;action=edit d6d40a58]&lt;br&gt;
+[[MediaWiki_talk:e1415b15|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Save file
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e1415b15</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ad98e68e&amp;action=edit 34ac956e]&lt;br&gt;
+[[MediaWiki_talk:ad98e68e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Save preferences
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ad98e68e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bce06414&amp;action=edit 3559d7ac]&lt;br&gt;
+[[MediaWiki_talk:bce06414|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Search
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bce06414</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8f6495a7&amp;action=edit cfa0722d]&lt;br&gt;
+[[MediaWiki_talk:8f6495a7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;p&amp;gt;Sorry! Full text search has been disabled temporarily, for performance reasons. In the meantime, you can use the Google search below, which may be out of date.&amp;lt;/p&amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8f6495a7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:72344e87&amp;action=edit 3eea6ce4]&lt;br&gt;
+[[MediaWiki_talk:72344e87|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary:Searching
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:72344e87</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cb9c1653&amp;action=edit da48347f]&lt;br&gt;
+[[MediaWiki_talk:cb9c1653|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Searching Wiktionary
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cb9c1653</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3d79ca88&amp;action=edit 64bdca9a]&lt;br&gt;
+[[MediaWiki_talk:3d79ca88|Talk]]
+&lt;/td&gt;&lt;td&gt;
+For query &amp;quot;$1&amp;quot;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3d79ca88</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b2f7c0e1&amp;action=edit 8ef6d4d3]&lt;br&gt;
+[[MediaWiki_talk:b2f7c0e1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Search results
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b2f7c0e1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e5ed9018&amp;action=edit 83d578cd]&lt;br&gt;
+[[MediaWiki_talk:e5ed9018|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Search result settings
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e5ed9018</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8cab5350&amp;action=edit 781b9fee]&lt;br&gt;
+[[MediaWiki_talk:8cab5350|Talk]]
+&lt;/td&gt;&lt;td&gt;
+For more information about searching Wiktionary, see $1.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8cab5350</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:37b6df63&amp;action=edit a26b768d]&lt;br&gt;
+[[MediaWiki_talk:37b6df63|Talk]]
+&lt;/td&gt;&lt;td&gt;
+ (section)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:37b6df63</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:be4aaa62&amp;action=edit 2ddce298]&lt;br&gt;
+[[MediaWiki_talk:be4aaa62|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Select a newer version for comparison
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:be4aaa62</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5124db4d&amp;action=edit 80ffa0cb]&lt;br&gt;
+[[MediaWiki_talk:5124db4d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Select an older version for comparison
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5124db4d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a3c0a747&amp;action=edit 5ec1b504]&lt;br&gt;
+[[MediaWiki_talk:a3c0a747|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Only read-only queries are allowed.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a3c0a747</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e93eec9e&amp;action=edit 06cf46b3]&lt;br&gt;
+[[MediaWiki_talk:e93eec9e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Pages with Self Links
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e93eec9e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f993dd01&amp;action=edit e7caf074]&lt;br&gt;
+[[MediaWiki_talk:f993dd01|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The following pages contain a link to themselves, which they should not.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f993dd01</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fa56e16c&amp;action=edit 249c203d]&lt;br&gt;
+[[MediaWiki_talk:fa56e16c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+There were serious xhtml markup errors detected by tidy.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:fa56e16c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5123f28d&amp;action=edit 8fcf47da]&lt;br&gt;
+[[MediaWiki_talk:5123f28d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Server time is now
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5123f28d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4075f71a&amp;action=edit 79d35179]&lt;br&gt;
+[[MediaWiki_talk:4075f71a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;b&amp;gt;User rights for &amp;quot;$1&amp;quot; could not be set. (Did you enter the name correctly?)&amp;lt;/b&amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4075f71a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:13187ffc&amp;action=edit f2cd2a2a]&lt;br&gt;
+[[MediaWiki_talk:13187ffc|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Set user rights
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:13187ffc</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:56640761&amp;action=edit c5bfd68a]&lt;br&gt;
+[[MediaWiki_talk:56640761|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Set bureaucrat flag
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:56640761</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0d3f883b&amp;action=edit fff9c94a]&lt;br&gt;
+[[MediaWiki_talk:0d3f883b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Short pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0d3f883b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d97d1ee3&amp;action=edit 9fb29051]&lt;br&gt;
+[[MediaWiki_talk:d97d1ee3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+show
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d97d1ee3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f1a75ecf&amp;action=edit 4fe654c7]&lt;br&gt;
+[[MediaWiki_talk:f1a75ecf|Talk]]
+&lt;/td&gt;&lt;td&gt;
+$1 minor edits &amp;#124; $2 bots &amp;#124; $3 logged in users
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f1a75ecf</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:72fad336&amp;action=edit 9569cf23]&lt;br&gt;
+[[MediaWiki_talk:72fad336|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Showing below &amp;lt;b&amp;gt;$1&amp;lt;/b&amp;gt; results starting with #&amp;lt;b&amp;gt;$2&amp;lt;/b&amp;gt;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:72fad336</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6db37657&amp;action=edit f7535b52]&lt;br&gt;
+[[MediaWiki_talk:6db37657|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Showing below &amp;lt;b&amp;gt;$3&amp;lt;/b&amp;gt; results starting with #&amp;lt;b&amp;gt;$2&amp;lt;/b&amp;gt;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6db37657</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:acbdf814&amp;action=edit 43158759]&lt;br&gt;
+[[MediaWiki_talk:acbdf814|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Show last $1 images sorted $2.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:acbdf814</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:915318a0&amp;action=edit ac2b4c32]&lt;br&gt;
+[[MediaWiki_talk:915318a0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Show preview
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:915318a0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cfff5a5a&amp;action=edit 6eeee3cb]&lt;br&gt;
+[[MediaWiki_talk:cfff5a5a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+show
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cfff5a5a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ac617b53&amp;action=edit 1144c9d9]&lt;br&gt;
+[[MediaWiki_talk:ac617b53|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Your signature with timestamp
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ac617b53</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f6baa6ad&amp;action=edit 7f5726ac]&lt;br&gt;
+[[MediaWiki_talk:f6baa6ad|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Site statistics
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f6baa6ad</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e150b0f4&amp;action=edit 8e86f95d]&lt;br&gt;
+[[MediaWiki_talk:e150b0f4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+There are &amp;#39;&amp;#39;&amp;#39;$1&amp;#39;&amp;#39;&amp;#39; total pages in the database.
+This includes &amp;quot;talk&amp;quot; pages, pages about Wiktionary, minimal &amp;quot;stub&amp;quot;
+pages, redirects, and others that probably don&amp;#39;t qualify as content pages.
+Excluding those, there are &amp;#39;&amp;#39;&amp;#39;$2&amp;#39;&amp;#39;&amp;#39; pages that are probably legitimate
+content pages.
+
+There have been a total of &amp;#39;&amp;#39;&amp;#39;$3&amp;#39;&amp;#39;&amp;#39; page views, and &amp;#39;&amp;#39;&amp;#39;$4&amp;#39;&amp;#39;&amp;#39; page edits
+since the wiki was setup.
+That comes to &amp;#39;&amp;#39;&amp;#39;$5&amp;#39;&amp;#39;&amp;#39; average edits per page, and &amp;#39;&amp;#39;&amp;#39;$6&amp;#39;&amp;#39;&amp;#39; views per edit.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e150b0f4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:daaf7240&amp;action=edit 8dca090f]&lt;br&gt;
+[[MediaWiki_talk:daaf7240|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The Free Encyclopedia
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:daaf7240</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3b434c6d&amp;action=edit 32b42c53]&lt;br&gt;
+[[MediaWiki_talk:3b434c6d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Donations
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3b434c6d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d75649ef&amp;action=edit d88e8164]&lt;br&gt;
+[[MediaWiki_talk:d75649ef|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d75649ef</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cb06d8a3&amp;action=edit b64ec710]&lt;br&gt;
+[[MediaWiki_talk:cb06d8a3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary user $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cb06d8a3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d25d37c8&amp;action=edit 4f548531]&lt;br&gt;
+[[MediaWiki_talk:d25d37c8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary user(s) $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d25d37c8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8f57bd61&amp;action=edit d0cb2acd]&lt;br&gt;
+[[MediaWiki_talk:8f57bd61|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Skin
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8f57bd61</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d3a6dd4e&amp;action=edit bcd196f9]&lt;br&gt;
+[[MediaWiki_talk:d3a6dd4e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The page you wanted to save was blocked by the spam filter. This is probably caused by a link to an external site.
+
+You might want to check the following regular expression for patterns that are currently blocked:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d3a6dd4e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:add33980&amp;action=edit 60a90929]&lt;br&gt;
+[[MediaWiki_talk:add33980|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Spam protection filter
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:add33980</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:984c6817&amp;action=edit 25255195]&lt;br&gt;
+[[MediaWiki_talk:984c6817|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Special Page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:984c6817</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b67d51d8&amp;action=edit 62bc32dc]&lt;br&gt;
+[[MediaWiki_talk:b67d51d8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Special pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b67d51d8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c9709132&amp;action=edit 73ac2b41]&lt;br&gt;
+[[MediaWiki_talk:c9709132|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Special pages for all users
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c9709132</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:87ac14f6&amp;action=edit ed31e1e1]&lt;br&gt;
+[[MediaWiki_talk:87ac14f6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Please note that all queries are logged.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:87ac14f6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:00c261e1&amp;action=edit 26cb51a1]&lt;br&gt;
+[[MediaWiki_talk:00c261e1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Enter query
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:00c261e1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2086b21f&amp;action=edit 3d18b2ea]&lt;br&gt;
+[[MediaWiki_talk:2086b21f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Statistics
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2086b21f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8cd0c85e&amp;action=edit 1b9e838c]&lt;br&gt;
+[[MediaWiki_talk:8cd0c85e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Stored version
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8cd0c85e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2eb6d4bd&amp;action=edit a5125d69]&lt;br&gt;
+[[MediaWiki_talk:2eb6d4bd|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Threshold for stub display
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2eb6d4bd</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f3da206c&amp;action=edit ef062b0e]&lt;br&gt;
+[[MediaWiki_talk:f3da206c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Subcategories
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f3da206c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8d183dbd&amp;action=edit 335ce16b]&lt;br&gt;
+[[MediaWiki_talk:8d183dbd|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Subject/headline
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8d183dbd</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ca29f2df&amp;action=edit d7084ef8]&lt;br&gt;
+[[MediaWiki_talk:ca29f2df|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View subject
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ca29f2df</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:17bc3900&amp;action=edit 3dfd0f51]&lt;br&gt;
+[[MediaWiki_talk:17bc3900|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Successful upload
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:17bc3900</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:12b71c3e&amp;action=edit 05535ecf]&lt;br&gt;
+[[MediaWiki_talk:12b71c3e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Summary
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:12b71c3e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ee858d9a&amp;action=edit fde4e0f4]&lt;br&gt;
+[[MediaWiki_talk:ee858d9a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+For sysop use only
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ee858d9a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2f758a39&amp;action=edit 85232d4f]&lt;br&gt;
+[[MediaWiki_talk:2f758a39|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The action you have requested can only be
+performed by users with &amp;quot;sysop&amp;quot; status.
+See $1.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2f758a39</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:91b8467b&amp;action=edit 3265b18d]&lt;br&gt;
+[[MediaWiki_talk:91b8467b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Sysop access required
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:91b8467b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:77b9c5ad&amp;action=edit 109e51e1]&lt;br&gt;
+[[MediaWiki_talk:77b9c5ad|Talk]]
+&lt;/td&gt;&lt;td&gt;
+table
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:77b9c5ad</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4e6a710d&amp;action=edit e55e91b2]&lt;br&gt;
+[[MediaWiki_talk:4e6a710d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Discussion
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4e6a710d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c11ac522&amp;action=edit 1ed6d2b4]&lt;br&gt;
+[[MediaWiki_talk:c11ac522|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The page itself was moved successfully, but the
+talk page could not be moved because one already exists at the new
+title. Please merge them manually.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c11ac522</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6a38ff98&amp;action=edit 3c940bbf]&lt;br&gt;
+[[MediaWiki_talk:6a38ff98|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Discuss this page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6a38ff98</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2b630ea0&amp;action=edit f053e191]&lt;br&gt;
+[[MediaWiki_talk:2b630ea0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The corresponding talk page was also moved.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2b630ea0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2282b1ca&amp;action=edit f3b6a64f]&lt;br&gt;
+[[MediaWiki_talk:2282b1ca|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The corresponding talk page was &amp;lt;strong&amp;gt;not&amp;lt;/strong&amp;gt; moved.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2282b1ca</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:45e3f76d&amp;action=edit 6534acb5]&lt;br&gt;
+[[MediaWiki_talk:45e3f76d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;!-- MediaWiki:talkpagetext --&amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:45e3f76d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:607359f2&amp;action=edit 5788df25]&lt;br&gt;
+[[MediaWiki_talk:607359f2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Textbox dimensions
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:607359f2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:66571fbc&amp;action=edit 7d66aa0e]&lt;br&gt;
+[[MediaWiki_talk:66571fbc|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Page text matches
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:66571fbc</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e1f36741&amp;action=edit 83f663ed]&lt;br&gt;
+[[MediaWiki_talk:e1f36741|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View or restore $1?
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e1f36741</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a29f027b&amp;action=edit a299730b]&lt;br&gt;
+[[MediaWiki_talk:a29f027b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Enlarge
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a29f027b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9ced4850&amp;action=edit 36ee6f56]&lt;br&gt;
+[[MediaWiki_talk:9ced4850|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Time zone
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9ced4850</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ca45a968&amp;action=edit 9dba4eb8]&lt;br&gt;
+[[MediaWiki_talk:ca45a968|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Offset
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ca45a968</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:60bc3c41&amp;action=edit 3f58a2a9]&lt;br&gt;
+[[MediaWiki_talk:60bc3c41|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Enter number of hours your local time differs
+from server time (UTC).
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:60bc3c41</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2618febd&amp;action=edit e3f5384c]&lt;br&gt;
+[[MediaWiki_talk:2618febd|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Article title matches
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2618febd</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b8684fcf&amp;action=edit 2a609230]&lt;br&gt;
+[[MediaWiki_talk:b8684fcf|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Table of contents
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b8684fcf</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:692107c0&amp;action=edit d75ba923]&lt;br&gt;
+[[MediaWiki_talk:692107c0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Toolbox
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:692107c0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b9116941&amp;action=edit 04ced041]&lt;br&gt;
+[[MediaWiki_talk:b9116941|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Add a comment to this page. &amp;#91;alt-+]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b9116941</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b583d36c&amp;action=edit b987f993]&lt;br&gt;
+[[MediaWiki_talk:b583d36c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Discussion about edits from this ip address &amp;#91;alt-n]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b583d36c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9efcbe2f&amp;action=edit e3522c89]&lt;br&gt;
+[[MediaWiki_talk:9efcbe2f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The user page for the ip you&amp;#39;re editing as &amp;#91;alt-.]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9efcbe2f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2a4b9eea&amp;action=edit f3025f7a]&lt;br&gt;
+[[MediaWiki_talk:2a4b9eea|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View the content page &amp;#91;alt-a]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2a4b9eea</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dcd3ca0c&amp;action=edit e420bf33]&lt;br&gt;
+[[MediaWiki_talk:dcd3ca0c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Atom feed for this page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:dcd3ca0c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d318992a&amp;action=edit d2ae036e]&lt;br&gt;
+[[MediaWiki_talk:d318992a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+See the differences between the two selected versions of this page. &amp;#91;alt-v]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d318992a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:17165e38&amp;action=edit 2039dc44]&lt;br&gt;
+[[MediaWiki_talk:17165e38|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View the list of contributions of this user
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:17165e38</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:32bcbdd6&amp;action=edit d57ba9d6]&lt;br&gt;
+[[MediaWiki_talk:32bcbdd6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Find background information on current events
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:32bcbdd6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b1488dbf&amp;action=edit 742e0c2a]&lt;br&gt;
+[[MediaWiki_talk:b1488dbf|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Delete this page &amp;#91;alt-d]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b1488dbf</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8c519c79&amp;action=edit 6b354128]&lt;br&gt;
+[[MediaWiki_talk:8c519c79|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You can edit this page. Please use the preview button before saving. &amp;#91;alt-e]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8c519c79</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d61f42ac&amp;action=edit 6a333373]&lt;br&gt;
+[[MediaWiki_talk:d61f42ac|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Send a mail to this user
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d61f42ac</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:691c7c4c&amp;action=edit 1f2e0a5e]&lt;br&gt;
+[[MediaWiki_talk:691c7c4c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The place to find out.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:691c7c4c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:da5d5f0e&amp;action=edit 357e85a5]&lt;br&gt;
+[[MediaWiki_talk:da5d5f0e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Past versions of this page, &amp;#91;alt-h]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:da5d5f0e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e20e86bb&amp;action=edit ff6db008]&lt;br&gt;
+[[MediaWiki_talk:e20e86bb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You are encouraged to log in, it is not mandatory however. &amp;#91;alt-o]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e20e86bb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ab189540&amp;action=edit bd25c34c]&lt;br&gt;
+[[MediaWiki_talk:ab189540|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Log out &amp;#91;alt-o]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ab189540</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8d2a8168&amp;action=edit 9dd4b86c]&lt;br&gt;
+[[MediaWiki_talk:8d2a8168|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Visit the Main Page &amp;#91;alt-z]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8d2a8168</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7316f250&amp;action=edit 2138d388]&lt;br&gt;
+[[MediaWiki_talk:7316f250|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Mark this as a minor edit &amp;#91;alt-i]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7316f250</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bfe45253&amp;action=edit 00ef7343]&lt;br&gt;
+[[MediaWiki_talk:bfe45253|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Move this page &amp;#91;alt-m]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bfe45253</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c2b4d858&amp;action=edit 0bafecde]&lt;br&gt;
+[[MediaWiki_talk:c2b4d858|Talk]]
+&lt;/td&gt;&lt;td&gt;
+List of my contributions &amp;#91;alt-y]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c2b4d858</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:22361e38&amp;action=edit ef887076]&lt;br&gt;
+[[MediaWiki_talk:22361e38|Talk]]
+&lt;/td&gt;&lt;td&gt;
+My talk page &amp;#91;alt-n]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:22361e38</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b34e28e3&amp;action=edit 6d66eb21]&lt;br&gt;
+[[MediaWiki_talk:b34e28e3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You don&amp;#39;t have the permissions to move this page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b34e28e3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dc2cdd3b&amp;action=edit 7e77da11]&lt;br&gt;
+[[MediaWiki_talk:dc2cdd3b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+About the project, what you can do, where to find things
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:dc2cdd3b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2032ad70&amp;action=edit 5611ec73]&lt;br&gt;
+[[MediaWiki_talk:2032ad70|Talk]]
+&lt;/td&gt;&lt;td&gt;
+My preferences
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2032ad70</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d87a6e27&amp;action=edit 71b5f228]&lt;br&gt;
+[[MediaWiki_talk:d87a6e27|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Preview your changes, please use this before saving! &amp;#91;alt-p]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d87a6e27</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:44edd577&amp;action=edit fa1fc302]&lt;br&gt;
+[[MediaWiki_talk:44edd577|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Protect this page &amp;#91;alt-=]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:44edd577</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:aa67a70a&amp;action=edit f2eedbde]&lt;br&gt;
+[[MediaWiki_talk:aa67a70a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Load a random page &amp;#91;alt-x]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:aa67a70a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a5de539f&amp;action=edit cafd58f7]&lt;br&gt;
+[[MediaWiki_talk:a5de539f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The list of recent changes in the wiki. &amp;#91;alt-r]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a5de539f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:47cd8236&amp;action=edit ccab4d0f]&lt;br&gt;
+[[MediaWiki_talk:47cd8236|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Recent changes in pages linking to this page &amp;#91;alt-c]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:47cd8236</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:82964371&amp;action=edit 53235bed]&lt;br&gt;
+[[MediaWiki_talk:82964371|Talk]]
+&lt;/td&gt;&lt;td&gt;
+RSS feed for this page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:82964371</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ec76631f&amp;action=edit 8ce4a9b9]&lt;br&gt;
+[[MediaWiki_talk:ec76631f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Save your changes &amp;#91;alt-s]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ec76631f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6d206f30&amp;action=edit a6413695]&lt;br&gt;
+[[MediaWiki_talk:6d206f30|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Search this wiki &amp;#91;alt-f]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6d206f30</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:65256208&amp;action=edit c2dafa2a]&lt;br&gt;
+[[MediaWiki_talk:65256208|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Support Wiktionary
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:65256208</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:280cc8fd&amp;action=edit 73f9a677]&lt;br&gt;
+[[MediaWiki_talk:280cc8fd|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This is a special page, you can&amp;#39;t edit the page itself.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:280cc8fd</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7c7223be&amp;action=edit 764993b1]&lt;br&gt;
+[[MediaWiki_talk:7c7223be|Talk]]
+&lt;/td&gt;&lt;td&gt;
+List of all special pages &amp;#91;alt-q]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7c7223be</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:06fb1d8e&amp;action=edit cb23801a]&lt;br&gt;
+[[MediaWiki_talk:06fb1d8e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Discussion about the content page &amp;#91;alt-t]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:06fb1d8e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:341a8a32&amp;action=edit df81e982]&lt;br&gt;
+[[MediaWiki_talk:341a8a32|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Restore the $1 edits done to this page before it was deleted &amp;#91;alt-d]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:341a8a32</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:377e895f&amp;action=edit 53f18c52]&lt;br&gt;
+[[MediaWiki_talk:377e895f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Remove this page from your watchlist &amp;#91;alt-w]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:377e895f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b1d4103e&amp;action=edit 6143ca0f]&lt;br&gt;
+[[MediaWiki_talk:b1d4103e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Upload images or media files &amp;#91;alt-u]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b1d4103e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3d8cae2f&amp;action=edit 2b3a6ed0]&lt;br&gt;
+[[MediaWiki_talk:3d8cae2f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+My user page &amp;#91;alt-.]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3d8cae2f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:141dd88c&amp;action=edit 1ef2ea9d]&lt;br&gt;
+[[MediaWiki_talk:141dd88c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This page is protected. You can view its source. &amp;#91;alt-e]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:141dd88c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:68a73399&amp;action=edit 07e7b59d]&lt;br&gt;
+[[MediaWiki_talk:68a73399|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Add this page to your watchlist &amp;#91;alt-w]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:68a73399</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:56a787ff&amp;action=edit e24666fc]&lt;br&gt;
+[[MediaWiki_talk:56a787ff|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The list of pages you&amp;#39;re monitoring for changes. &amp;#91;alt-l]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:56a787ff</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3fd46bc1&amp;action=edit fbd416b7]&lt;br&gt;
+[[MediaWiki_talk:3fd46bc1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+List of all wiki pages that link here &amp;#91;alt-b]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3fd46bc1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:47514ec5&amp;action=edit c48d8241]&lt;br&gt;
+[[MediaWiki_talk:47514ec5|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View the last $1 changes; view the last $2 days.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:47514ec5</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6442d081&amp;action=edit 0d69ac51]&lt;br&gt;
+[[MediaWiki_talk:6442d081|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Below are this user&amp;#39;s last &amp;lt;b&amp;gt;$1&amp;lt;/b&amp;gt; changes in the last &amp;lt;b&amp;gt;$2&amp;lt;/b&amp;gt; days.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6442d081</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:507d407a&amp;action=edit f9bb6366]&lt;br&gt;
+[[MediaWiki_talk:507d407a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+ (top)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:507d407a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8c29ba33&amp;action=edit 2b4842fd]&lt;br&gt;
+[[MediaWiki_talk:8c29ba33|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Unblock user
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8c29ba33</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3a879bdb&amp;action=edit 98f7f719]&lt;br&gt;
+[[MediaWiki_talk:3a879bdb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Use the form below to restore write access
+to a previously blocked IP address or username.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3a879bdb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e954d285&amp;action=edit 24e7c6e7]&lt;br&gt;
+[[MediaWiki_talk:e954d285|Talk]]
+&lt;/td&gt;&lt;td&gt;
+unblock
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e954d285</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5e55820a&amp;action=edit eecac2a2]&lt;br&gt;
+[[MediaWiki_talk:5e55820a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+unblocked &amp;quot;$1&amp;quot;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5e55820a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9b2a9354&amp;action=edit f690005f]&lt;br&gt;
+[[MediaWiki_talk:9b2a9354|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Restore deleted page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9b2a9354</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:85cdbe83&amp;action=edit d4adea3f]&lt;br&gt;
+[[MediaWiki_talk:85cdbe83|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Undelete $1 edits
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:85cdbe83</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9ba522b6&amp;action=edit c4635e76]&lt;br&gt;
+[[MediaWiki_talk:9ba522b6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Restore deleted page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9ba522b6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3346239e&amp;action=edit 5dd4b2af]&lt;br&gt;
+[[MediaWiki_talk:3346239e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Restore!
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3346239e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c2a7eb23&amp;action=edit cf1590b6]&lt;br&gt;
+[[MediaWiki_talk:c2a7eb23|Talk]]
+&lt;/td&gt;&lt;td&gt;
+restored &amp;quot;$1&amp;quot;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c2a7eb23</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:40f0db7a&amp;action=edit 5e55bc75]&lt;br&gt;
+[[MediaWiki_talk:40f0db7a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;#91;&amp;#91;$1]] has been successfully restored.
+See &amp;#91;&amp;#91;Wiktionary:Deletion_log]] for a record of recent deletions and restorations.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:40f0db7a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:feaa86c6&amp;action=edit a69aad99]&lt;br&gt;
+[[MediaWiki_talk:feaa86c6|Talk]]
+&lt;/td&gt;&lt;td&gt;
+If you restore the page, all revisions will be restored to the history.
+If a new page with the same name has been created since the deletion, the restored
+revisions will appear in the prior history, and the current revision of the live page
+will not be automatically replaced.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:feaa86c6</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9fa6521f&amp;action=edit d650f0a7]&lt;br&gt;
+[[MediaWiki_talk:9fa6521f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View and restore deleted pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9fa6521f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2e99f66e&amp;action=edit 19b68d7f]&lt;br&gt;
+[[MediaWiki_talk:2e99f66e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The following pages have been deleted but are still in the archive and
+can be restored. The archive may be periodically cleaned out.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2e99f66e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b097a89b&amp;action=edit 2a3672ef]&lt;br&gt;
+[[MediaWiki_talk:b097a89b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Deleted revision as of $1
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b097a89b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:eb2694a4&amp;action=edit d0cd3f87]&lt;br&gt;
+[[MediaWiki_talk:eb2694a4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+$1 revisions archived
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:eb2694a4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3b67a8c9&amp;action=edit fd2a2764]&lt;br&gt;
+[[MediaWiki_talk:3b67a8c9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Unexpected value: &amp;quot;$1&amp;quot;=&amp;quot;$2&amp;quot;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3b67a8c9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:016b68d2&amp;action=edit 74a8f293]&lt;br&gt;
+[[MediaWiki_talk:016b68d2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Unlock database
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:016b68d2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fc3080bf&amp;action=edit ded00b4f]&lt;br&gt;
+[[MediaWiki_talk:fc3080bf|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Yes, I really want to unlock the database.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:fc3080bf</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4df98d29&amp;action=edit 68a2c7e3]&lt;br&gt;
+[[MediaWiki_talk:4df98d29|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Unlock database
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:4df98d29</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:86605aa9&amp;action=edit eb575aa1]&lt;br&gt;
+[[MediaWiki_talk:86605aa9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Database lock removed
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:86605aa9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1896db20&amp;action=edit a7a78572]&lt;br&gt;
+[[MediaWiki_talk:1896db20|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The database has been unlocked.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1896db20</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bd3decce&amp;action=edit 6b32a82f]&lt;br&gt;
+[[MediaWiki_talk:bd3decce|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Unlocking the database will restore the ability of all
+users to edit pages, change their preferences, edit their watchlists, and
+other things requiring changes in the database.
+Please confirm that this is what you intend to do.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bd3decce</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d180e0d9&amp;action=edit 116b2a3b]&lt;br&gt;
+[[MediaWiki_talk:d180e0d9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Unprotect
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d180e0d9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:affff3c2&amp;action=edit a4439c30]&lt;br&gt;
+[[MediaWiki_talk:affff3c2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Reason for unprotecting
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:affff3c2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b8d58125&amp;action=edit 66029ebc]&lt;br&gt;
+[[MediaWiki_talk:b8d58125|Talk]]
+&lt;/td&gt;&lt;td&gt;
+unprotected &amp;#91;&amp;#91;$1]]
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b8d58125</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b15ab8cb&amp;action=edit c77cef4c]&lt;br&gt;
+[[MediaWiki_talk:b15ab8cb|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(Unprotecting &amp;quot;$1&amp;quot;)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b15ab8cb</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:10782968&amp;action=edit caa31f1e]&lt;br&gt;
+[[MediaWiki_talk:10782968|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Unprotect this page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:10782968</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5ed67176&amp;action=edit e17373a9]&lt;br&gt;
+[[MediaWiki_talk:5ed67176|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Unused images
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:5ed67176</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:373709c4&amp;action=edit 13626cea]&lt;br&gt;
+[[MediaWiki_talk:373709c4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;p&amp;gt;Please note that other web sites may link to an image with
+a direct URL, and so may still be listed here despite being
+in active use.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:373709c4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:51055a00&amp;action=edit f6f282e9]&lt;br&gt;
+[[MediaWiki_talk:51055a00|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Unwatch
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:51055a00</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e21d3614&amp;action=edit c7d1cd1e]&lt;br&gt;
+[[MediaWiki_talk:e21d3614|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Stop watching
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e21d3614</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f2f8570d&amp;action=edit 13a1891a]&lt;br&gt;
+[[MediaWiki_talk:f2f8570d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(Updated)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f2f8570d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8bdf057f&amp;action=edit bb73aaaf]&lt;br&gt;
+[[MediaWiki_talk:8bdf057f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Upload file
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8bdf057f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0238951b&amp;action=edit 6be1c689]&lt;br&gt;
+[[MediaWiki_talk:0238951b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Upload file
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0238951b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:88f59c5a&amp;action=edit 693f4b51]&lt;br&gt;
+[[MediaWiki_talk:88f59c5a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Sorry, uploading is disabled.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:88f59c5a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7b8969f2&amp;action=edit 7d4f03ff]&lt;br&gt;
+[[MediaWiki_talk:7b8969f2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Uploaded files
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7b8969f2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:954c2a11&amp;action=edit e57056a0]&lt;br&gt;
+[[MediaWiki_talk:954c2a11|Talk]]
+&lt;/td&gt;&lt;td&gt;
+uploaded &amp;quot;$1&amp;quot;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:954c2a11</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:304f9593&amp;action=edit 8f1603bd]&lt;br&gt;
+[[MediaWiki_talk:304f9593|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Upload error
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:304f9593</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9e71a62c&amp;action=edit 40d977b5]&lt;br&gt;
+[[MediaWiki_talk:9e71a62c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Upload images, sounds, documents etc.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9e71a62c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:955e39f9&amp;action=edit 0bf93eec]&lt;br&gt;
+[[MediaWiki_talk:955e39f9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Upload images
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:955e39f9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0d39c428&amp;action=edit 0d49abe6]&lt;br&gt;
+[[MediaWiki_talk:0d39c428|Talk]]
+&lt;/td&gt;&lt;td&gt;
+upload log
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0d39c428</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1f68a0e7&amp;action=edit 87611d30]&lt;br&gt;
+[[MediaWiki_talk:1f68a0e7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Upload_log
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1f68a0e7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2a430331&amp;action=edit 8aa7bf47]&lt;br&gt;
+[[MediaWiki_talk:2a430331|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Below is a list of the most recent file uploads.
+All times shown are server time (UTC).
+&amp;lt;ul&amp;gt;
+&amp;lt;/ul&amp;gt;
+
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2a430331</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:92dd3bc9&amp;action=edit d2d8bd08]&lt;br&gt;
+[[MediaWiki_talk:92dd3bc9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Not logged in
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:92dd3bc9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fecdb77e&amp;action=edit 09b01f51]&lt;br&gt;
+[[MediaWiki_talk:fecdb77e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You must be &amp;lt;a href=&amp;quot;/wiki/Special:Userlogin&amp;quot;&amp;gt;logged in&amp;lt;/a&amp;gt;
+to upload files.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:fecdb77e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7804cb84&amp;action=edit 85bb3caa]&lt;br&gt;
+[[MediaWiki_talk:7804cb84|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;strong&amp;gt;STOP!&amp;lt;/strong&amp;gt; Before you upload here,
+make sure to read and follow the &amp;lt;a href=&amp;quot;/wiki/Special:Image_use_policy&amp;quot;&amp;gt;image use policy&amp;lt;/a&amp;gt;.
+&amp;lt;p&amp;gt;If a file with the name you are specifying already
+exists on the wiki, it&amp;#39;ll be replaced without warning.
+So unless you mean to update a file, it&amp;#39;s a good idea
+to first check if such a file exists.
+&amp;lt;p&amp;gt;To view or search previously uploaded images,
+go to the &amp;lt;a href=&amp;quot;/wiki/Special:Imagelist&amp;quot;&amp;gt;list of uploaded images&amp;lt;/a&amp;gt;.
+Uploads and deletions are logged on the &amp;lt;a href=&amp;quot;/wiki/Wiktionary:Upload_log&amp;quot;&amp;gt;upload log&amp;lt;/a&amp;gt;.
+&amp;lt;/p&amp;gt;&amp;lt;p&amp;gt;Use the form below to upload new image files for use in
+illustrating your pages.
+On most browsers, you will see a &amp;quot;Browse...&amp;quot; button, which will
+bring up your operating system&amp;#39;s standard file open dialog.
+Choosing a file will fill the name of that file into the text
+field next to the button.
+You must also check the box affirming that you are not
+violating any copyrights by uploading the file.
+Press the &amp;quot;Upload&amp;quot; button to finish the upload.
+This may take some time if you have a slow internet connection.
+&amp;lt;p&amp;gt;The preferred formats are JPEG for photographic images, PNG
+for drawings and other iconic images, and OGG for sounds.
+Please name your files descriptively to avoid confusion.
+To include the image in a page, use a link in the form
+&amp;lt;b&amp;gt;&amp;#91;&amp;#91;Image:file.jpg]]&amp;lt;/b&amp;gt; or &amp;lt;b&amp;gt;&amp;#91;&amp;#91;Image:file.png&amp;#124;alt text]]&amp;lt;/b&amp;gt;
+or &amp;lt;b&amp;gt;&amp;#91;&amp;#91;Media:file.ogg]]&amp;lt;/b&amp;gt; for sounds.
+&amp;lt;p&amp;gt;Please note that as with wiki pages, others may edit or
+delete your uploads if they think it serves the project, and
+you may be blocked from uploading if you abuse the system.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7804cb84</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fb3ef2ae&amp;action=edit 8aae8210]&lt;br&gt;
+[[MediaWiki_talk:fb3ef2ae|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Upload warning
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:fb3ef2ae</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a5430c9b&amp;action=edit 6a080eb0]&lt;br&gt;
+[[MediaWiki_talk:a5430c9b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;b&amp;gt;User rights for &amp;quot;$1&amp;quot; updated&amp;lt;/b&amp;gt;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a5430c9b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:89b73748&amp;action=edit 8b23a826]&lt;br&gt;
+[[MediaWiki_talk:89b73748|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;#39;&amp;#39;&amp;#39;Note:&amp;#39;&amp;#39;&amp;#39; After saving, you have to tell your bowser to get the new version: &amp;#39;&amp;#39;&amp;#39;Mozilla:&amp;#39;&amp;#39;&amp;#39; click &amp;#39;&amp;#39;reload&amp;#39;&amp;#39;(or &amp;#39;&amp;#39;ctrl-r&amp;#39;&amp;#39;), &amp;#39;&amp;#39;&amp;#39;IE / Opera:&amp;#39;&amp;#39;&amp;#39; &amp;#39;&amp;#39;ctrl-f5&amp;#39;&amp;#39;, &amp;#39;&amp;#39;&amp;#39;Safari:&amp;#39;&amp;#39;&amp;#39; &amp;#39;&amp;#39;cmd-r&amp;#39;&amp;#39;, &amp;#39;&amp;#39;&amp;#39;Konqueror&amp;#39;&amp;#39;&amp;#39; &amp;#39;&amp;#39;ctrl-r&amp;#39;&amp;#39;.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:89b73748</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1656c92b&amp;action=edit 97bd6e75]&lt;br&gt;
+[[MediaWiki_talk:1656c92b|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;strong&amp;gt;Tip:&amp;lt;/strong&amp;gt; Use the &amp;#39;Show preview&amp;#39; button to test your new css/js before saving.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1656c92b</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9f62117d&amp;action=edit b51c3667]&lt;br&gt;
+[[MediaWiki_talk:9f62117d|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;#39;&amp;#39;&amp;#39;Remember that you are only previewing your user css, it has not yet been saved!&amp;#39;&amp;#39;&amp;#39;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:9f62117d</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:77541367&amp;action=edit a49220af]&lt;br&gt;
+[[MediaWiki_talk:77541367|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The user name you entered is already in use. Please choose a different name.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:77541367</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:eca4b211&amp;action=edit 2e8efec0]&lt;br&gt;
+[[MediaWiki_talk:eca4b211|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;#39;&amp;#39;&amp;#39;Remember that you are only testing/previewing your user javascript, it has not yet been saved!&amp;#39;&amp;#39;&amp;#39;
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:eca4b211</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:49c670f4&amp;action=edit eb0f23d8]&lt;br&gt;
+[[MediaWiki_talk:49c670f4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Log in
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:49c670f4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fb3467d9&amp;action=edit 271a962f]&lt;br&gt;
+[[MediaWiki_talk:fb3467d9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Log out
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:fb3467d9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e1881ca2&amp;action=edit 0e3f35e1]&lt;br&gt;
+[[MediaWiki_talk:e1881ca2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Mail object returned error:
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e1881ca2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:823fdaf7&amp;action=edit ea81d010]&lt;br&gt;
+[[MediaWiki_talk:823fdaf7|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View user page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:823fdaf7</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f25ef873&amp;action=edit 2ab9a2af]&lt;br&gt;
+[[MediaWiki_talk:f25ef873|Talk]]
+&lt;/td&gt;&lt;td&gt;
+User statistics
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f25ef873</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b704c939&amp;action=edit 903f135d]&lt;br&gt;
+[[MediaWiki_talk:b704c939|Talk]]
+&lt;/td&gt;&lt;td&gt;
+There are &amp;#39;&amp;#39;&amp;#39;$1&amp;#39;&amp;#39;&amp;#39; registered users.
+&amp;#39;&amp;#39;&amp;#39;$2&amp;#39;&amp;#39;&amp;#39; of these are administrators (see $3).
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b704c939</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2da600bf&amp;action=edit c692273d]&lt;br&gt;
+[[MediaWiki_talk:2da600bf|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Version
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2da600bf</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cd20ed80&amp;action=edit 9204f6f2]&lt;br&gt;
+[[MediaWiki_talk:cd20ed80|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This page has been accessed $1 times.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cd20ed80</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b3a212e8&amp;action=edit 023f0549]&lt;br&gt;
+[[MediaWiki_talk:b3a212e8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View ($1) ($2) ($3).
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:b3a212e8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1affdb1e&amp;action=edit db9e2eba]&lt;br&gt;
+[[MediaWiki_talk:1affdb1e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View source
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:1affdb1e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f6336004&amp;action=edit 2e250bd9]&lt;br&gt;
+[[MediaWiki_talk:f6336004|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View discussion
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f6336004</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7da05431&amp;action=edit 4d2466a3]&lt;br&gt;
+[[MediaWiki_talk:7da05431|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wanted pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7da05431</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d91ebf58&amp;action=edit 292b0901]&lt;br&gt;
+[[MediaWiki_talk:d91ebf58|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Watch
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d91ebf58</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d815e414&amp;action=edit ddfeb02c]&lt;br&gt;
+[[MediaWiki_talk:d815e414|Talk]]
+&lt;/td&gt;&lt;td&gt;
+($1 pages watched not counting talk pages;
+$2 total pages edited since cutoff;
+$3...
+&amp;lt;a href=&amp;#39;$4&amp;#39;&amp;gt;show and edit complete list&amp;lt;/a&amp;gt;.)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d815e414</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:780e4559&amp;action=edit d7d3bb79]&lt;br&gt;
+[[MediaWiki_talk:780e4559|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Here&amp;#39;s an alphabetical list of your
+watched pages. Check the boxes of pages you want to remove
+from your watchlist and click the &amp;#39;remove checked&amp;#39; button
+at the bottom of the screen.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:780e4559</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:48a616d9&amp;action=edit db14f0be]&lt;br&gt;
+[[MediaWiki_talk:48a616d9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+My watchlist
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:48a616d9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:690e08f8&amp;action=edit 37074879]&lt;br&gt;
+[[MediaWiki_talk:690e08f8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Your watchlist contains $1 pages.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:690e08f8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:913a8eb4&amp;action=edit 1d490f51]&lt;br&gt;
+[[MediaWiki_talk:913a8eb4|Talk]]
+&lt;/td&gt;&lt;td&gt;
+(for user &amp;quot;$1&amp;quot;)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:913a8eb4</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:983ce9b1&amp;action=edit b5396cea]&lt;br&gt;
+[[MediaWiki_talk:983ce9b1|Talk]]
+&lt;/td&gt;&lt;td&gt;
+checking watched pages for recent edits
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:983ce9b1</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2e5e56e2&amp;action=edit c69b87d2]&lt;br&gt;
+[[MediaWiki_talk:2e5e56e2|Talk]]
+&lt;/td&gt;&lt;td&gt;
+checking recent edits for watched pages
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2e5e56e2</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cdd40087&amp;action=edit 24353550]&lt;br&gt;
+[[MediaWiki_talk:cdd40087|Talk]]
+&lt;/td&gt;&lt;td&gt;
+None of your watched items were edited in the time period displayed.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:cdd40087</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ec51da09&amp;action=edit c873a8c3]&lt;br&gt;
+[[MediaWiki_talk:ec51da09|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Not logged in
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:ec51da09</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7870af11&amp;action=edit 080c4da9]&lt;br&gt;
+[[MediaWiki_talk:7870af11|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You must be &amp;lt;a href=&amp;quot;/wiki/Special:Userlogin&amp;quot;&amp;gt;logged in&amp;lt;/a&amp;gt;
+to modify your watchlist.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7870af11</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2fa65d73&amp;action=edit 89758668]&lt;br&gt;
+[[MediaWiki_talk:2fa65d73|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Watch this page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2fa65d73</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:79260dc8&amp;action=edit d94c2857]&lt;br&gt;
+[[MediaWiki_talk:79260dc8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Watch this page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:79260dc8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:622a355f&amp;action=edit 5b3750aa]&lt;br&gt;
+[[MediaWiki_talk:622a355f|Talk]]
+&lt;/td&gt;&lt;td&gt;
+&amp;lt;h2&amp;gt;Welcome, $1!&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Your account has been created.
+Don&amp;#39;t forget to change your Wiktionary preferences.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:622a355f</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bde1fbba&amp;action=edit 28d0e2a8]&lt;br&gt;
+[[MediaWiki_talk:bde1fbba|Talk]]
+&lt;/td&gt;&lt;td&gt;
+What links here
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:bde1fbba</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:53f0999a&amp;action=edit b3775e04]&lt;br&gt;
+[[MediaWiki_talk:53f0999a|Talk]]
+&lt;/td&gt;&lt;td&gt;
+To be allowed to create accounts in this Wiki you have to &amp;#91;&amp;#91;Special:Userlogin&amp;#124;log]] in and have the appropriate permissions.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:53f0999a</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:901574b0&amp;action=edit 13f7d937]&lt;br&gt;
+[[MediaWiki_talk:901574b0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You are not allowed to create an account
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:901574b0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:28589651&amp;action=edit 8d93b543]&lt;br&gt;
+[[MediaWiki_talk:28589651|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You have to &amp;#91;&amp;#91;Special:Userlogin&amp;#124;login]] to edit pages.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:28589651</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d4e0db33&amp;action=edit 76713eb6]&lt;br&gt;
+[[MediaWiki_talk:d4e0db33|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Login required to edit
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d4e0db33</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7046be67&amp;action=edit 3c46b4af]&lt;br&gt;
+[[MediaWiki_talk:7046be67|Talk]]
+&lt;/td&gt;&lt;td&gt;
+You have to &amp;#91;&amp;#91;Special:Userlogin&amp;#124;login]] to read pages.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:7046be67</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:56f7b6c8&amp;action=edit 27809c2a]&lt;br&gt;
+[[MediaWiki_talk:56f7b6c8|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Login required to read
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:56f7b6c8</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8815fbb3&amp;action=edit 80a9ff8d]&lt;br&gt;
+[[MediaWiki_talk:8815fbb3|Talk]]
+&lt;/td&gt;&lt;td&gt;
+View project page
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:8815fbb3</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d00a5142&amp;action=edit 937cdab5]&lt;br&gt;
+[[MediaWiki_talk:d00a5142|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Wiktionary
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:d00a5142</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a7ee1c5c&amp;action=edit b6ea8219]&lt;br&gt;
+[[MediaWiki_talk:a7ee1c5c|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Below are the last $1 changes in the last &amp;lt;b&amp;gt;$2&amp;lt;/b&amp;gt; hours.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a7ee1c5c</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:deb21c59&amp;action=edit 843af08d]&lt;br&gt;
+[[MediaWiki_talk:deb21c59|Talk]]
+&lt;/td&gt;&lt;td&gt;
+This is a saved version of your watchlist.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:deb21c59</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e77517fa&amp;action=edit 89821357]&lt;br&gt;
+[[MediaWiki_talk:e77517fa|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Show last $1 hours $2 days $3
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:e77517fa</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a9b62164&amp;action=edit d526a8a6]&lt;br&gt;
+[[MediaWiki_talk:a9b62164|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Incorrect parameters to wfQuery()&amp;lt;br /&amp;gt;
+Function: $1&amp;lt;br /&amp;gt;
+Query: $2
+
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:a9b62164</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3314dacf&amp;action=edit d8ecf7db]&lt;br&gt;
+[[MediaWiki_talk:3314dacf|Talk]]
+&lt;/td&gt;&lt;td&gt;
+The password you entered is incorrect. Please try again.
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:3314dacf</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2e0414f0&amp;action=edit 4fe151ac]&lt;br&gt;
+[[MediaWiki_talk:2e0414f0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Differences
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:2e0414f0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0f5ab9c9&amp;action=edit 0a98c2ad]&lt;br&gt;
+[[MediaWiki_talk:0f5ab9c9|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Your email*
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:0f5ab9c9</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f495043e&amp;action=edit 32d0e33a]&lt;br&gt;
+[[MediaWiki_talk:f495043e|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Your user name
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f495043e</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6aa78968&amp;action=edit f8b28bd9]&lt;br&gt;
+[[MediaWiki_talk:6aa78968|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Your nickname (for signatures)
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:6aa78968</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:13a44203&amp;action=edit b48cf014]&lt;br&gt;
+[[MediaWiki_talk:13a44203|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Your password
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:13a44203</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c0ad7f05&amp;action=edit e14a732b]&lt;br&gt;
+[[MediaWiki_talk:c0ad7f05|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Retype password
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:c0ad7f05</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:519f30b5&amp;action=edit 7fc3e5b1]&lt;br&gt;
+[[MediaWiki_talk:519f30b5|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Your real name*
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:519f30b5</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f98650e0&amp;action=edit 5b5af4ea]&lt;br&gt;
+[[MediaWiki_talk:f98650e0|Talk]]
+&lt;/td&gt;&lt;td&gt;
+Your text
+&lt;/td&gt;&lt;td&gt;
+<template lineStart="1"><title>int:f98650e0</title></template>
+&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
+
+</root> \ No newline at end of file
diff --git a/www/wiki/tests/parser/preprocess/All_system_messages.txt b/www/wiki/tests/parser/preprocess/All_system_messages.txt
new file mode 100644
index 00000000..3212ae10
--- /dev/null
+++ b/www/wiki/tests/parser/preprocess/All_system_messages.txt
@@ -0,0 +1,5603 @@
+{{int:57dbe26a}}
+
+<table border=1 width=100%><tr><td>
+'''Name'''
+</td><td>
+'''Default text'''
+</td><td>
+'''Current text'''
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f9cca05b&action=edit f9cca05b]<br>
+[[MediaWiki_talk:f9cca05b|Talk]]
+</td><td>
+$1 moved to $2
+</td><td>
+{{int:f9cca05b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ed065216&action=edit ed065216]<br>
+[[MediaWiki_talk:ed065216|Talk]]
+</td><td>
+/* edit this file to customize the monobook skin for the entire site */
+</td><td>
+{{int:ed065216}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6b21fb79&action=edit 5780daf6]<br>
+[[MediaWiki_talk:6b21fb79|Talk]]
+</td><td>
+About
+</td><td>
+{{int:6b21fb79}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:54f19e13&action=edit 4bd9b804]<br>
+[[MediaWiki_talk:54f19e13|Talk]]
+</td><td>
+Wiktionary:About
+</td><td>
+{{int:54f19e13}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8e17cc1b&action=edit 7be96c69]<br>
+[[MediaWiki_talk:8e17cc1b|Talk]]
+</td><td>
+About Wiktionary
+</td><td>
+{{int:8e17cc1b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4b7f0428&action=edit 69f5ae1e]<br>
+[[MediaWiki_talk:4b7f0428|Talk]]
+</td><td>
++
+</td><td>
+{{int:4b7f0428}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b18a7fba&action=edit ba8c9426]<br>
+[[MediaWiki_talk:b18a7fba|Talk]]
+</td><td>
+n
+</td><td>
+{{int:b18a7fba}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3cfd08b4&action=edit 098256f5]<br>
+[[MediaWiki_talk:3cfd08b4|Talk]]
+</td><td>
+.
+</td><td>
+{{int:3cfd08b4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d00706c5&action=edit 7638fc38]<br>
+[[MediaWiki_talk:d00706c5|Talk]]
+</td><td>
+a
+</td><td>
+{{int:d00706c5}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7bbcdfc9&action=edit 840afed8]<br>
+[[MediaWiki_talk:7bbcdfc9|Talk]]
+</td><td>
+v
+</td><td>
+{{int:7bbcdfc9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0750ed4b&action=edit 9703e6d9]<br>
+[[MediaWiki_talk:0750ed4b|Talk]]
+</td><td>
+&amp;lt;accesskey-contributions&amp;gt;
+</td><td>
+{{int:0750ed4b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:602dda6f&action=edit 7e13f963]<br>
+[[MediaWiki_talk:602dda6f|Talk]]
+</td><td>
+&amp;lt;accesskey-currentevents&amp;gt;
+</td><td>
+{{int:602dda6f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a395260e&action=edit be42f966]<br>
+[[MediaWiki_talk:a395260e|Talk]]
+</td><td>
+d
+</td><td>
+{{int:a395260e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f89faca3&action=edit 89888a71]<br>
+[[MediaWiki_talk:f89faca3|Talk]]
+</td><td>
+e
+</td><td>
+{{int:f89faca3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bc7a3e78&action=edit 7b2ee991]<br>
+[[MediaWiki_talk:bc7a3e78|Talk]]
+</td><td>
+&amp;lt;accesskey-emailuser&amp;gt;
+</td><td>
+{{int:bc7a3e78}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9e9d3613&action=edit fe788279]<br>
+[[MediaWiki_talk:9e9d3613|Talk]]
+</td><td>
+&amp;lt;accesskey-help&amp;gt;
+</td><td>
+{{int:9e9d3613}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7ea0e322&action=edit 4bb7a2e4]<br>
+[[MediaWiki_talk:7ea0e322|Talk]]
+</td><td>
+h
+</td><td>
+{{int:7ea0e322}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4204d3db&action=edit 725cb6bf]<br>
+[[MediaWiki_talk:4204d3db|Talk]]
+</td><td>
+o
+</td><td>
+{{int:4204d3db}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2a92e37a&action=edit a1de2049]<br>
+[[MediaWiki_talk:2a92e37a|Talk]]
+</td><td>
+o
+</td><td>
+{{int:2a92e37a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:68d388ec&action=edit 0542623d]<br>
+[[MediaWiki_talk:68d388ec|Talk]]
+</td><td>
+z
+</td><td>
+{{int:68d388ec}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:18fe1121&action=edit e3f25b72]<br>
+[[MediaWiki_talk:18fe1121|Talk]]
+</td><td>
+i
+</td><td>
+{{int:18fe1121}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6d15983f&action=edit c9d212d3]<br>
+[[MediaWiki_talk:6d15983f|Talk]]
+</td><td>
+m
+</td><td>
+{{int:6d15983f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ecaba7f4&action=edit ac57178f]<br>
+[[MediaWiki_talk:ecaba7f4|Talk]]
+</td><td>
+y
+</td><td>
+{{int:ecaba7f4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:711aec5d&action=edit 6d3ae9a7]<br>
+[[MediaWiki_talk:711aec5d|Talk]]
+</td><td>
+n
+</td><td>
+{{int:711aec5d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9905f56f&action=edit 0a376cab]<br>
+[[MediaWiki_talk:9905f56f|Talk]]
+</td><td>
+&amp;lt;accesskey-portal&amp;gt;
+</td><td>
+{{int:9905f56f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9305eef3&action=edit e72912be]<br>
+[[MediaWiki_talk:9305eef3|Talk]]
+</td><td>
+&amp;lt;accesskey-preferences&amp;gt;
+</td><td>
+{{int:9305eef3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:186ee8a4&action=edit cef51de6]<br>
+[[MediaWiki_talk:186ee8a4|Talk]]
+</td><td>
+p
+</td><td>
+{{int:186ee8a4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:676f28e9&action=edit 0f10afb5]<br>
+[[MediaWiki_talk:676f28e9|Talk]]
+</td><td>
+=
+</td><td>
+{{int:676f28e9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1a09f43e&action=edit 46a4e82c]<br>
+[[MediaWiki_talk:1a09f43e|Talk]]
+</td><td>
+x
+</td><td>
+{{int:1a09f43e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1306607d&action=edit 025b667f]<br>
+[[MediaWiki_talk:1306607d|Talk]]
+</td><td>
+r
+</td><td>
+{{int:1306607d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e14390c4&action=edit 600e8a44]<br>
+[[MediaWiki_talk:e14390c4|Talk]]
+</td><td>
+c
+</td><td>
+{{int:e14390c4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:59b75a10&action=edit 0fde75cd]<br>
+[[MediaWiki_talk:59b75a10|Talk]]
+</td><td>
+s
+</td><td>
+{{int:59b75a10}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0b6fd89e&action=edit 5163ba5b]<br>
+[[MediaWiki_talk:0b6fd89e|Talk]]
+</td><td>
+f
+</td><td>
+{{int:0b6fd89e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ba9e0fc4&action=edit f70dbcff]<br>
+[[MediaWiki_talk:ba9e0fc4|Talk]]
+</td><td>
+&amp;lt;accesskey-sitesupport&amp;gt;
+</td><td>
+{{int:ba9e0fc4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b932fee9&action=edit 8949be8d]<br>
+[[MediaWiki_talk:b932fee9|Talk]]
+</td><td>
+&amp;lt;accesskey-specialpage&amp;gt;
+</td><td>
+{{int:b932fee9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1ac10275&action=edit 59a5e487]<br>
+[[MediaWiki_talk:1ac10275|Talk]]
+</td><td>
+q
+</td><td>
+{{int:1ac10275}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:116fd1b0&action=edit a83f2193]<br>
+[[MediaWiki_talk:116fd1b0|Talk]]
+</td><td>
+t
+</td><td>
+{{int:116fd1b0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ec06f1a7&action=edit 5894e42e]<br>
+[[MediaWiki_talk:ec06f1a7|Talk]]
+</td><td>
+d
+</td><td>
+{{int:ec06f1a7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7f99a8c2&action=edit 2a2a9d13]<br>
+[[MediaWiki_talk:7f99a8c2|Talk]]
+</td><td>
+w
+</td><td>
+{{int:7f99a8c2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:903549e8&action=edit 3a1dcde8]<br>
+[[MediaWiki_talk:903549e8|Talk]]
+</td><td>
+u
+</td><td>
+{{int:903549e8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8f696cc0&action=edit be76a8c2]<br>
+[[MediaWiki_talk:8f696cc0|Talk]]
+</td><td>
+.
+</td><td>
+{{int:8f696cc0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:613ebbad&action=edit e467bdec]<br>
+[[MediaWiki_talk:613ebbad|Talk]]
+</td><td>
+e
+</td><td>
+{{int:613ebbad}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f598b5d6&action=edit 8bbdd8ad]<br>
+[[MediaWiki_talk:f598b5d6|Talk]]
+</td><td>
+w
+</td><td>
+{{int:f598b5d6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:59863979&action=edit f8563593]<br>
+[[MediaWiki_talk:59863979|Talk]]
+</td><td>
+l
+</td><td>
+{{int:59863979}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:00143391&action=edit 016415ff]<br>
+[[MediaWiki_talk:00143391|Talk]]
+</td><td>
+b
+</td><td>
+{{int:00143391}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d4dce921&action=edit c90b0565]<br>
+[[MediaWiki_talk:d4dce921|Talk]]
+</td><td>
+The Password for &#39;$1&#39; has been sent to $2.
+</td><td>
+{{int:d4dce921}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9e6cd678&action=edit 05cb31f3]<br>
+[[MediaWiki_talk:9e6cd678|Talk]]
+</td><td>
+Password sent.
+</td><td>
+{{int:9e6cd678}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:37186ec6&action=edit 6703566b]<br>
+[[MediaWiki_talk:37186ec6|Talk]]
+</td><td>
+Action complete
+</td><td>
+{{int:37186ec6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2bacba53&action=edit 9a954e94]<br>
+[[MediaWiki_talk:2bacba53|Talk]]
+</td><td>
+Added to watchlist
+</td><td>
+{{int:2bacba53}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b28893b8&action=edit cb101aa3]<br>
+[[MediaWiki_talk:b28893b8|Talk]]
+</td><td>
+The page &quot;$1&quot; has been added to your &#91;&#91;Special:Watchlist&#124;watchlist]].
+Future changes to this page and its associated Talk page will be listed there,
+and the page will appear &#39;&#39;&#39;bolded&#39;&#39;&#39; in the &#91;&#91;Special:Recentchanges&#124;list of recent changes]] to
+make it easier to pick out.
+
+&lt;p&gt;If you want to remove the page from your watchlist later, click &quot;Stop watching&quot; in the sidebar.
+</td><td>
+{{int:b28893b8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:291bfe3c&action=edit 788205f7]<br>
+[[MediaWiki_talk:291bfe3c|Talk]]
+</td><td>
++
+</td><td>
+{{int:291bfe3c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0d4d418a&action=edit 69189a95]<br>
+[[MediaWiki_talk:0d4d418a|Talk]]
+</td><td>
+Wiktionary:Administrators
+</td><td>
+{{int:0d4d418a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1b4ceeda&action=edit f61e4837]<br>
+[[MediaWiki_talk:1b4ceeda|Talk]]
+</td><td>
+I affirm that the copyright holder of this file
+agrees to license it under the terms of the $1.
+</td><td>
+{{int:1b4ceeda}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6a720856&action=edit d87c4480]<br>
+[[MediaWiki_talk:6a720856|Talk]]
+</td><td>
+all
+</td><td>
+{{int:6a720856}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f2fab435&action=edit a6623c77]<br>
+[[MediaWiki_talk:f2fab435|Talk]]
+</td><td>
+All system messages
+</td><td>
+{{int:f2fab435}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e57c77b8&action=edit 57dbe26a]<br>
+[[MediaWiki_talk:e57c77b8|Talk]]
+</td><td>
+This is a list of all system messages available in the MediaWiki: namespace.
+</td><td>
+{{int:e57c77b8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ff8db74d&action=edit bf1dccf6]<br>
+[[MediaWiki_talk:ff8db74d|Talk]]
+</td><td>
+All pages
+</td><td>
+{{int:ff8db74d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:89c23e53&action=edit 1ee05de8]<br>
+[[MediaWiki_talk:89c23e53|Talk]]
+</td><td>
+$1 to $2
+</td><td>
+{{int:89c23e53}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8550125b&action=edit 0dc174ae]<br>
+[[MediaWiki_talk:8550125b|Talk]]
+</td><td>
+&lt;font color=red&gt;&lt;b&gt;User $1, you are already logged in!&lt;/b&gt;&lt;/font&gt;&lt;br /&gt;
+
+</td><td>
+{{int:8550125b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3f7be8a8&action=edit 3f1bd6a1]<br>
+[[MediaWiki_talk:3f7be8a8|Talk]]
+</td><td>
+Cannot rollback last edit of &#91;&#91;$1]]
+by &#91;&#91;User:$2&#124;$2]] (&#91;&#91;User talk:$2&#124;Talk]]); someone else has edited or rolled back the page already.
+
+Last edit was by &#91;&#91;User:$3&#124;$3]] (&#91;&#91;User talk:$3&#124;Talk]]).
+</td><td>
+{{int:3f7be8a8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:49a4df39&action=edit 4f70712f]<br>
+[[MediaWiki_talk:49a4df39|Talk]]
+</td><td>
+Oldest pages
+</td><td>
+{{int:49a4df39}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a01e33f4&action=edit cffa50a3]<br>
+[[MediaWiki_talk:a01e33f4|Talk]]
+</td><td>
+and
+</td><td>
+{{int:a01e33f4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:20cb482e&action=edit 801db13e]<br>
+[[MediaWiki_talk:20cb482e|Talk]]
+</td><td>
+Talk for this IP
+</td><td>
+{{int:20cb482e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5bbc19f4&action=edit 07575f81]<br>
+[[MediaWiki_talk:5bbc19f4|Talk]]
+</td><td>
+----&#39;&#39;This is the discussion page for an anonymous user who has not created an account yet or who does not use it. We therefore have to use the numerical &#91;&#91;IP address]] to identify him/her. Such an IP address can be shared by several users. If you are an anonymous user and feel that irrelevant comments have been directed at you, please &#91;&#91;Special:Userlogin&#124;create an account or log in]] to avoid future confusion with other anonymous users.&#39;&#39;
+</td><td>
+{{int:5bbc19f4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9bed5104&action=edit 0a92fab3]<br>
+[[MediaWiki_talk:9bed5104|Talk]]
+</td><td>
+Anonymous user(s) of Wiktionary
+</td><td>
+{{int:9bed5104}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4360c2dc&action=edit 565cecd7]<br>
+[[MediaWiki_talk:4360c2dc|Talk]]
+</td><td>
+Content page
+</td><td>
+{{int:4360c2dc}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d3ee4a57&action=edit ac8af25b]<br>
+[[MediaWiki_talk:d3ee4a57|Talk]]
+</td><td>
+A page of that name already exists, or the
+name you have chosen is not valid.
+Please choose another name.
+</td><td>
+{{int:d3ee4a57}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:494f1af2&action=edit 01d643b6]<br>
+[[MediaWiki_talk:494f1af2|Talk]]
+</td><td>
+View content page
+</td><td>
+{{int:494f1af2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dc93382e&action=edit 4e529571]<br>
+[[MediaWiki_talk:dc93382e|Talk]]
+</td><td>
+SQL query
+</td><td>
+{{int:dc93382e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d12f6023&action=edit 47551563]<br>
+[[MediaWiki_talk:d12f6023|Talk]]
+</td><td>
+Use the form below to make a direct query of the
+database.
+Use single quotes (&#39;like this&#39;) to delimit string literals.
+This can often add considerable load to the server, so please use
+this function sparingly.
+</td><td>
+{{int:d12f6023}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f36efd21&action=edit d8f0b5e0]<br>
+[[MediaWiki_talk:f36efd21|Talk]]
+</td><td>
+Autoblocked because you share an IP address with &quot;$1&quot;. Reason &quot;$2&quot;.
+</td><td>
+{{int:f36efd21}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9503e2b1&action=edit 100ce8a2]<br>
+[[MediaWiki_talk:9503e2b1|Talk]]
+</td><td>
+This action cannot be performed on this page.
+</td><td>
+{{int:9503e2b1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f146770f&action=edit 5c50b102]<br>
+[[MediaWiki_talk:f146770f|Talk]]
+</td><td>
+Image name has been changed to &quot;$1&quot;.
+</td><td>
+{{int:f146770f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a87ee981&action=edit fe89c3de]<br>
+[[MediaWiki_talk:a87ee981|Talk]]
+</td><td>
+&quot;.$1&quot; is not a recommended image file format.
+</td><td>
+{{int:a87ee981}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0222775a&action=edit c7623eeb]<br>
+[[MediaWiki_talk:0222775a|Talk]]
+</td><td>
+Invalid IP address
+</td><td>
+{{int:0222775a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:feabd786&action=edit 798bc46a]<br>
+[[MediaWiki_talk:feabd786|Talk]]
+</td><td>
+Badly formed search query
+</td><td>
+{{int:feabd786}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7e82f04b&action=edit e5493056]<br>
+[[MediaWiki_talk:7e82f04b|Talk]]
+</td><td>
+We could not process your query.
+This is probably because you have attempted to search for a
+word fewer than three letters long, which is not yet supported.
+It could also be that you have mistyped the expression, for
+example &quot;fish and and scales&quot;.
+Please try another query.
+</td><td>
+{{int:7e82f04b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:36ad01d4&action=edit c36e32c1]<br>
+[[MediaWiki_talk:36ad01d4|Talk]]
+</td><td>
+The passwords you entered do not match.
+</td><td>
+{{int:36ad01d4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ab570b90&action=edit 5c0f9f2b]<br>
+[[MediaWiki_talk:ab570b90|Talk]]
+</td><td>
+Bad title
+</td><td>
+{{int:ab570b90}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:73766845&action=edit e9ac7510]<br>
+[[MediaWiki_talk:73766845|Talk]]
+</td><td>
+The requested page title was invalid, empty, or
+an incorrectly linked inter-language or inter-wiki title.
+</td><td>
+{{int:73766845}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ef0a17b1&action=edit ec00742f]<br>
+[[MediaWiki_talk:ef0a17b1|Talk]]
+</td><td>
+(Main)
+</td><td>
+{{int:ef0a17b1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2fe89b37&action=edit 0756a2f3]<br>
+[[MediaWiki_talk:2fe89b37|Talk]]
+</td><td>
+Your user name or IP address has been blocked by $1.
+The reason given is this:&lt;br /&gt;&#39;&#39;$2&#39;&#39;&lt;p&gt;You may contact $1 or one of the other
+&#91;&#91;Wiktionary:Administrators&#124;administrators]] to discuss the block.
+
+Note that you may not use the &quot;email this user&quot; feature unless you have a valid email address registered in your &#91;&#91;Special:Preferences&#124;user preferences]].
+
+Your IP address is $3. Please include this address in any queries you make.
+
+</td><td>
+{{int:2fe89b37}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4153985a&action=edit ee09eebe]<br>
+[[MediaWiki_talk:4153985a|Talk]]
+</td><td>
+User is blocked
+</td><td>
+{{int:4153985a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f8225753&action=edit 387c304e]<br>
+[[MediaWiki_talk:f8225753|Talk]]
+</td><td>
+Block user
+</td><td>
+{{int:f8225753}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:178b4021&action=edit d19404ef]<br>
+[[MediaWiki_talk:178b4021|Talk]]
+</td><td>
+Block succeeded
+</td><td>
+{{int:178b4021}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c9aa5295&action=edit 8c464806]<br>
+[[MediaWiki_talk:c9aa5295|Talk]]
+</td><td>
+&quot;$1&quot; has been blocked.
+&lt;br /&gt;See &#91;&#91;Special:Ipblocklist&#124;IP block list]] to review blocks.
+</td><td>
+{{int:c9aa5295}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d79d9fe6&action=edit ec372bf2]<br>
+[[MediaWiki_talk:d79d9fe6|Talk]]
+</td><td>
+Use the form below to block write access
+from a specific IP address or username.
+This should be done only only to prevent vandalism, and in
+accordance with &#91;&#91;Wiktionary:Policy&#124;policy]].
+Fill in a specific reason below (for example, citing particular
+pages that were vandalized).
+</td><td>
+{{int:d79d9fe6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9a96cfdc&action=edit 1c6c7aa2]<br>
+[[MediaWiki_talk:9a96cfdc|Talk]]
+</td><td>
+block
+</td><td>
+{{int:9a96cfdc}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b81f5cad&action=edit b821b758]<br>
+[[MediaWiki_talk:b81f5cad|Talk]]
+</td><td>
+$1, $2 blocked $3 (expires $4)
+</td><td>
+{{int:b81f5cad}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0871a19a&action=edit 9be87d66]<br>
+[[MediaWiki_talk:0871a19a|Talk]]
+</td><td>
+blocked &quot;$1&quot; with an expiry time of $2
+</td><td>
+{{int:0871a19a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:31534d45&action=edit 4bcce96c]<br>
+[[MediaWiki_talk:31534d45|Talk]]
+</td><td>
+Block_log
+</td><td>
+{{int:31534d45}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f4872c71&action=edit b9902a3c]<br>
+[[MediaWiki_talk:f4872c71|Talk]]
+</td><td>
+This is a log of user blocking and unblocking actions. Automatically
+blocked IP addresses are not be listed. See the &#91;&#91;Special:Ipblocklist&#124;IP block list]] for
+the list of currently operational bans and blocks.
+</td><td>
+{{int:f4872c71}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d8ae34f5&action=edit e9a0daa2]<br>
+[[MediaWiki_talk:d8ae34f5|Talk]]
+</td><td>
+Bold text
+</td><td>
+{{int:d8ae34f5}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:58086558&action=edit 02320399]<br>
+[[MediaWiki_talk:58086558|Talk]]
+</td><td>
+Bold text
+</td><td>
+{{int:58086558}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1d546a7c&action=edit 9bd576b3]<br>
+[[MediaWiki_talk:1d546a7c|Talk]]
+</td><td>
+Book sources
+</td><td>
+{{int:1d546a7c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:809e6557&action=edit 72f7a5ba]<br>
+[[MediaWiki_talk:809e6557|Talk]]
+</td><td>
+Below is a list of links to other sites that
+sell new and used books, and may also have further information
+about books you are looking for.Wiktionary is not affiliated with any of these businesses, and
+this list should not be construed as an endorsement.
+</td><td>
+{{int:809e6557}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:40925079&action=edit fe673f57]<br>
+[[MediaWiki_talk:40925079|Talk]]
+</td><td>
+Broken Redirects
+</td><td>
+{{int:40925079}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3953d564&action=edit 283e89cc]<br>
+[[MediaWiki_talk:3953d564|Talk]]
+</td><td>
+The following redirects link to a non-existing pages.
+</td><td>
+{{int:3953d564}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f12ee4ee&action=edit 741bd9a7]<br>
+[[MediaWiki_talk:f12ee4ee|Talk]]
+</td><td>
+Bug reports
+</td><td>
+{{int:f12ee4ee}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1e9054cf&action=edit 7cc56699]<br>
+[[MediaWiki_talk:1e9054cf|Talk]]
+</td><td>
+Wiktionary:Bug_reports
+</td><td>
+{{int:1e9054cf}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cfea7660&action=edit eaac0dcf]<br>
+[[MediaWiki_talk:cfea7660|Talk]]
+</td><td>
+Bureaucrat_log
+</td><td>
+{{int:cfea7660}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:04cf1ba3&action=edit cc1544ab]<br>
+[[MediaWiki_talk:04cf1ba3|Talk]]
+</td><td>
+Rights for user &quot;$1&quot; set &quot;$2&quot;
+</td><td>
+{{int:04cf1ba3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a0750047&action=edit 5eb1e911]<br>
+[[MediaWiki_talk:a0750047|Talk]]
+</td><td>
+The action you have requested can only be
+performed by sysops with &quot;bureaucrat&quot; status.
+</td><td>
+{{int:a0750047}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4837977b&action=edit f523b504]<br>
+[[MediaWiki_talk:4837977b|Talk]]
+</td><td>
+Bureaucrat access required
+</td><td>
+{{int:4837977b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:590988e2&action=edit f06de085]<br>
+[[MediaWiki_talk:590988e2|Talk]]
+</td><td>
+by date
+</td><td>
+{{int:590988e2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c08c3f0d&action=edit e51a8413]<br>
+[[MediaWiki_talk:c08c3f0d|Talk]]
+</td><td>
+by name
+</td><td>
+{{int:c08c3f0d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ede279e9&action=edit 43ba766a]<br>
+[[MediaWiki_talk:ede279e9|Talk]]
+</td><td>
+by size
+</td><td>
+{{int:ede279e9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e1ad9b35&action=edit 075fc8df]<br>
+[[MediaWiki_talk:e1ad9b35|Talk]]
+</td><td>
+The following is a cached copy of the requested page, and may not be up to date.
+</td><td>
+{{int:e1ad9b35}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:77dfd213&action=edit 4fd0653c]<br>
+[[MediaWiki_talk:77dfd213|Talk]]
+</td><td>
+Cancel
+</td><td>
+{{int:77dfd213}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:03442eec&action=edit ee57c22e]<br>
+[[MediaWiki_talk:03442eec|Talk]]
+</td><td>
+Could not delete the page or image specified. (It may have already been deleted by someone else.)
+</td><td>
+{{int:03442eec}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:27b55ed3&action=edit 4b739ac2]<br>
+[[MediaWiki_talk:27b55ed3|Talk]]
+</td><td>
+Cannot revert edit; last contributor is only author of this page.
+</td><td>
+{{int:27b55ed3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6ccb6007&action=edit 50b9e781]<br>
+[[MediaWiki_talk:6ccb6007|Talk]]
+</td><td>
+Categories
+</td><td>
+{{int:6ccb6007}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a3c686e7&action=edit 5ccbf9c9]<br>
+[[MediaWiki_talk:a3c686e7|Talk]]
+</td><td>
+category
+</td><td>
+{{int:a3c686e7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f2ff5f46&action=edit 7245f61e]<br>
+[[MediaWiki_talk:f2ff5f46|Talk]]
+</td><td>
+Articles in category &quot;$1&quot;
+</td><td>
+{{int:f2ff5f46}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cc60b2e2&action=edit 3dfd4581]<br>
+[[MediaWiki_talk:cc60b2e2|Talk]]
+</td><td>
+Change password
+</td><td>
+{{int:cc60b2e2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8aa57de6&action=edit 49a04ba4]<br>
+[[MediaWiki_talk:8aa57de6|Talk]]
+</td><td>
+changes
+</td><td>
+{{int:8aa57de6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cf723c59&action=edit 4f1b1dbe]<br>
+[[MediaWiki_talk:cf723c59|Talk]]
+</td><td>
+Columns
+</td><td>
+{{int:cf723c59}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2a4f8ff8&action=edit 99be507a]<br>
+[[MediaWiki_talk:2a4f8ff8|Talk]]
+</td><td>
+ (comment)
+</td><td>
+{{int:2a4f8ff8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9833df65&action=edit 978cce5f]<br>
+[[MediaWiki_talk:9833df65|Talk]]
+</td><td>
+Compare selected versions
+</td><td>
+{{int:9833df65}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:04a21221&action=edit d0c4047c]<br>
+[[MediaWiki_talk:04a21221|Talk]]
+</td><td>
+Confirm
+</td><td>
+{{int:04a21221}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b8e469fe&action=edit bb4bf8de]<br>
+[[MediaWiki_talk:b8e469fe|Talk]]
+</td><td>
+Yes, I really want to delete this.
+</td><td>
+{{int:b8e469fe}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7773ad82&action=edit 16805d57]<br>
+[[MediaWiki_talk:7773ad82|Talk]]
+</td><td>
+Confirm delete
+</td><td>
+{{int:7773ad82}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:87358bf0&action=edit 872e01c0]<br>
+[[MediaWiki_talk:87358bf0|Talk]]
+</td><td>
+You are about to permanently delete a page
+or image along with all of its history from the database.
+Please confirm that you intend to do this, that you understand the
+consequences, and that you are doing this in accordance with
+&#91;&#91;Wiktionary:Policy]].
+</td><td>
+{{int:87358bf0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b46f1463&action=edit 96a48c11]<br>
+[[MediaWiki_talk:b46f1463|Talk]]
+</td><td>
+Confirm protection
+</td><td>
+{{int:b46f1463}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e382b883&action=edit e76ab37d]<br>
+[[MediaWiki_talk:e382b883|Talk]]
+</td><td>
+Do you really want to protect this page?
+</td><td>
+{{int:e382b883}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:33be1711&action=edit 306661e6]<br>
+[[MediaWiki_talk:33be1711|Talk]]
+</td><td>
+Confirm unprotection
+</td><td>
+{{int:33be1711}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2d82e05f&action=edit 4df1abe3]<br>
+[[MediaWiki_talk:2d82e05f|Talk]]
+</td><td>
+Do you really want to unprotect this page?
+</td><td>
+{{int:2d82e05f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7f051e88&action=edit 0858695c]<br>
+[[MediaWiki_talk:7f051e88|Talk]]
+</td><td>
+Characters of context per line
+</td><td>
+{{int:7f051e88}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7127b581&action=edit e9d81e50]<br>
+[[MediaWiki_talk:7127b581|Talk]]
+</td><td>
+Lines to show per hit
+</td><td>
+{{int:7127b581}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9850ceab&action=edit df5b918c]<br>
+[[MediaWiki_talk:9850ceab|Talk]]
+</td><td>
+contribs
+</td><td>
+{{int:9850ceab}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b688a4d7&action=edit ab48ec14]<br>
+[[MediaWiki_talk:b688a4d7|Talk]]
+</td><td>
+For $1
+</td><td>
+{{int:b688a4d7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:aa11023f&action=edit 9d5b6e5e]<br>
+[[MediaWiki_talk:aa11023f|Talk]]
+</td><td>
+User contributions
+</td><td>
+{{int:aa11023f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a420abf6&action=edit 521307dd]<br>
+[[MediaWiki_talk:a420abf6|Talk]]
+</td><td>
+Content is available under $1.
+</td><td>
+{{int:a420abf6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fc8c1b42&action=edit 5327fdcf]<br>
+[[MediaWiki_talk:fc8c1b42|Talk]]
+</td><td>
+Wiktionary:Copyrights
+</td><td>
+{{int:fc8c1b42}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:52a98e51&action=edit f6652583]<br>
+[[MediaWiki_talk:52a98e51|Talk]]
+</td><td>
+Wiktionary copyright
+</td><td>
+{{int:52a98e51}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:25a1afd7&action=edit 731cc8a6]<br>
+[[MediaWiki_talk:25a1afd7|Talk]]
+</td><td>
+Please note that all contributions to Wiktionary are
+considered to be released under the GNU Free Documentation License
+(see $1 for details).
+If you don&#39;t want your writing to be edited mercilessly and redistributed
+at will, then don&#39;t submit it here.&lt;br /&gt;
+You are also promising us that you wrote this yourself, or copied it from a
+public domain or similar free resource.
+&lt;strong&gt;DO NOT SUBMIT COPYRIGHTED WORK WITHOUT PERMISSION!&lt;/strong&gt;
+</td><td>
+{{int:25a1afd7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:465ca4b8&action=edit e04b3a96]<br>
+[[MediaWiki_talk:465ca4b8|Talk]]
+</td><td>
+Couldn&#39;t remove item &#39;$1&#39;...
+</td><td>
+{{int:465ca4b8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3724dfa6&action=edit 19fd5658]<br>
+[[MediaWiki_talk:3724dfa6|Talk]]
+</td><td>
+Create new account
+</td><td>
+{{int:3724dfa6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a4a1f0cb&action=edit b10d6306]<br>
+[[MediaWiki_talk:a4a1f0cb|Talk]]
+</td><td>
+by email
+</td><td>
+{{int:a4a1f0cb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:07f81f3c&action=edit dce81611]<br>
+[[MediaWiki_talk:07f81f3c|Talk]]
+</td><td>
+cur
+</td><td>
+{{int:07f81f3c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:42b921f8&action=edit 81c8f458]<br>
+[[MediaWiki_talk:42b921f8|Talk]]
+</td><td>
+Current events
+</td><td>
+{{int:42b921f8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:78892831&action=edit 23418566]<br>
+[[MediaWiki_talk:78892831|Talk]]
+</td><td>
+Current revision
+</td><td>
+{{int:78892831}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e4838ed0&action=edit 66409316]<br>
+[[MediaWiki_talk:e4838ed0|Talk]]
+</td><td>
+Database error
+</td><td>
+{{int:e4838ed0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:507be676&action=edit 3b538bdf]<br>
+[[MediaWiki_talk:507be676|Talk]]
+</td><td>
+Date format
+</td><td>
+{{int:507be676}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0bfa85bb&action=edit 34c01d3c]<br>
+[[MediaWiki_talk:0bfa85bb|Talk]]
+</td><td>
+A database query syntax error has occurred.
+This could be because of an illegal search query (see $5),
+or it may indicate a bug in the software.
+The last attempted database query was:
+&lt;blockquote&gt;&lt;tt&gt;$1&lt;/tt&gt;&lt;/blockquote&gt;
+from within function &quot;&lt;tt&gt;$2&lt;/tt&gt;&quot;.
+MySQL returned error &quot;&lt;tt&gt;$3: $4&lt;/tt&gt;&quot;.
+</td><td>
+{{int:0bfa85bb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:39d82941&action=edit f6e1bcbd]<br>
+[[MediaWiki_talk:39d82941|Talk]]
+</td><td>
+A database query syntax error has occurred.
+The last attempted database query was:
+&quot;$1&quot;
+from within function &quot;$2&quot;.
+MySQL returned error &quot;$3: $4&quot;.
+
+</td><td>
+{{int:39d82941}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ae14da43&action=edit d4234aad]<br>
+[[MediaWiki_talk:ae14da43|Talk]]
+</td><td>
+Dead-end pages
+</td><td>
+{{int:ae14da43}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bd604d99&action=edit 32faaeca]<br>
+[[MediaWiki_talk:bd604d99|Talk]]
+</td><td>
+Debug
+</td><td>
+{{int:bd604d99}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6f349a89&action=edit f674a4a1]<br>
+[[MediaWiki_talk:6f349a89|Talk]]
+</td><td>
+Search in these namespaces by default:
+</td><td>
+{{int:6f349a89}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:801b725b&action=edit 93c2c32b]<br>
+[[MediaWiki_talk:801b725b|Talk]]
+</td><td>
+Wiktionary e-mail
+</td><td>
+{{int:801b725b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f6fdbe48&action=edit 9485989f]<br>
+[[MediaWiki_talk:f6fdbe48|Talk]]
+</td><td>
+Delete
+</td><td>
+{{int:f6fdbe48}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:728e102f&action=edit 070ad01c]<br>
+[[MediaWiki_talk:728e102f|Talk]]
+</td><td>
+Reason for deletion
+</td><td>
+{{int:728e102f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:784f094b&action=edit abb03e0b]<br>
+[[MediaWiki_talk:784f094b|Talk]]
+</td><td>
+deleted &quot;$1&quot;
+</td><td>
+{{int:784f094b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b40dc398&action=edit 81545b85]<br>
+[[MediaWiki_talk:b40dc398|Talk]]
+</td><td>
+&quot;$1&quot; has been deleted.
+See $2 for a record of recent deletions.
+</td><td>
+{{int:b40dc398}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:64a8bf46&action=edit 6f4d03ee]<br>
+[[MediaWiki_talk:64a8bf46|Talk]]
+</td><td>
+del
+</td><td>
+{{int:64a8bf46}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3316ac85&action=edit c423c282]<br>
+[[MediaWiki_talk:3316ac85|Talk]]
+</td><td>
+Delete page
+</td><td>
+{{int:3316ac85}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a2496a13&action=edit a3173eab]<br>
+[[MediaWiki_talk:a2496a13|Talk]]
+</td><td>
+(Deleting &quot;$1&quot;)
+</td><td>
+{{int:a2496a13}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9d817726&action=edit b93901eb]<br>
+[[MediaWiki_talk:9d817726|Talk]]
+</td><td>
+Delete this page
+</td><td>
+{{int:9d817726}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:49653e1b&action=edit 58f0b919]<br>
+[[MediaWiki_talk:49653e1b|Talk]]
+</td><td>
+deletion log
+</td><td>
+{{int:49653e1b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d73442e4&action=edit c5ee36a7]<br>
+[[MediaWiki_talk:d73442e4|Talk]]
+</td><td>
+Deletion_log
+</td><td>
+{{int:d73442e4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2349bb58&action=edit 6526e633]<br>
+[[MediaWiki_talk:2349bb58|Talk]]
+</td><td>
+Below is a list of the most recent deletions.
+All times shown are server time (UTC).
+&lt;ul&gt;
+&lt;/ul&gt;
+
+</td><td>
+{{int:2349bb58}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:381bedfc&action=edit 015693e1]<br>
+[[MediaWiki_talk:381bedfc|Talk]]
+</td><td>
+For developer use only
+</td><td>
+{{int:381bedfc}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:52713d3d&action=edit 9c6f4cd5]<br>
+[[MediaWiki_talk:52713d3d|Talk]]
+</td><td>
+The action you have requested can only be
+performed by users with &quot;developer&quot; status.
+See $1.
+</td><td>
+{{int:52713d3d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6cca6111&action=edit 59afe6b0]<br>
+[[MediaWiki_talk:6cca6111|Talk]]
+</td><td>
+Developer access required
+</td><td>
+{{int:6cca6111}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d23a1c9f&action=edit 75a0ee1b]<br>
+[[MediaWiki_talk:d23a1c9f|Talk]]
+</td><td>
+diff
+</td><td>
+{{int:d23a1c9f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b16f1f66&action=edit 48d53c6e]<br>
+[[MediaWiki_talk:b16f1f66|Talk]]
+</td><td>
+(Difference between revisions)
+</td><td>
+{{int:b16f1f66}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3c1c1596&action=edit 657b3530]<br>
+[[MediaWiki_talk:3c1c1596|Talk]]
+</td><td>
+Wiktionary:General_disclaimer
+</td><td>
+{{int:3c1c1596}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2cbbc29e&action=edit 774706d2]<br>
+[[MediaWiki_talk:2cbbc29e|Talk]]
+</td><td>
+Disclaimers
+</td><td>
+{{int:2cbbc29e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ac57c500&action=edit c06b805b]<br>
+[[MediaWiki_talk:ac57c500|Talk]]
+</td><td>
+Double Redirects
+</td><td>
+{{int:ac57c500}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ba6ba737&action=edit 49eadc1d]<br>
+[[MediaWiki_talk:ba6ba737|Talk]]
+</td><td>
+&lt;b&gt;Attention:&lt;/b&gt; This list may contain false positives. That usually means there is additional text with links below the first #REDIRECT.&lt;br /&gt;
+Each row contains links to the first and second redirect, as well as the first line of the second redirect text, usually giving the &quot;real&quot; target page, which the first redirect should point to.
+</td><td>
+{{int:ba6ba737}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5301648d&action=edit 9ead47a8]<br>
+[[MediaWiki_talk:5301648d|Talk]]
+</td><td>
+Edit
+</td><td>
+{{int:5301648d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:299ca80d&action=edit 74940f72]<br>
+[[MediaWiki_talk:299ca80d|Talk]]
+</td><td>
+The edit comment was: &quot;&lt;i&gt;$1&lt;/i&gt;&quot;.
+</td><td>
+{{int:299ca80d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1af657fb&action=edit 3b56e95b]<br>
+[[MediaWiki_talk:1af657fb|Talk]]
+</td><td>
+Edit conflict: $1
+</td><td>
+{{int:1af657fb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8b46c7e0&action=edit 10d395b9]<br>
+[[MediaWiki_talk:8b46c7e0|Talk]]
+</td><td>
+Edit the current version of this page
+</td><td>
+{{int:8b46c7e0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:47f4389a&action=edit a1524e37]<br>
+[[MediaWiki_talk:47f4389a|Talk]]
+</td><td>
+Editing help
+</td><td>
+{{int:47f4389a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:56815513&action=edit 7072deb5]<br>
+[[MediaWiki_talk:56815513|Talk]]
+</td><td>
+Help:Editing
+</td><td>
+{{int:56815513}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3b9ba1a2&action=edit f34946be]<br>
+[[MediaWiki_talk:3b9ba1a2|Talk]]
+</td><td>
+Editing $1
+</td><td>
+{{int:3b9ba1a2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5e6bd9f3&action=edit bd55ff2e]<br>
+[[MediaWiki_talk:5e6bd9f3|Talk]]
+</td><td>
+&lt;strong&gt;WARNING: You are editing an out-of-date
+revision of this page.
+If you save it, any changes made since this revision will be lost.&lt;/strong&gt;
+
+</td><td>
+{{int:5e6bd9f3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:51905619&action=edit 965b4116]<br>
+[[MediaWiki_talk:51905619|Talk]]
+</td><td>
+edit
+</td><td>
+{{int:51905619}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2138f524&action=edit 8bc15909]<br>
+[[MediaWiki_talk:2138f524|Talk]]
+</td><td>
+Edit this page
+</td><td>
+{{int:2138f524}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:96c0b2c5&action=edit 4a27c333]<br>
+[[MediaWiki_talk:96c0b2c5|Talk]]
+</td><td>
+Disable e-mail from other users
+</td><td>
+{{int:96c0b2c5}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8a275a27&action=edit e4573eb4]<br>
+[[MediaWiki_talk:8a275a27|Talk]]
+</td><td>
+Fields marked with a star (*) are optional. Storing an email address enables people to contact you through the website without you having to reveal your
+email address to them, and it can be used to send you a new password if you forget it.&lt;br /&gt;&lt;br /&gt;Your real name, if you choose to provide it, will be used for giving you attribution for your work.
+</td><td>
+{{int:8a275a27}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:34a573ac&action=edit b29c18eb]<br>
+[[MediaWiki_talk:34a573ac|Talk]]
+</td><td>
+From
+</td><td>
+{{int:34a573ac}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1653aeb3&action=edit 8d8c0edf]<br>
+[[MediaWiki_talk:1653aeb3|Talk]]
+</td><td>
+Message
+</td><td>
+{{int:1653aeb3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ac5cfb8e&action=edit a9b033ab]<br>
+[[MediaWiki_talk:ac5cfb8e|Talk]]
+</td><td>
+E-mail user
+</td><td>
+{{int:ac5cfb8e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e3fc4fe2&action=edit eb6bf1bb]<br>
+[[MediaWiki_talk:e3fc4fe2|Talk]]
+</td><td>
+If this user has entered a valid e-mail address in
+his or her user preferences, the form below will send a single message.
+The e-mail address you entered in your user preferences will appear
+as the &quot;From&quot; address of the mail, so the recipient will be able
+to reply.
+</td><td>
+{{int:e3fc4fe2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:145da553&action=edit 05072b51]<br>
+[[MediaWiki_talk:145da553|Talk]]
+</td><td>
+Send
+</td><td>
+{{int:145da553}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:833a22dc&action=edit b1d3f3e4]<br>
+[[MediaWiki_talk:833a22dc|Talk]]
+</td><td>
+E-mail sent
+</td><td>
+{{int:833a22dc}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e3935836&action=edit 2effa7aa]<br>
+[[MediaWiki_talk:e3935836|Talk]]
+</td><td>
+Your e-mail message has been sent.
+</td><td>
+{{int:e3935836}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c8dba338&action=edit 275a0d68]<br>
+[[MediaWiki_talk:c8dba338|Talk]]
+</td><td>
+Subject
+</td><td>
+{{int:c8dba338}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c35740f4&action=edit 88b0fd50]<br>
+[[MediaWiki_talk:c35740f4|Talk]]
+</td><td>
+To
+</td><td>
+{{int:c35740f4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ac5cfb8e&action=edit a9b033ab]<br>
+[[MediaWiki_talk:ac5cfb8e|Talk]]
+</td><td>
+E-mail this user
+</td><td>
+{{int:ac5cfb8e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6b0a234b&action=edit 698a308a]<br>
+[[MediaWiki_talk:6b0a234b|Talk]]
+</td><td>
+Enter a reason for the lock, including an estimate
+of when the lock will be released
+</td><td>
+{{int:6b0a234b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7f2f6a15&action=edit 11f9578d]<br>
+[[MediaWiki_talk:7f2f6a15|Talk]]
+</td><td>
+Error
+</td><td>
+{{int:7f2f6a15}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:53ee1378&action=edit aa2d1eba]<br>
+[[MediaWiki_talk:53ee1378|Talk]]
+</td><td>
+Error
+</td><td>
+{{int:53ee1378}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8aedeece&action=edit a1c634a7]<br>
+[[MediaWiki_talk:8aedeece|Talk]]
+</td><td>
+content before blanking was:
+</td><td>
+{{int:8aedeece}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:42abde88&action=edit eba6d64f]<br>
+[[MediaWiki_talk:42abde88|Talk]]
+</td><td>
+page was empty
+</td><td>
+{{int:42abde88}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dd028a5c&action=edit fe80d230]<br>
+[[MediaWiki_talk:dd028a5c|Talk]]
+</td><td>
+content was:
+</td><td>
+{{int:dd028a5c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b7845dfb&action=edit 1a7999fa]<br>
+[[MediaWiki_talk:b7845dfb|Talk]]
+</td><td>
+Someone else has changed this page since you
+started editing it.
+The upper text area contains the page text as it currently exists.
+Your changes are shown in the lower text area.
+You will have to merge your changes into the existing text.
+&lt;b&gt;Only&lt;/b&gt; the text in the upper text area will be saved when you
+press &quot;Save page&quot;.
+&lt;p&gt;
+</td><td>
+{{int:b7845dfb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f3e4fadb&action=edit 51713409]<br>
+[[MediaWiki_talk:f3e4fadb|Talk]]
+</td><td>
+Export pages
+</td><td>
+{{int:f3e4fadb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b68aeee1&action=edit bf364325]<br>
+[[MediaWiki_talk:b68aeee1|Talk]]
+</td><td>
+Include only the current revision, not the full history
+</td><td>
+{{int:b68aeee1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7e884d79&action=edit eddfb839]<br>
+[[MediaWiki_talk:7e884d79|Talk]]
+</td><td>
+You can export the text and editing history of a particular
+page or set of pages wrapped in some XML; this can then be imported into another
+wiki running MediaWiki software, transformed, or just kept for your private
+amusement.
+</td><td>
+{{int:7e884d79}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a34d80e8&action=edit 8f95a409]<br>
+[[MediaWiki_talk:a34d80e8|Talk]]
+</td><td>
+http&#58;//www.example.com link title
+</td><td>
+{{int:a34d80e8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:117651f0&action=edit 481904c0]<br>
+[[MediaWiki_talk:117651f0|Talk]]
+</td><td>
+External link (remember http&#58;// prefix)
+</td><td>
+{{int:117651f0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f11042a4&action=edit e75bc045]<br>
+[[MediaWiki_talk:f11042a4|Talk]]
+</td><td>
+FAQ
+</td><td>
+{{int:f11042a4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2e891e10&action=edit 5b772c96]<br>
+[[MediaWiki_talk:2e891e10|Talk]]
+</td><td>
+Wiktionary:FAQ
+</td><td>
+{{int:2e891e10}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:86edbc13&action=edit 62f8af98]<br>
+[[MediaWiki_talk:86edbc13|Talk]]
+</td><td>
+Feed:
+</td><td>
+{{int:86edbc13}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2727dd90&action=edit 6c916412]<br>
+[[MediaWiki_talk:2727dd90|Talk]]
+</td><td>
+Could not copy file &quot;$1&quot; to &quot;$2&quot;.
+</td><td>
+{{int:2727dd90}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e60a2c42&action=edit d393dbbc]<br>
+[[MediaWiki_talk:e60a2c42|Talk]]
+</td><td>
+Could not delete file &quot;$1&quot;.
+</td><td>
+{{int:e60a2c42}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d7b25eeb&action=edit 6dace2d5]<br>
+[[MediaWiki_talk:d7b25eeb|Talk]]
+</td><td>
+Summary
+</td><td>
+{{int:d7b25eeb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a3cbb98d&action=edit 08deae8d]<br>
+[[MediaWiki_talk:a3cbb98d|Talk]]
+</td><td>
+Filename
+</td><td>
+{{int:a3cbb98d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c8f6c94d&action=edit 35c4ded6]<br>
+[[MediaWiki_talk:c8f6c94d|Talk]]
+</td><td>
+Could not find file &quot;$1&quot;.
+</td><td>
+{{int:c8f6c94d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b9b56972&action=edit 6d195b75]<br>
+[[MediaWiki_talk:b9b56972|Talk]]
+</td><td>
+Could not rename file &quot;$1&quot; to &quot;$2&quot;.
+</td><td>
+{{int:b9b56972}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0a6a1eb6&action=edit 1ffce53a]<br>
+[[MediaWiki_talk:0a6a1eb6|Talk]]
+</td><td>
+Source
+</td><td>
+{{int:0a6a1eb6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0f0e70a0&action=edit 040e2ba8]<br>
+[[MediaWiki_talk:0f0e70a0|Talk]]
+</td><td>
+Copyright status
+</td><td>
+{{int:0f0e70a0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:45eaa53f&action=edit 79423d38]<br>
+[[MediaWiki_talk:45eaa53f|Talk]]
+</td><td>
+File &quot;$1&quot; uploaded successfully.
+Please follow this link: $2 to the description page and fill
+in information about the file, such as where it came from, when it was
+created and by whom, and anything else you may know about it.
+</td><td>
+{{int:45eaa53f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a303ff06&action=edit 416cf9e4]<br>
+[[MediaWiki_talk:a303ff06|Talk]]
+</td><td>
+Error: could not submit form
+</td><td>
+{{int:a303ff06}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dc08787f&action=edit ada66d8e]<br>
+[[MediaWiki_talk:dc08787f|Talk]]
+</td><td>
+From Wiktionary
+</td><td>
+{{int:dc08787f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c4c83db8&action=edit f8d783cd]<br>
+[[MediaWiki_talk:c4c83db8|Talk]]
+</td><td>
+fetching image list
+</td><td>
+{{int:c4c83db8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2e0b45f2&action=edit 1ec558a6]<br>
+[[MediaWiki_talk:2e0b45f2|Talk]]
+</td><td>
+Go
+</td><td>
+{{int:2e0b45f2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:749351b4&action=edit 49a50bdf]<br>
+[[MediaWiki_talk:749351b4|Talk]]
+</td><td>
+
+&lt;!-- SiteSearch Google --&gt;
+&lt;FORM method=GET action=&quot;http&#58;//www.google.com/search&quot;&gt;
+&lt;TABLE bgcolor=&quot;#FFFFFF&quot;&gt;&lt;tr&gt;&lt;td&gt;
+&lt;A HREF=&quot;http&#58;//www.google.com/&quot;&gt;
+&lt;IMG SRC=&quot;http&#58;//www.google.com/logos/Logo_40wht.gif&quot;
+border=&quot;0&quot; ALT=&quot;Google&quot;&gt;&lt;/A&gt;
+&lt;/td&gt;
+&lt;td&gt;
+&lt;INPUT TYPE=text name=q size=31 maxlength=255 value=&quot;$1&quot;&gt;
+&lt;INPUT type=submit name=btnG VALUE=&quot;Google Search&quot;&gt;
+&lt;font size=-1&gt;
+&lt;input type=hidden name=domains value=&quot;http&#58;//tl.wiktionary.org&quot;&gt;&lt;br /&gt;&lt;input type=radio name=sitesearch value=&quot;&quot;&gt; WWW &lt;input type=radio name=sitesearch value=&quot;http&#58;//tl.wiktionary.org&quot; checked&gt; http&#58;//tl.wiktionary.org &lt;br /&gt;
+&lt;input type=&#39;hidden&#39; name=&#39;ie&#39; value=&#39;$2&#39;&gt;
+&lt;input type=&#39;hidden&#39; name=&#39;oe&#39; value=&#39;$2&#39;&gt;
+&lt;/font&gt;
+&lt;/td&gt;&lt;/tr&gt;&lt;/TABLE&gt;
+&lt;/FORM&gt;
+&lt;!-- SiteSearch Google --&gt;
+</td><td>
+{{int:749351b4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:489b474b&action=edit 8da95a41]<br>
+[[MediaWiki_talk:489b474b|Talk]]
+</td><td>
+Fill in from browser
+</td><td>
+{{int:489b474b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:237cf168&action=edit 7f401fbb]<br>
+[[MediaWiki_talk:237cf168|Talk]]
+</td><td>
+Headline text
+</td><td>
+{{int:237cf168}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:91b3bc72&action=edit c4eef2f5]<br>
+[[MediaWiki_talk:91b3bc72|Talk]]
+</td><td>
+Level 2 headline
+</td><td>
+{{int:91b3bc72}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c47ae153&action=edit 92005ecf]<br>
+[[MediaWiki_talk:c47ae153|Talk]]
+</td><td>
+Help
+</td><td>
+{{int:c47ae153}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:56203224&action=edit 9ca36083]<br>
+[[MediaWiki_talk:56203224|Talk]]
+</td><td>
+Help:Contents
+</td><td>
+{{int:56203224}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:34d8b60f&action=edit 93c8c96b]<br>
+[[MediaWiki_talk:34d8b60f|Talk]]
+</td><td>
+hide
+</td><td>
+{{int:34d8b60f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9aa39fe3&action=edit 1cc77a14]<br>
+[[MediaWiki_talk:9aa39fe3|Talk]]
+</td><td>
+hide
+</td><td>
+{{int:9aa39fe3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4d9810c0&action=edit 56714843]<br>
+[[MediaWiki_talk:4d9810c0|Talk]]
+</td><td>
+hist
+</td><td>
+{{int:4d9810c0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f37ab91a&action=edit 4e7121e9]<br>
+[[MediaWiki_talk:f37ab91a|Talk]]
+</td><td>
+Diff selection: mark the radio boxes of the versions to compare and hit enter or the button at the bottom.&lt;br/&gt;
+Legend: (cur) = difference with current version,
+(last) = difference with preceding version, M = minor edit.
+</td><td>
+{{int:f37ab91a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:90ccd649&action=edit 66f79d8a]<br>
+[[MediaWiki_talk:90ccd649|Talk]]
+</td><td>
+Page history
+</td><td>
+{{int:90ccd649}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:15a13ace&action=edit a937e036]<br>
+[[MediaWiki_talk:15a13ace|Talk]]
+</td><td>
+History
+</td><td>
+{{int:15a13ace}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e02c3587&action=edit 6079f80a]<br>
+[[MediaWiki_talk:e02c3587|Talk]]
+</td><td>
+Warning: The page you are about to delete has a history:
+</td><td>
+{{int:e02c3587}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5741ad8f&action=edit 48849a80]<br>
+[[MediaWiki_talk:5741ad8f|Talk]]
+</td><td>
+Horizontal line (use sparingly)
+</td><td>
+{{int:5741ad8f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7e18602a&action=edit d874ec59]<br>
+[[MediaWiki_talk:7e18602a|Talk]]
+</td><td>
+Ignore warning and save file anyway.
+</td><td>
+{{int:7e18602a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5bf1efaa&action=edit a98182df]<br>
+[[MediaWiki_talk:5bf1efaa|Talk]]
+</td><td>
+Show all images with names matching
+</td><td>
+{{int:5bf1efaa}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8e7a51d5&action=edit f8288ad8]<br>
+[[MediaWiki_talk:8e7a51d5|Talk]]
+</td><td>
+Search
+</td><td>
+{{int:8e7a51d5}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:133971b3&action=edit be19a728]<br>
+[[MediaWiki_talk:133971b3|Talk]]
+</td><td>
+Example.jpg
+</td><td>
+{{int:133971b3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2a73e0c3&action=edit d103e97d]<br>
+[[MediaWiki_talk:2a73e0c3|Talk]]
+</td><td>
+Embedded image
+</td><td>
+{{int:2a73e0c3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:353c260c&action=edit 3414ac48]<br>
+[[MediaWiki_talk:353c260c|Talk]]
+</td><td>
+Image links
+</td><td>
+{{int:353c260c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:affc6aca&action=edit 4c06ba77]<br>
+[[MediaWiki_talk:affc6aca|Talk]]
+</td><td>
+Image list
+</td><td>
+{{int:affc6aca}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ade85019&action=edit 2e8294bd]<br>
+[[MediaWiki_talk:ade85019|Talk]]
+</td><td>
+Below is a list of $1 images sorted $2.
+</td><td>
+{{int:ade85019}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7277ee94&action=edit a152014b]<br>
+[[MediaWiki_talk:7277ee94|Talk]]
+</td><td>
+View image page
+</td><td>
+{{int:7277ee94}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c783375c&action=edit b1d4cc4c]<br>
+[[MediaWiki_talk:c783375c|Talk]]
+</td><td>
+Revert to earlier version was successful.
+</td><td>
+{{int:c783375c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c6f52751&action=edit 6656e4f4]<br>
+[[MediaWiki_talk:c6f52751|Talk]]
+</td><td>
+del
+</td><td>
+{{int:c6f52751}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3c281ed6&action=edit a1adca28]<br>
+[[MediaWiki_talk:3c281ed6|Talk]]
+</td><td>
+desc
+</td><td>
+{{int:3c281ed6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:186e5ca1&action=edit de786597]<br>
+[[MediaWiki_talk:186e5ca1|Talk]]
+</td><td>
+Legend: (cur) = this is the current image, (del) = delete
+this old version, (rev) = revert to this old version.
+&lt;br /&gt;&lt;i&gt;Click on date to see image uploaded on that date&lt;/i&gt;.
+</td><td>
+{{int:186e5ca1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8d78c0d7&action=edit d9305ede]<br>
+[[MediaWiki_talk:8d78c0d7|Talk]]
+</td><td>
+Image history
+</td><td>
+{{int:8d78c0d7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:86260b9f&action=edit a46e05c1]<br>
+[[MediaWiki_talk:86260b9f|Talk]]
+</td><td>
+Legend: (desc) = show/edit image description.
+</td><td>
+{{int:86260b9f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d6fbc9d2&action=edit 62fdfbd5]<br>
+[[MediaWiki_talk:d6fbc9d2|Talk]]
+</td><td>
+Import pages
+</td><td>
+{{int:d6fbc9d2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bac6ed75&action=edit 85d2877a]<br>
+[[MediaWiki_talk:bac6ed75|Talk]]
+</td><td>
+Import failed: $1
+</td><td>
+{{int:bac6ed75}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f4dee51e&action=edit f74f664b]<br>
+[[MediaWiki_talk:f4dee51e|Talk]]
+</td><td>
+Conflicting history revision exists (may have imported this page before)
+</td><td>
+{{int:f4dee51e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1d461354&action=edit ff881471]<br>
+[[MediaWiki_talk:1d461354|Talk]]
+</td><td>
+Empty or no text
+</td><td>
+{{int:1d461354}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5b910f21&action=edit e2781bd1]<br>
+[[MediaWiki_talk:5b910f21|Talk]]
+</td><td>
+Import succeeded!
+</td><td>
+{{int:5b910f21}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3d58d609&action=edit 965243c5]<br>
+[[MediaWiki_talk:3d58d609|Talk]]
+</td><td>
+Please export the file from the source wiki using the Special:Export utility, save it to your disk and upload it here.
+</td><td>
+{{int:3d58d609}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:31be67da&action=edit 176bd169]<br>
+[[MediaWiki_talk:31be67da|Talk]]
+</td><td>
+Click a button to get an example text
+</td><td>
+{{int:31be67da}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2854823a&action=edit 6de0a6d1]<br>
+[[MediaWiki_talk:2854823a|Talk]]
+</td><td>
+Please enter the text you want to be formatted.\n It will be shown in the infobox for copy and pasting.\nExample:\n$1\nwill become:\n$2
+</td><td>
+{{int:2854823a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:103c360a&action=edit e90e9e1c]<br>
+[[MediaWiki_talk:103c360a|Talk]]
+</td><td>
+Internal error
+</td><td>
+{{int:103c360a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:148008a6&action=edit 919b03e5]<br>
+[[MediaWiki_talk:148008a6|Talk]]
+</td><td>
+Interlanguage links
+</td><td>
+{{int:148008a6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:07586557&action=edit 5c2ff182]<br>
+[[MediaWiki_talk:07586557|Talk]]
+</td><td>
+Invalid IP range.
+
+</td><td>
+{{int:07586557}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:22f9ff50&action=edit 1f99aaad]<br>
+[[MediaWiki_talk:22f9ff50|Talk]]
+</td><td>
+IP Address/username
+</td><td>
+{{int:22f9ff50}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f8994552&action=edit 91b35c2d]<br>
+[[MediaWiki_talk:f8994552|Talk]]
+</td><td>
+Expiry time invalid.
+</td><td>
+{{int:f8994552}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2147c662&action=edit 524cfd7e]<br>
+[[MediaWiki_talk:2147c662|Talk]]
+</td><td>
+Expiry
+</td><td>
+{{int:2147c662}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9cb7e6ee&action=edit 503153e9]<br>
+[[MediaWiki_talk:9cb7e6ee|Talk]]
+</td><td>
+List of blocked IP addresses and usernames
+</td><td>
+{{int:9cb7e6ee}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6774dfa8&action=edit 1ecdad25]<br>
+[[MediaWiki_talk:6774dfa8|Talk]]
+</td><td>
+Reason
+</td><td>
+{{int:6774dfa8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:62218118&action=edit e29a20a2]<br>
+[[MediaWiki_talk:62218118|Talk]]
+</td><td>
+Block this user
+</td><td>
+{{int:62218118}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7863d305&action=edit 73ecf10c]<br>
+[[MediaWiki_talk:7863d305|Talk]]
+</td><td>
+Unblock this address
+</td><td>
+{{int:7863d305}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ba95215c&action=edit 5fc7f411]<br>
+[[MediaWiki_talk:ba95215c|Talk]]
+</td><td>
+&quot;$1&quot; unblocked
+</td><td>
+{{int:ba95215c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8e89a827&action=edit 9aa403ad]<br>
+[[MediaWiki_talk:8e89a827|Talk]]
+</td><td>
+ISBN
+</td><td>
+{{int:8e89a827}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5b017ff1&action=edit abdf987b]<br>
+[[MediaWiki_talk:5b017ff1|Talk]]
+</td><td>
+redirect page
+</td><td>
+{{int:5b017ff1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:208e76ed&action=edit 95f02073]<br>
+[[MediaWiki_talk:208e76ed|Talk]]
+</td><td>
+Italic text
+</td><td>
+{{int:208e76ed}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7e5211e9&action=edit 3f1c7185]<br>
+[[MediaWiki_talk:7e5211e9|Talk]]
+</td><td>
+Italic text
+</td><td>
+{{int:7e5211e9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bcce0a8a&action=edit 0e29818a]<br>
+[[MediaWiki_talk:bcce0a8a|Talk]]
+</td><td>
+Problem with item &#39;$1&#39;, invalid name...
+</td><td>
+{{int:bcce0a8a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:996c231b&action=edit 5dd7fd8c]<br>
+[[MediaWiki_talk:996c231b|Talk]]
+</td><td>
+It is recommended that images not exceed 100k in size.
+</td><td>
+{{int:996c231b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d1c69a85&action=edit 213ed3ea]<br>
+[[MediaWiki_talk:d1c69a85|Talk]]
+</td><td>
+last
+</td><td>
+{{int:d1c69a85}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:26d03483&action=edit 1d0be5cf]<br>
+[[MediaWiki_talk:26d03483|Talk]]
+</td><td>
+This page was last modified $1.
+</td><td>
+{{int:26d03483}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d8b6a1ce&action=edit b4c7424e]<br>
+[[MediaWiki_talk:d8b6a1ce|Talk]]
+</td><td>
+This page was last modified $1 by $2.
+</td><td>
+{{int:d8b6a1ce}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b5fb75c3&action=edit 7aab91e5]<br>
+[[MediaWiki_talk:b5fb75c3|Talk]]
+</td><td>
+Line $1:
+</td><td>
+{{int:b5fb75c3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a1a27fd2&action=edit 4c4ec68a]<br>
+[[MediaWiki_talk:a1a27fd2|Talk]]
+</td><td>
+Link title
+</td><td>
+{{int:a1a27fd2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:28f8c928&action=edit e0ee37d8]<br>
+[[MediaWiki_talk:28f8c928|Talk]]
+</td><td>
+Internal link
+</td><td>
+{{int:28f8c928}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:003058f7&action=edit c692b683]<br>
+[[MediaWiki_talk:003058f7|Talk]]
+</td><td>
+(List of links)
+</td><td>
+{{int:003058f7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6508e12f&action=edit ce30384f]<br>
+[[MediaWiki_talk:6508e12f|Talk]]
+</td><td>
+The following pages link to here:
+</td><td>
+{{int:6508e12f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f6dbd59d&action=edit 50b839a3]<br>
+[[MediaWiki_talk:f6dbd59d|Talk]]
+</td><td>
+The following pages link to this image:
+</td><td>
+{{int:f6dbd59d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:eb68781a&action=edit 743236e7]<br>
+[[MediaWiki_talk:eb68781a|Talk]]
+</td><td>
+/^(&#91;a-z]+)(.*)$/sD
+</td><td>
+{{int:eb68781a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a8ca6811&action=edit 069b38c0]<br>
+[[MediaWiki_talk:a8ca6811|Talk]]
+</td><td>
+list
+</td><td>
+{{int:a8ca6811}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9bf82beb&action=edit aabbb062]<br>
+[[MediaWiki_talk:9bf82beb|Talk]]
+</td><td>
+User list
+</td><td>
+{{int:9bf82beb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:61a6ed55&action=edit 1b4ae4f9]<br>
+[[MediaWiki_talk:61a6ed55|Talk]]
+</td><td>
+Loading page history
+</td><td>
+{{int:61a6ed55}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bf19b1de&action=edit b6bb9fa5]<br>
+[[MediaWiki_talk:bf19b1de|Talk]]
+</td><td>
+loading revision for diff
+</td><td>
+{{int:bf19b1de}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:43678846&action=edit f25bccd7]<br>
+[[MediaWiki_talk:43678846|Talk]]
+</td><td>
+Local time display
+</td><td>
+{{int:43678846}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:31dcaa22&action=edit 62c50181]<br>
+[[MediaWiki_talk:31dcaa22|Talk]]
+</td><td>
+Lock database
+</td><td>
+{{int:31dcaa22}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9dc82fa2&action=edit 5199ac8e]<br>
+[[MediaWiki_talk:9dc82fa2|Talk]]
+</td><td>
+Yes, I really want to lock the database.
+</td><td>
+{{int:9dc82fa2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fef93b9b&action=edit 4f29ae0a]<br>
+[[MediaWiki_talk:fef93b9b|Talk]]
+</td><td>
+Lock database
+</td><td>
+{{int:fef93b9b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b4abc4bb&action=edit e73c06d7]<br>
+[[MediaWiki_talk:b4abc4bb|Talk]]
+</td><td>
+Database lock succeeded
+</td><td>
+{{int:b4abc4bb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b6fcfec5&action=edit 88c6fb22]<br>
+[[MediaWiki_talk:b6fcfec5|Talk]]
+</td><td>
+The database has been locked.
+&lt;br /&gt;Remember to remove the lock after your maintenance is complete.
+</td><td>
+{{int:b6fcfec5}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:459bf648&action=edit 070ff9ae]<br>
+[[MediaWiki_talk:459bf648|Talk]]
+</td><td>
+Locking the database will suspend the ability of all
+users to edit pages, change their preferences, edit their watchlists, and
+other things requiring changes in the database.
+Please confirm that this is what you intend to do, and that you will
+unlock the database when your maintenance is done.
+</td><td>
+{{int:459bf648}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2727a733&action=edit 8a890d0a]<br>
+[[MediaWiki_talk:2727a733|Talk]]
+</td><td>
+You did not check the confirmation box.
+</td><td>
+{{int:2727a733}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4e5a2893&action=edit 2736fab2]<br>
+[[MediaWiki_talk:4e5a2893|Talk]]
+</td><td>
+Log in
+</td><td>
+{{int:4e5a2893}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fcfc7549&action=edit e6f9a4e2]<br>
+[[MediaWiki_talk:fcfc7549|Talk]]
+</td><td>
+Login error
+</td><td>
+{{int:fcfc7549}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4113f724&action=edit 36f843a7]<br>
+[[MediaWiki_talk:4113f724|Talk]]
+</td><td>
+User login
+</td><td>
+{{int:4113f724}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7a6963a6&action=edit d23ee6a8]<br>
+[[MediaWiki_talk:7a6963a6|Talk]]
+</td><td>
+&lt;b&gt;There has been a problem with your login.&lt;/b&gt;&lt;br /&gt;Try again!
+</td><td>
+{{int:7a6963a6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bbf56890&action=edit 221d44a4]<br>
+[[MediaWiki_talk:bbf56890|Talk]]
+</td><td>
+You must have cookies enabled to log in to Wiktionary.
+</td><td>
+{{int:bbf56890}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:75749962&action=edit ee8446ea]<br>
+[[MediaWiki_talk:75749962|Talk]]
+</td><td>
+You must &#91;&#91;special:Userlogin&#124;login]] to view other pages.
+</td><td>
+{{int:75749962}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c779400b&action=edit a90049e8]<br>
+[[MediaWiki_talk:c779400b|Talk]]
+</td><td>
+Login Required
+</td><td>
+{{int:c779400b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:753694e0&action=edit a5607b10]<br>
+[[MediaWiki_talk:753694e0|Talk]]
+</td><td>
+You are now logged in to Wiktionary as &quot;$1&quot;.
+</td><td>
+{{int:753694e0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:73eb6767&action=edit 5c2a05be]<br>
+[[MediaWiki_talk:73eb6767|Talk]]
+</td><td>
+Login successful
+</td><td>
+{{int:73eb6767}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e43d612e&action=edit 55525e1b]<br>
+[[MediaWiki_talk:e43d612e|Talk]]
+</td><td>
+Log out
+</td><td>
+{{int:e43d612e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a8455b1c&action=edit 50310460]<br>
+[[MediaWiki_talk:a8455b1c|Talk]]
+</td><td>
+You are now logged out.
+You can continue to use Wiktionary anonymously, or you can log in
+again as the same or as a different user. Note that some pages may
+continue to be displayed as if you were still logged in, until you clear
+your browser cache
+
+</td><td>
+{{int:a8455b1c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cd48f4e7&action=edit 8f9db4e5]<br>
+[[MediaWiki_talk:cd48f4e7|Talk]]
+</td><td>
+User logout
+</td><td>
+{{int:cd48f4e7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:916f5569&action=edit 92ab2259]<br>
+[[MediaWiki_talk:916f5569|Talk]]
+</td><td>
+Orphaned pages
+</td><td>
+{{int:916f5569}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9cdfa115&action=edit 38996948]<br>
+[[MediaWiki_talk:9cdfa115|Talk]]
+</td><td>
+Long pages
+</td><td>
+{{int:9cdfa115}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b91ee293&action=edit 09b5b0a2]<br>
+[[MediaWiki_talk:b91ee293|Talk]]
+</td><td>
+WARNING: This page is $1 kilobytes long; some
+browsers may have problems editing pages approaching or longer than 32kb.
+Please consider breaking the page into smaller sections.
+</td><td>
+{{int:b91ee293}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1ca3c8a2&action=edit 2b82fce3]<br>
+[[MediaWiki_talk:1ca3c8a2|Talk]]
+</td><td>
+Error sending mail: $1
+</td><td>
+{{int:1ca3c8a2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:97681e3e&action=edit 669d145f]<br>
+[[MediaWiki_talk:97681e3e|Talk]]
+</td><td>
+Mail me a new password
+</td><td>
+{{int:97681e3e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8646515d&action=edit 874a6660]<br>
+[[MediaWiki_talk:8646515d|Talk]]
+</td><td>
+No send address
+</td><td>
+{{int:8646515d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f8116e36&action=edit ce0442ed]<br>
+[[MediaWiki_talk:f8116e36|Talk]]
+</td><td>
+You must be &lt;a href=&quot;{{localurl:Special:Userlogin&quot;&gt;logged in&lt;/a&gt;
+and have a valid e-mail address in your &lt;a href=&quot;/wiki/Special:Preferences&quot;&gt;preferences&lt;/a&gt;
+to send e-mail to other users.
+</td><td>
+{{int:f8116e36}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:95989ab3&action=edit 6ad3db9a]<br>
+[[MediaWiki_talk:95989ab3|Talk]]
+</td><td>
+Main Page
+</td><td>
+{{int:95989ab3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:216e0fe3&action=edit 19d499cf]<br>
+[[MediaWiki_talk:216e0fe3|Talk]]
+</td><td>
+Please see &#91;http&#58;//meta.wikipedia.org/wiki/MediaWiki_i18n documentation on customizing the interface]
+and the &#91;http&#58;//meta.wikipedia.org/wiki/MediaWiki_User%27s_Guide User&#39;s Guide] for usage and configuration help.
+</td><td>
+{{int:216e0fe3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:29c07aac&action=edit 30186460]<br>
+[[MediaWiki_talk:29c07aac|Talk]]
+</td><td>
+Wiki software successfully installed.
+</td><td>
+{{int:29c07aac}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:94de303b&action=edit 5b30e2c5]<br>
+[[MediaWiki_talk:94de303b|Talk]]
+</td><td>
+Maintenance page
+</td><td>
+{{int:94de303b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b98df751&action=edit aa734abd]<br>
+[[MediaWiki_talk:b98df751|Talk]]
+</td><td>
+Back to Maintenance Page
+</td><td>
+{{int:b98df751}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5e830e7c&action=edit ff589b21]<br>
+[[MediaWiki_talk:5e830e7c|Talk]]
+</td><td>
+This page includes several handy tools for everyday maintenance. Some of these functions tend to stress the database, so please do not hit reload after every item you fixed ;-)
+</td><td>
+{{int:5e830e7c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:147d840b&action=edit 192a7baa]<br>
+[[MediaWiki_talk:147d840b|Talk]]
+</td><td>
+Make a user into a sysop
+</td><td>
+{{int:147d840b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3e1272dd&action=edit c857a847]<br>
+[[MediaWiki_talk:3e1272dd|Talk]]
+</td><td>
+&lt;b&gt;User &quot;$1&quot; could not be made into a sysop. (Did you enter the name correctly?)&lt;/b&gt;
+</td><td>
+{{int:3e1272dd}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f942827d&action=edit 4ae2de91]<br>
+[[MediaWiki_talk:f942827d|Talk]]
+</td><td>
+Name of the user:
+</td><td>
+{{int:f942827d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8933e97e&action=edit 1138d88d]<br>
+[[MediaWiki_talk:8933e97e|Talk]]
+</td><td>
+&lt;b&gt;User &quot;$1&quot; is now a sysop&lt;/b&gt;
+</td><td>
+{{int:8933e97e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ffde53f7&action=edit 51a3d81a]<br>
+[[MediaWiki_talk:ffde53f7|Talk]]
+</td><td>
+Make this user into a sysop
+</td><td>
+{{int:ffde53f7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6135d20c&action=edit 9014f0fd]<br>
+[[MediaWiki_talk:6135d20c|Talk]]
+</td><td>
+This form is used by bureaucrats to turn ordinary users into administrators.
+Type the name of the user in the box and press the button to make the user an administrator
+</td><td>
+{{int:6135d20c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:40537c23&action=edit 9d7a92cc]<br>
+[[MediaWiki_talk:40537c23|Talk]]
+</td><td>
+Make a user into a sysop
+</td><td>
+{{int:40537c23}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b00f5f1f&action=edit f2f4e13e]<br>
+[[MediaWiki_talk:b00f5f1f|Talk]]
+</td><td>
+The query &quot;$1&quot; matched $2 page titles
+and the text of $3 pages.
+</td><td>
+{{int:b00f5f1f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3edf0df4&action=edit 7a488390]<br>
+[[MediaWiki_talk:3edf0df4|Talk]]
+</td><td>
+Rendering math
+</td><td>
+{{int:3edf0df4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:78c6cb06&action=edit d9b8688c]<br>
+[[MediaWiki_talk:78c6cb06|Talk]]
+</td><td>
+Can&#39;t write to or create math output directory
+</td><td>
+{{int:78c6cb06}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f83fe947&action=edit be21263f]<br>
+[[MediaWiki_talk:f83fe947|Talk]]
+</td><td>
+Can&#39;t write to or create math temp directory
+</td><td>
+{{int:f83fe947}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f8cf40ba&action=edit 53e1c013]<br>
+[[MediaWiki_talk:f8cf40ba|Talk]]
+</td><td>
+Failed to parse
+</td><td>
+{{int:f8cf40ba}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7b3e958f&action=edit 7082c48f]<br>
+[[MediaWiki_talk:7b3e958f|Talk]]
+</td><td>
+PNG conversion failed; check for correct installation of latex, dvips, gs, and convert
+</td><td>
+{{int:7b3e958f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d6a158de&action=edit 41e6fe2b]<br>
+[[MediaWiki_talk:d6a158de|Talk]]
+</td><td>
+lexing error
+</td><td>
+{{int:d6a158de}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8109168a&action=edit 20ec4685]<br>
+[[MediaWiki_talk:8109168a|Talk]]
+</td><td>
+Missing texvc executable; please see math/README to configure.
+</td><td>
+{{int:8109168a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:41b65279&action=edit 3e8b5972]<br>
+[[MediaWiki_talk:41b65279|Talk]]
+</td><td>
+Insert formula here
+</td><td>
+{{int:41b65279}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5cbab860&action=edit d5667f6b]<br>
+[[MediaWiki_talk:5cbab860|Talk]]
+</td><td>
+syntax error
+</td><td>
+{{int:5cbab860}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7e756feb&action=edit 0baadf18]<br>
+[[MediaWiki_talk:7e756feb|Talk]]
+</td><td>
+Mathematical formula (LaTeX)
+</td><td>
+{{int:7e756feb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fb4d261d&action=edit 5e0c970a]<br>
+[[MediaWiki_talk:fb4d261d|Talk]]
+</td><td>
+unknown error
+</td><td>
+{{int:fb4d261d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:26558f91&action=edit a0577d1d]<br>
+[[MediaWiki_talk:26558f91|Talk]]
+</td><td>
+unknown function
+</td><td>
+{{int:26558f91}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:63e94059&action=edit 704093ed]<br>
+[[MediaWiki_talk:63e94059|Talk]]
+</td><td>
+Example.mp3
+</td><td>
+{{int:63e94059}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8e4baaa8&action=edit 77fbb90b]<br>
+[[MediaWiki_talk:8e4baaa8|Talk]]
+</td><td>
+Media file link
+</td><td>
+{{int:8e4baaa8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cca18055&action=edit 61350cd2]<br>
+[[MediaWiki_talk:cca18055|Talk]]
+</td><td>
+Image names must be at least three letters.
+</td><td>
+{{int:cca18055}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7f8c4ff3&action=edit 3dd77123]<br>
+[[MediaWiki_talk:7f8c4ff3|Talk]]
+</td><td>
+This is a minor edit
+</td><td>
+{{int:7f8c4ff3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ce5828a8&action=edit 3c37ba2f]<br>
+[[MediaWiki_talk:ce5828a8|Talk]]
+</td><td>
+M
+</td><td>
+{{int:ce5828a8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1e3a3f5e&action=edit abf0b01a]<br>
+[[MediaWiki_talk:1e3a3f5e|Talk]]
+</td><td>
+Pages with misspellings
+</td><td>
+{{int:1e3a3f5e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:18c50601&action=edit 4841b1be]<br>
+[[MediaWiki_talk:18c50601|Talk]]
+</td><td>
+List of common misspellings
+</td><td>
+{{int:18c50601}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ff661e66&action=edit 20eeb250]<br>
+[[MediaWiki_talk:ff661e66|Talk]]
+</td><td>
+The following pages contain a common misspelling, which are listed on $1. The correct spelling might be given (like this).
+</td><td>
+{{int:ff661e66}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:77dd649d&action=edit 28d8d2f3]<br>
+[[MediaWiki_talk:77dd649d|Talk]]
+</td><td>
+The database did not find the text of a page
+that it should have found, named &quot;$1&quot;.
+
+&lt;p&gt;This is usually caused by following an outdated diff or history link to a
+page that has been deleted.
+
+&lt;p&gt;If this is not the case, you may have found a bug in the software.
+Please report this to an administrator, making note of the URL.
+</td><td>
+{{int:77dd649d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:43bf0acd&action=edit d6472ac8]<br>
+[[MediaWiki_talk:43bf0acd|Talk]]
+</td><td>
+&lt;b&gt;Missing image&lt;/b&gt;&lt;br /&gt;&lt;i&gt;$1&lt;/i&gt;
+
+</td><td>
+{{int:43bf0acd}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:75c0518a&action=edit f433e9c8]<br>
+[[MediaWiki_talk:75c0518a|Talk]]
+</td><td>
+Missing Language Links
+</td><td>
+{{int:75c0518a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5ef61b91&action=edit a4a9fdcd]<br>
+[[MediaWiki_talk:5ef61b91|Talk]]
+</td><td>
+Find missing language links for
+</td><td>
+{{int:5ef61b91}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f5affad8&action=edit e46ff038]<br>
+[[MediaWiki_talk:f5affad8|Talk]]
+</td><td>
+These pages do &lt;i&gt;not&lt;/i&gt; link to their counterpart in $1. Redirects and subpages are &lt;i&gt;not&lt;/i&gt; shown.
+</td><td>
+{{int:f5affad8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:22e2c957&action=edit b43c02b9]<br>
+[[MediaWiki_talk:22e2c957|Talk]]
+</td><td>
+More...
+</td><td>
+{{int:22e2c957}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:76cdb950&action=edit 379d6ce9]<br>
+[[MediaWiki_talk:76cdb950|Talk]]
+</td><td>
+Move
+</td><td>
+{{int:76cdb950}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:31d22872&action=edit d55a3c2a]<br>
+[[MediaWiki_talk:31d22872|Talk]]
+</td><td>
+Move page
+</td><td>
+{{int:31d22872}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fb280ed2&action=edit 0bd0c880]<br>
+[[MediaWiki_talk:fb280ed2|Talk]]
+</td><td>
+moved to
+</td><td>
+{{int:fb280ed2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8ddc20a0&action=edit 7c041d6e]<br>
+[[MediaWiki_talk:8ddc20a0|Talk]]
+</td><td>
+Not logged in
+</td><td>
+{{int:8ddc20a0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:75985d0e&action=edit e479574b]<br>
+[[MediaWiki_talk:75985d0e|Talk]]
+</td><td>
+You must be a registered user and &lt;a href=&quot;/wiki/Special:Userlogin&quot;&gt;logged in&lt;/a&gt;
+to move a page.
+</td><td>
+{{int:75985d0e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:addffb42&action=edit 0f05ab2b]<br>
+[[MediaWiki_talk:addffb42|Talk]]
+</td><td>
+Move page
+</td><td>
+{{int:addffb42}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6f9e8dfc&action=edit 0311d79b]<br>
+[[MediaWiki_talk:6f9e8dfc|Talk]]
+</td><td>
+Move page
+</td><td>
+{{int:6f9e8dfc}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:993d5ce8&action=edit 53ab3d1c]<br>
+[[MediaWiki_talk:993d5ce8|Talk]]
+</td><td>
+The associated talk page, if any, will be automatically moved along with it &#39;&#39;&#39;unless:&#39;&#39;&#39;
+*You are moving the page across namespaces,
+*A non-empty talk page already exists under the new name, or
+*You uncheck the box below.
+
+In those cases, you will have to move or merge the page manually if desired.
+</td><td>
+{{int:993d5ce8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ce6bc0ee&action=edit a363312c]<br>
+[[MediaWiki_talk:ce6bc0ee|Talk]]
+</td><td>
+Using the form below will rename a page, moving all
+of its history to the new name.
+The old title will become a redirect page to the new title.
+Links to the old page title will not be changed; be sure to
+&#91;&#91;Special:Maintenance&#124;check]] for double or broken redirects.
+You are responsible for making sure that links continue to
+point where they are supposed to go.
+
+Note that the page will &#39;&#39;&#39;not&#39;&#39;&#39; be moved if there is already
+a page at the new title, unless it is empty or a redirect and has no
+past edit history. This means that you can rename a page back to where
+it was just renamed from if you make a mistake, and you cannot overwrite
+an existing page.
+
+&lt;b&gt;WARNING!&lt;/b&gt;
+This can be a drastic and unexpected change for a popular page;
+please be sure you understand the consequences of this before
+proceeding.
+</td><td>
+{{int:ce6bc0ee}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e0a05db0&action=edit 7bd87d2d]<br>
+[[MediaWiki_talk:e0a05db0|Talk]]
+</td><td>
+Move &quot;talk&quot; page as well, if applicable.
+</td><td>
+{{int:e0a05db0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:689ff1e7&action=edit 2119d3ee]<br>
+[[MediaWiki_talk:689ff1e7|Talk]]
+</td><td>
+Move this page
+</td><td>
+{{int:689ff1e7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0dc37cdb&action=edit 12b6caf0]<br>
+[[MediaWiki_talk:0dc37cdb|Talk]]
+</td><td>
+My contributions
+</td><td>
+{{int:0dc37cdb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:51a7215d&action=edit 5d558678]<br>
+[[MediaWiki_talk:51a7215d|Talk]]
+</td><td>
+My page
+</td><td>
+{{int:51a7215d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fbe8f485&action=edit 49886539]<br>
+[[MediaWiki_talk:fbe8f485|Talk]]
+</td><td>
+My talk
+</td><td>
+{{int:fbe8f485}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cf03cf2e&action=edit ad831792]<br>
+[[MediaWiki_talk:cf03cf2e|Talk]]
+</td><td>
+Navigation
+</td><td>
+{{int:cf03cf2e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b5b13ae8&action=edit e75caf8a]<br>
+[[MediaWiki_talk:b5b13ae8|Talk]]
+</td><td>
+$1 bytes
+</td><td>
+{{int:b5b13ae8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bee99a5f&action=edit 3d7d513a]<br>
+[[MediaWiki_talk:bee99a5f|Talk]]
+</td><td>
+$1 changes
+</td><td>
+{{int:bee99a5f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:654df301&action=edit 06b1c460]<br>
+[[MediaWiki_talk:654df301|Talk]]
+</td><td>
+(New)
+</td><td>
+{{int:654df301}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f1622d18&action=edit b90d5eb0]<br>
+[[MediaWiki_talk:f1622d18|Talk]]
+</td><td>
+You&#39;ve followed a link to a page that doesn&#39;t exist yet.
+To create the page, start typing in the box below
+(see the &#91;&#91;Wiktionary:Help&#124;help page]] for more info).
+If you are here by mistake, just click your browser&#39;s &#39;&#39;&#39;back&#39;&#39;&#39; button.
+</td><td>
+{{int:f1622d18}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:780ce01b&action=edit 0b08523d]<br>
+[[MediaWiki_talk:780ce01b|Talk]]
+</td><td>
+You have $1.
+</td><td>
+{{int:780ce01b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e09d8ffe&action=edit 1f028736]<br>
+[[MediaWiki_talk:e09d8ffe|Talk]]
+</td><td>
+new messages
+</td><td>
+{{int:e09d8ffe}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ce656abe&action=edit d68c7e3c]<br>
+[[MediaWiki_talk:ce656abe|Talk]]
+</td><td>
+New page
+</td><td>
+{{int:ce656abe}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b886426f&action=edit d081a481]<br>
+[[MediaWiki_talk:b886426f|Talk]]
+</td><td>
+N
+</td><td>
+{{int:b886426f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2adf1ae7&action=edit eeadf049]<br>
+[[MediaWiki_talk:2adf1ae7|Talk]]
+</td><td>
+New pages
+</td><td>
+{{int:2adf1ae7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:393f8bca&action=edit f2c57870]<br>
+[[MediaWiki_talk:393f8bca|Talk]]
+</td><td>
+New password
+</td><td>
+{{int:393f8bca}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fa56bbd9&action=edit a104cc01]<br>
+[[MediaWiki_talk:fa56bbd9|Talk]]
+</td><td>
+To new title
+</td><td>
+{{int:fa56bbd9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2a57c83c&action=edit 41af2ba5]<br>
+[[MediaWiki_talk:2a57c83c|Talk]]
+</td><td>
+ (new users only)
+</td><td>
+{{int:2a57c83c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bc981983&action=edit edee9402]<br>
+[[MediaWiki_talk:bc981983|Talk]]
+</td><td>
+next
+</td><td>
+{{int:bc981983}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5e067f51&action=edit e0bd4ddb]<br>
+[[MediaWiki_talk:5e067f51|Talk]]
+</td><td>
+next $1
+</td><td>
+{{int:5e067f51}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:61c11c45&action=edit 2b45e9af]<br>
+[[MediaWiki_talk:61c11c45|Talk]]
+</td><td>
+$1 links
+</td><td>
+{{int:61c11c45}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e307257f&action=edit f6f5e28d]<br>
+[[MediaWiki_talk:e307257f|Talk]]
+</td><td>
+You must affirm that your upload does not violate
+any copyrights.
+</td><td>
+{{int:e307257f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:335462de&action=edit 2658d031]<br>
+[[MediaWiki_talk:335462de|Talk]]
+</td><td>
+(There is currently no text in this page)
+</td><td>
+{{int:335462de}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:46716843&action=edit 68326cbc]<br>
+[[MediaWiki_talk:46716843|Talk]]
+</td><td>
+You must supply a reason for the block.
+</td><td>
+{{int:46716843}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8fa787f6&action=edit 5d122d51]<br>
+[[MediaWiki_talk:8fa787f6|Talk]]
+</td><td>
+Sorry! The wiki is experiencing some technical difficulties, and cannot contact the database server.
+</td><td>
+{{int:8fa787f6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f60d1a6d&action=edit b88f305b]<br>
+[[MediaWiki_talk:f60d1a6d|Talk]]
+</td><td>
+No changes were found matching these criteria.
+</td><td>
+{{int:f60d1a6d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9d931b8c&action=edit de736886]<br>
+[[MediaWiki_talk:9d931b8c|Talk]]
+</td><td>
+Wiktionary uses cookies to log in users. You have cookies disabled. Please enable them and try again.
+</td><td>
+{{int:9d931b8c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e4a19fc8&action=edit 71c8d192]<br>
+[[MediaWiki_talk:e4a19fc8|Talk]]
+</td><td>
+The user account was created, but you are not logged in. Wiktionary uses cookies to log in users. You have cookies disabled. Please enable them, then log in with your new username and password.
+</td><td>
+{{int:e4a19fc8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6fbb6d3a&action=edit cc61a719]<br>
+[[MediaWiki_talk:6fbb6d3a|Talk]]
+</td><td>
+Creative Commons RDF metadata disabled for this server.
+</td><td>
+{{int:6fbb6d3a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e0dd32fc&action=edit 5ed4cf16]<br>
+[[MediaWiki_talk:e0dd32fc|Talk]]
+</td><td>
+Could not select database $1
+</td><td>
+{{int:e0dd32fc}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:067ee3e9&action=edit 3a58322b]<br>
+[[MediaWiki_talk:067ee3e9|Talk]]
+</td><td>
+Dublin Core RDF metadata disabled for this server.
+</td><td>
+{{int:067ee3e9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:325a917f&action=edit 4c8d93d2]<br>
+[[MediaWiki_talk:325a917f|Talk]]
+</td><td>
+There is no e-mail address recorded for user &quot;$1&quot;.
+</td><td>
+{{int:325a917f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:deb172c1&action=edit f8bace82]<br>
+[[MediaWiki_talk:deb172c1|Talk]]
+</td><td>
+This user has not specified a valid e-mail address,
+or has chosen not to receive e-mail from other users.
+</td><td>
+{{int:deb172c1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6bd33d89&action=edit a158d61f]<br>
+[[MediaWiki_talk:6bd33d89|Talk]]
+</td><td>
+No e-mail address
+</td><td>
+{{int:6bd33d89}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e68327b0&action=edit 36552107]<br>
+[[MediaWiki_talk:e68327b0|Talk]]
+</td><td>
+No page with this exact title exists, trying full text search.
+</td><td>
+{{int:e68327b0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:90c1625a&action=edit 8d231ce4]<br>
+[[MediaWiki_talk:90c1625a|Talk]]
+</td><td>
+There is no edit history for this page.
+</td><td>
+{{int:90c1625a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:13e13fa2&action=edit e63b6d19]<br>
+[[MediaWiki_talk:13e13fa2|Talk]]
+</td><td>
+No pages link to here.
+</td><td>
+{{int:13e13fa2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5c3c99a8&action=edit 1e827a30]<br>
+[[MediaWiki_talk:5c3c99a8|Talk]]
+</td><td>
+There are no pages that link to this image.
+</td><td>
+{{int:5c3c99a8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d9ff75a4&action=edit e21bfc14]<br>
+[[MediaWiki_talk:d9ff75a4|Talk]]
+</td><td>
+You have not specified a valid user name.
+</td><td>
+{{int:d9ff75a4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f7d27b0c&action=edit 5db654d1]<br>
+[[MediaWiki_talk:f7d27b0c|Talk]]
+</td><td>
+&lt;strong&gt;Note&lt;/strong&gt;: unsuccessful searches are
+often caused by searching for common words like &quot;have&quot; and &quot;from&quot;,
+which are not indexed, or by specifying more than one search term (only pages
+containing all of the search terms will appear in the result).
+</td><td>
+{{int:f7d27b0c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d5f565dd&action=edit aaaac807]<br>
+[[MediaWiki_talk:d5f565dd|Talk]]
+</td><td>
+You have requested a special page that is not
+recognized by the wiki.
+</td><td>
+{{int:d5f565dd}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:247c2db2&action=edit 273b8154]<br>
+[[MediaWiki_talk:247c2db2|Talk]]
+</td><td>
+No such action
+</td><td>
+{{int:247c2db2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0e2f696c&action=edit e8773306]<br>
+[[MediaWiki_talk:0e2f696c|Talk]]
+</td><td>
+The action specified by the URL is not
+recognized by the wiki
+</td><td>
+{{int:0e2f696c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bd0d7ac6&action=edit b98c7c10]<br>
+[[MediaWiki_talk:bd0d7ac6|Talk]]
+</td><td>
+No such special page
+</td><td>
+{{int:bd0d7ac6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:22012b0a&action=edit f542883d]<br>
+[[MediaWiki_talk:22012b0a|Talk]]
+</td><td>
+There is no user by the name &quot;$1&quot;.
+Check your spelling, or use the form below to create a new user account.
+</td><td>
+{{int:22012b0a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:982342c7&action=edit f4909824]<br>
+[[MediaWiki_talk:982342c7|Talk]]
+</td><td>
+The wiki server can&#39;t provide data in a format your client can read.
+</td><td>
+{{int:982342c7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:272cfb97&action=edit cdb5d3a9]<br>
+[[MediaWiki_talk:272cfb97|Talk]]
+</td><td>
+Not a content page
+</td><td>
+{{int:272cfb97}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8ccaecd6&action=edit 42534913]<br>
+[[MediaWiki_talk:8ccaecd6|Talk]]
+</td><td>
+You have not specified a target page or user
+to perform this function on.
+</td><td>
+{{int:8ccaecd6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4532ec15&action=edit dff62a20]<br>
+[[MediaWiki_talk:4532ec15|Talk]]
+</td><td>
+No target
+</td><td>
+{{int:4532ec15}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2c924e30&action=edit c51048b7]<br>
+[[MediaWiki_talk:2c924e30|Talk]]
+</td><td>
+&lt;strong&gt;Note:&lt;/strong&gt;
+</td><td>
+{{int:2c924e30}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:51c2043b&action=edit 879701e9]<br>
+[[MediaWiki_talk:51c2043b|Talk]]
+</td><td>
+No page text matches
+</td><td>
+{{int:51c2043b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6f3befe0&action=edit 5a56ca1b]<br>
+[[MediaWiki_talk:6f3befe0|Talk]]
+</td><td>
+No page title matches
+</td><td>
+{{int:6f3befe0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:219a05e4&action=edit 02bcadd3]<br>
+[[MediaWiki_talk:219a05e4|Talk]]
+</td><td>
+Not logged in
+</td><td>
+{{int:219a05e4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:28b54fd2&action=edit ba736b7f]<br>
+[[MediaWiki_talk:28b54fd2|Talk]]
+</td><td>
+You have no items on your watchlist.
+</td><td>
+{{int:28b54fd2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a78319d8&action=edit 2398990d]<br>
+[[MediaWiki_talk:a78319d8|Talk]]
+</td><td>
+Insert non-formatted text here
+</td><td>
+{{int:a78319d8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:20d39be1&action=edit cf8602ad]<br>
+[[MediaWiki_talk:20d39be1|Talk]]
+</td><td>
+Ignore wiki formatting
+</td><td>
+{{int:20d39be1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dee84866&action=edit 7a6336e0]<br>
+[[MediaWiki_talk:dee84866|Talk]]
+</td><td>
+Category
+</td><td>
+{{int:dee84866}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a3e0d95e&action=edit 16b32116]<br>
+[[MediaWiki_talk:a3e0d95e|Talk]]
+</td><td>
+Help
+</td><td>
+{{int:a3e0d95e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:911dff1f&action=edit 081e450a]<br>
+[[MediaWiki_talk:911dff1f|Talk]]
+</td><td>
+Image
+</td><td>
+{{int:911dff1f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:931f9736&action=edit 5b9c503a]<br>
+[[MediaWiki_talk:931f9736|Talk]]
+</td><td>
+Article
+</td><td>
+{{int:931f9736}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6da2f6ae&action=edit 86e5f16d]<br>
+[[MediaWiki_talk:6da2f6ae|Talk]]
+</td><td>
+Media
+</td><td>
+{{int:6da2f6ae}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:53504d48&action=edit 368d5d22]<br>
+[[MediaWiki_talk:53504d48|Talk]]
+</td><td>
+Message
+</td><td>
+{{int:53504d48}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:14d4daef&action=edit 34a2cba3]<br>
+[[MediaWiki_talk:14d4daef|Talk]]
+</td><td>
+Special
+</td><td>
+{{int:14d4daef}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ed2e8b27&action=edit a1024e18]<br>
+[[MediaWiki_talk:ed2e8b27|Talk]]
+</td><td>
+Template
+</td><td>
+{{int:ed2e8b27}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:31ebc74b&action=edit 313f5ee2]<br>
+[[MediaWiki_talk:31ebc74b|Talk]]
+</td><td>
+User page
+</td><td>
+{{int:31ebc74b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a8d28daa&action=edit 0611a13e]<br>
+[[MediaWiki_talk:a8d28daa|Talk]]
+</td><td>
+About
+</td><td>
+{{int:a8d28daa}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b0a98216&action=edit 7a85f476]<br>
+[[MediaWiki_talk:b0a98216|Talk]]
+</td><td>
+OK
+</td><td>
+{{int:b0a98216}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e081cf87&action=edit 23ace733]<br>
+[[MediaWiki_talk:e081cf87|Talk]]
+</td><td>
+Old password
+</td><td>
+{{int:e081cf87}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:db6998a7&action=edit dc894908]<br>
+[[MediaWiki_talk:db6998a7|Talk]]
+</td><td>
+orig
+</td><td>
+{{int:db6998a7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cb5dc4a4&action=edit 89f56e51]<br>
+[[MediaWiki_talk:cb5dc4a4|Talk]]
+</td><td>
+Orphaned pages
+</td><td>
+{{int:cb5dc4a4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:51caf0b1&action=edit e6287b24]<br>
+[[MediaWiki_talk:51caf0b1|Talk]]
+</td><td>
+Based on work by $1.
+</td><td>
+{{int:51caf0b1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:838fda53&action=edit f953cc13]<br>
+[[MediaWiki_talk:838fda53|Talk]]
+</td><td>
+Other languages
+</td><td>
+{{int:838fda53}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8b80dc12&action=edit 06b2b863]<br>
+[[MediaWiki_talk:8b80dc12|Talk]]
+</td><td>
+Move succeeded
+</td><td>
+{{int:8b80dc12}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:67c1c9b9&action=edit 6df06888]<br>
+[[MediaWiki_talk:67c1c9b9|Talk]]
+</td><td>
+Page &quot;&#91;&#91;$1]]&quot; moved to &quot;&#91;&#91;$2]]&quot;.
+</td><td>
+{{int:67c1c9b9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0488d9f9&action=edit ca0a1736]<br>
+[[MediaWiki_talk:0488d9f9|Talk]]
+</td><td>
+$1 - Wiktionary
+</td><td>
+{{int:0488d9f9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:724220c3&action=edit 00c46482]<br>
+[[MediaWiki_talk:724220c3|Talk]]
+</td><td>
+Someone (probably you, from IP address $1)
+requested that we send you a new Wiktionary login password.
+The password for user &quot;$2&quot; is now &quot;$3&quot;.
+You should log in and change your password now.
+</td><td>
+{{int:724220c3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:67675177&action=edit 9943fd1d]<br>
+[[MediaWiki_talk:67675177|Talk]]
+</td><td>
+Password reminder from Wiktionary
+</td><td>
+{{int:67675177}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:feea022e&action=edit 52c6d21a]<br>
+[[MediaWiki_talk:feea022e|Talk]]
+</td><td>
+A new password has been sent to the e-mail address
+registered for &quot;$1&quot;.
+Please log in again after you receive it.
+</td><td>
+{{int:feea022e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d89b33a4&action=edit 6148b748]<br>
+[[MediaWiki_talk:d89b33a4|Talk]]
+</td><td>
+The following data is cached and may not be completely up to date:
+</td><td>
+{{int:d89b33a4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7c3d6ba1&action=edit edb94b6f]<br>
+[[MediaWiki_talk:7c3d6ba1|Talk]]
+</td><td>
+Sorry! This feature has been temporarily disabled
+because it slows the database down to the point that no one can use
+the wiki.
+</td><td>
+{{int:7c3d6ba1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ba8fb63e&action=edit 7971fbbc]<br>
+[[MediaWiki_talk:ba8fb63e|Talk]]
+</td><td>
+Here&#39;s a saved copy from $1:
+</td><td>
+{{int:ba8fb63e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1f9d5196&action=edit faae8244]<br>
+[[MediaWiki_talk:1f9d5196|Talk]]
+</td><td>
+Personal tools
+</td><td>
+{{int:1f9d5196}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b55223c7&action=edit 23f3fd77]<br>
+[[MediaWiki_talk:b55223c7|Talk]]
+</td><td>
+Community portal
+</td><td>
+{{int:b55223c7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b6100630&action=edit d69501d7]<br>
+[[MediaWiki_talk:b6100630|Talk]]
+</td><td>
+Wiktionary:Community Portal
+</td><td>
+{{int:b6100630}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:83c6e160&action=edit 7ce546d1]<br>
+[[MediaWiki_talk:83c6e160|Talk]]
+</td><td>
+Post a comment
+</td><td>
+{{int:83c6e160}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f715eef0&action=edit 03d7f055]<br>
+[[MediaWiki_talk:f715eef0|Talk]]
+</td><td>
+Wiktionary is powered by &#91;http&#58;//www.mediawiki.org/ MediaWiki], an open source wiki engine.
+</td><td>
+{{int:f715eef0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5f86f380&action=edit fe586261]<br>
+[[MediaWiki_talk:5f86f380|Talk]]
+</td><td>
+Search
+</td><td>
+{{int:5f86f380}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:02360031&action=edit 7c50040c]<br>
+[[MediaWiki_talk:02360031|Talk]]
+</td><td>
+
+Search in namespaces :&lt;br /&gt;
+$1&lt;br /&gt;
+$2 List redirects &amp;nbsp; Search for $3 $9
+</td><td>
+{{int:02360031}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9dfd349e&action=edit dcedb31d]<br>
+[[MediaWiki_talk:9dfd349e|Talk]]
+</td><td>
+Preferences
+</td><td>
+{{int:9dfd349e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6b97fde2&action=edit 4d381b11]<br>
+[[MediaWiki_talk:6b97fde2|Talk]]
+</td><td>
+* &lt;strong&gt;Real name&lt;/strong&gt; (optional): if you choose to provide it this will be used for giving you attribution for your work.&lt;br/&gt;
+* &lt;strong&gt;Email&lt;/strong&gt; (optional): Enables people to contact you through the website without you having to reveal your
+email address to them, and it can be used to send you a new password if you forget it.
+</td><td>
+{{int:6b97fde2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:30cafb20&action=edit 4413aea7]<br>
+[[MediaWiki_talk:30cafb20|Talk]]
+</td><td>
+Misc settings
+</td><td>
+{{int:30cafb20}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:58796ee5&action=edit 79de347d]<br>
+[[MediaWiki_talk:58796ee5|Talk]]
+</td><td>
+User data
+</td><td>
+{{int:58796ee5}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e055ac90&action=edit b8a6f738]<br>
+[[MediaWiki_talk:e055ac90|Talk]]
+</td><td>
+Recent changes and stub display
+</td><td>
+{{int:e055ac90}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0603b5a9&action=edit 3b8a7d0e]<br>
+[[MediaWiki_talk:0603b5a9|Talk]]
+</td><td>
+You are logged in as &quot;$1&quot;.
+Your internal ID number is $2.
+
+See &#91;&#91;Wiktionary:User preferences help]] for help deciphering the options.
+</td><td>
+{{int:0603b5a9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2366fb91&action=edit f2475be5]<br>
+[[MediaWiki_talk:2366fb91|Talk]]
+</td><td>
+Not logged in
+</td><td>
+{{int:2366fb91}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0389a76a&action=edit 69cb02c9]<br>
+[[MediaWiki_talk:0389a76a|Talk]]
+</td><td>
+You must be &lt;a href=&quot;/wiki/Special:Userlogin&quot;&gt;logged in&lt;/a&gt;
+to set user preferences.
+</td><td>
+{{int:0389a76a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e6216751&action=edit 2b688ff4]<br>
+[[MediaWiki_talk:e6216751|Talk]]
+</td><td>
+Preferences have been reset from storage.
+</td><td>
+{{int:e6216751}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f1fbb2b4&action=edit 1aa787fe]<br>
+[[MediaWiki_talk:f1fbb2b4|Talk]]
+</td><td>
+Preview
+</td><td>
+{{int:f1fbb2b4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7357cd58&action=edit 353820b9]<br>
+[[MediaWiki_talk:7357cd58|Talk]]
+</td><td>
+This preview reflects the text in the upper
+text editing area as it will appear if you choose to save.
+</td><td>
+{{int:7357cd58}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f0bd6ebe&action=edit 2281018c]<br>
+[[MediaWiki_talk:f0bd6ebe|Talk]]
+</td><td>
+Remember that this is only a preview, and has not yet been saved!
+</td><td>
+{{int:f0bd6ebe}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c7db0778&action=edit 8b3bb669]<br>
+[[MediaWiki_talk:c7db0778|Talk]]
+</td><td>
+previous $1
+</td><td>
+{{int:c7db0778}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0a67b813&action=edit e1a919ba]<br>
+[[MediaWiki_talk:0a67b813|Talk]]
+</td><td>
+Printable version
+</td><td>
+{{int:0a67b813}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dc3b6f21&action=edit d4d3cccd]<br>
+[[MediaWiki_talk:dc3b6f21|Talk]]
+</td><td>
+(From http&#58;//tl.wiktionary.org)
+</td><td>
+{{int:dc3b6f21}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:016ac2dc&action=edit 145969f1]<br>
+[[MediaWiki_talk:016ac2dc|Talk]]
+</td><td>
+Protect
+</td><td>
+{{int:016ac2dc}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:073135b4&action=edit bf7f9e49]<br>
+[[MediaWiki_talk:073135b4|Talk]]
+</td><td>
+Reason for protecting
+</td><td>
+{{int:073135b4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3ee691ce&action=edit 1f880b64]<br>
+[[MediaWiki_talk:3ee691ce|Talk]]
+</td><td>
+protected &#91;&#91;$1]]
+</td><td>
+{{int:3ee691ce}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a44b308c&action=edit 7afa7fea]<br>
+[[MediaWiki_talk:a44b308c|Talk]]
+</td><td>
+Protected page
+</td><td>
+{{int:a44b308c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0017a4f5&action=edit 962032da]<br>
+[[MediaWiki_talk:0017a4f5|Talk]]
+</td><td>
+WARNING: This page has been locked so that only
+users with sysop privileges can edit it. Be sure you are following the
+&lt;a href=&#39;/w/wiki.phtml/Wiktionary:Protected_page_guidelines&#39;&gt;protected page
+guidelines&lt;/a&gt;.
+</td><td>
+{{int:0017a4f5}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cf2a914e&action=edit 561f00bf]<br>
+[[MediaWiki_talk:cf2a914e|Talk]]
+</td><td>
+This page has been locked to prevent editing; there are
+a number of reasons why this may be so, please see
+&#91;&#91;Wiktionary:Protected page]].
+
+You can view and copy the source of this page:
+</td><td>
+{{int:cf2a914e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bb915483&action=edit 85888484]<br>
+[[MediaWiki_talk:bb915483|Talk]]
+</td><td>
+Protection_log
+</td><td>
+{{int:bb915483}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:061ec7fa&action=edit 197cfa0d]<br>
+[[MediaWiki_talk:061ec7fa|Talk]]
+</td><td>
+Below is a list of page locks/unlocks.
+See &#91;&#91;Wiktionary:Protected page]] for more information.
+</td><td>
+{{int:061ec7fa}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d2ae1354&action=edit 33c2c02c]<br>
+[[MediaWiki_talk:d2ae1354|Talk]]
+</td><td>
+Protect page
+</td><td>
+{{int:d2ae1354}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c0e9bbaf&action=edit 5cbc043a]<br>
+[[MediaWiki_talk:c0e9bbaf|Talk]]
+</td><td>
+(give a reason)
+</td><td>
+{{int:c0e9bbaf}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:23176a41&action=edit 24a81acc]<br>
+[[MediaWiki_talk:23176a41|Talk]]
+</td><td>
+(Protecting &quot;$1&quot;)
+</td><td>
+{{int:23176a41}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:884b47b3&action=edit 77ca39fa]<br>
+[[MediaWiki_talk:884b47b3|Talk]]
+</td><td>
+Protect this page
+</td><td>
+{{int:884b47b3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0ebe1928&action=edit 11599708]<br>
+[[MediaWiki_talk:0ebe1928|Talk]]
+</td><td>
+Proxy blocker
+</td><td>
+{{int:0ebe1928}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0ccb1a72&action=edit f4482395]<br>
+[[MediaWiki_talk:0ccb1a72|Talk]]
+</td><td>
+Your IP address has been blocked because it is an open proxy. Please contact your Internet service provider or tech support and inform them of this serious security problem.
+</td><td>
+{{int:0ccb1a72}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:88af6e64&action=edit 01b6671f]<br>
+[[MediaWiki_talk:88af6e64|Talk]]
+</td><td>
+Done.
+
+</td><td>
+{{int:88af6e64}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b1594c4a&action=edit 596b17aa]<br>
+[[MediaWiki_talk:b1594c4a|Talk]]
+</td><td>
+Browse
+</td><td>
+{{int:b1594c4a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:25b61f80&action=edit 9e11e13b]<br>
+[[MediaWiki_talk:25b61f80|Talk]]
+</td><td>
+Edit
+</td><td>
+{{int:25b61f80}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e1a9ed9d&action=edit cc717307]<br>
+[[MediaWiki_talk:e1a9ed9d|Talk]]
+</td><td>
+Find
+</td><td>
+{{int:e1a9ed9d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:24074cfc&action=edit a40d0b3f]<br>
+[[MediaWiki_talk:24074cfc|Talk]]
+</td><td>
+My pages
+</td><td>
+{{int:24074cfc}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:af83fbba&action=edit 8f794a0f]<br>
+[[MediaWiki_talk:af83fbba|Talk]]
+</td><td>
+Context
+</td><td>
+{{int:af83fbba}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c8fff0e7&action=edit 20fec244]<br>
+[[MediaWiki_talk:c8fff0e7|Talk]]
+</td><td>
+This page
+</td><td>
+{{int:c8fff0e7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5a6ec2af&action=edit 2dfd6121]<br>
+[[MediaWiki_talk:5a6ec2af|Talk]]
+</td><td>
+Quickbar settings
+</td><td>
+{{int:5a6ec2af}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8eda832f&action=edit e97e9088]<br>
+[[MediaWiki_talk:8eda832f|Talk]]
+</td><td>
+Special pages
+</td><td>
+{{int:8eda832f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ce1c6b9a&action=edit dc17545e]<br>
+[[MediaWiki_talk:ce1c6b9a|Talk]]
+</td><td>
+Submit query
+</td><td>
+{{int:ce1c6b9a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:342970ff&action=edit 7f2e7314]<br>
+[[MediaWiki_talk:342970ff|Talk]]
+</td><td>
+Query successful
+</td><td>
+{{int:342970ff}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c2300ac1&action=edit 08f4cb5c]<br>
+[[MediaWiki_talk:c2300ac1|Talk]]
+</td><td>
+Random page
+</td><td>
+{{int:c2300ac1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9f84f8de&action=edit 0da9559a]<br>
+[[MediaWiki_talk:9f84f8de|Talk]]
+</td><td>
+The sysop ability to create range blocks is disabled.
+</td><td>
+{{int:9f84f8de}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f65142b8&action=edit 65c24302]<br>
+[[MediaWiki_talk:f65142b8|Talk]]
+</td><td>
+in $4 form; $1 minor edits; $2 secondary namespaces; $3 multiple edits.
+</td><td>
+{{int:f65142b8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:78b9278a&action=edit 96bcbd6a]<br>
+[[MediaWiki_talk:78b9278a|Talk]]
+</td><td>
+Show last $1 changes in last $2 days&lt;br /&gt;$3
+</td><td>
+{{int:78b9278a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ead9cd8b&action=edit 69cdd5ad]<br>
+[[MediaWiki_talk:ead9cd8b|Talk]]
+</td><td>
+Show new changes starting from $1
+</td><td>
+{{int:ead9cd8b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bad8b81d&action=edit f13491ba]<br>
+[[MediaWiki_talk:bad8b81d|Talk]]
+</td><td>
+; $1 edits from logged in users
+</td><td>
+{{int:bad8b81d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:58a7c0de&action=edit ced7752e]<br>
+[[MediaWiki_talk:58a7c0de|Talk]]
+</td><td>
+Loading recent changes
+</td><td>
+{{int:58a7c0de}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c3fd1aca&action=edit d259fbf6]<br>
+[[MediaWiki_talk:c3fd1aca|Talk]]
+</td><td>
+(to pages linked from &quot;$1&quot;)
+</td><td>
+{{int:c3fd1aca}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2c0a654c&action=edit 15ea8401]<br>
+[[MediaWiki_talk:2c0a654c|Talk]]
+</td><td>
+Below are the last &lt;strong&gt;$1&lt;/strong&gt; changes in last &lt;strong&gt;$2&lt;/strong&gt; days.
+</td><td>
+{{int:2c0a654c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0a00aaba&action=edit c516366b]<br>
+[[MediaWiki_talk:0a00aaba|Talk]]
+</td><td>
+Below are the changes since &lt;b&gt;$2&lt;/b&gt; (up to &lt;b&gt;$1&lt;/b&gt; shown).
+</td><td>
+{{int:0a00aaba}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1d920fff&action=edit 9a277182]<br>
+[[MediaWiki_talk:1d920fff|Talk]]
+</td><td>
+Database locked
+</td><td>
+{{int:1d920fff}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:64743780&action=edit e5990e81]<br>
+[[MediaWiki_talk:64743780|Talk]]
+</td><td>
+The database is currently locked to new
+entries and other modifications, probably for routine database maintenance,
+after which it will be back to normal.
+The administrator who locked it offered this explanation:
+&lt;p&gt;$1
+</td><td>
+{{int:64743780}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8c9d6af6&action=edit 74bcbeed]<br>
+[[MediaWiki_talk:8c9d6af6|Talk]]
+</td><td>
+WARNING: The database has been locked for maintenance,
+so you will not be able to save your edits right now. You may wish to cut-n-paste
+the text into a text file and save it for later.
+</td><td>
+{{int:8c9d6af6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4d75dd33&action=edit 51734654]<br>
+[[MediaWiki_talk:4d75dd33|Talk]]
+</td><td>
+Recent changes
+</td><td>
+{{int:4d75dd33}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:40f1d259&action=edit 44d93957]<br>
+[[MediaWiki_talk:40f1d259|Talk]]
+</td><td>
+Number of titles in recent changes
+</td><td>
+{{int:40f1d259}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:312aafe1&action=edit b5822b16]<br>
+[[MediaWiki_talk:312aafe1|Talk]]
+</td><td>
+Related changes
+</td><td>
+{{int:312aafe1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2f453993&action=edit 049f8c5f]<br>
+[[MediaWiki_talk:2f453993|Talk]]
+</td><td>
+Track the most recent changes to the wiki on this page.
+</td><td>
+{{int:2f453993}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7124fa4a&action=edit 43d741c1]<br>
+[[MediaWiki_talk:7124fa4a|Talk]]
+</td><td>
+(Redirected from $1)
+</td><td>
+{{int:7124fa4a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:54d89323&action=edit 4eef1c9f]<br>
+[[MediaWiki_talk:54d89323|Talk]]
+</td><td>
+Remember my password across sessions.
+</td><td>
+{{int:54d89323}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:537f5507&action=edit bfa5dc98]<br>
+[[MediaWiki_talk:537f5507|Talk]]
+</td><td>
+Remove checked items from watchlist
+</td><td>
+{{int:537f5507}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:78e82769&action=edit eeadf87c]<br>
+[[MediaWiki_talk:78e82769|Talk]]
+</td><td>
+Removed from watchlist
+</td><td>
+{{int:78e82769}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ad711aa8&action=edit d9807612]<br>
+[[MediaWiki_talk:ad711aa8|Talk]]
+</td><td>
+The page &quot;$1&quot; has been removed from your watchlist.
+</td><td>
+{{int:ad711aa8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:48b0bcb6&action=edit 7d083ee5]<br>
+[[MediaWiki_talk:48b0bcb6|Talk]]
+</td><td>
+Removing requested items from watchlist...
+</td><td>
+{{int:48b0bcb6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2f022894&action=edit 4b81718e]<br>
+[[MediaWiki_talk:2f022894|Talk]]
+</td><td>
+Reset preferences
+</td><td>
+{{int:2f022894}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bc47acaf&action=edit 8f8f7d13]<br>
+[[MediaWiki_talk:bc47acaf|Talk]]
+</td><td>
+$1 deleted edits
+</td><td>
+{{int:bc47acaf}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6add8c15&action=edit 8f0c68f0]<br>
+[[MediaWiki_talk:6add8c15|Talk]]
+</td><td>
+Hits to show per page
+</td><td>
+{{int:6add8c15}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6f54af5b&action=edit a5e2f101]<br>
+[[MediaWiki_talk:6f54af5b|Talk]]
+</td><td>
+Retrieved from &quot;$1&quot;
+</td><td>
+{{int:6f54af5b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3dcff1b0&action=edit 58f19667]<br>
+[[MediaWiki_talk:3dcff1b0|Talk]]
+</td><td>
+Return to $1.
+</td><td>
+{{int:3dcff1b0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b9ac79c6&action=edit 2b9171b6]<br>
+[[MediaWiki_talk:b9ac79c6|Talk]]
+</td><td>
+Retype new password
+</td><td>
+{{int:b9ac79c6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b7ae8f64&action=edit a3eee606]<br>
+[[MediaWiki_talk:b7ae8f64|Talk]]
+</td><td>
+Re-upload
+</td><td>
+{{int:b7ae8f64}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cd783da0&action=edit d7ba5bcb]<br>
+[[MediaWiki_talk:cd783da0|Talk]]
+</td><td>
+Return to the upload form.
+</td><td>
+{{int:cd783da0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c73c43f3&action=edit f322de9a]<br>
+[[MediaWiki_talk:c73c43f3|Talk]]
+</td><td>
+Reverted to earlier revision
+</td><td>
+{{int:c73c43f3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:18591a4b&action=edit 0d86ed82]<br>
+[[MediaWiki_talk:18591a4b|Talk]]
+</td><td>
+rev
+</td><td>
+{{int:18591a4b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b24ef4f1&action=edit 8ce494ea]<br>
+[[MediaWiki_talk:b24ef4f1|Talk]]
+</td><td>
+Reverted edit of $2, changed back to last version by $1
+</td><td>
+{{int:b24ef4f1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:96e64350&action=edit 949a77c7]<br>
+[[MediaWiki_talk:96e64350|Talk]]
+</td><td>
+Revision history
+</td><td>
+{{int:96e64350}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0c299dc7&action=edit 3338672b]<br>
+[[MediaWiki_talk:0c299dc7|Talk]]
+</td><td>
+Revision as of $1
+</td><td>
+{{int:0c299dc7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:159f321a&action=edit d567812b]<br>
+[[MediaWiki_talk:159f321a|Talk]]
+</td><td>
+Revision not found
+</td><td>
+{{int:159f321a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:955fec48&action=edit 4060f114]<br>
+[[MediaWiki_talk:955fec48|Talk]]
+</td><td>
+The old revision of the page you asked for could not be found.
+Please check the URL you used to access this page.
+
+</td><td>
+{{int:955fec48}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b2f04988&action=edit e8b606c2]<br>
+[[MediaWiki_talk:b2f04988|Talk]]
+</td><td>
+http&#58;//www.faqs.org/rfcs/rfc$1.html
+</td><td>
+{{int:b2f04988}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:db94ff6b&action=edit 1407cb23]<br>
+[[MediaWiki_talk:db94ff6b|Talk]]
+</td><td>
+Rights:
+</td><td>
+{{int:db94ff6b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f28daee2&action=edit ff3a6f3b]<br>
+[[MediaWiki_talk:f28daee2|Talk]]
+</td><td>
+Roll back edits
+</td><td>
+{{int:f28daee2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2aaec24c&action=edit 5f0fa7e7]<br>
+[[MediaWiki_talk:2aaec24c|Talk]]
+</td><td>
+Rollback
+</td><td>
+{{int:2aaec24c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:54d37a4c&action=edit 73c685e6]<br>
+[[MediaWiki_talk:54d37a4c|Talk]]
+</td><td>
+Rollback failed
+</td><td>
+{{int:54d37a4c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b82a8f42&action=edit 1a9fae49]<br>
+[[MediaWiki_talk:b82a8f42|Talk]]
+</td><td>
+rollback
+</td><td>
+{{int:b82a8f42}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:52d0b352&action=edit 6c30d261]<br>
+[[MediaWiki_talk:52d0b352|Talk]]
+</td><td>
+Rows
+</td><td>
+{{int:52d0b352}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5a43014e&action=edit 1308cde0]<br>
+[[MediaWiki_talk:5a43014e|Talk]]
+</td><td>
+Save page
+</td><td>
+{{int:5a43014e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0e53fdc8&action=edit 5f6543d0]<br>
+[[MediaWiki_talk:0e53fdc8|Talk]]
+</td><td>
+Your preferences have been saved.
+</td><td>
+{{int:0e53fdc8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e1415b15&action=edit d6d40a58]<br>
+[[MediaWiki_talk:e1415b15|Talk]]
+</td><td>
+Save file
+</td><td>
+{{int:e1415b15}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ad98e68e&action=edit 34ac956e]<br>
+[[MediaWiki_talk:ad98e68e|Talk]]
+</td><td>
+Save preferences
+</td><td>
+{{int:ad98e68e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bce06414&action=edit 3559d7ac]<br>
+[[MediaWiki_talk:bce06414|Talk]]
+</td><td>
+Search
+</td><td>
+{{int:bce06414}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8f6495a7&action=edit cfa0722d]<br>
+[[MediaWiki_talk:8f6495a7|Talk]]
+</td><td>
+&lt;p&gt;Sorry! Full text search has been disabled temporarily, for performance reasons. In the meantime, you can use the Google search below, which may be out of date.&lt;/p&gt;
+</td><td>
+{{int:8f6495a7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:72344e87&action=edit 3eea6ce4]<br>
+[[MediaWiki_talk:72344e87|Talk]]
+</td><td>
+Wiktionary:Searching
+</td><td>
+{{int:72344e87}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cb9c1653&action=edit da48347f]<br>
+[[MediaWiki_talk:cb9c1653|Talk]]
+</td><td>
+Searching Wiktionary
+</td><td>
+{{int:cb9c1653}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3d79ca88&action=edit 64bdca9a]<br>
+[[MediaWiki_talk:3d79ca88|Talk]]
+</td><td>
+For query &quot;$1&quot;
+</td><td>
+{{int:3d79ca88}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b2f7c0e1&action=edit 8ef6d4d3]<br>
+[[MediaWiki_talk:b2f7c0e1|Talk]]
+</td><td>
+Search results
+</td><td>
+{{int:b2f7c0e1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e5ed9018&action=edit 83d578cd]<br>
+[[MediaWiki_talk:e5ed9018|Talk]]
+</td><td>
+Search result settings
+</td><td>
+{{int:e5ed9018}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8cab5350&action=edit 781b9fee]<br>
+[[MediaWiki_talk:8cab5350|Talk]]
+</td><td>
+For more information about searching Wiktionary, see $1.
+</td><td>
+{{int:8cab5350}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:37b6df63&action=edit a26b768d]<br>
+[[MediaWiki_talk:37b6df63|Talk]]
+</td><td>
+ (section)
+</td><td>
+{{int:37b6df63}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:be4aaa62&action=edit 2ddce298]<br>
+[[MediaWiki_talk:be4aaa62|Talk]]
+</td><td>
+Select a newer version for comparison
+</td><td>
+{{int:be4aaa62}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5124db4d&action=edit 80ffa0cb]<br>
+[[MediaWiki_talk:5124db4d|Talk]]
+</td><td>
+Select an older version for comparison
+</td><td>
+{{int:5124db4d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a3c0a747&action=edit 5ec1b504]<br>
+[[MediaWiki_talk:a3c0a747|Talk]]
+</td><td>
+Only read-only queries are allowed.
+</td><td>
+{{int:a3c0a747}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e93eec9e&action=edit 06cf46b3]<br>
+[[MediaWiki_talk:e93eec9e|Talk]]
+</td><td>
+Pages with Self Links
+</td><td>
+{{int:e93eec9e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f993dd01&action=edit e7caf074]<br>
+[[MediaWiki_talk:f993dd01|Talk]]
+</td><td>
+The following pages contain a link to themselves, which they should not.
+</td><td>
+{{int:f993dd01}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fa56e16c&action=edit 249c203d]<br>
+[[MediaWiki_talk:fa56e16c|Talk]]
+</td><td>
+There were serious xhtml markup errors detected by tidy.
+</td><td>
+{{int:fa56e16c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5123f28d&action=edit 8fcf47da]<br>
+[[MediaWiki_talk:5123f28d|Talk]]
+</td><td>
+Server time is now
+</td><td>
+{{int:5123f28d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4075f71a&action=edit 79d35179]<br>
+[[MediaWiki_talk:4075f71a|Talk]]
+</td><td>
+&lt;b&gt;User rights for &quot;$1&quot; could not be set. (Did you enter the name correctly?)&lt;/b&gt;
+</td><td>
+{{int:4075f71a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:13187ffc&action=edit f2cd2a2a]<br>
+[[MediaWiki_talk:13187ffc|Talk]]
+</td><td>
+Set user rights
+</td><td>
+{{int:13187ffc}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:56640761&action=edit c5bfd68a]<br>
+[[MediaWiki_talk:56640761|Talk]]
+</td><td>
+Set bureaucrat flag
+</td><td>
+{{int:56640761}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0d3f883b&action=edit fff9c94a]<br>
+[[MediaWiki_talk:0d3f883b|Talk]]
+</td><td>
+Short pages
+</td><td>
+{{int:0d3f883b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d97d1ee3&action=edit 9fb29051]<br>
+[[MediaWiki_talk:d97d1ee3|Talk]]
+</td><td>
+show
+</td><td>
+{{int:d97d1ee3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f1a75ecf&action=edit 4fe654c7]<br>
+[[MediaWiki_talk:f1a75ecf|Talk]]
+</td><td>
+$1 minor edits &#124; $2 bots &#124; $3 logged in users
+</td><td>
+{{int:f1a75ecf}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:72fad336&action=edit 9569cf23]<br>
+[[MediaWiki_talk:72fad336|Talk]]
+</td><td>
+Showing below &lt;b&gt;$1&lt;/b&gt; results starting with #&lt;b&gt;$2&lt;/b&gt;.
+</td><td>
+{{int:72fad336}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6db37657&action=edit f7535b52]<br>
+[[MediaWiki_talk:6db37657|Talk]]
+</td><td>
+Showing below &lt;b&gt;$3&lt;/b&gt; results starting with #&lt;b&gt;$2&lt;/b&gt;.
+</td><td>
+{{int:6db37657}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:acbdf814&action=edit 43158759]<br>
+[[MediaWiki_talk:acbdf814|Talk]]
+</td><td>
+Show last $1 images sorted $2.
+</td><td>
+{{int:acbdf814}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:915318a0&action=edit ac2b4c32]<br>
+[[MediaWiki_talk:915318a0|Talk]]
+</td><td>
+Show preview
+</td><td>
+{{int:915318a0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cfff5a5a&action=edit 6eeee3cb]<br>
+[[MediaWiki_talk:cfff5a5a|Talk]]
+</td><td>
+show
+</td><td>
+{{int:cfff5a5a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ac617b53&action=edit 1144c9d9]<br>
+[[MediaWiki_talk:ac617b53|Talk]]
+</td><td>
+Your signature with timestamp
+</td><td>
+{{int:ac617b53}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f6baa6ad&action=edit 7f5726ac]<br>
+[[MediaWiki_talk:f6baa6ad|Talk]]
+</td><td>
+Site statistics
+</td><td>
+{{int:f6baa6ad}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e150b0f4&action=edit 8e86f95d]<br>
+[[MediaWiki_talk:e150b0f4|Talk]]
+</td><td>
+There are &#39;&#39;&#39;$1&#39;&#39;&#39; total pages in the database.
+This includes &quot;talk&quot; pages, pages about Wiktionary, minimal &quot;stub&quot;
+pages, redirects, and others that probably don&#39;t qualify as content pages.
+Excluding those, there are &#39;&#39;&#39;$2&#39;&#39;&#39; pages that are probably legitimate
+content pages.
+
+There have been a total of &#39;&#39;&#39;$3&#39;&#39;&#39; page views, and &#39;&#39;&#39;$4&#39;&#39;&#39; page edits
+since the wiki was setup.
+That comes to &#39;&#39;&#39;$5&#39;&#39;&#39; average edits per page, and &#39;&#39;&#39;$6&#39;&#39;&#39; views per edit.
+</td><td>
+{{int:e150b0f4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:daaf7240&action=edit 8dca090f]<br>
+[[MediaWiki_talk:daaf7240|Talk]]
+</td><td>
+The Free Encyclopedia
+</td><td>
+{{int:daaf7240}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3b434c6d&action=edit 32b42c53]<br>
+[[MediaWiki_talk:3b434c6d|Talk]]
+</td><td>
+Donations
+</td><td>
+{{int:3b434c6d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d75649ef&action=edit d88e8164]<br>
+[[MediaWiki_talk:d75649ef|Talk]]
+</td><td>
+Wiktionary
+</td><td>
+{{int:d75649ef}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cb06d8a3&action=edit b64ec710]<br>
+[[MediaWiki_talk:cb06d8a3|Talk]]
+</td><td>
+Wiktionary user $1
+</td><td>
+{{int:cb06d8a3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d25d37c8&action=edit 4f548531]<br>
+[[MediaWiki_talk:d25d37c8|Talk]]
+</td><td>
+Wiktionary user(s) $1
+</td><td>
+{{int:d25d37c8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8f57bd61&action=edit d0cb2acd]<br>
+[[MediaWiki_talk:8f57bd61|Talk]]
+</td><td>
+Skin
+</td><td>
+{{int:8f57bd61}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d3a6dd4e&action=edit bcd196f9]<br>
+[[MediaWiki_talk:d3a6dd4e|Talk]]
+</td><td>
+The page you wanted to save was blocked by the spam filter. This is probably caused by a link to an external site.
+
+You might want to check the following regular expression for patterns that are currently blocked:
+</td><td>
+{{int:d3a6dd4e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:add33980&action=edit 60a90929]<br>
+[[MediaWiki_talk:add33980|Talk]]
+</td><td>
+Spam protection filter
+</td><td>
+{{int:add33980}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:984c6817&action=edit 25255195]<br>
+[[MediaWiki_talk:984c6817|Talk]]
+</td><td>
+Special Page
+</td><td>
+{{int:984c6817}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b67d51d8&action=edit 62bc32dc]<br>
+[[MediaWiki_talk:b67d51d8|Talk]]
+</td><td>
+Special pages
+</td><td>
+{{int:b67d51d8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c9709132&action=edit 73ac2b41]<br>
+[[MediaWiki_talk:c9709132|Talk]]
+</td><td>
+Special pages for all users
+</td><td>
+{{int:c9709132}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:87ac14f6&action=edit ed31e1e1]<br>
+[[MediaWiki_talk:87ac14f6|Talk]]
+</td><td>
+Please note that all queries are logged.
+</td><td>
+{{int:87ac14f6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:00c261e1&action=edit 26cb51a1]<br>
+[[MediaWiki_talk:00c261e1|Talk]]
+</td><td>
+Enter query
+</td><td>
+{{int:00c261e1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2086b21f&action=edit 3d18b2ea]<br>
+[[MediaWiki_talk:2086b21f|Talk]]
+</td><td>
+Statistics
+</td><td>
+{{int:2086b21f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8cd0c85e&action=edit 1b9e838c]<br>
+[[MediaWiki_talk:8cd0c85e|Talk]]
+</td><td>
+Stored version
+</td><td>
+{{int:8cd0c85e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2eb6d4bd&action=edit a5125d69]<br>
+[[MediaWiki_talk:2eb6d4bd|Talk]]
+</td><td>
+Threshold for stub display
+</td><td>
+{{int:2eb6d4bd}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f3da206c&action=edit ef062b0e]<br>
+[[MediaWiki_talk:f3da206c|Talk]]
+</td><td>
+Subcategories
+</td><td>
+{{int:f3da206c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8d183dbd&action=edit 335ce16b]<br>
+[[MediaWiki_talk:8d183dbd|Talk]]
+</td><td>
+Subject/headline
+</td><td>
+{{int:8d183dbd}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ca29f2df&action=edit d7084ef8]<br>
+[[MediaWiki_talk:ca29f2df|Talk]]
+</td><td>
+View subject
+</td><td>
+{{int:ca29f2df}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:17bc3900&action=edit 3dfd0f51]<br>
+[[MediaWiki_talk:17bc3900|Talk]]
+</td><td>
+Successful upload
+</td><td>
+{{int:17bc3900}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:12b71c3e&action=edit 05535ecf]<br>
+[[MediaWiki_talk:12b71c3e|Talk]]
+</td><td>
+Summary
+</td><td>
+{{int:12b71c3e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ee858d9a&action=edit fde4e0f4]<br>
+[[MediaWiki_talk:ee858d9a|Talk]]
+</td><td>
+For sysop use only
+</td><td>
+{{int:ee858d9a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2f758a39&action=edit 85232d4f]<br>
+[[MediaWiki_talk:2f758a39|Talk]]
+</td><td>
+The action you have requested can only be
+performed by users with &quot;sysop&quot; status.
+See $1.
+</td><td>
+{{int:2f758a39}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:91b8467b&action=edit 3265b18d]<br>
+[[MediaWiki_talk:91b8467b|Talk]]
+</td><td>
+Sysop access required
+</td><td>
+{{int:91b8467b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:77b9c5ad&action=edit 109e51e1]<br>
+[[MediaWiki_talk:77b9c5ad|Talk]]
+</td><td>
+table
+</td><td>
+{{int:77b9c5ad}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4e6a710d&action=edit e55e91b2]<br>
+[[MediaWiki_talk:4e6a710d|Talk]]
+</td><td>
+Discussion
+</td><td>
+{{int:4e6a710d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c11ac522&action=edit 1ed6d2b4]<br>
+[[MediaWiki_talk:c11ac522|Talk]]
+</td><td>
+The page itself was moved successfully, but the
+talk page could not be moved because one already exists at the new
+title. Please merge them manually.
+</td><td>
+{{int:c11ac522}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6a38ff98&action=edit 3c940bbf]<br>
+[[MediaWiki_talk:6a38ff98|Talk]]
+</td><td>
+Discuss this page
+</td><td>
+{{int:6a38ff98}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2b630ea0&action=edit f053e191]<br>
+[[MediaWiki_talk:2b630ea0|Talk]]
+</td><td>
+The corresponding talk page was also moved.
+</td><td>
+{{int:2b630ea0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2282b1ca&action=edit f3b6a64f]<br>
+[[MediaWiki_talk:2282b1ca|Talk]]
+</td><td>
+The corresponding talk page was &lt;strong&gt;not&lt;/strong&gt; moved.
+</td><td>
+{{int:2282b1ca}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:45e3f76d&action=edit 6534acb5]<br>
+[[MediaWiki_talk:45e3f76d|Talk]]
+</td><td>
+&lt;!-- MediaWiki:talkpagetext --&gt;
+</td><td>
+{{int:45e3f76d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:607359f2&action=edit 5788df25]<br>
+[[MediaWiki_talk:607359f2|Talk]]
+</td><td>
+Textbox dimensions
+</td><td>
+{{int:607359f2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:66571fbc&action=edit 7d66aa0e]<br>
+[[MediaWiki_talk:66571fbc|Talk]]
+</td><td>
+Page text matches
+</td><td>
+{{int:66571fbc}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e1f36741&action=edit 83f663ed]<br>
+[[MediaWiki_talk:e1f36741|Talk]]
+</td><td>
+View or restore $1?
+</td><td>
+{{int:e1f36741}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a29f027b&action=edit a299730b]<br>
+[[MediaWiki_talk:a29f027b|Talk]]
+</td><td>
+Enlarge
+</td><td>
+{{int:a29f027b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9ced4850&action=edit 36ee6f56]<br>
+[[MediaWiki_talk:9ced4850|Talk]]
+</td><td>
+Time zone
+</td><td>
+{{int:9ced4850}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ca45a968&action=edit 9dba4eb8]<br>
+[[MediaWiki_talk:ca45a968|Talk]]
+</td><td>
+Offset
+</td><td>
+{{int:ca45a968}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:60bc3c41&action=edit 3f58a2a9]<br>
+[[MediaWiki_talk:60bc3c41|Talk]]
+</td><td>
+Enter number of hours your local time differs
+from server time (UTC).
+</td><td>
+{{int:60bc3c41}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2618febd&action=edit e3f5384c]<br>
+[[MediaWiki_talk:2618febd|Talk]]
+</td><td>
+Article title matches
+</td><td>
+{{int:2618febd}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b8684fcf&action=edit 2a609230]<br>
+[[MediaWiki_talk:b8684fcf|Talk]]
+</td><td>
+Table of contents
+</td><td>
+{{int:b8684fcf}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:692107c0&action=edit d75ba923]<br>
+[[MediaWiki_talk:692107c0|Talk]]
+</td><td>
+Toolbox
+</td><td>
+{{int:692107c0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b9116941&action=edit 04ced041]<br>
+[[MediaWiki_talk:b9116941|Talk]]
+</td><td>
+Add a comment to this page. &#91;alt-+]
+</td><td>
+{{int:b9116941}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b583d36c&action=edit b987f993]<br>
+[[MediaWiki_talk:b583d36c|Talk]]
+</td><td>
+Discussion about edits from this ip address &#91;alt-n]
+</td><td>
+{{int:b583d36c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9efcbe2f&action=edit e3522c89]<br>
+[[MediaWiki_talk:9efcbe2f|Talk]]
+</td><td>
+The user page for the ip you&#39;re editing as &#91;alt-.]
+</td><td>
+{{int:9efcbe2f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2a4b9eea&action=edit f3025f7a]<br>
+[[MediaWiki_talk:2a4b9eea|Talk]]
+</td><td>
+View the content page &#91;alt-a]
+</td><td>
+{{int:2a4b9eea}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dcd3ca0c&action=edit e420bf33]<br>
+[[MediaWiki_talk:dcd3ca0c|Talk]]
+</td><td>
+Atom feed for this page
+</td><td>
+{{int:dcd3ca0c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d318992a&action=edit d2ae036e]<br>
+[[MediaWiki_talk:d318992a|Talk]]
+</td><td>
+See the differences between the two selected versions of this page. &#91;alt-v]
+</td><td>
+{{int:d318992a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:17165e38&action=edit 2039dc44]<br>
+[[MediaWiki_talk:17165e38|Talk]]
+</td><td>
+View the list of contributions of this user
+</td><td>
+{{int:17165e38}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:32bcbdd6&action=edit d57ba9d6]<br>
+[[MediaWiki_talk:32bcbdd6|Talk]]
+</td><td>
+Find background information on current events
+</td><td>
+{{int:32bcbdd6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b1488dbf&action=edit 742e0c2a]<br>
+[[MediaWiki_talk:b1488dbf|Talk]]
+</td><td>
+Delete this page &#91;alt-d]
+</td><td>
+{{int:b1488dbf}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8c519c79&action=edit 6b354128]<br>
+[[MediaWiki_talk:8c519c79|Talk]]
+</td><td>
+You can edit this page. Please use the preview button before saving. &#91;alt-e]
+</td><td>
+{{int:8c519c79}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d61f42ac&action=edit 6a333373]<br>
+[[MediaWiki_talk:d61f42ac|Talk]]
+</td><td>
+Send a mail to this user
+</td><td>
+{{int:d61f42ac}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:691c7c4c&action=edit 1f2e0a5e]<br>
+[[MediaWiki_talk:691c7c4c|Talk]]
+</td><td>
+The place to find out.
+</td><td>
+{{int:691c7c4c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:da5d5f0e&action=edit 357e85a5]<br>
+[[MediaWiki_talk:da5d5f0e|Talk]]
+</td><td>
+Past versions of this page, &#91;alt-h]
+</td><td>
+{{int:da5d5f0e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e20e86bb&action=edit ff6db008]<br>
+[[MediaWiki_talk:e20e86bb|Talk]]
+</td><td>
+You are encouraged to log in, it is not mandatory however. &#91;alt-o]
+</td><td>
+{{int:e20e86bb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ab189540&action=edit bd25c34c]<br>
+[[MediaWiki_talk:ab189540|Talk]]
+</td><td>
+Log out &#91;alt-o]
+</td><td>
+{{int:ab189540}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8d2a8168&action=edit 9dd4b86c]<br>
+[[MediaWiki_talk:8d2a8168|Talk]]
+</td><td>
+Visit the Main Page &#91;alt-z]
+</td><td>
+{{int:8d2a8168}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7316f250&action=edit 2138d388]<br>
+[[MediaWiki_talk:7316f250|Talk]]
+</td><td>
+Mark this as a minor edit &#91;alt-i]
+</td><td>
+{{int:7316f250}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bfe45253&action=edit 00ef7343]<br>
+[[MediaWiki_talk:bfe45253|Talk]]
+</td><td>
+Move this page &#91;alt-m]
+</td><td>
+{{int:bfe45253}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c2b4d858&action=edit 0bafecde]<br>
+[[MediaWiki_talk:c2b4d858|Talk]]
+</td><td>
+List of my contributions &#91;alt-y]
+</td><td>
+{{int:c2b4d858}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:22361e38&action=edit ef887076]<br>
+[[MediaWiki_talk:22361e38|Talk]]
+</td><td>
+My talk page &#91;alt-n]
+</td><td>
+{{int:22361e38}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b34e28e3&action=edit 6d66eb21]<br>
+[[MediaWiki_talk:b34e28e3|Talk]]
+</td><td>
+You don&#39;t have the permissions to move this page
+</td><td>
+{{int:b34e28e3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:dc2cdd3b&action=edit 7e77da11]<br>
+[[MediaWiki_talk:dc2cdd3b|Talk]]
+</td><td>
+About the project, what you can do, where to find things
+</td><td>
+{{int:dc2cdd3b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2032ad70&action=edit 5611ec73]<br>
+[[MediaWiki_talk:2032ad70|Talk]]
+</td><td>
+My preferences
+</td><td>
+{{int:2032ad70}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d87a6e27&action=edit 71b5f228]<br>
+[[MediaWiki_talk:d87a6e27|Talk]]
+</td><td>
+Preview your changes, please use this before saving! &#91;alt-p]
+</td><td>
+{{int:d87a6e27}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:44edd577&action=edit fa1fc302]<br>
+[[MediaWiki_talk:44edd577|Talk]]
+</td><td>
+Protect this page &#91;alt-=]
+</td><td>
+{{int:44edd577}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:aa67a70a&action=edit f2eedbde]<br>
+[[MediaWiki_talk:aa67a70a|Talk]]
+</td><td>
+Load a random page &#91;alt-x]
+</td><td>
+{{int:aa67a70a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a5de539f&action=edit cafd58f7]<br>
+[[MediaWiki_talk:a5de539f|Talk]]
+</td><td>
+The list of recent changes in the wiki. &#91;alt-r]
+</td><td>
+{{int:a5de539f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:47cd8236&action=edit ccab4d0f]<br>
+[[MediaWiki_talk:47cd8236|Talk]]
+</td><td>
+Recent changes in pages linking to this page &#91;alt-c]
+</td><td>
+{{int:47cd8236}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:82964371&action=edit 53235bed]<br>
+[[MediaWiki_talk:82964371|Talk]]
+</td><td>
+RSS feed for this page
+</td><td>
+{{int:82964371}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ec76631f&action=edit 8ce4a9b9]<br>
+[[MediaWiki_talk:ec76631f|Talk]]
+</td><td>
+Save your changes &#91;alt-s]
+</td><td>
+{{int:ec76631f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6d206f30&action=edit a6413695]<br>
+[[MediaWiki_talk:6d206f30|Talk]]
+</td><td>
+Search this wiki &#91;alt-f]
+</td><td>
+{{int:6d206f30}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:65256208&action=edit c2dafa2a]<br>
+[[MediaWiki_talk:65256208|Talk]]
+</td><td>
+Support Wiktionary
+</td><td>
+{{int:65256208}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:280cc8fd&action=edit 73f9a677]<br>
+[[MediaWiki_talk:280cc8fd|Talk]]
+</td><td>
+This is a special page, you can&#39;t edit the page itself.
+</td><td>
+{{int:280cc8fd}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7c7223be&action=edit 764993b1]<br>
+[[MediaWiki_talk:7c7223be|Talk]]
+</td><td>
+List of all special pages &#91;alt-q]
+</td><td>
+{{int:7c7223be}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:06fb1d8e&action=edit cb23801a]<br>
+[[MediaWiki_talk:06fb1d8e|Talk]]
+</td><td>
+Discussion about the content page &#91;alt-t]
+</td><td>
+{{int:06fb1d8e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:341a8a32&action=edit df81e982]<br>
+[[MediaWiki_talk:341a8a32|Talk]]
+</td><td>
+Restore the $1 edits done to this page before it was deleted &#91;alt-d]
+</td><td>
+{{int:341a8a32}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:377e895f&action=edit 53f18c52]<br>
+[[MediaWiki_talk:377e895f|Talk]]
+</td><td>
+Remove this page from your watchlist &#91;alt-w]
+</td><td>
+{{int:377e895f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b1d4103e&action=edit 6143ca0f]<br>
+[[MediaWiki_talk:b1d4103e|Talk]]
+</td><td>
+Upload images or media files &#91;alt-u]
+</td><td>
+{{int:b1d4103e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3d8cae2f&action=edit 2b3a6ed0]<br>
+[[MediaWiki_talk:3d8cae2f|Talk]]
+</td><td>
+My user page &#91;alt-.]
+</td><td>
+{{int:3d8cae2f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:141dd88c&action=edit 1ef2ea9d]<br>
+[[MediaWiki_talk:141dd88c|Talk]]
+</td><td>
+This page is protected. You can view its source. &#91;alt-e]
+</td><td>
+{{int:141dd88c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:68a73399&action=edit 07e7b59d]<br>
+[[MediaWiki_talk:68a73399|Talk]]
+</td><td>
+Add this page to your watchlist &#91;alt-w]
+</td><td>
+{{int:68a73399}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:56a787ff&action=edit e24666fc]<br>
+[[MediaWiki_talk:56a787ff|Talk]]
+</td><td>
+The list of pages you&#39;re monitoring for changes. &#91;alt-l]
+</td><td>
+{{int:56a787ff}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3fd46bc1&action=edit fbd416b7]<br>
+[[MediaWiki_talk:3fd46bc1|Talk]]
+</td><td>
+List of all wiki pages that link here &#91;alt-b]
+</td><td>
+{{int:3fd46bc1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:47514ec5&action=edit c48d8241]<br>
+[[MediaWiki_talk:47514ec5|Talk]]
+</td><td>
+View the last $1 changes; view the last $2 days.
+</td><td>
+{{int:47514ec5}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6442d081&action=edit 0d69ac51]<br>
+[[MediaWiki_talk:6442d081|Talk]]
+</td><td>
+Below are this user&#39;s last &lt;b&gt;$1&lt;/b&gt; changes in the last &lt;b&gt;$2&lt;/b&gt; days.
+</td><td>
+{{int:6442d081}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:507d407a&action=edit f9bb6366]<br>
+[[MediaWiki_talk:507d407a|Talk]]
+</td><td>
+ (top)
+</td><td>
+{{int:507d407a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8c29ba33&action=edit 2b4842fd]<br>
+[[MediaWiki_talk:8c29ba33|Talk]]
+</td><td>
+Unblock user
+</td><td>
+{{int:8c29ba33}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3a879bdb&action=edit 98f7f719]<br>
+[[MediaWiki_talk:3a879bdb|Talk]]
+</td><td>
+Use the form below to restore write access
+to a previously blocked IP address or username.
+</td><td>
+{{int:3a879bdb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e954d285&action=edit 24e7c6e7]<br>
+[[MediaWiki_talk:e954d285|Talk]]
+</td><td>
+unblock
+</td><td>
+{{int:e954d285}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5e55820a&action=edit eecac2a2]<br>
+[[MediaWiki_talk:5e55820a|Talk]]
+</td><td>
+unblocked &quot;$1&quot;
+</td><td>
+{{int:5e55820a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9b2a9354&action=edit f690005f]<br>
+[[MediaWiki_talk:9b2a9354|Talk]]
+</td><td>
+Restore deleted page
+</td><td>
+{{int:9b2a9354}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:85cdbe83&action=edit d4adea3f]<br>
+[[MediaWiki_talk:85cdbe83|Talk]]
+</td><td>
+Undelete $1 edits
+</td><td>
+{{int:85cdbe83}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9ba522b6&action=edit c4635e76]<br>
+[[MediaWiki_talk:9ba522b6|Talk]]
+</td><td>
+Restore deleted page
+</td><td>
+{{int:9ba522b6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3346239e&action=edit 5dd4b2af]<br>
+[[MediaWiki_talk:3346239e|Talk]]
+</td><td>
+Restore!
+</td><td>
+{{int:3346239e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c2a7eb23&action=edit cf1590b6]<br>
+[[MediaWiki_talk:c2a7eb23|Talk]]
+</td><td>
+restored &quot;$1&quot;
+</td><td>
+{{int:c2a7eb23}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:40f0db7a&action=edit 5e55bc75]<br>
+[[MediaWiki_talk:40f0db7a|Talk]]
+</td><td>
+&#91;&#91;$1]] has been successfully restored.
+See &#91;&#91;Wiktionary:Deletion_log]] for a record of recent deletions and restorations.
+</td><td>
+{{int:40f0db7a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:feaa86c6&action=edit a69aad99]<br>
+[[MediaWiki_talk:feaa86c6|Talk]]
+</td><td>
+If you restore the page, all revisions will be restored to the history.
+If a new page with the same name has been created since the deletion, the restored
+revisions will appear in the prior history, and the current revision of the live page
+will not be automatically replaced.
+</td><td>
+{{int:feaa86c6}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9fa6521f&action=edit d650f0a7]<br>
+[[MediaWiki_talk:9fa6521f|Talk]]
+</td><td>
+View and restore deleted pages
+</td><td>
+{{int:9fa6521f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2e99f66e&action=edit 19b68d7f]<br>
+[[MediaWiki_talk:2e99f66e|Talk]]
+</td><td>
+The following pages have been deleted but are still in the archive and
+can be restored. The archive may be periodically cleaned out.
+</td><td>
+{{int:2e99f66e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b097a89b&action=edit 2a3672ef]<br>
+[[MediaWiki_talk:b097a89b|Talk]]
+</td><td>
+Deleted revision as of $1
+</td><td>
+{{int:b097a89b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:eb2694a4&action=edit d0cd3f87]<br>
+[[MediaWiki_talk:eb2694a4|Talk]]
+</td><td>
+$1 revisions archived
+</td><td>
+{{int:eb2694a4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3b67a8c9&action=edit fd2a2764]<br>
+[[MediaWiki_talk:3b67a8c9|Talk]]
+</td><td>
+Unexpected value: &quot;$1&quot;=&quot;$2&quot;.
+</td><td>
+{{int:3b67a8c9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:016b68d2&action=edit 74a8f293]<br>
+[[MediaWiki_talk:016b68d2|Talk]]
+</td><td>
+Unlock database
+</td><td>
+{{int:016b68d2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fc3080bf&action=edit ded00b4f]<br>
+[[MediaWiki_talk:fc3080bf|Talk]]
+</td><td>
+Yes, I really want to unlock the database.
+</td><td>
+{{int:fc3080bf}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:4df98d29&action=edit 68a2c7e3]<br>
+[[MediaWiki_talk:4df98d29|Talk]]
+</td><td>
+Unlock database
+</td><td>
+{{int:4df98d29}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:86605aa9&action=edit eb575aa1]<br>
+[[MediaWiki_talk:86605aa9|Talk]]
+</td><td>
+Database lock removed
+</td><td>
+{{int:86605aa9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1896db20&action=edit a7a78572]<br>
+[[MediaWiki_talk:1896db20|Talk]]
+</td><td>
+The database has been unlocked.
+</td><td>
+{{int:1896db20}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bd3decce&action=edit 6b32a82f]<br>
+[[MediaWiki_talk:bd3decce|Talk]]
+</td><td>
+Unlocking the database will restore the ability of all
+users to edit pages, change their preferences, edit their watchlists, and
+other things requiring changes in the database.
+Please confirm that this is what you intend to do.
+</td><td>
+{{int:bd3decce}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d180e0d9&action=edit 116b2a3b]<br>
+[[MediaWiki_talk:d180e0d9|Talk]]
+</td><td>
+Unprotect
+</td><td>
+{{int:d180e0d9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:affff3c2&action=edit a4439c30]<br>
+[[MediaWiki_talk:affff3c2|Talk]]
+</td><td>
+Reason for unprotecting
+</td><td>
+{{int:affff3c2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b8d58125&action=edit 66029ebc]<br>
+[[MediaWiki_talk:b8d58125|Talk]]
+</td><td>
+unprotected &#91;&#91;$1]]
+</td><td>
+{{int:b8d58125}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b15ab8cb&action=edit c77cef4c]<br>
+[[MediaWiki_talk:b15ab8cb|Talk]]
+</td><td>
+(Unprotecting &quot;$1&quot;)
+</td><td>
+{{int:b15ab8cb}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:10782968&action=edit caa31f1e]<br>
+[[MediaWiki_talk:10782968|Talk]]
+</td><td>
+Unprotect this page
+</td><td>
+{{int:10782968}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:5ed67176&action=edit e17373a9]<br>
+[[MediaWiki_talk:5ed67176|Talk]]
+</td><td>
+Unused images
+</td><td>
+{{int:5ed67176}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:373709c4&action=edit 13626cea]<br>
+[[MediaWiki_talk:373709c4|Talk]]
+</td><td>
+&lt;p&gt;Please note that other web sites may link to an image with
+a direct URL, and so may still be listed here despite being
+in active use.
+</td><td>
+{{int:373709c4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:51055a00&action=edit f6f282e9]<br>
+[[MediaWiki_talk:51055a00|Talk]]
+</td><td>
+Unwatch
+</td><td>
+{{int:51055a00}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e21d3614&action=edit c7d1cd1e]<br>
+[[MediaWiki_talk:e21d3614|Talk]]
+</td><td>
+Stop watching
+</td><td>
+{{int:e21d3614}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f2f8570d&action=edit 13a1891a]<br>
+[[MediaWiki_talk:f2f8570d|Talk]]
+</td><td>
+(Updated)
+</td><td>
+{{int:f2f8570d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8bdf057f&action=edit bb73aaaf]<br>
+[[MediaWiki_talk:8bdf057f|Talk]]
+</td><td>
+Upload file
+</td><td>
+{{int:8bdf057f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0238951b&action=edit 6be1c689]<br>
+[[MediaWiki_talk:0238951b|Talk]]
+</td><td>
+Upload file
+</td><td>
+{{int:0238951b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:88f59c5a&action=edit 693f4b51]<br>
+[[MediaWiki_talk:88f59c5a|Talk]]
+</td><td>
+Sorry, uploading is disabled.
+</td><td>
+{{int:88f59c5a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7b8969f2&action=edit 7d4f03ff]<br>
+[[MediaWiki_talk:7b8969f2|Talk]]
+</td><td>
+Uploaded files
+</td><td>
+{{int:7b8969f2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:954c2a11&action=edit e57056a0]<br>
+[[MediaWiki_talk:954c2a11|Talk]]
+</td><td>
+uploaded &quot;$1&quot;
+</td><td>
+{{int:954c2a11}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:304f9593&action=edit 8f1603bd]<br>
+[[MediaWiki_talk:304f9593|Talk]]
+</td><td>
+Upload error
+</td><td>
+{{int:304f9593}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9e71a62c&action=edit 40d977b5]<br>
+[[MediaWiki_talk:9e71a62c|Talk]]
+</td><td>
+Upload images, sounds, documents etc.
+</td><td>
+{{int:9e71a62c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:955e39f9&action=edit 0bf93eec]<br>
+[[MediaWiki_talk:955e39f9|Talk]]
+</td><td>
+Upload images
+</td><td>
+{{int:955e39f9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0d39c428&action=edit 0d49abe6]<br>
+[[MediaWiki_talk:0d39c428|Talk]]
+</td><td>
+upload log
+</td><td>
+{{int:0d39c428}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1f68a0e7&action=edit 87611d30]<br>
+[[MediaWiki_talk:1f68a0e7|Talk]]
+</td><td>
+Upload_log
+</td><td>
+{{int:1f68a0e7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2a430331&action=edit 8aa7bf47]<br>
+[[MediaWiki_talk:2a430331|Talk]]
+</td><td>
+Below is a list of the most recent file uploads.
+All times shown are server time (UTC).
+&lt;ul&gt;
+&lt;/ul&gt;
+
+</td><td>
+{{int:2a430331}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:92dd3bc9&action=edit d2d8bd08]<br>
+[[MediaWiki_talk:92dd3bc9|Talk]]
+</td><td>
+Not logged in
+</td><td>
+{{int:92dd3bc9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fecdb77e&action=edit 09b01f51]<br>
+[[MediaWiki_talk:fecdb77e|Talk]]
+</td><td>
+You must be &lt;a href=&quot;/wiki/Special:Userlogin&quot;&gt;logged in&lt;/a&gt;
+to upload files.
+</td><td>
+{{int:fecdb77e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7804cb84&action=edit 85bb3caa]<br>
+[[MediaWiki_talk:7804cb84|Talk]]
+</td><td>
+&lt;strong&gt;STOP!&lt;/strong&gt; Before you upload here,
+make sure to read and follow the &lt;a href=&quot;/wiki/Special:Image_use_policy&quot;&gt;image use policy&lt;/a&gt;.
+&lt;p&gt;If a file with the name you are specifying already
+exists on the wiki, it&#39;ll be replaced without warning.
+So unless you mean to update a file, it&#39;s a good idea
+to first check if such a file exists.
+&lt;p&gt;To view or search previously uploaded images,
+go to the &lt;a href=&quot;/wiki/Special:Imagelist&quot;&gt;list of uploaded images&lt;/a&gt;.
+Uploads and deletions are logged on the &lt;a href=&quot;/wiki/Wiktionary:Upload_log&quot;&gt;upload log&lt;/a&gt;.
+&lt;/p&gt;&lt;p&gt;Use the form below to upload new image files for use in
+illustrating your pages.
+On most browsers, you will see a &quot;Browse...&quot; button, which will
+bring up your operating system&#39;s standard file open dialog.
+Choosing a file will fill the name of that file into the text
+field next to the button.
+You must also check the box affirming that you are not
+violating any copyrights by uploading the file.
+Press the &quot;Upload&quot; button to finish the upload.
+This may take some time if you have a slow internet connection.
+&lt;p&gt;The preferred formats are JPEG for photographic images, PNG
+for drawings and other iconic images, and OGG for sounds.
+Please name your files descriptively to avoid confusion.
+To include the image in a page, use a link in the form
+&lt;b&gt;&#91;&#91;Image:file.jpg]]&lt;/b&gt; or &lt;b&gt;&#91;&#91;Image:file.png&#124;alt text]]&lt;/b&gt;
+or &lt;b&gt;&#91;&#91;Media:file.ogg]]&lt;/b&gt; for sounds.
+&lt;p&gt;Please note that as with wiki pages, others may edit or
+delete your uploads if they think it serves the project, and
+you may be blocked from uploading if you abuse the system.
+</td><td>
+{{int:7804cb84}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fb3ef2ae&action=edit 8aae8210]<br>
+[[MediaWiki_talk:fb3ef2ae|Talk]]
+</td><td>
+Upload warning
+</td><td>
+{{int:fb3ef2ae}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a5430c9b&action=edit 6a080eb0]<br>
+[[MediaWiki_talk:a5430c9b|Talk]]
+</td><td>
+&lt;b&gt;User rights for &quot;$1&quot; updated&lt;/b&gt;
+</td><td>
+{{int:a5430c9b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:89b73748&action=edit 8b23a826]<br>
+[[MediaWiki_talk:89b73748|Talk]]
+</td><td>
+&#39;&#39;&#39;Note:&#39;&#39;&#39; After saving, you have to tell your bowser to get the new version: &#39;&#39;&#39;Mozilla:&#39;&#39;&#39; click &#39;&#39;reload&#39;&#39;(or &#39;&#39;ctrl-r&#39;&#39;), &#39;&#39;&#39;IE / Opera:&#39;&#39;&#39; &#39;&#39;ctrl-f5&#39;&#39;, &#39;&#39;&#39;Safari:&#39;&#39;&#39; &#39;&#39;cmd-r&#39;&#39;, &#39;&#39;&#39;Konqueror&#39;&#39;&#39; &#39;&#39;ctrl-r&#39;&#39;.
+</td><td>
+{{int:89b73748}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1656c92b&action=edit 97bd6e75]<br>
+[[MediaWiki_talk:1656c92b|Talk]]
+</td><td>
+&lt;strong&gt;Tip:&lt;/strong&gt; Use the &#39;Show preview&#39; button to test your new css/js before saving.
+</td><td>
+{{int:1656c92b}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:9f62117d&action=edit b51c3667]<br>
+[[MediaWiki_talk:9f62117d|Talk]]
+</td><td>
+&#39;&#39;&#39;Remember that you are only previewing your user css, it has not yet been saved!&#39;&#39;&#39;
+</td><td>
+{{int:9f62117d}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:77541367&action=edit a49220af]<br>
+[[MediaWiki_talk:77541367|Talk]]
+</td><td>
+The user name you entered is already in use. Please choose a different name.
+</td><td>
+{{int:77541367}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:eca4b211&action=edit 2e8efec0]<br>
+[[MediaWiki_talk:eca4b211|Talk]]
+</td><td>
+&#39;&#39;&#39;Remember that you are only testing/previewing your user javascript, it has not yet been saved!&#39;&#39;&#39;
+</td><td>
+{{int:eca4b211}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:49c670f4&action=edit eb0f23d8]<br>
+[[MediaWiki_talk:49c670f4|Talk]]
+</td><td>
+Log in
+</td><td>
+{{int:49c670f4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:fb3467d9&action=edit 271a962f]<br>
+[[MediaWiki_talk:fb3467d9|Talk]]
+</td><td>
+Log out
+</td><td>
+{{int:fb3467d9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e1881ca2&action=edit 0e3f35e1]<br>
+[[MediaWiki_talk:e1881ca2|Talk]]
+</td><td>
+Mail object returned error:
+</td><td>
+{{int:e1881ca2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:823fdaf7&action=edit ea81d010]<br>
+[[MediaWiki_talk:823fdaf7|Talk]]
+</td><td>
+View user page
+</td><td>
+{{int:823fdaf7}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f25ef873&action=edit 2ab9a2af]<br>
+[[MediaWiki_talk:f25ef873|Talk]]
+</td><td>
+User statistics
+</td><td>
+{{int:f25ef873}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b704c939&action=edit 903f135d]<br>
+[[MediaWiki_talk:b704c939|Talk]]
+</td><td>
+There are &#39;&#39;&#39;$1&#39;&#39;&#39; registered users.
+&#39;&#39;&#39;$2&#39;&#39;&#39; of these are administrators (see $3).
+</td><td>
+{{int:b704c939}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2da600bf&action=edit c692273d]<br>
+[[MediaWiki_talk:2da600bf|Talk]]
+</td><td>
+Version
+</td><td>
+{{int:2da600bf}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cd20ed80&action=edit 9204f6f2]<br>
+[[MediaWiki_talk:cd20ed80|Talk]]
+</td><td>
+This page has been accessed $1 times.
+</td><td>
+{{int:cd20ed80}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:b3a212e8&action=edit 023f0549]<br>
+[[MediaWiki_talk:b3a212e8|Talk]]
+</td><td>
+View ($1) ($2) ($3).
+</td><td>
+{{int:b3a212e8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:1affdb1e&action=edit db9e2eba]<br>
+[[MediaWiki_talk:1affdb1e|Talk]]
+</td><td>
+View source
+</td><td>
+{{int:1affdb1e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f6336004&action=edit 2e250bd9]<br>
+[[MediaWiki_talk:f6336004|Talk]]
+</td><td>
+View discussion
+</td><td>
+{{int:f6336004}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7da05431&action=edit 4d2466a3]<br>
+[[MediaWiki_talk:7da05431|Talk]]
+</td><td>
+Wanted pages
+</td><td>
+{{int:7da05431}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d91ebf58&action=edit 292b0901]<br>
+[[MediaWiki_talk:d91ebf58|Talk]]
+</td><td>
+Watch
+</td><td>
+{{int:d91ebf58}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d815e414&action=edit ddfeb02c]<br>
+[[MediaWiki_talk:d815e414|Talk]]
+</td><td>
+($1 pages watched not counting talk pages;
+$2 total pages edited since cutoff;
+$3...
+&lt;a href=&#39;$4&#39;&gt;show and edit complete list&lt;/a&gt;.)
+</td><td>
+{{int:d815e414}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:780e4559&action=edit d7d3bb79]<br>
+[[MediaWiki_talk:780e4559|Talk]]
+</td><td>
+Here&#39;s an alphabetical list of your
+watched pages. Check the boxes of pages you want to remove
+from your watchlist and click the &#39;remove checked&#39; button
+at the bottom of the screen.
+</td><td>
+{{int:780e4559}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:48a616d9&action=edit db14f0be]<br>
+[[MediaWiki_talk:48a616d9|Talk]]
+</td><td>
+My watchlist
+</td><td>
+{{int:48a616d9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:690e08f8&action=edit 37074879]<br>
+[[MediaWiki_talk:690e08f8|Talk]]
+</td><td>
+Your watchlist contains $1 pages.
+</td><td>
+{{int:690e08f8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:913a8eb4&action=edit 1d490f51]<br>
+[[MediaWiki_talk:913a8eb4|Talk]]
+</td><td>
+(for user &quot;$1&quot;)
+</td><td>
+{{int:913a8eb4}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:983ce9b1&action=edit b5396cea]<br>
+[[MediaWiki_talk:983ce9b1|Talk]]
+</td><td>
+checking watched pages for recent edits
+</td><td>
+{{int:983ce9b1}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2e5e56e2&action=edit c69b87d2]<br>
+[[MediaWiki_talk:2e5e56e2|Talk]]
+</td><td>
+checking recent edits for watched pages
+</td><td>
+{{int:2e5e56e2}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:cdd40087&action=edit 24353550]<br>
+[[MediaWiki_talk:cdd40087|Talk]]
+</td><td>
+None of your watched items were edited in the time period displayed.
+</td><td>
+{{int:cdd40087}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:ec51da09&action=edit c873a8c3]<br>
+[[MediaWiki_talk:ec51da09|Talk]]
+</td><td>
+Not logged in
+</td><td>
+{{int:ec51da09}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7870af11&action=edit 080c4da9]<br>
+[[MediaWiki_talk:7870af11|Talk]]
+</td><td>
+You must be &lt;a href=&quot;/wiki/Special:Userlogin&quot;&gt;logged in&lt;/a&gt;
+to modify your watchlist.
+</td><td>
+{{int:7870af11}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2fa65d73&action=edit 89758668]<br>
+[[MediaWiki_talk:2fa65d73|Talk]]
+</td><td>
+Watch this page
+</td><td>
+{{int:2fa65d73}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:79260dc8&action=edit d94c2857]<br>
+[[MediaWiki_talk:79260dc8|Talk]]
+</td><td>
+Watch this page
+</td><td>
+{{int:79260dc8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:622a355f&action=edit 5b3750aa]<br>
+[[MediaWiki_talk:622a355f|Talk]]
+</td><td>
+&lt;h2&gt;Welcome, $1!&lt;/h2&gt;&lt;p&gt;Your account has been created.
+Don&#39;t forget to change your Wiktionary preferences.
+</td><td>
+{{int:622a355f}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:bde1fbba&action=edit 28d0e2a8]<br>
+[[MediaWiki_talk:bde1fbba|Talk]]
+</td><td>
+What links here
+</td><td>
+{{int:bde1fbba}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:53f0999a&action=edit b3775e04]<br>
+[[MediaWiki_talk:53f0999a|Talk]]
+</td><td>
+To be allowed to create accounts in this Wiki you have to &#91;&#91;Special:Userlogin&#124;log]] in and have the appropriate permissions.
+</td><td>
+{{int:53f0999a}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:901574b0&action=edit 13f7d937]<br>
+[[MediaWiki_talk:901574b0|Talk]]
+</td><td>
+You are not allowed to create an account
+</td><td>
+{{int:901574b0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:28589651&action=edit 8d93b543]<br>
+[[MediaWiki_talk:28589651|Talk]]
+</td><td>
+You have to &#91;&#91;Special:Userlogin&#124;login]] to edit pages.
+</td><td>
+{{int:28589651}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d4e0db33&action=edit 76713eb6]<br>
+[[MediaWiki_talk:d4e0db33|Talk]]
+</td><td>
+Login required to edit
+</td><td>
+{{int:d4e0db33}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:7046be67&action=edit 3c46b4af]<br>
+[[MediaWiki_talk:7046be67|Talk]]
+</td><td>
+You have to &#91;&#91;Special:Userlogin&#124;login]] to read pages.
+</td><td>
+{{int:7046be67}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:56f7b6c8&action=edit 27809c2a]<br>
+[[MediaWiki_talk:56f7b6c8|Talk]]
+</td><td>
+Login required to read
+</td><td>
+{{int:56f7b6c8}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:8815fbb3&action=edit 80a9ff8d]<br>
+[[MediaWiki_talk:8815fbb3|Talk]]
+</td><td>
+View project page
+</td><td>
+{{int:8815fbb3}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:d00a5142&action=edit 937cdab5]<br>
+[[MediaWiki_talk:d00a5142|Talk]]
+</td><td>
+Wiktionary
+</td><td>
+{{int:d00a5142}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a7ee1c5c&action=edit b6ea8219]<br>
+[[MediaWiki_talk:a7ee1c5c|Talk]]
+</td><td>
+Below are the last $1 changes in the last &lt;b&gt;$2&lt;/b&gt; hours.
+</td><td>
+{{int:a7ee1c5c}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:deb21c59&action=edit 843af08d]<br>
+[[MediaWiki_talk:deb21c59|Talk]]
+</td><td>
+This is a saved version of your watchlist.
+</td><td>
+{{int:deb21c59}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:e77517fa&action=edit 89821357]<br>
+[[MediaWiki_talk:e77517fa|Talk]]
+</td><td>
+Show last $1 hours $2 days $3
+</td><td>
+{{int:e77517fa}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:a9b62164&action=edit d526a8a6]<br>
+[[MediaWiki_talk:a9b62164|Talk]]
+</td><td>
+Incorrect parameters to wfQuery()&lt;br /&gt;
+Function: $1&lt;br /&gt;
+Query: $2
+
+</td><td>
+{{int:a9b62164}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:3314dacf&action=edit d8ecf7db]<br>
+[[MediaWiki_talk:3314dacf|Talk]]
+</td><td>
+The password you entered is incorrect. Please try again.
+</td><td>
+{{int:3314dacf}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:2e0414f0&action=edit 4fe151ac]<br>
+[[MediaWiki_talk:2e0414f0|Talk]]
+</td><td>
+Differences
+</td><td>
+{{int:2e0414f0}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:0f5ab9c9&action=edit 0a98c2ad]<br>
+[[MediaWiki_talk:0f5ab9c9|Talk]]
+</td><td>
+Your email*
+</td><td>
+{{int:0f5ab9c9}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f495043e&action=edit 32d0e33a]<br>
+[[MediaWiki_talk:f495043e|Talk]]
+</td><td>
+Your user name
+</td><td>
+{{int:f495043e}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:6aa78968&action=edit f8b28bd9]<br>
+[[MediaWiki_talk:6aa78968|Talk]]
+</td><td>
+Your nickname (for signatures)
+</td><td>
+{{int:6aa78968}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:13a44203&action=edit b48cf014]<br>
+[[MediaWiki_talk:13a44203|Talk]]
+</td><td>
+Your password
+</td><td>
+{{int:13a44203}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:c0ad7f05&action=edit e14a732b]<br>
+[[MediaWiki_talk:c0ad7f05|Talk]]
+</td><td>
+Retype password
+</td><td>
+{{int:c0ad7f05}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:519f30b5&action=edit 7fc3e5b1]<br>
+[[MediaWiki_talk:519f30b5|Talk]]
+</td><td>
+Your real name*
+</td><td>
+{{int:519f30b5}}
+</td></tr><tr><td>
+[http://tl.wiktionary.org/w/wiki.phtml?title=MediaWiki:f98650e0&action=edit 5b5af4ea]<br>
+[[MediaWiki_talk:f98650e0|Talk]]
+</td><td>
+Your text
+</td><td>
+{{int:f98650e0}}
+</td></tr></table>
+
diff --git a/www/wiki/tests/parser/preprocess/Factorial.expected b/www/wiki/tests/parser/preprocess/Factorial.expected
new file mode 100644
index 00000000..a10fd6ca
--- /dev/null
+++ b/www/wiki/tests/parser/preprocess/Factorial.expected
@@ -0,0 +1,17 @@
+<root><template><title>#expr:<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=00</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>01<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=01</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*01<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=02</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*02<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=03</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*03<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=04</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*04<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=05</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*05<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=06</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*06<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=07</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*07<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=08</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*08<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=09</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*09<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=10</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*10<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=11</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*11<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=12</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*12<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=13</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*13<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=14</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*14<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=15</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*15<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=16</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*16<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=17</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*17<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=18</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*18<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=19</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*19<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=20</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*20<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=21</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*21<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=22</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*22<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=23</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*23<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=24</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*24<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=25</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*25<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=26</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*26<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=27</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*27<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=28</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*28<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=29</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*29<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=30</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*30<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=31</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*31<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=32</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*32<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=33</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*33<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=34</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*34<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=35</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*35<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=36</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*36<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=37</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*37<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=38</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*38<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=39</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*39<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=40</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*40<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=41</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*41<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=42</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*42<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=43</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*43<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=44</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*44<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=45</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*45<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=46</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*46<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=47</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*47<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=48</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*48<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=49</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*49<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=50</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*50<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=51</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*51<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=52</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*52<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=53</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*53<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=54</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*54<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=55</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*55<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=56</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*56<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=57</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*57<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=58</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*58<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=59</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*59<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=60</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*60<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=61</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*61<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=62</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*62<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=63</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*63<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=64</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*64<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=65</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*65<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=66</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*66<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=67</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*67<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=68</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*68<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=69</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*69<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=70</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*70<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=71</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*71<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=72</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*72<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=73</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*73<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=74</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*74<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=75</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*75<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=76</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*76<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=77</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*77<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=78</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*78<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=79</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*79<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=80</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*80<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=81</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*81<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=82</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*82<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=83</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*83<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=84</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*84<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=85</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*85<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=86</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*86<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=87</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*87<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=88</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*88<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=89</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*89<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=90</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*90<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=91</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*91<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=92</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*92<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=93</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*93<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=94</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*94<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=95</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*95<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=96</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*96<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=97</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*97<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=98</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*98<template><title>#ifeq:<template><title>#expr:<tplarg><title>1</title></tplarg>&gt;=99</title></template></title><part><name index="1" /><value>1</value></part><part><name index="2" /><value>*99</value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></value></part></template></title></template><ignore>&lt;noinclude&gt;</ignore>
+<template lineStart="1"><title>Template documentation</title></template>
+This template finds the [[factorial]] of a number. To use it, enter:&lt;br /&gt;
+&lt;code&gt;&lt;nowiki&gt;<template><title>factorial</title><part><name index="1" /><value>input</value></part></template>&lt;/nowiki&gt;&lt;/code&gt;&lt;br /&gt;
+The input must be a positive interger smaller than 100 (better than most calculators, which go up to only 69). This template works by repeating conditional multiplications. Examples:&lt;br /&gt;
+*&lt;nowiki&gt;<template><title>factorial</title><part><name index="1" /><value>2</value></part></template>&lt;/nowiki&gt; gives <template><title>factorial</title><part><name index="1" /><value>2</value></part></template>
+*&lt;nowiki&gt;<template><title>factorial</title><part><name index="1" /><value>3</value></part></template>&lt;/nowiki&gt; gives <template><title>factorial</title><part><name index="1" /><value>3</value></part></template>
+*&lt;nowiki&gt;<template><title>factorial</title><part><name index="1" /><value>5</value></part></template>&lt;/nowiki&gt; gives <template><title>factorial</title><part><name index="1" /><value>5</value></part></template>
+*&lt;nowiki&gt;<template><title>factorial</title><part><name index="1" /><value>10</value></part></template>&lt;/nowiki&gt; gives <template><title>factorial</title><part><name index="1" /><value>10</value></part></template>
+*&lt;nowiki&gt;<template><title>factorial</title><part><name index="1" /><value>80</value></part></template>&lt;/nowiki&gt; gives <template><title>factorial</title><part><name index="1" /><value>80</value></part></template>
+*&lt;nowiki&gt;<template><title>factorial</title><part><name index="1" /><value>0.5</value></part></template>&lt;/nowiki&gt; gives <template><title>factorial</title><part><name index="1" /><value>0.5</value></part></template> (invalid input)
+*&lt;nowiki&gt;<template><title>factorial</title><part><name index="1" /><value>-1</value></part></template>&lt;/nowiki&gt; gives <template><title>factorial</title><part><name index="1" /><value>-1</value></part></template> (invalid input)
+<template lineStart="1"><title>esoteric</title></template>
+[[Category:Mathematical templates|<template><title>PAGENAME</title></template>]]
+<ignore>&lt;/noinclude&gt;</ignore>
+
+</root> \ No newline at end of file
diff --git a/www/wiki/tests/parser/preprocess/Factorial.txt b/www/wiki/tests/parser/preprocess/Factorial.txt
new file mode 100644
index 00000000..316f0792
--- /dev/null
+++ b/www/wiki/tests/parser/preprocess/Factorial.txt
@@ -0,0 +1,16 @@
+{{#expr:{{#ifeq:{{#expr:{{{1}}}>=00}}|1|01{{#ifeq:{{#expr:{{{1}}}>=01}}|1|*01{{#ifeq:{{#expr:{{{1}}}>=02}}|1|*02{{#ifeq:{{#expr:{{{1}}}>=03}}|1|*03{{#ifeq:{{#expr:{{{1}}}>=04}}|1|*04{{#ifeq:{{#expr:{{{1}}}>=05}}|1|*05{{#ifeq:{{#expr:{{{1}}}>=06}}|1|*06{{#ifeq:{{#expr:{{{1}}}>=07}}|1|*07{{#ifeq:{{#expr:{{{1}}}>=08}}|1|*08{{#ifeq:{{#expr:{{{1}}}>=09}}|1|*09{{#ifeq:{{#expr:{{{1}}}>=10}}|1|*10{{#ifeq:{{#expr:{{{1}}}>=11}}|1|*11{{#ifeq:{{#expr:{{{1}}}>=12}}|1|*12{{#ifeq:{{#expr:{{{1}}}>=13}}|1|*13{{#ifeq:{{#expr:{{{1}}}>=14}}|1|*14{{#ifeq:{{#expr:{{{1}}}>=15}}|1|*15{{#ifeq:{{#expr:{{{1}}}>=16}}|1|*16{{#ifeq:{{#expr:{{{1}}}>=17}}|1|*17{{#ifeq:{{#expr:{{{1}}}>=18}}|1|*18{{#ifeq:{{#expr:{{{1}}}>=19}}|1|*19{{#ifeq:{{#expr:{{{1}}}>=20}}|1|*20{{#ifeq:{{#expr:{{{1}}}>=21}}|1|*21{{#ifeq:{{#expr:{{{1}}}>=22}}|1|*22{{#ifeq:{{#expr:{{{1}}}>=23}}|1|*23{{#ifeq:{{#expr:{{{1}}}>=24}}|1|*24{{#ifeq:{{#expr:{{{1}}}>=25}}|1|*25{{#ifeq:{{#expr:{{{1}}}>=26}}|1|*26{{#ifeq:{{#expr:{{{1}}}>=27}}|1|*27{{#ifeq:{{#expr:{{{1}}}>=28}}|1|*28{{#ifeq:{{#expr:{{{1}}}>=29}}|1|*29{{#ifeq:{{#expr:{{{1}}}>=30}}|1|*30{{#ifeq:{{#expr:{{{1}}}>=31}}|1|*31{{#ifeq:{{#expr:{{{1}}}>=32}}|1|*32{{#ifeq:{{#expr:{{{1}}}>=33}}|1|*33{{#ifeq:{{#expr:{{{1}}}>=34}}|1|*34{{#ifeq:{{#expr:{{{1}}}>=35}}|1|*35{{#ifeq:{{#expr:{{{1}}}>=36}}|1|*36{{#ifeq:{{#expr:{{{1}}}>=37}}|1|*37{{#ifeq:{{#expr:{{{1}}}>=38}}|1|*38{{#ifeq:{{#expr:{{{1}}}>=39}}|1|*39{{#ifeq:{{#expr:{{{1}}}>=40}}|1|*40{{#ifeq:{{#expr:{{{1}}}>=41}}|1|*41{{#ifeq:{{#expr:{{{1}}}>=42}}|1|*42{{#ifeq:{{#expr:{{{1}}}>=43}}|1|*43{{#ifeq:{{#expr:{{{1}}}>=44}}|1|*44{{#ifeq:{{#expr:{{{1}}}>=45}}|1|*45{{#ifeq:{{#expr:{{{1}}}>=46}}|1|*46{{#ifeq:{{#expr:{{{1}}}>=47}}|1|*47{{#ifeq:{{#expr:{{{1}}}>=48}}|1|*48{{#ifeq:{{#expr:{{{1}}}>=49}}|1|*49{{#ifeq:{{#expr:{{{1}}}>=50}}|1|*50{{#ifeq:{{#expr:{{{1}}}>=51}}|1|*51{{#ifeq:{{#expr:{{{1}}}>=52}}|1|*52{{#ifeq:{{#expr:{{{1}}}>=53}}|1|*53{{#ifeq:{{#expr:{{{1}}}>=54}}|1|*54{{#ifeq:{{#expr:{{{1}}}>=55}}|1|*55{{#ifeq:{{#expr:{{{1}}}>=56}}|1|*56{{#ifeq:{{#expr:{{{1}}}>=57}}|1|*57{{#ifeq:{{#expr:{{{1}}}>=58}}|1|*58{{#ifeq:{{#expr:{{{1}}}>=59}}|1|*59{{#ifeq:{{#expr:{{{1}}}>=60}}|1|*60{{#ifeq:{{#expr:{{{1}}}>=61}}|1|*61{{#ifeq:{{#expr:{{{1}}}>=62}}|1|*62{{#ifeq:{{#expr:{{{1}}}>=63}}|1|*63{{#ifeq:{{#expr:{{{1}}}>=64}}|1|*64{{#ifeq:{{#expr:{{{1}}}>=65}}|1|*65{{#ifeq:{{#expr:{{{1}}}>=66}}|1|*66{{#ifeq:{{#expr:{{{1}}}>=67}}|1|*67{{#ifeq:{{#expr:{{{1}}}>=68}}|1|*68{{#ifeq:{{#expr:{{{1}}}>=69}}|1|*69{{#ifeq:{{#expr:{{{1}}}>=70}}|1|*70{{#ifeq:{{#expr:{{{1}}}>=71}}|1|*71{{#ifeq:{{#expr:{{{1}}}>=72}}|1|*72{{#ifeq:{{#expr:{{{1}}}>=73}}|1|*73{{#ifeq:{{#expr:{{{1}}}>=74}}|1|*74{{#ifeq:{{#expr:{{{1}}}>=75}}|1|*75{{#ifeq:{{#expr:{{{1}}}>=76}}|1|*76{{#ifeq:{{#expr:{{{1}}}>=77}}|1|*77{{#ifeq:{{#expr:{{{1}}}>=78}}|1|*78{{#ifeq:{{#expr:{{{1}}}>=79}}|1|*79{{#ifeq:{{#expr:{{{1}}}>=80}}|1|*80{{#ifeq:{{#expr:{{{1}}}>=81}}|1|*81{{#ifeq:{{#expr:{{{1}}}>=82}}|1|*82{{#ifeq:{{#expr:{{{1}}}>=83}}|1|*83{{#ifeq:{{#expr:{{{1}}}>=84}}|1|*84{{#ifeq:{{#expr:{{{1}}}>=85}}|1|*85{{#ifeq:{{#expr:{{{1}}}>=86}}|1|*86{{#ifeq:{{#expr:{{{1}}}>=87}}|1|*87{{#ifeq:{{#expr:{{{1}}}>=88}}|1|*88{{#ifeq:{{#expr:{{{1}}}>=89}}|1|*89{{#ifeq:{{#expr:{{{1}}}>=90}}|1|*90{{#ifeq:{{#expr:{{{1}}}>=91}}|1|*91{{#ifeq:{{#expr:{{{1}}}>=92}}|1|*92{{#ifeq:{{#expr:{{{1}}}>=93}}|1|*93{{#ifeq:{{#expr:{{{1}}}>=94}}|1|*94{{#ifeq:{{#expr:{{{1}}}>=95}}|1|*95{{#ifeq:{{#expr:{{{1}}}>=96}}|1|*96{{#ifeq:{{#expr:{{{1}}}>=97}}|1|*97{{#ifeq:{{#expr:{{{1}}}>=98}}|1|*98{{#ifeq:{{#expr:{{{1}}}>=99}}|1|*99}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}<noinclude>
+{{Template documentation}}
+This template finds the [[factorial]] of a number. To use it, enter:<br />
+<code><nowiki>{{factorial|input}}</nowiki></code><br />
+The input must be a positive interger smaller than 100 (better than most calculators, which go up to only 69). This template works by repeating conditional multiplications. Examples:<br />
+*<nowiki>{{factorial|2}}</nowiki> gives {{factorial|2}}
+*<nowiki>{{factorial|3}}</nowiki> gives {{factorial|3}}
+*<nowiki>{{factorial|5}}</nowiki> gives {{factorial|5}}
+*<nowiki>{{factorial|10}}</nowiki> gives {{factorial|10}}
+*<nowiki>{{factorial|80}}</nowiki> gives {{factorial|80}}
+*<nowiki>{{factorial|0.5}}</nowiki> gives {{factorial|0.5}} (invalid input)
+*<nowiki>{{factorial|-1}}</nowiki> gives {{factorial|-1}} (invalid input)
+{{esoteric}}
+[[Category:Mathematical templates|{{PAGENAME}}]]
+</noinclude>
+
diff --git a/www/wiki/tests/parser/preprocess/Fundraising.expected b/www/wiki/tests/parser/preprocess/Fundraising.expected
new file mode 100644
index 00000000..f5b32cc5
--- /dev/null
+++ b/www/wiki/tests/parser/preprocess/Fundraising.expected
@@ -0,0 +1,18 @@
+<root>&lt;div name=&quot;fundraising&quot; id=&quot;fundraising&quot; class=&quot;plainlinks&quot; style=&quot;margin-top:5px; text-align: center; background-color: #ffffe0; border: solid 1px #e0e0c0&quot;&gt;
+'''Pwede kang [[Wikimedia:give the gift of knowledge|maghandog ng kaalaman]] sa paraan ng [[Wikimedia:Fundraising#Donation_methods|pagbibigay ng donasyon sa Pundasyong Wikimedia!]]'''
+&lt;br /&gt;
+&lt;fundraising/&gt;
+&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
+&lt;fundraisinglogo/&gt;
+&lt;br /&gt;
+&lt;b&gt;Ngayon, ang iyong [[Wikimedia:Fundraising|kontribusyon]] ay [[Wikimedia:Fundraising FAQ|itatambal]] ng isang anonimong kaibigan.&lt;/b&gt;
+&lt;br /&gt;
+&lt;small&gt;
+[[Wikimedia:Deductibility of donations|Pagbabawas sa mga buwis ng donasyon]]
+|
+[[Wikimedia:Fundraising FAQ|FAQ]]
+|
+[http://upload.wikimedia.org/wikipedia/foundation/2/28/Wikimedia_2006_fs.pdf Mga pampananalaping pahayag]
+&lt;/small&gt;
+&lt;/div&gt;
+</root> \ No newline at end of file
diff --git a/www/wiki/tests/parser/preprocess/Fundraising.txt b/www/wiki/tests/parser/preprocess/Fundraising.txt
new file mode 100644
index 00000000..b868b4d8
--- /dev/null
+++ b/www/wiki/tests/parser/preprocess/Fundraising.txt
@@ -0,0 +1,17 @@
+<div name="fundraising" id="fundraising" class="plainlinks" style="margin-top:5px; text-align: center; background-color: #ffffe0; border: solid 1px #e0e0c0">
+'''Pwede kang [[Wikimedia:give the gift of knowledge|maghandog ng kaalaman]] sa paraan ng [[Wikimedia:Fundraising#Donation_methods|pagbibigay ng donasyon sa Pundasyong Wikimedia!]]'''
+<br />
+<fundraising/>
+&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+<fundraisinglogo/>
+<br />
+<b>Ngayon, ang iyong [[Wikimedia:Fundraising|kontribusyon]] ay [[Wikimedia:Fundraising FAQ|itatambal]] ng isang anonimong kaibigan.</b>
+<br />
+<small>
+[[Wikimedia:Deductibility of donations|Pagbabawas sa mga buwis ng donasyon]]
+|
+[[Wikimedia:Fundraising FAQ|FAQ]]
+|
+[http://upload.wikimedia.org/wikipedia/foundation/2/28/Wikimedia_2006_fs.pdf Mga pampananalaping pahayag]
+</small>
+</div>
diff --git a/www/wiki/tests/parser/preprocess/NestedTemplates.expected b/www/wiki/tests/parser/preprocess/NestedTemplates.expected
new file mode 100644
index 00000000..645626df
--- /dev/null
+++ b/www/wiki/tests/parser/preprocess/NestedTemplates.expected
@@ -0,0 +1,90 @@
+<root><template><title>vorlage</title></template>
+
+<tplarg lineStart="1"><title>argument</title></tplarg>
+
+Nach [[:meta:Help:Expansion#XML parse tree]]
+{<tplarg><title>vorlagenname</title></tplarg>}
+<template lineStart="1"><title> <template><title>vorlagenname</title></template></title></template>
+<template lineStart="1"><title><template><title>vorlagenname</title></template> </title></template>
+<template lineStart="1"><title><template><title>vorlagenname</title></template>erweiterung</title></template>
+
+<template lineStart="1"><title><tplarg><title>vorlagenname</title></tplarg></title></template>
+<tplarg lineStart="1"><title> <template><title>vorlagenname</title></template></title></tplarg>
+<template lineStart="1"><title> <tplarg><title>vorlagenname</title></tplarg></title></template>
+<tplarg lineStart="1"><title><template><title>vorlagenname</title></template> </title></tplarg>
+<template lineStart="1"><title><tplarg><title>vorlagenname</title></tplarg> </title></template>
+
+nur etwas erweitert
+<tplarg lineStart="1"><title><tplarg><title>vorlagenname</title></tplarg></title></tplarg>
+<tplarg lineStart="1"><title> <tplarg><title>vorlagenname</title></tplarg></title></tplarg>
+<tplarg lineStart="1"><title><tplarg><title>vorlagenname</title></tplarg> </title></tplarg>
+<template lineStart="1"><title> {<tplarg><title>vorlagenname</title></tplarg></title></template>}
+{<tplarg><title> <template><title>vorlagenname</title></template></title></tplarg>}
+<template lineStart="1"><title> <template><title> <template><title>vorlagenname</title></template></title></template></title></template>
+{<tplarg><title> <template><title>vorlagenname</title></template>} </title></tplarg>
+{<template><title><tplarg><title>vorlagenname</title></tplarg>} </title></template>
+{<tplarg><title><template><title>vorlagenname</title></template> </title></tplarg>}
+<template lineStart="1"><title> <template><title><template><title>vorlagenname</title></template> </title></template></title></template>
+<tplarg lineStart="1"><title> {<template><title>vorlagenname</title></template> </title></tplarg>}
+
+{<tplarg><title><tplarg><title> </title></tplarg></title></tplarg>}
+
+<template lineStart="1"><title><tplarg><title><tplarg><title> </title></tplarg></title></tplarg></title></template>
+<tplarg lineStart="1"><title><tplarg><title><template><title> </title></template> </title></tplarg></title></tplarg>
+<template lineStart="1"><title><tplarg><title><tplarg><title> </title></tplarg> </title></tplarg></title></template>
+{{<tplarg><title><tplarg><title> </title></tplarg>} </title></tplarg>}
+<tplarg lineStart="1"><title><template><title><tplarg><title> </title></tplarg></title></template> </title></tplarg>
+<template lineStart="1"><title><tplarg><title><tplarg><title> </title></tplarg></title></tplarg> </title></template>
+{<tplarg><title><template><title><template><title> </title></template> </title></template> </title></tplarg>}
+{<template><title><tplarg><title><template><title> </title></template> </title></tplarg>} </title></template>
+{<template><title><template><title><tplarg><title> </title></tplarg>} </title></template> </title></template>
+<template lineStart="1"><title><tplarg><title><tplarg><title> </title></tplarg> </title></tplarg> </title></template>
+<tplarg lineStart="1"><title><template><title><tplarg><title> </title></tplarg> </title></template> </title></tplarg>
+<tplarg lineStart="1"><title><tplarg><title><template><title> </title></template> </title></tplarg> </title></tplarg>
+<template lineStart="1"><title><template><title><template><title><template><title> </title></template> </title></template> </title></template> </title></template>
+
+<template lineStart="1"><title>vorlage</title></template>
+
+<tplarg lineStart="1"><title>argument</title></tplarg>
+
+Nach [[:meta:Help:Expansion#XML parse tree]]
+{<tplarg><title>vorlagenname</title></tplarg>}
+<template lineStart="1"><title> <template><title>vorlagenname</title></template></title></template>
+<template lineStart="1"><title><template><title>vorlagenname</title></template> </title></template>
+<template lineStart="1"><title><template><title>vorlagenname</title></template>erweiterung</title></template>
+
+<template lineStart="1"><title><tplarg><title>vorlagenname</title></tplarg></title></template>
+<tplarg lineStart="1"><title> <template><title>vorlagenname</title></template></title></tplarg>
+<template lineStart="1"><title> <tplarg><title>vorlagenname</title></tplarg></title></template>
+<tplarg lineStart="1"><title><template><title>vorlagenname</title></template> </title></tplarg>
+<template lineStart="1"><title><tplarg><title>vorlagenname</title></tplarg> </title></template>
+
+nur etwas erweitert
+<tplarg lineStart="1"><title><tplarg><title>vorlagenname</title></tplarg></title></tplarg>
+<tplarg lineStart="1"><title> <tplarg><title>vorlagenname</title></tplarg></title></tplarg>
+<tplarg lineStart="1"><title><tplarg><title>vorlagenname</title></tplarg> </title></tplarg>
+<template lineStart="1"><title> {<tplarg><title>vorlagenname</title></tplarg></title></template>}
+{<tplarg><title> <template><title>vorlagenname</title></template></title></tplarg>}
+<template lineStart="1"><title> <template><title> <template><title>vorlagenname</title></template></title></template></title></template>
+{<tplarg><title> <template><title>vorlagenname</title></template>} </title></tplarg>
+{<template><title><tplarg><title>vorlagenname</title></tplarg>} </title></template>
+{<tplarg><title><template><title>vorlagenname</title></template> </title></tplarg>}
+<template lineStart="1"><title> <template><title><template><title>vorlagenname</title></template> </title></template></title></template>
+<tplarg lineStart="1"><title> {<template><title>vorlagenname</title></template> </title></tplarg>}
+
+{<tplarg><title><tplarg><title> </title></tplarg></title></tplarg>}
+
+<template lineStart="1"><title><tplarg><title><tplarg><title> </title></tplarg></title></tplarg></title></template>
+<tplarg lineStart="1"><title><tplarg><title><template><title> </title></template> </title></tplarg></title></tplarg>
+<template lineStart="1"><title><tplarg><title><tplarg><title> </title></tplarg> </title></tplarg></title></template>
+{{<tplarg><title><tplarg><title> </title></tplarg>} </title></tplarg>}
+<tplarg lineStart="1"><title><template><title><tplarg><title> </title></tplarg></title></template> </title></tplarg>
+<template lineStart="1"><title><tplarg><title><tplarg><title> </title></tplarg></title></tplarg> </title></template>
+{<tplarg><title><template><title><template><title> </title></template> </title></template> </title></tplarg>}
+{<template><title><tplarg><title><template><title> </title></template> </title></tplarg>} </title></template>
+{<template><title><template><title><tplarg><title> </title></tplarg>} </title></template> </title></template>
+<template lineStart="1"><title><tplarg><title><tplarg><title> </title></tplarg> </title></tplarg> </title></template>
+<tplarg lineStart="1"><title><template><title><tplarg><title> </title></tplarg> </title></template> </title></tplarg>
+<tplarg lineStart="1"><title><tplarg><title><template><title> </title></template> </title></tplarg> </title></tplarg>
+<template lineStart="1"><title><template><title><template><title><template><title> </title></template> </title></template> </title></template> </title></template>
+</root> \ No newline at end of file
diff --git a/www/wiki/tests/parser/preprocess/NestedTemplates.txt b/www/wiki/tests/parser/preprocess/NestedTemplates.txt
new file mode 100644
index 00000000..aa9a472d
--- /dev/null
+++ b/www/wiki/tests/parser/preprocess/NestedTemplates.txt
@@ -0,0 +1,89 @@
+{{vorlage}}
+
+{{{argument}}}
+
+Nach [[:meta:Help:Expansion#XML parse tree]]
+{{{{vorlagenname}}}}
+{{ {{vorlagenname}}}}
+{{{{vorlagenname}} }}
+{{{{vorlagenname}}erweiterung}}
+
+{{{{{vorlagenname}}}}}
+{{{ {{vorlagenname}}}}}
+{{ {{{vorlagenname}}}}}
+{{{{{vorlagenname}} }}}
+{{{{{vorlagenname}}} }}
+
+nur etwas erweitert
+{{{{{{vorlagenname}}}}}}
+{{{ {{{vorlagenname}}}}}}
+{{{{{{vorlagenname}}} }}}
+{{ {{{{vorlagenname}}}}}}
+{{{{ {{vorlagenname}}}}}}
+{{ {{ {{vorlagenname}}}}}}
+{{{{ {{vorlagenname}}} }}}
+{{{{{{vorlagenname}}}} }}
+{{{{{{vorlagenname}} }}}}
+{{ {{{{vorlagenname}} }}}}
+{{{ {{{vorlagenname}} }}}}
+
+{{{{{{{ }}}}}}}
+
+{{{{{{{{ }}}}}}}}
+{{{{{{{{ }} }}}}}}
+{{{{{{{{ }}} }}}}}
+{{{{{{{{ }}}} }}}}
+{{{{{{{{ }}}}} }}}
+{{{{{{{{ }}}}}} }}
+{{{{{{{{ }} }} }}}}
+{{{{{{{{ }} }}}} }}
+{{{{{{{{ }}}} }} }}
+{{{{{{{{ }}} }}} }}
+{{{{{{{{ }}} }} }}}
+{{{{{{{{ }} }}} }}}
+{{{{{{{{ }} }} }} }}
+
+{{vorlage}}
+
+{{{argument}}}
+
+Nach [[:meta:Help:Expansion#XML parse tree]]
+{{{{vorlagenname}}}}
+{{ {{vorlagenname}}}}
+{{{{vorlagenname}} }}
+{{{{vorlagenname}}erweiterung}}
+
+{{{{{vorlagenname}}}}}
+{{{ {{vorlagenname}}}}}
+{{ {{{vorlagenname}}}}}
+{{{{{vorlagenname}} }}}
+{{{{{vorlagenname}}} }}
+
+nur etwas erweitert
+{{{{{{vorlagenname}}}}}}
+{{{ {{{vorlagenname}}}}}}
+{{{{{{vorlagenname}}} }}}
+{{ {{{{vorlagenname}}}}}}
+{{{{ {{vorlagenname}}}}}}
+{{ {{ {{vorlagenname}}}}}}
+{{{{ {{vorlagenname}}} }}}
+{{{{{{vorlagenname}}}} }}
+{{{{{{vorlagenname}} }}}}
+{{ {{{{vorlagenname}} }}}}
+{{{ {{{vorlagenname}} }}}}
+
+{{{{{{{ }}}}}}}
+
+{{{{{{{{ }}}}}}}}
+{{{{{{{{ }} }}}}}}
+{{{{{{{{ }}} }}}}}
+{{{{{{{{ }}}} }}}}
+{{{{{{{{ }}}}} }}}
+{{{{{{{{ }}}}}} }}
+{{{{{{{{ }} }} }}}}
+{{{{{{{{ }} }}}} }}
+{{{{{{{{ }}}} }} }}
+{{{{{{{{ }}} }}} }}
+{{{{{{{{ }}} }} }}}
+{{{{{{{{ }} }}} }}}
+{{{{{{{{ }} }} }} }}
diff --git a/www/wiki/tests/parser/preprocess/QuoteQuran.expected b/www/wiki/tests/parser/preprocess/QuoteQuran.expected
new file mode 100644
index 00000000..e9a78e46
--- /dev/null
+++ b/www/wiki/tests/parser/preprocess/QuoteQuran.expected
@@ -0,0 +1,140 @@
+<root><ignore>&lt;noinclude&gt;</ignore><template><title>Template sandbox notice</title></template><ignore>&lt;/noinclude&gt;</ignore>
+&lt;div class=&quot;boilerplate metadata rfa&quot; style=&quot;background-color:#FFFFF5; margin: 2em 0 0 0; padding: 0 10px 0 10px; border: 1px solid #AAAAAA;&quot;&gt;The [[Qur'an]], [[sura|chapter]] <template><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 1) </title><part><name index="1" /><value> 1 ([[Al-Fatiha]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 2) </title><part><name index="1" /><value> 2 ([[Al-Baqara]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 3) </title><part><name index="1" /><value> 3 ([[Ali Imran]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 4) </title><part><name index="1" /><value> 4 ([[An-Nisa]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 5) </title><part><name index="1" /><value> 5 ([[Al-Ma'ida]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 6) </title><part><name index="1" /><value> 6 ([[Al-An'am]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 7) </title><part><name index="1" /><value> 7 ([[Al-A'raf]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 8) </title><part><name index="1" /><value> 8 ([[Al-Anfal]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 9) </title><part><name index="1" /><value> 9 ([[At-Tawba]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 10) </title><part><name index="1" /><value> 10 ([[Yunus (sura)|Yunus]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 11) </title><part><name index="1" /><value> 11 ([[Hud (sura)|Hud]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 12) </title><part><name index="1" /><value> 12 ([[Yusuf (sura)|Yusuf]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 13) </title><part><name index="1" /><value> 13 ([[Ar-Ra'd]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 14) </title><part><name index="1" /><value> 14 ([[Ibrahim (sura)|Ibrahim]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 15) </title><part><name index="1" /><value> 15 ([[Al-Hijr]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 16) </title><part><name index="1" /><value> 16 ([[An-Nahl]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 17) </title><part><name index="1" /><value> 17 ([[Al-Isra]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 18) </title><part><name index="1" /><value> 18 ([[Al-Kahf]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 19) </title><part><name index="1" /><value> 19 ([[Maryam (sura)|Maryam]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 20) </title><part><name index="1" /><value> 20 ([[Ta-Ha]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 21) </title><part><name index="1" /><value> 21 ([[Al-Anbiya]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 22) </title><part><name index="1" /><value> 22 ([[Al-Hajj]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 23) </title><part><name index="1" /><value> 23 ([[Al-Muminun]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 24) </title><part><name index="1" /><value> 24 ([[An-Noor]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 25) </title><part><name index="1" /><value> 25 ([[Al-Furqan]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 26) </title><part><name index="1" /><value> 26 ([[Ash-Shu'ara]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 27) </title><part><name index="1" /><value> 27 ([[An-Naml]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 28) </title><part><name index="1" /><value> 28 ([[Al-Qisas]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 29) </title><part><name index="1" /><value> 29 ([[Al-Ankabut]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 30) </title><part><name index="1" /><value> 30 ([[Ar-Rum]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 31) </title><part><name index="1" /><value> 31 ([[Luqman (sura)|Luqman]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 32) </title><part><name index="1" /><value> 32 ([[As-Sajda]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 33) </title><part><name index="1" /><value> 33 ([[Al-Ahzab]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 34) </title><part><name index="1" /><value> 34 ([[Saba (sura)|Saba]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 35) </title><part><name index="1" /><value> 35 ([[Fatir]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 36) </title><part><name index="1" /><value> 36 ([[Ya-Seen]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 37) </title><part><name index="1" /><value> 37 ([[As-Saaffat]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 38) </title><part><name index="1" /><value> 38 ([[Sad (sura)|Sad]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 39) </title><part><name index="1" /><value> 39 ([[Az-Zumar]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 40) </title><part><name index="1" /><value> 40 ([[Ghafir]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 41) </title><part><name index="1" /><value> 41 ([[Fussilat]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 42) </title><part><name index="1" /><value> 42 ([[Ash-Shura]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 43) </title><part><name index="1" /><value> 43 ([[Az-Zukhruf]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 44) </title><part><name index="1" /><value> 44 ([[Ad-Dukhan]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 45) </title><part><name index="1" /><value> 45 ([[Al-Jathiya]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 46) </title><part><name index="1" /><value> 46 ([[Al-Ahqaf]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 47) </title><part><name index="1" /><value> 47 ([[Muhammad (sura)|Muhammad]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 48) </title><part><name index="1" /><value> 48 ([[Al-Fath]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 49) </title><part><name index="1" /><value> 49 ([[Al-Hujraat]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 50) </title><part><name index="1" /><value> 50 ([[Qaf (sura)|Qaf]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 51) </title><part><name index="1" /><value> 51 ([[Adh-Dhariyat]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 52) </title><part><name index="1" /><value> 52 ([[At-Tur]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 53) </title><part><name index="1" /><value> 53 ([[An-Najm]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 54) </title><part><name index="1" /><value> 54 ([[Al-Qamar]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 55) </title><part><name index="1" /><value> 55 ([[Ar-Rahman]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 56) </title><part><name index="1" /><value> 56 ([[Al-Waqia]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 57) </title><part><name index="1" /><value> 57 ([[Al-Hadid]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 58) </title><part><name index="1" /><value> 58 ([[Al-Mujadila]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 59) </title><part><name index="1" /><value> 59 ([[Al-Hashr]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 60) </title><part><name index="1" /><value> 60 ([[Al-Mumtahina]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 61) </title><part><name index="1" /><value> 61 ([[As-Saff]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 62) </title><part><name index="1" /><value> 62 ([[Al-Jumua]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 63) </title><part><name index="1" /><value> 63 ([[Al-Munafiqoon]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 64) </title><part><name index="1" /><value> 64 ([[At-Taghabun]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 65) </title><part><name index="1" /><value> 65 ([[At-Talaq]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 66) </title><part><name index="1" /><value> 66 ([[At-Tahrim]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 67) </title><part><name index="1" /><value> 67 ([[Al-Mulk]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 68) </title><part><name index="1" /><value> 68 ([[Al-Qalam]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 69) </title><part><name index="1" /><value> 69 ([[Al-Haaqqa]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 70) </title><part><name index="1" /><value> 70 ([[Al-Maarij]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 71) </title><part><name index="1" /><value> 71 ([[Nooh (sura)|Nooh]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 72) </title><part><name index="1" /><value> 72 ([[Al-Jinn]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 73) </title><part><name index="1" /><value> 73 ([[Al-Muzzammil]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 74) </title><part><name index="1" /><value> 74 ([[Al-Muddaththir]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 75) </title><part><name index="1" /><value> 75 ([[Al-Qiyama]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 76) </title><part><name index="1" /><value> 76 ([[Al-Insan]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 77) </title><part><name index="1" /><value> 77 ([[Al-Mursalat]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 78) </title><part><name index="1" /><value> 78 ([[An-Naba]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 79) </title><part><name index="1" /><value> 79 ([[An-Naziat]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 80) </title><part><name index="1" /><value> 80 ([[Abasa]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 81) </title><part><name index="1" /><value> 81 ([[At-Takwir]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 82) </title><part><name index="1" /><value> 82 ([[Al-Infitar]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 83) </title><part><name index="1" /><value> 83 ([[Al-Mutaffifin]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 84) </title><part><name index="1" /><value> 84 ([[Al-Inshiqaq]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 85) </title><part><name index="1" /><value> 85 ([[Al-Burooj]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 86) </title><part><name index="1" /><value> 86 ([[At-Tariq]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 87) </title><part><name index="1" /><value> 87 ([[Al-Ala]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 88) </title><part><name index="1" /><value> 88 ([[Al-Ghashiya]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 89) </title><part><name index="1" /><value> 89 ([[Al-Fajr (sura)|Al-Fajr]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 90) </title><part><name index="1" /><value> 90 ([[Al-Balad]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 91) </title><part><name index="1" /><value> 91 ([[Ash-Shams]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 92) </title><part><name index="1" /><value> 92 ([[Al-Lail]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 93) </title><part><name index="1" /><value> 93 ([[Ad-Dhuha]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 94) </title><part><name index="1" /><value> 94 ([[Al-Inshirah]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 95) </title><part><name index="1" /><value> 95 ([[At-Tin]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 96) </title><part><name index="1" /><value> 96 ([[Al-Alaq]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 97) </title><part><name index="1" /><value> 97 ([[Al-Qadr]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 98) </title><part><name index="1" /><value> 98 ([[Al-Bayyina]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 99) </title><part><name index="1" /><value> 99 ([[Az-Zalzala]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 100) </title><part><name index="1" /><value> 100 ([[Al-Adiyat]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 101) </title><part><name index="1" /><value> 101 ([[Al-Qaria]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 102) </title><part><name index="1" /><value> 102 ([[At-Takathur]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 103) </title><part><name index="1" /><value> 103 ([[Al-Asr]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 104) </title><part><name index="1" /><value> 104 ([[Al-Humaza]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 105) </title><part><name index="1" /><value> 105 ([[Al-Fil]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 106) </title><part><name index="1" /><value> 106 ([[Quraysh (sura)|Quraysh]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 107) </title><part><name index="1" /><value> 107 ([[Al-Ma'un]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 108) </title><part><name index="1" /><value> 108 ([[Al-Kawthar]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 109) </title><part><name index="1" /><value> 109 ([[Al-Kafirun]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 110) </title><part><name index="1" /><value> 110 ([[An-Nasr]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 111) </title><part><name index="1" /><value> 111 ([[Al-Masadd]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 112) </title><part><name index="1" /><value> 112 ([[Al-Ikhlas]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 113) </title><part><name index="1" /><value> 113 ([[Al-Falaq]]) </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg> = 114) </title><part><name index="1" /><value> 114 ([[An-Nas]]) </value></part><part><name index="2" /><value>
+error </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template>, [[ayat|verse]] [http://www.usc.edu/dept/MSA/quran/<template><title>three digit</title><part><name index="1" /><value><tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg></value></part></template>.qmt.html#<template><title>three digit</title><part><name index="1" /><value><tplarg><title>1</title><part><name index="1" /><value>1</value></part></tplarg></value></part></template>.<template><title>three digit</title><part><name index="1" /><value><tplarg><title>2</title><part><name index="1" /><value>1</value></part></tplarg></value></part></template> <tplarg><title>2</title><part><name index="1" /><value>1</value></part></tplarg>]''':'''<template><title>cquote</title><part><name index="1" /><value> <tplarg><title>3</title><part><name index="1" /><value>Default text</value></part></tplarg>&amp;mdash; &lt;small&gt;[[Qur'an translations|translated]] by <template><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 0) </title><part><name index="1" /><value> Unknown </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 1) </title><part><name index="1" /><value> [[Salman the Persian]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 101) </title><part><name index="1" /><value> [[Marmaduke Pickthall]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 102) </title><part><name index="1" /><value> [[Abdullah Yusuf Ali]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 601) </title><part><name index="1" /><value> [[Muhammad Muhsin Khan]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 701) </title><part><name index="1" /><value> [[Mohammed Habib Shakir|M. H. Shakir]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 901) </title><part><name index="1" /><value> [[Maulana Muhammad Ali]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 902) </title><part><name index="1" /><value> [[Rashad Khalifa]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 1001) </title><part><name index="1" /><value> [[Theodor Bibliander]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 1002) </title><part><name index="1" /><value> [[Robert of Ketton]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 1003) </title><part><name index="1" /><value> [[Andre du Ryer]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 1004) </title><part><name index="1" /><value> [[Alexander Ross (writer)|Alexander Ross]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 1005) </title><part><name index="1" /><value> [[Abraham Hinckelmann]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 1006) </title><part><name index="1" /><value> [[George Sale]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 1007) </title><part><name index="1" /><value> [[John Medows Rodwell]] </value></part><part><name index="2" /><value>
+<template lineStart="1"><title>#ifexpr: (<tplarg><title>4</title><part><name index="1" /><value>0</value></part></tplarg> = 1008) </title><part><name index="1" /><value> [[Arthur John Arberry]] </value></part><part><name index="2" /><value>
+error </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template> </value></part></template>&lt;/small&gt;
+<template lineStart="1"><title>#if:<tplarg><title>trans</title><part><name index="1" /><value></value></part></tplarg></title><part><name index="1" /><value>
+----
+[[Transliteration]]: <tplarg><title>trans</title></tplarg></value></part><part><name index="2" /><value> </value></part></template>
+<template lineStart="1"><title>#if:<tplarg><title>arab</title><part><name index="1" /><value></value></part></tplarg></title><part><name index="1" /><value>
+----
+[[Arabic language|Arabic]]: <tplarg><title>arab</title></tplarg></value></part><part><name index="2" /><value> </value></part></template> </value></part></template>&lt;/font&gt;&lt;/div&gt;
+
+</root> \ No newline at end of file
diff --git a/www/wiki/tests/parser/preprocess/QuoteQuran.txt b/www/wiki/tests/parser/preprocess/QuoteQuran.txt
new file mode 100644
index 00000000..3cfac5b2
--- /dev/null
+++ b/www/wiki/tests/parser/preprocess/QuoteQuran.txt
@@ -0,0 +1,139 @@
+<noinclude>{{Template sandbox notice}}</noinclude>
+<div class="boilerplate metadata rfa" style="background-color:#FFFFF5; margin: 2em 0 0 0; padding: 0 10px 0 10px; border: 1px solid #AAAAAA;">The [[Qur'an]], [[sura|chapter]] {{#ifexpr: ({{{1|1}}} = 1) | 1 ([[Al-Fatiha]]) |
+{{#ifexpr: ({{{1|1}}} = 2) | 2 ([[Al-Baqara]]) |
+{{#ifexpr: ({{{1|1}}} = 3) | 3 ([[Ali Imran]]) |
+{{#ifexpr: ({{{1|1}}} = 4) | 4 ([[An-Nisa]]) |
+{{#ifexpr: ({{{1|1}}} = 5) | 5 ([[Al-Ma'ida]]) |
+{{#ifexpr: ({{{1|1}}} = 6) | 6 ([[Al-An'am]]) |
+{{#ifexpr: ({{{1|1}}} = 7) | 7 ([[Al-A'raf]]) |
+{{#ifexpr: ({{{1|1}}} = 8) | 8 ([[Al-Anfal]]) |
+{{#ifexpr: ({{{1|1}}} = 9) | 9 ([[At-Tawba]]) |
+{{#ifexpr: ({{{1|1}}} = 10) | 10 ([[Yunus (sura)|Yunus]]) |
+{{#ifexpr: ({{{1|1}}} = 11) | 11 ([[Hud (sura)|Hud]]) |
+{{#ifexpr: ({{{1|1}}} = 12) | 12 ([[Yusuf (sura)|Yusuf]]) |
+{{#ifexpr: ({{{1|1}}} = 13) | 13 ([[Ar-Ra'd]]) |
+{{#ifexpr: ({{{1|1}}} = 14) | 14 ([[Ibrahim (sura)|Ibrahim]]) |
+{{#ifexpr: ({{{1|1}}} = 15) | 15 ([[Al-Hijr]]) |
+{{#ifexpr: ({{{1|1}}} = 16) | 16 ([[An-Nahl]]) |
+{{#ifexpr: ({{{1|1}}} = 17) | 17 ([[Al-Isra]]) |
+{{#ifexpr: ({{{1|1}}} = 18) | 18 ([[Al-Kahf]]) |
+{{#ifexpr: ({{{1|1}}} = 19) | 19 ([[Maryam (sura)|Maryam]]) |
+{{#ifexpr: ({{{1|1}}} = 20) | 20 ([[Ta-Ha]]) |
+{{#ifexpr: ({{{1|1}}} = 21) | 21 ([[Al-Anbiya]]) |
+{{#ifexpr: ({{{1|1}}} = 22) | 22 ([[Al-Hajj]]) |
+{{#ifexpr: ({{{1|1}}} = 23) | 23 ([[Al-Muminun]]) |
+{{#ifexpr: ({{{1|1}}} = 24) | 24 ([[An-Noor]]) |
+{{#ifexpr: ({{{1|1}}} = 25) | 25 ([[Al-Furqan]]) |
+{{#ifexpr: ({{{1|1}}} = 26) | 26 ([[Ash-Shu'ara]]) |
+{{#ifexpr: ({{{1|1}}} = 27) | 27 ([[An-Naml]]) |
+{{#ifexpr: ({{{1|1}}} = 28) | 28 ([[Al-Qisas]]) |
+{{#ifexpr: ({{{1|1}}} = 29) | 29 ([[Al-Ankabut]]) |
+{{#ifexpr: ({{{1|1}}} = 30) | 30 ([[Ar-Rum]]) |
+{{#ifexpr: ({{{1|1}}} = 31) | 31 ([[Luqman (sura)|Luqman]]) |
+{{#ifexpr: ({{{1|1}}} = 32) | 32 ([[As-Sajda]]) |
+{{#ifexpr: ({{{1|1}}} = 33) | 33 ([[Al-Ahzab]]) |
+{{#ifexpr: ({{{1|1}}} = 34) | 34 ([[Saba (sura)|Saba]]) |
+{{#ifexpr: ({{{1|1}}} = 35) | 35 ([[Fatir]]) |
+{{#ifexpr: ({{{1|1}}} = 36) | 36 ([[Ya-Seen]]) |
+{{#ifexpr: ({{{1|1}}} = 37) | 37 ([[As-Saaffat]]) |
+{{#ifexpr: ({{{1|1}}} = 38) | 38 ([[Sad (sura)|Sad]]) |
+{{#ifexpr: ({{{1|1}}} = 39) | 39 ([[Az-Zumar]]) |
+{{#ifexpr: ({{{1|1}}} = 40) | 40 ([[Ghafir]]) |
+{{#ifexpr: ({{{1|1}}} = 41) | 41 ([[Fussilat]]) |
+{{#ifexpr: ({{{1|1}}} = 42) | 42 ([[Ash-Shura]]) |
+{{#ifexpr: ({{{1|1}}} = 43) | 43 ([[Az-Zukhruf]]) |
+{{#ifexpr: ({{{1|1}}} = 44) | 44 ([[Ad-Dukhan]]) |
+{{#ifexpr: ({{{1|1}}} = 45) | 45 ([[Al-Jathiya]]) |
+{{#ifexpr: ({{{1|1}}} = 46) | 46 ([[Al-Ahqaf]]) |
+{{#ifexpr: ({{{1|1}}} = 47) | 47 ([[Muhammad (sura)|Muhammad]]) |
+{{#ifexpr: ({{{1|1}}} = 48) | 48 ([[Al-Fath]]) |
+{{#ifexpr: ({{{1|1}}} = 49) | 49 ([[Al-Hujraat]]) |
+{{#ifexpr: ({{{1|1}}} = 50) | 50 ([[Qaf (sura)|Qaf]]) |
+{{#ifexpr: ({{{1|1}}} = 51) | 51 ([[Adh-Dhariyat]]) |
+{{#ifexpr: ({{{1|1}}} = 52) | 52 ([[At-Tur]]) |
+{{#ifexpr: ({{{1|1}}} = 53) | 53 ([[An-Najm]]) |
+{{#ifexpr: ({{{1|1}}} = 54) | 54 ([[Al-Qamar]]) |
+{{#ifexpr: ({{{1|1}}} = 55) | 55 ([[Ar-Rahman]]) |
+{{#ifexpr: ({{{1|1}}} = 56) | 56 ([[Al-Waqia]]) |
+{{#ifexpr: ({{{1|1}}} = 57) | 57 ([[Al-Hadid]]) |
+{{#ifexpr: ({{{1|1}}} = 58) | 58 ([[Al-Mujadila]]) |
+{{#ifexpr: ({{{1|1}}} = 59) | 59 ([[Al-Hashr]]) |
+{{#ifexpr: ({{{1|1}}} = 60) | 60 ([[Al-Mumtahina]]) |
+{{#ifexpr: ({{{1|1}}} = 61) | 61 ([[As-Saff]]) |
+{{#ifexpr: ({{{1|1}}} = 62) | 62 ([[Al-Jumua]]) |
+{{#ifexpr: ({{{1|1}}} = 63) | 63 ([[Al-Munafiqoon]]) |
+{{#ifexpr: ({{{1|1}}} = 64) | 64 ([[At-Taghabun]]) |
+{{#ifexpr: ({{{1|1}}} = 65) | 65 ([[At-Talaq]]) |
+{{#ifexpr: ({{{1|1}}} = 66) | 66 ([[At-Tahrim]]) |
+{{#ifexpr: ({{{1|1}}} = 67) | 67 ([[Al-Mulk]]) |
+{{#ifexpr: ({{{1|1}}} = 68) | 68 ([[Al-Qalam]]) |
+{{#ifexpr: ({{{1|1}}} = 69) | 69 ([[Al-Haaqqa]]) |
+{{#ifexpr: ({{{1|1}}} = 70) | 70 ([[Al-Maarij]]) |
+{{#ifexpr: ({{{1|1}}} = 71) | 71 ([[Nooh (sura)|Nooh]]) |
+{{#ifexpr: ({{{1|1}}} = 72) | 72 ([[Al-Jinn]]) |
+{{#ifexpr: ({{{1|1}}} = 73) | 73 ([[Al-Muzzammil]]) |
+{{#ifexpr: ({{{1|1}}} = 74) | 74 ([[Al-Muddaththir]]) |
+{{#ifexpr: ({{{1|1}}} = 75) | 75 ([[Al-Qiyama]]) |
+{{#ifexpr: ({{{1|1}}} = 76) | 76 ([[Al-Insan]]) |
+{{#ifexpr: ({{{1|1}}} = 77) | 77 ([[Al-Mursalat]]) |
+{{#ifexpr: ({{{1|1}}} = 78) | 78 ([[An-Naba]]) |
+{{#ifexpr: ({{{1|1}}} = 79) | 79 ([[An-Naziat]]) |
+{{#ifexpr: ({{{1|1}}} = 80) | 80 ([[Abasa]]) |
+{{#ifexpr: ({{{1|1}}} = 81) | 81 ([[At-Takwir]]) |
+{{#ifexpr: ({{{1|1}}} = 82) | 82 ([[Al-Infitar]]) |
+{{#ifexpr: ({{{1|1}}} = 83) | 83 ([[Al-Mutaffifin]]) |
+{{#ifexpr: ({{{1|1}}} = 84) | 84 ([[Al-Inshiqaq]]) |
+{{#ifexpr: ({{{1|1}}} = 85) | 85 ([[Al-Burooj]]) |
+{{#ifexpr: ({{{1|1}}} = 86) | 86 ([[At-Tariq]]) |
+{{#ifexpr: ({{{1|1}}} = 87) | 87 ([[Al-Ala]]) |
+{{#ifexpr: ({{{1|1}}} = 88) | 88 ([[Al-Ghashiya]]) |
+{{#ifexpr: ({{{1|1}}} = 89) | 89 ([[Al-Fajr (sura)|Al-Fajr]]) |
+{{#ifexpr: ({{{1|1}}} = 90) | 90 ([[Al-Balad]]) |
+{{#ifexpr: ({{{1|1}}} = 91) | 91 ([[Ash-Shams]]) |
+{{#ifexpr: ({{{1|1}}} = 92) | 92 ([[Al-Lail]]) |
+{{#ifexpr: ({{{1|1}}} = 93) | 93 ([[Ad-Dhuha]]) |
+{{#ifexpr: ({{{1|1}}} = 94) | 94 ([[Al-Inshirah]]) |
+{{#ifexpr: ({{{1|1}}} = 95) | 95 ([[At-Tin]]) |
+{{#ifexpr: ({{{1|1}}} = 96) | 96 ([[Al-Alaq]]) |
+{{#ifexpr: ({{{1|1}}} = 97) | 97 ([[Al-Qadr]]) |
+{{#ifexpr: ({{{1|1}}} = 98) | 98 ([[Al-Bayyina]]) |
+{{#ifexpr: ({{{1|1}}} = 99) | 99 ([[Az-Zalzala]]) |
+{{#ifexpr: ({{{1|1}}} = 100) | 100 ([[Al-Adiyat]]) |
+{{#ifexpr: ({{{1|1}}} = 101) | 101 ([[Al-Qaria]]) |
+{{#ifexpr: ({{{1|1}}} = 102) | 102 ([[At-Takathur]]) |
+{{#ifexpr: ({{{1|1}}} = 103) | 103 ([[Al-Asr]]) |
+{{#ifexpr: ({{{1|1}}} = 104) | 104 ([[Al-Humaza]]) |
+{{#ifexpr: ({{{1|1}}} = 105) | 105 ([[Al-Fil]]) |
+{{#ifexpr: ({{{1|1}}} = 106) | 106 ([[Quraysh (sura)|Quraysh]]) |
+{{#ifexpr: ({{{1|1}}} = 107) | 107 ([[Al-Ma'un]]) |
+{{#ifexpr: ({{{1|1}}} = 108) | 108 ([[Al-Kawthar]]) |
+{{#ifexpr: ({{{1|1}}} = 109) | 109 ([[Al-Kafirun]]) |
+{{#ifexpr: ({{{1|1}}} = 110) | 110 ([[An-Nasr]]) |
+{{#ifexpr: ({{{1|1}}} = 111) | 111 ([[Al-Masadd]]) |
+{{#ifexpr: ({{{1|1}}} = 112) | 112 ([[Al-Ikhlas]]) |
+{{#ifexpr: ({{{1|1}}} = 113) | 113 ([[Al-Falaq]]) |
+{{#ifexpr: ({{{1|1}}} = 114) | 114 ([[An-Nas]]) |
+error }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }}, [[ayat|verse]] [http://www.usc.edu/dept/MSA/quran/{{three digit|{{{1|1}}}}}.qmt.html#{{three digit|{{{1|1}}}}}.{{three digit|{{{2|1}}}}} {{{2|1}}}]''':'''{{cquote| {{{3|Default text}}}&mdash; <small>[[Qur'an translations|translated]] by {{#ifexpr: ({{{4|0}}} = 0) | Unknown |
+{{#ifexpr: ({{{4|0}}} = 1) | [[Salman the Persian]] |
+{{#ifexpr: ({{{4|0}}} = 101) | [[Marmaduke Pickthall]] |
+{{#ifexpr: ({{{4|0}}} = 102) | [[Abdullah Yusuf Ali]] |
+{{#ifexpr: ({{{4|0}}} = 601) | [[Muhammad Muhsin Khan]] |
+{{#ifexpr: ({{{4|0}}} = 701) | [[Mohammed Habib Shakir|M. H. Shakir]] |
+{{#ifexpr: ({{{4|0}}} = 901) | [[Maulana Muhammad Ali]] |
+{{#ifexpr: ({{{4|0}}} = 902) | [[Rashad Khalifa]] |
+{{#ifexpr: ({{{4|0}}} = 1001) | [[Theodor Bibliander]] |
+{{#ifexpr: ({{{4|0}}} = 1002) | [[Robert of Ketton]] |
+{{#ifexpr: ({{{4|0}}} = 1003) | [[Andre du Ryer]] |
+{{#ifexpr: ({{{4|0}}} = 1004) | [[Alexander Ross (writer)|Alexander Ross]] |
+{{#ifexpr: ({{{4|0}}} = 1005) | [[Abraham Hinckelmann]] |
+{{#ifexpr: ({{{4|0}}} = 1006) | [[George Sale]] |
+{{#ifexpr: ({{{4|0}}} = 1007) | [[John Medows Rodwell]] |
+{{#ifexpr: ({{{4|0}}} = 1008) | [[Arthur John Arberry]] |
+error }} }} }} }} }} }} }} }} }} }} }} }} }} }} }} }}</small>
+{{#if:{{{trans|}}}|
+----
+[[Transliteration]]: {{{trans}}}| }}
+{{#if:{{{arab|}}}|
+----
+[[Arabic language|Arabic]]: {{{arab}}}| }} }}</font></div>
+
diff --git a/www/wiki/tests/phan/bin/phan b/www/wiki/tests/phan/bin/phan
new file mode 100755
index 00000000..ad06823a
--- /dev/null
+++ b/www/wiki/tests/phan/bin/phan
@@ -0,0 +1,90 @@
+#!/bin/bash
+
+# mediawiki-vagrant installs dont have realpath by default
+if ! which realpath > /dev/null; then
+ realpath() {
+ php -r "echo realpath('$*');"
+ }
+fi
+
+if hash php7.0 2>/dev/null; then
+ export PHP="php7.0"
+else
+ export PHP="php"
+fi
+
+# Note that this isn't loaded in via composer because then composer can
+# only be run with php7.0
+if [ ! -f "$PHAN" ]; then
+ # If no PHAN is specified then try to get location from PATH
+ export PHAN="$(which phan)"
+ if [ ! -f "$PHAN" ]; then
+ echo "The environment variable PHAN must point to the 'phan' file"
+ echo "in a checkout of https://github.com/etsy/phan.git"
+ echo "Or phan must be included in your PATH"
+ exit 1
+ fi
+else
+ export PHAN="$PHP $PHAN"
+fi
+
+if [ -z "$MW_INSTALL_PATH" ]; then
+ # Figure out where mediawiki is based on the location of this script
+ pushd "$(dirname "$0")" > /dev/null
+ export MW_INSTALL_PATH="$(git rev-parse --show-toplevel)"
+ popd >/dev/null
+fi
+
+# If the first argument doesn't start with a -, then it's a path
+# to another project (extension, skin, etc.) to analyze
+if [[ -n "$1" && "$1" != "-"* ]]; then
+ cd $1
+ shift
+else
+ cd "$(dirname "$0")"
+fi
+
+# Root directory of project
+export ROOT="$(git rev-parse --show-toplevel)"
+
+# Go to the root of this git repo
+cd "$ROOT"
+
+export CONFIG_FILE="$ROOT/tests/phan/config.php"
+if [ ! -f "$CONFIG_FILE" ]; then
+ echo "Could not find a phan config file to apply in"
+ echo "$CONFIG_FILE"
+ exit 1
+fi
+
+# Phan's issues directory
+export ISSUES="${ROOT}/tests/phan/issues"
+mkdir -p "$ISSUES"
+
+# Get the current hash of HEAD
+export REV="$(git rev-parse HEAD)"
+
+# Destination for issues found
+export RUN="${ISSUES}/issues-${REV}"
+
+
+# Run the analysis, emitting output to the
+# issues file.
+$PHAN \
+ --project-root-directory "$ROOT" \
+ --config-file "$CONFIG_FILE" \
+ --output "php://stdout" \
+ "${@}" \
+ | php "$MW_INSTALL_PATH/tests/phan/bin/postprocess-phan.php" "${@}" \
+ > $RUN
+
+EXIT_CODE="$?"
+
+# Re-link the latest file
+rm -f "${ISSUES}/latest"
+ln -s "${RUN}" "${ISSUES}/latest"
+
+# Output any issues that were found
+cat "${RUN}"
+
+exit $EXIT_CODE
diff --git a/www/wiki/tests/phan/bin/postprocess-phan.php b/www/wiki/tests/phan/bin/postprocess-phan.php
new file mode 100644
index 00000000..3e805986
--- /dev/null
+++ b/www/wiki/tests/phan/bin/postprocess-phan.php
@@ -0,0 +1,146 @@
+<?php
+
+abstract class Suppressor {
+ /**
+ * @param string $input
+ * @return bool do errors remain
+ */
+ abstract public function suppress( $input );
+
+ /**
+ * @param string[] $source
+ * @param string $type
+ * @param int $lineno
+ * @return bool
+ */
+ protected function isSuppressed( array $source, $type, $lineno ) {
+ return $lineno > 0 && preg_match(
+ "|/\*\* @suppress {$type} |",
+ $source[$lineno - 1]
+ );
+ }
+}
+
+class TextSuppressor extends Suppressor {
+ /**
+ * @param string $input
+ * @return bool do errors remain
+ */
+ public function suppress( $input ) {
+ $hasErrors = false;
+ $errors = [];
+ foreach ( explode( "\n", $input ) as $error ) {
+ if ( empty( $error ) ) {
+ continue;
+ }
+ if ( !preg_match( '/^(.*):(\d+) (Phan\w+) (.*)$/', $error, $matches ) ) {
+ echo "Failed to parse line: $error\n";
+ continue;
+ }
+ list( $source, $file, $lineno, $type, $message ) = $matches;
+ $errors[$file][] = [
+ 'orig' => $error,
+ // convert from 1 indexed to 0 indexed
+ 'lineno' => $lineno - 1,
+ 'type' => $type,
+ ];
+ }
+ foreach ( $errors as $file => $fileErrors ) {
+ $source = file( $file );
+ foreach ( $fileErrors as $error ) {
+ if ( !$this->isSuppressed( $source, $error['type'], $error['lineno'] ) ) {
+ echo $error['orig'], "\n";
+ $hasErrors = true;
+ }
+ }
+ }
+
+ return $hasErrors;
+ }
+}
+
+class CheckStyleSuppressor extends Suppressor {
+ /**
+ * @param string $input
+ * @return bool True do errors remain
+ */
+ public function suppress( $input ) {
+ $dom = new DOMDocument();
+ $dom->loadXML( $input );
+ $hasErrors = false;
+ // DOMNodeList's are "live", convert to an array so it works as expected
+ $files = [];
+ foreach ( $dom->getElementsByTagName( 'file' ) as $file ) {
+ $files[] = $file;
+ }
+ foreach ( $files as $file ) {
+ $errors = [];
+ foreach ( $file->getElementsByTagName( 'error' ) as $error ) {
+ $errors[] = $error;
+ }
+ $source = file( $file->getAttribute( 'name' ) );
+ $fileHasErrors = false;
+ foreach ( $errors as $error ) {
+ $lineno = $error->getAttribute( 'line' ) - 1;
+ $type = $error->getAttribute( 'source' );
+ if ( $this->isSuppressed( $source, $type, $lineno ) ) {
+ $error->parentNode->removeChild( $error );
+ } else {
+ $fileHasErrors = true;
+ $hasErrors = true;
+ }
+ }
+ if ( !$fileHasErrors ) {
+ $file->parentNode->removeChild( $file );
+ }
+ }
+ echo $dom->saveXML();
+
+ return $hasErrors;
+ }
+}
+
+class NoopSuppressor extends Suppressor {
+ private $mode;
+
+ public function __construct( $mode ) {
+ $this->mode = $mode;
+ }
+ public function suppress( $input ) {
+ echo "Unsupported output mode: {$this->mode}\n$input";
+ return true;
+ }
+}
+
+$opt = getopt( "m:", [ "output-mode:" ] );
+// if provided multiple times getopt returns an array
+if ( isset( $opt['m'] ) ) {
+ $mode = $opt['m'];
+} elseif ( isset( $mode['output-mode'] ) ) {
+ $mode = $opt['output-mode'];
+} else {
+ $mode = 'text';
+}
+if ( is_array( $mode ) ) {
+ // If an option is passed multiple times getopt returns an
+ // array. Just take the last one.
+ $mode = end( $mode );
+}
+
+switch ( $mode ) {
+case 'text':
+ $suppressor = new TextSuppressor();
+ break;
+case 'checkstyle':
+ $suppressor = new CheckStyleSuppressor();
+ break;
+default:
+ $suppressor = new NoopSuppressor( $mode );
+}
+
+$input = file_get_contents( 'php://stdin' );
+$hasErrors = $suppressor->suppress( $input );
+
+if ( $hasErrors ) {
+ exit( 1 );
+}
diff --git a/www/wiki/tests/phan/config.php b/www/wiki/tests/phan/config.php
new file mode 100644
index 00000000..71ebd6f4
--- /dev/null
+++ b/www/wiki/tests/phan/config.php
@@ -0,0 +1,497 @@
+<?php
+
+// If xdebug is enabled, we need to increase the nesting level for phan
+ini_set( 'xdebug.max_nesting_level', 1000 );
+
+/**
+ * This configuration will be read and overlayed on top of the
+ * default configuration. Command line arguments will be applied
+ * after this file is read.
+ *
+ * @see src/Phan/Config.php
+ * See Config for all configurable options.
+ *
+ * A Note About Paths
+ * ==================
+ *
+ * Files referenced from this file should be defined as
+ *
+ * ```
+ * Config::projectPath('relative_path/to/file')
+ * ```
+ *
+ * where the relative path is relative to the root of the
+ * project which is defined as either the working directory
+ * of the phan executable or a path passed in via the CLI
+ * '-d' flag.
+ */
+return [
+ /**
+ * A list of individual files to include in analysis
+ * with a path relative to the root directory of the
+ * project. directory_list won't find .inc files so
+ * we augment it here.
+ */
+ 'file_list' => array_merge(
+ function_exists( 'register_postsend_function' ) ? [] : [ 'tests/phan/stubs/hhvm.php' ],
+ function_exists( 'wikidiff2_do_diff' ) ? [] : [ 'tests/phan/stubs/wikidiff.php' ],
+ function_exists( 'tideways_enable' ) ? [] : [ 'tests/phan/stubs/tideways.php' ],
+ class_exists( PEAR::class ) ? [] : [ 'tests/phan/stubs/mail.php' ],
+ class_exists( Memcached::class ) ? [] : [ 'tests/phan/stubs/memcached.php' ],
+ // Per composer.json, PHPUnit 6 is used for PHP 7.0+, PHPUnit 4 otherwise.
+ // Load the interface for the version of PHPUnit that isn't installed.
+ // Phan only supports PHP 7.0+ (and not HHVM), so we only need to stub PHPUnit 4.
+ class_exists( PHPUnit_TextUI_Command::class ) ? [] : [ 'tests/phan/stubs/phpunit4.php' ],
+ [
+ 'maintenance/7zip.inc',
+ 'maintenance/backup.inc',
+ 'maintenance/cleanupTable.inc',
+ 'maintenance/CodeCleanerGlobalsPass.inc',
+ 'maintenance/commandLine.inc',
+ 'maintenance/importImages.inc',
+ 'maintenance/sqlite.inc',
+ 'maintenance/userDupes.inc',
+ 'maintenance/userOptions.inc',
+ 'maintenance/language/checkLanguage.inc',
+ 'maintenance/language/languages.inc',
+ ]
+ ),
+
+ /**
+ * A list of directories that should be parsed for class and
+ * method information. After excluding the directories
+ * defined in exclude_analysis_directory_list, the remaining
+ * files will be statically analyzed for errors.
+ *
+ * Thus, both first-party and third-party code being used by
+ * your application should be included in this list.
+ */
+ 'directory_list' => [
+ 'includes/',
+ 'languages/',
+ 'maintenance/',
+ 'mw-config/',
+ 'resources/',
+ 'skins/',
+ 'vendor/',
+ ],
+
+ /**
+ * A file list that defines files that will be excluded
+ * from parsing and analysis and will not be read at all.
+ *
+ * This is useful for excluding hopelessly unanalyzable
+ * files that can't be removed for whatever reason.
+ */
+ 'exclude_file_list' => [],
+
+ /**
+ * A list of directories holding code that we want
+ * to parse, but not analyze. Also works for individual
+ * files.
+ */
+ "exclude_analysis_directory_list" => [
+ 'vendor/',
+ 'tests/phan/stubs/',
+ // The referenced classes are not available in vendor, only when
+ // included from composer.
+ 'includes/composer/',
+ // Directly references classes that only exist in Translate extension
+ 'maintenance/language/',
+ // External class
+ 'includes/libs/jsminplus.php',
+ // separate repositories
+ 'skins/',
+ ],
+
+ /**
+ * Backwards Compatibility Checking. This is slow
+ * and expensive, but you should consider running
+ * it before upgrading your version of PHP to a
+ * new version that has backward compatibility
+ * breaks.
+ */
+ 'backward_compatibility_checks' => false,
+
+ /**
+ * A set of fully qualified class-names for which
+ * a call to parent::__construct() is required
+ */
+ 'parent_constructor_required' => [
+ ],
+
+ /**
+ * Run a quick version of checks that takes less
+ * time at the cost of not running as thorough
+ * an analysis. You should consider setting this
+ * to true only when you wish you had more issues
+ * to fix in your code base.
+ *
+ * In quick-mode the scanner doesn't rescan a function
+ * or a method's code block every time a call is seen.
+ * This means that the problem here won't be detected:
+ *
+ * ```php
+ * <?php
+ * function test($arg):int {
+ * return $arg;
+ * }
+ * test("abc");
+ * ```
+ *
+ * This would normally generate:
+ *
+ * ```sh
+ * test.php:3 TypeError return string but `test()` is declared to return int
+ * ```
+ *
+ * The initial scan of the function's code block has no
+ * type information for `$arg`. It isn't until we see
+ * the call and rescan test()'s code block that we can
+ * detect that it is actually returning the passed in
+ * `string` instead of an `int` as declared.
+ */
+ 'quick_mode' => false,
+
+ /**
+ * By default, Phan will not analyze all node types
+ * in order to save time. If this config is set to true,
+ * Phan will dig deeper into the AST tree and do an
+ * analysis on all nodes, possibly finding more issues.
+ *
+ * See \Phan\Analysis::shouldVisit for the set of skipped
+ * nodes.
+ */
+ 'should_visit_all_nodes' => true,
+
+ /**
+ * If enabled, check all methods that override a
+ * parent method to make sure its signature is
+ * compatible with the parent's. This check
+ * can add quite a bit of time to the analysis.
+ */
+ 'analyze_signature_compatibility' => true,
+
+ // Emit all issues. They are then suppressed via
+ // suppress_issue_types, rather than a minimum
+ // severity.
+ "minimum_severity" => 0,
+
+ /**
+ * If true, missing properties will be created when
+ * they are first seen. If false, we'll report an
+ * error message if there is an attempt to write
+ * to a class property that wasn't explicitly
+ * defined.
+ */
+ 'allow_missing_properties' => false,
+
+ /**
+ * Allow null to be cast as any type and for any
+ * type to be cast to null. Setting this to false
+ * will cut down on false positives.
+ */
+ 'null_casts_as_any_type' => true,
+
+ /**
+ * If enabled, scalars (int, float, bool, string, null)
+ * are treated as if they can cast to each other.
+ *
+ * MediaWiki is pretty lax and uses many scalar
+ * types interchangably.
+ */
+ 'scalar_implicit_cast' => true,
+
+ /**
+ * If true, seemingly undeclared variables in the global
+ * scope will be ignored. This is useful for projects
+ * with complicated cross-file globals that you have no
+ * hope of fixing.
+ */
+ 'ignore_undeclared_variables_in_global_scope' => true,
+
+ /**
+ * Set to true in order to attempt to detect dead
+ * (unreferenced) code. Keep in mind that the
+ * results will only be a guess given that classes,
+ * properties, constants and methods can be referenced
+ * as variables (like `$class->$property` or
+ * `$class->$method()`) in ways that we're unable
+ * to make sense of.
+ */
+ 'dead_code_detection' => false,
+
+ /**
+ * If true, the dead code detection rig will
+ * prefer false negatives (not report dead code) to
+ * false positives (report dead code that is not
+ * actually dead) which is to say that the graph of
+ * references will create too many edges rather than
+ * too few edges when guesses have to be made about
+ * what references what.
+ */
+ 'dead_code_detection_prefer_false_negative' => true,
+
+ /**
+ * If disabled, Phan will not read docblock type
+ * annotation comments (such as for @return, @param,
+ * @var, @suppress, @deprecated) and only rely on
+ * types expressed in code.
+ */
+ 'read_type_annotations' => true,
+
+ /**
+ * If a file path is given, the code base will be
+ * read from and written to the given location in
+ * order to attempt to save some work from being
+ * done. Only changed files will get analyzed if
+ * the file is read
+ */
+ 'stored_state_file_path' => null,
+
+ /**
+ * Set to true in order to ignore issue suppression.
+ * This is useful for testing the state of your code, but
+ * unlikely to be useful outside of that.
+ */
+ 'disable_suppression' => false,
+
+ /**
+ * If set to true, we'll dump the AST instead of
+ * analyzing files
+ */
+ 'dump_ast' => false,
+
+ /**
+ * If set to a string, we'll dump the fully qualified lowercase
+ * function and method signatures instead of analyzing files.
+ */
+ 'dump_signatures_file' => null,
+
+ /**
+ * If true (and if stored_state_file_path is set) we'll
+ * look at the list of files passed in and expand the list
+ * to include files that depend on the given files
+ */
+ 'expand_file_list' => false,
+
+ // Include a progress bar in the output
+ 'progress_bar' => false,
+
+ /**
+ * The probability of actually emitting any progress
+ * bar update. Setting this to something very low
+ * is good for reducing network IO and filling up
+ * your terminal's buffer when running phan on a
+ * remote host.
+ */
+ 'progress_bar_sample_rate' => 0.005,
+
+ /**
+ * The number of processes to fork off during the analysis
+ * phase.
+ */
+ 'processes' => 1,
+
+ /**
+ * Add any issue types (such as 'PhanUndeclaredMethod')
+ * to this black-list to inhibit them from being reported.
+ */
+ 'suppress_issue_types' => [
+ // approximate error count: 29
+ "PhanCommentParamOnEmptyParamList",
+ // approximate error count: 33
+ "PhanCommentParamWithoutRealParam",
+ // approximate error count: 8
+ "PhanDeprecatedClass",
+ // approximate error count: 415
+ "PhanDeprecatedFunction",
+ // approximate error count: 25
+ "PhanDeprecatedProperty",
+ // approximate error count: 17
+ "PhanNonClassMethodCall",
+ // approximate error count: 11
+ "PhanParamReqAfterOpt",
+ // approximate error count: 888
+ "PhanParamSignatureMismatch",
+ // approximate error count: 7
+ "PhanParamSignatureMismatchInternal",
+ // approximate error count: 1
+ "PhanParamSignatureRealMismatchTooFewParameters",
+ // approximate error count: 125
+ "PhanParamTooMany",
+ // approximate error count: 1
+ "PhanParamTooManyCallable",
+ // approximate error count: 3
+ "PhanParamTooManyInternal",
+ // approximate error count: 1
+ "PhanRedefineFunctionInternal",
+ // approximate error count: 2
+ "PhanTraitParentReference",
+ // approximate error count: 3
+ "PhanTypeComparisonFromArray",
+ // approximate error count: 2
+ "PhanTypeComparisonToArray",
+ // approximate error count: 3
+ "PhanTypeInvalidRightOperand",
+ // approximate error count: 1
+ "PhanTypeMagicVoidWithReturn",
+ // approximate error count: 218
+ "PhanTypeMismatchArgument",
+ // approximate error count: 13
+ "PhanTypeMismatchArgumentInternal",
+ // approximate error count: 6
+ "PhanTypeMismatchDeclaredParam",
+ // approximate error count: 111
+ "PhanTypeMismatchDeclaredParamNullable",
+ // approximate error count: 1
+ "PhanTypeMismatchDefault",
+ // approximate error count: 5
+ "PhanTypeMismatchDimAssignment",
+ // approximate error count: 2
+ "PhanTypeMismatchDimEmpty",
+ // approximate error count: 1
+ "PhanTypeMismatchDimFetch",
+ // approximate error count: 14
+ "PhanTypeMismatchForeach",
+ // approximate error count: 56
+ "PhanTypeMismatchProperty",
+ // approximate error count: 74
+ "PhanTypeMismatchReturn",
+ // approximate error count: 11
+ "PhanTypeMissingReturn",
+ // approximate error count: 5
+ "PhanTypeNonVarPassByRef",
+ // approximate error count: 1
+ "PhanUndeclaredClassInCallable",
+ // approximate error count: 32
+ "PhanUndeclaredConstant",
+ // approximate error count: 233
+ "PhanUndeclaredMethod",
+ // approximate error count: 1224
+ "PhanUndeclaredProperty",
+ // approximate error count: 3
+ "PhanUndeclaredStaticMethod",
+ // approximate error count: 11
+ "PhanUndeclaredTypeReturnType",
+ // approximate error count: 27
+ "PhanUndeclaredVariable",
+ // approximate error count: 58
+ "PhanUndeclaredVariableDim",
+ ],
+
+ /**
+ * If empty, no filter against issues types will be applied.
+ * If this white-list is non-empty, only issues within the list
+ * will be emitted by Phan.
+ */
+ 'whitelist_issue_types' => [
+ // 'PhanAccessMethodPrivate',
+ // 'PhanAccessMethodProtected',
+ // 'PhanAccessNonStaticToStatic',
+ // 'PhanAccessPropertyPrivate',
+ // 'PhanAccessPropertyProtected',
+ // 'PhanAccessSignatureMismatch',
+ // 'PhanAccessSignatureMismatchInternal',
+ // 'PhanAccessStaticToNonStatic',
+ // 'PhanCompatibleExpressionPHP7',
+ // 'PhanCompatiblePHP7',
+ // 'PhanContextNotObject',
+ // 'PhanDeprecatedClass',
+ // 'PhanDeprecatedFunction',
+ // 'PhanDeprecatedProperty',
+ // 'PhanEmptyFile',
+ // 'PhanNonClassMethodCall',
+ // 'PhanNoopArray',
+ // 'PhanNoopClosure',
+ // 'PhanNoopConstant',
+ // 'PhanNoopProperty',
+ // 'PhanNoopVariable',
+ // 'PhanParamRedefined',
+ // 'PhanParamReqAfterOpt',
+ // 'PhanParamSignatureMismatch',
+ // 'PhanParamSignatureMismatchInternal',
+ // 'PhanParamSpecial1',
+ // 'PhanParamSpecial2',
+ // 'PhanParamSpecial3',
+ // 'PhanParamSpecial4',
+ // 'PhanParamTooFew',
+ // 'PhanParamTooFewInternal',
+ // 'PhanParamTooMany',
+ // 'PhanParamTooManyInternal',
+ // 'PhanParamTypeMismatch',
+ // 'PhanParentlessClass',
+ // 'PhanRedefineClass',
+ // 'PhanRedefineClassInternal',
+ // 'PhanRedefineFunction',
+ // 'PhanRedefineFunctionInternal',
+ // 'PhanStaticCallToNonStatic',
+ // 'PhanSyntaxError',
+ // 'PhanTraitParentReference',
+ // 'PhanTypeArrayOperator',
+ // 'PhanTypeArraySuspicious',
+ // 'PhanTypeComparisonFromArray',
+ // 'PhanTypeComparisonToArray',
+ // 'PhanTypeConversionFromArray',
+ // 'PhanTypeInstantiateAbstract',
+ // 'PhanTypeInstantiateInterface',
+ // 'PhanTypeInvalidLeftOperand',
+ // 'PhanTypeInvalidRightOperand',
+ // 'PhanTypeMismatchArgument',
+ // 'PhanTypeMismatchArgumentInternal',
+ // 'PhanTypeMismatchDefault',
+ // 'PhanTypeMismatchForeach',
+ // 'PhanTypeMismatchProperty',
+ // 'PhanTypeMismatchReturn',
+ // 'PhanTypeMissingReturn',
+ // 'PhanTypeNonVarPassByRef',
+ // 'PhanTypeParentConstructorCalled',
+ // 'PhanTypeVoidAssignment',
+ // 'PhanUnanalyzable',
+ // 'PhanUndeclaredClass',
+ // 'PhanUndeclaredClassCatch',
+ // 'PhanUndeclaredClassConstant',
+ // 'PhanUndeclaredClassInstanceof',
+ // 'PhanUndeclaredClassMethod',
+ // 'PhanUndeclaredClassReference',
+ // 'PhanUndeclaredConstant',
+ // 'PhanUndeclaredExtendedClass',
+ // 'PhanUndeclaredFunction',
+ // 'PhanUndeclaredInterface',
+ // 'PhanUndeclaredMethod',
+ // 'PhanUndeclaredProperty',
+ // 'PhanUndeclaredStaticMethod',
+ // 'PhanUndeclaredStaticProperty',
+ // 'PhanUndeclaredTrait',
+ // 'PhanUndeclaredTypeParameter',
+ // 'PhanUndeclaredTypeProperty',
+ // 'PhanUndeclaredVariable',
+ // 'PhanUnreferencedClass',
+ // 'PhanUnreferencedConstant',
+ // 'PhanUnreferencedMethod',
+ // 'PhanUnreferencedProperty',
+ // 'PhanVariableUseClause',
+ ],
+
+ /**
+ * Override to hardcode existence and types of (non-builtin) globals in the global scope.
+ * Class names must be prefixed with '\\'.
+ * (E.g. ['_FOO' => '\\FooClass', 'page' => '\\PageClass', 'userId' => 'int'])
+ */
+ 'globals_type_map' => [
+ 'IP' => 'string',
+ ],
+
+ // Emit issue messages with markdown formatting
+ 'markdown_issue_messages' => false,
+
+ /**
+ * Enable or disable support for generic templated
+ * class types.
+ */
+ 'generic_types_enabled' => true,
+
+ // A list of plugin files to execute
+ 'plugins' => [
+ ],
+];
diff --git a/www/wiki/tests/phan/stubs/README b/www/wiki/tests/phan/stubs/README
new file mode 100644
index 00000000..c458ab58
--- /dev/null
+++ b/www/wiki/tests/phan/stubs/README
@@ -0,0 +1,3 @@
+These stubs describe how code that is not available at analysis time should be
+used. No implementations are necessary, just define the classes and their
+methods and use phpdoc to describe what arguments are allowed.
diff --git a/www/wiki/tests/phan/stubs/hhvm.php b/www/wiki/tests/phan/stubs/hhvm.php
new file mode 100644
index 00000000..364ebdaa
--- /dev/null
+++ b/www/wiki/tests/phan/stubs/hhvm.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * 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
+ */
+
+// phpcs:ignoreFile
+
+/**
+ * @param callable $callback
+ * @param mixed ...$parameters
+ */
+function register_postsend_function( $callback ) {
+}
diff --git a/www/wiki/tests/phan/stubs/mail.php b/www/wiki/tests/phan/stubs/mail.php
new file mode 100644
index 00000000..ba1efb96
--- /dev/null
+++ b/www/wiki/tests/phan/stubs/mail.php
@@ -0,0 +1,89 @@
+<?php
+
+/**
+ * Minimal set of classes necessary for UserMailer to be happy. Types
+ * taken from documentation at pear.php.net.
+ * phpcs:ignoreFile
+ */
+
+class PEAR {
+ /**
+ * @param mixed $data
+ * @return bool
+ */
+ public static function isError( $data ) {
+ }
+}
+
+class PEAR_Error {
+ /**
+ * @return string
+ */
+ public function getMessage() {
+ }
+}
+
+class Mail {
+ /**
+ * @param string $driver
+ * @param array $params
+ * @return self
+ */
+ static public function factory( $driver, array $params = [] ) {
+ }
+
+ /**
+ * @param mixed $recipients
+ * @param array $headers
+ * @param string $body
+ * @return bool|PEAR_Error
+ */
+ public function send( $recipients, array $headers, $body ) {
+ }
+}
+
+class Mail_smtp extends Mail {
+}
+
+class Mail_mime {
+ /**
+ * @param mixed $params
+ */
+ public function __construct( $params = [] ) {
+ }
+
+ /**
+ * @param string $data
+ * @param bool $isfile
+ * @param bool $append
+ * @return bool|PEAR_Error
+ */
+ public function setTXTBody( $data, $isfile = false, $append = false ) {
+ }
+
+ /**
+ * @param string $data
+ * @param bool $isfile
+ * @return bool|PEAR_Error
+ */
+ public function setHTMLBody( $data, $isfile = false ) {
+ }
+
+ /**
+ * @param array|null $parms
+ * @param mixed $filename
+ * @param bool $skip_head
+ * @return string|bool|PEAR_Error
+ */
+ public function get( $params = null, $filename = null, $skip_head = false ) {
+ }
+
+ /**
+ * @param array|null $xtra_headers
+ * @param bool $overwrite
+ * @param bool $skip_content
+ * @return array
+ */
+ public function headers( array $xtra_headers = null, $overwrite = false, $skip_content = false ) {
+ }
+}
diff --git a/www/wiki/tests/phan/stubs/memcached.php b/www/wiki/tests/phan/stubs/memcached.php
new file mode 100644
index 00000000..0f8859d2
--- /dev/null
+++ b/www/wiki/tests/phan/stubs/memcached.php
@@ -0,0 +1,16 @@
+<?php
+
+/**
+ * The phpstorm stubs package includes the Memcached class with two parameters and docs saying
+ * that they are optional. Phan can not detect this and thus throws an error for a usage with
+ * no params. So we have this small stub just for the constructor to allow no params.
+ * @see https://secure.php.net/manual/en/memcached.construct.php
+ * phpcs:ignoreFile
+ */
+
+class Memcached {
+
+ public function __construct() {
+ }
+
+}
diff --git a/www/wiki/tests/phan/stubs/phpunit4.php b/www/wiki/tests/phan/stubs/phpunit4.php
new file mode 100644
index 00000000..e5e88e6b
--- /dev/null
+++ b/www/wiki/tests/phan/stubs/phpunit4.php
@@ -0,0 +1,11 @@
+<?php
+
+/**
+ * Some old classes from PHPUnit 4 that MediaWiki (conditionally) references.
+ *
+ * phpcs:ignoreFile
+ */
+
+class PHPUnit_TextUI_Command {
+
+}
diff --git a/www/wiki/tests/phan/stubs/tideways.php b/www/wiki/tests/phan/stubs/tideways.php
new file mode 100644
index 00000000..34ac735c
--- /dev/null
+++ b/www/wiki/tests/phan/stubs/tideways.php
@@ -0,0 +1,12 @@
+<?php
+
+/**
+ * Minimal set of classes necessary for Xhprof using tideways
+ * phpcs:ignoreFile
+ */
+
+function tideways_enable(){
+}
+
+function tideways_disable(){
+}
diff --git a/www/wiki/tests/phan/stubs/wikidiff.php b/www/wiki/tests/phan/stubs/wikidiff.php
new file mode 100644
index 00000000..02bcd1fb
--- /dev/null
+++ b/www/wiki/tests/phan/stubs/wikidiff.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * 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
+ */
+
+// phpcs:ignoreFile
+
+/**
+ * @param string $text1
+ * @param string $text2
+ * @param int $numContextLines
+ * @param int $movedParagraphDetectionCutoff
+ * @return string
+ */
+function wikidiff2_do_diff( $text1, $text2, $numContextLines, $movedParagraphDetectionCutoff = 0 ) {
+}
+
+/**
+ * @param string $text1
+ * @param string $text2
+ * @param int $numContextLines
+ * @param int $maxMovedLines
+ * @return string
+ */
+function wikidiff2_inline_diff( $text1, $text2, $numContextLines, $maxMovedLines = 25 ) {
+}
diff --git a/www/wiki/tests/phpunit/HamcrestPHPUnitIntegration.php b/www/wiki/tests/phpunit/HamcrestPHPUnitIntegration.php
new file mode 100644
index 00000000..def08ff3
--- /dev/null
+++ b/www/wiki/tests/phpunit/HamcrestPHPUnitIntegration.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Copyright (C) 2018 Kunal Mehta <legoktm@member.fsf.org>
+ *
+ * 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.
+ *
+ */
+
+/**
+ * @since 1.31
+ */
+trait HamcrestPHPUnitIntegration {
+
+ /**
+ * Wrapper around Hamcrest's assertThat, which marks the assertion
+ * for PHPUnit so the test is not marked as risky
+ */
+ public function assertThatHamcrest( /* ... */ ) {
+ call_user_func_array( 'assertThat', func_get_args() );
+ $this->addToAssertionCount( 1 );
+ }
+}
diff --git a/www/wiki/tests/phpunit/LessFileCompilationTest.php b/www/wiki/tests/phpunit/LessFileCompilationTest.php
new file mode 100644
index 00000000..5e1f1a96
--- /dev/null
+++ b/www/wiki/tests/phpunit/LessFileCompilationTest.php
@@ -0,0 +1,54 @@
+<?php
+
+/**
+ * Modelled on Sebastian Bergmann's PHPUnit_Extensions_PhptTestCase class.
+ *
+ * @see https://github.com/sebastianbergmann/phpunit/blob/master/src/Extensions/PhptTestCase.php
+ * @author Sam Smith <samsmith@wikimedia.org>
+ */
+class LessFileCompilationTest extends ResourceLoaderTestCase {
+
+ /**
+ * @var string $file
+ */
+ protected $file;
+
+ /**
+ * @var ResourceLoaderModule The ResourceLoader module that contains
+ * the file
+ */
+ protected $module;
+
+ /**
+ * @param string $file
+ * @param ResourceLoaderModule $module The ResourceLoader module that
+ * contains the file
+ */
+ public function __construct( $file, ResourceLoaderModule $module ) {
+ parent::__construct( 'testLessFileCompilation' );
+
+ $this->file = $file;
+ $this->module = $module;
+ }
+
+ public function testLessFileCompilation() {
+ $thisString = $this->toString();
+ $this->assertTrue(
+ is_string( $this->file ) && is_file( $this->file ) && is_readable( $this->file ),
+ "$thisString must refer to a readable file"
+ );
+
+ $rlContext = $this->getResourceLoaderContext();
+
+ // Bleh
+ $method = new ReflectionMethod( $this->module, 'compileLessFile' );
+ $method->setAccessible( true );
+ $this->assertNotNull( $method->invoke( $this->module, $this->file, $rlContext ) );
+ }
+
+ public function toString() {
+ $moduleName = $this->module->getName();
+
+ return "{$this->file} in the \"{$moduleName}\" module";
+ }
+}
diff --git a/www/wiki/tests/phpunit/Makefile b/www/wiki/tests/phpunit/Makefile
new file mode 100644
index 00000000..d34e1836
--- /dev/null
+++ b/www/wiki/tests/phpunit/Makefile
@@ -0,0 +1,78 @@
+.PHONY: help test phpunit coverage warning destructive parser noparser safe databaseless list-groups
+.DEFAULT: warning
+
+SHELL = /bin/sh
+CONFIG_FILE = ${PWD}/suite.xml
+PHP = php
+PU = ${PHP} phpunit.php --configuration ${CONFIG_FILE} ${FLAGS}
+
+all test: warning
+
+warning:
+ @echo "Run 'make help' to get usage"
+ @echo ""
+ @echo "WARNING -- some tests are DESTRUCTIVE and will alter your wiki."
+ @echo "DO NOT RUN THESE TESTS on a production wiki."
+ @echo ""
+ @echo "Until the default tests are made non-destructive, you can run"
+ @echo "the destructive tests like so:"
+ @echo ""
+ @echo " make destructive"
+ @echo ""
+ @echo "Some tests are expected to be safe, you can run them with"
+ @echo ""
+ @echo " make safe"
+ @echo ""
+ @echo "You are recommended to run the tests with read-only credentials."
+ @echo ""
+ @echo "If you don't have a database running, you can still run"
+ @echo ""
+ @echo " make databaseless"
+ @echo ""
+
+destructive: phpunit
+
+phpunit:
+ ${PU}
+
+tap:
+ ${PU} --tap
+
+coverage:
+ ${PU} --coverage-html ../../docs/code-coverage
+
+parser:
+ ${PU} --group Parser
+noparser:
+ ${PU} --exclude-group Parser,Broken,Stub
+
+safe:
+ ${PU} --exclude-group Broken,Destructive,Stub
+
+databaseless:
+ ${PU} --exclude-group Broken,Destructive,Database,Stub
+
+database:
+ ${PU} --exclude-group Broken,Destructive,Stub --group Database
+
+list-groups:
+ ${PU} --list-groups
+
+help:
+ # Usage:
+ # make <target> [OPTION=value]
+ #
+ # Targets:
+ # phpunit (default) Run all the tests with phpunit
+ # tap Run the tests individually through Test::Harness's prove(1)
+ # help You're looking at it!
+ # coverage Run the tests and generates an HTML code coverage report
+ # You will need the Xdebug PHP extension for the latter.
+ # [no]parser Skip or only run Parser tests
+ #
+ # list-groups List available Tests groups.
+ #
+ # Options:
+ # CONFIG_FILE Path to a PHPUnit configuration file (default: suite.xml)
+ # FLAGS Additional flags to pass to PHPUnit
+ # PHP Path to php
diff --git a/www/wiki/tests/phpunit/MediaWikiCoversValidator.php b/www/wiki/tests/phpunit/MediaWikiCoversValidator.php
new file mode 100644
index 00000000..a79a139c
--- /dev/null
+++ b/www/wiki/tests/phpunit/MediaWikiCoversValidator.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * Copyright (C) 2017 Kunal Mehta <legoktm@member.fsf.org>
+ *
+ * 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.
+ *
+ */
+
+/**
+ * Trait that checks that covers tags are valid, since PHPUnit
+ * won't do it unless you run it with coverage, which is super
+ * slow.
+ *
+ * @since 1.31
+ */
+trait MediaWikiCoversValidator {
+
+ /**
+ * Test that all methods in this class that begin
+ * with "test" have valid covers tags.
+ */
+ public function testValidCovers() {
+ $methods = get_class_methods( $this );
+ $class = get_class( $this );
+ $bad = '';
+ foreach ( $methods as $method ) {
+ if ( strpos( $method, 'test' ) === 0 ) {
+ try {
+ PHPUnit_Util_Test::getLinesToBeCovered( $class, $method );
+ } catch ( PHPUnit_Framework_CodeCoverageException $e ) {
+ $bad .= "$class::$method: {$e->getMessage()}\n";
+ }
+ }
+ }
+
+ $this->assertEquals( '', $bad );
+ }
+}
diff --git a/www/wiki/tests/phpunit/MediaWikiLangTestCase.php b/www/wiki/tests/phpunit/MediaWikiLangTestCase.php
new file mode 100644
index 00000000..fd308b2d
--- /dev/null
+++ b/www/wiki/tests/phpunit/MediaWikiLangTestCase.php
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * Base class that store and restore the Language objects
+ */
+abstract class MediaWikiLangTestCase extends MediaWikiTestCase {
+ protected function setUp() {
+ global $wgLanguageCode, $wgContLang;
+
+ if ( $wgLanguageCode != $wgContLang->getCode() ) {
+ throw new MWException( "Error in MediaWikiLangTestCase::setUp(): " .
+ "\$wgLanguageCode ('$wgLanguageCode') is different from " .
+ "\$wgContLang->getCode() (" . $wgContLang->getCode() . ")" );
+ }
+
+ parent::setUp();
+
+ $this->setUserLang( 'en' );
+ // For mainpage to be 'Main Page'
+ $this->setContentLang( 'en' );
+
+ MessageCache::singleton()->disable();
+ }
+}
diff --git a/www/wiki/tests/phpunit/MediaWikiPHPUnitTestListener.php b/www/wiki/tests/phpunit/MediaWikiPHPUnitTestListener.php
new file mode 100644
index 00000000..0a162a28
--- /dev/null
+++ b/www/wiki/tests/phpunit/MediaWikiPHPUnitTestListener.php
@@ -0,0 +1,130 @@
+<?php
+
+class MediaWikiPHPUnitTestListener
+ extends PHPUnit_TextUI_ResultPrinter implements PHPUnit_Framework_TestListener {
+
+ /**
+ * @var string
+ */
+ protected $logChannel = 'PHPUnitCommand';
+
+ protected function getTestName( PHPUnit_Framework_Test $test ) {
+ $name = get_class( $test );
+
+ if ( $test instanceof PHPUnit\Framework\TestCase ) {
+ $name .= '::' . $test->getName( true );
+ }
+
+ return $name;
+ }
+
+ protected function getErrorName( Exception $exception ) {
+ $name = get_class( $exception );
+ $name = "[$name] " . $exception->getMessage();
+
+ return $name;
+ }
+
+ /**
+ * An error occurred.
+ *
+ * @param PHPUnit_Framework_Test $test
+ * @param Exception $e
+ * @param float $time
+ */
+ public function addError( PHPUnit_Framework_Test $test, Exception $e, $time ) {
+ parent::addError( $test, $e, $time );
+ wfDebugLog(
+ $this->logChannel,
+ 'ERROR in ' . $this->getTestName( $test ) . ': ' . $this->getErrorName( $e )
+ );
+ }
+
+ /**
+ * A failure occurred.
+ *
+ * @param PHPUnit_Framework_Test $test
+ * @param PHPUnit_Framework_AssertionFailedError $e
+ * @param float $time
+ */
+ public function addFailure( PHPUnit_Framework_Test $test,
+ PHPUnit_Framework_AssertionFailedError $e, $time
+ ) {
+ parent::addFailure( $test, $e, $time );
+ wfDebugLog(
+ $this->logChannel,
+ 'FAILURE in ' . $this->getTestName( $test ) . ': ' . $this->getErrorName( $e )
+ );
+ }
+
+ /**
+ * Incomplete test.
+ *
+ * @param PHPUnit_Framework_Test $test
+ * @param Exception $e
+ * @param float $time
+ */
+ public function addIncompleteTest( PHPUnit_Framework_Test $test, Exception $e, $time ) {
+ parent::addIncompleteTest( $test, $e, $time );
+ wfDebugLog(
+ $this->logChannel,
+ 'Incomplete test ' . $this->getTestName( $test ) . ': ' . $this->getErrorName( $e )
+ );
+ }
+
+ /**
+ * Skipped test.
+ *
+ * @param PHPUnit_Framework_Test $test
+ * @param Exception $e
+ * @param float $time
+ */
+ public function addSkippedTest( PHPUnit_Framework_Test $test, Exception $e, $time ) {
+ parent::addSkippedTest( $test, $e, $time );
+ wfDebugLog(
+ $this->logChannel,
+ 'Skipped test ' . $this->getTestName( $test ) . ': ' . $this->getErrorName( $e )
+ );
+ }
+
+ /**
+ * A test suite started.
+ *
+ * @param PHPUnit_Framework_TestSuite $suite
+ */
+ public function startTestSuite( PHPUnit_Framework_TestSuite $suite ) {
+ parent::startTestSuite( $suite );
+ wfDebugLog( $this->logChannel, 'START suite ' . $suite->getName() );
+ }
+
+ /**
+ * A test suite ended.
+ *
+ * @param PHPUnit_Framework_TestSuite $suite
+ */
+ public function endTestSuite( PHPUnit_Framework_TestSuite $suite ) {
+ parent::endTestSuite( $suite );
+ wfDebugLog( $this->logChannel, 'END suite ' . $suite->getName() );
+ }
+
+ /**
+ * A test started.
+ *
+ * @param PHPUnit_Framework_Test $test
+ */
+ public function startTest( PHPUnit_Framework_Test $test ) {
+ parent::startTest( $test );
+ wfDebugLog( $this->logChannel, 'Start test ' . $this->getTestName( $test ) );
+ }
+
+ /**
+ * A test ended.
+ *
+ * @param PHPUnit_Framework_Test $test
+ * @param float $time
+ */
+ public function endTest( PHPUnit_Framework_Test $test, $time ) {
+ parent::endTest( $test, $time );
+ wfDebugLog( $this->logChannel, 'End test ' . $this->getTestName( $test ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/MediaWikiTestCase.php b/www/wiki/tests/phpunit/MediaWikiTestCase.php
new file mode 100644
index 00000000..87ca9181
--- /dev/null
+++ b/www/wiki/tests/phpunit/MediaWikiTestCase.php
@@ -0,0 +1,2043 @@
+<?php
+
+use MediaWiki\Logger\LegacySpi;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Logger\MonologSpi;
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LoggerInterface;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\IMaintainableDatabase;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\LBFactory;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @since 1.18
+ */
+abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ /**
+ * The service locator created by prepareServices(). This service locator will
+ * be restored after each test. Tests that pollute the global service locator
+ * instance should use overrideMwServices() to isolate the test.
+ *
+ * @var MediaWikiServices|null
+ */
+ private static $serviceLocator = null;
+
+ /**
+ * $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 loggers which have been replaced by setLogger()
+ * @var LoggerInterface[]
+ */
+ private $loggers = [];
+
+ /**
+ * 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 service locator, and reset services if it's not done already
+ self::$serviceLocator = self::prepareServices( new GlobalVarConfig() );
+ }
+
+ /**
+ * Convenience method for getting an immutable test user
+ *
+ * @since 1.28
+ *
+ * @param 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[] $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' ] );
+ }
+
+ /**
+ * Prepare service configuration for unit testing.
+ *
+ * This calls MediaWikiServices::resetGlobalInstance() to allow some critical services
+ * to be overridden for testing.
+ *
+ * prepareServices() only needs to be called once, but should be called as early as possible,
+ * before any class has a chance to grab a reference to any of the global services
+ * instances that get discarded by prepareServices(). Only the first call has any effect,
+ * later calls are ignored.
+ *
+ * @note This is called by PHPUnitMaintClass::finalSetup.
+ *
+ * @see MediaWikiServices::resetGlobalInstance()
+ *
+ * @param Config $bootstrapConfig The bootstrap config to use with the new
+ * MediaWikiServices. Only used for the first call to this method.
+ * @return MediaWikiServices
+ */
+ public static function prepareServices( Config $bootstrapConfig ) {
+ static $services = null;
+
+ if ( !$services ) {
+ $services = self::resetGlobalServices( $bootstrapConfig );
+ }
+ return $services;
+ }
+
+ /**
+ * Reset global services, and install testing environment.
+ * This is the testing equivalent of MediaWikiServices::resetGlobalInstance().
+ * This should only be used to set up the testing environment, not when
+ * running unit tests. Use MediaWikiTestCase::overrideMwServices() for that.
+ *
+ * @see MediaWikiServices::resetGlobalInstance()
+ * @see prepareServices()
+ * @see MediaWikiTestCase::overrideMwServices()
+ *
+ * @param Config|null $bootstrapConfig The bootstrap config to use with the new
+ * MediaWikiServices.
+ * @return MediaWikiServices
+ */
+ protected static function resetGlobalServices( Config $bootstrapConfig = null ) {
+ $oldServices = MediaWikiServices::getInstance();
+ $oldConfigFactory = $oldServices->getConfigFactory();
+ $oldLoadBalancerFactory = $oldServices->getDBLoadBalancerFactory();
+
+ $testConfig = self::makeTestConfig( $bootstrapConfig );
+
+ MediaWikiServices::resetGlobalInstance( $testConfig );
+
+ $serviceLocator = MediaWikiServices::getInstance();
+ self::installTestServices(
+ $oldConfigFactory,
+ $oldLoadBalancerFactory,
+ $serviceLocator
+ );
+ return $serviceLocator;
+ }
+
+ /**
+ * 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 = MediaWikiServices::getInstance()->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 $oldConfigFactory
+ * @param LBFactory $oldLoadBalancerFactory
+ * @param MediaWikiServices $newServices
+ *
+ * @throws MWException
+ */
+ private static function installTestServices(
+ ConfigFactory $oldConfigFactory,
+ LBFactory $oldLoadBalancerFactory,
+ 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;
+ }
+ );
+ }
+
+ /**
+ * @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 well known services that typically have state that may interfere with unit tests.
+ * This is a lightweight alternative to resetGlobalServices().
+ *
+ * @note There is no guarantee that no references remain to stale service instances destroyed
+ * by a call to doLightweightServiceReset().
+ *
+ * @throws MWException if called outside of PHPUnit tests.
+ *
+ * @see resetGlobalServices()
+ */
+ private function doLightweightServiceReset() {
+ global $wgRequest;
+
+ JobQueueGroup::destroySingletons();
+ ObjectCache::clear();
+ $services = MediaWikiServices::getInstance();
+ $services->resetServiceForTesting( 'MainObjectStash' );
+ $services->resetServiceForTesting( 'LocalServerObjectCache' );
+ $services->getMainWANObjectCache()->clearProcessCache();
+ FileBackendGroup::destroySingleton();
+
+ // 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 ) {
+ // Reset all caches between tests.
+ $this->doLightweightServiceReset();
+
+ $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' );
+
+ $this->db = wfGetDB( DB_MASTER );
+
+ $this->checkDbIsSupported();
+
+ if ( !self::$dbSetup ) {
+ $this->setupAllTestDBs();
+ $this->addCoreDBData();
+
+ if ( ( $this->db->getType() == 'oracle' || !self::$useTemporaryTables ) && self::$reuseDB ) {
+ $this->resetDB( $this->db, $this->tablesUsed );
+ }
+ }
+
+ // 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->addDBDataOnce();
+ }
+
+ $this->addDBData();
+ $needsResetDB = true;
+ }
+
+ parent::run( $result );
+
+ if ( $needsResetDB ) {
+ $this->resetDB( $this->db, $this->tablesUsed );
+ }
+ }
+
+ /**
+ * @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(), 'MW_PHPUnit_' . 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 );
+ $this->assertTrue( wfMkdirParents( $fileName ) );
+
+ return $fileName;
+ }
+
+ protected function setUp() {
+ parent::setUp();
+ $this->called['setUp'] = true;
+
+ $this->phpErrorLevel = intval( ini_get( 'error_reporting' ) );
+
+ // 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'" );
+ }
+ }
+
+ DeferredUpdates::clearPendingUpdates();
+ ObjectCache::getMainWANInstance()->clearProcessCache();
+
+ // XXX: reset maintenance triggers
+ // Hook into period lag checks which often happen in long-running scripts
+ $services = MediaWikiServices::getInstance();
+ $lbFactory = $services->getDBLoadBalancerFactory();
+ Maintenance::setLBFactoryTriggers( $lbFactory, $services->getMainConfig() );
+
+ ob_start( 'MediaWikiTestCase::wfResetOutputBuffersBarrier' );
+ }
+
+ protected function addTmpFiles( $files ) {
+ $this->tmpFiles = array_merge( $this->tmpFiles, (array)$files );
+ }
+
+ 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 ) );
+ }
+ }
+
+ // Restore mw globals
+ foreach ( $this->mwGlobals as $key => $value ) {
+ $GLOBALS[$key] = $value;
+ }
+ foreach ( $this->mwGlobalsToUnset as $value ) {
+ unset( $GLOBALS[$value] );
+ }
+ $this->mwGlobals = [];
+ $this->mwGlobalsToUnset = [];
+ $this->restoreLoggers();
+
+ if ( self::$serviceLocator && MediaWikiServices::getInstance() !== self::$serviceLocator ) {
+ MediaWikiServices::forceGlobalInstance( self::$serviceLocator );
+ }
+
+ // 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 we did not yet override the service locator, so so now.
+ if ( MediaWikiServices::getInstance() === self::$serviceLocator ) {
+ $this->overrideMwServices();
+ }
+
+ MediaWikiServices::getInstance()->disableService( $name );
+ MediaWikiServices::getInstance()->redefineService(
+ $name,
+ function () use ( $object ) {
+ return $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 $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 ];
+ }
+
+ $this->stashMwGlobals( array_keys( $pairs ) );
+
+ foreach ( $pairs as $key => $value ) {
+ $GLOBALS[$key] = $value;
+ }
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Stashes the global, will be restored in tearDown()
+ *
+ * Individual test functions may override globals through the setMwGlobals() function
+ * or directly. When directly overriding globals their keys should first be passed to this
+ * method in setUp to avoid breaking global state for other tests
+ *
+ * That way all other tests are executed with the same settings (instead of using the
+ * unreliable local settings for most tests and fix it only for some tests).
+ *
+ * @param array|string $globalKeys Key to the global variable, or an array of keys.
+ *
+ * @note To allow changes to global variables to take effect on global service instances,
+ * call overrideMwServices().
+ *
+ * @since 1.23
+ */
+ protected function stashMwGlobals( $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 $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 ( !$configOverrides ) {
+ $configOverrides = new HashConfig();
+ }
+
+ $oldInstance = MediaWikiServices::getInstance();
+ $oldConfigFactory = $oldInstance->getConfigFactory();
+ $oldLoadBalancerFactory = $oldInstance->getDBLoadBalancerFactory();
+
+ $testConfig = self::makeTestConfig( null, $configOverrides );
+ $newInstance = new MediaWikiServices( $testConfig );
+
+ // Load the default wiring from the specified files.
+ // NOTE: this logic mirrors the logic in MediaWikiServices::newInstance.
+ $wiringFiles = $testConfig->get( 'ServiceWiringFiles' );
+ $newInstance->loadWiringFiles( $wiringFiles );
+
+ // Provide a traditional hook point to allow extensions to configure services.
+ Hooks::run( 'MediaWikiServices', [ $newInstance ] );
+
+ foreach ( $services as $name => $callback ) {
+ $newInstance->redefineService( $name, $callback );
+ }
+
+ self::installTestServices(
+ $oldConfigFactory,
+ $oldLoadBalancerFactory,
+ $newInstance
+ );
+ MediaWikiServices::forceGlobalInstance( $newInstance );
+
+ return $newInstance;
+ }
+
+ /**
+ * @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 ) {
+ $langCode = $lang->getCode();
+ $langObj = $lang;
+ } else {
+ $langCode = $lang;
+ $langObj = Language::factory( $langCode );
+ }
+ $this->setMwGlobals( [
+ 'wgLanguageCode' => $langCode,
+ 'wgContLang' => $langObj,
+ ] );
+ }
+
+ /**
+ * 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 $newValue
+ */
+ public function setGroupPermissions( $newPerms, $newKey = null, $newValue = null ) {
+ global $wgGroupPermissions;
+
+ $this->stashMwGlobals( 'wgGroupPermissions' );
+
+ if ( is_string( $newPerms ) ) {
+ $newPerms = [ $newPerms => [ $newKey => $newValue ] ];
+ }
+
+ foreach ( $newPerms as $group => $permissions ) {
+ foreach ( $permissions as $key => $value ) {
+ $wgGroupPermissions[$group][$key] = $value;
+ }
+ }
+ }
+
+ /**
+ * 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] = isset( $singletons['loggers'][$channel] )
+ ? $singletons['loggers'][$channel] : null;
+ }
+ $singletons['loggers'][$channel] = $logger;
+ } elseif ( $provider instanceof LegacySpi ) {
+ if ( !isset( $this->loggers[$channel] ) ) {
+ $this->loggers[$channel] = isset( $singletons[$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 ) {
+ if ( $logger === null ) {
+ unset( $singletons[$channel] );
+ } else {
+ $singletons[$channel] = $logger;
+ }
+ }
+ }
+ $wrappedProvider->singletons = $singletons;
+ $this->loggers = [];
+ }
+
+ /**
+ * @return string
+ * @since 1.18
+ */
+ public function dbPrefix() {
+ return $this->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
+ if ( $this->tablesUsed ) {
+ return true;
+ }
+
+ // 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 );
+ if ( preg_match( '/@group +Database/im', $rc->getDocComment() ) ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * 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 $namespace Namespace id (name cannot already contain namespace)
+ * @return array Title object and page id
+ */
+ protected function insertPage(
+ $pageName,
+ $text = 'Sample page for unit test.',
+ $namespace = null
+ ) {
+ if ( is_string( $pageName ) ) {
+ $title = Title::newFromText( $pageName, $namespace );
+ } else {
+ $title = $pageName;
+ }
+
+ $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() {
+ }
+
+ private 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 unneccessary.
+ 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).
+ *
+ * @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();
+ }
+
+ CloneDatabase::changePrefix( self::$oldTablePrefix );
+
+ self::$oldTablePrefix = false;
+ self::$dbSetup = false;
+ }
+
+ /**
+ * Setups a database with 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.
+ *
+ * Clones all tables in the given database (whatever database that connection has
+ * open), to versions with the test prefix.
+ *
+ * @param IMaintainableDatabase $db Database to use
+ * @param string $prefix Prefix to use for test tables
+ * @return bool True if tables were cloned, false if only the prefix was changed
+ */
+ protected static function setupDatabaseWithTestPrefix( IMaintainableDatabase $db, $prefix ) {
+ $tablesCloned = self::listTables( $db );
+ $dbClone = new CloneDatabase( $db, $tablesCloned, $prefix );
+ $dbClone->useTemporaryTables( self::$useTemporaryTables );
+
+ $db->_originalTablePrefix = $db->tablePrefix();
+
+ if ( ( $db->getType() == 'oracle' || !self::$useTemporaryTables ) && self::$reuseDB ) {
+ CloneDatabase::changePrefix( $prefix );
+
+ return false;
+ } else {
+ $dbClone->cloneTableStructure();
+ 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 );
+ }
+ }
+
+ /**
+ * 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 Database $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( Database $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;' );
+ }
+
+ Hooks::run( 'UnitTestsAfterDatabaseSetup', [ $db, $prefix ] );
+ }
+
+ /**
+ * Clones the External Store database(s) for testing
+ *
+ * @param string $testPrefix Prefix for test tables
+ */
+ protected static function setupExternalStoreTestDBs( $testPrefix ) {
+ $connections = self::getExternalStoreDatabaseConnections();
+ foreach ( $connections as $dbw ) {
+ // Hack: cloneTableStructure sets $wgDBprefix to the unit test
+ // prefix,. Even though listTables now uses tablePrefix, that
+ // itself is populated from $wgDBprefix by default.
+
+ // We have to set it back, or we won't find the original 'blobs'
+ // table to copy.
+
+ $dbw->tablePrefix( self::$oldTablePrefix );
+ 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.
+ */
+ private 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.
+ *
+ * @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 dpecified 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 );
+ }
+ }
+
+ /**
+ * 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 = isset( $db->_schemaOverrides ) ? $db->_schemaOverrides
+ : self::$schemaOverrideDefaults;
+
+ if ( $oldOverrides['alter'] || $oldOverrides['create'] || $oldOverrides['drop'] ) {
+ $this->undoSchemaOverrides( $db, $oldOverrides );
+ }
+
+ // 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__ );
+
+ if ( $tbl === 'page' ) {
+ // Forget about the pages since they don't
+ // exist in the DB.
+ LinkCache::singleton()->clear();
+ }
+ }
+ }
+
+ /**
+ * Lists all tables in the live database schema.
+ *
+ * @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__ );
+ return $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',
+ 'revision_actor_temp', 'comment' ];
+ $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();
+ }
+ if ( array_intersect( $tablesUsed, $pageTables ) ) {
+ $tablesUsed = array_unique( array_merge( $tablesUsed, $pageTables ) );
+ }
+
+ $truncate = in_array( $db->getType(), [ 'oracle', 'mysql' ] );
+ foreach ( $tablesUsed as $tbl ) {
+ // TODO: reset interwiki table to its original content.
+ if ( $tbl == 'interwiki' ) {
+ continue;
+ }
+
+ if ( !$db->tableExists( $tbl ) ) {
+ continue;
+ }
+
+ if ( $truncate ) {
+ $db->query( 'TRUNCATE TABLE ' . $db->tableName( $tbl ), __METHOD__ );
+ } else {
+ $db->delete( $tbl, '*', __METHOD__ );
+ }
+
+ if ( in_array( $db->getType(), [ 'postgres', 'sqlite' ], true ) ) {
+ // Reset the table's sequence too.
+ $db->resetSequenceForTable( $tbl, __METHOD__ );
+ }
+
+ if ( $tbl === 'page' ) {
+ // Forget about the pages since they don't
+ // exist in the DB.
+ LinkCache::singleton()->clear();
+ }
+ }
+
+ if ( array_intersect( $tablesUsed, $coreDBDataTables ) ) {
+ // Re-add core DB data that was deleted
+ $this->addCoreDBData();
+ }
+ }
+ }
+
+ 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;
+ }
+
+ /**
+ * @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 ) {
+ if ( isset( PHPUnitMaintClass::$additionalOptions[$offset] ) ) {
+ return PHPUnitMaintClass::$additionalOptions[$offset];
+ }
+
+ return null;
+ }
+
+ /**
+ * @since 1.18
+ * @param string $offset
+ * @param mixed $value
+ */
+ public function setCliArg( $offset, $value ) {
+ PHPUnitMaintClass::$additionalOptions[$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 ) ? 1 : -1;
+ }
+ );
+ }
+
+ /**
+ * 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
+ $namespaces = array_unique( array_merge(
+ MWNamespace::getContentNamespaces(),
+ [ NS_MAIN, NS_HELP, NS_PROJECT ], // prefer these
+ MWNamespace::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 ) {
+ return MWNamespace::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;
+ }
+
+ /**
+ * 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 = true,
+ $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 );
+ }
+}
diff --git a/www/wiki/tests/phpunit/PHPUnit4And6Compat.php b/www/wiki/tests/phpunit/PHPUnit4And6Compat.php
new file mode 100644
index 00000000..672ab4a4
--- /dev/null
+++ b/www/wiki/tests/phpunit/PHPUnit4And6Compat.php
@@ -0,0 +1,121 @@
+<?php
+/**
+ * Copyright (C) 2018 Kunal Mehta <legoktm@member.fsf.org>
+ *
+ * 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.
+ *
+ */
+
+/**
+ * @since 1.31
+ */
+trait PHPUnit4And6Compat {
+ /**
+ * @see PHPUnit_Framework_TestCase::setExpectedException
+ *
+ * This function was renamed to expectException() in PHPUnit 6, so this
+ * is a temporary backwards-compatibility layer while we transition.
+ */
+ public function setExpectedException( $name, $message = '', $code = null ) {
+ if ( is_callable( [ $this, 'expectException' ] ) ) {
+ if ( $name !== null ) {
+ $this->expectException( $name );
+ }
+ if ( $message !== '' ) {
+ $this->expectExceptionMessage( $message );
+ }
+ if ( $code !== null ) {
+ $this->expectExceptionCode( $code );
+ }
+ } else {
+ parent::setExpectedException( $name, $message, $code );
+ }
+ }
+
+ /**
+ * @see PHPUnit_Framework_TestCase::getMock
+ *
+ * @return PHPUnit_Framework_MockObject_MockObject
+ */
+ public function getMock( $originalClassName, $methods = [], array $arguments = [],
+ $mockClassName = '', $callOriginalConstructor = true, $callOriginalClone = true,
+ $callAutoload = true, $cloneArguments = false, $callOriginalMethods = false,
+ $proxyTarget = null
+ ) {
+ if ( is_callable( 'parent::getMock' ) ) {
+ return parent::getMock(
+ $originalClassName, $methods, $arguments, $mockClassName,
+ $callOriginalConstructor, $callOriginalClone, $callAutoload,
+ $cloneArguments, $callOriginalMethods, $proxyTarget
+ );
+ } else {
+ $builder = $this->getMockBuilder( $originalClassName )
+ ->setMethods( $methods )
+ ->setConstructorArgs( $arguments )
+ ->setMockClassName( $mockClassName )
+ ->setProxyTarget( $proxyTarget );
+ if ( $callOriginalConstructor ) {
+ $builder->enableOriginalConstructor();
+ } else {
+ $builder->disableOriginalConstructor();
+ }
+ if ( $callOriginalClone ) {
+ $builder->enableOriginalClone();
+ } else {
+ $builder->disableOriginalClone();
+ }
+ if ( $callAutoload ) {
+ $builder->enableAutoload();
+ } else {
+ $builder->disableAutoload();
+ }
+ if ( $cloneArguments ) {
+ $builder->enableArgumentCloning();
+ } else {
+ $builder->disableArgumentCloning();
+ }
+ if ( $callOriginalMethods ) {
+ $builder->enableProxyingToOriginalMethods();
+ } else {
+ $builder->disableProxyingToOriginalMethods();
+ }
+
+ return $builder->getMock();
+ }
+ }
+
+ /**
+ * Return a test double for the specified class. This
+ * is a forward port of the createMock function that
+ * was introduced in PHPUnit 5.4.
+ *
+ * @param string $originalClassName
+ * @return PHPUnit_Framework_MockObject_MockObject
+ * @throws Exception
+ */
+ public function createMock( $originalClassName ) {
+ if ( is_callable( 'parent::createMock' ) ) {
+ return parent::createMock( $originalClassName );
+ }
+ // Compat for PHPUnit <= 5.4
+ return $this->getMockBuilder( $originalClassName )
+ ->disableOriginalConstructor()
+ ->disableOriginalClone()
+ ->disableArgumentCloning()
+ // New in phpunit-mock-objects 3.2 (phpunit 5.4.0)
+ // ->disallowMockingUnknownTypes()
+ ->getMock();
+ }
+}
diff --git a/www/wiki/tests/phpunit/README b/www/wiki/tests/phpunit/README
new file mode 100644
index 00000000..f555812d
--- /dev/null
+++ b/www/wiki/tests/phpunit/README
@@ -0,0 +1,50 @@
+== MediaWiki PHPUnit Tests ==
+
+The unit tests for MediaWiki are implemented using the PHPUnit testing
+framework and require PHPUnit to run.
+
+
+=== WARNING ===
+
+Some of the unit tests are DESTRUCTIVE and WILL ALTER YOUR WIKI'S CONTENTS.
+
+DO NOT RUN THESE TESTS ON A PRODUCTION SYSTEM OR ON ANY SYSTEM WHERE YOU NEED
+TO RETAIN YOUR DATA.
+
+
+== Installation ==
+
+If you used composer to install MediaWiki's dependencies PHPUnit will already be available, unless
+you explicitly specified the --no-dev flag during the install. In this case just run "composer update".
+
+Otherwise follow the installation instructions in the
+PHPUnit Manual at:
+
+ https://phpunit.de/manual/current/en/installation.html
+
+
+== Running tests ==
+
+The tests are run from your operating system's command line.
+
+Ensure that you are in the tests/phpunit directory of your MediaWiki
+installation.
+
+
+On Unix-like operating systems, the tests runs are controlled with a makefile.
+Run command:
+
+ make help
+
+for a full list of options for running tests.
+
+
+On Windows-family operating systems, run the 'run-tests.bat' batch file.
+
+
+=== Writing tests ===
+
+A guide to writing PHP unit tests for MediaWiki can be found at:
+
+ https://www.mediawiki.org/wiki/Manual:PHP_unit_testing
+
diff --git a/www/wiki/tests/phpunit/ResourceLoaderTestCase.php b/www/wiki/tests/phpunit/ResourceLoaderTestCase.php
new file mode 100644
index 00000000..d5c14a25
--- /dev/null
+++ b/www/wiki/tests/phpunit/ResourceLoaderTestCase.php
@@ -0,0 +1,185 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+abstract class ResourceLoaderTestCase extends MediaWikiTestCase {
+ // Version hash for a blank file module.
+ // Result of ResourceLoader::makeHash(), ResourceLoaderTestModule
+ // and ResourceLoaderFileModule::getDefinitionSummary().
+ const BLANK_VERSION = '09p30q0';
+
+ /**
+ * @param array|string $options Language code or options array
+ * - string 'lang' Language code
+ * - string 'dir' Language direction (ltr or rtl)
+ * - string 'modules' Pipe-separated list of module names
+ * - string|null 'only' "scripts" (unwrapped script), "styles" (stylesheet), or null
+ * (mw.loader.implement).
+ * @param ResourceLoader|null $rl
+ * @return ResourceLoaderContext
+ */
+ protected function getResourceLoaderContext( $options = [], ResourceLoader $rl = null ) {
+ if ( is_string( $options ) ) {
+ // Back-compat for extension tests
+ $options = [ 'lang' => $options ];
+ }
+ $options += [
+ 'lang' => 'en',
+ 'dir' => 'ltr',
+ 'skin' => 'vector',
+ 'modules' => 'startup',
+ 'only' => 'scripts',
+ ];
+ $resourceLoader = $rl ?: new ResourceLoader();
+ $request = new FauxRequest( [
+ 'lang' => $options['lang'],
+ 'modules' => $options['modules'],
+ 'only' => $options['only'],
+ 'skin' => $options['skin'],
+ 'target' => 'phpunit',
+ ] );
+ $ctx = $this->getMockBuilder( ResourceLoaderContext::class )
+ ->setConstructorArgs( [ $resourceLoader, $request ] )
+ ->setMethods( [ 'getDirection' ] )
+ ->getMock();
+ $ctx->method( 'getDirection' )->willReturn( $options['dir'] );
+ return $ctx;
+ }
+
+ public static function getSettings() {
+ return [
+ // For ResourceLoader::inDebugMode since it doesn't have context
+ 'ResourceLoaderDebug' => true,
+
+ // Avoid influence from wgInvalidateCacheOnLocalSettingsChange
+ 'CacheEpoch' => '20140101000000',
+
+ // For ResourceLoader::__construct()
+ 'ResourceLoaderSources' => [],
+
+ // For wfScript()
+ 'ScriptPath' => '/w',
+ 'Script' => '/w/index.php',
+ 'LoadScript' => '/w/load.php',
+ ];
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ ResourceLoader::clearCache();
+
+ $globals = [];
+ foreach ( self::getSettings() as $key => $value ) {
+ $globals['wg' . $key] = $value;
+ }
+ $this->setMwGlobals( $globals );
+ }
+}
+
+/* Stubs */
+
+class ResourceLoaderTestModule extends ResourceLoaderModule {
+ protected $messages = [];
+ protected $dependencies = [];
+ protected $group = null;
+ protected $source = 'local';
+ protected $script = '';
+ protected $styles = '';
+ protected $skipFunction = null;
+ protected $isRaw = false;
+ protected $isKnownEmpty = false;
+ protected $type = ResourceLoaderModule::LOAD_GENERAL;
+ protected $targets = [ 'phpunit' ];
+ protected $shouldEmbed = null;
+
+ public function __construct( $options = [] ) {
+ foreach ( $options as $key => $value ) {
+ $this->$key = $value;
+ }
+ }
+
+ public function getScript( ResourceLoaderContext $context ) {
+ return $this->validateScriptFile( 'input', $this->script );
+ }
+
+ public function getStyles( ResourceLoaderContext $context ) {
+ return [ '' => $this->styles ];
+ }
+
+ public function getMessages() {
+ return $this->messages;
+ }
+
+ public function getDependencies( ResourceLoaderContext $context = null ) {
+ return $this->dependencies;
+ }
+
+ public function getGroup() {
+ return $this->group;
+ }
+
+ public function getSource() {
+ return $this->source;
+ }
+
+ public function getType() {
+ return $this->type;
+ }
+
+ public function getSkipFunction() {
+ return $this->skipFunction;
+ }
+
+ public function isRaw() {
+ return $this->isRaw;
+ }
+ public function isKnownEmpty( ResourceLoaderContext $context ) {
+ return $this->isKnownEmpty;
+ }
+
+ public function shouldEmbedModule( ResourceLoaderContext $context ) {
+ return $this->shouldEmbed !== null ? $this->shouldEmbed : parent::shouldEmbedModule( $context );
+ }
+
+ public function enableModuleContentVersion() {
+ return true;
+ }
+}
+
+class ResourceLoaderFileTestModule extends ResourceLoaderFileModule {
+ protected $lessVars = [];
+
+ public function __construct( $options = [], $test = [] ) {
+ parent::__construct( $options );
+
+ foreach ( $test as $key => $value ) {
+ $this->$key = $value;
+ }
+ }
+
+ public function getLessVars( ResourceLoaderContext $context ) {
+ return $this->lessVars;
+ }
+}
+
+class ResourceLoaderFileModuleTestModule extends ResourceLoaderFileModule {
+}
+
+class EmptyResourceLoader extends ResourceLoader {
+ // TODO: This won't be needed once ResourceLoader is empty by default
+ // and default registrations are done from ServiceWiring instead.
+ public function __construct( Config $config = null, LoggerInterface $logger = null ) {
+ $this->setLogger( $logger ?: new NullLogger() );
+ $this->config = $config ?: MediaWikiServices::getInstance()->getMainConfig();
+ // Source "local" is required by StartupModule
+ $this->addSource( 'local', $this->config->get( 'LoadScript' ) );
+ $this->setMessageBlobStore( new MessageBlobStore( $this, $this->getLogger() ) );
+ }
+
+ public function getErrors() {
+ return $this->errors;
+ }
+}
diff --git a/www/wiki/tests/phpunit/TODO b/www/wiki/tests/phpunit/TODO
new file mode 100644
index 00000000..cd9b9e2d
--- /dev/null
+++ b/www/wiki/tests/phpunit/TODO
@@ -0,0 +1,20 @@
+== Things To Do ==
+
+* Most of the tests are named poorly;
+ naming should describe a use case in story-like language,
+ not simply identify the unit under test.
+ An example would be the difference between "testCalculate"
+ and "testAddingIntegersTogetherWorks".
+
+* Many of the tests make multiple assertions, and are thus not unitary tests.
+ By using data-providers and more use-case oriented test selection
+ nearly all of these cases can be easily resolved.
+
+* Some of the test files are either incorrectly named or in the wrong folder.
+ Tests should be organized in a mirrored structure to the source they are testing,
+ and named the same, with the exception of the word "Test" at the end.
+
+* Shared set-up code or base classes are present,
+ but usually named improperly or appear to be poorly factored.
+ Support code should share as much of the same naming as the code it's supporting,
+ and test and test-case depenencies should be considered to resolve other shared needs.
diff --git a/www/wiki/tests/phpunit/autoload.ide.php b/www/wiki/tests/phpunit/autoload.ide.php
new file mode 100644
index 00000000..4b0b1873
--- /dev/null
+++ b/www/wiki/tests/phpunit/autoload.ide.php
@@ -0,0 +1,109 @@
+<?php
+
+/**
+ * This file is PHPUnit autoload file for PhpStorm IDE and other JetBrains IDEs.
+ *
+ * This file should be set in `Languages and frameworks > PHP > PhpUnit`
+ * select `Use Composer autoloader` and set `Path to script` to `<path to this file>`.
+ * After that, tests can be run in PhpStorm using Right-click > Run or `Ctrl + Shift + F10`.
+ * Also, tests can be run with debugger very easily.
+ *
+ * This file basically does almost the same thing as `tests/phpunit/phpunit.php`, except that all
+ * code is going to be executed inside some function, so some hacks needed to make old code to be
+ * executed as if it was executed on top of the execution stack.
+ *
+ * PS: Mostly it is copy-paste from `phpunit.php` and `doMaintenance.php`.
+ *
+ * @file
+ */
+
+// Set a flag which can be used to detect when other scripts have been entered
+// through this entry point or not.
+use MediaWiki\MediaWikiServices;
+
+global $argv;
+$argv[1] = '--wiki';
+$argv[2] = getenv( 'WIKI_NAME' ) ?: 'wiki';
+
+require_once __DIR__ . "/phpunit.php";
+
+// Get an object to start us off
+/** @var Maintenance $maintenance */
+$maintenance = new PHPUnitMaintClass();
+
+// Basic sanity checks and such
+$maintenance->setup();
+
+// We used to call this variable $self, but it was moved
+// to $maintenance->mSelf. Keep that here for b/c
+$self = $maintenance->getName();
+global $IP;
+# Get profiler configuraton
+$wgProfiler = [];
+if ( file_exists( "$IP/StartProfiler.php" ) ) {
+ require "$IP/StartProfiler.php";
+}
+# Start the autoloader, so that extensions can derive classes from core files
+require_once "$IP/includes/AutoLoader.php";
+
+$requireOnceGlobalsScope = function ( $file ) use ( $self ) {
+ foreach ( array_keys( $GLOBALS ) as $varName ) {
+ eval( sprintf( 'global $%s;', $varName ) );
+ }
+
+ require_once $file;
+
+ unset( $file );
+ $definedVars = get_defined_vars();
+ foreach ( $definedVars as $varName => $value ) {
+ eval( sprintf( 'global $%s; $%s = $value;', $varName, $varName ) );
+ }
+};
+
+// Some other requires
+$requireOnceGlobalsScope( "$IP/includes/Defines.php" );
+$requireOnceGlobalsScope( "$IP/includes/DefaultSettings.php" );
+$requireOnceGlobalsScope( "$IP/includes/GlobalFunctions.php" );
+
+foreach ( array_keys( $GLOBALS ) as $varName ) {
+ eval( sprintf( 'global $%s;', $varName ) );
+}
+
+# Load composer's autoloader if present
+if ( is_readable( "$IP/vendor/autoload.php" ) ) {
+ require_once "$IP/vendor/autoload.php";
+}
+
+if ( defined( 'MW_CONFIG_CALLBACK' ) ) {
+ # Use a callback function to configure MediaWiki
+ call_user_func( MW_CONFIG_CALLBACK );
+} else {
+ // Require the configuration (probably LocalSettings.php)
+ require $maintenance->loadSettings();
+}
+
+if ( $maintenance->getDbType() === Maintenance::DB_NONE ) {
+ if (
+ $wgLocalisationCacheConf['storeClass'] === false
+ && (
+ $wgLocalisationCacheConf['store'] == 'db'
+ || ( $wgLocalisationCacheConf['store'] == 'detect' && !$wgCacheDirectory )
+ )
+ ) {
+ $wgLocalisationCacheConf['storeClass'] = LCStoreNull::class;
+ }
+}
+
+$maintenance->finalSetup();
+// Some last includes
+$requireOnceGlobalsScope( "$IP/includes/Setup.php" );
+
+// Initialize main config instance
+$maintenance->setConfig( MediaWikiServices::getInstance()->getMainConfig() );
+
+// Sanity-check required extensions are installed
+$maintenance->checkRequiredExtensions();
+
+// A good time when no DBs have writes pending is around lag checks.
+// This avoids having long running scripts just OOM and lose all the updates.
+$maintenance->setAgentAndTriggers();
diff --git a/www/wiki/tests/phpunit/bootstrap.php b/www/wiki/tests/phpunit/bootstrap.php
new file mode 100644
index 00000000..a5c8ef61
--- /dev/null
+++ b/www/wiki/tests/phpunit/bootstrap.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * Bootstrapping for MediaWiki PHPUnit tests
+ * 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";
+}
+
+class MediaWikiPHPUnitBootstrap {
+ public function __destruct() {
+ // Return to real wiki db, so profiling data is preserved
+ MediaWikiTestCase::teardownTestDB();
+
+ // Log profiling data, e.g. in the database or UDP
+ wfLogProfilingData();
+ }
+
+}
+
+// This will be destructed after all tests have been run
+$mediawikiPHPUnitBootstrap = new MediaWikiPHPUnitBootstrap();
diff --git a/www/wiki/tests/phpunit/data/autoloader/TestAutoloadedCamlClass.php b/www/wiki/tests/phpunit/data/autoloader/TestAutoloadedCamlClass.php
new file mode 100644
index 00000000..6dfce7a1
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/autoloader/TestAutoloadedCamlClass.php
@@ -0,0 +1,4 @@
+<?php
+
+class TestAutoloadedCamlClass {
+}
diff --git a/www/wiki/tests/phpunit/data/autoloader/TestAutoloadedClass.php b/www/wiki/tests/phpunit/data/autoloader/TestAutoloadedClass.php
new file mode 100644
index 00000000..9ceedf6b
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/autoloader/TestAutoloadedClass.php
@@ -0,0 +1,4 @@
+<?php
+
+class TestAutoloadedClass {
+}
diff --git a/www/wiki/tests/phpunit/data/autoloader/TestAutoloadedLocalClass.php b/www/wiki/tests/phpunit/data/autoloader/TestAutoloadedLocalClass.php
new file mode 100644
index 00000000..1b397cd6
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/autoloader/TestAutoloadedLocalClass.php
@@ -0,0 +1,4 @@
+<?php
+
+class TestAutoloadedLocalClass {
+}
diff --git a/www/wiki/tests/phpunit/data/autoloader/TestAutoloadedSerializedClass.php b/www/wiki/tests/phpunit/data/autoloader/TestAutoloadedSerializedClass.php
new file mode 100644
index 00000000..80b9d58d
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/autoloader/TestAutoloadedSerializedClass.php
@@ -0,0 +1,4 @@
+<?php
+
+class TestAutoloadedSerializedClass {
+}
diff --git a/www/wiki/tests/phpunit/data/categoriesrdf/categoriesRdf-out.nt b/www/wiki/tests/phpunit/data/categoriesrdf/categoriesRdf-out.nt
new file mode 100644
index 00000000..bbb37870
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/categoriesrdf/categoriesRdf-out.nt
@@ -0,0 +1,23 @@
+<http://acme.test/wiki/Special:CategoryDump> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://schema.org/Dataset> .
+<http://acme.test/wiki/Special:CategoryDump> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/2002/07/owl#Ontology> .
+<http://acme.test/wiki/Special:CategoryDump> <http://creativecommons.org/ns#license> <https://creativecommons.org/licenses/by-sa/3.0/> .
+<http://acme.test/wiki/Special:CategoryDump> <http://schema.org/softwareVersion> "1.1" .
+<http://acme.test/wiki/Special:CategoryDump> <http://schema.org/dateModified> "{DATE}"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
+<http://acme.test/wiki/Special:CategoryDump> <http://schema.org/isPartOf> <http://acme.test/> .
+<http://acme.test/wiki/Special:CategoryDump> <http://www.w3.org/2002/07/owl#imports> <https://www.mediawiki.org/ontology/ontology.owl> .
+<http://acme.test/wiki/Category:Category_One> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://www.mediawiki.org/ontology#Category> .
+<http://acme.test/wiki/Category:Category_One> <http://www.w3.org/2000/01/rdf-schema#label> "Category One" .
+<http://acme.test/wiki/Category:Category_One> <https://www.mediawiki.org/ontology#pages> "7"^^<http://www.w3.org/2001/XMLSchema#integer> .
+<http://acme.test/wiki/Category:Category_One> <https://www.mediawiki.org/ontology#subcategories> "10"^^<http://www.w3.org/2001/XMLSchema#integer> .
+<http://acme.test/wiki/Category:2_Category_Two> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://www.mediawiki.org/ontology#Category> .
+<http://acme.test/wiki/Category:2_Category_Two> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://www.mediawiki.org/ontology#HiddenCategory> .
+<http://acme.test/wiki/Category:2_Category_Two> <http://www.w3.org/2000/01/rdf-schema#label> "2 Category Two" .
+<http://acme.test/wiki/Category:2_Category_Two> <https://www.mediawiki.org/ontology#pages> "17"^^<http://www.w3.org/2001/XMLSchema#integer> .
+<http://acme.test/wiki/Category:2_Category_Two> <https://www.mediawiki.org/ontology#subcategories> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
+<http://acme.test/wiki/Category:Category_One> <https://www.mediawiki.org/ontology#isInCategory> <http://acme.test/wiki/Category:Parent_of_1> .
+<http://acme.test/wiki/Category:2_Category_Two> <https://www.mediawiki.org/ontology#isInCategory> <http://acme.test/wiki/Category:Parent_of_2> .
+<http://acme.test/wiki/Category:%D0%A2%D1%80%D0%B5%D1%82%D1%8C%D1%8F_%D0%BA%D0%B0%D1%82%D0%B5%D0%B3%D0%BE%D1%80%D0%B8%D1%8F> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://www.mediawiki.org/ontology#Category> .
+<http://acme.test/wiki/Category:%D0%A2%D1%80%D0%B5%D1%82%D1%8C%D1%8F_%D0%BA%D0%B0%D1%82%D0%B5%D0%B3%D0%BE%D1%80%D0%B8%D1%8F> <http://www.w3.org/2000/01/rdf-schema#label> "\u0422\u0440\u0435\u0442\u044C\u044F \u043A\u0430\u0442\u0435\u0433\u043E\u0440\u0438\u044F" .
+<http://acme.test/wiki/Category:%D0%A2%D1%80%D0%B5%D1%82%D1%8C%D1%8F_%D0%BA%D0%B0%D1%82%D0%B5%D0%B3%D0%BE%D1%80%D0%B8%D1%8F> <https://www.mediawiki.org/ontology#pages> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
+<http://acme.test/wiki/Category:%D0%A2%D1%80%D0%B5%D1%82%D1%8C%D1%8F_%D0%BA%D0%B0%D1%82%D0%B5%D0%B3%D0%BE%D1%80%D0%B8%D1%8F> <https://www.mediawiki.org/ontology#subcategories> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
+<http://acme.test/wiki/Category:%D0%A2%D1%80%D0%B5%D1%82%D1%8C%D1%8F_%D0%BA%D0%B0%D1%82%D0%B5%D0%B3%D0%BE%D1%80%D0%B8%D1%8F> <https://www.mediawiki.org/ontology#isInCategory> <http://acme.test/wiki/Category:Parent_of_3> .
diff --git a/www/wiki/tests/phpunit/data/composer/composer.json b/www/wiki/tests/phpunit/data/composer/composer.json
new file mode 100644
index 00000000..9b902ae8
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/composer/composer.json
@@ -0,0 +1,48 @@
+{
+ "name": "mediawiki/core",
+ "description": "Free software wiki application developed by the Wikimedia Foundation and others",
+ "keywords": ["mediawiki", "wiki"],
+ "homepage": "https://www.mediawiki.org/",
+ "authors": [
+ {
+ "name": "MediaWiki Community",
+ "homepage": "https://www.mediawiki.org/wiki/Special:Version/Credits"
+ }
+ ],
+ "license": "GPL-2.0-only",
+ "support": {
+ "issues": "https://bugzilla.wikimedia.org/",
+ "irc": "irc://irc.freenode.net/mediawiki",
+ "wiki": "https://www.mediawiki.org/"
+ },
+ "require": {
+ "leafo/lessphp": "0.5.0",
+ "php": ">=5.3.3",
+ "psr/log": "1.0.0",
+ "cssjanus/cssjanus": "1.1.1",
+ "cdb/cdb": "1.0.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "*"
+ },
+ "suggest": {
+ "ext-fileinfo": "*",
+ "ext-mbstring": "*",
+ "ext-wikidiff2": "*",
+ "ext-apc": "*",
+ "monolog/monolog": "*"
+ },
+ "autoload": {
+ "psr-0": {
+ "ComposerHookHandler": "includes/composer"
+ }
+ },
+ "scripts": {
+ "pre-update-cmd": "ComposerHookHandler::onPreUpdate",
+ "pre-install-cmd": "ComposerHookHandler::onPreInstall"
+ },
+ "config": {
+ "prepend-autoloader": false,
+ "optimize-autoloader": true
+ }
+}
diff --git a/www/wiki/tests/phpunit/data/composer/composer.lock b/www/wiki/tests/phpunit/data/composer/composer.lock
new file mode 100644
index 00000000..5c030db8
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/composer/composer.lock
@@ -0,0 +1,1195 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
+ "This file is @generated automatically"
+ ],
+ "hash": "a3bb80b0ac4c4a31e52574d48c032923",
+ "packages": [
+ {
+ "name": "composer/installers",
+ "version": "v1.0.19",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/installers.git",
+ "reference": "89d77bfbee79e16653f7162c86e602cc188471db"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/installers/zipball/89d77bfbee79e16653f7162c86e602cc188471db",
+ "reference": "89d77bfbee79e16653f7162c86e602cc188471db",
+ "shasum": ""
+ },
+ "replace": {
+ "roundcube/plugin-installer": "*",
+ "shama/baton": "*"
+ },
+ "require-dev": {
+ "composer/composer": "1.0.*@dev",
+ "phpunit/phpunit": "4.1.*"
+ },
+ "type": "composer-installer",
+ "extra": {
+ "class": "Composer\\Installers\\Installer",
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-0": {
+ "Composer\\Installers\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Kyle Robinson Young",
+ "email": "kyle@dontkry.com",
+ "homepage": "https://github.com/shama"
+ }
+ ],
+ "description": "A multi-framework Composer library installer",
+ "homepage": "http://composer.github.com/installers/",
+ "keywords": [
+ "Craft",
+ "Dolibarr",
+ "Hurad",
+ "MODX Evo",
+ "OXID",
+ "Thelia",
+ "WolfCMS",
+ "agl",
+ "annotatecms",
+ "bitrix",
+ "cakephp",
+ "chef",
+ "codeigniter",
+ "concrete5",
+ "croogo",
+ "dokuwiki",
+ "drupal",
+ "elgg",
+ "fuelphp",
+ "grav",
+ "installer",
+ "joomla",
+ "kohana",
+ "laravel",
+ "lithium",
+ "magento",
+ "mako",
+ "mediawiki",
+ "modulework",
+ "moodle",
+ "phpbb",
+ "piwik",
+ "ppi",
+ "puppet",
+ "roundcube",
+ "shopware",
+ "silverstripe",
+ "symfony",
+ "typo3",
+ "wordpress",
+ "zend",
+ "zikula"
+ ],
+ "time": "2014-11-29 01:29:17"
+ },
+ {
+ "name": "cssjanus/cssjanus",
+ "version": "v1.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cssjanus/php-cssjanus.git",
+ "reference": "62a9c32e6e140de09082b40a6e99d868ad14d4e0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cssjanus/php-cssjanus/zipball/62a9c32e6e140de09082b40a6e99d868ad14d4e0",
+ "reference": "62a9c32e6e140de09082b40a6e99d868ad14d4e0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "require-dev": {
+ "jakub-onderka/php-parallel-lint": "0.8.*",
+ "phpunit/phpunit": "3.7.*",
+ "squizlabs/php_codesniffer": "1.*"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "description": "Convert CSS stylesheets between left-to-right and right-to-left.",
+ "time": "2014-11-14 20:00:50"
+ },
+ {
+ "name": "leafo/lessphp",
+ "version": "v0.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/leafo/lessphp.git",
+ "reference": "0f5a7f5545d2bcf4e9fad9a228c8ad89cc9aa283"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/leafo/lessphp/zipball/0f5a7f5545d2bcf4e9fad9a228c8ad89cc9aa283",
+ "reference": "0f5a7f5545d2bcf4e9fad9a228c8ad89cc9aa283",
+ "shasum": ""
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "0.4.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "lessc.inc.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT",
+ "GPL-3.0-only"
+ ],
+ "authors": [
+ {
+ "name": "Leaf Corcoran",
+ "email": "leafot@gmail.com",
+ "homepage": "http://leafo.net"
+ }
+ ],
+ "description": "lessphp is a compiler for LESS written in PHP.",
+ "homepage": "http://leafo.net/lessphp/",
+ "time": "2014-11-24 18:39:20"
+ },
+ {
+ "name": "mediawiki/translate",
+ "version": "2014.12",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/wikimedia/mediawiki-extensions-Translate.git",
+ "reference": "2bc100763f3150380412faceea258c7378ce7ea0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/wikimedia/mediawiki-extensions-Translate/zipball/2bc100763f3150380412faceea258c7378ce7ea0",
+ "reference": "2bc100763f3150380412faceea258c7378ce7ea0",
+ "shasum": ""
+ },
+ "require": {
+ "composer/installers": ">=1.0.1",
+ "mediawiki/universal-language-selector": "*",
+ "php": ">=5.3.0"
+ },
+ "suggest": {
+ "mediawiki/babel": "Users can easily indicate their language proficiency on their user page",
+ "mediawiki/translation-notifications": "Manage communication with translators",
+ "mustangostang/spyc": "More recent version of the bundled spyc library"
+ },
+ "type": "mediawiki-extension",
+ "autoload": {
+ "files": [
+ "Translate.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-2.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Niklas Laxström",
+ "email": "niklas.laxstrom@gmail.com",
+ "role": "Lead nitpicker"
+ },
+ {
+ "name": "Siebrand Mazeland",
+ "email": "s.mazeland@xs4all.nl",
+ "role": "Developer"
+ }
+ ],
+ "description": "The only standard solution to translate any kind of text with an avant-garde web interface within MediaWiki, including your documentation and software",
+ "homepage": "https://www.mediawiki.org/wiki/Extension:Translate",
+ "keywords": [
+ "g11n",
+ "i18n",
+ "internationalization",
+ "l10n",
+ "localization",
+ "m17n",
+ "mediawiki",
+ "translatewiki.net",
+ "translation"
+ ],
+ "time": "2014-12-30 15:21:24"
+ },
+ {
+ "name": "mediawiki/universal-language-selector",
+ "version": "2014.12",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/wikimedia/mediawiki-extensions-UniversalLanguageSelector.git",
+ "reference": "f730b0f47e2828001c1e03ec40d4681bfb0bff2d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/wikimedia/mediawiki-extensions-UniversalLanguageSelector/zipball/f730b0f47e2828001c1e03ec40d4681bfb0bff2d",
+ "reference": "f730b0f47e2828001c1e03ec40d4681bfb0bff2d",
+ "shasum": ""
+ },
+ "require": {
+ "composer/installers": ">=1.0.1",
+ "php": ">=5.3.0"
+ },
+ "suggest": {
+ "mediawiki/cldr": "Language names in all languages"
+ },
+ "type": "mediawiki-extension",
+ "autoload": {
+ "files": [
+ "UniversalLanguageSelector.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-2.0-or-later",
+ "MIT"
+ ],
+ "description": "The primary aim is to allow users to select a language and configure its support in an easy way. Main features are language selection, input methods and web fonts.",
+ "homepage": "https://www.mediawiki.org/wiki/Extension:UniversalLanguageSelector",
+ "keywords": [
+ "Input methods",
+ "Language selection",
+ "Web fonts",
+ "mediawiki"
+ ],
+ "time": "2014-12-30 15:21:25"
+ },
+ {
+ "name": "oojs/oojs-ui",
+ "version": "v0.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/wikimedia/oojs-ui.git",
+ "reference": "50fa12637ad377f00bdbf1913406a3bfe9c1689e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/wikimedia/oojs-ui/zipball/50fa12637ad377f00bdbf1913406a3bfe9c1689e",
+ "reference": "50fa12637ad377f00bdbf1913406a3bfe9c1689e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "php/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "homepage": "https://www.mediawiki.org/wiki/OOjs_UI",
+ "time": "2014-12-16 20:50:05"
+ },
+ {
+ "name": "psr/log",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/fe0936ee26643249e916849d48e3a51d5f5e278b",
+ "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b",
+ "shasum": ""
+ },
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "Psr\\Log\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "time": "2012-12-21 11:40:51"
+ },
+ {
+ "name": "wikimedia/cdb",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/wikimedia/cdb.git",
+ "reference": "3b7d5366c88eccf2517ebac57c59eb557c82f46c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/wikimedia/cdb/zipball/3b7d5366c88eccf2517ebac57c59eb557c82f46c",
+ "reference": "3b7d5366c88eccf2517ebac57c59eb557c82f46c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "*"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-2.0-only"
+ ],
+ "authors": [
+ {
+ "name": "Tim Starling",
+ "email": "tstarling@wikimedia.org"
+ },
+ {
+ "name": "Chad Horohoe",
+ "email": "chad@wikimedia.org"
+ }
+ ],
+ "description": "Constant Database (CDB) wrapper library for PHP. Provides pure-PHP fallback when dba_* functions are absent.",
+ "homepage": "https://www.mediawiki.org/wiki/CDB",
+ "time": "2014-12-08 19:26:44"
+ }
+ ],
+ "packages-dev": [
+ {
+ "name": "doctrine/instantiator",
+ "version": "1.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/instantiator.git",
+ "reference": "f976e5de371104877ebc89bd8fecb0019ed9c119"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/f976e5de371104877ebc89bd8fecb0019ed9c119",
+ "reference": "f976e5de371104877ebc89bd8fecb0019ed9c119",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3,<8.0-DEV"
+ },
+ "require-dev": {
+ "athletic/athletic": "~0.1.8",
+ "ext-pdo": "*",
+ "ext-phar": "*",
+ "phpunit/phpunit": "~4.0",
+ "squizlabs/php_codesniffer": "2.0.*@ALPHA"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-0": {
+ "Doctrine\\Instantiator\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marco Pivetta",
+ "email": "ocramius@gmail.com",
+ "homepage": "http://ocramius.github.com/"
+ }
+ ],
+ "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+ "homepage": "https://github.com/doctrine/instantiator",
+ "keywords": [
+ "constructor",
+ "instantiate"
+ ],
+ "time": "2014-10-13 12:58:55"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "2.0.14",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "ca158276c1200cc27f5409a5e338486bc0b4fc94"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/ca158276c1200cc27f5409a5e338486bc0b4fc94",
+ "reference": "ca158276c1200cc27f5409a5e338486bc0b4fc94",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3",
+ "phpunit/php-file-iterator": "~1.3",
+ "phpunit/php-text-template": "~1.2",
+ "phpunit/php-token-stream": "~1.3",
+ "sebastian/environment": "~1.0",
+ "sebastian/version": "~1.0"
+ },
+ "require-dev": {
+ "ext-xdebug": ">=2.1.4",
+ "phpunit/phpunit": "~4.1"
+ },
+ "suggest": {
+ "ext-dom": "*",
+ "ext-xdebug": ">=2.2.1",
+ "ext-xmlwriter": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "include-path": [
+ ""
+ ],
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "time": "2014-12-26 13:28:33"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "1.3.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "acd690379117b042d1c8af1fafd61bde001bf6bb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/acd690379117b042d1c8af1fafd61bde001bf6bb",
+ "reference": "acd690379117b042d1c8af1fafd61bde001bf6bb",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "File/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "include-path": [
+ ""
+ ],
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "time": "2013-10-10 15:34:57"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "1.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "206dfefc0ffe9cebf65c413e3d0e809c82fbf00a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/206dfefc0ffe9cebf65c413e3d0e809c82fbf00a",
+ "reference": "206dfefc0ffe9cebf65c413e3d0e809c82fbf00a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "Text/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "include-path": [
+ ""
+ ],
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "time": "2014-01-30 17:20:04"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "1.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "19689d4354b295ee3d8c54b4f42c3efb69cbc17c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/19689d4354b295ee3d8c54b4f42c3efb69cbc17c",
+ "reference": "19689d4354b295ee3d8c54b4f42c3efb69cbc17c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "PHP/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "include-path": [
+ ""
+ ],
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "time": "2013-08-02 07:42:54"
+ },
+ {
+ "name": "phpunit/php-token-stream",
+ "version": "1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-token-stream.git",
+ "reference": "f8d5d08c56de5cfd592b3340424a81733259a876"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/f8d5d08c56de5cfd592b3340424a81733259a876",
+ "reference": "f8d5d08c56de5cfd592b3340424a81733259a876",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": ">=5.3.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~4.2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.3-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Wrapper around PHP's tokenizer extension.",
+ "homepage": "https://github.com/sebastianbergmann/php-token-stream/",
+ "keywords": [
+ "tokenizer"
+ ],
+ "time": "2014-08-31 06:12:13"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "4.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "6a5e49a86ce5e33b8d0657abe145057fc513543a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6a5e49a86ce5e33b8d0657abe145057fc513543a",
+ "reference": "6a5e49a86ce5e33b8d0657abe145057fc513543a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-pcre": "*",
+ "ext-reflection": "*",
+ "ext-spl": "*",
+ "php": ">=5.3.3",
+ "phpunit/php-code-coverage": "~2.0",
+ "phpunit/php-file-iterator": "~1.3.2",
+ "phpunit/php-text-template": "~1.2",
+ "phpunit/php-timer": "~1.0.2",
+ "phpunit/phpunit-mock-objects": "~2.3",
+ "sebastian/comparator": "~1.0",
+ "sebastian/diff": "~1.1",
+ "sebastian/environment": "~1.1",
+ "sebastian/exporter": "~1.0",
+ "sebastian/global-state": "~1.0",
+ "sebastian/version": "~1.0",
+ "symfony/yaml": "~2.0"
+ },
+ "suggest": {
+ "phpunit/php-invoker": "~1.1"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.4.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "time": "2014-12-28 07:57:05"
+ },
+ {
+ "name": "phpunit/phpunit-mock-objects",
+ "version": "2.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git",
+ "reference": "c63d2367247365f688544f0d500af90a11a44c65"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/c63d2367247365f688544f0d500af90a11a44c65",
+ "reference": "c63d2367247365f688544f0d500af90a11a44c65",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/instantiator": "~1.0,>=1.0.1",
+ "php": ">=5.3.3",
+ "phpunit/php-text-template": "~1.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~4.3"
+ },
+ "suggest": {
+ "ext-soap": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.3.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Mock Object library for PHPUnit",
+ "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/",
+ "keywords": [
+ "mock",
+ "xunit"
+ ],
+ "time": "2014-10-03 05:12:11"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "c484a80f97573ab934e37826dba0135a3301b26a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c484a80f97573ab934e37826dba0135a3301b26a",
+ "reference": "c484a80f97573ab934e37826dba0135a3301b26a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3",
+ "sebastian/diff": "~1.1",
+ "sebastian/exporter": "~1.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~4.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "http://www.github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "time": "2014-11-16 21:32:38"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "1.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "5843509fed39dee4b356a306401e9dd1a931fec7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/5843509fed39dee4b356a306401e9dd1a931fec7",
+ "reference": "5843509fed39dee4b356a306401e9dd1a931fec7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~4.2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "http://www.github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff"
+ ],
+ "time": "2014-08-15 10:29:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "1.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "6e6c71d918088c251b181ba8b3088af4ac336dd7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6e6c71d918088c251b181ba8b3088af4ac336dd7",
+ "reference": "6e6c71d918088c251b181ba8b3088af4ac336dd7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~4.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.2.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "http://www.github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "time": "2014-10-25 08:00:45"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "c7d59948d6e82818e1bdff7cadb6c34710eb7dc0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c7d59948d6e82818e1bdff7cadb6c34710eb7dc0",
+ "reference": "c7d59948d6e82818e1bdff7cadb6c34710eb7dc0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~4.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "http://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "time": "2014-09-10 00:51:36"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "c7428acdb62ece0a45e6306f1ae85e1c05b09c01"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/c7428acdb62ece0a45e6306f1ae85e1c05b09c01",
+ "reference": "c7428acdb62ece0a45e6306f1ae85e1c05b09c01",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~4.2"
+ },
+ "suggest": {
+ "ext-uopz": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "http://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "time": "2014-10-06 09:23:50"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "1.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "a77d9123f8e809db3fbdea15038c27a95da4058b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/a77d9123f8e809db3fbdea15038c27a95da4058b",
+ "reference": "a77d9123f8e809db3fbdea15038c27a95da4058b",
+ "shasum": ""
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "time": "2014-12-15 14:25:24"
+ },
+ {
+ "name": "symfony/yaml",
+ "version": "v2.6.1",
+ "target-dir": "Symfony/Component/Yaml",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/Yaml.git",
+ "reference": "3346fc090a3eb6b53d408db2903b241af51dcb20"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/Yaml/zipball/3346fc090a3eb6b53d408db2903b241af51dcb20",
+ "reference": "3346fc090a3eb6b53d408db2903b241af51dcb20",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-0": {
+ "Symfony\\Component\\Yaml\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Symfony Community",
+ "homepage": "http://symfony.com/contributors"
+ },
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ }
+ ],
+ "description": "Symfony Yaml Component",
+ "homepage": "http://symfony.com",
+ "time": "2014-12-02 20:19:20"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "platform": {
+ "php": ">=5.3.3"
+ },
+ "platform-dev": []
+}
diff --git a/www/wiki/tests/phpunit/data/composer/installed.json b/www/wiki/tests/phpunit/data/composer/installed.json
new file mode 100644
index 00000000..88a6bae2
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/composer/installed.json
@@ -0,0 +1,1682 @@
+[
+ {
+ "name": "leafo/lessphp",
+ "version": "v0.5.0",
+ "version_normalized": "0.5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/leafo/lessphp.git",
+ "reference": "0f5a7f5545d2bcf4e9fad9a228c8ad89cc9aa283"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/leafo/lessphp/zipball/0f5a7f5545d2bcf4e9fad9a228c8ad89cc9aa283",
+ "reference": "0f5a7f5545d2bcf4e9fad9a228c8ad89cc9aa283",
+ "shasum": ""
+ },
+ "time": "2014-11-24T18:39:20+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "0.4.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "lessc.inc.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT",
+ "GPL-3.0-only"
+ ],
+ "authors": [
+ {
+ "name": "Leaf Corcoran",
+ "email": "leafot@gmail.com",
+ "homepage": "http://leafo.net"
+ }
+ ],
+ "description": "lessphp is a compiler for LESS written in PHP.",
+ "homepage": "http://leafo.net/lessphp/"
+ },
+ {
+ "name": "psr/log",
+ "version": "1.0.0",
+ "version_normalized": "1.0.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/fe0936ee26643249e916849d48e3a51d5f5e278b",
+ "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b",
+ "shasum": ""
+ },
+ "time": "2012-12-21T11:40:51+00:00",
+ "type": "library",
+ "installation-source": "dist",
+ "autoload": {
+ "psr-0": {
+ "Psr\\Log\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ]
+ },
+ {
+ "name": "cssjanus/cssjanus",
+ "version": "v1.1.1",
+ "version_normalized": "1.1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cssjanus/php-cssjanus.git",
+ "reference": "62a9c32e6e140de09082b40a6e99d868ad14d4e0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cssjanus/php-cssjanus/zipball/62a9c32e6e140de09082b40a6e99d868ad14d4e0",
+ "reference": "62a9c32e6e140de09082b40a6e99d868ad14d4e0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "require-dev": {
+ "jakub-onderka/php-parallel-lint": "0.8.*",
+ "phpunit/phpunit": "3.7.*",
+ "squizlabs/php_codesniffer": "1.*"
+ },
+ "time": "2014-11-14T20:00:50+00:00",
+ "type": "library",
+ "installation-source": "dist",
+ "autoload": {
+ "psr-0": {
+ "": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "description": "Convert CSS stylesheets between left-to-right and right-to-left."
+ },
+ {
+ "name": "cdb/cdb",
+ "version": "1.0.0",
+ "version_normalized": "1.0.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/wikimedia/cdb.git",
+ "reference": "918601ea3d31b8c37312e9c0e54446aa8bfb3425"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/wikimedia/cdb/zipball/918601ea3d31b8c37312e9c0e54446aa8bfb3425",
+ "reference": "918601ea3d31b8c37312e9c0e54446aa8bfb3425",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "*"
+ },
+ "time": "2014-11-12T19:03:26+00:00",
+ "type": "library",
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPLv2"
+ ],
+ "authors": [
+ {
+ "name": "Tim Starling",
+ "email": "tstarling@wikimedia.org"
+ },
+ {
+ "name": "Chad Horohoe",
+ "email": "chad@wikimedia.org"
+ }
+ ],
+ "description": "Constant Database (CDB) wrapper library for PHP. Provides pure-PHP fallback when dba_* functions are absent.",
+ "homepage": "https://www.mediawiki.org/wiki/CDB",
+ "abandoned": "wikimedia/cdb"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "2.0.1",
+ "version_normalized": "2.0.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/99732be0ddb3361e16ad77b68ba41efc8e979019",
+ "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6"
+ },
+ "time": "2016-10-03T07:35:21+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version"
+ },
+ {
+ "name": "sebastian/resource-operations",
+ "version": "1.0.0",
+ "version_normalized": "1.0.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/resource-operations.git",
+ "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ce990bb21759f94aeafd30209e8cfcdfa8bc3f52",
+ "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6.0"
+ },
+ "time": "2015-07-28T20:34:47+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides a list of PHP built-in functions that operate on resources",
+ "homepage": "https://www.github.com/sebastianbergmann/resource-operations"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "3.0.0",
+ "version_normalized": "3.0.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8",
+ "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.0"
+ },
+ "time": "2017-03-03T06:23:57+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "http://www.github.com/sebastianbergmann/recursion-context"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "1.1.1",
+ "version_normalized": "1.1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "773f97c67f28de00d397be301821b06708fca0be"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/773f97c67f28de00d397be301821b06708fca0be",
+ "reference": "773f97c67f28de00d397be301821b06708fca0be",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.0"
+ },
+ "time": "2017-03-29T09:07:27+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "3.0.3",
+ "version_normalized": "3.0.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/7cfd9e65d11ffb5af41198476395774d4c8a84c5",
+ "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0",
+ "sebastian/object-reflector": "^1.1.1",
+ "sebastian/recursion-context": "^3.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.0"
+ },
+ "time": "2017-08-03T12:35:26+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "2.0.0",
+ "version_normalized": "2.0.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4",
+ "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.0"
+ },
+ "suggest": {
+ "ext-uopz": "*"
+ },
+ "time": "2017-04-27T15:39:26+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "http://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ]
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "3.1.0",
+ "version_normalized": "3.1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "234199f4528de6d12aaa58b612e98f7d36adb937"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/234199f4528de6d12aaa58b612e98f7d36adb937",
+ "reference": "234199f4528de6d12aaa58b612e98f7d36adb937",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0",
+ "sebastian/recursion-context": "^3.0"
+ },
+ "require-dev": {
+ "ext-mbstring": "*",
+ "phpunit/phpunit": "^6.0"
+ },
+ "time": "2017-04-03T13:19:02+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.1.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "http://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ]
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "3.1.0",
+ "version_normalized": "3.1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/cd0871b3975fb7fc44d11314fd1ee20925fce4f5",
+ "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.1"
+ },
+ "time": "2017-07-01T08:51:00+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.1.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "http://www.github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ]
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "2.0.1",
+ "version_normalized": "2.0.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/347c1d8b49c5c3ee30c7040ea6fc446790e6bddd",
+ "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.2"
+ },
+ "time": "2017-08-03T08:09:46+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff"
+ ]
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "2.1.1",
+ "version_normalized": "2.1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "b11c729f95109b56a0fe9650c6a63a0fcd8c439f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b11c729f95109b56a0fe9650c6a63a0fcd8c439f",
+ "reference": "b11c729f95109b56a0fe9650c6a63a0fcd8c439f",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0",
+ "sebastian/diff": "^2.0",
+ "sebastian/exporter": "^3.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.4"
+ },
+ "time": "2017-12-22T14:50:35+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.1.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ]
+ },
+ {
+ "name": "doctrine/instantiator",
+ "version": "1.1.0",
+ "version_normalized": "1.1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/instantiator.git",
+ "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda",
+ "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1"
+ },
+ "require-dev": {
+ "athletic/athletic": "~0.1.8",
+ "ext-pdo": "*",
+ "ext-phar": "*",
+ "phpunit/phpunit": "^6.2.3",
+ "squizlabs/php_codesniffer": "^3.0.2"
+ },
+ "time": "2017-07-22T11:58:36+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.2.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marco Pivetta",
+ "email": "ocramius@gmail.com",
+ "homepage": "http://ocramius.github.com/"
+ }
+ ],
+ "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+ "homepage": "https://github.com/doctrine/instantiator",
+ "keywords": [
+ "constructor",
+ "instantiate"
+ ]
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "1.2.1",
+ "version_normalized": "1.2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
+ "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "time": "2015-06-21T13:50:34+00:00",
+ "type": "library",
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ]
+ },
+ {
+ "name": "phpunit/phpunit-mock-objects",
+ "version": "5.0.6",
+ "version_normalized": "5.0.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git",
+ "reference": "33fd41a76e746b8fa96d00b49a23dadfa8334cdf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/33fd41a76e746b8fa96d00b49a23dadfa8334cdf",
+ "reference": "33fd41a76e746b8fa96d00b49a23dadfa8334cdf",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/instantiator": "^1.0.5",
+ "php": "^7.0",
+ "phpunit/php-text-template": "^1.2.1",
+ "sebastian/exporter": "^3.1"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<6.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.5"
+ },
+ "suggest": {
+ "ext-soap": "*"
+ },
+ "time": "2018-01-06T05:45:45+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.0.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Mock Object library for PHPUnit",
+ "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/",
+ "keywords": [
+ "mock",
+ "xunit"
+ ]
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "1.0.9",
+ "version_normalized": "1.0.9.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3dcf38ca72b158baf0bc245e9184d3fdffa9c46f",
+ "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.3.3 || ^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0"
+ },
+ "time": "2017-02-26T11:10:40+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ]
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "1.4.5",
+ "version_normalized": "1.4.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/730b01bc3e867237eaac355e06a36b85dd93a8b4",
+ "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "time": "2017-11-27T13:52:08+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.4.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sb@sebastian-bergmann.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ]
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "1.1.0",
+ "version_normalized": "1.1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "cb2f008f3f05af2893a87208fe6a6c4985483f8b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/cb2f008f3f05af2893a87208fe6a6c4985483f8b",
+ "reference": "cb2f008f3f05af2893a87208fe6a6c4985483f8b",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.0"
+ },
+ "time": "2017-04-07T12:08:54+00:00",
+ "type": "library",
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats"
+ },
+ {
+ "name": "sebastian/code-unit-reverse-lookup",
+ "version": "1.0.1",
+ "version_normalized": "1.0.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+ "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/4419fcdb5eabb9caa61a27c7a1db532a6b55dd18",
+ "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.6 || ^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^5.7 || ^6.0"
+ },
+ "time": "2017-03-04T06:30:41+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Looks up which function or method a line of code belongs to",
+ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/"
+ },
+ {
+ "name": "phpunit/php-token-stream",
+ "version": "2.0.2",
+ "version_normalized": "2.0.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-token-stream.git",
+ "reference": "791198a2c6254db10131eecfe8c06670700904db"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/791198a2c6254db10131eecfe8c06670700904db",
+ "reference": "791198a2c6254db10131eecfe8c06670700904db",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.2.4"
+ },
+ "time": "2017-11-27T05:48:46+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Wrapper around PHP's tokenizer extension.",
+ "homepage": "https://github.com/sebastianbergmann/php-token-stream/",
+ "keywords": [
+ "tokenizer"
+ ]
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "5.3.0",
+ "version_normalized": "5.3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "661f34d0bd3f1a7225ef491a70a020ad23a057a1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/661f34d0bd3f1a7225ef491a70a020ad23a057a1",
+ "reference": "661f34d0bd3f1a7225ef491a70a020ad23a057a1",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.0",
+ "phpunit/php-file-iterator": "^1.4.2",
+ "phpunit/php-text-template": "^1.2.1",
+ "phpunit/php-token-stream": "^2.0.1",
+ "sebastian/code-unit-reverse-lookup": "^1.0.1",
+ "sebastian/environment": "^3.0",
+ "sebastian/version": "^2.0.1",
+ "theseer/tokenizer": "^1.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.0"
+ },
+ "suggest": {
+ "ext-xdebug": "^2.5.5"
+ },
+ "time": "2017-12-06T09:29:45+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.3.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ]
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "1.2.0",
+ "version_normalized": "1.2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webmozart/assert.git",
+ "reference": "2db61e59ff05fe5126d152bd0655c9ea113e550f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webmozart/assert/zipball/2db61e59ff05fe5126d152bd0655c9ea113e550f",
+ "reference": "2db61e59ff05fe5126d152bd0655c9ea113e550f",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.3.3 || ^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.6",
+ "sebastian/version": "^1.0.1"
+ },
+ "time": "2016-11-23T20:04:58+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.3-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "psr-4": {
+ "Webmozart\\Assert\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Assertions to validate method input/output with nice error messages.",
+ "keywords": [
+ "assert",
+ "check",
+ "validate"
+ ]
+ },
+ {
+ "name": "phpdocumentor/reflection-common",
+ "version": "1.0.1",
+ "version_normalized": "1.0.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
+ "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6",
+ "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.5"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.6"
+ },
+ "time": "2017-09-11T18:02:19+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": [
+ "src"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
+ "homepage": "http://www.phpdoc.org",
+ "keywords": [
+ "FQSEN",
+ "phpDocumentor",
+ "phpdoc",
+ "reflection",
+ "static analysis"
+ ]
+ },
+ {
+ "name": "phpdocumentor/type-resolver",
+ "version": "0.4.0",
+ "version_normalized": "0.4.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/TypeResolver.git",
+ "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/9c977708995954784726e25d0cd1dddf4e65b0f7",
+ "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.5 || ^7.0",
+ "phpdocumentor/reflection-common": "^1.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^0.9.4",
+ "phpunit/phpunit": "^5.2||^4.8.24"
+ },
+ "time": "2017-07-14T14:27:02+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": [
+ "src/"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ }
+ ]
+ },
+ {
+ "name": "phpdocumentor/reflection-docblock",
+ "version": "4.2.0",
+ "version_normalized": "4.2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
+ "reference": "66465776cfc249844bde6d117abff1d22e06c2da"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/66465776cfc249844bde6d117abff1d22e06c2da",
+ "reference": "66465776cfc249844bde6d117abff1d22e06c2da",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0",
+ "phpdocumentor/reflection-common": "^1.0.0",
+ "phpdocumentor/type-resolver": "^0.4.0",
+ "webmozart/assert": "^1.0"
+ },
+ "require-dev": {
+ "doctrine/instantiator": "~1.0.5",
+ "mockery/mockery": "^1.0",
+ "phpunit/phpunit": "^6.4"
+ },
+ "time": "2017-11-27T17:38:31+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": [
+ "src/"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ }
+ ],
+ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock."
+ },
+ {
+ "name": "phpspec/prophecy",
+ "version": "1.7.3",
+ "version_normalized": "1.7.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpspec/prophecy.git",
+ "reference": "e4ed002c67da8eceb0eb8ddb8b3847bb53c5c2bf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpspec/prophecy/zipball/e4ed002c67da8eceb0eb8ddb8b3847bb53c5c2bf",
+ "reference": "e4ed002c67da8eceb0eb8ddb8b3847bb53c5c2bf",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/instantiator": "^1.0.2",
+ "php": "^5.3|^7.0",
+ "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0",
+ "sebastian/comparator": "^1.1|^2.0",
+ "sebastian/recursion-context": "^1.0|^2.0|^3.0"
+ },
+ "require-dev": {
+ "phpspec/phpspec": "^2.5|^3.2",
+ "phpunit/phpunit": "^4.8.35 || ^5.7"
+ },
+ "time": "2017-11-24T13:59:53+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.7.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "psr-0": {
+ "Prophecy\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Konstantin Kudryashov",
+ "email": "ever.zet@gmail.com",
+ "homepage": "http://everzet.com"
+ },
+ {
+ "name": "Marcello Duarte",
+ "email": "marcello.duarte@gmail.com"
+ }
+ ],
+ "description": "Highly opinionated mocking framework for PHP 5.3+",
+ "homepage": "https://github.com/phpspec/prophecy",
+ "keywords": [
+ "Double",
+ "Dummy",
+ "fake",
+ "mock",
+ "spy",
+ "stub"
+ ]
+ },
+ {
+ "name": "phar-io/version",
+ "version": "1.0.1",
+ "version_normalized": "1.0.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/a70c0ced4be299a63d32fa96d9281d03e94041df",
+ "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.6 || ^7.0"
+ },
+ "time": "2017-03-05T17:38:23+00:00",
+ "type": "library",
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "1.0.1",
+ "version_normalized": "1.0.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/2df402786ab5368a0169091f61a7c1e0eb6852d0",
+ "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-phar": "*",
+ "phar-io/version": "^1.0.1",
+ "php": "^5.6 || ^7.0"
+ },
+ "time": "2017-03-05T18:14:27+00:00",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.7.0",
+ "version_normalized": "1.7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e",
+ "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.6 || ^7.0"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.0",
+ "doctrine/common": "^2.6",
+ "phpunit/phpunit": "^4.1"
+ },
+ "time": "2017-10-19T19:58:43+00:00",
+ "type": "library",
+ "installation-source": "dist",
+ "autoload": {
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ },
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ]
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "6.5.5",
+ "version_normalized": "6.5.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "83d27937a310f2984fd575686138597147bdc7df"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/83d27937a310f2984fd575686138597147bdc7df",
+ "reference": "83d27937a310f2984fd575686138597147bdc7df",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xml": "*",
+ "myclabs/deep-copy": "^1.6.1",
+ "phar-io/manifest": "^1.0.1",
+ "phar-io/version": "^1.0",
+ "php": "^7.0",
+ "phpspec/prophecy": "^1.7",
+ "phpunit/php-code-coverage": "^5.3",
+ "phpunit/php-file-iterator": "^1.4.3",
+ "phpunit/php-text-template": "^1.2.1",
+ "phpunit/php-timer": "^1.0.9",
+ "phpunit/phpunit-mock-objects": "^5.0.5",
+ "sebastian/comparator": "^2.1",
+ "sebastian/diff": "^2.0",
+ "sebastian/environment": "^3.1",
+ "sebastian/exporter": "^3.1",
+ "sebastian/global-state": "^2.0",
+ "sebastian/object-enumerator": "^3.0.3",
+ "sebastian/resource-operations": "^1.0",
+ "sebastian/version": "^2.0.1"
+ },
+ "conflict": {
+ "phpdocumentor/reflection-docblock": "3.0.2",
+ "phpunit/dbunit": "<3.0"
+ },
+ "require-dev": {
+ "ext-pdo": "*"
+ },
+ "suggest": {
+ "ext-xdebug": "*",
+ "phpunit/php-invoker": "^1.1"
+ },
+ "time": "2017-12-17T06:31:19+00:00",
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "6.5.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ]
+ }
+]
diff --git a/www/wiki/tests/phpunit/data/composer/new-composer.json b/www/wiki/tests/phpunit/data/composer/new-composer.json
new file mode 100644
index 00000000..3a886769
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/composer/new-composer.json
@@ -0,0 +1,48 @@
+{
+ "name": "mediawiki/core",
+ "description": "Free software wiki application developed by the Wikimedia Foundation and others",
+ "keywords": ["mediawiki", "wiki"],
+ "homepage": "https://www.mediawiki.org/",
+ "authors": [
+ {
+ "name": "MediaWiki Community",
+ "homepage": "https://www.mediawiki.org/wiki/Special:Version/Credits"
+ }
+ ],
+ "license": "GPL-2.0-only",
+ "support": {
+ "issues": "https://bugzilla.wikimedia.org/",
+ "irc": "irc://irc.freenode.net/mediawiki",
+ "wiki": "https://www.mediawiki.org/"
+ },
+ "require": {
+ "leafo/lessphp": "0.5.0",
+ "php": ">=5.3.3",
+ "psr/log": "1.0.0",
+ "cssjanus/cssjanus": "1.1.1",
+ "wikimedia/cdb": "1.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "*"
+ },
+ "suggest": {
+ "ext-fileinfo": "*",
+ "ext-mbstring": "*",
+ "ext-wikidiff2": "*",
+ "ext-apc": "*",
+ "monolog/monolog": "*"
+ },
+ "autoload": {
+ "psr-0": {
+ "ComposerHookHandler": "includes/composer"
+ }
+ },
+ "scripts": {
+ "pre-update-cmd": "ComposerHookHandler::onPreUpdate",
+ "pre-install-cmd": "ComposerHookHandler::onPreInstall"
+ },
+ "config": {
+ "prepend-autoloader": false,
+ "optimize-autoloader": true
+ }
+}
diff --git a/www/wiki/tests/phpunit/data/css/bom.css b/www/wiki/tests/phpunit/data/css/bom.css
new file mode 100644
index 00000000..3382ea47
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/css/bom.css
@@ -0,0 +1 @@
+.efbbbf_bom_char_at_start_of_file {}
diff --git a/www/wiki/tests/phpunit/data/css/comments.css b/www/wiki/tests/phpunit/data/css/comments.css
new file mode 100644
index 00000000..744a14c7
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/css/comments.css
@@ -0,0 +1,7 @@
+/* url expressions in comments should be ignored */
+
+.selector { /*@noflip*/ background-image: /*@embed*/ url(not-commented.gif); }
+
+/*
+.selector { background-image: url(commented-out.gif); }
+*/
diff --git a/www/wiki/tests/phpunit/data/css/expected.css b/www/wiki/tests/phpunit/data/css/expected.css
new file mode 100644
index 00000000..03addcb7
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/css/expected.css
@@ -0,0 +1,11 @@
+/* All of the combinations should result in the same output in LTR and RTL mode. */
+
+.selector { /*@embed*/ background-image: url(simple-ltr.gif); }
+
+.selector { /*@embed*/ background-image: url(simple-ltr.gif); }
+
+.selector { /*@embed*/ background-image: url(simple-ltr.gif); }
+
+.selector { /*@embed*/ background-image: url(simple-ltr.gif); }
+
+.selector { /*@embed*/ background-image: url(simple-ltr.gif); }
diff --git a/www/wiki/tests/phpunit/data/css/simple-ltr.gif b/www/wiki/tests/phpunit/data/css/simple-ltr.gif
new file mode 100644
index 00000000..13c43e90
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/css/simple-ltr.gif
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/css/simple-rtl.gif b/www/wiki/tests/phpunit/data/css/simple-rtl.gif
new file mode 100644
index 00000000..f9e75316
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/css/simple-rtl.gif
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/css/test.css b/www/wiki/tests/phpunit/data/css/test.css
new file mode 100644
index 00000000..8d0d6708
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/css/test.css
@@ -0,0 +1,11 @@
+/* All of the combinations should result in the same output in LTR and RTL mode. */
+
+/*@noflip*/ .selector { /*@embed*/ background-image: url(simple-ltr.gif); }
+
+/*@noflip*/ .selector { background-image: /*@embed*/ url(simple-ltr.gif); }
+
+.selector { /*@noflip*/ /*@embed*/ background-image: url(simple-ltr.gif); }
+
+.selector { /*@embed*/ /*@noflip*/ background-image: url(simple-ltr.gif); }
+
+.selector { /*@noflip*/ background-image: /*@embed*/ url(simple-ltr.gif); }
diff --git a/www/wiki/tests/phpunit/data/cssmin/circle.svg b/www/wiki/tests/phpunit/data/cssmin/circle.svg
new file mode 100644
index 00000000..415d9920
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/cssmin/circle.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
+ <circle cx="4" cy="4" r="2"/>
+ <a xmlns:xlink="http://www.w3.org/1999/xlink" xlink:title="?>">test</a>
+</svg> \ No newline at end of file
diff --git a/www/wiki/tests/phpunit/data/cssmin/green.gif b/www/wiki/tests/phpunit/data/cssmin/green.gif
new file mode 100644
index 00000000..f9e75316
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/cssmin/green.gif
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/cssmin/large.png b/www/wiki/tests/phpunit/data/cssmin/large.png
new file mode 100644
index 00000000..64bf48aa
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/cssmin/large.png
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/cssmin/red.gif b/www/wiki/tests/phpunit/data/cssmin/red.gif
new file mode 100644
index 00000000..13c43e90
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/cssmin/red.gif
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/db/mysql/functions.sql b/www/wiki/tests/phpunit/data/db/mysql/functions.sql
new file mode 100644
index 00000000..9e5e470f
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/db/mysql/functions.sql
@@ -0,0 +1,12 @@
+-- MySQL test file for DatabaseTest::testStoredFunctions()
+
+DELIMITER //
+
+CREATE FUNCTION mw_test_function()
+RETURNS int DETERMINISTIC
+BEGIN
+ SET @foo = 21;
+ RETURN @foo * 2;
+END//
+
+DELIMITER //
diff --git a/www/wiki/tests/phpunit/data/db/postgres/functions.sql b/www/wiki/tests/phpunit/data/db/postgres/functions.sql
new file mode 100644
index 00000000..3086d4d5
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/db/postgres/functions.sql
@@ -0,0 +1,12 @@
+-- Postgres test file for DatabaseTest::testStoredFunctions()
+
+CREATE FUNCTION mw_test_function()
+RETURNS INTEGER
+LANGUAGE plpgsql AS
+$mw$
+DECLARE foo INTEGER;
+BEGIN
+ foo := 21;
+ RETURN foo * 2;
+END
+$mw$;
diff --git a/www/wiki/tests/phpunit/data/db/sqlite/tables-1.13.sql b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.13.sql
new file mode 100644
index 00000000..2efb7a0e
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.13.sql
@@ -0,0 +1,342 @@
+-- This is a copy of SQLite schema from MediaWiki 1.13 used for updater testing
+
+CREATE TABLE /*$wgDBprefix*/user (
+ user_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_name varchar(255) default '',
+ user_real_name varchar(255) default '',
+ user_password tinyblob ,
+ user_newpassword tinyblob ,
+ user_newpass_time BLOB,
+ user_email tinytext ,
+ user_options blob ,
+ user_touched BLOB default '',
+ user_token BLOB default '',
+ user_email_authenticated BLOB,
+ user_email_token BLOB,
+ user_email_token_expires BLOB,
+ user_registration BLOB,
+ user_editcount int) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/user_groups (
+ ug_user INTEGER default '0',
+ ug_group varBLOB default '') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/user_newtalk (
+ user_id INTEGER default '0',
+ user_ip varBLOB default '',
+ user_last_timestamp BLOB default '') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/page (
+ page_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ page_namespace INTEGER ,
+ page_title varchar(255) ,
+ page_restrictions tinyblob ,
+ page_counter bigint default '0',
+ page_is_redirect tinyint default '0',
+ page_is_new tinyint default '0',
+ page_random real ,
+ page_touched BLOB default '',
+ page_latest INTEGER ,
+ page_len INTEGER ) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/revision (
+ rev_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ rev_page INTEGER ,
+ rev_text_id INTEGER ,
+ rev_comment tinyblob ,
+ rev_user INTEGER default '0',
+ rev_user_text varchar(255) default '',
+ rev_timestamp BLOB default '',
+ rev_minor_edit tinyint default '0',
+ rev_deleted tinyint default '0',
+ rev_len int,
+ rev_parent_id INTEGER default NULL) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/text (
+ old_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ old_text mediumblob ,
+ old_flags tinyblob ) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/archive (
+ ar_namespace INTEGER default '0',
+ ar_title varchar(255) default '',
+ ar_text mediumblob ,
+ ar_comment tinyblob ,
+ ar_user INTEGER default '0',
+ ar_user_text varchar(255) ,
+ ar_timestamp BLOB default '',
+ ar_minor_edit tinyint default '0',
+ ar_flags tinyblob ,
+ ar_rev_id int,
+ ar_text_id int,
+ ar_deleted tinyint default '0',
+ ar_len int,
+ ar_page_id int,
+ ar_parent_id INTEGER default NULL) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/pagelinks (
+ pl_from INTEGER default '0',
+ pl_namespace INTEGER default '0',
+ pl_title varchar(255) default '') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/templatelinks (
+ tl_from INTEGER default '0',
+ tl_namespace INTEGER default '0',
+ tl_title varchar(255) default '') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/imagelinks (
+ il_from INTEGER default '0',
+ il_to varchar(255) default '') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/categorylinks (
+ cl_from INTEGER default '0',
+ cl_to varchar(255) default '',
+ cl_sortkey varchar(70) default '',
+ cl_timestamp timestamp ) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/category (
+ cat_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ cat_title varchar(255) ,
+ cat_pages INTEGER signed default 0,
+ cat_subcats INTEGER signed default 0,
+ cat_files INTEGER signed default 0,
+ cat_hidden tinyint default 0) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/externallinks (
+ el_from INTEGER default '0',
+ el_to blob ,
+ el_index blob ) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/langlinks (
+ ll_from INTEGER default '0',
+ ll_lang varBLOB default '',
+ ll_title varchar(255) default '') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/site_stats (
+ ss_row_id INTEGER ,
+ ss_total_views bigint default '0',
+ ss_total_edits bigint default '0',
+ ss_good_articles bigint default '0',
+ ss_total_pages bigint default '-1',
+ ss_users bigint default '-1',
+ ss_admins INTEGER default '-1',
+ ss_images INTEGER default '0') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/hitcounter (
+ hc_id INTEGER
+);
+
+CREATE TABLE /*$wgDBprefix*/ipblocks (
+ ipb_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ipb_address tinyblob ,
+ ipb_user INTEGER default '0',
+ ipb_by INTEGER default '0',
+ ipb_by_text varchar(255) default '',
+ ipb_reason tinyblob ,
+ ipb_timestamp BLOB default '',
+ ipb_auto bool default 0,
+ ipb_anon_only bool default 0,
+ ipb_create_account bool default 1,
+ ipb_enable_autoblock bool default '1',
+ ipb_expiry varBLOB default '',
+ ipb_range_start tinyblob ,
+ ipb_range_end tinyblob ,
+ ipb_deleted bool default 0,
+ ipb_block_email bool default 0) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/image (
+ img_name varchar(255) default '',
+ img_size INTEGER default '0',
+ img_width INTEGER default '0',
+ img_height INTEGER default '0',
+ img_metadata mediumblob ,
+ img_bits INTEGER default '0',
+ img_media_type TEXT default NULL,
+ img_major_mime TEXT default "unknown",
+ img_minor_mime varBLOB default "unknown",
+ img_description tinyblob ,
+ img_user INTEGER default '0',
+ img_user_text varchar(255) ,
+ img_timestamp varBLOB default '',
+ img_sha1 varBLOB default '') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/oldimage (
+ oi_name varchar(255) default '',
+ oi_archive_name varchar(255) default '',
+ oi_size INTEGER default 0,
+ oi_width INTEGER default 0,
+ oi_height INTEGER default 0,
+ oi_bits INTEGER default 0,
+ oi_description tinyblob ,
+ oi_user INTEGER default '0',
+ oi_user_text varchar(255) ,
+ oi_timestamp BLOB default '',
+ oi_metadata mediumblob ,
+ oi_media_type TEXT default NULL,
+ oi_major_mime TEXT default "unknown",
+ oi_minor_mime varBLOB default "unknown",
+ oi_deleted tinyint default '0',
+ oi_sha1 varBLOB default '') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/filearchive (
+ fa_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ fa_name varchar(255) default '',
+ fa_archive_name varchar(255) default '',
+ fa_storage_group varBLOB,
+ fa_storage_key varBLOB default '',
+ fa_deleted_user int,
+ fa_deleted_timestamp BLOB default '',
+ fa_deleted_reason text,
+ fa_size INTEGER default '0',
+ fa_width INTEGER default '0',
+ fa_height INTEGER default '0',
+ fa_metadata mediumblob,
+ fa_bits INTEGER default '0',
+ fa_media_type TEXT default NULL,
+ fa_major_mime TEXT default "unknown",
+ fa_minor_mime varBLOB default "unknown",
+ fa_description tinyblob,
+ fa_user INTEGER default '0',
+ fa_user_text varchar(255) ,
+ fa_timestamp BLOB default '',
+ fa_deleted tinyint default '0') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/recentchanges (
+ rc_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ rc_timestamp varBLOB default '',
+ rc_cur_time varBLOB default '',
+ rc_user INTEGER default '0',
+ rc_user_text varchar(255) ,
+ rc_namespace INTEGER default '0',
+ rc_title varchar(255) default '',
+ rc_comment varchar(255) default '',
+ rc_minor tinyint default '0',
+ rc_bot tinyint default '0',
+ rc_new tinyint default '0',
+ rc_cur_id INTEGER default '0',
+ rc_this_oldid INTEGER default '0',
+ rc_last_oldid INTEGER default '0',
+ rc_type tinyint default '0',
+ rc_moved_to_ns tinyint default '0',
+ rc_moved_to_title varchar(255) default '',
+ rc_patrolled tinyint default '0',
+ rc_ip varBLOB default '',
+ rc_old_len int,
+ rc_new_len int,
+ rc_deleted tinyint default '0',
+ rc_logid INTEGER default '0',
+ rc_log_type varBLOB NULL default NULL,
+ rc_log_action varBLOB NULL default NULL,
+ rc_params blob NULL) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/watchlist (
+ wl_user INTEGER ,
+ wl_namespace INTEGER default '0',
+ wl_title varchar(255) default '',
+ wl_notificationtimestamp varBLOB) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/math (
+ math_inputhash varBLOB ,
+ math_outputhash varBLOB ,
+ math_html_conservativeness tinyint ,
+ math_html text,
+ math_mathml text) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/searchindex (
+ si_page INTEGER ,
+ si_title varchar(255) default '',
+ si_text mediumtext );
+
+CREATE TABLE /*$wgDBprefix*/interwiki (
+ iw_prefix varchar(32) ,
+ iw_url blob ,
+ iw_local bool ,
+ iw_trans tinyint default 0) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/querycache (
+ qc_type varBLOB ,
+ qc_value INTEGER default '0',
+ qc_namespace INTEGER default '0',
+ qc_title varchar(255) default '') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/objectcache (
+ keyname varBLOB default '',
+ value mediumblob,
+ exptime datetime) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/transcache (
+ tc_url varBLOB ,
+ tc_contents text,
+ tc_time INTEGER ) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/logging (
+ log_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ log_type varBLOB default '',
+ log_action varBLOB default '',
+ log_timestamp BLOB default '19700101000000',
+ log_user INTEGER default 0,
+ log_namespace INTEGER default 0,
+ log_title varchar(255) default '',
+ log_comment varchar(255) default '',
+ log_params blob ,
+ log_deleted tinyint default '0') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/trackbacks (
+ tb_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ tb_page INTEGER REFERENCES /*$wgDBprefix*/page(page_id) ON DELETE CASCADE,
+ tb_title varchar(255) ,
+ tb_url blob ,
+ tb_ex text,
+ tb_name varchar(255)) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/job (
+ job_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ job_cmd varBLOB default '',
+ job_namespace INTEGER ,
+ job_title varchar(255) ,
+ job_params blob ) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/querycache_info (
+ qci_type varBLOB default '',
+ qci_timestamp BLOB default '19700101000000') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/redirect (
+ rd_from INTEGER default '0',
+ rd_namespace INTEGER default '0',
+ rd_title varchar(255) default '') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/querycachetwo (
+ qcc_type varBLOB ,
+ qcc_value INTEGER default '0',
+ qcc_namespace INTEGER default '0',
+ qcc_title varchar(255) default '',
+ qcc_namespacetwo INTEGER default '0',
+ qcc_titletwo varchar(255) default '') /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/page_restrictions (
+ pr_page INTEGER ,
+ pr_type varBLOB ,
+ pr_level varBLOB ,
+ pr_cascade tinyint ,
+ pr_user INTEGER NULL,
+ pr_expiry varBLOB NULL,
+ pr_id INTEGER PRIMARY KEY AUTOINCREMENT) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/protected_titles (
+ pt_namespace INTEGER ,
+ pt_title varchar(255) ,
+ pt_user INTEGER ,
+ pt_reason tinyblob,
+ pt_timestamp BLOB ,
+ pt_expiry varBLOB default '',
+ pt_create_perm varBLOB ) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/page_props (
+ pp_page INTEGER ,
+ pp_propname varBLOB ,
+ pp_value blob ) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*$wgDBprefix*/updatelog (
+ ul_key varchar(255) ) /*$wgDBTableOptions*/;
+
+
diff --git a/www/wiki/tests/phpunit/data/db/sqlite/tables-1.15.sql b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.15.sql
new file mode 100644
index 00000000..6b3a628e
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.15.sql
@@ -0,0 +1,454 @@
+-- This is a copy of MediaWiki 1.15 schema shared by MySQL and SQLite.
+-- It is used for updater testing. Comments are stripped to decrease
+-- file size, as we don't need to maintain it.
+
+CREATE TABLE /*_*/user (
+ user_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ user_name varchar(255) binary NOT NULL default '',
+ user_real_name varchar(255) binary NOT NULL default '',
+ user_password tinyblob NOT NULL,
+ user_newpassword tinyblob NOT NULL,
+ user_newpass_time binary(14),
+ user_email tinytext NOT NULL,
+ user_options blob NOT NULL,
+ user_touched binary(14) NOT NULL default '',
+ user_token binary(32) NOT NULL default '',
+ user_email_authenticated binary(14),
+ user_email_token binary(32),
+ user_email_token_expires binary(14),
+ user_registration binary(14),
+ user_editcount int
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_name ON /*_*/user (user_name);
+CREATE INDEX /*i*/user_email_token ON /*_*/user (user_email_token);
+CREATE TABLE /*_*/user_groups (
+ ug_user int unsigned NOT NULL default 0,
+ ug_group varbinary(16) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user,ug_group);
+CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE TABLE /*_*/user_newtalk (
+ user_id int NOT NULL default 0,
+ user_ip varbinary(40) NOT NULL default '',
+ user_last_timestamp binary(14) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id);
+CREATE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip);
+CREATE TABLE /*_*/page (
+ page_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ page_namespace int NOT NULL,
+ page_title varchar(255) binary NOT NULL,
+ page_restrictions tinyblob NOT NULL,
+ page_counter bigint unsigned NOT NULL default 0,
+ page_is_redirect tinyint unsigned NOT NULL default 0,
+ page_is_new tinyint unsigned NOT NULL default 0,
+ page_random real unsigned NOT NULL,
+ page_touched binary(14) NOT NULL default '',
+ page_latest int unsigned NOT NULL,
+ page_len int unsigned NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/name_title ON /*_*/page (page_namespace,page_title);
+CREATE INDEX /*i*/page_random ON /*_*/page (page_random);
+CREATE INDEX /*i*/page_len ON /*_*/page (page_len);
+CREATE TABLE /*_*/revision (
+ rev_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rev_page int unsigned NOT NULL,
+ rev_text_id int unsigned NOT NULL,
+ rev_comment tinyblob NOT NULL,
+ rev_user int unsigned NOT NULL default 0,
+ rev_user_text varchar(255) binary NOT NULL default '',
+ rev_timestamp binary(14) NOT NULL default '',
+ rev_minor_edit tinyint unsigned NOT NULL default 0,
+ rev_deleted tinyint unsigned NOT NULL default 0,
+ rev_len int unsigned,
+ rev_parent_id int unsigned default NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=1024;
+CREATE UNIQUE INDEX /*i*/rev_page_id ON /*_*/revision (rev_page, rev_id);
+CREATE INDEX /*i*/rev_timestamp ON /*_*/revision (rev_timestamp);
+CREATE INDEX /*i*/page_timestamp ON /*_*/revision (rev_page,rev_timestamp);
+CREATE INDEX /*i*/user_timestamp ON /*_*/revision (rev_user,rev_timestamp);
+CREATE INDEX /*i*/usertext_timestamp ON /*_*/revision (rev_user_text,rev_timestamp);
+CREATE TABLE /*_*/text (
+ old_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ old_text mediumblob NOT NULL,
+ old_flags tinyblob NOT NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=10240;
+CREATE TABLE /*_*/archive (
+ ar_namespace int NOT NULL default 0,
+ ar_title varchar(255) binary NOT NULL default '',
+ ar_text mediumblob NOT NULL,
+ ar_comment tinyblob NOT NULL,
+ ar_user int unsigned NOT NULL default 0,
+ ar_user_text varchar(255) binary NOT NULL,
+ ar_timestamp binary(14) NOT NULL default '',
+ ar_minor_edit tinyint NOT NULL default 0,
+ ar_flags tinyblob NOT NULL,
+ ar_rev_id int unsigned,
+ ar_text_id int unsigned,
+ ar_deleted tinyint unsigned NOT NULL default 0,
+ ar_len int unsigned,
+ ar_page_id int unsigned,
+ ar_parent_id int unsigned default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/name_title_timestamp ON /*_*/archive (ar_namespace,ar_title,ar_timestamp);
+CREATE INDEX /*i*/ar_usertext_timestamp ON /*_*/archive (ar_user_text,ar_timestamp);
+CREATE TABLE /*_*/pagelinks (
+ pl_from int unsigned NOT NULL default 0,
+ pl_namespace int NOT NULL default 0,
+ pl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pl_from ON /*_*/pagelinks (pl_from,pl_namespace,pl_title);
+CREATE UNIQUE INDEX /*i*/pl_namespace ON /*_*/pagelinks (pl_namespace,pl_title,pl_from);
+CREATE TABLE /*_*/templatelinks (
+ tl_from int unsigned NOT NULL default 0,
+ tl_namespace int NOT NULL default 0,
+ tl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tl_from ON /*_*/templatelinks (tl_from,tl_namespace,tl_title);
+CREATE UNIQUE INDEX /*i*/tl_namespace ON /*_*/templatelinks (tl_namespace,tl_title,tl_from);
+CREATE TABLE /*_*/imagelinks (
+ il_from int unsigned NOT NULL default 0,
+ il_to varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/il_from ON /*_*/imagelinks (il_from,il_to);
+CREATE UNIQUE INDEX /*i*/il_to ON /*_*/imagelinks (il_to,il_from);
+CREATE TABLE /*_*/categorylinks (
+ cl_from int unsigned NOT NULL default 0,
+ cl_to varchar(255) binary NOT NULL default '',
+ cl_sortkey varchar(70) binary NOT NULL default '',
+ cl_timestamp timestamp NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cl_from ON /*_*/categorylinks (cl_from,cl_to);
+CREATE INDEX /*i*/cl_sortkey ON /*_*/categorylinks (cl_to,cl_sortkey,cl_from);
+CREATE INDEX /*i*/cl_timestamp ON /*_*/categorylinks (cl_to,cl_timestamp);
+CREATE TABLE /*_*/category (
+ cat_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ cat_title varchar(255) binary NOT NULL,
+ cat_pages int signed NOT NULL default 0,
+ cat_subcats int signed NOT NULL default 0,
+ cat_files int signed NOT NULL default 0,
+ cat_hidden tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cat_title ON /*_*/category (cat_title);
+CREATE INDEX /*i*/cat_pages ON /*_*/category (cat_pages);
+CREATE TABLE /*_*/externallinks (
+ el_from int unsigned NOT NULL default 0,
+ el_to blob NOT NULL,
+ el_index blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/el_from ON /*_*/externallinks (el_from, el_to(40));
+CREATE INDEX /*i*/el_to ON /*_*/externallinks (el_to(60), el_from);
+CREATE INDEX /*i*/el_index ON /*_*/externallinks (el_index(60));
+CREATE TABLE /*_*/langlinks (
+ ll_from int unsigned NOT NULL default 0,
+
+ ll_lang varbinary(20) NOT NULL default '',
+ ll_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ll_from ON /*_*/langlinks (ll_from, ll_lang);
+CREATE INDEX /*i*/ll_lang ON /*_*/langlinks (ll_lang, ll_title);
+CREATE TABLE /*_*/site_stats (
+ ss_row_id int unsigned NOT NULL,
+ ss_total_views bigint unsigned default 0,
+ ss_total_edits bigint unsigned default 0,
+ ss_good_articles bigint unsigned default 0,
+ ss_total_pages bigint default '-1',
+ ss_users bigint default '-1',
+ ss_active_users bigint default '-1',
+ ss_admins int default '-1',
+ ss_images int default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ss_row_id ON /*_*/site_stats (ss_row_id);
+CREATE TABLE /*_*/hitcounter (
+ hc_id int unsigned NOT NULL
+) ENGINE=HEAP MAX_ROWS=25000;
+CREATE TABLE /*_*/ipblocks (
+ ipb_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ ipb_address tinyblob NOT NULL,
+ ipb_user int unsigned NOT NULL default 0,
+ ipb_by int unsigned NOT NULL default 0,
+ ipb_by_text varchar(255) binary NOT NULL default '',
+ ipb_reason tinyblob NOT NULL,
+ ipb_timestamp binary(14) NOT NULL default '',
+ ipb_auto bool NOT NULL default 0,
+ ipb_anon_only bool NOT NULL default 0,
+ ipb_create_account bool NOT NULL default 1,
+ ipb_enable_autoblock bool NOT NULL default '1',
+ ipb_expiry varbinary(14) NOT NULL default '',
+ ipb_range_start tinyblob NOT NULL,
+ ipb_range_end tinyblob NOT NULL,
+ ipb_deleted bool NOT NULL default 0,
+ ipb_block_email bool NOT NULL default 0,
+ ipb_allow_usertalk bool NOT NULL default 0
+) /*$wgDBTableOptions*/;
+
+CREATE UNIQUE INDEX /*i*/ipb_address ON /*_*/ipblocks (ipb_address(255), ipb_user, ipb_auto, ipb_anon_only);
+CREATE INDEX /*i*/ipb_user ON /*_*/ipblocks (ipb_user);
+CREATE INDEX /*i*/ipb_range ON /*_*/ipblocks (ipb_range_start(8), ipb_range_end(8));
+CREATE INDEX /*i*/ipb_timestamp ON /*_*/ipblocks (ipb_timestamp);
+CREATE INDEX /*i*/ipb_expiry ON /*_*/ipblocks (ipb_expiry);
+CREATE TABLE /*_*/image (
+ img_name varchar(255) binary NOT NULL default '' PRIMARY KEY,
+ img_size int unsigned NOT NULL default 0,
+ img_width int NOT NULL default 0,
+ img_height int NOT NULL default 0,
+ img_metadata mediumblob NOT NULL,
+ img_bits int NOT NULL default 0,
+ img_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ img_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ img_minor_mime varbinary(32) NOT NULL default "unknown",
+ img_description tinyblob NOT NULL,
+ img_user int unsigned NOT NULL default 0,
+ img_user_text varchar(255) binary NOT NULL,
+ img_timestamp varbinary(14) NOT NULL default '',
+ img_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/img_usertext_timestamp ON /*_*/image (img_user_text,img_timestamp);
+CREATE INDEX /*i*/img_size ON /*_*/image (img_size);
+CREATE INDEX /*i*/img_timestamp ON /*_*/image (img_timestamp);
+CREATE INDEX /*i*/img_sha1 ON /*_*/image (img_sha1);
+CREATE TABLE /*_*/oldimage (
+ oi_name varchar(255) binary NOT NULL default '',
+ oi_archive_name varchar(255) binary NOT NULL default '',
+ oi_size int unsigned NOT NULL default 0,
+ oi_width int NOT NULL default 0,
+ oi_height int NOT NULL default 0,
+ oi_bits int NOT NULL default 0,
+ oi_description tinyblob NOT NULL,
+ oi_user int unsigned NOT NULL default 0,
+ oi_user_text varchar(255) binary NOT NULL,
+ oi_timestamp binary(14) NOT NULL default '',
+ oi_metadata mediumblob NOT NULL,
+ oi_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ oi_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ oi_minor_mime varbinary(32) NOT NULL default "unknown",
+ oi_deleted tinyint unsigned NOT NULL default 0,
+ oi_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/oi_usertext_timestamp ON /*_*/oldimage (oi_user_text,oi_timestamp);
+CREATE INDEX /*i*/oi_name_timestamp ON /*_*/oldimage (oi_name,oi_timestamp);
+CREATE INDEX /*i*/oi_name_archive_name ON /*_*/oldimage (oi_name,oi_archive_name(14));
+CREATE INDEX /*i*/oi_sha1 ON /*_*/oldimage (oi_sha1);
+CREATE TABLE /*_*/filearchive (
+ fa_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ fa_name varchar(255) binary NOT NULL default '',
+ fa_archive_name varchar(255) binary default '',
+ fa_storage_group varbinary(16),
+ fa_storage_key varbinary(64) default '',
+ fa_deleted_user int,
+ fa_deleted_timestamp binary(14) default '',
+ fa_deleted_reason text,
+ fa_size int unsigned default 0,
+ fa_width int default 0,
+ fa_height int default 0,
+ fa_metadata mediumblob,
+ fa_bits int default 0,
+ fa_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ fa_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") default "unknown",
+ fa_minor_mime varbinary(32) default "unknown",
+ fa_description tinyblob,
+ fa_user int unsigned default 0,
+ fa_user_text varchar(255) binary,
+ fa_timestamp binary(14) default '',
+ fa_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/fa_name ON /*_*/filearchive (fa_name, fa_timestamp);
+CREATE INDEX /*i*/fa_storage_group ON /*_*/filearchive (fa_storage_group, fa_storage_key);
+CREATE INDEX /*i*/fa_deleted_timestamp ON /*_*/filearchive (fa_deleted_timestamp);
+CREATE INDEX /*i*/fa_user_timestamp ON /*_*/filearchive (fa_user_text,fa_timestamp);
+CREATE TABLE /*_*/recentchanges (
+ rc_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rc_timestamp varbinary(14) NOT NULL default '',
+ rc_cur_time varbinary(14) NOT NULL default '',
+ rc_user int unsigned NOT NULL default 0,
+ rc_user_text varchar(255) binary NOT NULL,
+ rc_namespace int NOT NULL default 0,
+ rc_title varchar(255) binary NOT NULL default '',
+ rc_comment varchar(255) binary NOT NULL default '',
+ rc_minor tinyint unsigned NOT NULL default 0,
+ rc_bot tinyint unsigned NOT NULL default 0,
+ rc_new tinyint unsigned NOT NULL default 0,
+ rc_cur_id int unsigned NOT NULL default 0,
+ rc_this_oldid int unsigned NOT NULL default 0,
+ rc_last_oldid int unsigned NOT NULL default 0,
+ rc_type tinyint unsigned NOT NULL default 0,
+ rc_moved_to_ns tinyint unsigned NOT NULL default 0,
+ rc_moved_to_title varchar(255) binary NOT NULL default '',
+ rc_patrolled tinyint unsigned NOT NULL default 0,
+ rc_ip varbinary(40) NOT NULL default '',
+ rc_old_len int,
+ rc_new_len int,
+ rc_deleted tinyint unsigned NOT NULL default 0,
+ rc_logid int unsigned NOT NULL default 0,
+ rc_log_type varbinary(255) NULL default NULL,
+ rc_log_action varbinary(255) NULL default NULL,
+ rc_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rc_timestamp ON /*_*/recentchanges (rc_timestamp);
+CREATE INDEX /*i*/rc_namespace_title ON /*_*/recentchanges (rc_namespace, rc_title);
+CREATE INDEX /*i*/rc_cur_id ON /*_*/recentchanges (rc_cur_id);
+CREATE INDEX /*i*/new_name_timestamp ON /*_*/recentchanges (rc_new,rc_namespace,rc_timestamp);
+CREATE INDEX /*i*/rc_ip ON /*_*/recentchanges (rc_ip);
+CREATE INDEX /*i*/rc_ns_usertext ON /*_*/recentchanges (rc_namespace, rc_user_text);
+CREATE INDEX /*i*/rc_user_text ON /*_*/recentchanges (rc_user_text, rc_timestamp);
+CREATE TABLE /*_*/watchlist (
+ wl_user int unsigned NOT NULL,
+ wl_namespace int NOT NULL default 0,
+ wl_title varchar(255) binary NOT NULL default '',
+ wl_notificationtimestamp varbinary(14)
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/wl_user ON /*_*/watchlist (wl_user, wl_namespace, wl_title);
+CREATE INDEX /*i*/namespace_title ON /*_*/watchlist (wl_namespace, wl_title);
+CREATE TABLE /*_*/math (
+ math_inputhash varbinary(16) NOT NULL,
+ math_outputhash varbinary(16) NOT NULL,
+ math_html_conservativeness tinyint NOT NULL,
+ math_html text,
+ math_mathml text
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/math_inputhash ON /*_*/math (math_inputhash);
+CREATE TABLE /*_*/searchindex (
+ si_page int unsigned NOT NULL,
+ si_title varchar(255) NOT NULL default '',
+ si_text mediumtext NOT NULL
+) ENGINE=MyISAM;
+CREATE UNIQUE INDEX /*i*/si_page ON /*_*/searchindex (si_page);
+CREATE FULLTEXT INDEX /*i*/si_title ON /*_*/searchindex (si_title);
+CREATE FULLTEXT INDEX /*i*/si_text ON /*_*/searchindex (si_text);
+CREATE TABLE /*_*/interwiki (
+ iw_prefix varchar(32) NOT NULL,
+ iw_url blob NOT NULL,
+ iw_local bool NOT NULL,
+ iw_trans tinyint NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iw_prefix ON /*_*/interwiki (iw_prefix);
+CREATE TABLE /*_*/querycache (
+ qc_type varbinary(32) NOT NULL,
+ qc_value int unsigned NOT NULL default 0,
+ qc_namespace int NOT NULL default 0,
+ qc_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qc_type ON /*_*/querycache (qc_type,qc_value);
+CREATE TABLE /*_*/objectcache (
+ keyname varbinary(255) NOT NULL default '' PRIMARY KEY,
+ value mediumblob,
+ exptime datetime
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/exptime ON /*_*/objectcache (exptime);
+CREATE TABLE /*_*/transcache (
+ tc_url varbinary(255) NOT NULL,
+ tc_contents text,
+ tc_time int NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tc_url_idx ON /*_*/transcache (tc_url);
+CREATE TABLE /*_*/logging (
+ log_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ log_type varbinary(10) NOT NULL default '',
+ log_action varbinary(10) NOT NULL default '',
+ log_timestamp binary(14) NOT NULL default '19700101000000',
+ log_user int unsigned NOT NULL default 0,
+ log_namespace int NOT NULL default 0,
+ log_title varchar(255) binary NOT NULL default '',
+ log_comment varchar(255) NOT NULL default '',
+ log_params blob NOT NULL,
+ log_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/type_time ON /*_*/logging (log_type, log_timestamp);
+CREATE INDEX /*i*/user_time ON /*_*/logging (log_user, log_timestamp);
+CREATE INDEX /*i*/page_time ON /*_*/logging (log_namespace, log_title, log_timestamp);
+CREATE INDEX /*i*/times ON /*_*/logging (log_timestamp);
+CREATE TABLE /*_*/trackbacks (
+ tb_id int PRIMARY KEY AUTO_INCREMENT,
+ tb_page int REFERENCES /*_*/page(page_id) ON DELETE CASCADE,
+ tb_title varchar(255) NOT NULL,
+ tb_url blob NOT NULL,
+ tb_ex text,
+ tb_name varchar(255)
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/tb_page ON /*_*/trackbacks (tb_page);
+CREATE TABLE /*_*/job (
+ job_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ job_cmd varbinary(60) NOT NULL default '',
+ job_namespace int NOT NULL,
+ job_title varchar(255) binary NOT NULL,
+ job_params blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/job_cmd ON /*_*/job (job_cmd, job_namespace, job_title);
+CREATE TABLE /*_*/querycache_info (
+ qci_type varbinary(32) NOT NULL default '',
+ qci_timestamp binary(14) NOT NULL default '19700101000000'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/qci_type ON /*_*/querycache_info (qci_type);
+CREATE TABLE /*_*/redirect (
+ rd_from int unsigned NOT NULL default 0 PRIMARY KEY,
+ rd_namespace int NOT NULL default 0,
+ rd_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rd_ns_title ON /*_*/redirect (rd_namespace,rd_title,rd_from);
+CREATE TABLE /*_*/querycachetwo (
+ qcc_type varbinary(32) NOT NULL,
+ qcc_value int unsigned NOT NULL default 0,
+ qcc_namespace int NOT NULL default 0,
+ qcc_title varchar(255) binary NOT NULL default '',
+ qcc_namespacetwo int NOT NULL default 0,
+ qcc_titletwo varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qcc_type ON /*_*/querycachetwo (qcc_type,qcc_value);
+CREATE INDEX /*i*/qcc_title ON /*_*/querycachetwo (qcc_type,qcc_namespace,qcc_title);
+CREATE INDEX /*i*/qcc_titletwo ON /*_*/querycachetwo (qcc_type,qcc_namespacetwo,qcc_titletwo);
+CREATE TABLE /*_*/page_restrictions (
+ pr_page int NOT NULL,
+ pr_type varbinary(60) NOT NULL,
+ pr_level varbinary(60) NOT NULL,
+ pr_cascade tinyint NOT NULL,
+ pr_user int NULL,
+ pr_expiry varbinary(14) NULL,
+ pr_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pr_pagetype ON /*_*/page_restrictions (pr_page,pr_type);
+CREATE INDEX /*i*/pr_typelevel ON /*_*/page_restrictions (pr_type,pr_level);
+CREATE INDEX /*i*/pr_level ON /*_*/page_restrictions (pr_level);
+CREATE INDEX /*i*/pr_cascade ON /*_*/page_restrictions (pr_cascade);
+CREATE TABLE /*_*/protected_titles (
+ pt_namespace int NOT NULL,
+ pt_title varchar(255) binary NOT NULL,
+ pt_user int unsigned NOT NULL,
+ pt_reason tinyblob,
+ pt_timestamp binary(14) NOT NULL,
+ pt_expiry varbinary(14) NOT NULL default '',
+ pt_create_perm varbinary(60) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pt_namespace_title ON /*_*/protected_titles (pt_namespace,pt_title);
+CREATE INDEX /*i*/pt_timestamp ON /*_*/protected_titles (pt_timestamp);
+CREATE TABLE /*_*/page_props (
+ pp_page int NOT NULL,
+ pp_propname varbinary(60) NOT NULL,
+ pp_value blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pp_page_propname ON /*_*/page_props (pp_page,pp_propname);
+CREATE TABLE /*_*/updatelog (
+ ul_key varchar(255) NOT NULL PRIMARY KEY
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/change_tag (
+ ct_rc_id int NULL,
+ ct_log_id int NULL,
+ ct_rev_id int NULL,
+ ct_tag varchar(255) NOT NULL,
+ ct_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/change_tag_rc_tag ON /*_*/change_tag (ct_rc_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_log_tag ON /*_*/change_tag (ct_log_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_rev_tag ON /*_*/change_tag (ct_rev_id,ct_tag);
+CREATE INDEX /*i*/change_tag_tag_id ON /*_*/change_tag (ct_tag,ct_rc_id,ct_rev_id,ct_log_id);
+CREATE TABLE /*_*/tag_summary (
+ ts_rc_id int NULL,
+ ts_log_id int NULL,
+ ts_rev_id int NULL,
+ ts_tags blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tag_summary_rc_id ON /*_*/tag_summary (ts_rc_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_log_id ON /*_*/tag_summary (ts_log_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_rev_id ON /*_*/tag_summary (ts_rev_id);
+CREATE TABLE /*_*/valid_tag (
+ vt_tag varchar(255) NOT NULL PRIMARY KEY
+) /*$wgDBTableOptions*/;
diff --git a/www/wiki/tests/phpunit/data/db/sqlite/tables-1.16.sql b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.16.sql
new file mode 100644
index 00000000..7e8f30ec
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.16.sql
@@ -0,0 +1,478 @@
+-- This is a copy of MediaWiki 1.16 schema shared by MySQL and SQLite.
+-- It is used for updater testing. Comments are stripped to decrease
+-- file size, as we don't need to maintain it.
+
+CREATE TABLE /*_*/user (
+ user_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ user_name varchar(255) binary NOT NULL default '',
+ user_real_name varchar(255) binary NOT NULL default '',
+ user_password tinyblob NOT NULL,
+ user_newpassword tinyblob NOT NULL,
+ user_newpass_time binary(14),
+ user_email tinytext NOT NULL,
+ user_options blob NOT NULL,
+ user_touched binary(14) NOT NULL default '',
+ user_token binary(32) NOT NULL default '',
+ user_email_authenticated binary(14),
+ user_email_token binary(32),
+ user_email_token_expires binary(14),
+ user_registration binary(14),
+ user_editcount int
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_name ON /*_*/user (user_name);
+CREATE INDEX /*i*/user_email_token ON /*_*/user (user_email_token);
+CREATE TABLE /*_*/user_groups (
+ ug_user int unsigned NOT NULL default 0,
+ ug_group varbinary(16) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user,ug_group);
+CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE TABLE /*_*/user_newtalk (
+ user_id int NOT NULL default 0,
+ user_ip varbinary(40) NOT NULL default '',
+ user_last_timestamp binary(14) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id);
+CREATE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip);
+CREATE TABLE /*_*/user_properties (
+ up_user int NOT NULL,
+ up_property varbinary(32) NOT NULL,
+ up_value blob
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_properties_user_property ON /*_*/user_properties (up_user,up_property);
+CREATE INDEX /*i*/user_properties_property ON /*_*/user_properties (up_property);
+CREATE TABLE /*_*/page (
+ page_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ page_namespace int NOT NULL,
+ page_title varchar(255) binary NOT NULL,
+ page_restrictions tinyblob NOT NULL,
+ page_counter bigint unsigned NOT NULL default 0,
+ page_is_redirect tinyint unsigned NOT NULL default 0,
+ page_is_new tinyint unsigned NOT NULL default 0,
+ page_random real unsigned NOT NULL,
+ page_touched binary(14) NOT NULL default '',
+ page_latest int unsigned NOT NULL,
+ page_len int unsigned NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/name_title ON /*_*/page (page_namespace,page_title);
+CREATE INDEX /*i*/page_random ON /*_*/page (page_random);
+CREATE INDEX /*i*/page_len ON /*_*/page (page_len);
+CREATE TABLE /*_*/revision (
+ rev_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rev_page int unsigned NOT NULL,
+ rev_text_id int unsigned NOT NULL,
+ rev_comment tinyblob NOT NULL,
+ rev_user int unsigned NOT NULL default 0,
+ rev_user_text varchar(255) binary NOT NULL default '',
+ rev_timestamp binary(14) NOT NULL default '',
+ rev_minor_edit tinyint unsigned NOT NULL default 0,
+ rev_deleted tinyint unsigned NOT NULL default 0,
+ rev_len int unsigned,
+ rev_parent_id int unsigned default NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=1024;
+CREATE UNIQUE INDEX /*i*/rev_page_id ON /*_*/revision (rev_page, rev_id);
+CREATE INDEX /*i*/rev_timestamp ON /*_*/revision (rev_timestamp);
+CREATE INDEX /*i*/page_timestamp ON /*_*/revision (rev_page,rev_timestamp);
+CREATE INDEX /*i*/user_timestamp ON /*_*/revision (rev_user,rev_timestamp);
+CREATE INDEX /*i*/usertext_timestamp ON /*_*/revision (rev_user_text,rev_timestamp);
+CREATE TABLE /*_*/text (
+ old_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ old_text mediumblob NOT NULL,
+ old_flags tinyblob NOT NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=10240;
+CREATE TABLE /*_*/archive (
+ ar_namespace int NOT NULL default 0,
+ ar_title varchar(255) binary NOT NULL default '',
+ ar_text mediumblob NOT NULL,
+ ar_comment tinyblob NOT NULL,
+ ar_user int unsigned NOT NULL default 0,
+ ar_user_text varchar(255) binary NOT NULL,
+ ar_timestamp binary(14) NOT NULL default '',
+ ar_minor_edit tinyint NOT NULL default 0,
+ ar_flags tinyblob NOT NULL,
+ ar_rev_id int unsigned,
+ ar_text_id int unsigned,
+ ar_deleted tinyint unsigned NOT NULL default 0,
+ ar_len int unsigned,
+ ar_page_id int unsigned,
+ ar_parent_id int unsigned default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/name_title_timestamp ON /*_*/archive (ar_namespace,ar_title,ar_timestamp);
+CREATE INDEX /*i*/ar_usertext_timestamp ON /*_*/archive (ar_user_text,ar_timestamp);
+CREATE TABLE /*_*/pagelinks (
+ pl_from int unsigned NOT NULL default 0,
+ pl_namespace int NOT NULL default 0,
+ pl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pl_from ON /*_*/pagelinks (pl_from,pl_namespace,pl_title);
+CREATE UNIQUE INDEX /*i*/pl_namespace ON /*_*/pagelinks (pl_namespace,pl_title,pl_from);
+CREATE TABLE /*_*/templatelinks (
+ tl_from int unsigned NOT NULL default 0,
+ tl_namespace int NOT NULL default 0,
+ tl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tl_from ON /*_*/templatelinks (tl_from,tl_namespace,tl_title);
+CREATE UNIQUE INDEX /*i*/tl_namespace ON /*_*/templatelinks (tl_namespace,tl_title,tl_from);
+CREATE TABLE /*_*/imagelinks (
+ il_from int unsigned NOT NULL default 0,
+ il_to varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/il_from ON /*_*/imagelinks (il_from,il_to);
+CREATE UNIQUE INDEX /*i*/il_to ON /*_*/imagelinks (il_to,il_from);
+CREATE TABLE /*_*/categorylinks (
+ cl_from int unsigned NOT NULL default 0,
+ cl_to varchar(255) binary NOT NULL default '',
+ cl_sortkey varchar(70) binary NOT NULL default '',
+ cl_timestamp timestamp NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cl_from ON /*_*/categorylinks (cl_from,cl_to);
+CREATE INDEX /*i*/cl_sortkey ON /*_*/categorylinks (cl_to,cl_sortkey,cl_from);
+CREATE INDEX /*i*/cl_timestamp ON /*_*/categorylinks (cl_to,cl_timestamp);
+CREATE TABLE /*_*/category (
+ cat_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ cat_title varchar(255) binary NOT NULL,
+ cat_pages int signed NOT NULL default 0,
+ cat_subcats int signed NOT NULL default 0,
+ cat_files int signed NOT NULL default 0,
+ cat_hidden tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cat_title ON /*_*/category (cat_title);
+CREATE INDEX /*i*/cat_pages ON /*_*/category (cat_pages);
+CREATE TABLE /*_*/externallinks (
+ el_from int unsigned NOT NULL default 0,
+ el_to blob NOT NULL,
+ el_index blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/el_from ON /*_*/externallinks (el_from, el_to(40));
+CREATE INDEX /*i*/el_to ON /*_*/externallinks (el_to(60), el_from);
+CREATE INDEX /*i*/el_index ON /*_*/externallinks (el_index(60));
+CREATE TABLE /*_*/langlinks (
+ ll_from int unsigned NOT NULL default 0,
+ ll_lang varbinary(20) NOT NULL default '',
+ ll_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ll_from ON /*_*/langlinks (ll_from, ll_lang);
+CREATE INDEX /*i*/ll_lang ON /*_*/langlinks (ll_lang, ll_title);
+CREATE TABLE /*_*/site_stats (
+ ss_row_id int unsigned NOT NULL,
+ ss_total_views bigint unsigned default 0,
+ ss_total_edits bigint unsigned default 0,
+ ss_good_articles bigint unsigned default 0,
+ ss_total_pages bigint default '-1',
+ ss_users bigint default '-1',
+ ss_active_users bigint default '-1',
+ ss_admins int default '-1',
+ ss_images int default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ss_row_id ON /*_*/site_stats (ss_row_id);
+CREATE TABLE /*_*/hitcounter (
+ hc_id int unsigned NOT NULL
+) ENGINE=HEAP MAX_ROWS=25000;
+CREATE TABLE /*_*/ipblocks (
+ ipb_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ ipb_address tinyblob NOT NULL,
+ ipb_user int unsigned NOT NULL default 0,
+ ipb_by int unsigned NOT NULL default 0,
+ ipb_by_text varchar(255) binary NOT NULL default '',
+ ipb_reason tinyblob NOT NULL,
+ ipb_timestamp binary(14) NOT NULL default '',
+ ipb_auto bool NOT NULL default 0,
+ ipb_anon_only bool NOT NULL default 0,
+ ipb_create_account bool NOT NULL default 1,
+ ipb_enable_autoblock bool NOT NULL default '1',
+ ipb_expiry varbinary(14) NOT NULL default '',
+ ipb_range_start tinyblob NOT NULL,
+ ipb_range_end tinyblob NOT NULL,
+ ipb_deleted bool NOT NULL default 0,
+ ipb_block_email bool NOT NULL default 0,
+ ipb_allow_usertalk bool NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ipb_address ON /*_*/ipblocks (ipb_address(255), ipb_user, ipb_auto, ipb_anon_only);
+CREATE INDEX /*i*/ipb_user ON /*_*/ipblocks (ipb_user);
+CREATE INDEX /*i*/ipb_range ON /*_*/ipblocks (ipb_range_start(8), ipb_range_end(8));
+CREATE INDEX /*i*/ipb_timestamp ON /*_*/ipblocks (ipb_timestamp);
+CREATE INDEX /*i*/ipb_expiry ON /*_*/ipblocks (ipb_expiry);
+CREATE TABLE /*_*/image (
+ img_name varchar(255) binary NOT NULL default '' PRIMARY KEY,
+ img_size int unsigned NOT NULL default 0,
+ img_width int NOT NULL default 0,
+ img_height int NOT NULL default 0,
+ img_metadata mediumblob NOT NULL,
+ img_bits int NOT NULL default 0,
+ img_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ img_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ img_minor_mime varbinary(100) NOT NULL default "unknown",
+ img_description tinyblob NOT NULL,
+ img_user int unsigned NOT NULL default 0,
+ img_user_text varchar(255) binary NOT NULL,
+ img_timestamp varbinary(14) NOT NULL default '',
+ img_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/img_usertext_timestamp ON /*_*/image (img_user_text,img_timestamp);
+CREATE INDEX /*i*/img_size ON /*_*/image (img_size);
+CREATE INDEX /*i*/img_timestamp ON /*_*/image (img_timestamp);
+CREATE INDEX /*i*/img_sha1 ON /*_*/image (img_sha1);
+CREATE TABLE /*_*/oldimage (
+ oi_name varchar(255) binary NOT NULL default '',
+ oi_archive_name varchar(255) binary NOT NULL default '',
+ oi_size int unsigned NOT NULL default 0,
+ oi_width int NOT NULL default 0,
+ oi_height int NOT NULL default 0,
+ oi_bits int NOT NULL default 0,
+ oi_description tinyblob NOT NULL,
+ oi_user int unsigned NOT NULL default 0,
+ oi_user_text varchar(255) binary NOT NULL,
+ oi_timestamp binary(14) NOT NULL default '',
+ oi_metadata mediumblob NOT NULL,
+ oi_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ oi_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ oi_minor_mime varbinary(100) NOT NULL default "unknown",
+ oi_deleted tinyint unsigned NOT NULL default 0,
+ oi_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/oi_usertext_timestamp ON /*_*/oldimage (oi_user_text,oi_timestamp);
+CREATE INDEX /*i*/oi_name_timestamp ON /*_*/oldimage (oi_name,oi_timestamp);
+CREATE INDEX /*i*/oi_name_archive_name ON /*_*/oldimage (oi_name,oi_archive_name(14));
+CREATE INDEX /*i*/oi_sha1 ON /*_*/oldimage (oi_sha1);
+CREATE TABLE /*_*/filearchive (
+ fa_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ fa_name varchar(255) binary NOT NULL default '',
+ fa_archive_name varchar(255) binary default '',
+ fa_storage_group varbinary(16),
+ fa_storage_key varbinary(64) default '',
+ fa_deleted_user int,
+ fa_deleted_timestamp binary(14) default '',
+ fa_deleted_reason text,
+ fa_size int unsigned default 0,
+ fa_width int default 0,
+ fa_height int default 0,
+ fa_metadata mediumblob,
+ fa_bits int default 0,
+ fa_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ fa_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") default "unknown",
+ fa_minor_mime varbinary(100) default "unknown",
+ fa_description tinyblob,
+ fa_user int unsigned default 0,
+ fa_user_text varchar(255) binary,
+ fa_timestamp binary(14) default '',
+ fa_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/fa_name ON /*_*/filearchive (fa_name, fa_timestamp);
+CREATE INDEX /*i*/fa_storage_group ON /*_*/filearchive (fa_storage_group, fa_storage_key);
+CREATE INDEX /*i*/fa_deleted_timestamp ON /*_*/filearchive (fa_deleted_timestamp);
+CREATE INDEX /*i*/fa_user_timestamp ON /*_*/filearchive (fa_user_text,fa_timestamp);
+CREATE TABLE /*_*/recentchanges (
+ rc_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rc_timestamp varbinary(14) NOT NULL default '',
+ rc_cur_time varbinary(14) NOT NULL default '',
+ rc_user int unsigned NOT NULL default 0,
+ rc_user_text varchar(255) binary NOT NULL,
+ rc_namespace int NOT NULL default 0,
+ rc_title varchar(255) binary NOT NULL default '',
+ rc_comment varchar(255) binary NOT NULL default '',
+ rc_minor tinyint unsigned NOT NULL default 0,
+ rc_bot tinyint unsigned NOT NULL default 0,
+ rc_new tinyint unsigned NOT NULL default 0,
+ rc_cur_id int unsigned NOT NULL default 0,
+ rc_this_oldid int unsigned NOT NULL default 0,
+ rc_last_oldid int unsigned NOT NULL default 0,
+ rc_type tinyint unsigned NOT NULL default 0,
+ rc_moved_to_ns tinyint unsigned NOT NULL default 0,
+ rc_moved_to_title varchar(255) binary NOT NULL default '',
+ rc_patrolled tinyint unsigned NOT NULL default 0,
+ rc_ip varbinary(40) NOT NULL default '',
+ rc_old_len int,
+ rc_new_len int,
+ rc_deleted tinyint unsigned NOT NULL default 0,
+ rc_logid int unsigned NOT NULL default 0,
+ rc_log_type varbinary(255) NULL default NULL,
+ rc_log_action varbinary(255) NULL default NULL,
+ rc_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rc_timestamp ON /*_*/recentchanges (rc_timestamp);
+CREATE INDEX /*i*/rc_namespace_title ON /*_*/recentchanges (rc_namespace, rc_title);
+CREATE INDEX /*i*/rc_cur_id ON /*_*/recentchanges (rc_cur_id);
+CREATE INDEX /*i*/new_name_timestamp ON /*_*/recentchanges (rc_new,rc_namespace,rc_timestamp);
+CREATE INDEX /*i*/rc_ip ON /*_*/recentchanges (rc_ip);
+CREATE INDEX /*i*/rc_ns_usertext ON /*_*/recentchanges (rc_namespace, rc_user_text);
+CREATE INDEX /*i*/rc_user_text ON /*_*/recentchanges (rc_user_text, rc_timestamp);
+CREATE TABLE /*_*/watchlist (
+ wl_user int unsigned NOT NULL,
+ wl_namespace int NOT NULL default 0,
+ wl_title varchar(255) binary NOT NULL default '',
+ wl_notificationtimestamp varbinary(14)
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/wl_user ON /*_*/watchlist (wl_user, wl_namespace, wl_title);
+CREATE INDEX /*i*/namespace_title ON /*_*/watchlist (wl_namespace, wl_title);
+CREATE TABLE /*_*/math (
+ math_inputhash varbinary(16) NOT NULL,
+ math_outputhash varbinary(16) NOT NULL,
+ math_html_conservativeness tinyint NOT NULL,
+ math_html text,
+ math_mathml text
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/math_inputhash ON /*_*/math (math_inputhash);
+CREATE TABLE /*_*/searchindex (
+ si_page int unsigned NOT NULL,
+ si_title varchar(255) NOT NULL default '',
+ si_text mediumtext NOT NULL
+) ENGINE=MyISAM;
+CREATE UNIQUE INDEX /*i*/si_page ON /*_*/searchindex (si_page);
+CREATE FULLTEXT INDEX /*i*/si_title ON /*_*/searchindex (si_title);
+CREATE FULLTEXT INDEX /*i*/si_text ON /*_*/searchindex (si_text);
+CREATE TABLE /*_*/interwiki (
+ iw_prefix varchar(32) NOT NULL,
+ iw_url blob NOT NULL,
+ iw_local bool NOT NULL,
+ iw_trans tinyint NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iw_prefix ON /*_*/interwiki (iw_prefix);
+CREATE TABLE /*_*/querycache (
+ qc_type varbinary(32) NOT NULL,
+ qc_value int unsigned NOT NULL default 0,
+ qc_namespace int NOT NULL default 0,
+ qc_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qc_type ON /*_*/querycache (qc_type,qc_value);
+CREATE TABLE /*_*/objectcache (
+ keyname varbinary(255) NOT NULL default '' PRIMARY KEY,
+ value mediumblob,
+ exptime datetime
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/exptime ON /*_*/objectcache (exptime);
+CREATE TABLE /*_*/transcache (
+ tc_url varbinary(255) NOT NULL,
+ tc_contents text,
+ tc_time binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tc_url_idx ON /*_*/transcache (tc_url);
+CREATE TABLE /*_*/logging (
+ log_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ log_type varbinary(32) NOT NULL default '',
+ log_action varbinary(32) NOT NULL default '',
+ log_timestamp binary(14) NOT NULL default '19700101000000',
+ log_user int unsigned NOT NULL default 0,
+ log_user_text varchar(255) binary NOT NULL default '',
+ log_namespace int NOT NULL default 0,
+ log_title varchar(255) binary NOT NULL default '',
+ log_page int unsigned NULL,
+ log_comment varchar(255) NOT NULL default '',
+ log_params blob NOT NULL,
+ log_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/type_time ON /*_*/logging (log_type, log_timestamp);
+CREATE INDEX /*i*/user_time ON /*_*/logging (log_user, log_timestamp);
+CREATE INDEX /*i*/page_time ON /*_*/logging (log_namespace, log_title, log_timestamp);
+CREATE INDEX /*i*/times ON /*_*/logging (log_timestamp);
+CREATE INDEX /*i*/log_user_type_time ON /*_*/logging (log_user, log_type, log_timestamp);
+CREATE INDEX /*i*/log_page_id_time ON /*_*/logging (log_page,log_timestamp);
+CREATE TABLE /*_*/log_search (
+ ls_field varbinary(32) NOT NULL,
+ ls_value varchar(255) NOT NULL,
+ ls_log_id int unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ls_field_val ON /*_*/log_search (ls_field,ls_value,ls_log_id);
+CREATE INDEX /*i*/ls_log_id ON /*_*/log_search (ls_log_id);
+CREATE TABLE /*_*/trackbacks (
+ tb_id int PRIMARY KEY AUTO_INCREMENT,
+ tb_page int REFERENCES /*_*/page(page_id) ON DELETE CASCADE,
+ tb_title varchar(255) NOT NULL,
+ tb_url blob NOT NULL,
+ tb_ex text,
+ tb_name varchar(255)
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/tb_page ON /*_*/trackbacks (tb_page);
+CREATE TABLE /*_*/job (
+ job_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ job_cmd varbinary(60) NOT NULL default '',
+ job_namespace int NOT NULL,
+ job_title varchar(255) binary NOT NULL,
+ job_params blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/job_cmd ON /*_*/job (job_cmd, job_namespace, job_title, job_params(128));
+CREATE TABLE /*_*/querycache_info (
+ qci_type varbinary(32) NOT NULL default '',
+ qci_timestamp binary(14) NOT NULL default '19700101000000'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/qci_type ON /*_*/querycache_info (qci_type);
+CREATE TABLE /*_*/redirect (
+ rd_from int unsigned NOT NULL default 0 PRIMARY KEY,
+ rd_namespace int NOT NULL default 0,
+ rd_title varchar(255) binary NOT NULL default '',
+ rd_interwiki varchar(32) default NULL,
+ rd_fragment varchar(255) binary default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rd_ns_title ON /*_*/redirect (rd_namespace,rd_title,rd_from);
+CREATE TABLE /*_*/querycachetwo (
+ qcc_type varbinary(32) NOT NULL,
+ qcc_value int unsigned NOT NULL default 0,
+ qcc_namespace int NOT NULL default 0,
+ qcc_title varchar(255) binary NOT NULL default '',
+ qcc_namespacetwo int NOT NULL default 0,
+ qcc_titletwo varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qcc_type ON /*_*/querycachetwo (qcc_type,qcc_value);
+CREATE INDEX /*i*/qcc_title ON /*_*/querycachetwo (qcc_type,qcc_namespace,qcc_title);
+CREATE INDEX /*i*/qcc_titletwo ON /*_*/querycachetwo (qcc_type,qcc_namespacetwo,qcc_titletwo);
+CREATE TABLE /*_*/page_restrictions (
+ pr_page int NOT NULL,
+ pr_type varbinary(60) NOT NULL,
+ pr_level varbinary(60) NOT NULL,
+ pr_cascade tinyint NOT NULL,
+ pr_user int NULL,
+ pr_expiry varbinary(14) NULL,
+ pr_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pr_pagetype ON /*_*/page_restrictions (pr_page,pr_type);
+CREATE INDEX /*i*/pr_typelevel ON /*_*/page_restrictions (pr_type,pr_level);
+CREATE INDEX /*i*/pr_level ON /*_*/page_restrictions (pr_level);
+CREATE INDEX /*i*/pr_cascade ON /*_*/page_restrictions (pr_cascade);
+CREATE TABLE /*_*/protected_titles (
+ pt_namespace int NOT NULL,
+ pt_title varchar(255) binary NOT NULL,
+ pt_user int unsigned NOT NULL,
+ pt_reason tinyblob,
+ pt_timestamp binary(14) NOT NULL,
+ pt_expiry varbinary(14) NOT NULL default '',
+ pt_create_perm varbinary(60) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pt_namespace_title ON /*_*/protected_titles (pt_namespace,pt_title);
+CREATE INDEX /*i*/pt_timestamp ON /*_*/protected_titles (pt_timestamp);
+CREATE TABLE /*_*/page_props (
+ pp_page int NOT NULL,
+ pp_propname varbinary(60) NOT NULL,
+ pp_value blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pp_page_propname ON /*_*/page_props (pp_page,pp_propname);
+CREATE TABLE /*_*/updatelog (
+ ul_key varchar(255) NOT NULL PRIMARY KEY
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/change_tag (
+ ct_rc_id int NULL,
+ ct_log_id int NULL,
+ ct_rev_id int NULL,
+ ct_tag varchar(255) NOT NULL,
+ ct_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/change_tag_rc_tag ON /*_*/change_tag (ct_rc_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_log_tag ON /*_*/change_tag (ct_log_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_rev_tag ON /*_*/change_tag (ct_rev_id,ct_tag);
+CREATE INDEX /*i*/change_tag_tag_id ON /*_*/change_tag (ct_tag,ct_rc_id,ct_rev_id,ct_log_id);
+CREATE TABLE /*_*/tag_summary (
+ ts_rc_id int NULL,
+ ts_log_id int NULL,
+ ts_rev_id int NULL,
+ ts_tags blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tag_summary_rc_id ON /*_*/tag_summary (ts_rc_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_log_id ON /*_*/tag_summary (ts_log_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_rev_id ON /*_*/tag_summary (ts_rev_id);
+CREATE TABLE /*_*/valid_tag (
+ vt_tag varchar(255) NOT NULL PRIMARY KEY
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/l10n_cache (
+ lc_lang varbinary(32) NOT NULL,
+ lc_key varchar(255) NOT NULL,
+ lc_value mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/lc_lang_key ON /*_*/l10n_cache (lc_lang, lc_key);
diff --git a/www/wiki/tests/phpunit/data/db/sqlite/tables-1.17.sql b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.17.sql
new file mode 100644
index 00000000..e02e3e14
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.17.sql
@@ -0,0 +1,511 @@
+-- This is a copy of MediaWiki 1.17 schema shared by MySQL and SQLite.
+-- It is used for updater testing. Comments are stripped to decrease
+-- file size, as we don't need to maintain it.
+
+CREATE TABLE /*_*/user (
+ user_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ user_name varchar(255) binary NOT NULL default '',
+ user_real_name varchar(255) binary NOT NULL default '',
+ user_password tinyblob NOT NULL,
+ user_newpassword tinyblob NOT NULL,
+ user_newpass_time binary(14),
+ user_email tinytext NOT NULL,
+ user_options blob NOT NULL,
+ user_touched binary(14) NOT NULL default '',
+ user_token binary(32) NOT NULL default '',
+ user_email_authenticated binary(14),
+ user_email_token binary(32),
+ user_email_token_expires binary(14),
+ user_registration binary(14),
+ user_editcount int
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_name ON /*_*/user (user_name);
+CREATE INDEX /*i*/user_email_token ON /*_*/user (user_email_token);
+CREATE TABLE /*_*/user_groups (
+ ug_user int unsigned NOT NULL default 0,
+ ug_group varbinary(16) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user,ug_group);
+CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE TABLE /*_*/user_newtalk (
+ user_id int NOT NULL default 0,
+ user_ip varbinary(40) NOT NULL default '',
+ user_last_timestamp binary(14) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id);
+CREATE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip);
+CREATE TABLE /*_*/user_properties (
+ up_user int NOT NULL,
+ up_property varbinary(32) NOT NULL,
+ up_value blob
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_properties_user_property ON /*_*/user_properties (up_user,up_property);
+CREATE INDEX /*i*/user_properties_property ON /*_*/user_properties (up_property);
+CREATE TABLE /*_*/page (
+ page_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ page_namespace int NOT NULL,
+ page_title varchar(255) binary NOT NULL,
+ page_restrictions tinyblob NOT NULL,
+ page_counter bigint unsigned NOT NULL default 0,
+ page_is_redirect tinyint unsigned NOT NULL default 0,
+ page_is_new tinyint unsigned NOT NULL default 0,
+ page_random real unsigned NOT NULL,
+ page_touched binary(14) NOT NULL default '',
+ page_latest int unsigned NOT NULL,
+ page_len int unsigned NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/name_title ON /*_*/page (page_namespace,page_title);
+CREATE INDEX /*i*/page_random ON /*_*/page (page_random);
+CREATE INDEX /*i*/page_len ON /*_*/page (page_len);
+CREATE TABLE /*_*/revision (
+ rev_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rev_page int unsigned NOT NULL,
+ rev_text_id int unsigned NOT NULL,
+ rev_comment tinyblob NOT NULL,
+ rev_user int unsigned NOT NULL default 0,
+ rev_user_text varchar(255) binary NOT NULL default '',
+ rev_timestamp binary(14) NOT NULL default '',
+ rev_minor_edit tinyint unsigned NOT NULL default 0,
+ rev_deleted tinyint unsigned NOT NULL default 0,
+ rev_len int unsigned,
+ rev_parent_id int unsigned default NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=1024;
+CREATE UNIQUE INDEX /*i*/rev_page_id ON /*_*/revision (rev_page, rev_id);
+CREATE INDEX /*i*/rev_timestamp ON /*_*/revision (rev_timestamp);
+CREATE INDEX /*i*/page_timestamp ON /*_*/revision (rev_page,rev_timestamp);
+CREATE INDEX /*i*/user_timestamp ON /*_*/revision (rev_user,rev_timestamp);
+CREATE INDEX /*i*/usertext_timestamp ON /*_*/revision (rev_user_text,rev_timestamp);
+CREATE TABLE /*_*/text (
+ old_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ old_text mediumblob NOT NULL,
+ old_flags tinyblob NOT NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=10240;
+CREATE TABLE /*_*/archive (
+ ar_namespace int NOT NULL default 0,
+ ar_title varchar(255) binary NOT NULL default '',
+ ar_text mediumblob NOT NULL,
+ ar_comment tinyblob NOT NULL,
+ ar_user int unsigned NOT NULL default 0,
+ ar_user_text varchar(255) binary NOT NULL,
+ ar_timestamp binary(14) NOT NULL default '',
+ ar_minor_edit tinyint NOT NULL default 0,
+ ar_flags tinyblob NOT NULL,
+ ar_rev_id int unsigned,
+ ar_text_id int unsigned,
+ ar_deleted tinyint unsigned NOT NULL default 0,
+ ar_len int unsigned,
+ ar_page_id int unsigned,
+ ar_parent_id int unsigned default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/name_title_timestamp ON /*_*/archive (ar_namespace,ar_title,ar_timestamp);
+CREATE INDEX /*i*/ar_usertext_timestamp ON /*_*/archive (ar_user_text,ar_timestamp);
+CREATE INDEX /*i*/ar_revid ON /*_*/archive (ar_rev_id);
+CREATE TABLE /*_*/pagelinks (
+ pl_from int unsigned NOT NULL default 0,
+ pl_namespace int NOT NULL default 0,
+ pl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pl_from ON /*_*/pagelinks (pl_from,pl_namespace,pl_title);
+CREATE UNIQUE INDEX /*i*/pl_namespace ON /*_*/pagelinks (pl_namespace,pl_title,pl_from);
+CREATE TABLE /*_*/templatelinks (
+ tl_from int unsigned NOT NULL default 0,
+ tl_namespace int NOT NULL default 0,
+ tl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tl_from ON /*_*/templatelinks (tl_from,tl_namespace,tl_title);
+CREATE UNIQUE INDEX /*i*/tl_namespace ON /*_*/templatelinks (tl_namespace,tl_title,tl_from);
+CREATE TABLE /*_*/imagelinks (
+ il_from int unsigned NOT NULL default 0,
+ il_to varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/il_from ON /*_*/imagelinks (il_from,il_to);
+CREATE UNIQUE INDEX /*i*/il_to ON /*_*/imagelinks (il_to,il_from);
+CREATE TABLE /*_*/categorylinks (
+ cl_from int unsigned NOT NULL default 0,
+ cl_to varchar(255) binary NOT NULL default '',
+ cl_sortkey varbinary(230) NOT NULL default '',
+ cl_sortkey_prefix varchar(255) binary NOT NULL default '',
+ cl_timestamp timestamp NOT NULL,
+ cl_collation varbinary(32) NOT NULL default '',
+ cl_type ENUM('page', 'subcat', 'file') NOT NULL default 'page'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cl_from ON /*_*/categorylinks (cl_from,cl_to);
+CREATE INDEX /*i*/cl_sortkey ON /*_*/categorylinks (cl_to,cl_type,cl_sortkey,cl_from);
+CREATE INDEX /*i*/cl_timestamp ON /*_*/categorylinks (cl_to,cl_timestamp);
+CREATE INDEX /*i*/cl_collation ON /*_*/categorylinks (cl_collation);
+CREATE TABLE /*_*/category (
+ cat_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ cat_title varchar(255) binary NOT NULL,
+ cat_pages int signed NOT NULL default 0,
+ cat_subcats int signed NOT NULL default 0,
+ cat_files int signed NOT NULL default 0,
+ cat_hidden tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cat_title ON /*_*/category (cat_title);
+CREATE INDEX /*i*/cat_pages ON /*_*/category (cat_pages);
+CREATE TABLE /*_*/externallinks (
+ el_from int unsigned NOT NULL default 0,
+ el_to blob NOT NULL,
+ el_index blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/el_from ON /*_*/externallinks (el_from, el_to(40));
+CREATE INDEX /*i*/el_to ON /*_*/externallinks (el_to(60), el_from);
+CREATE INDEX /*i*/el_index ON /*_*/externallinks (el_index(60));
+CREATE TABLE /*_*/langlinks (
+ ll_from int unsigned NOT NULL default 0,
+ ll_lang varbinary(20) NOT NULL default '',
+ ll_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ll_from ON /*_*/langlinks (ll_from, ll_lang);
+CREATE INDEX /*i*/ll_lang ON /*_*/langlinks (ll_lang, ll_title);
+CREATE TABLE /*_*/iwlinks (
+ iwl_from int unsigned NOT NULL default 0,
+ iwl_prefix varbinary(20) NOT NULL default '',
+ iwl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iwl_from ON /*_*/iwlinks (iwl_from, iwl_prefix, iwl_title);
+CREATE UNIQUE INDEX /*i*/iwl_prefix_title_from ON /*_*/iwlinks (iwl_prefix, iwl_title, iwl_from);
+CREATE TABLE /*_*/site_stats (
+ ss_row_id int unsigned NOT NULL,
+ ss_total_views bigint unsigned default 0,
+ ss_total_edits bigint unsigned default 0,
+ ss_good_articles bigint unsigned default 0,
+ ss_total_pages bigint default '-1',
+ ss_users bigint default '-1',
+ ss_active_users bigint default '-1',
+ ss_admins int default '-1',
+ ss_images int default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ss_row_id ON /*_*/site_stats (ss_row_id);
+CREATE TABLE /*_*/hitcounter (
+ hc_id int unsigned NOT NULL
+) ENGINE=HEAP MAX_ROWS=25000;
+CREATE TABLE /*_*/ipblocks (
+ ipb_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ ipb_address tinyblob NOT NULL,
+ ipb_user int unsigned NOT NULL default 0,
+ ipb_by int unsigned NOT NULL default 0,
+ ipb_by_text varchar(255) binary NOT NULL default '',
+ ipb_reason tinyblob NOT NULL,
+ ipb_timestamp binary(14) NOT NULL default '',
+ ipb_auto bool NOT NULL default 0,
+ ipb_anon_only bool NOT NULL default 0,
+ ipb_create_account bool NOT NULL default 1,
+ ipb_enable_autoblock bool NOT NULL default '1',
+ ipb_expiry varbinary(14) NOT NULL default '',
+ ipb_range_start tinyblob NOT NULL,
+ ipb_range_end tinyblob NOT NULL,
+ ipb_deleted bool NOT NULL default 0,
+ ipb_block_email bool NOT NULL default 0,
+ ipb_allow_usertalk bool NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ipb_address ON /*_*/ipblocks (ipb_address(255), ipb_user, ipb_auto, ipb_anon_only);
+CREATE INDEX /*i*/ipb_user ON /*_*/ipblocks (ipb_user);
+CREATE INDEX /*i*/ipb_range ON /*_*/ipblocks (ipb_range_start(8), ipb_range_end(8));
+CREATE INDEX /*i*/ipb_timestamp ON /*_*/ipblocks (ipb_timestamp);
+CREATE INDEX /*i*/ipb_expiry ON /*_*/ipblocks (ipb_expiry);
+CREATE TABLE /*_*/image (
+ img_name varchar(255) binary NOT NULL default '' PRIMARY KEY,
+ img_size int unsigned NOT NULL default 0,
+ img_width int NOT NULL default 0,
+ img_height int NOT NULL default 0,
+ img_metadata mediumblob NOT NULL,
+ img_bits int NOT NULL default 0,
+ img_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ img_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ img_minor_mime varbinary(100) NOT NULL default "unknown",
+ img_description tinyblob NOT NULL,
+ img_user int unsigned NOT NULL default 0,
+ img_user_text varchar(255) binary NOT NULL,
+ img_timestamp varbinary(14) NOT NULL default '',
+ img_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/img_usertext_timestamp ON /*_*/image (img_user_text,img_timestamp);
+CREATE INDEX /*i*/img_size ON /*_*/image (img_size);
+CREATE INDEX /*i*/img_timestamp ON /*_*/image (img_timestamp);
+CREATE INDEX /*i*/img_sha1 ON /*_*/image (img_sha1);
+CREATE TABLE /*_*/oldimage (
+ oi_name varchar(255) binary NOT NULL default '',
+ oi_archive_name varchar(255) binary NOT NULL default '',
+ oi_size int unsigned NOT NULL default 0,
+ oi_width int NOT NULL default 0,
+ oi_height int NOT NULL default 0,
+ oi_bits int NOT NULL default 0,
+ oi_description tinyblob NOT NULL,
+ oi_user int unsigned NOT NULL default 0,
+ oi_user_text varchar(255) binary NOT NULL,
+ oi_timestamp binary(14) NOT NULL default '',
+ oi_metadata mediumblob NOT NULL,
+ oi_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ oi_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ oi_minor_mime varbinary(100) NOT NULL default "unknown",
+ oi_deleted tinyint unsigned NOT NULL default 0,
+ oi_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/oi_usertext_timestamp ON /*_*/oldimage (oi_user_text,oi_timestamp);
+CREATE INDEX /*i*/oi_name_timestamp ON /*_*/oldimage (oi_name,oi_timestamp);
+CREATE INDEX /*i*/oi_name_archive_name ON /*_*/oldimage (oi_name,oi_archive_name(14));
+CREATE INDEX /*i*/oi_sha1 ON /*_*/oldimage (oi_sha1);
+CREATE TABLE /*_*/filearchive (
+ fa_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ fa_name varchar(255) binary NOT NULL default '',
+ fa_archive_name varchar(255) binary default '',
+ fa_storage_group varbinary(16),
+ fa_storage_key varbinary(64) default '',
+ fa_deleted_user int,
+ fa_deleted_timestamp binary(14) default '',
+ fa_deleted_reason text,
+ fa_size int unsigned default 0,
+ fa_width int default 0,
+ fa_height int default 0,
+ fa_metadata mediumblob,
+ fa_bits int default 0,
+ fa_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ fa_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") default "unknown",
+ fa_minor_mime varbinary(100) default "unknown",
+ fa_description tinyblob,
+ fa_user int unsigned default 0,
+ fa_user_text varchar(255) binary,
+ fa_timestamp binary(14) default '',
+ fa_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/fa_name ON /*_*/filearchive (fa_name, fa_timestamp);
+CREATE INDEX /*i*/fa_storage_group ON /*_*/filearchive (fa_storage_group, fa_storage_key);
+CREATE INDEX /*i*/fa_deleted_timestamp ON /*_*/filearchive (fa_deleted_timestamp);
+CREATE INDEX /*i*/fa_user_timestamp ON /*_*/filearchive (fa_user_text,fa_timestamp);
+CREATE TABLE /*_*/recentchanges (
+ rc_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rc_timestamp varbinary(14) NOT NULL default '',
+ rc_cur_time varbinary(14) NOT NULL default '',
+ rc_user int unsigned NOT NULL default 0,
+ rc_user_text varchar(255) binary NOT NULL,
+ rc_namespace int NOT NULL default 0,
+ rc_title varchar(255) binary NOT NULL default '',
+ rc_comment varchar(255) binary NOT NULL default '',
+ rc_minor tinyint unsigned NOT NULL default 0,
+ rc_bot tinyint unsigned NOT NULL default 0,
+ rc_new tinyint unsigned NOT NULL default 0,
+ rc_cur_id int unsigned NOT NULL default 0,
+ rc_this_oldid int unsigned NOT NULL default 0,
+ rc_last_oldid int unsigned NOT NULL default 0,
+ rc_type tinyint unsigned NOT NULL default 0,
+ rc_moved_to_ns tinyint unsigned NOT NULL default 0,
+ rc_moved_to_title varchar(255) binary NOT NULL default '',
+ rc_patrolled tinyint unsigned NOT NULL default 0,
+ rc_ip varbinary(40) NOT NULL default '',
+ rc_old_len int,
+ rc_new_len int,
+ rc_deleted tinyint unsigned NOT NULL default 0,
+ rc_logid int unsigned NOT NULL default 0,
+ rc_log_type varbinary(255) NULL default NULL,
+ rc_log_action varbinary(255) NULL default NULL,
+ rc_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rc_timestamp ON /*_*/recentchanges (rc_timestamp);
+CREATE INDEX /*i*/rc_namespace_title ON /*_*/recentchanges (rc_namespace, rc_title);
+CREATE INDEX /*i*/rc_cur_id ON /*_*/recentchanges (rc_cur_id);
+CREATE INDEX /*i*/new_name_timestamp ON /*_*/recentchanges (rc_new,rc_namespace,rc_timestamp);
+CREATE INDEX /*i*/rc_ip ON /*_*/recentchanges (rc_ip);
+CREATE INDEX /*i*/rc_ns_usertext ON /*_*/recentchanges (rc_namespace, rc_user_text);
+CREATE INDEX /*i*/rc_user_text ON /*_*/recentchanges (rc_user_text, rc_timestamp);
+CREATE TABLE /*_*/watchlist (
+ wl_user int unsigned NOT NULL,
+ wl_namespace int NOT NULL default 0,
+ wl_title varchar(255) binary NOT NULL default '',
+ wl_notificationtimestamp varbinary(14)
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/wl_user ON /*_*/watchlist (wl_user, wl_namespace, wl_title);
+CREATE INDEX /*i*/namespace_title ON /*_*/watchlist (wl_namespace, wl_title);
+CREATE TABLE /*_*/math (
+ math_inputhash varbinary(16) NOT NULL,
+ math_outputhash varbinary(16) NOT NULL,
+ math_html_conservativeness tinyint NOT NULL,
+ math_html text,
+ math_mathml text
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/math_inputhash ON /*_*/math (math_inputhash);
+CREATE TABLE /*_*/searchindex (
+ si_page int unsigned NOT NULL,
+ si_title varchar(255) NOT NULL default '',
+ si_text mediumtext NOT NULL
+) ENGINE=MyISAM;
+CREATE UNIQUE INDEX /*i*/si_page ON /*_*/searchindex (si_page);
+CREATE FULLTEXT INDEX /*i*/si_title ON /*_*/searchindex (si_title);
+CREATE FULLTEXT INDEX /*i*/si_text ON /*_*/searchindex (si_text);
+CREATE TABLE /*_*/interwiki (
+ iw_prefix varchar(32) NOT NULL,
+ iw_url blob NOT NULL,
+ iw_api blob NOT NULL,
+ iw_wikiid varchar(64) NOT NULL,
+ iw_local bool NOT NULL,
+ iw_trans tinyint NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iw_prefix ON /*_*/interwiki (iw_prefix);
+CREATE TABLE /*_*/querycache (
+ qc_type varbinary(32) NOT NULL,
+ qc_value int unsigned NOT NULL default 0,
+ qc_namespace int NOT NULL default 0,
+ qc_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qc_type ON /*_*/querycache (qc_type,qc_value);
+CREATE TABLE /*_*/objectcache (
+ keyname varbinary(255) NOT NULL default '' PRIMARY KEY,
+ value mediumblob,
+ exptime datetime
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/exptime ON /*_*/objectcache (exptime);
+CREATE TABLE /*_*/transcache (
+ tc_url varbinary(255) NOT NULL,
+ tc_contents text,
+ tc_time binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tc_url_idx ON /*_*/transcache (tc_url);
+CREATE TABLE /*_*/logging (
+ log_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ log_type varbinary(32) NOT NULL default '',
+ log_action varbinary(32) NOT NULL default '',
+ log_timestamp binary(14) NOT NULL default '19700101000000',
+ log_user int unsigned NOT NULL default 0,
+ log_user_text varchar(255) binary NOT NULL default '',
+ log_namespace int NOT NULL default 0,
+ log_title varchar(255) binary NOT NULL default '',
+ log_page int unsigned NULL,
+ log_comment varchar(255) NOT NULL default '',
+ log_params blob NOT NULL,
+ log_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/type_time ON /*_*/logging (log_type, log_timestamp);
+CREATE INDEX /*i*/user_time ON /*_*/logging (log_user, log_timestamp);
+CREATE INDEX /*i*/page_time ON /*_*/logging (log_namespace, log_title, log_timestamp);
+CREATE INDEX /*i*/times ON /*_*/logging (log_timestamp);
+CREATE INDEX /*i*/log_user_type_time ON /*_*/logging (log_user, log_type, log_timestamp);
+CREATE INDEX /*i*/log_page_id_time ON /*_*/logging (log_page,log_timestamp);
+CREATE TABLE /*_*/log_search (
+ ls_field varbinary(32) NOT NULL,
+ ls_value varchar(255) NOT NULL,
+ ls_log_id int unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ls_field_val ON /*_*/log_search (ls_field,ls_value,ls_log_id);
+CREATE INDEX /*i*/ls_log_id ON /*_*/log_search (ls_log_id);
+CREATE TABLE /*_*/trackbacks (
+ tb_id int PRIMARY KEY AUTO_INCREMENT,
+ tb_page int REFERENCES /*_*/page(page_id) ON DELETE CASCADE,
+ tb_title varchar(255) NOT NULL,
+ tb_url blob NOT NULL,
+ tb_ex text,
+ tb_name varchar(255)
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/tb_page ON /*_*/trackbacks (tb_page);
+CREATE TABLE /*_*/job (
+ job_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ job_cmd varbinary(60) NOT NULL default '',
+ job_namespace int NOT NULL,
+ job_title varchar(255) binary NOT NULL,
+ job_params blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/job_cmd ON /*_*/job (job_cmd, job_namespace, job_title, job_params(128));
+CREATE TABLE /*_*/querycache_info (
+ qci_type varbinary(32) NOT NULL default '',
+ qci_timestamp binary(14) NOT NULL default '19700101000000'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/qci_type ON /*_*/querycache_info (qci_type);
+CREATE TABLE /*_*/redirect (
+ rd_from int unsigned NOT NULL default 0 PRIMARY KEY,
+ rd_namespace int NOT NULL default 0,
+ rd_title varchar(255) binary NOT NULL default '',
+ rd_interwiki varchar(32) default NULL,
+ rd_fragment varchar(255) binary default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rd_ns_title ON /*_*/redirect (rd_namespace,rd_title,rd_from);
+CREATE TABLE /*_*/querycachetwo (
+ qcc_type varbinary(32) NOT NULL,
+ qcc_value int unsigned NOT NULL default 0,
+ qcc_namespace int NOT NULL default 0,
+ qcc_title varchar(255) binary NOT NULL default '',
+ qcc_namespacetwo int NOT NULL default 0,
+ qcc_titletwo varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qcc_type ON /*_*/querycachetwo (qcc_type,qcc_value);
+CREATE INDEX /*i*/qcc_title ON /*_*/querycachetwo (qcc_type,qcc_namespace,qcc_title);
+CREATE INDEX /*i*/qcc_titletwo ON /*_*/querycachetwo (qcc_type,qcc_namespacetwo,qcc_titletwo);
+CREATE TABLE /*_*/page_restrictions (
+ pr_page int NOT NULL,
+ pr_type varbinary(60) NOT NULL,
+ pr_level varbinary(60) NOT NULL,
+ pr_cascade tinyint NOT NULL,
+ pr_user int NULL,
+ pr_expiry varbinary(14) NULL,
+ pr_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pr_pagetype ON /*_*/page_restrictions (pr_page,pr_type);
+CREATE INDEX /*i*/pr_typelevel ON /*_*/page_restrictions (pr_type,pr_level);
+CREATE INDEX /*i*/pr_level ON /*_*/page_restrictions (pr_level);
+CREATE INDEX /*i*/pr_cascade ON /*_*/page_restrictions (pr_cascade);
+CREATE TABLE /*_*/protected_titles (
+ pt_namespace int NOT NULL,
+ pt_title varchar(255) binary NOT NULL,
+ pt_user int unsigned NOT NULL,
+ pt_reason tinyblob,
+ pt_timestamp binary(14) NOT NULL,
+ pt_expiry varbinary(14) NOT NULL default '',
+ pt_create_perm varbinary(60) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pt_namespace_title ON /*_*/protected_titles (pt_namespace,pt_title);
+CREATE INDEX /*i*/pt_timestamp ON /*_*/protected_titles (pt_timestamp);
+CREATE TABLE /*_*/page_props (
+ pp_page int NOT NULL,
+ pp_propname varbinary(60) NOT NULL,
+ pp_value blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pp_page_propname ON /*_*/page_props (pp_page,pp_propname);
+CREATE TABLE /*_*/updatelog (
+ ul_key varchar(255) NOT NULL PRIMARY KEY,
+ ul_value blob
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/change_tag (
+ ct_rc_id int NULL,
+ ct_log_id int NULL,
+ ct_rev_id int NULL,
+ ct_tag varchar(255) NOT NULL,
+ ct_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/change_tag_rc_tag ON /*_*/change_tag (ct_rc_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_log_tag ON /*_*/change_tag (ct_log_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_rev_tag ON /*_*/change_tag (ct_rev_id,ct_tag);
+CREATE INDEX /*i*/change_tag_tag_id ON /*_*/change_tag (ct_tag,ct_rc_id,ct_rev_id,ct_log_id);
+CREATE TABLE /*_*/tag_summary (
+ ts_rc_id int NULL,
+ ts_log_id int NULL,
+ ts_rev_id int NULL,
+ ts_tags blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tag_summary_rc_id ON /*_*/tag_summary (ts_rc_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_log_id ON /*_*/tag_summary (ts_log_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_rev_id ON /*_*/tag_summary (ts_rev_id);
+CREATE TABLE /*_*/valid_tag (
+ vt_tag varchar(255) NOT NULL PRIMARY KEY
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/l10n_cache (
+ lc_lang varbinary(32) NOT NULL,
+ lc_key varchar(255) NOT NULL,
+ lc_value mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/lc_lang_key ON /*_*/l10n_cache (lc_lang, lc_key);
+CREATE TABLE /*_*/msg_resource (
+ mr_resource varbinary(255) NOT NULL,
+ mr_lang varbinary(32) NOT NULL,
+ mr_blob mediumblob NOT NULL,
+ mr_timestamp binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mr_resource_lang ON /*_*/msg_resource (mr_resource, mr_lang);
+CREATE TABLE /*_*/msg_resource_links (
+ mrl_resource varbinary(255) NOT NULL,
+ mrl_message varbinary(255) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mrl_message_resource ON /*_*/msg_resource_links (mrl_message, mrl_resource);
+CREATE TABLE /*_*/module_deps (
+ md_module varbinary(255) NOT NULL,
+ md_skin varbinary(32) NOT NULL,
+ md_deps mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/md_module_skin ON /*_*/module_deps (md_module, md_skin);
diff --git a/www/wiki/tests/phpunit/data/db/sqlite/tables-1.18.sql b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.18.sql
new file mode 100644
index 00000000..8bfc28e2
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.18.sql
@@ -0,0 +1,530 @@
+-- This is a copy of MediaWiki 1.18 schema shared by MySQL and SQLite.
+-- It is used for updater testing. Comments are stripped to decrease
+-- file size, as we don't need to maintain it.
+
+CREATE TABLE /*_*/user (
+ user_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ user_name varchar(255) binary NOT NULL default '',
+ user_real_name varchar(255) binary NOT NULL default '',
+ user_password tinyblob NOT NULL,
+ user_newpassword tinyblob NOT NULL,
+ user_newpass_time binary(14),
+ user_email tinytext NOT NULL,
+ user_options blob NOT NULL,
+ user_touched binary(14) NOT NULL default '',
+ user_token binary(32) NOT NULL default '',
+ user_email_authenticated binary(14),
+ user_email_token binary(32),
+ user_email_token_expires binary(14),
+ user_registration binary(14),
+ user_editcount int
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_name ON /*_*/user (user_name);
+CREATE INDEX /*i*/user_email_token ON /*_*/user (user_email_token);
+CREATE INDEX /*i*/user_email ON /*_*/user (user_email(50));
+CREATE TABLE /*_*/user_groups (
+ ug_user int unsigned NOT NULL default 0,
+ ug_group varbinary(16) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user,ug_group);
+CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE TABLE /*_*/user_former_groups (
+ ufg_user int unsigned NOT NULL default 0,
+ ufg_group varbinary(16) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ufg_user_group ON /*_*/user_former_groups (ufg_user,ufg_group);
+CREATE TABLE /*_*/user_newtalk (
+ user_id int NOT NULL default 0,
+ user_ip varbinary(40) NOT NULL default '',
+ user_last_timestamp varbinary(14) NULL default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id);
+CREATE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip);
+CREATE TABLE /*_*/user_properties (
+ up_user int NOT NULL,
+ up_property varbinary(255) NOT NULL,
+ up_value blob
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_properties_user_property ON /*_*/user_properties (up_user,up_property);
+CREATE INDEX /*i*/user_properties_property ON /*_*/user_properties (up_property);
+CREATE TABLE /*_*/page (
+ page_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ page_namespace int NOT NULL,
+ page_title varchar(255) binary NOT NULL,
+ page_restrictions tinyblob NOT NULL,
+ page_counter bigint unsigned NOT NULL default 0,
+ page_is_redirect tinyint unsigned NOT NULL default 0,
+ page_is_new tinyint unsigned NOT NULL default 0,
+ page_random real unsigned NOT NULL,
+ page_touched binary(14) NOT NULL default '',
+ page_latest int unsigned NOT NULL,
+ page_len int unsigned NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/name_title ON /*_*/page (page_namespace,page_title);
+CREATE INDEX /*i*/page_random ON /*_*/page (page_random);
+CREATE INDEX /*i*/page_len ON /*_*/page (page_len);
+CREATE TABLE /*_*/revision (
+ rev_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rev_page int unsigned NOT NULL,
+ rev_text_id int unsigned NOT NULL,
+ rev_comment tinyblob NOT NULL,
+ rev_user int unsigned NOT NULL default 0,
+ rev_user_text varchar(255) binary NOT NULL default '',
+ rev_timestamp binary(14) NOT NULL default '',
+ rev_minor_edit tinyint unsigned NOT NULL default 0,
+ rev_deleted tinyint unsigned NOT NULL default 0,
+ rev_len int unsigned,
+ rev_parent_id int unsigned default NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=1024;
+CREATE UNIQUE INDEX /*i*/rev_page_id ON /*_*/revision (rev_page, rev_id);
+CREATE INDEX /*i*/rev_timestamp ON /*_*/revision (rev_timestamp);
+CREATE INDEX /*i*/page_timestamp ON /*_*/revision (rev_page,rev_timestamp);
+CREATE INDEX /*i*/user_timestamp ON /*_*/revision (rev_user,rev_timestamp);
+CREATE INDEX /*i*/usertext_timestamp ON /*_*/revision (rev_user_text,rev_timestamp);
+CREATE TABLE /*_*/text (
+ old_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ old_text mediumblob NOT NULL,
+ old_flags tinyblob NOT NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=10240;
+CREATE TABLE /*_*/archive (
+ ar_namespace int NOT NULL default 0,
+ ar_title varchar(255) binary NOT NULL default '',
+ ar_text mediumblob NOT NULL,
+ ar_comment tinyblob NOT NULL,
+ ar_user int unsigned NOT NULL default 0,
+ ar_user_text varchar(255) binary NOT NULL,
+ ar_timestamp binary(14) NOT NULL default '',
+ ar_minor_edit tinyint NOT NULL default 0,
+ ar_flags tinyblob NOT NULL,
+ ar_rev_id int unsigned,
+ ar_text_id int unsigned,
+ ar_deleted tinyint unsigned NOT NULL default 0,
+ ar_len int unsigned,
+ ar_page_id int unsigned,
+ ar_parent_id int unsigned default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/name_title_timestamp ON /*_*/archive (ar_namespace,ar_title,ar_timestamp);
+CREATE INDEX /*i*/ar_usertext_timestamp ON /*_*/archive (ar_user_text,ar_timestamp);
+CREATE INDEX /*i*/ar_revid ON /*_*/archive (ar_rev_id);
+CREATE TABLE /*_*/pagelinks (
+ pl_from int unsigned NOT NULL default 0,
+ pl_namespace int NOT NULL default 0,
+ pl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pl_from ON /*_*/pagelinks (pl_from,pl_namespace,pl_title);
+CREATE UNIQUE INDEX /*i*/pl_namespace ON /*_*/pagelinks (pl_namespace,pl_title,pl_from);
+CREATE TABLE /*_*/templatelinks (
+ tl_from int unsigned NOT NULL default 0,
+ tl_namespace int NOT NULL default 0,
+ tl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tl_from ON /*_*/templatelinks (tl_from,tl_namespace,tl_title);
+CREATE UNIQUE INDEX /*i*/tl_namespace ON /*_*/templatelinks (tl_namespace,tl_title,tl_from);
+CREATE TABLE /*_*/imagelinks (
+ il_from int unsigned NOT NULL default 0,
+ il_to varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/il_from ON /*_*/imagelinks (il_from,il_to);
+CREATE UNIQUE INDEX /*i*/il_to ON /*_*/imagelinks (il_to,il_from);
+CREATE TABLE /*_*/categorylinks (
+ cl_from int unsigned NOT NULL default 0,
+ cl_to varchar(255) binary NOT NULL default '',
+ cl_sortkey varbinary(230) NOT NULL default '',
+ cl_sortkey_prefix varchar(255) binary NOT NULL default '',
+ cl_timestamp timestamp NOT NULL,
+ cl_collation varbinary(32) NOT NULL default '',
+ cl_type ENUM('page', 'subcat', 'file') NOT NULL default 'page'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cl_from ON /*_*/categorylinks (cl_from,cl_to);
+CREATE INDEX /*i*/cl_sortkey ON /*_*/categorylinks (cl_to,cl_type,cl_sortkey,cl_from);
+CREATE INDEX /*i*/cl_timestamp ON /*_*/categorylinks (cl_to,cl_timestamp);
+CREATE INDEX /*i*/cl_collation ON /*_*/categorylinks (cl_collation);
+CREATE TABLE /*_*/category (
+ cat_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ cat_title varchar(255) binary NOT NULL,
+ cat_pages int signed NOT NULL default 0,
+ cat_subcats int signed NOT NULL default 0,
+ cat_files int signed NOT NULL default 0,
+ cat_hidden tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cat_title ON /*_*/category (cat_title);
+CREATE INDEX /*i*/cat_pages ON /*_*/category (cat_pages);
+CREATE TABLE /*_*/externallinks (
+ el_from int unsigned NOT NULL default 0,
+ el_to blob NOT NULL,
+ el_index blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/el_from ON /*_*/externallinks (el_from, el_to(40));
+CREATE INDEX /*i*/el_to ON /*_*/externallinks (el_to(60), el_from);
+CREATE INDEX /*i*/el_index ON /*_*/externallinks (el_index(60));
+CREATE TABLE /*_*/langlinks (
+ ll_from int unsigned NOT NULL default 0,
+ ll_lang varbinary(20) NOT NULL default '',
+ ll_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ll_from ON /*_*/langlinks (ll_from, ll_lang);
+CREATE INDEX /*i*/ll_lang ON /*_*/langlinks (ll_lang, ll_title);
+CREATE TABLE /*_*/iwlinks (
+ iwl_from int unsigned NOT NULL default 0,
+ iwl_prefix varbinary(20) NOT NULL default '',
+ iwl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iwl_from ON /*_*/iwlinks (iwl_from, iwl_prefix, iwl_title);
+CREATE UNIQUE INDEX /*i*/iwl_prefix_title_from ON /*_*/iwlinks (iwl_prefix, iwl_title, iwl_from);
+CREATE TABLE /*_*/site_stats (
+ ss_row_id int unsigned NOT NULL,
+ ss_total_views bigint unsigned default 0,
+ ss_total_edits bigint unsigned default 0,
+ ss_good_articles bigint unsigned default 0,
+ ss_total_pages bigint default '-1',
+ ss_users bigint default '-1',
+ ss_active_users bigint default '-1',
+ ss_admins int default '-1',
+ ss_images int default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ss_row_id ON /*_*/site_stats (ss_row_id);
+CREATE TABLE /*_*/hitcounter (
+ hc_id int unsigned NOT NULL
+) ENGINE=HEAP MAX_ROWS=25000;
+CREATE TABLE /*_*/ipblocks (
+ ipb_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ ipb_address tinyblob NOT NULL,
+ ipb_user int unsigned NOT NULL default 0,
+ ipb_by int unsigned NOT NULL default 0,
+ ipb_by_text varchar(255) binary NOT NULL default '',
+ ipb_reason tinyblob NOT NULL,
+ ipb_timestamp binary(14) NOT NULL default '',
+ ipb_auto bool NOT NULL default 0,
+ ipb_anon_only bool NOT NULL default 0,
+ ipb_create_account bool NOT NULL default 1,
+ ipb_enable_autoblock bool NOT NULL default '1',
+ ipb_expiry varbinary(14) NOT NULL default '',
+ ipb_range_start tinyblob NOT NULL,
+ ipb_range_end tinyblob NOT NULL,
+ ipb_deleted bool NOT NULL default 0,
+ ipb_block_email bool NOT NULL default 0,
+ ipb_allow_usertalk bool NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ipb_address ON /*_*/ipblocks (ipb_address(255), ipb_user, ipb_auto, ipb_anon_only);
+CREATE INDEX /*i*/ipb_user ON /*_*/ipblocks (ipb_user);
+CREATE INDEX /*i*/ipb_range ON /*_*/ipblocks (ipb_range_start(8), ipb_range_end(8));
+CREATE INDEX /*i*/ipb_timestamp ON /*_*/ipblocks (ipb_timestamp);
+CREATE INDEX /*i*/ipb_expiry ON /*_*/ipblocks (ipb_expiry);
+CREATE TABLE /*_*/image (
+ img_name varchar(255) binary NOT NULL default '' PRIMARY KEY,
+ img_size int unsigned NOT NULL default 0,
+ img_width int NOT NULL default 0,
+ img_height int NOT NULL default 0,
+ img_metadata mediumblob NOT NULL,
+ img_bits int NOT NULL default 0,
+ img_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ img_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ img_minor_mime varbinary(100) NOT NULL default "unknown",
+ img_description tinyblob NOT NULL,
+ img_user int unsigned NOT NULL default 0,
+ img_user_text varchar(255) binary NOT NULL,
+ img_timestamp varbinary(14) NOT NULL default '',
+ img_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/img_usertext_timestamp ON /*_*/image (img_user_text,img_timestamp);
+CREATE INDEX /*i*/img_size ON /*_*/image (img_size);
+CREATE INDEX /*i*/img_timestamp ON /*_*/image (img_timestamp);
+CREATE INDEX /*i*/img_sha1 ON /*_*/image (img_sha1);
+CREATE TABLE /*_*/oldimage (
+ oi_name varchar(255) binary NOT NULL default '',
+ oi_archive_name varchar(255) binary NOT NULL default '',
+ oi_size int unsigned NOT NULL default 0,
+ oi_width int NOT NULL default 0,
+ oi_height int NOT NULL default 0,
+ oi_bits int NOT NULL default 0,
+ oi_description tinyblob NOT NULL,
+ oi_user int unsigned NOT NULL default 0,
+ oi_user_text varchar(255) binary NOT NULL,
+ oi_timestamp binary(14) NOT NULL default '',
+ oi_metadata mediumblob NOT NULL,
+ oi_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ oi_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ oi_minor_mime varbinary(100) NOT NULL default "unknown",
+ oi_deleted tinyint unsigned NOT NULL default 0,
+ oi_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/oi_usertext_timestamp ON /*_*/oldimage (oi_user_text,oi_timestamp);
+CREATE INDEX /*i*/oi_name_timestamp ON /*_*/oldimage (oi_name,oi_timestamp);
+CREATE INDEX /*i*/oi_name_archive_name ON /*_*/oldimage (oi_name,oi_archive_name(14));
+CREATE INDEX /*i*/oi_sha1 ON /*_*/oldimage (oi_sha1);
+CREATE TABLE /*_*/filearchive (
+ fa_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ fa_name varchar(255) binary NOT NULL default '',
+ fa_archive_name varchar(255) binary default '',
+ fa_storage_group varbinary(16),
+ fa_storage_key varbinary(64) default '',
+ fa_deleted_user int,
+ fa_deleted_timestamp binary(14) default '',
+ fa_deleted_reason text,
+ fa_size int unsigned default 0,
+ fa_width int default 0,
+ fa_height int default 0,
+ fa_metadata mediumblob,
+ fa_bits int default 0,
+ fa_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ fa_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") default "unknown",
+ fa_minor_mime varbinary(100) default "unknown",
+ fa_description tinyblob,
+ fa_user int unsigned default 0,
+ fa_user_text varchar(255) binary,
+ fa_timestamp binary(14) default '',
+ fa_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/fa_name ON /*_*/filearchive (fa_name, fa_timestamp);
+CREATE INDEX /*i*/fa_storage_group ON /*_*/filearchive (fa_storage_group, fa_storage_key);
+CREATE INDEX /*i*/fa_deleted_timestamp ON /*_*/filearchive (fa_deleted_timestamp);
+CREATE INDEX /*i*/fa_user_timestamp ON /*_*/filearchive (fa_user_text,fa_timestamp);
+CREATE TABLE /*_*/uploadstash (
+ us_id int unsigned NOT NULL PRIMARY KEY auto_increment,
+ us_user int unsigned NOT NULL,
+ us_key varchar(255) NOT NULL,
+ us_orig_path varchar(255) NOT NULL,
+ us_path varchar(255) NOT NULL,
+ us_source_type varchar(50),
+ us_timestamp varbinary(14) not null,
+ us_status varchar(50) not null,
+ us_size int unsigned NOT NULL,
+ us_sha1 varchar(31) NOT NULL,
+ us_mime varchar(255),
+ us_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ us_image_width int unsigned,
+ us_image_height int unsigned,
+ us_image_bits smallint unsigned
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/us_user ON /*_*/uploadstash (us_user);
+CREATE UNIQUE INDEX /*i*/us_key ON /*_*/uploadstash (us_key);
+CREATE INDEX /*i*/us_timestamp ON /*_*/uploadstash (us_timestamp);
+CREATE TABLE /*_*/recentchanges (
+ rc_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rc_timestamp varbinary(14) NOT NULL default '',
+ rc_cur_time varbinary(14) NOT NULL default '',
+ rc_user int unsigned NOT NULL default 0,
+ rc_user_text varchar(255) binary NOT NULL,
+ rc_namespace int NOT NULL default 0,
+ rc_title varchar(255) binary NOT NULL default '',
+ rc_comment varchar(255) binary NOT NULL default '',
+ rc_minor tinyint unsigned NOT NULL default 0,
+ rc_bot tinyint unsigned NOT NULL default 0,
+ rc_new tinyint unsigned NOT NULL default 0,
+ rc_cur_id int unsigned NOT NULL default 0,
+ rc_this_oldid int unsigned NOT NULL default 0,
+ rc_last_oldid int unsigned NOT NULL default 0,
+ rc_type tinyint unsigned NOT NULL default 0,
+ rc_moved_to_ns tinyint unsigned NOT NULL default 0,
+ rc_moved_to_title varchar(255) binary NOT NULL default '',
+ rc_patrolled tinyint unsigned NOT NULL default 0,
+ rc_ip varbinary(40) NOT NULL default '',
+ rc_old_len int,
+ rc_new_len int,
+ rc_deleted tinyint unsigned NOT NULL default 0,
+ rc_logid int unsigned NOT NULL default 0,
+ rc_log_type varbinary(255) NULL default NULL,
+ rc_log_action varbinary(255) NULL default NULL,
+ rc_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rc_timestamp ON /*_*/recentchanges (rc_timestamp);
+CREATE INDEX /*i*/rc_namespace_title ON /*_*/recentchanges (rc_namespace, rc_title);
+CREATE INDEX /*i*/rc_cur_id ON /*_*/recentchanges (rc_cur_id);
+CREATE INDEX /*i*/new_name_timestamp ON /*_*/recentchanges (rc_new,rc_namespace,rc_timestamp);
+CREATE INDEX /*i*/rc_ip ON /*_*/recentchanges (rc_ip);
+CREATE INDEX /*i*/rc_ns_usertext ON /*_*/recentchanges (rc_namespace, rc_user_text);
+CREATE INDEX /*i*/rc_user_text ON /*_*/recentchanges (rc_user_text, rc_timestamp);
+CREATE TABLE /*_*/watchlist (
+ wl_user int unsigned NOT NULL,
+ wl_namespace int NOT NULL default 0,
+ wl_title varchar(255) binary NOT NULL default '',
+ wl_notificationtimestamp varbinary(14)
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/wl_user ON /*_*/watchlist (wl_user, wl_namespace, wl_title);
+CREATE INDEX /*i*/namespace_title ON /*_*/watchlist (wl_namespace, wl_title);
+CREATE TABLE /*_*/searchindex (
+ si_page int unsigned NOT NULL,
+ si_title varchar(255) NOT NULL default '',
+ si_text mediumtext NOT NULL
+) ENGINE=MyISAM;
+CREATE UNIQUE INDEX /*i*/si_page ON /*_*/searchindex (si_page);
+CREATE FULLTEXT INDEX /*i*/si_title ON /*_*/searchindex (si_title);
+CREATE FULLTEXT INDEX /*i*/si_text ON /*_*/searchindex (si_text);
+CREATE TABLE /*_*/interwiki (
+ iw_prefix varchar(32) NOT NULL,
+ iw_url blob NOT NULL,
+ iw_api blob NOT NULL,
+ iw_wikiid varchar(64) NOT NULL,
+ iw_local bool NOT NULL,
+ iw_trans tinyint NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iw_prefix ON /*_*/interwiki (iw_prefix);
+CREATE TABLE /*_*/querycache (
+ qc_type varbinary(32) NOT NULL,
+ qc_value int unsigned NOT NULL default 0,
+ qc_namespace int NOT NULL default 0,
+ qc_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qc_type ON /*_*/querycache (qc_type,qc_value);
+CREATE TABLE /*_*/objectcache (
+ keyname varbinary(255) NOT NULL default '' PRIMARY KEY,
+ value mediumblob,
+ exptime datetime
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/exptime ON /*_*/objectcache (exptime);
+CREATE TABLE /*_*/transcache (
+ tc_url varbinary(255) NOT NULL,
+ tc_contents text,
+ tc_time binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tc_url_idx ON /*_*/transcache (tc_url);
+CREATE TABLE /*_*/logging (
+ log_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ log_type varbinary(32) NOT NULL default '',
+ log_action varbinary(32) NOT NULL default '',
+ log_timestamp binary(14) NOT NULL default '19700101000000',
+ log_user int unsigned NOT NULL default 0,
+ log_user_text varchar(255) binary NOT NULL default '',
+ log_namespace int NOT NULL default 0,
+ log_title varchar(255) binary NOT NULL default '',
+ log_page int unsigned NULL,
+ log_comment varchar(255) NOT NULL default '',
+ log_params blob NOT NULL,
+ log_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/type_time ON /*_*/logging (log_type, log_timestamp);
+CREATE INDEX /*i*/user_time ON /*_*/logging (log_user, log_timestamp);
+CREATE INDEX /*i*/page_time ON /*_*/logging (log_namespace, log_title, log_timestamp);
+CREATE INDEX /*i*/times ON /*_*/logging (log_timestamp);
+CREATE INDEX /*i*/log_user_type_time ON /*_*/logging (log_user, log_type, log_timestamp);
+CREATE INDEX /*i*/log_page_id_time ON /*_*/logging (log_page,log_timestamp);
+CREATE TABLE /*_*/log_search (
+ ls_field varbinary(32) NOT NULL,
+ ls_value varchar(255) NOT NULL,
+ ls_log_id int unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ls_field_val ON /*_*/log_search (ls_field,ls_value,ls_log_id);
+CREATE INDEX /*i*/ls_log_id ON /*_*/log_search (ls_log_id);
+CREATE TABLE /*_*/trackbacks (
+ tb_id int PRIMARY KEY AUTO_INCREMENT,
+ tb_page int REFERENCES /*_*/page(page_id) ON DELETE CASCADE,
+ tb_title varchar(255) NOT NULL,
+ tb_url blob NOT NULL,
+ tb_ex text,
+ tb_name varchar(255)
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/tb_page ON /*_*/trackbacks (tb_page);
+CREATE TABLE /*_*/job (
+ job_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ job_cmd varbinary(60) NOT NULL default '',
+ job_namespace int NOT NULL,
+ job_title varchar(255) binary NOT NULL,
+ job_params blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/job_cmd ON /*_*/job (job_cmd, job_namespace, job_title, job_params(128));
+CREATE TABLE /*_*/querycache_info (
+ qci_type varbinary(32) NOT NULL default '',
+ qci_timestamp binary(14) NOT NULL default '19700101000000'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/qci_type ON /*_*/querycache_info (qci_type);
+CREATE TABLE /*_*/redirect (
+ rd_from int unsigned NOT NULL default 0 PRIMARY KEY,
+ rd_namespace int NOT NULL default 0,
+ rd_title varchar(255) binary NOT NULL default '',
+ rd_interwiki varchar(32) default NULL,
+ rd_fragment varchar(255) binary default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rd_ns_title ON /*_*/redirect (rd_namespace,rd_title,rd_from);
+CREATE TABLE /*_*/querycachetwo (
+ qcc_type varbinary(32) NOT NULL,
+ qcc_value int unsigned NOT NULL default 0,
+ qcc_namespace int NOT NULL default 0,
+ qcc_title varchar(255) binary NOT NULL default '',
+ qcc_namespacetwo int NOT NULL default 0,
+ qcc_titletwo varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qcc_type ON /*_*/querycachetwo (qcc_type,qcc_value);
+CREATE INDEX /*i*/qcc_title ON /*_*/querycachetwo (qcc_type,qcc_namespace,qcc_title);
+CREATE INDEX /*i*/qcc_titletwo ON /*_*/querycachetwo (qcc_type,qcc_namespacetwo,qcc_titletwo);
+CREATE TABLE /*_*/page_restrictions (
+ pr_page int NOT NULL,
+ pr_type varbinary(60) NOT NULL,
+ pr_level varbinary(60) NOT NULL,
+ pr_cascade tinyint NOT NULL,
+ pr_user int NULL,
+ pr_expiry varbinary(14) NULL,
+ pr_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pr_pagetype ON /*_*/page_restrictions (pr_page,pr_type);
+CREATE INDEX /*i*/pr_typelevel ON /*_*/page_restrictions (pr_type,pr_level);
+CREATE INDEX /*i*/pr_level ON /*_*/page_restrictions (pr_level);
+CREATE INDEX /*i*/pr_cascade ON /*_*/page_restrictions (pr_cascade);
+CREATE TABLE /*_*/protected_titles (
+ pt_namespace int NOT NULL,
+ pt_title varchar(255) binary NOT NULL,
+ pt_user int unsigned NOT NULL,
+ pt_reason tinyblob,
+ pt_timestamp binary(14) NOT NULL,
+ pt_expiry varbinary(14) NOT NULL default '',
+ pt_create_perm varbinary(60) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pt_namespace_title ON /*_*/protected_titles (pt_namespace,pt_title);
+CREATE INDEX /*i*/pt_timestamp ON /*_*/protected_titles (pt_timestamp);
+CREATE TABLE /*_*/page_props (
+ pp_page int NOT NULL,
+ pp_propname varbinary(60) NOT NULL,
+ pp_value blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pp_page_propname ON /*_*/page_props (pp_page,pp_propname);
+CREATE TABLE /*_*/updatelog (
+ ul_key varchar(255) NOT NULL PRIMARY KEY,
+ ul_value blob
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/change_tag (
+ ct_rc_id int NULL,
+ ct_log_id int NULL,
+ ct_rev_id int NULL,
+ ct_tag varchar(255) NOT NULL,
+ ct_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/change_tag_rc_tag ON /*_*/change_tag (ct_rc_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_log_tag ON /*_*/change_tag (ct_log_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_rev_tag ON /*_*/change_tag (ct_rev_id,ct_tag);
+CREATE INDEX /*i*/change_tag_tag_id ON /*_*/change_tag (ct_tag,ct_rc_id,ct_rev_id,ct_log_id);
+CREATE TABLE /*_*/tag_summary (
+ ts_rc_id int NULL,
+ ts_log_id int NULL,
+ ts_rev_id int NULL,
+ ts_tags blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tag_summary_rc_id ON /*_*/tag_summary (ts_rc_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_log_id ON /*_*/tag_summary (ts_log_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_rev_id ON /*_*/tag_summary (ts_rev_id);
+CREATE TABLE /*_*/valid_tag (
+ vt_tag varchar(255) NOT NULL PRIMARY KEY
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/l10n_cache (
+ lc_lang varbinary(32) NOT NULL,
+ lc_key varchar(255) NOT NULL,
+ lc_value mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/lc_lang_key ON /*_*/l10n_cache (lc_lang, lc_key);
+CREATE TABLE /*_*/msg_resource (
+ mr_resource varbinary(255) NOT NULL,
+ mr_lang varbinary(32) NOT NULL,
+ mr_blob mediumblob NOT NULL,
+ mr_timestamp binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mr_resource_lang ON /*_*/msg_resource (mr_resource, mr_lang);
+CREATE TABLE /*_*/msg_resource_links (
+ mrl_resource varbinary(255) NOT NULL,
+ mrl_message varbinary(255) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mrl_message_resource ON /*_*/msg_resource_links (mrl_message, mrl_resource);
+CREATE TABLE /*_*/module_deps (
+ md_module varbinary(255) NOT NULL,
+ md_skin varbinary(32) NOT NULL,
+ md_deps mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/md_module_skin ON /*_*/module_deps (md_module, md_skin);
+
diff --git a/www/wiki/tests/phpunit/data/db/sqlite/tables-1.19.sql b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.19.sql
new file mode 100644
index 00000000..db853fcb
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.19.sql
@@ -0,0 +1,531 @@
+-- This is a copy of MediaWiki 1.19 schema shared by MySQL and SQLite.
+-- It is used for updater testing. Comments are stripped to decrease
+-- file size, as we don't need to maintain it.
+
+CREATE TABLE /*_*/user (
+ user_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ user_name varchar(255) binary NOT NULL default '',
+ user_real_name varchar(255) binary NOT NULL default '',
+ user_password tinyblob NOT NULL,
+ user_newpassword tinyblob NOT NULL,
+ user_newpass_time binary(14),
+ user_email tinytext NOT NULL,
+ user_touched binary(14) NOT NULL default '',
+ user_token binary(32) NOT NULL default '',
+ user_email_authenticated binary(14),
+ user_email_token binary(32),
+ user_email_token_expires binary(14),
+ user_registration binary(14),
+ user_editcount int
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_name ON /*_*/user (user_name);
+CREATE INDEX /*i*/user_email_token ON /*_*/user (user_email_token);
+CREATE INDEX /*i*/user_email ON /*_*/user (user_email(50));
+CREATE TABLE /*_*/user_groups (
+ ug_user int unsigned NOT NULL default 0,
+ ug_group varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user,ug_group);
+CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE TABLE /*_*/user_former_groups (
+ ufg_user int unsigned NOT NULL default 0,
+ ufg_group varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ufg_user_group ON /*_*/user_former_groups (ufg_user,ufg_group);
+CREATE TABLE /*_*/user_newtalk (
+ user_id int NOT NULL default 0,
+ user_ip varbinary(40) NOT NULL default '',
+ user_last_timestamp varbinary(14) NULL default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id);
+CREATE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip);
+CREATE TABLE /*_*/user_properties (
+ up_user int NOT NULL,
+ up_property varbinary(255) NOT NULL,
+ up_value blob
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_properties_user_property ON /*_*/user_properties (up_user,up_property);
+CREATE INDEX /*i*/user_properties_property ON /*_*/user_properties (up_property);
+CREATE TABLE /*_*/page (
+ page_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ page_namespace int NOT NULL,
+ page_title varchar(255) binary NOT NULL,
+ page_restrictions tinyblob NOT NULL,
+ page_counter bigint unsigned NOT NULL default 0,
+ page_is_redirect tinyint unsigned NOT NULL default 0,
+ page_is_new tinyint unsigned NOT NULL default 0,
+ page_random real unsigned NOT NULL,
+ page_touched binary(14) NOT NULL default '',
+ page_latest int unsigned NOT NULL,
+ page_len int unsigned NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/name_title ON /*_*/page (page_namespace,page_title);
+CREATE INDEX /*i*/page_random ON /*_*/page (page_random);
+CREATE INDEX /*i*/page_len ON /*_*/page (page_len);
+CREATE INDEX /*i*/page_redirect_namespace_len ON /*_*/page (page_is_redirect, page_namespace, page_len);
+CREATE TABLE /*_*/revision (
+ rev_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rev_page int unsigned NOT NULL,
+ rev_text_id int unsigned NOT NULL,
+ rev_comment tinyblob NOT NULL,
+ rev_user int unsigned NOT NULL default 0,
+ rev_user_text varchar(255) binary NOT NULL default '',
+ rev_timestamp binary(14) NOT NULL default '',
+ rev_minor_edit tinyint unsigned NOT NULL default 0,
+ rev_deleted tinyint unsigned NOT NULL default 0,
+ rev_len int unsigned,
+ rev_parent_id int unsigned default NULL,
+ rev_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=1024;
+CREATE UNIQUE INDEX /*i*/rev_page_id ON /*_*/revision (rev_page, rev_id);
+CREATE INDEX /*i*/rev_timestamp ON /*_*/revision (rev_timestamp);
+CREATE INDEX /*i*/page_timestamp ON /*_*/revision (rev_page,rev_timestamp);
+CREATE INDEX /*i*/user_timestamp ON /*_*/revision (rev_user,rev_timestamp);
+CREATE INDEX /*i*/usertext_timestamp ON /*_*/revision (rev_user_text,rev_timestamp);
+CREATE TABLE /*_*/text (
+ old_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ old_text mediumblob NOT NULL,
+ old_flags tinyblob NOT NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=10240;
+CREATE TABLE /*_*/archive (
+ ar_namespace int NOT NULL default 0,
+ ar_title varchar(255) binary NOT NULL default '',
+ ar_text mediumblob NOT NULL,
+ ar_comment tinyblob NOT NULL,
+ ar_user int unsigned NOT NULL default 0,
+ ar_user_text varchar(255) binary NOT NULL,
+ ar_timestamp binary(14) NOT NULL default '',
+ ar_minor_edit tinyint NOT NULL default 0,
+ ar_flags tinyblob NOT NULL,
+ ar_rev_id int unsigned,
+ ar_text_id int unsigned,
+ ar_deleted tinyint unsigned NOT NULL default 0,
+ ar_len int unsigned,
+ ar_page_id int unsigned,
+ ar_parent_id int unsigned default NULL,
+ ar_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/name_title_timestamp ON /*_*/archive (ar_namespace,ar_title,ar_timestamp);
+CREATE INDEX /*i*/ar_usertext_timestamp ON /*_*/archive (ar_user_text,ar_timestamp);
+CREATE INDEX /*i*/ar_revid ON /*_*/archive (ar_rev_id);
+CREATE TABLE /*_*/pagelinks (
+ pl_from int unsigned NOT NULL default 0,
+ pl_namespace int NOT NULL default 0,
+ pl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pl_from ON /*_*/pagelinks (pl_from,pl_namespace,pl_title);
+CREATE UNIQUE INDEX /*i*/pl_namespace ON /*_*/pagelinks (pl_namespace,pl_title,pl_from);
+CREATE TABLE /*_*/templatelinks (
+ tl_from int unsigned NOT NULL default 0,
+ tl_namespace int NOT NULL default 0,
+ tl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tl_from ON /*_*/templatelinks (tl_from,tl_namespace,tl_title);
+CREATE UNIQUE INDEX /*i*/tl_namespace ON /*_*/templatelinks (tl_namespace,tl_title,tl_from);
+CREATE TABLE /*_*/imagelinks (
+ il_from int unsigned NOT NULL default 0,
+ il_to varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/il_from ON /*_*/imagelinks (il_from,il_to);
+CREATE UNIQUE INDEX /*i*/il_to ON /*_*/imagelinks (il_to,il_from);
+CREATE TABLE /*_*/categorylinks (
+ cl_from int unsigned NOT NULL default 0,
+ cl_to varchar(255) binary NOT NULL default '',
+ cl_sortkey varbinary(230) NOT NULL default '',
+ cl_sortkey_prefix varchar(255) binary NOT NULL default '',
+ cl_timestamp timestamp NOT NULL,
+ cl_collation varbinary(32) NOT NULL default '',
+ cl_type ENUM('page', 'subcat', 'file') NOT NULL default 'page'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cl_from ON /*_*/categorylinks (cl_from,cl_to);
+CREATE INDEX /*i*/cl_sortkey ON /*_*/categorylinks (cl_to,cl_type,cl_sortkey,cl_from);
+CREATE INDEX /*i*/cl_timestamp ON /*_*/categorylinks (cl_to,cl_timestamp);
+CREATE INDEX /*i*/cl_collation ON /*_*/categorylinks (cl_collation);
+CREATE TABLE /*_*/category (
+ cat_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ cat_title varchar(255) binary NOT NULL,
+ cat_pages int signed NOT NULL default 0,
+ cat_subcats int signed NOT NULL default 0,
+ cat_files int signed NOT NULL default 0,
+ cat_hidden tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cat_title ON /*_*/category (cat_title);
+CREATE INDEX /*i*/cat_pages ON /*_*/category (cat_pages);
+CREATE TABLE /*_*/externallinks (
+ el_from int unsigned NOT NULL default 0,
+ el_to blob NOT NULL,
+ el_index blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/el_from ON /*_*/externallinks (el_from, el_to(40));
+CREATE INDEX /*i*/el_to ON /*_*/externallinks (el_to(60), el_from);
+CREATE INDEX /*i*/el_index ON /*_*/externallinks (el_index(60));
+CREATE TABLE /*_*/external_user (
+ eu_local_id int unsigned NOT NULL PRIMARY KEY,
+ eu_external_id varchar(255) binary NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/eu_external_id ON /*_*/external_user (eu_external_id);
+CREATE TABLE /*_*/langlinks (
+ ll_from int unsigned NOT NULL default 0,
+ ll_lang varbinary(20) NOT NULL default '',
+ ll_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ll_from ON /*_*/langlinks (ll_from, ll_lang);
+CREATE INDEX /*i*/ll_lang ON /*_*/langlinks (ll_lang, ll_title);
+CREATE TABLE /*_*/iwlinks (
+ iwl_from int unsigned NOT NULL default 0,
+ iwl_prefix varbinary(20) NOT NULL default '',
+ iwl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iwl_from ON /*_*/iwlinks (iwl_from, iwl_prefix, iwl_title);
+CREATE UNIQUE INDEX /*i*/iwl_prefix_title_from ON /*_*/iwlinks (iwl_prefix, iwl_title, iwl_from);
+CREATE TABLE /*_*/site_stats (
+ ss_row_id int unsigned NOT NULL,
+ ss_total_views bigint unsigned default 0,
+ ss_total_edits bigint unsigned default 0,
+ ss_good_articles bigint unsigned default 0,
+ ss_total_pages bigint default '-1',
+ ss_users bigint default '-1',
+ ss_active_users bigint default '-1',
+ ss_admins int default '-1',
+ ss_images int default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ss_row_id ON /*_*/site_stats (ss_row_id);
+CREATE TABLE /*_*/hitcounter (
+ hc_id int unsigned NOT NULL
+) ENGINE=HEAP MAX_ROWS=25000;
+CREATE TABLE /*_*/ipblocks (
+ ipb_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ ipb_address tinyblob NOT NULL,
+ ipb_user int unsigned NOT NULL default 0,
+ ipb_by int unsigned NOT NULL default 0,
+ ipb_by_text varchar(255) binary NOT NULL default '',
+ ipb_reason tinyblob NOT NULL,
+ ipb_timestamp binary(14) NOT NULL default '',
+ ipb_auto bool NOT NULL default 0,
+ ipb_anon_only bool NOT NULL default 0,
+ ipb_create_account bool NOT NULL default 1,
+ ipb_enable_autoblock bool NOT NULL default '1',
+ ipb_expiry varbinary(14) NOT NULL default '',
+ ipb_range_start tinyblob NOT NULL,
+ ipb_range_end tinyblob NOT NULL,
+ ipb_deleted bool NOT NULL default 0,
+ ipb_block_email bool NOT NULL default 0,
+ ipb_allow_usertalk bool NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ipb_address ON /*_*/ipblocks (ipb_address(255), ipb_user, ipb_auto, ipb_anon_only);
+CREATE INDEX /*i*/ipb_user ON /*_*/ipblocks (ipb_user);
+CREATE INDEX /*i*/ipb_range ON /*_*/ipblocks (ipb_range_start(8), ipb_range_end(8));
+CREATE INDEX /*i*/ipb_timestamp ON /*_*/ipblocks (ipb_timestamp);
+CREATE INDEX /*i*/ipb_expiry ON /*_*/ipblocks (ipb_expiry);
+CREATE TABLE /*_*/image (
+ img_name varchar(255) binary NOT NULL default '' PRIMARY KEY,
+ img_size int unsigned NOT NULL default 0,
+ img_width int NOT NULL default 0,
+ img_height int NOT NULL default 0,
+ img_metadata mediumblob NOT NULL,
+ img_bits int NOT NULL default 0,
+ img_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ img_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ img_minor_mime varbinary(100) NOT NULL default "unknown",
+ img_description tinyblob NOT NULL,
+ img_user int unsigned NOT NULL default 0,
+ img_user_text varchar(255) binary NOT NULL,
+ img_timestamp varbinary(14) NOT NULL default '',
+ img_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/img_usertext_timestamp ON /*_*/image (img_user_text,img_timestamp);
+CREATE INDEX /*i*/img_size ON /*_*/image (img_size);
+CREATE INDEX /*i*/img_timestamp ON /*_*/image (img_timestamp);
+CREATE INDEX /*i*/img_sha1 ON /*_*/image (img_sha1);
+CREATE TABLE /*_*/oldimage (
+ oi_name varchar(255) binary NOT NULL default '',
+ oi_archive_name varchar(255) binary NOT NULL default '',
+ oi_size int unsigned NOT NULL default 0,
+ oi_width int NOT NULL default 0,
+ oi_height int NOT NULL default 0,
+ oi_bits int NOT NULL default 0,
+ oi_description tinyblob NOT NULL,
+ oi_user int unsigned NOT NULL default 0,
+ oi_user_text varchar(255) binary NOT NULL,
+ oi_timestamp binary(14) NOT NULL default '',
+ oi_metadata mediumblob NOT NULL,
+ oi_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ oi_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ oi_minor_mime varbinary(100) NOT NULL default "unknown",
+ oi_deleted tinyint unsigned NOT NULL default 0,
+ oi_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/oi_usertext_timestamp ON /*_*/oldimage (oi_user_text,oi_timestamp);
+CREATE INDEX /*i*/oi_name_timestamp ON /*_*/oldimage (oi_name,oi_timestamp);
+CREATE INDEX /*i*/oi_name_archive_name ON /*_*/oldimage (oi_name,oi_archive_name(14));
+CREATE INDEX /*i*/oi_sha1 ON /*_*/oldimage (oi_sha1);
+CREATE TABLE /*_*/filearchive (
+ fa_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ fa_name varchar(255) binary NOT NULL default '',
+ fa_archive_name varchar(255) binary default '',
+ fa_storage_group varbinary(16),
+ fa_storage_key varbinary(64) default '',
+ fa_deleted_user int,
+ fa_deleted_timestamp binary(14) default '',
+ fa_deleted_reason text,
+ fa_size int unsigned default 0,
+ fa_width int default 0,
+ fa_height int default 0,
+ fa_metadata mediumblob,
+ fa_bits int default 0,
+ fa_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ fa_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") default "unknown",
+ fa_minor_mime varbinary(100) default "unknown",
+ fa_description tinyblob,
+ fa_user int unsigned default 0,
+ fa_user_text varchar(255) binary,
+ fa_timestamp binary(14) default '',
+ fa_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/fa_name ON /*_*/filearchive (fa_name, fa_timestamp);
+CREATE INDEX /*i*/fa_storage_group ON /*_*/filearchive (fa_storage_group, fa_storage_key);
+CREATE INDEX /*i*/fa_deleted_timestamp ON /*_*/filearchive (fa_deleted_timestamp);
+CREATE INDEX /*i*/fa_user_timestamp ON /*_*/filearchive (fa_user_text,fa_timestamp);
+CREATE TABLE /*_*/uploadstash (
+ us_id int unsigned NOT NULL PRIMARY KEY auto_increment,
+ us_user int unsigned NOT NULL,
+ us_key varchar(255) NOT NULL,
+ us_orig_path varchar(255) NOT NULL,
+ us_path varchar(255) NOT NULL,
+ us_source_type varchar(50),
+ us_timestamp varbinary(14) not null,
+ us_status varchar(50) not null,
+ us_chunk_inx int unsigned NULL,
+ us_size int unsigned NOT NULL,
+ us_sha1 varchar(31) NOT NULL,
+ us_mime varchar(255),
+ us_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ us_image_width int unsigned,
+ us_image_height int unsigned,
+ us_image_bits smallint unsigned
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/us_user ON /*_*/uploadstash (us_user);
+CREATE UNIQUE INDEX /*i*/us_key ON /*_*/uploadstash (us_key);
+CREATE INDEX /*i*/us_timestamp ON /*_*/uploadstash (us_timestamp);
+CREATE TABLE /*_*/recentchanges (
+ rc_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rc_timestamp varbinary(14) NOT NULL default '',
+ rc_cur_time varbinary(14) NOT NULL default '',
+ rc_user int unsigned NOT NULL default 0,
+ rc_user_text varchar(255) binary NOT NULL,
+ rc_namespace int NOT NULL default 0,
+ rc_title varchar(255) binary NOT NULL default '',
+ rc_comment varchar(255) binary NOT NULL default '',
+ rc_minor tinyint unsigned NOT NULL default 0,
+ rc_bot tinyint unsigned NOT NULL default 0,
+ rc_new tinyint unsigned NOT NULL default 0,
+ rc_cur_id int unsigned NOT NULL default 0,
+ rc_this_oldid int unsigned NOT NULL default 0,
+ rc_last_oldid int unsigned NOT NULL default 0,
+ rc_type tinyint unsigned NOT NULL default 0,
+ rc_moved_to_ns tinyint unsigned NOT NULL default 0,
+ rc_moved_to_title varchar(255) binary NOT NULL default '',
+ rc_patrolled tinyint unsigned NOT NULL default 0,
+ rc_ip varbinary(40) NOT NULL default '',
+ rc_old_len int,
+ rc_new_len int,
+ rc_deleted tinyint unsigned NOT NULL default 0,
+ rc_logid int unsigned NOT NULL default 0,
+ rc_log_type varbinary(255) NULL default NULL,
+ rc_log_action varbinary(255) NULL default NULL,
+ rc_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rc_timestamp ON /*_*/recentchanges (rc_timestamp);
+CREATE INDEX /*i*/rc_namespace_title ON /*_*/recentchanges (rc_namespace, rc_title);
+CREATE INDEX /*i*/rc_cur_id ON /*_*/recentchanges (rc_cur_id);
+CREATE INDEX /*i*/new_name_timestamp ON /*_*/recentchanges (rc_new,rc_namespace,rc_timestamp);
+CREATE INDEX /*i*/rc_ip ON /*_*/recentchanges (rc_ip);
+CREATE INDEX /*i*/rc_ns_usertext ON /*_*/recentchanges (rc_namespace, rc_user_text);
+CREATE INDEX /*i*/rc_user_text ON /*_*/recentchanges (rc_user_text, rc_timestamp);
+CREATE TABLE /*_*/watchlist (
+ wl_user int unsigned NOT NULL,
+ wl_namespace int NOT NULL default 0,
+ wl_title varchar(255) binary NOT NULL default '',
+ wl_notificationtimestamp varbinary(14)
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/wl_user ON /*_*/watchlist (wl_user, wl_namespace, wl_title);
+CREATE INDEX /*i*/namespace_title ON /*_*/watchlist (wl_namespace, wl_title);
+CREATE TABLE /*_*/searchindex (
+ si_page int unsigned NOT NULL,
+ si_title varchar(255) NOT NULL default '',
+ si_text mediumtext NOT NULL
+) ENGINE=MyISAM;
+CREATE UNIQUE INDEX /*i*/si_page ON /*_*/searchindex (si_page);
+CREATE FULLTEXT INDEX /*i*/si_title ON /*_*/searchindex (si_title);
+CREATE FULLTEXT INDEX /*i*/si_text ON /*_*/searchindex (si_text);
+CREATE TABLE /*_*/interwiki (
+ iw_prefix varchar(32) NOT NULL,
+ iw_url blob NOT NULL,
+ iw_api blob NOT NULL,
+ iw_wikiid varchar(64) NOT NULL,
+ iw_local bool NOT NULL,
+ iw_trans tinyint NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iw_prefix ON /*_*/interwiki (iw_prefix);
+CREATE TABLE /*_*/querycache (
+ qc_type varbinary(32) NOT NULL,
+ qc_value int unsigned NOT NULL default 0,
+ qc_namespace int NOT NULL default 0,
+ qc_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qc_type ON /*_*/querycache (qc_type,qc_value);
+CREATE TABLE /*_*/objectcache (
+ keyname varbinary(255) NOT NULL default '' PRIMARY KEY,
+ value mediumblob,
+ exptime datetime
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/exptime ON /*_*/objectcache (exptime);
+CREATE TABLE /*_*/transcache (
+ tc_url varbinary(255) NOT NULL,
+ tc_contents text,
+ tc_time binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tc_url_idx ON /*_*/transcache (tc_url);
+CREATE TABLE /*_*/logging (
+ log_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ log_type varbinary(32) NOT NULL default '',
+ log_action varbinary(32) NOT NULL default '',
+ log_timestamp binary(14) NOT NULL default '19700101000000',
+ log_user int unsigned NOT NULL default 0,
+ log_user_text varchar(255) binary NOT NULL default '',
+ log_namespace int NOT NULL default 0,
+ log_title varchar(255) binary NOT NULL default '',
+ log_page int unsigned NULL,
+ log_comment varchar(255) NOT NULL default '',
+ log_params blob NOT NULL,
+ log_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/type_time ON /*_*/logging (log_type, log_timestamp);
+CREATE INDEX /*i*/user_time ON /*_*/logging (log_user, log_timestamp);
+CREATE INDEX /*i*/page_time ON /*_*/logging (log_namespace, log_title, log_timestamp);
+CREATE INDEX /*i*/times ON /*_*/logging (log_timestamp);
+CREATE INDEX /*i*/log_user_type_time ON /*_*/logging (log_user, log_type, log_timestamp);
+CREATE INDEX /*i*/log_page_id_time ON /*_*/logging (log_page,log_timestamp);
+CREATE INDEX /*i*/type_action ON /*_*/logging(log_type, log_action, log_timestamp);
+CREATE TABLE /*_*/log_search (
+ ls_field varbinary(32) NOT NULL,
+ ls_value varchar(255) NOT NULL,
+ ls_log_id int unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ls_field_val ON /*_*/log_search (ls_field,ls_value,ls_log_id);
+CREATE INDEX /*i*/ls_log_id ON /*_*/log_search (ls_log_id);
+CREATE TABLE /*_*/job (
+ job_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ job_cmd varbinary(60) NOT NULL default '',
+ job_namespace int NOT NULL,
+ job_title varchar(255) binary NOT NULL,
+ job_timestamp varbinary(14) NULL default NULL,
+ job_params blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/job_cmd ON /*_*/job (job_cmd, job_namespace, job_title, job_params(128));
+CREATE INDEX /*i*/job_timestamp ON /*_*/job(job_timestamp);
+CREATE TABLE /*_*/querycache_info (
+ qci_type varbinary(32) NOT NULL default '',
+ qci_timestamp binary(14) NOT NULL default '19700101000000'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/qci_type ON /*_*/querycache_info (qci_type);
+CREATE TABLE /*_*/redirect (
+ rd_from int unsigned NOT NULL default 0 PRIMARY KEY,
+ rd_namespace int NOT NULL default 0,
+ rd_title varchar(255) binary NOT NULL default '',
+ rd_interwiki varchar(32) default NULL,
+ rd_fragment varchar(255) binary default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rd_ns_title ON /*_*/redirect (rd_namespace,rd_title,rd_from);
+CREATE TABLE /*_*/querycachetwo (
+ qcc_type varbinary(32) NOT NULL,
+ qcc_value int unsigned NOT NULL default 0,
+ qcc_namespace int NOT NULL default 0,
+ qcc_title varchar(255) binary NOT NULL default '',
+ qcc_namespacetwo int NOT NULL default 0,
+ qcc_titletwo varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qcc_type ON /*_*/querycachetwo (qcc_type,qcc_value);
+CREATE INDEX /*i*/qcc_title ON /*_*/querycachetwo (qcc_type,qcc_namespace,qcc_title);
+CREATE INDEX /*i*/qcc_titletwo ON /*_*/querycachetwo (qcc_type,qcc_namespacetwo,qcc_titletwo);
+CREATE TABLE /*_*/page_restrictions (
+ pr_page int NOT NULL,
+ pr_type varbinary(60) NOT NULL,
+ pr_level varbinary(60) NOT NULL,
+ pr_cascade tinyint NOT NULL,
+ pr_user int NULL,
+ pr_expiry varbinary(14) NULL,
+ pr_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pr_pagetype ON /*_*/page_restrictions (pr_page,pr_type);
+CREATE INDEX /*i*/pr_typelevel ON /*_*/page_restrictions (pr_type,pr_level);
+CREATE INDEX /*i*/pr_level ON /*_*/page_restrictions (pr_level);
+CREATE INDEX /*i*/pr_cascade ON /*_*/page_restrictions (pr_cascade);
+CREATE TABLE /*_*/protected_titles (
+ pt_namespace int NOT NULL,
+ pt_title varchar(255) binary NOT NULL,
+ pt_user int unsigned NOT NULL,
+ pt_reason tinyblob,
+ pt_timestamp binary(14) NOT NULL,
+ pt_expiry varbinary(14) NOT NULL default '',
+ pt_create_perm varbinary(60) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pt_namespace_title ON /*_*/protected_titles (pt_namespace,pt_title);
+CREATE INDEX /*i*/pt_timestamp ON /*_*/protected_titles (pt_timestamp);
+CREATE TABLE /*_*/page_props (
+ pp_page int NOT NULL,
+ pp_propname varbinary(60) NOT NULL,
+ pp_value blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pp_page_propname ON /*_*/page_props (pp_page,pp_propname);
+CREATE TABLE /*_*/updatelog (
+ ul_key varchar(255) NOT NULL PRIMARY KEY,
+ ul_value blob
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/change_tag (
+ ct_rc_id int NULL,
+ ct_log_id int NULL,
+ ct_rev_id int NULL,
+ ct_tag varchar(255) NOT NULL,
+ ct_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/change_tag_rc_tag ON /*_*/change_tag (ct_rc_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_log_tag ON /*_*/change_tag (ct_log_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_rev_tag ON /*_*/change_tag (ct_rev_id,ct_tag);
+CREATE INDEX /*i*/change_tag_tag_id ON /*_*/change_tag (ct_tag,ct_rc_id,ct_rev_id,ct_log_id);
+CREATE TABLE /*_*/tag_summary (
+ ts_rc_id int NULL,
+ ts_log_id int NULL,
+ ts_rev_id int NULL,
+ ts_tags blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tag_summary_rc_id ON /*_*/tag_summary (ts_rc_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_log_id ON /*_*/tag_summary (ts_log_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_rev_id ON /*_*/tag_summary (ts_rev_id);
+CREATE TABLE /*_*/valid_tag (
+ vt_tag varchar(255) NOT NULL PRIMARY KEY
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/l10n_cache (
+ lc_lang varbinary(32) NOT NULL,
+ lc_key varchar(255) NOT NULL,
+ lc_value mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/lc_lang_key ON /*_*/l10n_cache (lc_lang, lc_key);
+CREATE TABLE /*_*/msg_resource (
+ mr_resource varbinary(255) NOT NULL,
+ mr_lang varbinary(32) NOT NULL,
+ mr_blob mediumblob NOT NULL,
+ mr_timestamp binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mr_resource_lang ON /*_*/msg_resource (mr_resource, mr_lang);
+CREATE TABLE /*_*/msg_resource_links (
+ mrl_resource varbinary(255) NOT NULL,
+ mrl_message varbinary(255) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mrl_message_resource ON /*_*/msg_resource_links (mrl_message, mrl_resource);
+CREATE TABLE /*_*/module_deps (
+ md_module varbinary(255) NOT NULL,
+ md_skin varbinary(32) NOT NULL,
+ md_deps mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/md_module_skin ON /*_*/module_deps (md_module, md_skin);
diff --git a/www/wiki/tests/phpunit/data/db/sqlite/tables-1.20.sql b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.20.sql
new file mode 100644
index 00000000..d6c4f5bc
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.20.sql
@@ -0,0 +1,534 @@
+-- This is a copy of MediaWiki 1.20 schema shared by MySQL and SQLite.
+-- It is used for updater testing. Comments are stripped to decrease
+-- file size, as we don't need to maintain it.
+
+CREATE TABLE /*_*/user (
+ user_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ user_name varchar(255) binary NOT NULL default '',
+ user_real_name varchar(255) binary NOT NULL default '',
+ user_password tinyblob NOT NULL,
+ user_newpassword tinyblob NOT NULL,
+ user_newpass_time binary(14),
+ user_email tinytext NOT NULL,
+ user_touched binary(14) NOT NULL default '',
+ user_token binary(32) NOT NULL default '',
+ user_email_authenticated binary(14),
+ user_email_token binary(32),
+ user_email_token_expires binary(14),
+ user_registration binary(14),
+ user_editcount int
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_name ON /*_*/user (user_name);
+CREATE INDEX /*i*/user_email_token ON /*_*/user (user_email_token);
+CREATE INDEX /*i*/user_email ON /*_*/user (user_email(50));
+CREATE TABLE /*_*/user_groups (
+ ug_user int unsigned NOT NULL default 0,
+ ug_group varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user,ug_group);
+CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE TABLE /*_*/user_former_groups (
+ ufg_user int unsigned NOT NULL default 0,
+ ufg_group varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ufg_user_group ON /*_*/user_former_groups (ufg_user,ufg_group);
+CREATE TABLE /*_*/user_newtalk (
+ user_id int NOT NULL default 0,
+ user_ip varbinary(40) NOT NULL default '',
+ user_last_timestamp varbinary(14) NULL default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id);
+CREATE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip);
+CREATE TABLE /*_*/user_properties (
+ up_user int NOT NULL,
+ up_property varbinary(255) NOT NULL,
+ up_value blob
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_properties_user_property ON /*_*/user_properties (up_user,up_property);
+CREATE INDEX /*i*/user_properties_property ON /*_*/user_properties (up_property);
+CREATE TABLE /*_*/page (
+ page_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ page_namespace int NOT NULL,
+ page_title varchar(255) binary NOT NULL,
+ page_restrictions tinyblob NOT NULL,
+ page_counter bigint unsigned NOT NULL default 0,
+ page_is_redirect tinyint unsigned NOT NULL default 0,
+ page_is_new tinyint unsigned NOT NULL default 0,
+ page_random real unsigned NOT NULL,
+ page_touched binary(14) NOT NULL default '',
+ page_latest int unsigned NOT NULL,
+ page_len int unsigned NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/name_title ON /*_*/page (page_namespace,page_title);
+CREATE INDEX /*i*/page_random ON /*_*/page (page_random);
+CREATE INDEX /*i*/page_len ON /*_*/page (page_len);
+CREATE INDEX /*i*/page_redirect_namespace_len ON /*_*/page (page_is_redirect, page_namespace, page_len);
+CREATE TABLE /*_*/revision (
+ rev_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rev_page int unsigned NOT NULL,
+ rev_text_id int unsigned NOT NULL,
+ rev_comment tinyblob NOT NULL,
+ rev_user int unsigned NOT NULL default 0,
+ rev_user_text varchar(255) binary NOT NULL default '',
+ rev_timestamp binary(14) NOT NULL default '',
+ rev_minor_edit tinyint unsigned NOT NULL default 0,
+ rev_deleted tinyint unsigned NOT NULL default 0,
+ rev_len int unsigned,
+ rev_parent_id int unsigned default NULL,
+ rev_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=1024;
+CREATE UNIQUE INDEX /*i*/rev_page_id ON /*_*/revision (rev_page, rev_id);
+CREATE INDEX /*i*/rev_timestamp ON /*_*/revision (rev_timestamp);
+CREATE INDEX /*i*/page_timestamp ON /*_*/revision (rev_page,rev_timestamp);
+CREATE INDEX /*i*/user_timestamp ON /*_*/revision (rev_user,rev_timestamp);
+CREATE INDEX /*i*/usertext_timestamp ON /*_*/revision (rev_user_text,rev_timestamp);
+CREATE INDEX /*i*/page_user_timestamp ON /*_*/revision (rev_page,rev_user,rev_timestamp);
+CREATE TABLE /*_*/text (
+ old_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ old_text mediumblob NOT NULL,
+ old_flags tinyblob NOT NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=10240;
+CREATE TABLE /*_*/archive (
+ ar_namespace int NOT NULL default 0,
+ ar_title varchar(255) binary NOT NULL default '',
+ ar_text mediumblob NOT NULL,
+ ar_comment tinyblob NOT NULL,
+ ar_user int unsigned NOT NULL default 0,
+ ar_user_text varchar(255) binary NOT NULL,
+ ar_timestamp binary(14) NOT NULL default '',
+ ar_minor_edit tinyint NOT NULL default 0,
+ ar_flags tinyblob NOT NULL,
+ ar_rev_id int unsigned,
+ ar_text_id int unsigned,
+ ar_deleted tinyint unsigned NOT NULL default 0,
+ ar_len int unsigned,
+ ar_page_id int unsigned,
+ ar_parent_id int unsigned default NULL,
+ ar_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/name_title_timestamp ON /*_*/archive (ar_namespace,ar_title,ar_timestamp);
+CREATE INDEX /*i*/ar_usertext_timestamp ON /*_*/archive (ar_user_text,ar_timestamp);
+CREATE INDEX /*i*/ar_revid ON /*_*/archive (ar_rev_id);
+CREATE TABLE /*_*/pagelinks (
+ pl_from int unsigned NOT NULL default 0,
+ pl_namespace int NOT NULL default 0,
+ pl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pl_from ON /*_*/pagelinks (pl_from,pl_namespace,pl_title);
+CREATE UNIQUE INDEX /*i*/pl_namespace ON /*_*/pagelinks (pl_namespace,pl_title,pl_from);
+CREATE TABLE /*_*/templatelinks (
+ tl_from int unsigned NOT NULL default 0,
+ tl_namespace int NOT NULL default 0,
+ tl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tl_from ON /*_*/templatelinks (tl_from,tl_namespace,tl_title);
+CREATE UNIQUE INDEX /*i*/tl_namespace ON /*_*/templatelinks (tl_namespace,tl_title,tl_from);
+CREATE TABLE /*_*/imagelinks (
+ il_from int unsigned NOT NULL default 0,
+ il_to varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/il_from ON /*_*/imagelinks (il_from,il_to);
+CREATE UNIQUE INDEX /*i*/il_to ON /*_*/imagelinks (il_to,il_from);
+CREATE TABLE /*_*/categorylinks (
+ cl_from int unsigned NOT NULL default 0,
+ cl_to varchar(255) binary NOT NULL default '',
+ cl_sortkey varbinary(230) NOT NULL default '',
+ cl_sortkey_prefix varchar(255) binary NOT NULL default '',
+ cl_timestamp timestamp NOT NULL,
+ cl_collation varbinary(32) NOT NULL default '',
+ cl_type ENUM('page', 'subcat', 'file') NOT NULL default 'page'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cl_from ON /*_*/categorylinks (cl_from,cl_to);
+CREATE INDEX /*i*/cl_sortkey ON /*_*/categorylinks (cl_to,cl_type,cl_sortkey,cl_from);
+CREATE INDEX /*i*/cl_timestamp ON /*_*/categorylinks (cl_to,cl_timestamp);
+CREATE INDEX /*i*/cl_collation ON /*_*/categorylinks (cl_collation);
+CREATE TABLE /*_*/category (
+ cat_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ cat_title varchar(255) binary NOT NULL,
+ cat_pages int signed NOT NULL default 0,
+ cat_subcats int signed NOT NULL default 0,
+ cat_files int signed NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cat_title ON /*_*/category (cat_title);
+CREATE INDEX /*i*/cat_pages ON /*_*/category (cat_pages);
+CREATE TABLE /*_*/externallinks (
+ el_from int unsigned NOT NULL default 0,
+ el_to blob NOT NULL,
+ el_index blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/el_from ON /*_*/externallinks (el_from, el_to(40));
+CREATE INDEX /*i*/el_to ON /*_*/externallinks (el_to(60), el_from);
+CREATE INDEX /*i*/el_index ON /*_*/externallinks (el_index(60));
+CREATE TABLE /*_*/external_user (
+ eu_local_id int unsigned NOT NULL PRIMARY KEY,
+ eu_external_id varchar(255) binary NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/eu_external_id ON /*_*/external_user (eu_external_id);
+CREATE TABLE /*_*/langlinks (
+ ll_from int unsigned NOT NULL default 0,
+ ll_lang varbinary(20) NOT NULL default '',
+ ll_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ll_from ON /*_*/langlinks (ll_from, ll_lang);
+CREATE INDEX /*i*/ll_lang ON /*_*/langlinks (ll_lang, ll_title);
+CREATE TABLE /*_*/iwlinks (
+ iwl_from int unsigned NOT NULL default 0,
+ iwl_prefix varbinary(20) NOT NULL default '',
+ iwl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iwl_from ON /*_*/iwlinks (iwl_from, iwl_prefix, iwl_title);
+CREATE UNIQUE INDEX /*i*/iwl_prefix_title_from ON /*_*/iwlinks (iwl_prefix, iwl_title, iwl_from);
+CREATE TABLE /*_*/site_stats (
+ ss_row_id int unsigned NOT NULL,
+ ss_total_views bigint unsigned default 0,
+ ss_total_edits bigint unsigned default 0,
+ ss_good_articles bigint unsigned default 0,
+ ss_total_pages bigint default '-1',
+ ss_users bigint default '-1',
+ ss_active_users bigint default '-1',
+ ss_admins int default '-1',
+ ss_images int default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ss_row_id ON /*_*/site_stats (ss_row_id);
+CREATE TABLE /*_*/hitcounter (
+ hc_id int unsigned NOT NULL
+) ENGINE=HEAP MAX_ROWS=25000;
+CREATE TABLE /*_*/ipblocks (
+ ipb_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ ipb_address tinyblob NOT NULL,
+ ipb_user int unsigned NOT NULL default 0,
+ ipb_by int unsigned NOT NULL default 0,
+ ipb_by_text varchar(255) binary NOT NULL default '',
+ ipb_reason tinyblob NOT NULL,
+ ipb_timestamp binary(14) NOT NULL default '',
+ ipb_auto bool NOT NULL default 0,
+ ipb_anon_only bool NOT NULL default 0,
+ ipb_create_account bool NOT NULL default 1,
+ ipb_enable_autoblock bool NOT NULL default '1',
+ ipb_expiry varbinary(14) NOT NULL default '',
+ ipb_range_start tinyblob NOT NULL,
+ ipb_range_end tinyblob NOT NULL,
+ ipb_deleted bool NOT NULL default 0,
+ ipb_block_email bool NOT NULL default 0,
+ ipb_allow_usertalk bool NOT NULL default 0,
+ ipb_parent_block_id int default NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ipb_address ON /*_*/ipblocks (ipb_address(255), ipb_user, ipb_auto, ipb_anon_only);
+CREATE INDEX /*i*/ipb_user ON /*_*/ipblocks (ipb_user);
+CREATE INDEX /*i*/ipb_range ON /*_*/ipblocks (ipb_range_start(8), ipb_range_end(8));
+CREATE INDEX /*i*/ipb_timestamp ON /*_*/ipblocks (ipb_timestamp);
+CREATE INDEX /*i*/ipb_expiry ON /*_*/ipblocks (ipb_expiry);
+CREATE INDEX /*i*/ipb_parent_block_id ON /*_*/ipblocks (ipb_parent_block_id);
+CREATE TABLE /*_*/image (
+ img_name varchar(255) binary NOT NULL default '' PRIMARY KEY,
+ img_size int unsigned NOT NULL default 0,
+ img_width int NOT NULL default 0,
+ img_height int NOT NULL default 0,
+ img_metadata mediumblob NOT NULL,
+ img_bits int NOT NULL default 0,
+ img_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ img_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ img_minor_mime varbinary(100) NOT NULL default "unknown",
+ img_description tinyblob NOT NULL,
+ img_user int unsigned NOT NULL default 0,
+ img_user_text varchar(255) binary NOT NULL,
+ img_timestamp varbinary(14) NOT NULL default '',
+ img_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/img_usertext_timestamp ON /*_*/image (img_user_text,img_timestamp);
+CREATE INDEX /*i*/img_size ON /*_*/image (img_size);
+CREATE INDEX /*i*/img_timestamp ON /*_*/image (img_timestamp);
+CREATE INDEX /*i*/img_sha1 ON /*_*/image (img_sha1);
+CREATE TABLE /*_*/oldimage (
+ oi_name varchar(255) binary NOT NULL default '',
+ oi_archive_name varchar(255) binary NOT NULL default '',
+ oi_size int unsigned NOT NULL default 0,
+ oi_width int NOT NULL default 0,
+ oi_height int NOT NULL default 0,
+ oi_bits int NOT NULL default 0,
+ oi_description tinyblob NOT NULL,
+ oi_user int unsigned NOT NULL default 0,
+ oi_user_text varchar(255) binary NOT NULL,
+ oi_timestamp binary(14) NOT NULL default '',
+ oi_metadata mediumblob NOT NULL,
+ oi_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ oi_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ oi_minor_mime varbinary(100) NOT NULL default "unknown",
+ oi_deleted tinyint unsigned NOT NULL default 0,
+ oi_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/oi_usertext_timestamp ON /*_*/oldimage (oi_user_text,oi_timestamp);
+CREATE INDEX /*i*/oi_name_timestamp ON /*_*/oldimage (oi_name,oi_timestamp);
+CREATE INDEX /*i*/oi_name_archive_name ON /*_*/oldimage (oi_name,oi_archive_name(14));
+CREATE INDEX /*i*/oi_sha1 ON /*_*/oldimage (oi_sha1);
+CREATE TABLE /*_*/filearchive (
+ fa_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ fa_name varchar(255) binary NOT NULL default '',
+ fa_archive_name varchar(255) binary default '',
+ fa_storage_group varbinary(16),
+ fa_storage_key varbinary(64) default '',
+ fa_deleted_user int,
+ fa_deleted_timestamp binary(14) default '',
+ fa_deleted_reason text,
+ fa_size int unsigned default 0,
+ fa_width int default 0,
+ fa_height int default 0,
+ fa_metadata mediumblob,
+ fa_bits int default 0,
+ fa_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ fa_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") default "unknown",
+ fa_minor_mime varbinary(100) default "unknown",
+ fa_description tinyblob,
+ fa_user int unsigned default 0,
+ fa_user_text varchar(255) binary,
+ fa_timestamp binary(14) default '',
+ fa_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/fa_name ON /*_*/filearchive (fa_name, fa_timestamp);
+CREATE INDEX /*i*/fa_storage_group ON /*_*/filearchive (fa_storage_group, fa_storage_key);
+CREATE INDEX /*i*/fa_deleted_timestamp ON /*_*/filearchive (fa_deleted_timestamp);
+CREATE INDEX /*i*/fa_user_timestamp ON /*_*/filearchive (fa_user_text,fa_timestamp);
+CREATE TABLE /*_*/uploadstash (
+ us_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ us_user int unsigned NOT NULL,
+ us_key varchar(255) NOT NULL,
+ us_orig_path varchar(255) NOT NULL,
+ us_path varchar(255) NOT NULL,
+ us_source_type varchar(50),
+ us_timestamp varbinary(14) NOT NULL,
+ us_status varchar(50) NOT NULL,
+ us_chunk_inx int unsigned NULL,
+ us_size int unsigned NOT NULL,
+ us_sha1 varchar(31) NOT NULL,
+ us_mime varchar(255),
+ us_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ us_image_width int unsigned,
+ us_image_height int unsigned,
+ us_image_bits smallint unsigned
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/us_user ON /*_*/uploadstash (us_user);
+CREATE UNIQUE INDEX /*i*/us_key ON /*_*/uploadstash (us_key);
+CREATE INDEX /*i*/us_timestamp ON /*_*/uploadstash (us_timestamp);
+CREATE TABLE /*_*/recentchanges (
+ rc_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rc_timestamp varbinary(14) NOT NULL default '',
+ rc_cur_time varbinary(14) NOT NULL default '',
+ rc_user int unsigned NOT NULL default 0,
+ rc_user_text varchar(255) binary NOT NULL,
+ rc_namespace int NOT NULL default 0,
+ rc_title varchar(255) binary NOT NULL default '',
+ rc_comment varchar(255) binary NOT NULL default '',
+ rc_minor tinyint unsigned NOT NULL default 0,
+ rc_bot tinyint unsigned NOT NULL default 0,
+ rc_new tinyint unsigned NOT NULL default 0,
+ rc_cur_id int unsigned NOT NULL default 0,
+ rc_this_oldid int unsigned NOT NULL default 0,
+ rc_last_oldid int unsigned NOT NULL default 0,
+ rc_type tinyint unsigned NOT NULL default 0,
+ rc_moved_to_ns tinyint unsigned NOT NULL default 0,
+ rc_moved_to_title varchar(255) binary NOT NULL default '',
+ rc_patrolled tinyint unsigned NOT NULL default 0,
+ rc_ip varbinary(40) NOT NULL default '',
+ rc_old_len int,
+ rc_new_len int,
+ rc_deleted tinyint unsigned NOT NULL default 0,
+ rc_logid int unsigned NOT NULL default 0,
+ rc_log_type varbinary(255) NULL default NULL,
+ rc_log_action varbinary(255) NULL default NULL,
+ rc_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rc_timestamp ON /*_*/recentchanges (rc_timestamp);
+CREATE INDEX /*i*/rc_namespace_title ON /*_*/recentchanges (rc_namespace, rc_title);
+CREATE INDEX /*i*/rc_cur_id ON /*_*/recentchanges (rc_cur_id);
+CREATE INDEX /*i*/new_name_timestamp ON /*_*/recentchanges (rc_new,rc_namespace,rc_timestamp);
+CREATE INDEX /*i*/rc_ip ON /*_*/recentchanges (rc_ip);
+CREATE INDEX /*i*/rc_ns_usertext ON /*_*/recentchanges (rc_namespace, rc_user_text);
+CREATE INDEX /*i*/rc_user_text ON /*_*/recentchanges (rc_user_text, rc_timestamp);
+CREATE TABLE /*_*/watchlist (
+ wl_user int unsigned NOT NULL,
+ wl_namespace int NOT NULL default 0,
+ wl_title varchar(255) binary NOT NULL default '',
+ wl_notificationtimestamp varbinary(14)
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/wl_user ON /*_*/watchlist (wl_user, wl_namespace, wl_title);
+CREATE INDEX /*i*/namespace_title ON /*_*/watchlist (wl_namespace, wl_title);
+CREATE TABLE /*_*/searchindex (
+ si_page int unsigned NOT NULL,
+ si_title varchar(255) NOT NULL default '',
+ si_text mediumtext NOT NULL
+) ENGINE=MyISAM;
+CREATE UNIQUE INDEX /*i*/si_page ON /*_*/searchindex (si_page);
+CREATE FULLTEXT INDEX /*i*/si_title ON /*_*/searchindex (si_title);
+CREATE FULLTEXT INDEX /*i*/si_text ON /*_*/searchindex (si_text);
+CREATE TABLE /*_*/interwiki (
+ iw_prefix varchar(32) NOT NULL,
+ iw_url blob NOT NULL,
+ iw_api blob NOT NULL,
+ iw_wikiid varchar(64) NOT NULL,
+ iw_local bool NOT NULL,
+ iw_trans tinyint NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iw_prefix ON /*_*/interwiki (iw_prefix);
+CREATE TABLE /*_*/querycache (
+ qc_type varbinary(32) NOT NULL,
+ qc_value int unsigned NOT NULL default 0,
+ qc_namespace int NOT NULL default 0,
+ qc_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qc_type ON /*_*/querycache (qc_type,qc_value);
+CREATE TABLE /*_*/objectcache (
+ keyname varbinary(255) NOT NULL default '' PRIMARY KEY,
+ value mediumblob,
+ exptime datetime
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/exptime ON /*_*/objectcache (exptime);
+CREATE TABLE /*_*/transcache (
+ tc_url varbinary(255) NOT NULL,
+ tc_contents text,
+ tc_time binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tc_url_idx ON /*_*/transcache (tc_url);
+CREATE TABLE /*_*/logging (
+ log_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ log_type varbinary(32) NOT NULL default '',
+ log_action varbinary(32) NOT NULL default '',
+ log_timestamp binary(14) NOT NULL default '19700101000000',
+ log_user int unsigned NOT NULL default 0,
+ log_user_text varchar(255) binary NOT NULL default '',
+ log_namespace int NOT NULL default 0,
+ log_title varchar(255) binary NOT NULL default '',
+ log_page int unsigned NULL,
+ log_comment varchar(255) NOT NULL default '',
+ log_params blob NOT NULL,
+ log_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/type_time ON /*_*/logging (log_type, log_timestamp);
+CREATE INDEX /*i*/user_time ON /*_*/logging (log_user, log_timestamp);
+CREATE INDEX /*i*/page_time ON /*_*/logging (log_namespace, log_title, log_timestamp);
+CREATE INDEX /*i*/times ON /*_*/logging (log_timestamp);
+CREATE INDEX /*i*/log_user_type_time ON /*_*/logging (log_user, log_type, log_timestamp);
+CREATE INDEX /*i*/log_page_id_time ON /*_*/logging (log_page,log_timestamp);
+CREATE INDEX /*i*/type_action ON /*_*/logging (log_type, log_action, log_timestamp);
+CREATE TABLE /*_*/log_search (
+ ls_field varbinary(32) NOT NULL,
+ ls_value varchar(255) NOT NULL,
+ ls_log_id int unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ls_field_val ON /*_*/log_search (ls_field,ls_value,ls_log_id);
+CREATE INDEX /*i*/ls_log_id ON /*_*/log_search (ls_log_id);
+CREATE TABLE /*_*/job (
+ job_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ job_cmd varbinary(60) NOT NULL default '',
+ job_namespace int NOT NULL,
+ job_title varchar(255) binary NOT NULL,
+ job_timestamp varbinary(14) NULL default NULL,
+ job_params blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/job_cmd ON /*_*/job (job_cmd, job_namespace, job_title, job_params(128));
+CREATE INDEX /*i*/job_timestamp ON /*_*/job (job_timestamp);
+CREATE TABLE /*_*/querycache_info (
+ qci_type varbinary(32) NOT NULL default '',
+ qci_timestamp binary(14) NOT NULL default '19700101000000'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/qci_type ON /*_*/querycache_info (qci_type);
+CREATE TABLE /*_*/redirect (
+ rd_from int unsigned NOT NULL default 0 PRIMARY KEY,
+ rd_namespace int NOT NULL default 0,
+ rd_title varchar(255) binary NOT NULL default '',
+ rd_interwiki varchar(32) default NULL,
+ rd_fragment varchar(255) binary default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rd_ns_title ON /*_*/redirect (rd_namespace,rd_title,rd_from);
+CREATE TABLE /*_*/querycachetwo (
+ qcc_type varbinary(32) NOT NULL,
+ qcc_value int unsigned NOT NULL default 0,
+ qcc_namespace int NOT NULL default 0,
+ qcc_title varchar(255) binary NOT NULL default '',
+ qcc_namespacetwo int NOT NULL default 0,
+ qcc_titletwo varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qcc_type ON /*_*/querycachetwo (qcc_type,qcc_value);
+CREATE INDEX /*i*/qcc_title ON /*_*/querycachetwo (qcc_type,qcc_namespace,qcc_title);
+CREATE INDEX /*i*/qcc_titletwo ON /*_*/querycachetwo (qcc_type,qcc_namespacetwo,qcc_titletwo);
+CREATE TABLE /*_*/page_restrictions (
+ pr_page int NOT NULL,
+ pr_type varbinary(60) NOT NULL,
+ pr_level varbinary(60) NOT NULL,
+ pr_cascade tinyint NOT NULL,
+ pr_user int NULL,
+ pr_expiry varbinary(14) NULL,
+ pr_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pr_pagetype ON /*_*/page_restrictions (pr_page,pr_type);
+CREATE INDEX /*i*/pr_typelevel ON /*_*/page_restrictions (pr_type,pr_level);
+CREATE INDEX /*i*/pr_level ON /*_*/page_restrictions (pr_level);
+CREATE INDEX /*i*/pr_cascade ON /*_*/page_restrictions (pr_cascade);
+CREATE TABLE /*_*/protected_titles (
+ pt_namespace int NOT NULL,
+ pt_title varchar(255) binary NOT NULL,
+ pt_user int unsigned NOT NULL,
+ pt_reason tinyblob,
+ pt_timestamp binary(14) NOT NULL,
+ pt_expiry varbinary(14) NOT NULL default '',
+ pt_create_perm varbinary(60) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pt_namespace_title ON /*_*/protected_titles (pt_namespace,pt_title);
+CREATE INDEX /*i*/pt_timestamp ON /*_*/protected_titles (pt_timestamp);
+CREATE TABLE /*_*/page_props (
+ pp_page int NOT NULL,
+ pp_propname varbinary(60) NOT NULL,
+ pp_value blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pp_page_propname ON /*_*/page_props (pp_page,pp_propname);
+CREATE TABLE /*_*/updatelog (
+ ul_key varchar(255) NOT NULL PRIMARY KEY,
+ ul_value blob
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/change_tag (
+ ct_rc_id int NULL,
+ ct_log_id int NULL,
+ ct_rev_id int NULL,
+ ct_tag varchar(255) NOT NULL,
+ ct_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/change_tag_rc_tag ON /*_*/change_tag (ct_rc_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_log_tag ON /*_*/change_tag (ct_log_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_rev_tag ON /*_*/change_tag (ct_rev_id,ct_tag);
+CREATE INDEX /*i*/change_tag_tag_id ON /*_*/change_tag (ct_tag,ct_rc_id,ct_rev_id,ct_log_id);
+CREATE TABLE /*_*/tag_summary (
+ ts_rc_id int NULL,
+ ts_log_id int NULL,
+ ts_rev_id int NULL,
+ ts_tags blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tag_summary_rc_id ON /*_*/tag_summary (ts_rc_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_log_id ON /*_*/tag_summary (ts_log_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_rev_id ON /*_*/tag_summary (ts_rev_id);
+CREATE TABLE /*_*/valid_tag (
+ vt_tag varchar(255) NOT NULL PRIMARY KEY
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/l10n_cache (
+ lc_lang varbinary(32) NOT NULL,
+ lc_key varchar(255) NOT NULL,
+ lc_value mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/lc_lang_key ON /*_*/l10n_cache (lc_lang, lc_key);
+CREATE TABLE /*_*/msg_resource (
+ mr_resource varbinary(255) NOT NULL,
+ mr_lang varbinary(32) NOT NULL,
+ mr_blob mediumblob NOT NULL,
+ mr_timestamp binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mr_resource_lang ON /*_*/msg_resource (mr_resource, mr_lang);
+CREATE TABLE /*_*/msg_resource_links (
+ mrl_resource varbinary(255) NOT NULL,
+ mrl_message varbinary(255) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mrl_message_resource ON /*_*/msg_resource_links (mrl_message, mrl_resource);
+CREATE TABLE /*_*/module_deps (
+ md_module varbinary(255) NOT NULL,
+ md_skin varbinary(32) NOT NULL,
+ md_deps mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/md_module_skin ON /*_*/module_deps (md_module, md_skin);
+-- vim: sw=2 sts=2 et \ No newline at end of file
diff --git a/www/wiki/tests/phpunit/data/db/sqlite/tables-1.21.sql b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.21.sql
new file mode 100644
index 00000000..dbc84a60
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.21.sql
@@ -0,0 +1,577 @@
+-- This is a copy of MediaWiki 1.21 schema shared by MySQL and SQLite.
+-- It is used for updater testing. Comments are stripped to decrease
+-- file size, as we don't need to maintain it.
+
+CREATE TABLE /*_*/user (
+ user_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ user_name varchar(255) binary NOT NULL default '',
+ user_real_name varchar(255) binary NOT NULL default '',
+ user_password tinyblob NOT NULL,
+ user_newpassword tinyblob NOT NULL,
+ user_newpass_time binary(14),
+ user_email tinytext NOT NULL,
+ user_touched binary(14) NOT NULL default '',
+ user_token binary(32) NOT NULL default '',
+ user_email_authenticated binary(14),
+ user_email_token binary(32),
+ user_email_token_expires binary(14),
+ user_registration binary(14),
+ user_editcount int
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_name ON /*_*/user (user_name);
+CREATE INDEX /*i*/user_email_token ON /*_*/user (user_email_token);
+CREATE INDEX /*i*/user_email ON /*_*/user (user_email(50));
+CREATE TABLE /*_*/user_groups (
+ ug_user int unsigned NOT NULL default 0,
+ ug_group varbinary(255) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user,ug_group);
+CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE TABLE /*_*/user_former_groups (
+ ufg_user int unsigned NOT NULL default 0,
+ ufg_group varbinary(255) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ufg_user_group ON /*_*/user_former_groups (ufg_user,ufg_group);
+CREATE TABLE /*_*/user_newtalk (
+ user_id int NOT NULL default 0,
+ user_ip varbinary(40) NOT NULL default '',
+ user_last_timestamp varbinary(14) NULL default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id);
+CREATE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip);
+CREATE TABLE /*_*/user_properties (
+ up_user int NOT NULL,
+ up_property varbinary(255) NOT NULL,
+ up_value blob
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_properties_user_property ON /*_*/user_properties (up_user,up_property);
+CREATE INDEX /*i*/user_properties_property ON /*_*/user_properties (up_property);
+CREATE TABLE /*_*/page (
+ page_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ page_namespace int NOT NULL,
+ page_title varchar(255) binary NOT NULL,
+ page_restrictions tinyblob NOT NULL,
+ page_counter bigint unsigned NOT NULL default 0,
+ page_is_redirect tinyint unsigned NOT NULL default 0,
+ page_is_new tinyint unsigned NOT NULL default 0,
+ page_random real unsigned NOT NULL,
+ page_touched binary(14) NOT NULL default '',
+ page_latest int unsigned NOT NULL,
+ page_len int unsigned NOT NULL,
+ page_content_model varbinary(32) DEFAULT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/name_title ON /*_*/page (page_namespace,page_title);
+CREATE INDEX /*i*/page_random ON /*_*/page (page_random);
+CREATE INDEX /*i*/page_len ON /*_*/page (page_len);
+CREATE INDEX /*i*/page_redirect_namespace_len ON /*_*/page (page_is_redirect, page_namespace, page_len);
+CREATE TABLE /*_*/revision (
+ rev_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rev_page int unsigned NOT NULL,
+ rev_text_id int unsigned NOT NULL,
+ rev_comment tinyblob NOT NULL,
+ rev_user int unsigned NOT NULL default 0,
+ rev_user_text varchar(255) binary NOT NULL default '',
+ rev_timestamp binary(14) NOT NULL default '',
+ rev_minor_edit tinyint unsigned NOT NULL default 0,
+ rev_deleted tinyint unsigned NOT NULL default 0,
+ rev_len int unsigned,
+ rev_parent_id int unsigned default NULL,
+ rev_sha1 varbinary(32) NOT NULL default '',
+ rev_content_model varbinary(32) DEFAULT NULL,
+ rev_content_format varbinary(64) DEFAULT NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=1024;
+CREATE UNIQUE INDEX /*i*/rev_page_id ON /*_*/revision (rev_page, rev_id);
+CREATE INDEX /*i*/rev_timestamp ON /*_*/revision (rev_timestamp);
+CREATE INDEX /*i*/page_timestamp ON /*_*/revision (rev_page,rev_timestamp);
+CREATE INDEX /*i*/user_timestamp ON /*_*/revision (rev_user,rev_timestamp);
+CREATE INDEX /*i*/usertext_timestamp ON /*_*/revision (rev_user_text,rev_timestamp);
+CREATE INDEX /*i*/page_user_timestamp ON /*_*/revision (rev_page,rev_user,rev_timestamp);
+CREATE TABLE /*_*/text (
+ old_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ old_text mediumblob NOT NULL,
+ old_flags tinyblob NOT NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=10240;
+CREATE TABLE /*_*/archive (
+ ar_namespace int NOT NULL default 0,
+ ar_title varchar(255) binary NOT NULL default '',
+ ar_text mediumblob NOT NULL,
+ ar_comment tinyblob NOT NULL,
+ ar_user int unsigned NOT NULL default 0,
+ ar_user_text varchar(255) binary NOT NULL,
+ ar_timestamp binary(14) NOT NULL default '',
+ ar_minor_edit tinyint NOT NULL default 0,
+ ar_flags tinyblob NOT NULL,
+ ar_rev_id int unsigned,
+ ar_text_id int unsigned,
+ ar_deleted tinyint unsigned NOT NULL default 0,
+ ar_len int unsigned,
+ ar_page_id int unsigned,
+ ar_parent_id int unsigned default NULL,
+ ar_sha1 varbinary(32) NOT NULL default '',
+ ar_content_model varbinary(32) DEFAULT NULL,
+ ar_content_format varbinary(64) DEFAULT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/name_title_timestamp ON /*_*/archive (ar_namespace,ar_title,ar_timestamp);
+CREATE INDEX /*i*/ar_usertext_timestamp ON /*_*/archive (ar_user_text,ar_timestamp);
+CREATE INDEX /*i*/ar_revid ON /*_*/archive (ar_rev_id);
+CREATE TABLE /*_*/pagelinks (
+ pl_from int unsigned NOT NULL default 0,
+ pl_namespace int NOT NULL default 0,
+ pl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pl_from ON /*_*/pagelinks (pl_from,pl_namespace,pl_title);
+CREATE UNIQUE INDEX /*i*/pl_namespace ON /*_*/pagelinks (pl_namespace,pl_title,pl_from);
+CREATE TABLE /*_*/templatelinks (
+ tl_from int unsigned NOT NULL default 0,
+ tl_namespace int NOT NULL default 0,
+ tl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tl_from ON /*_*/templatelinks (tl_from,tl_namespace,tl_title);
+CREATE UNIQUE INDEX /*i*/tl_namespace ON /*_*/templatelinks (tl_namespace,tl_title,tl_from);
+CREATE TABLE /*_*/imagelinks (
+ il_from int unsigned NOT NULL default 0,
+ il_to varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/il_from ON /*_*/imagelinks (il_from,il_to);
+CREATE UNIQUE INDEX /*i*/il_to ON /*_*/imagelinks (il_to,il_from);
+CREATE TABLE /*_*/categorylinks (
+ cl_from int unsigned NOT NULL default 0,
+ cl_to varchar(255) binary NOT NULL default '',
+ cl_sortkey varbinary(230) NOT NULL default '',
+ cl_sortkey_prefix varchar(255) binary NOT NULL default '',
+ cl_timestamp timestamp NOT NULL,
+ cl_collation varbinary(32) NOT NULL default '',
+ cl_type ENUM('page', 'subcat', 'file') NOT NULL default 'page'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cl_from ON /*_*/categorylinks (cl_from,cl_to);
+CREATE INDEX /*i*/cl_sortkey ON /*_*/categorylinks (cl_to,cl_type,cl_sortkey,cl_from);
+CREATE INDEX /*i*/cl_timestamp ON /*_*/categorylinks (cl_to,cl_timestamp);
+CREATE INDEX /*i*/cl_collation ON /*_*/categorylinks (cl_collation);
+CREATE TABLE /*_*/category (
+ cat_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ cat_title varchar(255) binary NOT NULL,
+ cat_pages int signed NOT NULL default 0,
+ cat_subcats int signed NOT NULL default 0,
+ cat_files int signed NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cat_title ON /*_*/category (cat_title);
+CREATE INDEX /*i*/cat_pages ON /*_*/category (cat_pages);
+CREATE TABLE /*_*/externallinks (
+ el_from int unsigned NOT NULL default 0,
+ el_to blob NOT NULL,
+ el_index blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/el_from ON /*_*/externallinks (el_from, el_to(40));
+CREATE INDEX /*i*/el_to ON /*_*/externallinks (el_to(60), el_from);
+CREATE INDEX /*i*/el_index ON /*_*/externallinks (el_index(60));
+CREATE TABLE /*_*/external_user (
+ eu_local_id int unsigned NOT NULL PRIMARY KEY,
+ eu_external_id varchar(255) binary NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/eu_external_id ON /*_*/external_user (eu_external_id);
+CREATE TABLE /*_*/langlinks (
+ ll_from int unsigned NOT NULL default 0,
+ ll_lang varbinary(20) NOT NULL default '',
+ ll_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ll_from ON /*_*/langlinks (ll_from, ll_lang);
+CREATE INDEX /*i*/ll_lang ON /*_*/langlinks (ll_lang, ll_title);
+CREATE TABLE /*_*/iwlinks (
+ iwl_from int unsigned NOT NULL default 0,
+ iwl_prefix varbinary(20) NOT NULL default '',
+ iwl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iwl_from ON /*_*/iwlinks (iwl_from, iwl_prefix, iwl_title);
+CREATE UNIQUE INDEX /*i*/iwl_prefix_title_from ON /*_*/iwlinks (iwl_prefix, iwl_title, iwl_from);
+CREATE TABLE /*_*/site_stats (
+ ss_row_id int unsigned NOT NULL,
+ ss_total_views bigint unsigned default 0,
+ ss_total_edits bigint unsigned default 0,
+ ss_good_articles bigint unsigned default 0,
+ ss_total_pages bigint default '-1',
+ ss_users bigint default '-1',
+ ss_active_users bigint default '-1',
+ ss_images int default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ss_row_id ON /*_*/site_stats (ss_row_id);
+CREATE TABLE /*_*/hitcounter (
+ hc_id int unsigned NOT NULL
+) ENGINE=HEAP MAX_ROWS=25000;
+CREATE TABLE /*_*/ipblocks (
+ ipb_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ ipb_address tinyblob NOT NULL,
+ ipb_user int unsigned NOT NULL default 0,
+ ipb_by int unsigned NOT NULL default 0,
+ ipb_by_text varchar(255) binary NOT NULL default '',
+ ipb_reason tinyblob NOT NULL,
+ ipb_timestamp binary(14) NOT NULL default '',
+ ipb_auto bool NOT NULL default 0,
+ ipb_anon_only bool NOT NULL default 0,
+ ipb_create_account bool NOT NULL default 1,
+ ipb_enable_autoblock bool NOT NULL default '1',
+ ipb_expiry varbinary(14) NOT NULL default '',
+ ipb_range_start tinyblob NOT NULL,
+ ipb_range_end tinyblob NOT NULL,
+ ipb_deleted bool NOT NULL default 0,
+ ipb_block_email bool NOT NULL default 0,
+ ipb_allow_usertalk bool NOT NULL default 0,
+ ipb_parent_block_id int default NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ipb_address ON /*_*/ipblocks (ipb_address(255), ipb_user, ipb_auto, ipb_anon_only);
+CREATE INDEX /*i*/ipb_user ON /*_*/ipblocks (ipb_user);
+CREATE INDEX /*i*/ipb_range ON /*_*/ipblocks (ipb_range_start(8), ipb_range_end(8));
+CREATE INDEX /*i*/ipb_timestamp ON /*_*/ipblocks (ipb_timestamp);
+CREATE INDEX /*i*/ipb_expiry ON /*_*/ipblocks (ipb_expiry);
+CREATE INDEX /*i*/ipb_parent_block_id ON /*_*/ipblocks (ipb_parent_block_id);
+CREATE TABLE /*_*/image (
+ img_name varchar(255) binary NOT NULL default '' PRIMARY KEY,
+ img_size int unsigned NOT NULL default 0,
+ img_width int NOT NULL default 0,
+ img_height int NOT NULL default 0,
+ img_metadata mediumblob NOT NULL,
+ img_bits int NOT NULL default 0,
+ img_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ img_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ img_minor_mime varbinary(100) NOT NULL default "unknown",
+ img_description tinyblob NOT NULL,
+ img_user int unsigned NOT NULL default 0,
+ img_user_text varchar(255) binary NOT NULL,
+ img_timestamp varbinary(14) NOT NULL default '',
+ img_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/img_usertext_timestamp ON /*_*/image (img_user_text,img_timestamp);
+CREATE INDEX /*i*/img_size ON /*_*/image (img_size);
+CREATE INDEX /*i*/img_timestamp ON /*_*/image (img_timestamp);
+CREATE INDEX /*i*/img_sha1 ON /*_*/image (img_sha1(10));
+CREATE INDEX /*i*/img_media_mime ON /*_*/image (img_media_type,img_major_mime,img_minor_mime);
+CREATE TABLE /*_*/oldimage (
+ oi_name varchar(255) binary NOT NULL default '',
+ oi_archive_name varchar(255) binary NOT NULL default '',
+ oi_size int unsigned NOT NULL default 0,
+ oi_width int NOT NULL default 0,
+ oi_height int NOT NULL default 0,
+ oi_bits int NOT NULL default 0,
+ oi_description tinyblob NOT NULL,
+ oi_user int unsigned NOT NULL default 0,
+ oi_user_text varchar(255) binary NOT NULL,
+ oi_timestamp binary(14) NOT NULL default '',
+ oi_metadata mediumblob NOT NULL,
+ oi_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ oi_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ oi_minor_mime varbinary(100) NOT NULL default "unknown",
+ oi_deleted tinyint unsigned NOT NULL default 0,
+ oi_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/oi_usertext_timestamp ON /*_*/oldimage (oi_user_text,oi_timestamp);
+CREATE INDEX /*i*/oi_name_timestamp ON /*_*/oldimage (oi_name,oi_timestamp);
+CREATE INDEX /*i*/oi_name_archive_name ON /*_*/oldimage (oi_name,oi_archive_name(14));
+CREATE INDEX /*i*/oi_sha1 ON /*_*/oldimage (oi_sha1(10));
+CREATE TABLE /*_*/filearchive (
+ fa_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ fa_name varchar(255) binary NOT NULL default '',
+ fa_archive_name varchar(255) binary default '',
+ fa_storage_group varbinary(16),
+ fa_storage_key varbinary(64) default '',
+ fa_deleted_user int,
+ fa_deleted_timestamp binary(14) default '',
+ fa_deleted_reason text,
+ fa_size int unsigned default 0,
+ fa_width int default 0,
+ fa_height int default 0,
+ fa_metadata mediumblob,
+ fa_bits int default 0,
+ fa_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ fa_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") default "unknown",
+ fa_minor_mime varbinary(100) default "unknown",
+ fa_description tinyblob,
+ fa_user int unsigned default 0,
+ fa_user_text varchar(255) binary,
+ fa_timestamp binary(14) default '',
+ fa_deleted tinyint unsigned NOT NULL default 0,
+ fa_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/fa_name ON /*_*/filearchive (fa_name, fa_timestamp);
+CREATE INDEX /*i*/fa_storage_group ON /*_*/filearchive (fa_storage_group, fa_storage_key);
+CREATE INDEX /*i*/fa_deleted_timestamp ON /*_*/filearchive (fa_deleted_timestamp);
+CREATE INDEX /*i*/fa_user_timestamp ON /*_*/filearchive (fa_user_text,fa_timestamp);
+CREATE INDEX /*i*/fa_sha1 ON /*_*/filearchive (fa_sha1(10));
+CREATE TABLE /*_*/uploadstash (
+ us_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ us_user int unsigned NOT NULL,
+ us_key varchar(255) NOT NULL,
+ us_orig_path varchar(255) NOT NULL,
+ us_path varchar(255) NOT NULL,
+ us_source_type varchar(50),
+ us_timestamp varbinary(14) NOT NULL,
+ us_status varchar(50) NOT NULL,
+ us_chunk_inx int unsigned NULL,
+ us_props blob,
+ us_size int unsigned NOT NULL,
+ us_sha1 varchar(31) NOT NULL,
+ us_mime varchar(255),
+ us_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ us_image_width int unsigned,
+ us_image_height int unsigned,
+ us_image_bits smallint unsigned
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/us_user ON /*_*/uploadstash (us_user);
+CREATE UNIQUE INDEX /*i*/us_key ON /*_*/uploadstash (us_key);
+CREATE INDEX /*i*/us_timestamp ON /*_*/uploadstash (us_timestamp);
+CREATE TABLE /*_*/recentchanges (
+ rc_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rc_timestamp varbinary(14) NOT NULL default '',
+ rc_cur_time varbinary(14) NOT NULL default '',
+ rc_user int unsigned NOT NULL default 0,
+ rc_user_text varchar(255) binary NOT NULL,
+ rc_namespace int NOT NULL default 0,
+ rc_title varchar(255) binary NOT NULL default '',
+ rc_comment varchar(255) binary NOT NULL default '',
+ rc_minor tinyint unsigned NOT NULL default 0,
+ rc_bot tinyint unsigned NOT NULL default 0,
+ rc_new tinyint unsigned NOT NULL default 0,
+ rc_cur_id int unsigned NOT NULL default 0,
+ rc_this_oldid int unsigned NOT NULL default 0,
+ rc_last_oldid int unsigned NOT NULL default 0,
+ rc_type tinyint unsigned NOT NULL default 0,
+ rc_patrolled tinyint unsigned NOT NULL default 0,
+ rc_ip varbinary(40) NOT NULL default '',
+ rc_old_len int,
+ rc_new_len int,
+ rc_deleted tinyint unsigned NOT NULL default 0,
+ rc_logid int unsigned NOT NULL default 0,
+ rc_log_type varbinary(255) NULL default NULL,
+ rc_log_action varbinary(255) NULL default NULL,
+ rc_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rc_timestamp ON /*_*/recentchanges (rc_timestamp);
+CREATE INDEX /*i*/rc_namespace_title ON /*_*/recentchanges (rc_namespace, rc_title);
+CREATE INDEX /*i*/rc_cur_id ON /*_*/recentchanges (rc_cur_id);
+CREATE INDEX /*i*/new_name_timestamp ON /*_*/recentchanges (rc_new,rc_namespace,rc_timestamp);
+CREATE INDEX /*i*/rc_ip ON /*_*/recentchanges (rc_ip);
+CREATE INDEX /*i*/rc_ns_usertext ON /*_*/recentchanges (rc_namespace, rc_user_text);
+CREATE INDEX /*i*/rc_user_text ON /*_*/recentchanges (rc_user_text, rc_timestamp);
+CREATE TABLE /*_*/watchlist (
+ wl_user int unsigned NOT NULL,
+ wl_namespace int NOT NULL default 0,
+ wl_title varchar(255) binary NOT NULL default '',
+ wl_notificationtimestamp varbinary(14)
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/wl_user ON /*_*/watchlist (wl_user, wl_namespace, wl_title);
+CREATE INDEX /*i*/namespace_title ON /*_*/watchlist (wl_namespace, wl_title);
+CREATE TABLE /*_*/searchindex (
+ si_page int unsigned NOT NULL,
+ si_title varchar(255) NOT NULL default '',
+ si_text mediumtext NOT NULL
+) ENGINE=MyISAM;
+CREATE UNIQUE INDEX /*i*/si_page ON /*_*/searchindex (si_page);
+CREATE FULLTEXT INDEX /*i*/si_title ON /*_*/searchindex (si_title);
+CREATE FULLTEXT INDEX /*i*/si_text ON /*_*/searchindex (si_text);
+CREATE TABLE /*_*/interwiki (
+ iw_prefix varchar(32) NOT NULL,
+ iw_url blob NOT NULL,
+ iw_api blob NOT NULL,
+ iw_wikiid varchar(64) NOT NULL,
+ iw_local bool NOT NULL,
+ iw_trans tinyint NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iw_prefix ON /*_*/interwiki (iw_prefix);
+CREATE TABLE /*_*/querycache (
+ qc_type varbinary(32) NOT NULL,
+ qc_value int unsigned NOT NULL default 0,
+ qc_namespace int NOT NULL default 0,
+ qc_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qc_type ON /*_*/querycache (qc_type,qc_value);
+CREATE TABLE /*_*/objectcache (
+ keyname varbinary(255) NOT NULL default '' PRIMARY KEY,
+ value mediumblob,
+ exptime datetime
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/exptime ON /*_*/objectcache (exptime);
+CREATE TABLE /*_*/transcache (
+ tc_url varbinary(255) NOT NULL,
+ tc_contents text,
+ tc_time binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tc_url_idx ON /*_*/transcache (tc_url);
+CREATE TABLE /*_*/logging (
+ log_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ log_type varbinary(32) NOT NULL default '',
+ log_action varbinary(32) NOT NULL default '',
+ log_timestamp binary(14) NOT NULL default '19700101000000',
+ log_user int unsigned NOT NULL default 0,
+ log_user_text varchar(255) binary NOT NULL default '',
+ log_namespace int NOT NULL default 0,
+ log_title varchar(255) binary NOT NULL default '',
+ log_page int unsigned NULL,
+ log_comment varchar(255) NOT NULL default '',
+ log_params blob NOT NULL,
+ log_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/type_time ON /*_*/logging (log_type, log_timestamp);
+CREATE INDEX /*i*/user_time ON /*_*/logging (log_user, log_timestamp);
+CREATE INDEX /*i*/page_time ON /*_*/logging (log_namespace, log_title, log_timestamp);
+CREATE INDEX /*i*/times ON /*_*/logging (log_timestamp);
+CREATE INDEX /*i*/log_user_type_time ON /*_*/logging (log_user, log_type, log_timestamp);
+CREATE INDEX /*i*/log_page_id_time ON /*_*/logging (log_page,log_timestamp);
+CREATE INDEX /*i*/type_action ON /*_*/logging (log_type, log_action, log_timestamp);
+CREATE TABLE /*_*/log_search (
+ ls_field varbinary(32) NOT NULL,
+ ls_value varchar(255) NOT NULL,
+ ls_log_id int unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ls_field_val ON /*_*/log_search (ls_field,ls_value,ls_log_id);
+CREATE INDEX /*i*/ls_log_id ON /*_*/log_search (ls_log_id);
+CREATE TABLE /*_*/job (
+ job_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ job_cmd varbinary(60) NOT NULL default '',
+ job_namespace int NOT NULL,
+ job_title varchar(255) binary NOT NULL,
+ job_timestamp varbinary(14) NULL default NULL,
+ job_params blob NOT NULL,
+ job_random integer unsigned NOT NULL default 0,
+ job_attempts integer unsigned NOT NULL default 0,
+ job_token varbinary(32) NOT NULL default '',
+ job_token_timestamp varbinary(14) NULL default NULL,
+ job_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/job_sha1 ON /*_*/job (job_sha1);
+CREATE INDEX /*i*/job_cmd_token ON /*_*/job (job_cmd,job_token,job_random);
+CREATE INDEX /*i*/job_cmd_token_id ON /*_*/job (job_cmd,job_token,job_id);
+CREATE INDEX /*i*/job_cmd ON /*_*/job (job_cmd, job_namespace, job_title, job_params(128));
+CREATE INDEX /*i*/job_timestamp ON /*_*/job (job_timestamp);
+CREATE TABLE /*_*/querycache_info (
+ qci_type varbinary(32) NOT NULL default '',
+ qci_timestamp binary(14) NOT NULL default '19700101000000'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/qci_type ON /*_*/querycache_info (qci_type);
+CREATE TABLE /*_*/redirect (
+ rd_from int unsigned NOT NULL default 0 PRIMARY KEY,
+ rd_namespace int NOT NULL default 0,
+ rd_title varchar(255) binary NOT NULL default '',
+ rd_interwiki varchar(32) default NULL,
+ rd_fragment varchar(255) binary default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rd_ns_title ON /*_*/redirect (rd_namespace,rd_title,rd_from);
+CREATE TABLE /*_*/querycachetwo (
+ qcc_type varbinary(32) NOT NULL,
+ qcc_value int unsigned NOT NULL default 0,
+ qcc_namespace int NOT NULL default 0,
+ qcc_title varchar(255) binary NOT NULL default '',
+ qcc_namespacetwo int NOT NULL default 0,
+ qcc_titletwo varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qcc_type ON /*_*/querycachetwo (qcc_type,qcc_value);
+CREATE INDEX /*i*/qcc_title ON /*_*/querycachetwo (qcc_type,qcc_namespace,qcc_title);
+CREATE INDEX /*i*/qcc_titletwo ON /*_*/querycachetwo (qcc_type,qcc_namespacetwo,qcc_titletwo);
+CREATE TABLE /*_*/page_restrictions (
+ pr_page int NOT NULL,
+ pr_type varbinary(60) NOT NULL,
+ pr_level varbinary(60) NOT NULL,
+ pr_cascade tinyint NOT NULL,
+ pr_user int NULL,
+ pr_expiry varbinary(14) NULL,
+ pr_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pr_pagetype ON /*_*/page_restrictions (pr_page,pr_type);
+CREATE INDEX /*i*/pr_typelevel ON /*_*/page_restrictions (pr_type,pr_level);
+CREATE INDEX /*i*/pr_level ON /*_*/page_restrictions (pr_level);
+CREATE INDEX /*i*/pr_cascade ON /*_*/page_restrictions (pr_cascade);
+CREATE TABLE /*_*/protected_titles (
+ pt_namespace int NOT NULL,
+ pt_title varchar(255) binary NOT NULL,
+ pt_user int unsigned NOT NULL,
+ pt_reason tinyblob,
+ pt_timestamp binary(14) NOT NULL,
+ pt_expiry varbinary(14) NOT NULL default '',
+ pt_create_perm varbinary(60) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pt_namespace_title ON /*_*/protected_titles (pt_namespace,pt_title);
+CREATE INDEX /*i*/pt_timestamp ON /*_*/protected_titles (pt_timestamp);
+CREATE TABLE /*_*/page_props (
+ pp_page int NOT NULL,
+ pp_propname varbinary(60) NOT NULL,
+ pp_value blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pp_page_propname ON /*_*/page_props (pp_page,pp_propname);
+CREATE UNIQUE INDEX /*i*/pp_propname_page ON /*_*/page_props (pp_propname,pp_page);
+CREATE TABLE /*_*/updatelog (
+ ul_key varchar(255) NOT NULL PRIMARY KEY,
+ ul_value blob
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/change_tag (
+ ct_rc_id int NULL,
+ ct_log_id int NULL,
+ ct_rev_id int NULL,
+ ct_tag varchar(255) NOT NULL,
+ ct_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/change_tag_rc_tag ON /*_*/change_tag (ct_rc_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_log_tag ON /*_*/change_tag (ct_log_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_rev_tag ON /*_*/change_tag (ct_rev_id,ct_tag);
+CREATE INDEX /*i*/change_tag_tag_id ON /*_*/change_tag (ct_tag,ct_rc_id,ct_rev_id,ct_log_id);
+CREATE TABLE /*_*/tag_summary (
+ ts_rc_id int NULL,
+ ts_log_id int NULL,
+ ts_rev_id int NULL,
+ ts_tags blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tag_summary_rc_id ON /*_*/tag_summary (ts_rc_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_log_id ON /*_*/tag_summary (ts_log_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_rev_id ON /*_*/tag_summary (ts_rev_id);
+CREATE TABLE /*_*/valid_tag (
+ vt_tag varchar(255) NOT NULL PRIMARY KEY
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/l10n_cache (
+ lc_lang varbinary(32) NOT NULL,
+ lc_key varchar(255) NOT NULL,
+ lc_value mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/lc_lang_key ON /*_*/l10n_cache (lc_lang, lc_key);
+CREATE TABLE /*_*/msg_resource (
+ mr_resource varbinary(255) NOT NULL,
+ mr_lang varbinary(32) NOT NULL,
+ mr_blob mediumblob NOT NULL,
+ mr_timestamp binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mr_resource_lang ON /*_*/msg_resource (mr_resource, mr_lang);
+CREATE TABLE /*_*/msg_resource_links (
+ mrl_resource varbinary(255) NOT NULL,
+ mrl_message varbinary(255) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mrl_message_resource ON /*_*/msg_resource_links (mrl_message, mrl_resource);
+CREATE TABLE /*_*/module_deps (
+ md_module varbinary(255) NOT NULL,
+ md_skin varbinary(32) NOT NULL,
+ md_deps mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/md_module_skin ON /*_*/module_deps (md_module, md_skin);
+CREATE TABLE /*_*/sites (
+ site_id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ site_global_key varbinary(32) NOT NULL,
+ site_type varbinary(32) NOT NULL,
+ site_group varbinary(32) NOT NULL,
+ site_source varbinary(32) NOT NULL,
+ site_language varbinary(32) NOT NULL,
+ site_protocol varbinary(32) NOT NULL,
+ site_domain VARCHAR(255) NOT NULL,
+ site_data BLOB NOT NULL,
+ site_forward bool NOT NULL,
+ site_config BLOB NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/sites_global_key ON /*_*/sites (site_global_key);
+CREATE INDEX /*i*/sites_type ON /*_*/sites (site_type);
+CREATE INDEX /*i*/sites_group ON /*_*/sites (site_group);
+CREATE INDEX /*i*/sites_source ON /*_*/sites (site_source);
+CREATE INDEX /*i*/sites_language ON /*_*/sites (site_language);
+CREATE INDEX /*i*/sites_protocol ON /*_*/sites (site_protocol);
+CREATE INDEX /*i*/sites_domain ON /*_*/sites (site_domain);
+CREATE INDEX /*i*/sites_forward ON /*_*/sites (site_forward);
+CREATE TABLE /*_*/site_identifiers (
+ si_site INT UNSIGNED NOT NULL,
+ si_type varbinary(32) NOT NULL,
+ si_key varbinary(32) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/site_ids_type ON /*_*/site_identifiers (si_type, si_key);
+CREATE INDEX /*i*/site_ids_site ON /*_*/site_identifiers (si_site);
+CREATE INDEX /*i*/site_ids_key ON /*_*/site_identifiers (si_key);
diff --git a/www/wiki/tests/phpunit/data/db/sqlite/tables-1.22.sql b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.22.sql
new file mode 100644
index 00000000..74c5bd5d
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.22.sql
@@ -0,0 +1,575 @@
+-- This is a copy of MediaWiki 1.22 schema shared by MySQL and SQLite.
+-- It is used for updater testing. Comments are stripped to decrease
+-- file size, as we don't need to maintain it.
+
+CREATE TABLE /*_*/user (
+ user_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ user_name varchar(255) binary NOT NULL default '',
+ user_real_name varchar(255) binary NOT NULL default '',
+ user_password tinyblob NOT NULL,
+ user_newpassword tinyblob NOT NULL,
+ user_newpass_time binary(14),
+ user_email tinytext NOT NULL,
+ user_touched binary(14) NOT NULL default '',
+ user_token binary(32) NOT NULL default '',
+ user_email_authenticated binary(14),
+ user_email_token binary(32),
+ user_email_token_expires binary(14),
+ user_registration binary(14),
+ user_editcount int
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_name ON /*_*/user (user_name);
+CREATE INDEX /*i*/user_email_token ON /*_*/user (user_email_token);
+CREATE INDEX /*i*/user_email ON /*_*/user (user_email(50));
+CREATE TABLE /*_*/user_groups (
+ ug_user int unsigned NOT NULL default 0,
+ ug_group varbinary(255) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user,ug_group);
+CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE TABLE /*_*/user_former_groups (
+ ufg_user int unsigned NOT NULL default 0,
+ ufg_group varbinary(255) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ufg_user_group ON /*_*/user_former_groups (ufg_user,ufg_group);
+CREATE TABLE /*_*/user_newtalk (
+ user_id int NOT NULL default 0,
+ user_ip varbinary(40) NOT NULL default '',
+ user_last_timestamp varbinary(14) NULL default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id);
+CREATE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip);
+CREATE TABLE /*_*/user_properties (
+ up_user int NOT NULL,
+ up_property varbinary(255) NOT NULL,
+ up_value blob
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_properties_user_property ON /*_*/user_properties (up_user,up_property);
+CREATE INDEX /*i*/user_properties_property ON /*_*/user_properties (up_property);
+CREATE TABLE /*_*/page (
+ page_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ page_namespace int NOT NULL,
+ page_title varchar(255) binary NOT NULL,
+ page_restrictions tinyblob NOT NULL,
+ page_counter bigint unsigned NOT NULL default 0,
+ page_is_redirect tinyint unsigned NOT NULL default 0,
+ page_is_new tinyint unsigned NOT NULL default 0,
+ page_random real unsigned NOT NULL,
+ page_touched binary(14) NOT NULL default '',
+ page_latest int unsigned NOT NULL,
+ page_len int unsigned NOT NULL,
+ page_content_model varbinary(32) DEFAULT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/name_title ON /*_*/page (page_namespace,page_title);
+CREATE INDEX /*i*/page_random ON /*_*/page (page_random);
+CREATE INDEX /*i*/page_len ON /*_*/page (page_len);
+CREATE INDEX /*i*/page_redirect_namespace_len ON /*_*/page (page_is_redirect, page_namespace, page_len);
+CREATE TABLE /*_*/revision (
+ rev_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rev_page int unsigned NOT NULL,
+ rev_text_id int unsigned NOT NULL,
+ rev_comment tinyblob NOT NULL,
+ rev_user int unsigned NOT NULL default 0,
+ rev_user_text varchar(255) binary NOT NULL default '',
+ rev_timestamp binary(14) NOT NULL default '',
+ rev_minor_edit tinyint unsigned NOT NULL default 0,
+ rev_deleted tinyint unsigned NOT NULL default 0,
+ rev_len int unsigned,
+ rev_parent_id int unsigned default NULL,
+ rev_sha1 varbinary(32) NOT NULL default '',
+ rev_content_model varbinary(32) DEFAULT NULL,
+ rev_content_format varbinary(64) DEFAULT NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=1024;
+CREATE UNIQUE INDEX /*i*/rev_page_id ON /*_*/revision (rev_page, rev_id);
+CREATE INDEX /*i*/rev_timestamp ON /*_*/revision (rev_timestamp);
+CREATE INDEX /*i*/page_timestamp ON /*_*/revision (rev_page,rev_timestamp);
+CREATE INDEX /*i*/user_timestamp ON /*_*/revision (rev_user,rev_timestamp);
+CREATE INDEX /*i*/usertext_timestamp ON /*_*/revision (rev_user_text,rev_timestamp);
+CREATE INDEX /*i*/page_user_timestamp ON /*_*/revision (rev_page,rev_user,rev_timestamp);
+CREATE TABLE /*_*/text (
+ old_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ old_text mediumblob NOT NULL,
+ old_flags tinyblob NOT NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=10240;
+CREATE TABLE /*_*/archive (
+ ar_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ ar_namespace int NOT NULL default 0,
+ ar_title varchar(255) binary NOT NULL default '',
+ ar_text mediumblob NOT NULL,
+ ar_comment tinyblob NOT NULL,
+ ar_user int unsigned NOT NULL default 0,
+ ar_user_text varchar(255) binary NOT NULL,
+ ar_timestamp binary(14) NOT NULL default '',
+ ar_minor_edit tinyint NOT NULL default 0,
+ ar_flags tinyblob NOT NULL,
+ ar_rev_id int unsigned,
+ ar_text_id int unsigned,
+ ar_deleted tinyint unsigned NOT NULL default 0,
+ ar_len int unsigned,
+ ar_page_id int unsigned,
+ ar_parent_id int unsigned default NULL,
+ ar_sha1 varbinary(32) NOT NULL default '',
+ ar_content_model varbinary(32) DEFAULT NULL,
+ ar_content_format varbinary(64) DEFAULT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/name_title_timestamp ON /*_*/archive (ar_namespace,ar_title,ar_timestamp);
+CREATE INDEX /*i*/ar_usertext_timestamp ON /*_*/archive (ar_user_text,ar_timestamp);
+CREATE INDEX /*i*/ar_revid ON /*_*/archive (ar_rev_id);
+CREATE TABLE /*_*/pagelinks (
+ pl_from int unsigned NOT NULL default 0,
+ pl_namespace int NOT NULL default 0,
+ pl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pl_from ON /*_*/pagelinks (pl_from,pl_namespace,pl_title);
+CREATE UNIQUE INDEX /*i*/pl_namespace ON /*_*/pagelinks (pl_namespace,pl_title,pl_from);
+CREATE TABLE /*_*/templatelinks (
+ tl_from int unsigned NOT NULL default 0,
+ tl_namespace int NOT NULL default 0,
+ tl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tl_from ON /*_*/templatelinks (tl_from,tl_namespace,tl_title);
+CREATE UNIQUE INDEX /*i*/tl_namespace ON /*_*/templatelinks (tl_namespace,tl_title,tl_from);
+CREATE TABLE /*_*/imagelinks (
+ il_from int unsigned NOT NULL default 0,
+ il_to varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/il_from ON /*_*/imagelinks (il_from,il_to);
+CREATE UNIQUE INDEX /*i*/il_to ON /*_*/imagelinks (il_to,il_from);
+CREATE TABLE /*_*/categorylinks (
+ cl_from int unsigned NOT NULL default 0,
+ cl_to varchar(255) binary NOT NULL default '',
+ cl_sortkey varbinary(230) NOT NULL default '',
+ cl_sortkey_prefix varchar(255) binary NOT NULL default '',
+ cl_timestamp timestamp NOT NULL,
+ cl_collation varbinary(32) NOT NULL default '',
+ cl_type ENUM('page', 'subcat', 'file') NOT NULL default 'page'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cl_from ON /*_*/categorylinks (cl_from,cl_to);
+CREATE INDEX /*i*/cl_sortkey ON /*_*/categorylinks (cl_to,cl_type,cl_sortkey,cl_from);
+CREATE INDEX /*i*/cl_timestamp ON /*_*/categorylinks (cl_to,cl_timestamp);
+CREATE INDEX /*i*/cl_collation ON /*_*/categorylinks (cl_collation);
+CREATE TABLE /*_*/category (
+ cat_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ cat_title varchar(255) binary NOT NULL,
+ cat_pages int signed NOT NULL default 0,
+ cat_subcats int signed NOT NULL default 0,
+ cat_files int signed NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cat_title ON /*_*/category (cat_title);
+CREATE INDEX /*i*/cat_pages ON /*_*/category (cat_pages);
+CREATE TABLE /*_*/externallinks (
+ el_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ el_from int unsigned NOT NULL default 0,
+ el_to blob NOT NULL,
+ el_index blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/el_from ON /*_*/externallinks (el_from, el_to(40));
+CREATE INDEX /*i*/el_to ON /*_*/externallinks (el_to(60), el_from);
+CREATE INDEX /*i*/el_index ON /*_*/externallinks (el_index(60));
+CREATE TABLE /*_*/langlinks (
+ ll_from int unsigned NOT NULL default 0,
+ ll_lang varbinary(20) NOT NULL default '',
+ ll_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ll_from ON /*_*/langlinks (ll_from, ll_lang);
+CREATE INDEX /*i*/ll_lang ON /*_*/langlinks (ll_lang, ll_title);
+CREATE TABLE /*_*/iwlinks (
+ iwl_from int unsigned NOT NULL default 0,
+ iwl_prefix varbinary(20) NOT NULL default '',
+ iwl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iwl_from ON /*_*/iwlinks (iwl_from, iwl_prefix, iwl_title);
+CREATE INDEX /*i*/iwl_prefix_title_from ON /*_*/iwlinks (iwl_prefix, iwl_title, iwl_from);
+CREATE INDEX /*i*/iwl_prefix_from_title ON /*_*/iwlinks (iwl_prefix, iwl_from, iwl_title);
+CREATE TABLE /*_*/site_stats (
+ ss_row_id int unsigned NOT NULL,
+ ss_total_views bigint unsigned default 0,
+ ss_total_edits bigint unsigned default 0,
+ ss_good_articles bigint unsigned default 0,
+ ss_total_pages bigint default '-1',
+ ss_users bigint default '-1',
+ ss_active_users bigint default '-1',
+ ss_images int default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ss_row_id ON /*_*/site_stats (ss_row_id);
+CREATE TABLE /*_*/hitcounter (
+ hc_id int unsigned NOT NULL
+) ENGINE=HEAP MAX_ROWS=25000;
+CREATE TABLE /*_*/ipblocks (
+ ipb_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ ipb_address tinyblob NOT NULL,
+ ipb_user int unsigned NOT NULL default 0,
+ ipb_by int unsigned NOT NULL default 0,
+ ipb_by_text varchar(255) binary NOT NULL default '',
+ ipb_reason tinyblob NOT NULL,
+ ipb_timestamp binary(14) NOT NULL default '',
+ ipb_auto bool NOT NULL default 0,
+ ipb_anon_only bool NOT NULL default 0,
+ ipb_create_account bool NOT NULL default 1,
+ ipb_enable_autoblock bool NOT NULL default '1',
+ ipb_expiry varbinary(14) NOT NULL default '',
+ ipb_range_start tinyblob NOT NULL,
+ ipb_range_end tinyblob NOT NULL,
+ ipb_deleted bool NOT NULL default 0,
+ ipb_block_email bool NOT NULL default 0,
+ ipb_allow_usertalk bool NOT NULL default 0,
+ ipb_parent_block_id int default NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ipb_address ON /*_*/ipblocks (ipb_address(255), ipb_user, ipb_auto, ipb_anon_only);
+CREATE INDEX /*i*/ipb_user ON /*_*/ipblocks (ipb_user);
+CREATE INDEX /*i*/ipb_range ON /*_*/ipblocks (ipb_range_start(8), ipb_range_end(8));
+CREATE INDEX /*i*/ipb_timestamp ON /*_*/ipblocks (ipb_timestamp);
+CREATE INDEX /*i*/ipb_expiry ON /*_*/ipblocks (ipb_expiry);
+CREATE INDEX /*i*/ipb_parent_block_id ON /*_*/ipblocks (ipb_parent_block_id);
+CREATE TABLE /*_*/image (
+ img_name varchar(255) binary NOT NULL default '' PRIMARY KEY,
+ img_size int unsigned NOT NULL default 0,
+ img_width int NOT NULL default 0,
+ img_height int NOT NULL default 0,
+ img_metadata mediumblob NOT NULL,
+ img_bits int NOT NULL default 0,
+ img_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ img_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ img_minor_mime varbinary(100) NOT NULL default "unknown",
+ img_description tinyblob NOT NULL,
+ img_user int unsigned NOT NULL default 0,
+ img_user_text varchar(255) binary NOT NULL,
+ img_timestamp varbinary(14) NOT NULL default '',
+ img_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/img_usertext_timestamp ON /*_*/image (img_user_text,img_timestamp);
+CREATE INDEX /*i*/img_size ON /*_*/image (img_size);
+CREATE INDEX /*i*/img_timestamp ON /*_*/image (img_timestamp);
+CREATE INDEX /*i*/img_sha1 ON /*_*/image (img_sha1(10));
+CREATE INDEX /*i*/img_media_mime ON /*_*/image (img_media_type,img_major_mime,img_minor_mime);
+CREATE TABLE /*_*/oldimage (
+ oi_name varchar(255) binary NOT NULL default '',
+ oi_archive_name varchar(255) binary NOT NULL default '',
+ oi_size int unsigned NOT NULL default 0,
+ oi_width int NOT NULL default 0,
+ oi_height int NOT NULL default 0,
+ oi_bits int NOT NULL default 0,
+ oi_description tinyblob NOT NULL,
+ oi_user int unsigned NOT NULL default 0,
+ oi_user_text varchar(255) binary NOT NULL,
+ oi_timestamp binary(14) NOT NULL default '',
+ oi_metadata mediumblob NOT NULL,
+ oi_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ oi_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ oi_minor_mime varbinary(100) NOT NULL default "unknown",
+ oi_deleted tinyint unsigned NOT NULL default 0,
+ oi_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/oi_usertext_timestamp ON /*_*/oldimage (oi_user_text,oi_timestamp);
+CREATE INDEX /*i*/oi_name_timestamp ON /*_*/oldimage (oi_name,oi_timestamp);
+CREATE INDEX /*i*/oi_name_archive_name ON /*_*/oldimage (oi_name,oi_archive_name(14));
+CREATE INDEX /*i*/oi_sha1 ON /*_*/oldimage (oi_sha1(10));
+CREATE TABLE /*_*/filearchive (
+ fa_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ fa_name varchar(255) binary NOT NULL default '',
+ fa_archive_name varchar(255) binary default '',
+ fa_storage_group varbinary(16),
+ fa_storage_key varbinary(64) default '',
+ fa_deleted_user int,
+ fa_deleted_timestamp binary(14) default '',
+ fa_deleted_reason text,
+ fa_size int unsigned default 0,
+ fa_width int default 0,
+ fa_height int default 0,
+ fa_metadata mediumblob,
+ fa_bits int default 0,
+ fa_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ fa_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") default "unknown",
+ fa_minor_mime varbinary(100) default "unknown",
+ fa_description tinyblob,
+ fa_user int unsigned default 0,
+ fa_user_text varchar(255) binary,
+ fa_timestamp binary(14) default '',
+ fa_deleted tinyint unsigned NOT NULL default 0,
+ fa_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/fa_name ON /*_*/filearchive (fa_name, fa_timestamp);
+CREATE INDEX /*i*/fa_storage_group ON /*_*/filearchive (fa_storage_group, fa_storage_key);
+CREATE INDEX /*i*/fa_deleted_timestamp ON /*_*/filearchive (fa_deleted_timestamp);
+CREATE INDEX /*i*/fa_user_timestamp ON /*_*/filearchive (fa_user_text,fa_timestamp);
+CREATE INDEX /*i*/fa_sha1 ON /*_*/filearchive (fa_sha1(10));
+CREATE TABLE /*_*/uploadstash (
+ us_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ us_user int unsigned NOT NULL,
+ us_key varchar(255) NOT NULL,
+ us_orig_path varchar(255) NOT NULL,
+ us_path varchar(255) NOT NULL,
+ us_source_type varchar(50),
+ us_timestamp varbinary(14) NOT NULL,
+ us_status varchar(50) NOT NULL,
+ us_chunk_inx int unsigned NULL,
+ us_props blob,
+ us_size int unsigned NOT NULL,
+ us_sha1 varchar(31) NOT NULL,
+ us_mime varchar(255),
+ us_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ us_image_width int unsigned,
+ us_image_height int unsigned,
+ us_image_bits smallint unsigned
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/us_user ON /*_*/uploadstash (us_user);
+CREATE UNIQUE INDEX /*i*/us_key ON /*_*/uploadstash (us_key);
+CREATE INDEX /*i*/us_timestamp ON /*_*/uploadstash (us_timestamp);
+CREATE TABLE /*_*/recentchanges (
+ rc_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rc_timestamp varbinary(14) NOT NULL default '',
+ rc_cur_time varbinary(14) NOT NULL default '',
+ rc_user int unsigned NOT NULL default 0,
+ rc_user_text varchar(255) binary NOT NULL,
+ rc_namespace int NOT NULL default 0,
+ rc_title varchar(255) binary NOT NULL default '',
+ rc_comment varchar(255) binary NOT NULL default '',
+ rc_minor tinyint unsigned NOT NULL default 0,
+ rc_bot tinyint unsigned NOT NULL default 0,
+ rc_new tinyint unsigned NOT NULL default 0,
+ rc_cur_id int unsigned NOT NULL default 0,
+ rc_this_oldid int unsigned NOT NULL default 0,
+ rc_last_oldid int unsigned NOT NULL default 0,
+ rc_type tinyint unsigned NOT NULL default 0,
+ rc_patrolled tinyint unsigned NOT NULL default 0,
+ rc_ip varbinary(40) NOT NULL default '',
+ rc_old_len int,
+ rc_new_len int,
+ rc_deleted tinyint unsigned NOT NULL default 0,
+ rc_logid int unsigned NOT NULL default 0,
+ rc_log_type varbinary(255) NULL default NULL,
+ rc_log_action varbinary(255) NULL default NULL,
+ rc_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rc_timestamp ON /*_*/recentchanges (rc_timestamp);
+CREATE INDEX /*i*/rc_namespace_title ON /*_*/recentchanges (rc_namespace, rc_title);
+CREATE INDEX /*i*/rc_cur_id ON /*_*/recentchanges (rc_cur_id);
+CREATE INDEX /*i*/new_name_timestamp ON /*_*/recentchanges (rc_new,rc_namespace,rc_timestamp);
+CREATE INDEX /*i*/rc_ip ON /*_*/recentchanges (rc_ip);
+CREATE INDEX /*i*/rc_ns_usertext ON /*_*/recentchanges (rc_namespace, rc_user_text);
+CREATE INDEX /*i*/rc_user_text ON /*_*/recentchanges (rc_user_text, rc_timestamp);
+CREATE TABLE /*_*/watchlist (
+ wl_user int unsigned NOT NULL,
+ wl_namespace int NOT NULL default 0,
+ wl_title varchar(255) binary NOT NULL default '',
+ wl_notificationtimestamp varbinary(14)
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/wl_user ON /*_*/watchlist (wl_user, wl_namespace, wl_title);
+CREATE INDEX /*i*/namespace_title ON /*_*/watchlist (wl_namespace, wl_title);
+CREATE TABLE /*_*/searchindex (
+ si_page int unsigned NOT NULL,
+ si_title varchar(255) NOT NULL default '',
+ si_text mediumtext NOT NULL
+) ENGINE=MyISAM DEFAULT CHARSET=utf8;
+CREATE UNIQUE INDEX /*i*/si_page ON /*_*/searchindex (si_page);
+CREATE FULLTEXT INDEX /*i*/si_title ON /*_*/searchindex (si_title);
+CREATE FULLTEXT INDEX /*i*/si_text ON /*_*/searchindex (si_text);
+CREATE TABLE /*_*/interwiki (
+ iw_prefix varchar(32) NOT NULL,
+ iw_url blob NOT NULL,
+ iw_api blob NOT NULL,
+ iw_wikiid varchar(64) NOT NULL,
+ iw_local bool NOT NULL,
+ iw_trans tinyint NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iw_prefix ON /*_*/interwiki (iw_prefix);
+CREATE TABLE /*_*/querycache (
+ qc_type varbinary(32) NOT NULL,
+ qc_value int unsigned NOT NULL default 0,
+ qc_namespace int NOT NULL default 0,
+ qc_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qc_type ON /*_*/querycache (qc_type,qc_value);
+CREATE TABLE /*_*/objectcache (
+ keyname varbinary(255) NOT NULL default '' PRIMARY KEY,
+ value mediumblob,
+ exptime datetime
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/exptime ON /*_*/objectcache (exptime);
+CREATE TABLE /*_*/transcache (
+ tc_url varbinary(255) NOT NULL,
+ tc_contents text,
+ tc_time binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tc_url_idx ON /*_*/transcache (tc_url);
+CREATE TABLE /*_*/logging (
+ log_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ log_type varbinary(32) NOT NULL default '',
+ log_action varbinary(32) NOT NULL default '',
+ log_timestamp binary(14) NOT NULL default '19700101000000',
+ log_user int unsigned NOT NULL default 0,
+ log_user_text varchar(255) binary NOT NULL default '',
+ log_namespace int NOT NULL default 0,
+ log_title varchar(255) binary NOT NULL default '',
+ log_page int unsigned NULL,
+ log_comment varchar(255) NOT NULL default '',
+ log_params blob NOT NULL,
+ log_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/type_time ON /*_*/logging (log_type, log_timestamp);
+CREATE INDEX /*i*/user_time ON /*_*/logging (log_user, log_timestamp);
+CREATE INDEX /*i*/page_time ON /*_*/logging (log_namespace, log_title, log_timestamp);
+CREATE INDEX /*i*/times ON /*_*/logging (log_timestamp);
+CREATE INDEX /*i*/log_user_type_time ON /*_*/logging (log_user, log_type, log_timestamp);
+CREATE INDEX /*i*/log_page_id_time ON /*_*/logging (log_page,log_timestamp);
+CREATE INDEX /*i*/type_action ON /*_*/logging (log_type, log_action, log_timestamp);
+CREATE TABLE /*_*/log_search (
+ ls_field varbinary(32) NOT NULL,
+ ls_value varchar(255) NOT NULL,
+ ls_log_id int unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ls_field_val ON /*_*/log_search (ls_field,ls_value,ls_log_id);
+CREATE INDEX /*i*/ls_log_id ON /*_*/log_search (ls_log_id);
+CREATE TABLE /*_*/job (
+ job_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ job_cmd varbinary(60) NOT NULL default '',
+ job_namespace int NOT NULL,
+ job_title varchar(255) binary NOT NULL,
+ job_timestamp varbinary(14) NULL default NULL,
+ job_params blob NOT NULL,
+ job_random integer unsigned NOT NULL default 0,
+ job_attempts integer unsigned NOT NULL default 0,
+ job_token varbinary(32) NOT NULL default '',
+ job_token_timestamp varbinary(14) NULL default NULL,
+ job_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/job_sha1 ON /*_*/job (job_sha1);
+CREATE INDEX /*i*/job_cmd_token ON /*_*/job (job_cmd,job_token,job_random);
+CREATE INDEX /*i*/job_cmd_token_id ON /*_*/job (job_cmd,job_token,job_id);
+CREATE INDEX /*i*/job_cmd ON /*_*/job (job_cmd, job_namespace, job_title, job_params(128));
+CREATE INDEX /*i*/job_timestamp ON /*_*/job (job_timestamp);
+CREATE TABLE /*_*/querycache_info (
+ qci_type varbinary(32) NOT NULL default '',
+ qci_timestamp binary(14) NOT NULL default '19700101000000'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/qci_type ON /*_*/querycache_info (qci_type);
+CREATE TABLE /*_*/redirect (
+ rd_from int unsigned NOT NULL default 0 PRIMARY KEY,
+ rd_namespace int NOT NULL default 0,
+ rd_title varchar(255) binary NOT NULL default '',
+ rd_interwiki varchar(32) default NULL,
+ rd_fragment varchar(255) binary default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rd_ns_title ON /*_*/redirect (rd_namespace,rd_title,rd_from);
+CREATE TABLE /*_*/querycachetwo (
+ qcc_type varbinary(32) NOT NULL,
+ qcc_value int unsigned NOT NULL default 0,
+ qcc_namespace int NOT NULL default 0,
+ qcc_title varchar(255) binary NOT NULL default '',
+ qcc_namespacetwo int NOT NULL default 0,
+ qcc_titletwo varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qcc_type ON /*_*/querycachetwo (qcc_type,qcc_value);
+CREATE INDEX /*i*/qcc_title ON /*_*/querycachetwo (qcc_type,qcc_namespace,qcc_title);
+CREATE INDEX /*i*/qcc_titletwo ON /*_*/querycachetwo (qcc_type,qcc_namespacetwo,qcc_titletwo);
+CREATE TABLE /*_*/page_restrictions (
+ pr_page int NOT NULL,
+ pr_type varbinary(60) NOT NULL,
+ pr_level varbinary(60) NOT NULL,
+ pr_cascade tinyint NOT NULL,
+ pr_user int NULL,
+ pr_expiry varbinary(14) NULL,
+ pr_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pr_pagetype ON /*_*/page_restrictions (pr_page,pr_type);
+CREATE INDEX /*i*/pr_typelevel ON /*_*/page_restrictions (pr_type,pr_level);
+CREATE INDEX /*i*/pr_level ON /*_*/page_restrictions (pr_level);
+CREATE INDEX /*i*/pr_cascade ON /*_*/page_restrictions (pr_cascade);
+CREATE TABLE /*_*/protected_titles (
+ pt_namespace int NOT NULL,
+ pt_title varchar(255) binary NOT NULL,
+ pt_user int unsigned NOT NULL,
+ pt_reason tinyblob,
+ pt_timestamp binary(14) NOT NULL,
+ pt_expiry varbinary(14) NOT NULL default '',
+ pt_create_perm varbinary(60) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pt_namespace_title ON /*_*/protected_titles (pt_namespace,pt_title);
+CREATE INDEX /*i*/pt_timestamp ON /*_*/protected_titles (pt_timestamp);
+CREATE TABLE /*_*/page_props (
+ pp_page int NOT NULL,
+ pp_propname varbinary(60) NOT NULL,
+ pp_value blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pp_page_propname ON /*_*/page_props (pp_page,pp_propname);
+CREATE UNIQUE INDEX /*i*/pp_propname_page ON /*_*/page_props (pp_propname,pp_page);
+CREATE TABLE /*_*/updatelog (
+ ul_key varchar(255) NOT NULL PRIMARY KEY,
+ ul_value blob
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/change_tag (
+ ct_rc_id int NULL,
+ ct_log_id int NULL,
+ ct_rev_id int NULL,
+ ct_tag varchar(255) NOT NULL,
+ ct_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/change_tag_rc_tag ON /*_*/change_tag (ct_rc_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_log_tag ON /*_*/change_tag (ct_log_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_rev_tag ON /*_*/change_tag (ct_rev_id,ct_tag);
+CREATE INDEX /*i*/change_tag_tag_id ON /*_*/change_tag (ct_tag,ct_rc_id,ct_rev_id,ct_log_id);
+CREATE TABLE /*_*/tag_summary (
+ ts_rc_id int NULL,
+ ts_log_id int NULL,
+ ts_rev_id int NULL,
+ ts_tags blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tag_summary_rc_id ON /*_*/tag_summary (ts_rc_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_log_id ON /*_*/tag_summary (ts_log_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_rev_id ON /*_*/tag_summary (ts_rev_id);
+CREATE TABLE /*_*/valid_tag (
+ vt_tag varchar(255) NOT NULL PRIMARY KEY
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/l10n_cache (
+ lc_lang varbinary(32) NOT NULL,
+ lc_key varchar(255) NOT NULL,
+ lc_value mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/lc_lang_key ON /*_*/l10n_cache (lc_lang, lc_key);
+CREATE TABLE /*_*/msg_resource (
+ mr_resource varbinary(255) NOT NULL,
+ mr_lang varbinary(32) NOT NULL,
+ mr_blob mediumblob NOT NULL,
+ mr_timestamp binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mr_resource_lang ON /*_*/msg_resource (mr_resource, mr_lang);
+CREATE TABLE /*_*/msg_resource_links (
+ mrl_resource varbinary(255) NOT NULL,
+ mrl_message varbinary(255) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mrl_message_resource ON /*_*/msg_resource_links (mrl_message, mrl_resource);
+CREATE TABLE /*_*/module_deps (
+ md_module varbinary(255) NOT NULL,
+ md_skin varbinary(32) NOT NULL,
+ md_deps mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/md_module_skin ON /*_*/module_deps (md_module, md_skin);
+CREATE TABLE /*_*/sites (
+ site_id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ site_global_key varbinary(32) NOT NULL,
+ site_type varbinary(32) NOT NULL,
+ site_group varbinary(32) NOT NULL,
+ site_source varbinary(32) NOT NULL,
+ site_language varbinary(32) NOT NULL,
+ site_protocol varbinary(32) NOT NULL,
+ site_domain VARCHAR(255) NOT NULL,
+ site_data BLOB NOT NULL,
+ site_forward bool NOT NULL,
+ site_config BLOB NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/sites_global_key ON /*_*/sites (site_global_key);
+CREATE INDEX /*i*/sites_type ON /*_*/sites (site_type);
+CREATE INDEX /*i*/sites_group ON /*_*/sites (site_group);
+CREATE INDEX /*i*/sites_source ON /*_*/sites (site_source);
+CREATE INDEX /*i*/sites_language ON /*_*/sites (site_language);
+CREATE INDEX /*i*/sites_protocol ON /*_*/sites (site_protocol);
+CREATE INDEX /*i*/sites_domain ON /*_*/sites (site_domain);
+CREATE INDEX /*i*/sites_forward ON /*_*/sites (site_forward);
+CREATE TABLE /*_*/site_identifiers (
+ si_site INT UNSIGNED NOT NULL,
+ si_type varbinary(32) NOT NULL,
+ si_key varbinary(32) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/site_ids_type ON /*_*/site_identifiers (si_type, si_key);
+CREATE INDEX /*i*/site_ids_site ON /*_*/site_identifiers (si_site);
+CREATE INDEX /*i*/site_ids_key ON /*_*/site_identifiers (si_key);
diff --git a/www/wiki/tests/phpunit/data/db/sqlite/tables-1.23.sql b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.23.sql
new file mode 100644
index 00000000..1c3a8ae4
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/db/sqlite/tables-1.23.sql
@@ -0,0 +1,580 @@
+-- This is a copy of MediaWiki 1.23 schema shared by MySQL and SQLite.
+-- It is used for updater testing. Comments are stripped to decrease
+-- file size, as we don't need to maintain it.
+
+CREATE TABLE /*_*/user (
+ user_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ user_name varchar(255) binary NOT NULL default '',
+ user_real_name varchar(255) binary NOT NULL default '',
+ user_password tinyblob NOT NULL,
+ user_newpassword tinyblob NOT NULL,
+ user_newpass_time binary(14),
+ user_email tinytext NOT NULL,
+ user_touched binary(14) NOT NULL default '',
+ user_token binary(32) NOT NULL default '',
+ user_email_authenticated binary(14),
+ user_email_token binary(32),
+ user_email_token_expires binary(14),
+ user_registration binary(14),
+ user_editcount int,
+ user_password_expires varbinary(14) DEFAULT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_name ON /*_*/user (user_name);
+CREATE INDEX /*i*/user_email_token ON /*_*/user (user_email_token);
+CREATE INDEX /*i*/user_email ON /*_*/user (user_email(50));
+CREATE TABLE /*_*/user_groups (
+ ug_user int unsigned NOT NULL default 0,
+ ug_group varbinary(255) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user,ug_group);
+CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE TABLE /*_*/user_former_groups (
+ ufg_user int unsigned NOT NULL default 0,
+ ufg_group varbinary(255) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ufg_user_group ON /*_*/user_former_groups (ufg_user,ufg_group);
+CREATE TABLE /*_*/user_newtalk (
+ user_id int NOT NULL default 0,
+ user_ip varbinary(40) NOT NULL default '',
+ user_last_timestamp varbinary(14) NULL default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id);
+CREATE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip);
+CREATE TABLE /*_*/user_properties (
+ up_user int NOT NULL,
+ up_property varbinary(255) NOT NULL,
+ up_value blob
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/user_properties_user_property ON /*_*/user_properties (up_user,up_property);
+CREATE INDEX /*i*/user_properties_property ON /*_*/user_properties (up_property);
+CREATE TABLE /*_*/page (
+ page_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ page_namespace int NOT NULL,
+ page_title varchar(255) binary NOT NULL,
+ page_restrictions tinyblob NOT NULL,
+ page_counter bigint unsigned NOT NULL default 0,
+ page_is_redirect tinyint unsigned NOT NULL default 0,
+ page_is_new tinyint unsigned NOT NULL default 0,
+ page_random real unsigned NOT NULL,
+ page_touched binary(14) NOT NULL default '',
+ page_links_updated varbinary(14) NULL default NULL,
+ page_latest int unsigned NOT NULL,
+ page_len int unsigned NOT NULL,
+ page_content_model varbinary(32) DEFAULT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/name_title ON /*_*/page (page_namespace,page_title);
+CREATE INDEX /*i*/page_random ON /*_*/page (page_random);
+CREATE INDEX /*i*/page_len ON /*_*/page (page_len);
+CREATE INDEX /*i*/page_redirect_namespace_len ON /*_*/page (page_is_redirect, page_namespace, page_len);
+CREATE TABLE /*_*/revision (
+ rev_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rev_page int unsigned NOT NULL,
+ rev_text_id int unsigned NOT NULL,
+ rev_comment tinyblob NOT NULL,
+ rev_user int unsigned NOT NULL default 0,
+ rev_user_text varchar(255) binary NOT NULL default '',
+ rev_timestamp binary(14) NOT NULL default '',
+ rev_minor_edit tinyint unsigned NOT NULL default 0,
+ rev_deleted tinyint unsigned NOT NULL default 0,
+ rev_len int unsigned,
+ rev_parent_id int unsigned default NULL,
+ rev_sha1 varbinary(32) NOT NULL default '',
+ rev_content_model varbinary(32) DEFAULT NULL,
+ rev_content_format varbinary(64) DEFAULT NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=1024;
+CREATE UNIQUE INDEX /*i*/rev_page_id ON /*_*/revision (rev_page, rev_id);
+CREATE INDEX /*i*/rev_timestamp ON /*_*/revision (rev_timestamp);
+CREATE INDEX /*i*/page_timestamp ON /*_*/revision (rev_page,rev_timestamp);
+CREATE INDEX /*i*/user_timestamp ON /*_*/revision (rev_user,rev_timestamp);
+CREATE INDEX /*i*/usertext_timestamp ON /*_*/revision (rev_user_text,rev_timestamp);
+CREATE INDEX /*i*/page_user_timestamp ON /*_*/revision (rev_page,rev_user,rev_timestamp);
+CREATE TABLE /*_*/text (
+ old_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ old_text mediumblob NOT NULL,
+ old_flags tinyblob NOT NULL
+) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=10240;
+CREATE TABLE /*_*/archive (
+ ar_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ ar_namespace int NOT NULL default 0,
+ ar_title varchar(255) binary NOT NULL default '',
+ ar_text mediumblob NOT NULL,
+ ar_comment tinyblob NOT NULL,
+ ar_user int unsigned NOT NULL default 0,
+ ar_user_text varchar(255) binary NOT NULL,
+ ar_timestamp binary(14) NOT NULL default '',
+ ar_minor_edit tinyint NOT NULL default 0,
+ ar_flags tinyblob NOT NULL,
+ ar_rev_id int unsigned,
+ ar_text_id int unsigned,
+ ar_deleted tinyint unsigned NOT NULL default 0,
+ ar_len int unsigned,
+ ar_page_id int unsigned,
+ ar_parent_id int unsigned default NULL,
+ ar_sha1 varbinary(32) NOT NULL default '',
+ ar_content_model varbinary(32) DEFAULT NULL,
+ ar_content_format varbinary(64) DEFAULT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/name_title_timestamp ON /*_*/archive (ar_namespace,ar_title,ar_timestamp);
+CREATE INDEX /*i*/ar_usertext_timestamp ON /*_*/archive (ar_user_text,ar_timestamp);
+CREATE INDEX /*i*/ar_revid ON /*_*/archive (ar_rev_id);
+CREATE TABLE /*_*/pagelinks (
+ pl_from int unsigned NOT NULL default 0,
+ pl_namespace int NOT NULL default 0,
+ pl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pl_from ON /*_*/pagelinks (pl_from,pl_namespace,pl_title);
+CREATE UNIQUE INDEX /*i*/pl_namespace ON /*_*/pagelinks (pl_namespace,pl_title,pl_from);
+CREATE TABLE /*_*/templatelinks (
+ tl_from int unsigned NOT NULL default 0,
+ tl_namespace int NOT NULL default 0,
+ tl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tl_from ON /*_*/templatelinks (tl_from,tl_namespace,tl_title);
+CREATE UNIQUE INDEX /*i*/tl_namespace ON /*_*/templatelinks (tl_namespace,tl_title,tl_from);
+CREATE TABLE /*_*/imagelinks (
+ il_from int unsigned NOT NULL default 0,
+ il_to varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/il_from ON /*_*/imagelinks (il_from,il_to);
+CREATE UNIQUE INDEX /*i*/il_to ON /*_*/imagelinks (il_to,il_from);
+CREATE TABLE /*_*/categorylinks (
+ cl_from int unsigned NOT NULL default 0,
+ cl_to varchar(255) binary NOT NULL default '',
+ cl_sortkey varbinary(230) NOT NULL default '',
+ cl_sortkey_prefix varchar(255) binary NOT NULL default '',
+ cl_timestamp timestamp NOT NULL,
+ cl_collation varbinary(32) NOT NULL default '',
+ cl_type ENUM('page', 'subcat', 'file') NOT NULL default 'page'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cl_from ON /*_*/categorylinks (cl_from,cl_to);
+CREATE INDEX /*i*/cl_sortkey ON /*_*/categorylinks (cl_to,cl_type,cl_sortkey,cl_from);
+CREATE INDEX /*i*/cl_timestamp ON /*_*/categorylinks (cl_to,cl_timestamp);
+CREATE INDEX /*i*/cl_collation ON /*_*/categorylinks (cl_collation);
+CREATE TABLE /*_*/category (
+ cat_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ cat_title varchar(255) binary NOT NULL,
+ cat_pages int signed NOT NULL default 0,
+ cat_subcats int signed NOT NULL default 0,
+ cat_files int signed NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/cat_title ON /*_*/category (cat_title);
+CREATE INDEX /*i*/cat_pages ON /*_*/category (cat_pages);
+CREATE TABLE /*_*/externallinks (
+ el_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ el_from int unsigned NOT NULL default 0,
+ el_to blob NOT NULL,
+ el_index blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/el_from ON /*_*/externallinks (el_from, el_to(40));
+CREATE INDEX /*i*/el_to ON /*_*/externallinks (el_to(60), el_from);
+CREATE INDEX /*i*/el_index ON /*_*/externallinks (el_index(60));
+CREATE TABLE /*_*/langlinks (
+ ll_from int unsigned NOT NULL default 0,
+ ll_lang varbinary(20) NOT NULL default '',
+ ll_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ll_from ON /*_*/langlinks (ll_from, ll_lang);
+CREATE INDEX /*i*/ll_lang ON /*_*/langlinks (ll_lang, ll_title);
+CREATE TABLE /*_*/iwlinks (
+ iwl_from int unsigned NOT NULL default 0,
+ iwl_prefix varbinary(20) NOT NULL default '',
+ iwl_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iwl_from ON /*_*/iwlinks (iwl_from, iwl_prefix, iwl_title);
+CREATE INDEX /*i*/iwl_prefix_title_from ON /*_*/iwlinks (iwl_prefix, iwl_title, iwl_from);
+CREATE INDEX /*i*/iwl_prefix_from_title ON /*_*/iwlinks (iwl_prefix, iwl_from, iwl_title);
+CREATE TABLE /*_*/site_stats (
+ ss_row_id int unsigned NOT NULL,
+ ss_total_views bigint unsigned default 0,
+ ss_total_edits bigint unsigned default 0,
+ ss_good_articles bigint unsigned default 0,
+ ss_total_pages bigint default '-1',
+ ss_users bigint default '-1',
+ ss_active_users bigint default '-1',
+ ss_images int default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ss_row_id ON /*_*/site_stats (ss_row_id);
+CREATE TABLE /*_*/hitcounter (
+ hc_id int unsigned NOT NULL
+) ENGINE=HEAP MAX_ROWS=25000;
+CREATE TABLE /*_*/ipblocks (
+ ipb_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ ipb_address tinyblob NOT NULL,
+ ipb_user int unsigned NOT NULL default 0,
+ ipb_by int unsigned NOT NULL default 0,
+ ipb_by_text varchar(255) binary NOT NULL default '',
+ ipb_reason tinyblob NOT NULL,
+ ipb_timestamp binary(14) NOT NULL default '',
+ ipb_auto bool NOT NULL default 0,
+ ipb_anon_only bool NOT NULL default 0,
+ ipb_create_account bool NOT NULL default 1,
+ ipb_enable_autoblock bool NOT NULL default '1',
+ ipb_expiry varbinary(14) NOT NULL default '',
+ ipb_range_start tinyblob NOT NULL,
+ ipb_range_end tinyblob NOT NULL,
+ ipb_deleted bool NOT NULL default 0,
+ ipb_block_email bool NOT NULL default 0,
+ ipb_allow_usertalk bool NOT NULL default 0,
+ ipb_parent_block_id int default NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ipb_address ON /*_*/ipblocks (ipb_address(255), ipb_user, ipb_auto, ipb_anon_only);
+CREATE INDEX /*i*/ipb_user ON /*_*/ipblocks (ipb_user);
+CREATE INDEX /*i*/ipb_range ON /*_*/ipblocks (ipb_range_start(8), ipb_range_end(8));
+CREATE INDEX /*i*/ipb_timestamp ON /*_*/ipblocks (ipb_timestamp);
+CREATE INDEX /*i*/ipb_expiry ON /*_*/ipblocks (ipb_expiry);
+CREATE INDEX /*i*/ipb_parent_block_id ON /*_*/ipblocks (ipb_parent_block_id);
+CREATE TABLE /*_*/image (
+ img_name varchar(255) binary NOT NULL default '' PRIMARY KEY,
+ img_size int unsigned NOT NULL default 0,
+ img_width int NOT NULL default 0,
+ img_height int NOT NULL default 0,
+ img_metadata mediumblob NOT NULL,
+ img_bits int NOT NULL default 0,
+ img_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ img_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ img_minor_mime varbinary(100) NOT NULL default "unknown",
+ img_description tinyblob NOT NULL,
+ img_user int unsigned NOT NULL default 0,
+ img_user_text varchar(255) binary NOT NULL,
+ img_timestamp varbinary(14) NOT NULL default '',
+ img_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/img_usertext_timestamp ON /*_*/image (img_user_text,img_timestamp);
+CREATE INDEX /*i*/img_size ON /*_*/image (img_size);
+CREATE INDEX /*i*/img_timestamp ON /*_*/image (img_timestamp);
+CREATE INDEX /*i*/img_sha1 ON /*_*/image (img_sha1(10));
+CREATE INDEX /*i*/img_media_mime ON /*_*/image (img_media_type,img_major_mime,img_minor_mime);
+CREATE TABLE /*_*/oldimage (
+ oi_name varchar(255) binary NOT NULL default '',
+ oi_archive_name varchar(255) binary NOT NULL default '',
+ oi_size int unsigned NOT NULL default 0,
+ oi_width int NOT NULL default 0,
+ oi_height int NOT NULL default 0,
+ oi_bits int NOT NULL default 0,
+ oi_description tinyblob NOT NULL,
+ oi_user int unsigned NOT NULL default 0,
+ oi_user_text varchar(255) binary NOT NULL,
+ oi_timestamp binary(14) NOT NULL default '',
+ oi_metadata mediumblob NOT NULL,
+ oi_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ oi_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") NOT NULL default "unknown",
+ oi_minor_mime varbinary(100) NOT NULL default "unknown",
+ oi_deleted tinyint unsigned NOT NULL default 0,
+ oi_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/oi_usertext_timestamp ON /*_*/oldimage (oi_user_text,oi_timestamp);
+CREATE INDEX /*i*/oi_name_timestamp ON /*_*/oldimage (oi_name,oi_timestamp);
+CREATE INDEX /*i*/oi_name_archive_name ON /*_*/oldimage (oi_name,oi_archive_name(14));
+CREATE INDEX /*i*/oi_sha1 ON /*_*/oldimage (oi_sha1(10));
+CREATE TABLE /*_*/filearchive (
+ fa_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ fa_name varchar(255) binary NOT NULL default '',
+ fa_archive_name varchar(255) binary default '',
+ fa_storage_group varbinary(16),
+ fa_storage_key varbinary(64) default '',
+ fa_deleted_user int,
+ fa_deleted_timestamp binary(14) default '',
+ fa_deleted_reason text,
+ fa_size int unsigned default 0,
+ fa_width int default 0,
+ fa_height int default 0,
+ fa_metadata mediumblob,
+ fa_bits int default 0,
+ fa_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ fa_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") default "unknown",
+ fa_minor_mime varbinary(100) default "unknown",
+ fa_description tinyblob,
+ fa_user int unsigned default 0,
+ fa_user_text varchar(255) binary,
+ fa_timestamp binary(14) default '',
+ fa_deleted tinyint unsigned NOT NULL default 0,
+ fa_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/fa_name ON /*_*/filearchive (fa_name, fa_timestamp);
+CREATE INDEX /*i*/fa_storage_group ON /*_*/filearchive (fa_storage_group, fa_storage_key);
+CREATE INDEX /*i*/fa_deleted_timestamp ON /*_*/filearchive (fa_deleted_timestamp);
+CREATE INDEX /*i*/fa_user_timestamp ON /*_*/filearchive (fa_user_text,fa_timestamp);
+CREATE INDEX /*i*/fa_sha1 ON /*_*/filearchive (fa_sha1(10));
+CREATE TABLE /*_*/uploadstash (
+ us_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ us_user int unsigned NOT NULL,
+ us_key varchar(255) NOT NULL,
+ us_orig_path varchar(255) NOT NULL,
+ us_path varchar(255) NOT NULL,
+ us_source_type varchar(50),
+ us_timestamp varbinary(14) NOT NULL,
+ us_status varchar(50) NOT NULL,
+ us_chunk_inx int unsigned NULL,
+ us_props blob,
+ us_size int unsigned NOT NULL,
+ us_sha1 varchar(31) NOT NULL,
+ us_mime varchar(255),
+ us_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL,
+ us_image_width int unsigned,
+ us_image_height int unsigned,
+ us_image_bits smallint unsigned
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/us_user ON /*_*/uploadstash (us_user);
+CREATE UNIQUE INDEX /*i*/us_key ON /*_*/uploadstash (us_key);
+CREATE INDEX /*i*/us_timestamp ON /*_*/uploadstash (us_timestamp);
+CREATE TABLE /*_*/recentchanges (
+ rc_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ rc_timestamp varbinary(14) NOT NULL default '',
+ rc_cur_time varbinary(14) NOT NULL default '',
+ rc_user int unsigned NOT NULL default 0,
+ rc_user_text varchar(255) binary NOT NULL,
+ rc_namespace int NOT NULL default 0,
+ rc_title varchar(255) binary NOT NULL default '',
+ rc_comment varchar(255) binary NOT NULL default '',
+ rc_minor tinyint unsigned NOT NULL default 0,
+ rc_bot tinyint unsigned NOT NULL default 0,
+ rc_new tinyint unsigned NOT NULL default 0,
+ rc_cur_id int unsigned NOT NULL default 0,
+ rc_this_oldid int unsigned NOT NULL default 0,
+ rc_last_oldid int unsigned NOT NULL default 0,
+ rc_type tinyint unsigned NOT NULL default 0,
+ rc_source varchar(16) binary not null default '',
+ rc_patrolled tinyint unsigned NOT NULL default 0,
+ rc_ip varbinary(40) NOT NULL default '',
+ rc_old_len int,
+ rc_new_len int,
+ rc_deleted tinyint unsigned NOT NULL default 0,
+ rc_logid int unsigned NOT NULL default 0,
+ rc_log_type varbinary(255) NULL default NULL,
+ rc_log_action varbinary(255) NULL default NULL,
+ rc_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rc_timestamp ON /*_*/recentchanges (rc_timestamp);
+CREATE INDEX /*i*/rc_namespace_title ON /*_*/recentchanges (rc_namespace, rc_title);
+CREATE INDEX /*i*/rc_cur_id ON /*_*/recentchanges (rc_cur_id);
+CREATE INDEX /*i*/new_name_timestamp ON /*_*/recentchanges (rc_new,rc_namespace,rc_timestamp);
+CREATE INDEX /*i*/rc_ip ON /*_*/recentchanges (rc_ip);
+CREATE INDEX /*i*/rc_ns_usertext ON /*_*/recentchanges (rc_namespace, rc_user_text);
+CREATE INDEX /*i*/rc_user_text ON /*_*/recentchanges (rc_user_text, rc_timestamp);
+CREATE TABLE /*_*/watchlist (
+ wl_user int unsigned NOT NULL,
+ wl_namespace int NOT NULL default 0,
+ wl_title varchar(255) binary NOT NULL default '',
+ wl_notificationtimestamp varbinary(14)
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/wl_user ON /*_*/watchlist (wl_user, wl_namespace, wl_title);
+CREATE INDEX /*i*/namespace_title ON /*_*/watchlist (wl_namespace, wl_title);
+CREATE TABLE /*_*/searchindex (
+ si_page int unsigned NOT NULL,
+ si_title varchar(255) NOT NULL default '',
+ si_text mediumtext NOT NULL
+) ENGINE=MyISAM DEFAULT CHARSET=utf8;
+CREATE UNIQUE INDEX /*i*/si_page ON /*_*/searchindex (si_page);
+CREATE FULLTEXT INDEX /*i*/si_title ON /*_*/searchindex (si_title);
+CREATE FULLTEXT INDEX /*i*/si_text ON /*_*/searchindex (si_text);
+CREATE TABLE /*_*/interwiki (
+ iw_prefix varchar(32) NOT NULL,
+ iw_url blob NOT NULL,
+ iw_api blob NOT NULL,
+ iw_wikiid varchar(64) NOT NULL,
+ iw_local bool NOT NULL,
+ iw_trans tinyint NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/iw_prefix ON /*_*/interwiki (iw_prefix);
+CREATE TABLE /*_*/querycache (
+ qc_type varbinary(32) NOT NULL,
+ qc_value int unsigned NOT NULL default 0,
+ qc_namespace int NOT NULL default 0,
+ qc_title varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qc_type ON /*_*/querycache (qc_type,qc_value);
+CREATE TABLE /*_*/objectcache (
+ keyname varbinary(255) NOT NULL default '' PRIMARY KEY,
+ value mediumblob,
+ exptime datetime
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/exptime ON /*_*/objectcache (exptime);
+CREATE TABLE /*_*/transcache (
+ tc_url varbinary(255) NOT NULL,
+ tc_contents text,
+ tc_time binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tc_url_idx ON /*_*/transcache (tc_url);
+CREATE TABLE /*_*/logging (
+ log_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ log_type varbinary(32) NOT NULL default '',
+ log_action varbinary(32) NOT NULL default '',
+ log_timestamp binary(14) NOT NULL default '19700101000000',
+ log_user int unsigned NOT NULL default 0,
+ log_user_text varchar(255) binary NOT NULL default '',
+ log_namespace int NOT NULL default 0,
+ log_title varchar(255) binary NOT NULL default '',
+ log_page int unsigned NULL,
+ log_comment varchar(255) NOT NULL default '',
+ log_params blob NOT NULL,
+ log_deleted tinyint unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/type_time ON /*_*/logging (log_type, log_timestamp);
+CREATE INDEX /*i*/user_time ON /*_*/logging (log_user, log_timestamp);
+CREATE INDEX /*i*/page_time ON /*_*/logging (log_namespace, log_title, log_timestamp);
+CREATE INDEX /*i*/times ON /*_*/logging (log_timestamp);
+CREATE INDEX /*i*/log_user_type_time ON /*_*/logging (log_user, log_type, log_timestamp);
+CREATE INDEX /*i*/log_page_id_time ON /*_*/logging (log_page,log_timestamp);
+CREATE INDEX /*i*/type_action ON /*_*/logging (log_type, log_action, log_timestamp);
+CREATE INDEX /*i*/log_user_text_type_time ON /*_*/logging (log_user_text, log_type, log_timestamp);
+CREATE INDEX /*i*/log_user_text_time ON /*_*/logging (log_user_text, log_timestamp);
+CREATE TABLE /*_*/log_search (
+ ls_field varbinary(32) NOT NULL,
+ ls_value varchar(255) NOT NULL,
+ ls_log_id int unsigned NOT NULL default 0
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/ls_field_val ON /*_*/log_search (ls_field,ls_value,ls_log_id);
+CREATE INDEX /*i*/ls_log_id ON /*_*/log_search (ls_log_id);
+CREATE TABLE /*_*/job (
+ job_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ job_cmd varbinary(60) NOT NULL default '',
+ job_namespace int NOT NULL,
+ job_title varchar(255) binary NOT NULL,
+ job_timestamp varbinary(14) NULL default NULL,
+ job_params blob NOT NULL,
+ job_random integer unsigned NOT NULL default 0,
+ job_attempts integer unsigned NOT NULL default 0,
+ job_token varbinary(32) NOT NULL default '',
+ job_token_timestamp varbinary(14) NULL default NULL,
+ job_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/job_sha1 ON /*_*/job (job_sha1);
+CREATE INDEX /*i*/job_cmd_token ON /*_*/job (job_cmd,job_token,job_random);
+CREATE INDEX /*i*/job_cmd_token_id ON /*_*/job (job_cmd,job_token,job_id);
+CREATE INDEX /*i*/job_cmd ON /*_*/job (job_cmd, job_namespace, job_title, job_params(128));
+CREATE INDEX /*i*/job_timestamp ON /*_*/job (job_timestamp);
+CREATE TABLE /*_*/querycache_info (
+ qci_type varbinary(32) NOT NULL default '',
+ qci_timestamp binary(14) NOT NULL default '19700101000000'
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/qci_type ON /*_*/querycache_info (qci_type);
+CREATE TABLE /*_*/redirect (
+ rd_from int unsigned NOT NULL default 0 PRIMARY KEY,
+ rd_namespace int NOT NULL default 0,
+ rd_title varchar(255) binary NOT NULL default '',
+ rd_interwiki varchar(32) default NULL,
+ rd_fragment varchar(255) binary default NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/rd_ns_title ON /*_*/redirect (rd_namespace,rd_title,rd_from);
+CREATE TABLE /*_*/querycachetwo (
+ qcc_type varbinary(32) NOT NULL,
+ qcc_value int unsigned NOT NULL default 0,
+ qcc_namespace int NOT NULL default 0,
+ qcc_title varchar(255) binary NOT NULL default '',
+ qcc_namespacetwo int NOT NULL default 0,
+ qcc_titletwo varchar(255) binary NOT NULL default ''
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/qcc_type ON /*_*/querycachetwo (qcc_type,qcc_value);
+CREATE INDEX /*i*/qcc_title ON /*_*/querycachetwo (qcc_type,qcc_namespace,qcc_title);
+CREATE INDEX /*i*/qcc_titletwo ON /*_*/querycachetwo (qcc_type,qcc_namespacetwo,qcc_titletwo);
+CREATE TABLE /*_*/page_restrictions (
+ pr_id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ pr_page int NOT NULL,
+ pr_type varbinary(60) NOT NULL,
+ pr_level varbinary(60) NOT NULL,
+ pr_cascade tinyint NOT NULL,
+ pr_user int NULL,
+ pr_expiry varbinary(14) NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pr_pagetype ON /*_*/page_restrictions (pr_page,pr_type);
+CREATE INDEX /*i*/pr_typelevel ON /*_*/page_restrictions (pr_type,pr_level);
+CREATE INDEX /*i*/pr_level ON /*_*/page_restrictions (pr_level);
+CREATE INDEX /*i*/pr_cascade ON /*_*/page_restrictions (pr_cascade);
+CREATE TABLE /*_*/protected_titles (
+ pt_namespace int NOT NULL,
+ pt_title varchar(255) binary NOT NULL,
+ pt_user int unsigned NOT NULL,
+ pt_reason tinyblob,
+ pt_timestamp binary(14) NOT NULL,
+ pt_expiry varbinary(14) NOT NULL default '',
+ pt_create_perm varbinary(60) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pt_namespace_title ON /*_*/protected_titles (pt_namespace,pt_title);
+CREATE INDEX /*i*/pt_timestamp ON /*_*/protected_titles (pt_timestamp);
+CREATE TABLE /*_*/page_props (
+ pp_page int NOT NULL,
+ pp_propname varbinary(60) NOT NULL,
+ pp_value blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/pp_page_propname ON /*_*/page_props (pp_page,pp_propname);
+CREATE UNIQUE INDEX /*i*/pp_propname_page ON /*_*/page_props (pp_propname,pp_page);
+CREATE TABLE /*_*/updatelog (
+ ul_key varchar(255) NOT NULL PRIMARY KEY,
+ ul_value blob
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/change_tag (
+ ct_rc_id int NULL,
+ ct_log_id int NULL,
+ ct_rev_id int NULL,
+ ct_tag varchar(255) NOT NULL,
+ ct_params blob NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/change_tag_rc_tag ON /*_*/change_tag (ct_rc_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_log_tag ON /*_*/change_tag (ct_log_id,ct_tag);
+CREATE UNIQUE INDEX /*i*/change_tag_rev_tag ON /*_*/change_tag (ct_rev_id,ct_tag);
+CREATE INDEX /*i*/change_tag_tag_id ON /*_*/change_tag (ct_tag,ct_rc_id,ct_rev_id,ct_log_id);
+CREATE TABLE /*_*/tag_summary (
+ ts_rc_id int NULL,
+ ts_log_id int NULL,
+ ts_rev_id int NULL,
+ ts_tags blob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/tag_summary_rc_id ON /*_*/tag_summary (ts_rc_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_log_id ON /*_*/tag_summary (ts_log_id);
+CREATE UNIQUE INDEX /*i*/tag_summary_rev_id ON /*_*/tag_summary (ts_rev_id);
+CREATE TABLE /*_*/valid_tag (
+ vt_tag varchar(255) NOT NULL PRIMARY KEY
+) /*$wgDBTableOptions*/;
+CREATE TABLE /*_*/l10n_cache (
+ lc_lang varbinary(32) NOT NULL,
+ lc_key varchar(255) NOT NULL,
+ lc_value mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE INDEX /*i*/lc_lang_key ON /*_*/l10n_cache (lc_lang, lc_key);
+CREATE TABLE /*_*/msg_resource (
+ mr_resource varbinary(255) NOT NULL,
+ mr_lang varbinary(32) NOT NULL,
+ mr_blob mediumblob NOT NULL,
+ mr_timestamp binary(14) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mr_resource_lang ON /*_*/msg_resource (mr_resource, mr_lang);
+CREATE TABLE /*_*/msg_resource_links (
+ mrl_resource varbinary(255) NOT NULL,
+ mrl_message varbinary(255) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/mrl_message_resource ON /*_*/msg_resource_links (mrl_message, mrl_resource);
+CREATE TABLE /*_*/module_deps (
+ md_module varbinary(255) NOT NULL,
+ md_skin varbinary(32) NOT NULL,
+ md_deps mediumblob NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/md_module_skin ON /*_*/module_deps (md_module, md_skin);
+CREATE TABLE /*_*/sites (
+ site_id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ site_global_key varbinary(32) NOT NULL,
+ site_type varbinary(32) NOT NULL,
+ site_group varbinary(32) NOT NULL,
+ site_source varbinary(32) NOT NULL,
+ site_language varbinary(32) NOT NULL,
+ site_protocol varbinary(32) NOT NULL,
+ site_domain VARCHAR(255) NOT NULL,
+ site_data BLOB NOT NULL,
+ site_forward bool NOT NULL,
+ site_config BLOB NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/sites_global_key ON /*_*/sites (site_global_key);
+CREATE INDEX /*i*/sites_type ON /*_*/sites (site_type);
+CREATE INDEX /*i*/sites_group ON /*_*/sites (site_group);
+CREATE INDEX /*i*/sites_source ON /*_*/sites (site_source);
+CREATE INDEX /*i*/sites_language ON /*_*/sites (site_language);
+CREATE INDEX /*i*/sites_protocol ON /*_*/sites (site_protocol);
+CREATE INDEX /*i*/sites_domain ON /*_*/sites (site_domain);
+CREATE INDEX /*i*/sites_forward ON /*_*/sites (site_forward);
+CREATE TABLE /*_*/site_identifiers (
+ si_site INT UNSIGNED NOT NULL,
+ si_type varbinary(32) NOT NULL,
+ si_key varbinary(32) NOT NULL
+) /*$wgDBTableOptions*/;
+CREATE UNIQUE INDEX /*i*/site_ids_type ON /*_*/site_identifiers (si_type, si_key);
+CREATE INDEX /*i*/site_ids_site ON /*_*/site_identifiers (si_site);
+CREATE INDEX /*i*/site_ids_key ON /*_*/site_identifiers (si_key);
diff --git a/www/wiki/tests/phpunit/data/filecontentshasher/hash.svg b/www/wiki/tests/phpunit/data/filecontentshasher/hash.svg
new file mode 100644
index 00000000..44068ba8
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/filecontentshasher/hash.svg
@@ -0,0 +1,110 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="200"
+ height="200"
+ id="svg2"
+ version="1.1"
+ inkscape:version="0.48.2 r9819"
+ sodipodi:docname="New document 1">
+ <defs
+ id="defs4" />
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="1.765"
+ inkscape:cx="101.66909"
+ inkscape:cy="64.929256"
+ inkscape:document-units="px"
+ inkscape:current-layer="layer1"
+ showgrid="false"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0"
+ showborder="false"
+ inkscape:showpageshadow="false"
+ inkscape:window-width="1132"
+ inkscape:window-height="961"
+ inkscape:window-x="0"
+ inkscape:window-y="0"
+ inkscape:window-maximized="0" />
+ <metadata
+ id="metadata7">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-193.20113,-609.30267)">
+ <g
+ id="g3829">
+ <g
+ transform="translate(-61.473095,237.81998)"
+ id="g3821">
+ <rect
+ style="fill:#000000;fill-opacity:1;stroke:none"
+ id="rect2998"
+ width="120.67989"
+ height="19.546741"
+ x="298.017"
+ y="434.23184"
+ ry="0" />
+ <rect
+ style="fill:#000000;fill-opacity:1;stroke:none"
+ id="rect2998-1"
+ width="120.67989"
+ height="19.546741"
+ x="290.65155"
+ y="488.76447"
+ ry="0" />
+ <rect
+ style="fill:#000000;fill-opacity:1;stroke:none"
+ id="rect2998-7"
+ width="183.60896"
+ height="19.546741"
+ x="384.33142"
+ y="-455.46609"
+ ry="0"
+ transform="matrix(-0.13731609,0.99052728,-1,0,0,0)" />
+ <rect
+ style="fill:#000000;fill-opacity:1;stroke:none"
+ id="rect2998-7-4"
+ width="183.60896"
+ height="19.546741"
+ x="384.04288"
+ y="-406.21848"
+ ry="0"
+ transform="matrix(-0.13731609,0.99052728,-1,0,0,0)" />
+ </g>
+ <rect
+ y="609.30267"
+ x="193.20113"
+ height="200"
+ width="200"
+ id="rect3827"
+ style="fill:none;stroke:none" />
+ </g>
+ </g>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/filecontentshasher/primes.txt b/www/wiki/tests/phpunit/data/filecontentshasher/primes.txt
new file mode 100644
index 00000000..a2fe1fb5
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/filecontentshasher/primes.txt
@@ -0,0 +1,105 @@
+ The First 1,000 Primes
+ (the 1,000th is 7919)
+ For more information on primes see http://primes.utm.edu/
+
+ 2 3 5 7 11 13 17 19 23 29
+ 31 37 41 43 47 53 59 61 67 71
+ 73 79 83 89 97 101 103 107 109 113
+ 127 131 137 139 149 151 157 163 167 173
+ 179 181 191 193 197 199 211 223 227 229
+ 233 239 241 251 257 263 269 271 277 281
+ 283 293 307 311 313 317 331 337 347 349
+ 353 359 367 373 379 383 389 397 401 409
+ 419 421 431 433 439 443 449 457 461 463
+ 467 479 487 491 499 503 509 521 523 541
+ 547 557 563 569 571 577 587 593 599 601
+ 607 613 617 619 631 641 643 647 653 659
+ 661 673 677 683 691 701 709 719 727 733
+ 739 743 751 757 761 769 773 787 797 809
+ 811 821 823 827 829 839 853 857 859 863
+ 877 881 883 887 907 911 919 929 937 941
+ 947 953 967 971 977 983 991 997 1009 1013
+ 1019 1021 1031 1033 1039 1049 1051 1061 1063 1069
+ 1087 1091 1093 1097 1103 1109 1117 1123 1129 1151
+ 1153 1163 1171 1181 1187 1193 1201 1213 1217 1223
+ 1229 1231 1237 1249 1259 1277 1279 1283 1289 1291
+ 1297 1301 1303 1307 1319 1321 1327 1361 1367 1373
+ 1381 1399 1409 1423 1427 1429 1433 1439 1447 1451
+ 1453 1459 1471 1481 1483 1487 1489 1493 1499 1511
+ 1523 1531 1543 1549 1553 1559 1567 1571 1579 1583
+ 1597 1601 1607 1609 1613 1619 1621 1627 1637 1657
+ 1663 1667 1669 1693 1697 1699 1709 1721 1723 1733
+ 1741 1747 1753 1759 1777 1783 1787 1789 1801 1811
+ 1823 1831 1847 1861 1867 1871 1873 1877 1879 1889
+ 1901 1907 1913 1931 1933 1949 1951 1973 1979 1987
+ 1993 1997 1999 2003 2011 2017 2027 2029 2039 2053
+ 2063 2069 2081 2083 2087 2089 2099 2111 2113 2129
+ 2131 2137 2141 2143 2153 2161 2179 2203 2207 2213
+ 2221 2237 2239 2243 2251 2267 2269 2273 2281 2287
+ 2293 2297 2309 2311 2333 2339 2341 2347 2351 2357
+ 2371 2377 2381 2383 2389 2393 2399 2411 2417 2423
+ 2437 2441 2447 2459 2467 2473 2477 2503 2521 2531
+ 2539 2543 2549 2551 2557 2579 2591 2593 2609 2617
+ 2621 2633 2647 2657 2659 2663 2671 2677 2683 2687
+ 2689 2693 2699 2707 2711 2713 2719 2729 2731 2741
+ 2749 2753 2767 2777 2789 2791 2797 2801 2803 2819
+ 2833 2837 2843 2851 2857 2861 2879 2887 2897 2903
+ 2909 2917 2927 2939 2953 2957 2963 2969 2971 2999
+ 3001 3011 3019 3023 3037 3041 3049 3061 3067 3079
+ 3083 3089 3109 3119 3121 3137 3163 3167 3169 3181
+ 3187 3191 3203 3209 3217 3221 3229 3251 3253 3257
+ 3259 3271 3299 3301 3307 3313 3319 3323 3329 3331
+ 3343 3347 3359 3361 3371 3373 3389 3391 3407 3413
+ 3433 3449 3457 3461 3463 3467 3469 3491 3499 3511
+ 3517 3527 3529 3533 3539 3541 3547 3557 3559 3571
+ 3581 3583 3593 3607 3613 3617 3623 3631 3637 3643
+ 3659 3671 3673 3677 3691 3697 3701 3709 3719 3727
+ 3733 3739 3761 3767 3769 3779 3793 3797 3803 3821
+ 3823 3833 3847 3851 3853 3863 3877 3881 3889 3907
+ 3911 3917 3919 3923 3929 3931 3943 3947 3967 3989
+ 4001 4003 4007 4013 4019 4021 4027 4049 4051 4057
+ 4073 4079 4091 4093 4099 4111 4127 4129 4133 4139
+ 4153 4157 4159 4177 4201 4211 4217 4219 4229 4231
+ 4241 4243 4253 4259 4261 4271 4273 4283 4289 4297
+ 4327 4337 4339 4349 4357 4363 4373 4391 4397 4409
+ 4421 4423 4441 4447 4451 4457 4463 4481 4483 4493
+ 4507 4513 4517 4519 4523 4547 4549 4561 4567 4583
+ 4591 4597 4603 4621 4637 4639 4643 4649 4651 4657
+ 4663 4673 4679 4691 4703 4721 4723 4729 4733 4751
+ 4759 4783 4787 4789 4793 4799 4801 4813 4817 4831
+ 4861 4871 4877 4889 4903 4909 4919 4931 4933 4937
+ 4943 4951 4957 4967 4969 4973 4987 4993 4999 5003
+ 5009 5011 5021 5023 5039 5051 5059 5077 5081 5087
+ 5099 5101 5107 5113 5119 5147 5153 5167 5171 5179
+ 5189 5197 5209 5227 5231 5233 5237 5261 5273 5279
+ 5281 5297 5303 5309 5323 5333 5347 5351 5381 5387
+ 5393 5399 5407 5413 5417 5419 5431 5437 5441 5443
+ 5449 5471 5477 5479 5483 5501 5503 5507 5519 5521
+ 5527 5531 5557 5563 5569 5573 5581 5591 5623 5639
+ 5641 5647 5651 5653 5657 5659 5669 5683 5689 5693
+ 5701 5711 5717 5737 5741 5743 5749 5779 5783 5791
+ 5801 5807 5813 5821 5827 5839 5843 5849 5851 5857
+ 5861 5867 5869 5879 5881 5897 5903 5923 5927 5939
+ 5953 5981 5987 6007 6011 6029 6037 6043 6047 6053
+ 6067 6073 6079 6089 6091 6101 6113 6121 6131 6133
+ 6143 6151 6163 6173 6197 6199 6203 6211 6217 6221
+ 6229 6247 6257 6263 6269 6271 6277 6287 6299 6301
+ 6311 6317 6323 6329 6337 6343 6353 6359 6361 6367
+ 6373 6379 6389 6397 6421 6427 6449 6451 6469 6473
+ 6481 6491 6521 6529 6547 6551 6553 6563 6569 6571
+ 6577 6581 6599 6607 6619 6637 6653 6659 6661 6673
+ 6679 6689 6691 6701 6703 6709 6719 6733 6737 6761
+ 6763 6779 6781 6791 6793 6803 6823 6827 6829 6833
+ 6841 6857 6863 6869 6871 6883 6899 6907 6911 6917
+ 6947 6949 6959 6961 6967 6971 6977 6983 6991 6997
+ 7001 7013 7019 7027 7039 7043 7057 7069 7079 7103
+ 7109 7121 7127 7129 7151 7159 7177 7187 7193 7207
+ 7211 7213 7219 7229 7237 7243 7247 7253 7283 7297
+ 7307 7309 7321 7331 7333 7349 7351 7369 7393 7411
+ 7417 7433 7451 7457 7459 7477 7481 7487 7489 7499
+ 7507 7517 7523 7529 7537 7541 7547 7549 7559 7561
+ 7573 7577 7583 7589 7591 7603 7607 7621 7639 7643
+ 7649 7669 7673 7681 7687 7691 7699 7703 7717 7723
+ 7727 7741 7753 7757 7759 7789 7793 7817 7823 7829
+ 7841 7853 7867 7873 7877 7879 7883 7901 7907 7919
+end.
diff --git a/www/wiki/tests/phpunit/data/filerepo/video.png b/www/wiki/tests/phpunit/data/filerepo/video.png
new file mode 100644
index 00000000..d86dbe01
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/filerepo/video.png
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/filerepo/wiki.png b/www/wiki/tests/phpunit/data/filerepo/wiki.png
new file mode 100644
index 00000000..8c421183
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/filerepo/wiki.png
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/gitinfo/extension/gitinfo.json b/www/wiki/tests/phpunit/data/gitinfo/extension/gitinfo.json
new file mode 100644
index 00000000..8cf21bda
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/gitinfo/extension/gitinfo.json
@@ -0,0 +1,7 @@
+{
+ "head": "refs/heads/master",
+ "headSHA1": "0123456789abcdef0123456789abcdef01234567",
+ "headCommitDate": "1070884800",
+ "branch": "master",
+ "remoteURL": "https://gerrit.wikimedia.org/r/mediawiki/core"
+}
diff --git a/www/wiki/tests/phpunit/data/gitinfo/info-testValidJsonData.json b/www/wiki/tests/phpunit/data/gitinfo/info-testValidJsonData.json
new file mode 100644
index 00000000..8cf21bda
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/gitinfo/info-testValidJsonData.json
@@ -0,0 +1,7 @@
+{
+ "head": "refs/heads/master",
+ "headSHA1": "0123456789abcdef0123456789abcdef01234567",
+ "headCommitDate": "1070884800",
+ "branch": "master",
+ "remoteURL": "https://gerrit.wikimedia.org/r/mediawiki/core"
+}
diff --git a/www/wiki/tests/phpunit/data/import/ImportLinkCacheIntegrationTest.xml b/www/wiki/tests/phpunit/data/import/ImportLinkCacheIntegrationTest.xml
new file mode 100644
index 00000000..8949f406
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/import/ImportLinkCacheIntegrationTest.xml
@@ -0,0 +1,43 @@
+<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.6/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.6/ http://www.mediawiki.org/xml/export-0.6.xsd" version="0.6" xml:lang="en-gb">
+ <siteinfo>
+ <sitename>MW-19</sitename>
+ <base>http://localhost:8080/w/index.php/Main_Page</base>
+ <generator>MediaWiki 1.19.7</generator>
+ <case>first-letter</case>
+ </siteinfo>
+ <page>
+ <title>Lorem ipsum</title>
+ <ns>0</ns>
+ <id>493</id>
+ <sha1>94lztkh4kgb0mvjr87iyjfq4iv7ltlh</sha1>
+ <revision>
+ <id>1358</id>
+ <timestamp>2014-04-04T22:55:04Z</timestamp>
+ <contributor>
+ <username>Tester</username>
+ <id>1</id>
+ </contributor>
+ <text xml:space="preserve" bytes="979">[[Has text::Lorem ipsum dolor sit amet consectetuer Maecenas adipiscing Pellentesque id sem]]. [[Has page::Elit Aliquam urna interdum]] morbi faucibus id tellus ipsum semper wisi. [[Has page::Platea enim hendrerit]] pellentesque consectetuer scelerisque Sed est felis felis quis. Auctor Proin In dolor id et ipsum vel at vitae ut. Praesent elit convallis Praesent aliquet pellentesque vel dolor pellentesque lacinia vitae. At tortor lacus Sed In interdum pulvinar et.
+
+[[Has number::1001]] [[Has quantity::10.25 km²]] [[Has date::1 Jan 2014]] [[Has Url::http://loremipsum.org/]] [[Has annotation uri::http://loremipsum.org/foaf.rdf]] [[Has email::Lorem@ipsum.org]] [[Has temperature::100 °C]] [[Has boolean::true]]
+
+[[Category:Lorem ipsum]]</text>
+ </revision>
+ </page>
+ <page>
+ <title>Category:Lorem ipsum</title>
+ <ns>14</ns>
+ <id>496</id>
+ <sha1>sir97j6uzt9ev2uyhaz1aj4i3spogih</sha1>
+ <revision>
+ <id>1355</id>
+ <timestamp>2014-04-04T22:29:18Z</timestamp>
+ <contributor>
+ <username>Tester</username>
+ <id>1</id>
+ </contributor>
+ <text xml:space="preserve" bytes="17">[[Category:Main]]</text>
+ </revision>
+ </page>
+</mediawiki>
+
diff --git a/www/wiki/tests/phpunit/data/less/common/test.common.mixins.less b/www/wiki/tests/phpunit/data/less/common/test.common.mixins.less
new file mode 100644
index 00000000..40647291
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/less/common/test.common.mixins.less
@@ -0,0 +1,4 @@
+.test-mixin (@value) {
+ color: @value;
+ border: @foo solid @Foo;
+}
diff --git a/www/wiki/tests/phpunit/data/less/module/dependency.less b/www/wiki/tests/phpunit/data/less/module/dependency.less
new file mode 100644
index 00000000..c7725a25
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/less/module/dependency.less
@@ -0,0 +1,3 @@
+@import "test.common.mixins";
+
+@unitTestColor: green;
diff --git a/www/wiki/tests/phpunit/data/less/module/styles.css b/www/wiki/tests/phpunit/data/less/module/styles.css
new file mode 100644
index 00000000..bac695b9
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/less/module/styles.css
@@ -0,0 +1,5 @@
+/* @noflip */
+.unit-tests {
+ color: #008000;
+ border: 2px solid #eeeeee;
+}
diff --git a/www/wiki/tests/phpunit/data/less/module/styles.less b/www/wiki/tests/phpunit/data/less/module/styles.less
new file mode 100644
index 00000000..ecac8392
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/less/module/styles.less
@@ -0,0 +1,6 @@
+@import "dependency";
+
+/* @noflip */
+.unit-tests {
+ .test-mixin(@unitTestColor);
+}
diff --git a/www/wiki/tests/phpunit/data/localisationcache/ba.json b/www/wiki/tests/phpunit/data/localisationcache/ba.json
new file mode 100644
index 00000000..59b89121
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/localisationcache/ba.json
@@ -0,0 +1,3 @@
+{
+ "present-ba": "ba"
+}
diff --git a/www/wiki/tests/phpunit/data/localisationcache/en.json b/www/wiki/tests/phpunit/data/localisationcache/en.json
new file mode 100644
index 00000000..39cce86b
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/localisationcache/en.json
@@ -0,0 +1,5 @@
+{
+ "present-ba": "en",
+ "present-ru": "en",
+ "present-en": "en"
+}
diff --git a/www/wiki/tests/phpunit/data/localisationcache/ru.json b/www/wiki/tests/phpunit/data/localisationcache/ru.json
new file mode 100644
index 00000000..c9f89d38
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/localisationcache/ru.json
@@ -0,0 +1,4 @@
+{
+ "present-ba": "ru",
+ "present-ru": "ru"
+}
diff --git a/www/wiki/tests/phpunit/data/media/1bit-png.png b/www/wiki/tests/phpunit/data/media/1bit-png.png
new file mode 100644
index 00000000..254e403a
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/1bit-png.png
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/2_webp_a.webp b/www/wiki/tests/phpunit/data/media/2_webp_a.webp
new file mode 100644
index 00000000..8764f066
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/2_webp_a.webp
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/2_webp_ll.webp b/www/wiki/tests/phpunit/data/media/2_webp_ll.webp
new file mode 100644
index 00000000..5794bbf2
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/2_webp_ll.webp
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/80x60-2layers.xcf b/www/wiki/tests/phpunit/data/media/80x60-2layers.xcf
new file mode 100644
index 00000000..c51e980c
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/80x60-2layers.xcf
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/80x60-Greyscale.xcf b/www/wiki/tests/phpunit/data/media/80x60-Greyscale.xcf
new file mode 100644
index 00000000..84bf3e67
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/80x60-Greyscale.xcf
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/80x60-RGB.xcf b/www/wiki/tests/phpunit/data/media/80x60-RGB.xcf
new file mode 100644
index 00000000..1d58f16d
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/80x60-RGB.xcf
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/Animated_PNG_example_bouncing_beach_ball.png b/www/wiki/tests/phpunit/data/media/Animated_PNG_example_bouncing_beach_ball.png
new file mode 100644
index 00000000..c2f45d90
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/Animated_PNG_example_bouncing_beach_ball.png
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/Bishzilla_blink.gif b/www/wiki/tests/phpunit/data/media/Bishzilla_blink.gif
new file mode 100644
index 00000000..13e55362
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/Bishzilla_blink.gif
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/Gtk-media-play-ltr.svg b/www/wiki/tests/phpunit/data/media/Gtk-media-play-ltr.svg
new file mode 100644
index 00000000..fc22338a
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/Gtk-media-play-ltr.svg
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd" [
+ <!ATTLIST svg
+ xmlns:xlink CDATA #FIXED "http://www.w3.org/1999/xlink">
+]>
+<!-- Created with Sodipodi ("http://www.sodipodi.com/") -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/" version="1" x="0.00000000" y="0.00000000" width="60.0000000" height="60.0000000" viewBox="0 0 256 256" id="svg548">
+ <defs id="defs572"/>
+ <g style="font-size:12;stroke:#000000;" id="Layer_1">
+ <path d="M 256 256 L 0 256 L 0 0 L 256 0 L 256 256 z " style="fill:none;stroke:none;" id="path550"/>
+ </g>
+ <g style="font-size:12;stroke:#000000;" id="Layer_2">
+ <path d="M 35.159 8.29 C 32.18 10.01 30.329 13.216 30.329 16.656 L 30.329 245.539 C 30.329 248.978 32.179 252.184 35.158 253.902 C 38.138 255.623 41.839 255.623 44.817 253.904 L 243.037 139.463 C 246.016 137.742 247.867 134.537 247.867 131.098 C 247.867 127.658 246.016 124.452 243.037 122.731 L 44.818 8.29 C 41.839 6.57 38.138 6.57 35.159 8.29 z " style="opacity:0.2;stroke:none;" id="path552"/>
+ <path d="M 27.314 2.29 C 24.335 4.01 22.484 7.216 22.484 10.656 L 22.484 239.538 C 22.484 242.977 24.335 246.184 27.313 247.903 C 30.293 249.623 33.994 249.623 36.973 247.905 L 235.193 133.464 C 238.172 131.742 240.023 128.536 240.023 125.098 C 240.023 121.658 238.172 118.452 235.193 116.732 L 36.975 2.29 C 33.996 0.57 30.294 0.57 27.314 2.29 z " style="fill:#003399;stroke:none;" id="path553"/>
+ <path d="M 29.247 5.636 C 27.454 6.672 26.349 8.585 26.349 10.656 L 26.349 239.538 C 26.349 241.608 27.453 243.521 29.247 244.558 C 31.04 245.592 33.249 245.592 35.042 244.558 L 233.261 130.117 C 235.054 129.081 236.159 127.169 236.159 125.098 C 236.159 123.027 235.054 121.114 233.261 120.078 L 35.042 5.636 C 33.25 4.601 31.041 4.601 29.247 5.636 z " style="fill:#003399;stroke:none;" id="path554"/>
+ <path d="M 32.145 10.656 L 230.364 125.097 L 32.145 239.538 L 32.145 10.656 z " style="fill:#3399ff;stroke:none;" id="path555"/>
+ <linearGradient x1="109.971703" y1="8.70849991" x2="109.971703" y2="107.238800" id="XMLID_1_" gradientUnits="userSpaceOnUse" spreadMethod="pad">
+ <stop style="stop-color:#ffffff;stop-opacity:1;" offset="0.00000000" id="stop557"/>
+ <stop style="stop-color:#3399ff;stop-opacity:1;" offset="1.00000000" id="stop558"/>
+ <a:midPointStop offset="0" style="stop-color:#FFFFFF" id="midPointStop559"/>
+ <a:midPointStop offset="0.5" style="stop-color:#FFFFFF" id="midPointStop560"/>
+ <a:midPointStop offset="1" style="stop-color:#3399FF" id="midPointStop561"/>
+ </linearGradient>
+ <path d="M 32.145 141.057 C 36.775 141.258 41.456 141.368 46.183 141.368 C 105.41 141.368 157.526 125.124 187.799 100.524 L 32.145 10.656 L 32.145 141.057 z " style="fill:url(#XMLID_1_);stroke:none;" id="path562"/>
+ <linearGradient x1="109.972198" y1="264.875000" x2="109.972198" y2="145.249298" id="XMLID_2_" gradientUnits="userSpaceOnUse" spreadMethod="pad">
+ <stop style="stop-color:#ccffff;stop-opacity:1;" offset="0.00000000" id="stop564"/>
+ <stop style="stop-color:#3399ff;stop-opacity:1;" offset="1.00000000" id="stop565"/>
+ <a:midPointStop offset="0" style="stop-color:#CCFFFF" id="midPointStop566"/>
+ <a:midPointStop offset="0.5" style="stop-color:#CCFFFF" id="midPointStop567"/>
+ <a:midPointStop offset="1" style="stop-color:#3399FF" id="midPointStop568"/>
+ </linearGradient>
+ <path d="M 32.145 108.517 C 36.775 108.315 41.456 108.206 46.183 108.206 C 105.41 108.206 157.526 124.451 187.799 149.05 L 32.145 238.916 L 32.145 108.517 z " style="fill:url(#XMLID_2_);stroke:none;" id="path569"/>
+ <path d="M 37.145 19.316 C 36.526 19.673 36.145 20.334 36.145 21.048 L 36.145 162.69 C 36.145 163.768 36.999 198.629 38.077 198.667 C 39.154 198.703 40.41 48.03 48.066 40.375 C 55.722 32.72 212.492 122.951 213 122 C 213.507 121.049 186.703 104.509 185.77 103.97 L 39.145 19.316 C 38.526 18.959 37.764 18.959 37.145 19.316 z " style="opacity:0.5;fill:#ffffff;stroke:none;" id="path570"/>
+ </g>
+</svg> \ No newline at end of file
diff --git a/www/wiki/tests/phpunit/data/media/LoremIpsum.djvu b/www/wiki/tests/phpunit/data/media/LoremIpsum.djvu
new file mode 100644
index 00000000..42f47cd0
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/LoremIpsum.djvu
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/Png-native-test.png b/www/wiki/tests/phpunit/data/media/Png-native-test.png
new file mode 100644
index 00000000..a0b81ca9
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/Png-native-test.png
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/QA_icon.svg b/www/wiki/tests/phpunit/data/media/QA_icon.svg
new file mode 100644
index 00000000..6b5d86e4
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/QA_icon.svg
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<svg:svg xmlns:svg="http://www.w3.org/2000/svg" version="1.0" width="60" height="60" viewBox="0 0 128 128" id="svg548">
+ <svg:defs id="defs601">
+ <svg:linearGradient id="linearGradient2802">
+ <svg:stop style="stop-color:#1d12aa;stop-opacity:1" offset="0" id="stop2804"/>
+ <svg:stop style="stop-color:#8b12aa;stop-opacity:0" offset="1" id="stop2806"/>
+ </svg:linearGradient>
+ <svg:linearGradient id="linearGradient2812">
+ <svg:stop style="stop-color:#1d25aa;stop-opacity:1" offset="0" id="stop2814"/>
+ <svg:stop style="stop-color:#8b12aa;stop-opacity:0" offset="1" id="stop2816"/>
+ </svg:linearGradient>
+ <svg:marker refX="0" refY="0" orient="auto" style="overflow:visible" id="Arrow1Lstart">
+ <svg:path d="M 0,0 L 5,-5 L -12.5,0 L 5,5 L 0,0 z " transform="scale(0.8)" style="fill-rule:evenodd;stroke:#000000;stroke-width:1pt;marker-start:none" id="path2991"/>
+ </svg:marker>
+ <svg:linearGradient id="linearGradient4766">
+ <svg:stop style="stop-color:#0447ff;stop-opacity:1" offset="0" id="stop4768"/>
+ <svg:stop style="stop-color:#000000;stop-opacity:0" offset="1" id="stop4770"/>
+ </svg:linearGradient>
+ <svg:linearGradient x1="55.4272" y1="102.1953" x2="55.4272" y2="-7.1773" id="XMLID_1_" gradientUnits="userSpaceOnUse" gradientTransform="translate(0, -0.496766)" spreadMethod="pad">
+ <svg:stop style="stop-color:#7c74ff;stop-opacity:1" offset="0" id="stop556"/>
+ <svg:stop style="stop-color:#b3caff;stop-opacity:1" offset="0.41010001" id="stop557"/>
+ <svg:stop style="stop-color:#dfeaff;stop-opacity:1" offset="0.8258" id="stop558"/>
+ <svg:stop style="stop-color:#ffffff;stop-opacity:1" offset="1" id="stop559"/>
+ <midPointStop offset="0" style="stop-color:#7C74FF" id="midPointStop560"/>
+ <midPointStop offset="0.5" style="stop-color:#7C74FF" id="midPointStop561"/>
+ <midPointStop offset="0.4101" style="stop-color:#B3CAFF" id="midPointStop562"/>
+ <midPointStop offset="0.5" style="stop-color:#B3CAFF" id="midPointStop563"/>
+ <midPointStop offset="0.8258" style="stop-color:#DFEAFF" id="midPointStop564"/>
+ <midPointStop offset="0.5" style="stop-color:#DFEAFF" id="midPointStop565"/>
+ <midPointStop offset="1" style="stop-color:#FFFFFF" id="midPointStop566"/>
+ </svg:linearGradient>
+ <svg:linearGradient x1="54.7607" y1="7.2758999" x2="54.7607" y2="57.487301" id="XMLID_2_" gradientUnits="userSpaceOnUse" spreadMethod="pad">
+ <svg:stop style="stop-color:#ffffff;stop-opacity:1" offset="0" id="stop569"/>
+ <svg:stop style="stop-color:#b3caff;stop-opacity:1" offset="1" id="stop570"/>
+ <midPointStop offset="0" style="stop-color:#FFFFFF" id="midPointStop571"/>
+ <midPointStop offset="0.5" style="stop-color:#FFFFFF" id="midPointStop572"/>
+ <midPointStop offset="1" style="stop-color:#B3CAFF" id="midPointStop573"/>
+ </svg:linearGradient>
+ <svg:linearGradient x1="83.637703" y1="119.3457" x2="83.637703" y2="42.033901" id="XMLID_3_" gradientUnits="userSpaceOnUse" spreadMethod="pad">
+ <svg:stop style="stop-color:#006dff;stop-opacity:1" offset="0" id="stop577"/>
+ <svg:stop style="stop-color:#94caff;stop-opacity:1" offset="0.41010001" id="stop578"/>
+ <svg:stop style="stop-color:#dcf0ff;stop-opacity:1" offset="0.8258" id="stop579"/>
+ <svg:stop style="stop-color:#ffffff;stop-opacity:1" offset="1" id="stop580"/>
+ <midPointStop offset="0" style="stop-color:#006DFF" id="midPointStop581"/>
+ <midPointStop offset="0.5" style="stop-color:#006DFF" id="midPointStop582"/>
+ <midPointStop offset="0.4101" style="stop-color:#94CAFF" id="midPointStop583"/>
+ <midPointStop offset="0.5" style="stop-color:#94CAFF" id="midPointStop584"/>
+ <midPointStop offset="0.8258" style="stop-color:#DCF0FF" id="midPointStop585"/>
+ <midPointStop offset="0.5" style="stop-color:#DCF0FF" id="midPointStop586"/>
+ <midPointStop offset="1" style="stop-color:#FFFFFF" id="midPointStop587"/>
+ </svg:linearGradient>
+ <svg:linearGradient x1="265.11331" y1="52.250999" x2="265.11331" y2="87.743599" id="XMLID_4_" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-1, 0, 0, 1, 349, 0)" spreadMethod="pad">
+ <svg:stop style="stop-color:#ffffff;stop-opacity:1" offset="0" id="stop590"/>
+ <svg:stop style="stop-color:#94caff;stop-opacity:1" offset="1" id="stop591"/>
+ <midPointStop offset="0" style="stop-color:#FFFFFF" id="midPointStop592"/>
+ <midPointStop offset="0.5" style="stop-color:#FFFFFF" id="midPointStop593"/>
+ <midPointStop offset="1" style="stop-color:#94CAFF" id="midPointStop594"/>
+ </svg:linearGradient>
+ </svg:defs>
+ <svg:g style="font-size:12px;stroke:#000000" id="Layer_2">
+ <svg:path d="M 128,128 L 0,128 L 0,0 L 128,0 L 128,128 z " style="fill:none;stroke:none" id="path550"/>
+ </svg:g>
+ <svg:g style="font-size:12px;stroke:#000000" id="Layer_1"/>
+ <svg:path d="M 9.041,92.189 C 9.041,92.189 21.955,85.393 30.11,67.382 L 52.198,76.897 C 52.198,76.897 46.422,92.189 9.041,92.189 z " style="font-size:12px;fill:#00008d;stroke:none" id="path553"/>
+ <svg:path d="M 1.905,49.712 C 1.905,70.733 25.867,87.773 55.427,87.773 C 84.987,87.773 108.949,70.733 108.949,49.712 C 108.949,28.692 84.987,11.651 55.427,11.651 C 25.867,11.651 1.905,28.692 1.905,49.712 z " style="font-size:12px;fill:#00008d;stroke:none" id="path554"/>
+ <svg:path d="M 55.427,13.193234 C 27.039,13.193234 3.943,29.352234 3.943,49.214234 C 3.943,61.333234 12.55,72.067234 25.703,78.598234 C 22.202,83.521234 18.6,87.075234 15.722,89.464234 C 27.71,88.800234 35.664,86.388234 40.883,83.762234 C 45.498,84.716234 50.377,85.236234 55.427,85.236234 C 83.815,85.236234 106.91,69.077234 106.91,49.215234 C 106.91,29.353234 83.815,13.193234 55.427,13.193234 z " style="font-size:12px;fill:url(#XMLID_1_);stroke:none" id="path567"/>
+ <svg:path d="M 12.999,35.282 C 30.044,44.81 49.474,47.149 69.356,41.962 C 73.46,40.821 77.627,39.436 81.656,38.096 C 86.51,36.482 91.504,34.846 96.524,33.573 C 88.559,23.302 72.888,16.748 55.428,16.748 C 37.091,16.749 20.396,24.128 12.999,35.282 z " style="font-size:12px;fill:url(#XMLID_2_);stroke:none" id="path574"/>
+ <svg:text x="32.487015" y="68.006958" style="font-size:48px;font-style:normal;font-weight:bold;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;font-family:Microsoft Sans Serif" id="text2303" xml:space="preserve"><svg:tspan x="32.487015" y="68.006958" style="font-size:64px;font-style:normal;font-weight:bold;fill:#000000;fill-opacity:1;font-family:sans" id="tspan2305">?</svg:tspan></svg:text>
+ <svg:path d="M 42.401,82.248 C 42.401,98.96 60.9,112.557 83.638,112.557 C 86.794,112.557 90.053,112.231 93.329,111.651 C 98.255,113.817 104.317,115.142 111.437,115.538 L 126.095,116.35 L 114.798,106.972 C 114.067,106.367 113.056,105.441 111.922,104.237 C 120.165,98.531 124.875,90.66 124.875,82.25 C 124.875,65.538 106.376,51.942 83.637,51.942 C 60.9,51.94 42.401,65.536 42.401,82.248 z " style="font-size:12px;fill:#0032a4;stroke:none" id="path575"/>
+ <svg:path d="M 44.823,82.248 C 44.823,97.624 62.236,110.133 83.637,110.133 C 87.009,110.133 90.357,109.784 93.616,109.163 C 98.327,111.368 104.334,112.717 111.571,113.118 L 118.9,113.524 L 113.251,108.835 C 111.96,107.763 110.162,106.071 108.268,103.754 C 117.176,98.487 122.454,90.629 122.454,82.248 C 122.454,66.871 105.042,54.363 83.639,54.363 C 62.236,54.363 44.823,66.871 44.823,82.248 z " style="font-size:12px;fill:url(#XMLID_3_);stroke:none" id="path588"/>
+ <svg:path d="M 83.638,57.505 C 98.257,57.505 110.759,63.777 115.655,72.576 C 102.935,80.147 88.183,82.013 73.429,78.165 C 66.228,76.163 59.247,73.276 52.12,71.719 C 57.332,63.374 69.498,57.505 83.638,57.505 z " style="font-size:12px;fill:url(#XMLID_4_);stroke:none" id="path595"/>
+ <svg:g transform="matrix(1.38561, 0, 0, 1.38561, -32.2514, -30.5491)" id="g4248">
+ <svg:path d="M 103.21356 24.205935 A 24.311146 23.627199 0 1 1 54.591267,24.205935 A 24.311146 23.627199 0 1 1 103.21356 24.205935 z" transform="matrix(0.148134, 0, 0, 0.152972, 71.9504, 64.0705)" style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:6.64218044;stroke-linecap:square;marker-start:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" id="path3881"/>
+ <svg:path d="M 87.539971,77.627004 C 87.539971,78.31674 87.591107,91.916423 87.60756,94.355578 C 87.61771,95.860439 89.879004,95.050778 90.026509,95.980703 C 90.2785,97.569343 86.888685,97.025111 86.718511,97.01762 C 85.743882,96.974724 82.425764,97.036144 81.376943,97.036144 C 81.101002,97.036144 77.578516,97.69007 77.314172,96.309196 C 77.071189,95.039902 79.49446,95.29146 79.833236,94.380195 C 81.070282,91.052684 81.154686,84.029315 80.322646,79.891188 C 79.902772,77.802954 76.928763,78.363984 77.263297,76.859643 C 77.479369,75.888015 78.579837,75.778912 79.35102,75.513942 C 81.049574,74.930337 83.123826,75.068206 84.579101,74.012707 C 86.187481,72.846162 87.539971,75.631913 87.539971,77.627004 z " style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:0.99986994;stroke-linecap:square;marker-start:none;stroke-miterlimit:4;stroke-dashoffset:0;stroke-opacity:1" id="path4774"/>
+ </svg:g>
+</svg:svg> \ No newline at end of file
diff --git a/www/wiki/tests/phpunit/data/media/README b/www/wiki/tests/phpunit/data/media/README
new file mode 100644
index 00000000..52f19128
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/README
@@ -0,0 +1,61 @@
+This directory contains media files for use with the
+tests in includes/media directory.
+
+Image credits:
+
+QA_icon.svg:
+https://es.wikipedia.org/wiki/Archivo:QA_icon.svg
+GNU Lesser General Public License
+~~helix84 (16.4.2007), Philverney (6.12.2005) David Vignoni
+
+Gtk-media-play-ltr.svg
+https://commons.wikimedia.org/wiki/File:Gtk-media-play-ltr.svg
+GNU Lesser General Public License
+https://ftp.gnome.org/pub/GNOME/sources/gnome-themes-extras/0.9/gnome-themes-extras-0.9.0.tar.gz
+David Vignoni
+
+US_states_by_total_state_tax_revenue.svg
+https://commons.wikimedia.org/wiki/File:US_states_by_total_state_tax_revenue.svg
+CC BY 3.0
+TastyCakes on English Wikipedia
+
+greyscale-na-png.png, rgb-png.png, Xmp-exif-multilingual_test.jpg
+greyscale-png.png, 1bit-png.png, Png-native-test.png, rgb-na-png.png,
+test.tiff, test.jpg, jpeg-comment-multiple.jpg, jpeg-comment-utf.jpg,
+jpeg-comment-iso8859-1.jpg, jpeg-comment-binary.jpg, jpeg-xmp-psir.jpg,
+jpeg-xmp-alt.jpg, animated.gif, exif-user-comment.jpg, animated-xmp.gif,
+iptc-timetest-invalid.jpg, jpeg-iptc-bad-hash.jpg, iptc-timetest.jpg,
+xmp.png, nonanimated.gif, exif-gps.jpg, jpeg-xmp-psir.xmp, jpeg-iptc-good-hash.jpg,
+jpeg-padding-even.jpg, jpeg-padding-odd.jpg
+Are all by Bawolff. I don't think they contain enough originality to
+claim copyright, but on the off chance they do, feel free to use them
+however you feel fit, without restriction.
+
+Animated_PNG_example_bouncing_beach_ball.png
+https://commons.wikimedia.org/wiki/File:Animated_PNG_example_bouncing_beach_ball.png (originally http://www.treebuilder.de/default.asp?file=89031.xml )
+Public Domain
+Holger Will
+
+Tux.svg
+https://commons.wikimedia.org/wiki/File:Tux.svg
+Larry Ewing, Simon Budig, Anja Gerwinski
+"The copyright holder of this file allows anyone to use it for any purpose, provided that the copyright holder is properly attributed. Redistribution, derivative work, commercial use, and all other use is permitted."
+
+Speech_bubbles.svg (Modified slightly)
+https://commons.wikimedia.org/wiki/File:Speech_bubbles.svg
+CC BY-SA 3.0
+Jarry1250
+
+Soccer_ball_animated.svg
+https://commons.wikimedia.org/wiki/File:Soccer_ball_animated.svg
+GFDL 1.2 or later, CC-BY-SA 3.0 unported, CC-BY-SA 2.5 generic, CC-BY-SA 2.0 generic, or CC-BY-SA 1.0 generic
+Pumbaa80
+
+Bishzilla_blink.gif
+https://commons.wikimedia.org/wiki/File:Bishzilla_blink.gif
+Public domain
+Bishonen
+
+say-test.ogg
+Public domain
+Brian Wolff
diff --git a/www/wiki/tests/phpunit/data/media/Soccer_ball_animated.svg b/www/wiki/tests/phpunit/data/media/Soccer_ball_animated.svg
new file mode 100644
index 00000000..183e43d8
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/Soccer_ball_animated.svg
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE svg>
+<svg width="150" height="150" viewBox="-105 -105 210 210" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <defs>
+ <clipPath id="ball">
+ <circle r="100" stroke-width="0"/>
+ </clipPath>
+ <radialGradient id="shadow1" cx=".4" cy=".3" r=".8">
+ <stop offset="0" stop-color="white" stop-opacity="1"/>
+ <stop offset=".4" stop-color="white" stop-opacity="1"/>
+ <stop offset=".8" stop-color="#EEEEEE" stop-opacity="1"/>
+ </radialGradient>
+ <radialGradient id="shadow2" cx=".5" cy=".5" r=".5">
+ <stop offset="0" stop-color="white" stop-opacity="0"/>
+ <stop offset=".8" stop-color="white" stop-opacity="0"/>
+ <stop offset=".99" stop-color="black" stop-opacity=".3"/>
+ <stop offset="1" stop-color="black" stop-opacity="1"/>
+ </radialGradient>
+ <g id="black_stuff" stroke-linejoin="round" clip-path="url(#ball)">
+ <g fill="black">
+ <path d="M 6,-32 Q 26,-28 46,-19 Q 57,-35 64,-47 Q 50,-68 37,-76 Q 17,-75 1,-68 Q 4,-51 6,-32"/>
+ <path d="M -26,-2 Q -45,-8 -62,-11 Q -74,5 -76,22 Q -69,40 -50,54 Q -32,47 -17,39 Q -23,15 -26,-2"/>
+ <path d="M -95,22 Q -102,12 -102,-8 V 80 H -85 Q -95,45 -95,22"/>
+ <path d="M 55,24 Q 41,41 24,52 Q 28,65 31,79 Q 55,78 68,67 Q 78,50 80,35 Q 65,28 55,24"/>
+ <path d="M 0,120 L -3,95 Q -25,93 -42,82 Q -50,84 -60,81"/>
+ <path d="M -90,-48 Q -80,-52 -68,-49 Q -52,-71 -35,-77 Q -35,-100 -40,-100 H -100"/>
+ <path d="M 100,-55 L 87,-37 Q 98,-10 97,5 L 100,6"/>
+ </g>
+ <g fill="none">
+ <path d="M 6,-32 Q -18,-12 -26,-2
+ M 46,-19 Q 54,5 55,24
+ M 64,-47 Q 77,-44 87,-37
+ M 37,-76 Q 39,-90 36,-100
+ M 1,-68 Q -13,-77 -35,-77
+ M -62,-11 Q -67,-25 -68,-49
+ M -76,22 Q -85,24 -95,22
+ M -50,54 Q -49,70 -42,82
+ M -17,39 Q 0,48 24,52
+ M 31,79 Q 20,92 -3,95
+ M 68,67 L 80,80
+ M 80,35 Q 90,25 97,5
+ "/>
+ </g>
+ </g>
+ </defs>
+ <circle r="100" fill="white" stroke="none"/>
+ <circle r="100" fill="url(#shadow1)" stroke="none"/>
+ <g><animateTransform attributeName="transform" attributeType="XML" type="rotate" from="0" to="360" begin="0s" dur="3s" repeatCount="indefinite"/>
+ <use xlink:href="#black_stuff" stroke="#EEE" stroke-width="7"/>
+ <use xlink:href="#black_stuff" stroke="#DDD" stroke-width="4"/>
+ <use xlink:href="#black_stuff" stroke="#999" stroke-width="2"/>
+ <use xlink:href="#black_stuff" stroke="black" stroke-width="1"/>
+ </g>
+ <circle r="100" fill="url(#shadow2)" stroke="none"/>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/media/Speech_bubbles.svg b/www/wiki/tests/phpunit/data/media/Speech_bubbles.svg
new file mode 100644
index 00000000..6b1ef7a9
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/Speech_bubbles.svg
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="17.7cm" height="13cm" id="svg2" version="1.1" inkscape:version="0.48.2 r9819" sodipodi:docname="New document 1">
+ <defs id="defs4"/>
+ <sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="0.7" inkscape:cx="296.43458" inkscape:cy="130.17435" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="false" fit-margin-top="0" fit-margin-left="0" fit-margin-right="0" fit-margin-bottom="0" inkscape:window-width="1366" inkscape:window-height="706" inkscape:window-x="-8" inkscape:window-y="-8" inkscape:window-maximized="1"/>
+ <g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(-0.28125,-1.21875)">
+ <switch style="font-size:40px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Sans"><text xml:space="preserve" x="90" y="108.07646" id="text2985-de" sodipodi:linespacing="125%" systemLanguage="de"><tspan text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2987-de">Hallo!</tspan></text><text xml:space="preserve" x="90" y="108.07646" id="text2985-fr" sodipodi:linespacing="125%" systemLanguage="fr"><tspan x="80" y="108.07646" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2987-fr">Bonjour</tspan></text><text xml:space="preserve" x="90" y="108.07646" id="text2985-nl" sodipodi:linespacing="125%" systemLanguage="nl, tlh-ca"><tspan x="90" y="108.07646" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2987-nl">Hallo!</tspan></text><text xml:space="preserve" x="90" y="108.07646" id="text2985" sodipodi:linespacing="125%"><tspan x="90" y="108.07646" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2987" sodipodi:role="line">Hello!</tspan></text></switch>
+ <switch style="font-size:40px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Sans"><text xml:space="preserve" x="330" y="188.07648" id="text2989-de" sodipodi:linespacing="125%" systemLanguage="de"><tspan x="323" y="188.07648" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2991-de">Hallo! Wie</tspan><tspan x="350" y="238.07648" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2993-de" sodipodi:role="line">geht's?</tspan></text><text xml:space="preserve" x="330" y="188.07648" id="text2989-fr" sodipodi:linespacing="125%" systemLanguage="fr"><tspan x="335" y="188.07648" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2991-fr">Bonjour,</tspan><tspan x="350" y="238.07648" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2993-fr">ça va?</tspan></text><text xml:space="preserve" x="330" y="188.07648" id="text2989-nl" sodipodi:linespacing="125%" systemLanguage="nl, tlh-ca"><tspan x="310" y="188.07648" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2991-nl">Hallo! Hoe</tspan><tspan x="330" y="238.07648" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2993-nl">gaat het?</tspan></text><text xml:space="preserve" x="330" y="188.07648" id="text2989" sodipodi:linespacing="125%"><tspan x="330" y="188.07648" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2991" sodipodi:role="line">Hello! How</tspan><tspan x="330" y="238.07648" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2993" sodipodi:role="line">are you?</tspan></text></switch>
+ <switch style="font-size:40px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Sans"><text xml:space="preserve" x="101.42857" y="318.64789" id="text2995-fr" sodipodi:linespacing="125%" systemLanguage="fr"><tspan x="82" y="323" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2997-fr">Ça va bien,</tspan><tspan x="117.42857" y="368.64789" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2999-fr">et toi?</tspan></text><text xml:space="preserve" x="101.42857" y="318.64789" id="text2995-nl" sodipodi:linespacing="125%" systemLanguage="nl, tlh-ca"><tspan x="101.42857" y="318.64789" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2997-nl">Goed,</tspan><tspan x="101.42857" y="368.64789" font-size="90%" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2999-nl">met jou?</tspan></text><text xml:space="preserve" x="101.42857" y="318.64789" id="text2995" sodipodi:linespacing="125%"><tspan x="101.42857" y="318.64789" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2997" sodipodi:role="line">I'm well,</tspan><tspan x="101.42857" y="368.64789" text-decoration="normal" font-style="normal" font-weight="normal" id="tspan2999" sodipodi:role="line"> you?</tspan></text></switch>
+ <path style="color:#000000;fill:none;stroke:#808080;stroke-width:8.19999980999999960;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" d="m 145.41518,24.660714 c -54.439497,0 -98.562501,30.043022 -98.562501,67.125 0,9.936246 3.188468,19.358966 8.875,27.843746 -3.477405,24.25473 -24,58.71875 -24,58.71875 0,0 55.316401,-29.49598 68.544641,-28.55804 2.17169,0.15398 -0.660951,4.01645 -2.044641,0.93304 14.019951,5.22007 30.083661,8.21875 47.187501,8.21875 54.4395,0 98.59375,-30.07427 98.59375,-67.156246 0,-37.081978 -44.15425,-67.125 -98.59375,-67.125 z" id="path3769" inkscape:connector-curvature="0" sodipodi:nodetypes="ssccscsss"/>
+ <path style="color:#000000;fill:none;stroke:#808080;stroke-width:8;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" d="m 416.54255,99.214524 c 73.5252,0 133.11712,43.566276 133.11712,97.339926 0,14.40884 -4.3063,28.073 -11.98645,40.37703 4.69653,35.1725 32.41406,85.14978 32.41406,85.14978 0,0 -74.70955,-42.77297 -92.57542,-41.41284 -2.93306,0.22328 0.89266,5.82436 2.76145,1.35303 -18.93514,7.56977 -40.63057,11.91824 -63.73076,11.91824 -73.52523,0 -133.15935,-43.61157 -133.15935,-97.38524 0,-53.77365 59.63412,-97.339926 133.15935,-97.339926 z" id="path3769-1" inkscape:connector-curvature="0" sodipodi:nodetypes="ssccscsss"/>
+ <path style="color:#000000;fill:none;stroke:#808080;stroke-width:8;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" d="m 173.1621,250.34923 c -64.02996,0 -115.926026,34.29807 -115.926026,76.63201 0,11.34353 3.750173,22.1008 10.438488,31.7873 -4.090007,27.68997 -28.228023,67.03517 -28.228023,67.03517 0,0 65.061361,-33.67353 80.619991,-32.60275 2.55427,0.17578 -0.77738,4.5853 -2.40483,1.06519 16.4898,5.95939 35.38343,9.38278 55.5004,9.38278 64.02999,0 115.96279,-34.33373 115.96279,-76.66769 0,-42.33394 -51.9328,-76.63201 -115.96279,-76.63201 z" id="path3769-1-7" inkscape:connector-curvature="0" sodipodi:nodetypes="ssccscsss"/>
+ </g>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/media/Toll_Texas_1.svg b/www/wiki/tests/phpunit/data/media/Toll_Texas_1.svg
new file mode 100644
index 00000000..73004e3e
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/Toll_Texas_1.svg
@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 12.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 51448) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
+ <!ENTITY ns_svg "http://www.w3.org/2000/svg">
+ <!ENTITY ns_xlink "http://www.w3.org/1999/xlink">
+]>
+<svg version="1.1" id="Layer_1" xmlns="&ns_svg;" xmlns:xlink="&ns_xlink;" width="385" height="385.0004883"
+ viewBox="0 0 385 385.0004883" overflow="visible" enable-background="new 0 0 385 385.0004883" xml:space="preserve">
+<g>
+ <g>
+ <g>
+ <path fill="#FFFFFF" d="M0.5,24.5c0-13.2548828,10.7451172-24,24-24h336c13.2548828,0,24,10.7451172,24,24v336.0004883
+ c0,13.2548828-10.7451172,24-24,24h-336c-13.2548828,0-24-10.7451172-24-24V24.5L0.5,24.5z"/>
+ <path fill="#FFFFFF" d="M192.5,192.5004883"/>
+ </g>
+ <g>
+ <path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="3.863693" d="M0.5,24.5
+ c0-13.2548828,10.7451172-24,24-24h336c13.2548828,0,24,10.7451172,24,24v336.0004883c0,13.2548828-10.7451172,24-24,24h-336
+ c-13.2548828,0-24-10.7451172-24-24V24.5L0.5,24.5z"/>
+ <path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="3.863693" d="
+ M192.5,192.5004883"/>
+ </g>
+ </g>
+ <g>
+ <path fill="#003882" d="M24.5,0.5h336c13.2548828,0,24,10.7451172,24,24v232.0004883H0.5V24.5
+ C0.5,11.2451172,11.2451172,0.5,24.5,0.5z"/>
+ </g>
+ <g>
+ <path fill="#FFFFFF" d="M10.5,24.5c0-7.7319336,6.2680664-14,14-14h336c7.7324219,0,14,6.2680664,14,14v222.0004883h-364V24.5z"/>
+ </g>
+ <g>
+ <polygon fill-rule="evenodd" clip-rule="evenodd" fill="#003882" points="93.809082,348.2397461 91.6787109,347.8666992
+ 89.5478516,347.7368164 85.2929688,347.7368164 83.1640625,347.8666992 78.9042969,348.6157227 76.7763672,349.1166992
+ 72.7666016,350.3706055 70.7631836,351.246582 68.8837891,352.121582 67.0053711,353.1254883 65.1254883,354.253418
+ 63.3740234,355.5063477 60.1210938,358.2602539 58.4926758,359.762207 57.1132813,361.2641602 55.7338867,362.8959961
+ 54.3603516,364.6459961 21.2949219,301.3999023 21.168457,301.3999023 22.5478516,299.6469727 23.9248047,298.0200195
+ 25.3022461,296.5180664 26.9296875,295.0141602 30.1875,292.2592773 31.9404297,291.0073242 33.8188477,289.8793945
+ 35.6972656,288.8774414 37.5761719,288.0004883 39.5791016,287.1245117 43.5878906,285.8706055 45.7148438,285.3706055
+ 49.9755859,284.6176758 52.1020508,284.4946289 56.3632813,284.4946289 58.4926758,284.6176758 60.6201172,284.9946289
+ 60.6201172,284.8696289 "/>
+ </g>
+ <g>
+ <polygon fill-rule="evenodd" clip-rule="evenodd" fill="#FFFFFF" points="32.8154297,319.559082 45.0898438,312.4233398
+ 42.2080078,298.5209961 52.7299805,308.0375977 65.1254883,300.9008789 59.2421875,313.9243164 69.8867188,323.4428711
+ 55.7338867,321.9389648 49.8476563,334.965332 46.9677734,321.0629883 "/>
+ </g>
+ <g>
+ <polygon fill-rule="evenodd" clip-rule="evenodd" fill="#B01C2E" points="132.0053711,306.6606445 148.5385742,338.2211914
+ 147.4101563,339.7241211 146.1577148,341.2270508 144.9052734,342.6020508 143.5302734,343.9848633 142.1494141,345.2358398
+ 140.6484375,346.4868164 139.2705078,347.6157227 137.765625,348.6157227 136.1381836,349.6176758 134.6357422,350.621582
+ 133.0083008,351.3696289 131.3798828,352.246582 129.625,352.8745117 128,353.5024414 126.2441406,354.0004883
+ 124.4921875,354.5043945 122.737793,354.8774414 120.9853516,355.1293945 117.4785156,355.3793945 113.9707031,355.3793945
+ 112.09375,355.2543945 110.3408203,355.003418 108.5854492,354.6274414 106.9589844,354.253418 103.4501953,353.2485352
+ 101.8232422,352.6254883 100.0688477,351.871582 98.4428711,351.1235352 96.9389648,350.1176758 95.3095703,349.2426758
+ 93.809082,348.2397461 93.809082,348.1147461 93.809082,348.2397461 77.1523438,316.4282227 77.2753906,316.4282227
+ 77.2753906,316.5551758 78.78125,317.5581055 80.4057617,318.4350586 81.9116211,319.4360352 83.5395508,320.1860352
+ 85.2929688,320.9399414 86.9208984,321.5649414 90.4257813,322.5668945 92.0551758,322.9418945 93.809082,323.3188477
+ 95.5620117,323.5688477 97.4423828,323.6948242 100.9462891,323.6948242 102.6992188,323.5688477 104.4521484,323.4428711
+ 106.2060547,323.1928711 107.9609375,322.8168945 111.4682617,321.8168945 113.0947266,321.1889648 114.8481445,320.5639648
+ 116.4765625,319.6879883 118.1035156,318.9350586 119.6083984,317.934082 121.2363281,316.9301758 122.737793,315.9282227
+ 124.1152344,314.8012695 125.6196289,313.5483398 126.9960938,312.2983398 128.3759766,310.9194336 129.625,309.5424805
+ 130.8789063,308.0375977 132.0053711,306.5366211 132.1328125,306.5366211 "/>
+ </g>
+ <g>
+
+ <polyline fill="none" stroke="#003882" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="3.863693" points="
+ 60.7441406,285.1196289 63,286.4956055 65.2529297,287.7485352 67.5068359,288.8774414 69.8867188,289.8793945
+ 72.3916016,290.6313477 74.8955078,291.3813477 77.4008789,291.7583008 80.0302734,292.1333008 82.5366211,292.2592773
+ 85.1655273,292.2592773 87.6708984,292.0083008 90.3022461,291.6313477 92.8066406,291.1313477 95.3095703,290.3793945
+ 97.6904297,289.5024414 100.0688477,288.5004883 102.3256836,287.2504883 104.578125,285.8706055 106.7070313,284.4946289
+ 108.7124023,282.8637695 110.5888672,281.1108398 112.3427734,279.2329102 114.0986328,277.2290039 115.5976563,275.1010742 "/>
+ </g>
+ <g>
+
+ <line fill="none" stroke="#003882" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="3.863693" x1="115.4746094" y1="275.1010742" x2="131.8793945" y2="306.2836914"/>
+ </g>
+ <g>
+ <polygon fill-rule="evenodd" clip-rule="evenodd" fill="#003882" points="215.1650391,349.1166992 213.1601563,348.2397461
+ 211.2832031,347.237793 207.7773438,344.7329102 206.1503906,343.2319336 204.6435547,341.7270508 203.2685547,340.0991211
+ 202.0166016,338.347168 200.8867188,336.5942383 199.8857422,334.590332 199.0097656,332.7114258 198.2578125,330.7075195
+ 197.6328125,328.5776367 197.1289063,326.5756836 196.7548828,324.4458008 196.6318359,322.3168945 196.6318359,320.0629883
+ 196.7548828,317.934082 197.0058594,315.8041992 197.3818359,313.6743164 198.6357422,309.6665039 199.5107422,307.6635742
+ 200.5126953,305.7827148 201.6386719,303.9067383 202.890625,302.152832 204.2685547,300.3989258 205.6464844,298.8969727
+ 207.2744141,297.3920898 208.9023438,296.0161133 210.6572266,294.887207 212.5361328,293.7602539 214.4130859,292.7602539
+ 216.4179688,291.8833008 218.5449219,291.0073242 220.6767578,290.2543945 222.9306641,289.6274414 227.4384766,288.8774414
+ 229.6933594,288.6254883 231.9482422,288.5004883 234.2011719,288.5004883 236.4541016,288.6254883 238.7119141,288.8774414
+ 240.9638672,289.253418 243.21875,289.7543945 245.3476563,290.3793945 247.6025391,291.1313477 249.6054688,292.0083008
+ 251.7363281,293.0083008 251.7363281,292.8852539 253.6132813,294.137207 255.4931641,295.5151367 257.2460938,297.0161133
+ 258.8740234,298.6459961 260.5019531,300.3989258 261.8808594,302.277832 263.1328125,304.1577148 264.2578125,306.2836914
+ 266.0117188,310.543457 266.6386719,312.7983398 267.390625,317.3051758 267.5136719,319.6879883 267.390625,321.9418945
+ 267.265625,324.3188477 266.2626953,328.8286133 265.5117188,330.9575195 264.6347656,333.2114258 263.6318359,335.2163086
+ 262.5058594,337.2192383 261.1298828,339.0981445 259.625,340.9770508 258.1240234,342.6020508 256.3701172,344.1079102
+ 254.4902344,345.6098633 252.6142578,346.8618164 250.609375,347.9926758 248.4785156,348.9926758 246.3496094,349.8696289
+ 244.2216797,350.621582 242.0927734,351.246582 239.8359375,351.7485352 237.5830078,352.121582 235.3291016,352.3754883
+ 232.9501953,352.4985352 230.6933594,352.4985352 228.4414063,352.3754883 226.1865234,352.121582 223.9296875,351.7485352
+ 221.6777344,351.246582 219.421875,350.746582 217.2949219,349.9946289 "/>
+ </g>
+ <g>
+ <polygon fill-rule="evenodd" clip-rule="evenodd" fill="#FFFFFF" points="219.9238281,303.6547852 221.9287109,301.5258789
+ 224.4335938,299.8999023 227.1904297,298.8969727 230.1943359,298.2700195 233.0742188,298.3959961 235.9550781,299.0219727
+ 238.7119141,300.2739258 241.0898438,301.9018555 243.0957031,304.1577148 244.5957031,306.5366211 245.9746094,308.9145508
+ 246.9765625,311.4204102 247.8535156,314.0512695 248.4785156,316.8051758 248.8535156,319.559082 248.9804688,322.3168945
+ 248.9804688,325.0727539 248.6035156,327.8256836 248.1035156,330.5805664 247.3496094,333.2114258 246.3496094,335.7172852
+ 244.9726563,337.8442383 243.34375,339.6000977 241.3408203,341.1020508 239.2109375,342.355957 236.8330078,343.2319336
+ 234.4511719,343.6049805 231.9482422,343.7319336 229.4414063,343.3579102 227.1904297,342.6020508 224.9326172,341.4770508
+ 222.9306641,340.0991211 221.1757813,338.347168 219.6748047,336.3422852 218.5449219,334.2114258 217.7949219,331.8334961
+ 217.0449219,329.7036133 216.0419922,325.4477539 215.7910156,323.1928711 215.6660156,320.9399414 215.6660156,318.684082
+ 215.9169922,316.4282227 216.1669922,314.300293 216.6679688,312.0454102 217.2949219,309.918457 218.0458984,307.7895508
+ 218.9208984,305.7827148 219.9238281,303.7817383 "/>
+ </g>
+ <g>
+ <polygon fill-rule="evenodd" clip-rule="evenodd" fill="#003882" points="150.4169922,290.0063477 196.3789063,289.7543945
+ 192.7470703,304.4067383 192.6220703,304.5307617 192.1210938,303.7817383 191.4960938,303.1567383 190.8681641,302.5288086
+ 190.1162109,302.027832 189.2421875,301.652832 188.4887695,301.2768555 187.6113281,301.0258789 186.7353516,300.7758789
+ 179.9741211,300.7758789 179.8481445,345.1098633 179.8481445,345.2358398 180.0966797,346.1118164 180.3486328,347.1157227
+ 180.7246094,347.9926758 181.1005859,348.7407227 181.6015625,349.6176758 182.8549805,351.1235352 183.6054688,351.7485352
+ 158.5556641,351.7485352 158.5556641,351.871582 159.3095703,351.246582 160.0595703,350.4956055 160.6845703,349.7426758
+ 161.1875,348.9926758 161.6879883,347.9926758 161.9384766,347.1157227 162.1894531,346.1118164 162.3144531,345.1098633
+ 162.4375,300.9008789 156.5527344,300.9008789 155.1767578,301.0258789 153.9248047,301.2768555 152.671875,301.5258789
+ 151.4204102,301.9018555 150.1660156,302.4008789 148.9135742,303.0297852 146.6601563,304.2797852 146.6601563,304.4067383 "/>
+ </g>
+ <g>
+ <polygon fill-rule="evenodd" clip-rule="evenodd" fill="#003882" points="275.7822266,344.3598633 276.03125,298.1450195
+ 275.90625,297.769043 275.90625,297.894043 275.7822266,296.7661133 275.5302734,295.7670898 275.15625,294.6391602
+ 274.7792969,293.637207 272.8984375,291.0073242 272.0234375,290.2543945 272.1484375,290.2543945 297.4492188,290.1313477
+ 296.4453125,290.8803711 295.5683594,291.6313477 294.6904297,292.5083008 294.0673828,293.5102539 293.5644531,294.6391602
+ 293.1904297,295.7670898 293.0644531,297.0161133 293.0644531,298.2700195 293.0644531,298.1450195 293.1904297,298.1450195
+ 293.0644531,340.7260742 292.9394531,340.7260742 294.8183594,341.3540039 296.8222656,341.8540039 298.7011719,342.1040039
+ 300.7050781,342.355957 302.7070313,342.4799805 304.5878906,342.355957 306.5917969,342.2299805 308.46875,341.8540039
+ 311.4746094,340.7260742 312.4765625,340.2231445 313.3535156,339.7241211 314.3535156,339.2241211 316.109375,337.972168
+ 311.8505859,351.621582 271.3984375,351.7485352 272.3994141,351.1235352 273.2753906,350.3706055 274.0292969,349.6176758
+ 275.2802734,347.6157227 275.53125,346.4868164 275.7822266,345.4858398 275.90625,344.2348633 "/>
+ </g>
+ <g>
+ <polygon fill-rule="evenodd" clip-rule="evenodd" fill="#003882" points="327.5058594,344.3598633 327.7539063,298.1450195
+ 327.6308594,297.769043 327.6308594,297.894043 327.5058594,296.7661133 327.2539063,295.7670898 326.8769531,294.6391602
+ 326.5029297,293.637207 324.6259766,291.0073242 323.7480469,290.2543945 323.8740234,290.2543945 349.171875,290.1313477
+ 348.1708984,290.8803711 347.2929688,291.6313477 346.4179688,292.5083008 345.7890625,293.5102539 345.2871094,294.6391602
+ 344.9121094,295.7670898 344.7890625,297.0161133 344.7890625,298.2700195 344.7890625,298.1450195 344.9121094,298.1450195
+ 344.7890625,340.7260742 344.6640625,340.7260742 346.5410156,341.3540039 348.5458984,341.8540039 350.4238281,342.1040039
+ 352.4277344,342.355957 354.4316406,342.4799805 356.3105469,342.355957 358.3144531,342.2299805 360.1933594,341.8540039
+ 361.1933594,341.4770508 363.1992188,340.7260742 364.2011719,340.2231445 365.078125,339.7241211 366.0820313,339.2241211
+ 367.8320313,337.972168 363.5751953,351.621582 323.1220703,351.7485352 324.125,351.1235352 325.0019531,350.3706055
+ 325.7519531,349.6176758 327.0058594,347.6157227 327.2539063,346.4868164 327.5058594,345.4858398 327.6308594,344.2348633 "/>
+ </g>
+</g>
+<path fill-rule="evenodd" clip-rule="evenodd" fill="#003882" d="M188.4228516,211.0395508V89.7011719h-23.2304688V66.4711914
+ c7.4140625-0.3291016,13.8393555-2.3886719,19.2763672-6.1782227c5.4365234-3.7890625,9.3085938-8.7314453,11.6152344-14.8276367
+ h23.7236328v165.5742188H188.4228516z"/>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/media/Tux.svg b/www/wiki/tests/phpunit/data/media/Tux.svg
new file mode 100644
index 00000000..e48b7353
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/Tux.svg
@@ -0,0 +1,902 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 349.46883 405.12272">
+ <title>Tux</title>
+ <desc>For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg</desc>
+ <radialGradient id="ag" gradientUnits="userSpaceOnUse" cy="-551.04" cx="274.822" gradientTransform="matrix(.5671 0 0 -.2835 81.263 201.645)" r="165.384">
+ <stop stop-opacity=".502" offset="0"/>
+ <stop stop-opacity="0" offset="1"/>
+ </radialGradient>
+ <path fill="url(#ag)" d="m330.892 357.885c0 25.898-41.989 46.893-93.785 46.893-51.795 0-93.784-20.994-93.784-46.893s41.989-46.893 93.784-46.893c51.795 0.001 93.785 20.995 93.785 46.893z"/>
+ <radialGradient id="ak" gradientUnits="userSpaceOnUse" cy="-551.042" cx="268.794" gradientTransform="matrix(.5823 0 0 -.2835 -61.6052 201.14)" r="165.383">
+ <stop stop-opacity=".502" offset="0"/>
+ <stop stop-opacity="0" offset="1"/>
+ </radialGradient>
+ <path fill="url(#ak)" d="m191.223 357.381c0 25.897-43.117 46.892-96.306 46.892-53.188 0-96.305-20.994-96.305-46.892s43.117-46.893 96.305-46.893c53.188 0.001 96.306 20.995 96.306 46.893z"/>
+ <g transform="translate(8.99996 9.00046)">
+ <path d="m292.327 256.606c-4.752 19.584-28.872 60.48-41.688 78.48-12.815 18.072-11.231 34.344-34.92 28.008-23.616-6.336-30.24-5.184-54.647-3.744-24.265 1.439-19.009-0.721-34.2 6.12-15.12 6.84-65.88-82.944-69.984-99.647-4.031-16.705-5.976-14.689 4.536-32.761 10.513-18.071 12.024-35.928 25.92-57.816 13.896-21.96 29.952-33.12 28.8-49.896-4.535-62.28-8.136-93.384 19.513-107.784 26.352-13.68 48.384-5.544 57.096-0.864 3.744 2.016 11.376 5.904 17.064 12.744 5.688 6.696 10.8 16.848 13.68 29.664 5.904 25.704-2.448 17.208 4.248 46.656 6.624 29.375 20.088 43.775 36.504 67.031 16.414 23.257 33.55 61.633 28.078 83.809z"/>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#666" d="m148.47 94.049c4.319-1.728 3.592-1.958 6.472-8.222 2.304-4.824 4.328-6.898 4.256-14.242 0-7.2-2.232-9.648-5.616-14.328-3.24-4.464-8.424-4.68-11.664-4.104-1.872 0.288-4.319 2.664-5.976 6.192-1.08 2.376-1.944 5.4-2.017 8.568-0.216 8.496 0.505 11.736 2.448 17.496 2.305 6.769 7.921 10.297 12.097 8.64z"/>
+ <path fill="#6d6d6d" d="m148.47 94.023c4.293-1.717 3.563-1.954 6.425-8.178 2.289-4.793 4.312-6.861 4.271-14.164 0.027-7.152-2.162-9.702-5.488-14.201-3.296-4.345-8.376-4.509-11.593-3.953-1.916 0.283-4.354 2.569-6.038 5.968-1.159 2.31-2.016 5.353-2.087 8.535-0.212 8.438 0.547 11.691 2.46 17.417 2.268 6.731 7.901 10.221 12.05 8.576z"/>
+ <path fill="#757575" d="m148.471 93.996c4.264-1.706 3.533-1.95 6.377-8.133 2.273-4.762 4.296-6.823 4.288-14.085 0.053-7.105-2.093-9.756-5.363-14.075-3.35-4.225-8.327-4.338-11.52-3.801-1.961 0.278-4.389 2.474-6.099 5.744-1.242 2.245-2.089 5.305-2.16 8.501-0.207 8.38 0.591 11.647 2.473 17.34 2.231 6.691 7.881 10.144 12.004 8.509z"/>
+ <path fill="#7c7c7c" d="m148.471 93.969c4.235-1.694 3.506-1.946 6.329-8.089 2.26-4.731 4.28-6.786 4.304-14.006 0.081-7.058-2.021-9.811-5.236-13.948-3.403-4.105-8.278-4.167-11.446-3.649-2.006 0.273-4.424 2.379-6.16 5.519-1.322 2.179-2.161 5.257-2.232 8.468-0.202 8.323 0.636 11.603 2.486 17.261 2.191 6.654 7.859 10.068 11.955 8.444z"/>
+ <path fill="#848484" d="m148.471 93.943c4.209-1.684 3.477-1.942 6.282-8.045 2.245-4.7 4.266-6.749 4.319-13.928 0.107-7.01-1.95-9.864-5.109-13.821-3.458-3.985-8.23-3.996-11.375-3.498-2.049 0.268-4.458 2.284-6.222 5.295-1.403 2.114-2.233 5.21-2.303 8.435-0.198 8.265 0.679 11.559 2.498 17.183 2.156 6.615 7.842 9.992 11.91 8.379z"/>
+ <path fill="#8c8c8c" d="m148.471 93.916c4.181-1.672 3.448-1.938 6.235-8 2.23-4.668 4.249-6.711 4.335-13.85 0.134-6.962-1.88-9.918-4.982-13.695-3.513-3.865-8.183-3.825-11.303-3.347-2.094 0.263-4.492 2.189-6.283 5.07-1.484 2.049-2.306 5.163-2.375 8.401-0.193 8.207 0.723 11.515 2.511 17.105 2.118 6.58 7.821 9.919 11.862 8.316z"/>
+ <path fill="#939393" d="m148.472 93.889c4.152-1.661 3.419-1.934 6.188-7.956 2.215-4.638 4.233-6.674 4.35-13.771 0.161-6.915-1.809-9.972-4.854-13.568-3.567-3.746-8.134-3.654-11.23-3.195-2.138 0.259-4.527 2.094-6.345 4.847-1.564 1.983-2.378 5.115-2.447 8.368-0.188 8.149 0.767 11.47 2.523 17.026 2.079 6.54 7.8 9.841 11.815 8.249z"/>
+ <path fill="#9b9b9b" d="m148.472 93.863c4.125-1.65 3.391-1.93 6.141-7.912 2.2-4.607 4.217-6.637 4.366-13.693 0.188-6.868-1.739-10.026-4.729-13.441-3.621-3.626-8.085-3.484-11.157-3.044-2.183 0.253-4.562 1.999-6.406 4.622-1.646 1.918-2.45 5.068-2.52 8.335-0.185 8.091 0.811 11.426 2.535 16.948 2.044 6.502 7.782 9.766 11.77 8.185z"/>
+ <path fill="#a3a3a3" d="m148.472 93.836c4.097-1.639 3.361-1.926 6.094-7.867 2.185-4.576 4.201-6.599 4.382-13.614 0.214-6.82-1.669-10.081-4.603-13.315-3.676-3.506-8.036-3.313-11.084-2.893-2.229 0.249-4.598 1.904-6.47 4.398-1.726 1.852-2.521 5.021-2.591 8.301-0.18 8.034 0.854 11.382 2.548 16.87 2.008 6.465 7.763 9.691 11.724 8.12z"/>
+ <path fill="#aaa" d="m148.472 93.809c4.069-1.628 3.334-1.922 6.047-7.823 2.17-4.544 4.185-6.562 4.396-13.536 0.242-6.772-1.597-10.134-4.475-13.188-3.73-3.387-7.989-3.142-11.013-2.741-2.271 0.243-4.632 1.809-6.53 4.173-1.808 1.787-2.594 4.974-2.662 8.268-0.176 7.976 0.897 11.337 2.56 16.792 1.97 6.427 7.743 9.615 11.677 8.055z"/>
+ <path fill="#b2b2b2" d="m148.473 93.782c4.041-1.617 3.304-1.918 5.999-7.778 2.154-4.514 4.169-6.524 4.412-13.458 0.269-6.725-1.526-10.188-4.349-13.062-3.784-3.267-7.939-2.971-10.939-2.589-2.316 0.238-4.666 1.714-6.592 3.949-1.888 1.721-2.667 4.926-2.734 8.234-0.171 7.918 0.941 11.293 2.572 16.713 1.933 6.391 7.723 9.541 11.631 7.991z"/>
+ <path fill="#bababa" d="m148.473 93.756c4.014-1.606 3.275-1.914 5.951-7.734 2.141-4.482 4.153-6.487 4.43-13.379 0.295-6.678-1.457-10.243-4.223-12.935-3.839-3.147-7.892-2.8-10.867-2.438-2.36 0.233-4.701 1.619-6.653 3.725-1.969 1.656-2.739 4.879-2.806 8.201-0.167 7.86 0.984 11.249 2.585 16.636 1.895 6.35 7.702 9.462 11.583 7.924z"/>
+ <path fill="#c1c1c1" d="m148.473 93.729c3.985-1.595 3.247-1.91 5.904-7.69 2.125-4.451 4.138-6.45 4.445-13.3 0.321-6.63-1.387-10.297-4.096-12.808-3.894-3.028-7.844-2.629-10.795-2.287-2.405 0.229-4.735 1.524-6.716 3.5-2.049 1.59-2.811 4.831-2.878 8.167-0.161 7.802 1.029 11.205 2.599 16.557 1.859 6.314 7.683 9.389 11.537 7.861z"/>
+ <path fill="#c9c9c9" d="m148.473 93.702c3.958-1.583 3.219-1.906 5.857-7.646 2.11-4.42 4.121-6.412 4.46-13.222 0.35-6.583-1.315-10.351-3.969-12.682-3.947-2.908-7.794-2.458-10.722-2.135-2.45 0.224-4.771 1.429-6.777 3.276-2.13 1.525-2.883 4.784-2.95 8.135-0.157 7.745 1.073 11.16 2.611 16.479 1.821 6.276 7.663 9.313 11.49 7.795z"/>
+ <path fill="#d1d1d1" d="m148.474 93.676c3.93-1.573 3.188-1.902 5.809-7.601 2.097-4.389 4.107-6.375 4.477-13.144 0.375-6.535-1.245-10.404-3.842-12.555-4.002-2.788-7.747-2.287-10.65-1.984-2.493 0.219-4.805 1.334-6.837 3.052-2.213 1.459-2.957 4.736-3.022 8.101-0.153 7.687 1.116 11.116 2.623 16.401 1.782 6.237 7.642 9.237 11.442 7.73z"/>
+ <path fill="#d8d8d8" d="m148.474 93.649c3.901-1.562 3.16-1.898 5.762-7.557 2.082-4.358 4.091-6.338 4.493-13.065 0.401-6.487-1.176-10.458-3.716-12.428-4.057-2.668-7.698-2.116-10.578-1.832-2.538 0.214-4.839 1.239-6.899 2.827-2.292 1.394-3.029 4.689-3.094 8.068-0.148 7.629 1.16 11.072 2.636 16.322 1.746 6.2 7.623 9.161 11.396 7.665z"/>
+ <path fill="#e0e0e0" d="m148.474 93.622c3.875-1.55 3.132-1.894 5.715-7.512 2.066-4.327 4.075-6.3 4.508-12.987 0.429-6.44-1.104-10.513-3.588-12.302-4.111-2.549-7.65-1.945-10.506-1.681-2.582 0.209-4.874 1.144-6.961 2.604-2.373 1.328-3.102 4.642-3.165 8.034-0.145 7.571 1.204 11.027 2.647 16.244 1.709 6.162 7.604 9.086 11.35 7.6z"/>
+ <path fill="#e8e8e8" d="m148.474 93.596c3.847-1.54 3.104-1.89 5.668-7.468 2.052-4.296 4.059-6.263 4.523-12.908 0.456-6.393-1.034-10.567-3.462-12.175-4.165-2.429-7.601-1.774-10.433-1.529-2.627 0.204-4.908 1.049-7.023 2.379-2.453 1.263-3.173 4.594-3.236 8.001-0.141 7.514 1.247 10.983 2.659 16.166 1.673 6.123 7.585 9.008 11.304 7.534z"/>
+ <path fill="#efefef" d="m148.475 93.569c3.817-1.528 3.073-1.886 5.62-7.424 2.036-4.265 4.043-6.226 4.539-12.83 0.482-6.345-0.964-10.621-3.336-12.048-4.219-2.31-7.552-1.604-10.359-1.378-2.672 0.199-4.943 0.954-7.084 2.155-2.535 1.197-3.246 4.546-3.311 7.967-0.135 7.456 1.292 10.939 2.673 16.087 1.636 6.087 7.565 8.935 11.258 7.471z"/>
+ <path fill="#f7f7f7" d="m148.475 93.542c3.791-1.517 3.046-1.882 5.572-7.379 2.022-4.234 4.027-6.188 4.556-12.751 0.51-6.297-0.894-10.675-3.208-11.921-4.274-2.19-7.505-1.433-10.289-1.227-2.715 0.194-4.978 0.859-7.146 1.93-2.614 1.132-3.317 4.5-3.381 7.935-0.131 7.398 1.335 10.895 2.686 16.009 1.597 6.047 7.544 8.858 11.21 7.404z"/>
+ <path fill="#fff" d="m148.475 93.516c3.763-1.506 3.017-1.878 5.525-7.335 2.007-4.203 4.012-6.151 4.571-12.673 0.536-6.25-0.823-10.729-3.082-11.795-4.328-2.07-7.456-1.262-10.216-1.075-2.76 0.189-5.012 0.764-7.207 1.706-2.696 1.066-3.39 4.452-3.453 7.901-0.126 7.34 1.379 10.85 2.698 15.931 1.561 6.01 7.525 8.782 11.164 7.34z"/>
+ </g>
+ <path d="m132.033 74.7465c2.16 0 4.896 1.44 6.191 3.384 1.368 1.944 2.376 4.68 2.376 7.776 0 4.608-0.504 9.72-3.239 11.304-0.864 0.504-2.736 0.936-3.816 0.936-2.448 0-2.664-1.584-4.968-3.96-0.792-0.864-3.168-5.04-3.168-8.496 0-2.16-0.504-5.256 1.368-7.992 1.296-2.016 2.952-2.952 5.256-2.952z"/>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m143.862 68.608c0.844-1.305 4.222-0.69 5.45 1.996 1.229 2.687 0.998 8.522 0.153 8.829-2.226 0.691-1.535-2.534-3.454-5.451-1.919-2.762-2.994-4.067-2.149-5.374z"/>
+ <path fill="#070707" d="m143.916 68.664c0.833-1.289 4.169-0.681 5.381 1.971 1.215 2.653 0.985 8.414 0.152 8.717-2.198 0.682-1.516-2.502-3.411-5.382-1.895-2.728-2.956-4.017-2.122-5.306z"/>
+ <path fill="#0f0f0f" d="m143.97 68.719c0.822-1.272 4.114-0.673 5.312 1.945 1.198 2.619 0.973 8.306 0.15 8.605-2.169 0.673-1.497-2.47-3.367-5.313-1.871-2.692-2.918-3.964-2.095-5.237z"/>
+ <path fill="#161616" d="m144.024 68.774c0.812-1.255 4.062-0.664 5.243 1.92 1.182 2.585 0.96 8.198 0.147 8.493-2.141 0.665-1.477-2.438-3.323-5.244-1.846-2.657-2.88-3.913-2.067-5.169z"/>
+ <path fill="#1e1e1e" d="m144.078 68.829c0.801-1.239 4.008-0.655 5.174 1.895 1.167 2.551 0.947 8.09 0.146 8.381-2.113 0.656-1.458-2.405-3.28-5.174-1.821-2.623-2.842-3.863-2.04-5.102z"/>
+ <path fill="#262626" d="m144.132 68.884c0.791-1.222 3.955-0.646 5.105 1.87 1.151 2.517 0.935 7.982 0.144 8.27-2.085 0.647-1.438-2.374-3.235-5.105-1.798-2.589-2.805-3.812-2.014-5.035z"/>
+ <path fill="#2d2d2d" d="m144.186 68.939c0.779-1.206 3.9-0.638 5.036 1.844 1.135 2.483 0.922 7.874 0.142 8.158-2.057 0.639-1.419-2.341-3.192-5.037-1.773-2.552-2.766-3.758-1.986-4.965z"/>
+ <path fill="#353535" d="m144.24 68.994c0.769-1.189 3.848-0.629 4.967 1.819 1.12 2.449 0.909 7.766 0.141 8.046-2.028 0.629-1.399-2.31-3.148-4.967-1.75-2.518-2.73-3.708-1.96-4.898z"/>
+ <path fill="#3d3d3d" d="m144.294 69.049c0.76-1.172 3.794-0.621 4.898 1.793 1.104 2.415 0.896 7.658 0.138 7.934-2 0.621-1.38-2.277-3.104-4.898-1.725-2.482-2.691-3.655-1.932-4.829z"/>
+ <path fill="#444" d="m144.348 69.104c0.748-1.156 3.74-0.612 4.829 1.768 1.088 2.38 0.884 7.55 0.136 7.822-1.973 0.612-1.36-2.245-3.062-4.829-1.699-2.448-2.651-3.604-1.903-4.761z"/>
+ <path fill="#4c4c4c" d="m144.402 69.16c0.737-1.14 3.687-0.603 4.76 1.743 1.073 2.347 0.871 7.442 0.134 7.71-1.943 0.604-1.341-2.213-3.017-4.76-1.676-2.414-2.614-3.554-1.877-4.693z"/>
+ <path fill="#545454" d="m144.456 69.215c0.727-1.123 3.634-0.595 4.691 1.717 1.057 2.313 0.857 7.334 0.132 7.598-1.916 0.595-1.321-2.181-2.973-4.691-1.652-2.378-2.577-3.501-1.85-4.624z"/>
+ <path fill="#5b5b5b" d="m144.51 69.27c0.717-1.106 3.58-0.585 4.622 1.692 1.041 2.278 0.847 7.226 0.131 7.486-1.888 0.586-1.303-2.149-2.93-4.622-1.628-2.343-2.539-3.45-1.823-4.556z"/>
+ <path fill="#636363" d="m144.564 69.325c0.705-1.09 3.526-0.577 4.553 1.667 1.026 2.245 0.833 7.118 0.128 7.375-1.858 0.577-1.282-2.117-2.885-4.553-1.604-2.309-2.501-3.399-1.796-4.489z"/>
+ <path fill="#6b6b6b" d="m144.618 69.38c0.694-1.073 3.473-0.568 4.483 1.642 1.011 2.21 0.82 7.01 0.127 7.263-1.831 0.568-1.264-2.084-2.842-4.484-1.578-2.274-2.462-3.347-1.768-4.421z"/>
+ <path fill="#727272" d="m144.672 69.435c0.685-1.057 3.42-0.56 4.414 1.617 0.995 2.176 0.81 6.902 0.125 7.15-1.803 0.56-1.243-2.053-2.798-4.415-1.554-2.238-2.425-3.295-1.741-4.352z"/>
+ <path fill="#7a7a7a" d="m144.726 69.49c0.673-1.041 3.365-0.551 4.345 1.591 0.979 2.143 0.796 6.794 0.123 7.039-1.775 0.551-1.224-2.021-2.754-4.346-1.53-2.203-2.387-3.244-1.714-4.284z"/>
+ <path fill="#828282" d="m144.78 69.545c0.662-1.023 3.313-0.542 4.276 1.566 0.964 2.108 0.782 6.686 0.121 6.926-1.746 0.542-1.204-1.988-2.711-4.276-1.505-2.167-2.348-3.192-1.686-4.216z"/>
+ <path fill="#898989" d="m144.834 69.6c0.652-1.007 3.259-0.533 4.207 1.541s0.771 6.578 0.119 6.815c-1.718 0.534-1.185-1.956-2.666-4.207-1.482-2.134-2.311-3.142-1.66-4.149z"/>
+ <path fill="#919191" d="m144.888 69.655c0.641-0.99 3.206-0.524 4.138 1.516 0.933 2.04 0.758 6.47 0.117 6.703-1.69 0.525-1.165-1.924-2.623-4.138-1.457-2.098-2.273-3.09-1.632-4.081z"/>
+ <path fill="#999" d="m144.942 69.71c0.63-0.974 3.152-0.516 4.069 1.49s0.744 6.362 0.114 6.591c-1.662 0.516-1.146-1.892-2.579-4.069-1.432-2.062-2.234-3.037-1.604-4.012z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#666" d="m193.11 94.985c10.8-1.152 14.616-5.328 16.56-12.6 1.729-6.48 1.801-13.68-3.023-22.104-4.536-8.063-7.128-9.36-13.681-9.864-10.079-0.864-14.832 6.192-17.063 11.232-2.376 5.472-1.872 4.68-1.729 11.592 0.145 7.272 4.245 9.299 6.766 13.835 2.519 4.465 10.946 7.982 12.17 7.909z"/>
+ <path fill="#6d6d6d" d="m193.115 94.944c10.759-1.131 14.618-5.354 16.515-12.569 1.701-6.525 1.785-13.686-3.002-21.912-4.434-7.797-7.038-9.081-13.512-9.581-10.049-0.861-14.941 5.873-17.181 10.874-2.304 5.28-1.878 4.718-1.726 11.539 0.16 7.268 4.268 9.223 6.784 13.76 2.521 4.475 10.898 7.962 12.122 7.889z"/>
+ <path fill="#757575" d="m193.12 94.902c10.718-1.11 14.62-5.379 16.469-12.538 1.676-6.57 1.771-13.692-2.979-21.721-4.331-7.53-6.947-8.801-13.344-9.297-10.018-0.858-15.05 5.553-17.298 10.516-2.229 5.087-1.885 4.757-1.722 11.487 0.176 7.264 4.289 9.146 6.803 13.686 2.52 4.485 10.848 7.942 12.071 7.867z"/>
+ <path fill="#7c7c7c" d="m193.126 94.861c10.675-1.09 14.621-5.405 16.423-12.507 1.648-6.616 1.756-13.698-2.958-21.529-4.229-7.263-6.856-8.522-13.176-9.014-9.985-0.854-15.158 5.234-17.414 10.158-2.156 4.895-1.891 4.795-1.719 11.434 0.193 7.26 4.31 9.07 6.822 13.611 2.52 4.495 10.798 7.922 12.022 7.847z"/>
+ <path fill="#848484" d="m193.131 94.82c10.635-1.069 14.623-5.431 16.377-12.476 1.622-6.661 1.741-13.704-2.936-21.337-4.126-6.996-6.767-8.242-13.008-8.73-9.955-0.852-15.267 4.915-17.53 9.8-2.084 4.703-1.896 4.833-1.716 11.38 0.209 7.256 4.332 8.995 6.841 13.537 2.52 4.505 10.748 7.902 11.972 7.826z"/>
+ <path fill="#8c8c8c" d="m193.136 94.778c10.593-1.048 14.625-5.457 16.331-12.445 1.596-6.706 1.726-13.709-2.913-21.145-4.025-6.729-6.678-7.963-12.841-8.447-9.924-0.848-15.375 4.595-17.647 9.441-2.01 4.51-1.903 4.872-1.712 11.328 0.225 7.251 4.354 8.918 6.858 13.462 2.521 4.517 10.7 7.883 11.924 7.806z"/>
+ <path fill="#939393" d="m193.141 94.737c10.552-1.027 14.627-5.482 16.286-12.414 1.568-6.751 1.711-13.715-2.893-20.954-3.922-6.462-6.586-7.683-12.672-8.163-9.892-0.845-15.483 4.276-17.764 9.083-1.938 4.318-1.909 4.91-1.709 11.275 0.24 7.247 4.375 8.842 6.878 13.387 2.521 4.528 10.651 7.863 11.874 7.786z"/>
+ <path fill="#9b9b9b" d="m193.146 94.695c10.51-1.007 14.63-5.508 16.241-12.382 1.542-6.796 1.694-13.721-2.87-20.762-3.82-6.195-6.496-7.404-12.504-7.879-9.861-0.842-15.592 3.956-17.882 8.725-1.863 4.126-1.915 4.949-1.706 11.223 0.258 7.243 4.397 8.766 6.897 13.313 2.521 4.535 10.601 7.841 11.824 7.762z"/>
+ <path fill="#a3a3a3" d="m193.151 94.654c10.469-0.986 14.632-5.534 16.196-12.351 1.515-6.842 1.68-13.727-2.85-20.57-3.717-5.928-6.405-7.125-12.335-7.596-9.83-0.839-15.7 3.637-17.998 8.367-1.791 3.933-1.922 4.987-1.703 11.169 0.273 7.239 4.419 8.689 6.916 13.238 2.521 4.547 10.551 7.822 11.774 7.743z"/>
+ <path fill="#aaa" d="m193.157 94.612c10.427-0.965 14.633-5.56 16.149-12.32 1.488-6.887 1.666-13.733-2.826-20.379-3.615-5.661-6.316-6.845-12.168-7.313-9.799-0.835-15.809 3.317-18.114 8.009-1.718 3.741-1.928 5.025-1.7 11.117 0.29 7.235 4.44 8.613 6.936 13.163 2.519 4.558 10.499 7.804 11.723 7.723z"/>
+ <path fill="#b2b2b2" d="m193.162 94.571c10.386-0.944 14.635-5.585 16.104-12.289 1.462-6.932 1.649-13.739-2.806-20.188-3.512-5.394-6.225-6.565-11.999-7.029-9.768-0.833-15.917 2.998-18.23 7.651-1.646 3.549-1.935 5.064-1.697 11.064 0.306 7.231 4.462 8.537 6.954 13.088 2.52 4.569 10.451 7.784 11.674 7.703z"/>
+ <path fill="#bababa" d="m193.167 94.529c10.345-0.923 14.638-5.611 16.059-12.258 1.436-6.977 1.636-13.744-2.782-19.995-3.41-5.127-6.135-6.286-11.832-6.746-9.736-0.829-16.025 2.679-18.347 7.293-1.572 3.356-1.941 5.103-1.694 11.011 0.322 7.227 4.484 8.461 6.973 13.014 2.519 4.579 10.4 7.764 11.623 7.681z"/>
+ <path fill="#c1c1c1" d="m193.172 94.488c10.304-0.903 14.64-5.637 16.014-12.227 1.409-7.022 1.62-13.75-2.762-19.804-3.308-4.86-6.044-6.006-11.662-6.462-9.705-0.826-16.135 2.359-18.466 6.935-1.498 3.164-1.945 5.141-1.689 10.958 0.338 7.223 4.506 8.385 6.991 12.939 2.519 4.59 10.351 7.744 11.574 7.661z"/>
+ <path fill="#c9c9c9" d="m193.177 94.447c10.262-0.882 14.641-5.663 15.967-12.196 1.383-7.068 1.605-13.756-2.738-19.612-3.206-4.593-5.954-5.727-11.496-6.179-9.673-0.823-16.242 2.04-18.581 6.577-1.425 2.972-1.952 5.179-1.687 10.906 0.354 7.219 4.526 8.308 7.01 12.865 2.52 4.598 10.302 7.723 11.525 7.639z"/>
+ <path fill="#d1d1d1" d="m193.182 94.405c10.221-0.861 14.643-5.688 15.922-12.165 1.355-7.113 1.591-13.762-2.717-19.42-3.104-4.326-5.864-5.448-11.327-5.895-9.644-0.82-16.352 1.721-18.698 6.219-1.353 2.779-1.959 5.217-1.684 10.853 0.369 7.214 4.549 8.232 7.028 12.79 2.521 4.609 10.254 7.703 11.476 7.618z"/>
+ <path fill="#d8d8d8" d="m193.187 94.364c10.179-0.841 14.645-5.714 15.876-12.133 1.33-7.158 1.576-13.768-2.694-19.229-3.001-4.059-5.773-5.168-11.16-5.612-9.61-0.817-16.459 1.401-18.813 5.861-1.279 2.586-1.965 5.256-1.682 10.8 0.387 7.21 4.571 8.156 7.049 12.715 2.519 4.619 10.202 7.684 11.424 7.598z"/>
+ <path fill="#e0e0e0" d="m193.193 94.322c10.137-0.82 14.646-5.74 15.83-12.103 1.303-7.203 1.561-13.773-2.673-19.037-2.898-3.792-5.684-4.889-10.991-5.328-9.58-0.813-16.568 1.082-18.931 5.502-1.206 2.395-1.972 5.294-1.679 10.747 0.403 7.207 4.592 8.08 7.067 12.641 2.521 4.631 10.154 7.666 11.377 7.578z"/>
+ <path fill="#e8e8e8" d="m193.198 94.281c10.096-0.799 14.648-5.766 15.785-12.071 1.275-7.249 1.545-13.779-2.651-18.845-2.796-3.525-5.593-4.609-10.823-5.044-9.549-0.81-16.677 0.762-19.048 5.145-1.133 2.202-1.978 5.333-1.675 10.694 0.419 7.202 4.614 8.003 7.086 12.566 2.52 4.638 10.103 7.643 11.326 7.555z"/>
+ <path fill="#efefef" d="m193.203 94.239c10.055-0.778 14.65-5.792 15.739-12.04 1.25-7.293 1.531-13.785-2.629-18.653-2.694-3.258-5.502-4.33-10.655-4.761-9.517-0.807-16.785 0.443-19.165 4.786-1.059 2.01-1.983 5.372-1.671 10.642 0.435 7.198 4.636 7.928 7.104 12.492 2.52 4.649 10.055 7.624 11.277 7.534z"/>
+ <path fill="#f7f7f7" d="m193.208 94.198c10.014-0.757 14.652-5.817 15.694-12.009 1.223-7.339 1.516-13.792-2.607-18.462-2.592-2.991-5.413-4.05-10.486-4.478-9.487-0.804-16.895 0.124-19.282 4.428-0.986 1.817-1.989 5.41-1.668 10.589 0.451 7.194 4.657 7.851 7.123 12.417 2.519 4.661 10.004 7.605 11.226 7.515z"/>
+ <path fill="#fff" d="m193.213 94.156c9.973-0.737 14.654-5.843 15.648-11.978 1.197-7.384 1.501-13.797-2.585-18.27-2.489-2.724-5.322-3.771-10.319-4.194-9.455-0.801-17.002-0.196-19.397 4.07-0.913 1.625-1.996 5.448-1.665 10.536 0.467 7.19 4.679 7.775 7.142 12.342 2.519 4.671 9.954 7.586 11.176 7.494z"/>
+ </g>
+ <path d="m179.841 74.4585c5.4 0 8.568 4.824 9.648 11.016 0.432 2.808-0.216 6.048-1.944 8.28-1.944 2.592-5.4 4.176-8.208 4.176-2.664 0-5.688 0.432-7.271-1.728-1.584-2.232-1.944-7.2-1.944-10.728 0-3.96 1.152-6.768 3.168-9 1.511-1.657 4.247-2.016 6.551-2.016z"/>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m192.591 66.68c0.98-0.653 2.612 0 4.489 2.122 2.039 2.285 2.938 4.08 0.489 5.385-1.877 0.98-2.448-1.958-3.59-3.182-1.795-1.959-3.346-3.02-1.388-4.325z"/>
+ <path fill="#070707" d="m192.631 66.738c0.96-0.649 2.573 0 4.423 2.09 2.009 2.251 2.864 4.02 0.481 5.305-1.837 0.977-2.403-1.929-3.525-3.135-1.768-1.925-3.296-2.965-1.379-4.26z"/>
+ <path fill="#0f0f0f" d="m192.671 66.797c0.939-0.645 2.534 0 4.356 2.059 1.978 2.217 2.792 3.958 0.474 5.225-1.798 0.974-2.357-1.9-3.46-3.087-1.742-1.895-3.247-2.913-1.37-4.197z"/>
+ <path fill="#161616" d="m192.711 66.855c0.919-0.641 2.495 0 4.289 2.027 1.948 2.184 2.721 3.898 0.467 5.146-1.759 0.971-2.313-1.871-3.396-3.041-1.715-1.861-3.197-2.858-1.36-4.132z"/>
+ <path fill="#1e1e1e" d="m192.751 66.914c0.899-0.637 2.457 0 4.223 1.996 1.918 2.149 2.647 3.838 0.46 5.065-1.72 0.968-2.269-1.842-3.331-2.993-1.689-1.83-3.148-2.805-1.352-4.068z"/>
+ <path fill="#262626" d="m192.791 66.973c0.878-0.633 2.418 0 4.155 1.964 1.888 2.116 2.576 3.777 0.453 4.986-1.68 0.965-2.224-1.813-3.267-2.946-1.661-1.798-3.097-2.752-1.341-4.004z"/>
+ <path fill="#2d2d2d" d="m192.831 67.031c0.858-0.629 2.379 0 4.089 1.933 1.857 2.082 2.503 3.717 0.445 4.906-1.641 0.961-2.178-1.784-3.201-2.898-1.636-1.767-3.048-2.7-1.333-3.941z"/>
+ <path fill="#353535" d="m192.87 67.09c0.838-0.625 2.341 0 4.023 1.902 1.827 2.047 2.431 3.656 0.438 4.826-1.601 0.958-2.133-1.755-3.137-2.852-1.608-1.735-2.998-2.646-1.324-3.876z"/>
+ <path fill="#3d3d3d" d="m192.91 67.148c0.818-0.621 2.302 0 3.956 1.87 1.797 2.014 2.359 3.596 0.431 4.746-1.562 0.956-2.088-1.726-3.071-2.804-1.583-1.702-2.95-2.592-1.316-3.812z"/>
+ <path fill="#444" d="m192.95 67.207c0.798-0.617 2.263 0 3.889 1.839 1.768 1.98 2.287 3.535 0.425 4.666-1.523 0.952-2.043-1.697-3.008-2.757-1.556-1.671-2.899-2.539-1.306-3.748z"/>
+ <path fill="#4c4c4c" d="m192.99 67.266c0.777-0.614 2.224 0 3.823 1.807 1.735 1.946 2.214 3.474 0.416 4.586-1.483 0.949-1.998-1.667-2.942-2.709-1.529-1.639-2.85-2.486-1.297-3.684z"/>
+ <path fill="#545454" d="m193.03 67.325c0.757-0.61 2.185 0 3.756 1.775 1.706 1.912 2.143 3.414 0.409 4.506-1.444 0.946-1.953-1.639-2.878-2.663-1.502-1.606-2.799-2.431-1.287-3.618z"/>
+ <path fill="#5b5b5b" d="m193.07 67.383c0.736-0.605 2.146 0 3.688 1.744 1.677 1.878 2.07 3.353 0.402 4.426-1.405 0.943-1.908-1.609-2.813-2.615-1.475-1.575-2.749-2.378-1.277-3.555z"/>
+ <path fill="#636363" d="m193.11 67.442c0.716-0.602 2.106 0 3.622 1.712 1.646 1.844 1.998 3.293 0.395 4.347-1.364 0.94-1.862-1.581-2.748-2.568-1.449-1.543-2.701-2.326-1.269-3.491z"/>
+ <path fill="#6b6b6b" d="m193.15 67.5c0.696-0.598 2.069 0 3.556 1.681 1.615 1.811 1.925 3.232 0.387 4.267-1.325 0.937-1.818-1.552-2.683-2.521-1.423-1.511-2.651-2.272-1.26-3.427z"/>
+ <path fill="#727272" d="m193.19 67.559c0.675-0.594 2.03 0 3.489 1.649 1.585 1.777 1.853 3.172 0.38 4.187-1.287 0.935-1.774-1.522-2.619-2.473-1.396-1.48-2.601-2.219-1.25-3.363z"/>
+ <path fill="#7a7a7a" d="m193.23 67.618c0.654-0.59 1.991 0 3.422 1.618 1.555 1.743 1.781 3.111 0.373 4.107-1.247 0.931-1.729-1.494-2.554-2.426-1.369-1.448-2.551-2.166-1.241-3.299z"/>
+ <path fill="#828282" d="m193.269 67.677c0.635-0.586 1.953 0 3.355 1.586 1.525 1.708 1.709 3.05 0.366 4.026-1.208 0.928-1.684-1.464-2.489-2.378-1.342-1.416-2.501-2.112-1.232-3.234z"/>
+ <path fill="#898989" d="m193.309 67.735c0.614-0.582 1.914 0 3.29 1.555 1.493 1.675 1.636 2.99 0.357 3.947-1.169 0.925-1.639-1.435-2.424-2.332-1.316-1.384-2.452-2.058-1.223-3.17z"/>
+ <path fill="#919191" d="m193.349 67.794c0.595-0.578 1.875 0 3.223 1.523 1.464 1.641 1.564 2.93 0.351 3.867-1.129 0.922-1.594-1.406-2.359-2.284-1.29-1.352-2.403-2.005-1.215-3.106z"/>
+ <path fill="#999" d="m193.389 67.853c0.573-0.574 1.836 0 3.155 1.492 1.435 1.607 1.492 2.869 0.345 3.787-1.091 0.919-1.55-1.377-2.295-2.237-1.263-1.32-2.353-1.953-1.205-3.042z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m165.498 69.906c1.693-0.654 3.012-0.69 5.63 1.036 3.166 2.088 1.705 5.245-0.779 4.601-2.146-0.556-2.417-0.681-4.391-1.086-3.101-0.648-3.641-3.322-0.46-4.551z"/>
+ <path fill="#050505" d="m165.564 70.033c1.658-0.629 2.973-0.656 5.555 1.026 3.066 2.009 1.654 5.012-0.805 4.38-2.131-0.547-2.345-0.656-4.284-1.052-3.055-0.634-3.587-3.173-0.466-4.354z"/>
+ <path fill="#0a0a0a" d="m165.63 70.16c1.623-0.604 2.935-0.622 5.481 1.015 2.965 1.93 1.602 4.779-0.83 4.159-2.119-0.539-2.274-0.63-4.179-1.018-3.009-0.618-3.533-3.022-0.472-4.156z"/>
+ <path fill="#0f0f0f" d="m165.696 70.287c1.587-0.579 2.895-0.587 5.406 1.005 2.864 1.851 1.551 4.546-0.855 3.938-2.105-0.53-2.203-0.605-4.073-0.983-2.963-0.604-3.48-2.873-0.478-3.96z"/>
+ <path fill="#141414" d="m165.761 70.413c1.553-0.553 2.856-0.553 5.331 0.995 2.766 1.772 1.5 4.313-0.88 3.717-2.092-0.521-2.131-0.58-3.967-0.949-2.916-0.588-3.425-2.723-0.484-3.763z"/>
+ <path fill="#191919" d="m165.827 70.54c1.519-0.528 2.818-0.519 5.258 0.984 2.664 1.693 1.448 4.079-0.905 3.497-2.079-0.513-2.06-0.554-3.861-0.915-2.873-0.573-3.373-2.573-0.492-3.566z"/>
+ <path fill="#1e1e1e" d="m165.893 70.667c1.482-0.503 2.778-0.484 5.183 0.974 2.564 1.614 1.397 3.846-0.93 3.276-2.067-0.504-1.989-0.529-3.756-0.88-2.826-0.559-3.319-2.425-0.497-3.37z"/>
+ <path fill="#232323" d="m165.959 70.793c1.447-0.478 2.74-0.45 5.108 0.964 2.464 1.535 1.345 3.613-0.955 3.055-2.053-0.496-1.917-0.503-3.651-0.846-2.779-0.543-3.264-2.274-0.502-3.173z"/>
+ <path fill="#282828" d="m166.025 70.92c1.412-0.453 2.701-0.416 5.034 0.954 2.362 1.456 1.293 3.38-0.981 2.834-2.04-0.487-1.845-0.478-3.545-0.812-2.733-0.528-3.21-2.125-0.508-2.976z"/>
+ <path fill="#2d2d2d" d="m166.09 71.047c1.378-0.428 2.663-0.382 4.96 0.943 2.264 1.377 1.242 3.146-1.006 2.613-2.026-0.478-1.773-0.453-3.438-0.777-2.688-0.513-3.158-1.974-0.516-2.779z"/>
+ <path fill="#333" d="m166.156 71.173c1.343-0.402 2.624-0.347 4.885 0.933 2.163 1.298 1.191 2.914-1.029 2.392-2.015-0.47-1.703-0.428-3.334-0.743-2.642-0.498-3.104-1.824-0.522-2.582z"/>
+ <path fill="#383838" d="m166.222 71.3c1.307-0.377 2.585-0.313 4.81 0.922 2.063 1.219 1.14 2.681-1.055 2.171-2.001-0.461-1.631-0.402-3.229-0.708-2.594-0.483-3.048-1.674-0.526-2.385z"/>
+ <path fill="#3d3d3d" d="m166.288 71.427c1.272-0.352 2.546-0.279 4.736 0.913 1.962 1.14 1.088 2.447-1.081 1.95-1.988-0.452-1.56-0.377-3.122-0.674-2.55-0.469-2.995-1.526-0.533-2.189z"/>
+ <path fill="#424242" d="m166.354 71.554c1.236-0.327 2.507-0.245 4.661 0.902 1.861 1.061 1.037 2.214-1.106 1.729-1.974-0.444-1.488-0.352-3.016-0.64-2.504-0.453-2.942-1.375-0.539-1.991z"/>
+ <path fill="#474747" d="m166.419 71.68c1.203-0.302 2.469-0.21 4.587 0.892 1.762 0.982 0.986 1.98-1.13 1.508-1.962-0.435-1.417-0.326-2.911-0.606-2.458-0.437-2.888-1.224-0.546-1.794z"/>
+ <path fill="#4c4c4c" d="m166.485 71.807c1.167-0.276 2.429-0.176 4.513 0.882 1.66 0.903 0.935 1.748-1.156 1.288-1.948-0.426-1.345-0.301-2.805-0.572-2.412-0.423-2.834-1.076-0.552-1.598z"/>
+ <path fill="#515151" d="m166.551 71.934c1.133-0.251 2.391-0.142 4.438 0.871 1.56 0.824 0.883 1.515-1.181 1.067-1.936-0.417-1.274-0.275-2.699-0.537-2.366-0.408-2.781-0.926-0.558-1.401z"/>
+ <path fill="#565656" d="m166.617 72.061c1.097-0.227 2.351-0.108 4.363 0.861 1.46 0.745 0.831 1.281-1.206 0.846-1.922-0.409-1.202-0.25-2.594-0.503-2.319-0.393-2.726-0.777-0.563-1.204z"/>
+ <path fill="#5b5b5b" d="m166.683 72.187c1.062-0.201 2.312-0.073 4.289 0.851 1.358 0.666 0.778 1.048-1.231 0.625-1.91-0.4-1.131-0.225-2.489-0.469-2.274-0.377-2.672-0.626-0.569-1.007z"/>
+ <path fill="#606060" d="m166.748 72.314c1.027-0.176 2.274-0.04 4.215 0.84 1.26 0.587 0.729 0.815-1.256 0.404-1.896-0.392-1.06-0.2-2.383-0.435-2.228-0.361-2.619-0.475-0.576-0.809z"/>
+ <path fill="#666" d="m166.814 72.44c0.992-0.151 2.234-0.005 4.14 0.83 1.159 0.508 0.677 0.582-1.281 0.183-1.883-0.383-0.987-0.174-2.276-0.4-2.183-0.346-2.566-0.325-0.583-0.613z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#666" d="m159.99 128.249c-9.36 0.36-24.192-25.848-24.552-14.976-0.288 9.216 0.216 9.072 0.216 18 0 5.976-2.736 6.408-8.64 15.408-3.024 4.752-5.4 9.864-7.272 15.048-1.152 3.096-2.232 6.336-3.096 9.504-0.36 1.584-1.008 3.24-1.368 4.824-2.952 10.872-13.464 24.264-15.912 35.136-2.448 10.8-5.328 17.712-4.968 32.185 0.36 14.472 0.504 10.295 4.896 13.896 4.32 3.601 8.784 6.983 15.624 13.032 7.2 6.264 22.177 17.208 24.192 20.592 2.16 3.456 2.088 11.232 0.792 13.752-1.296 2.448-12.6 3.816-12.528 3.816-0.071 0 9.864 13.68 11.809 15.623 1.872 1.873 9.936 10.873 42.768 4.752 18.504-3.455 32.832-13.823 43.2-23.832 13.392-13.031 6.624-16.775 8.352-23.327 2.521-9.433 10.729-12.96 12.601-23.616 0.216-1.512 0.72-2.664 2.088-4.896 2.088-3.168 1.584-9.432 1.584-15.191 0-14.977-1.729-30.24-5.185-41.472-3.168-10.512-8.208-17.856-12.527-27.36-8.641-18.936-8.208-27.432-15.912-39.528-8.784-13.968-4.464-23.256-16.128-22.68-14.546 0.79-26.282 20.734-40.034 21.31z"/>
+ <path fill="#6d6d6d" d="m159.973 129.334c-9.281 0.353-23.746-25.511-24.242-15.179-0.316 8.755 0.1 8.678 0.03 17.247-0.15 5.87-2.953 6.637-8.727 15.481-3.013 4.763-5.273 9.812-6.993 14.877-0.968 3.253-1.56 6.422-2.43 9.526-0.415 1.642-1.497 3.187-2.185 5.042-3.254 10.78-13.545 24.182-15.961 34.877-2.466 10.81-5.37 17.694-4.961 32.141 0.366 14 0.395 10.177 4.773 13.816 4.283 3.616 8.839 7.069 15.662 13.103 7.183 6.248 22.237 17.216 24.243 20.588 2.149 3.444 2.131 11.317 0.844 13.823-1.284 2.439-12.579 3.875-12.508 3.875-0.071 0 9.815 13.566 11.757 15.508 1.87 1.87 9.902 10.809 42.678 4.704 18.524-3.455 33.124-13.753 43.078-23.856 12.789-12.762 6.107-16.773 7.826-23.291 2.513-9.416 11.277-12.961 13.143-23.602 0.216-1.508 0.754-2.654 2.113-4.876 2.096-3.202 1.561-9.447 1.582-15.185 0.067-15.027-1.705-30.234-5.159-41.434-3.171-10.483-8.204-17.817-12.515-27.305-8.624-18.906-8.221-27.415-15.933-39.474-8.586-13.613-4.601-22.583-16.011-21.99-14.374 0.826-26.375 21.016-40.104 21.584z"/>
+ <path fill="#757575" d="m159.955 130.419c-9.201 0.346-23.299-25.175-23.931-15.383-0.344 8.295-0.017 8.284-0.156 16.494-0.301 5.764-3.17 6.867-8.812 15.555-3.002 4.774-5.148 9.76-6.714 14.706-0.784 3.41-0.889 6.508-1.764 9.548-0.471 1.699-1.986 3.133-3.003 5.259-3.554 10.688-13.624 24.1-16.009 34.619-2.483 10.82-5.411 17.678-4.954 32.097 0.373 13.528 0.285 10.058 4.651 13.739 4.244 3.632 8.893 7.154 15.699 13.171 7.167 6.233 22.299 17.224 24.294 20.585 2.142 3.432 2.175 11.404 0.896 13.896-1.271 2.428-12.558 3.932-12.486 3.932-0.071 0 9.768 13.453 11.705 15.392 1.867 1.867 9.867 10.744 42.588 4.655 18.545-3.453 33.415-13.682 42.956-23.879 12.187-12.492 5.591-16.771 7.3-23.258 2.507-9.398 11.826-12.959 13.687-23.586 0.215-1.5 0.788-2.643 2.138-4.854 2.104-3.235 1.538-9.462 1.58-15.178 0.133-15.076-1.681-30.228-5.135-41.394-3.173-10.455-8.199-17.779-12.501-27.25-8.609-18.877-8.234-27.399-15.952-39.42-8.389-13.258-4.739-21.911-15.895-21.301-14.21 0.859-26.474 21.295-40.182 21.855z"/>
+ <path fill="#7c7c7c" d="m159.938 131.504c-9.122 0.338-22.854-24.838-23.622-15.586-0.37 7.833-0.131 7.89-0.341 15.741-0.452 5.657-3.388 7.096-8.899 15.628-2.99 4.785-5.021 9.708-6.433 14.535-0.602 3.566-0.218 6.594-1.099 9.57-0.526 1.756-2.475 3.08-3.82 5.477-3.854 10.596-13.703 24.016-16.057 34.361-2.501 10.829-5.453 17.66-4.948 32.052 0.38 13.059 0.177 9.939 4.529 13.66 4.208 3.648 8.948 7.239 15.739 13.242 7.149 6.217 22.358 17.232 24.345 20.581 2.13 3.42 2.216 11.489 0.946 13.968-1.259 2.417-12.538 3.99-12.466 3.99-0.072 0 9.718 13.34 11.653 15.275 1.865 1.864 9.833 10.681 42.498 4.607 18.565-3.453 33.706-13.609 42.834-23.902 11.583-12.223 5.074-16.771 6.774-23.223 2.499-9.382 12.375-12.959 14.229-23.57 0.215-1.496 0.821-2.633 2.162-4.834 2.111-3.271 1.516-9.478 1.578-15.173 0.199-15.125-1.657-30.221-5.109-41.354-3.177-10.427-8.196-17.741-12.488-27.195-8.594-18.848-8.247-27.383-15.972-39.366-8.192-12.903-4.877-21.239-15.779-20.612-14.041 0.894-26.569 21.576-40.254 22.128z"/>
+ <path fill="#848484" d="m159.921 132.589c-9.043 0.331-22.406-24.502-23.312-15.79-0.398 7.373-0.247 7.496-0.527 14.988-0.602 5.551-3.604 7.326-8.984 15.702-2.98 4.796-4.896 9.656-6.154 14.364-0.417 3.723 0.455 6.679-0.432 9.592-0.582 1.813-2.964 3.026-4.639 5.694-4.153 10.504-13.782 23.936-16.104 34.104-2.519 10.838-5.495 17.643-4.941 32.008 0.387 12.586 0.067 9.819 4.407 13.582 4.171 3.664 9.002 7.324 15.777 13.311 7.132 6.201 22.419 17.24 24.396 20.576 2.12 3.41 2.259 11.578 0.998 14.041-1.247 2.408-12.517 4.049-12.446 4.049-0.07 0 9.67 13.227 11.604 15.16 1.861 1.861 9.798 10.615 42.409 4.558 18.584-3.45 33.996-13.538 42.711-23.926 10.979-11.952 4.557-16.769 6.248-23.187 2.491-9.367 12.924-12.959 14.771-23.557 0.215-1.49 0.856-2.622 2.188-4.813 2.118-3.305 1.491-9.494 1.575-15.166 0.267-15.174-1.635-30.215-5.086-41.314-3.179-10.399-8.19-17.703-12.473-27.141-8.579-18.818-8.262-27.366-15.994-39.312-7.993-12.547-5.013-20.565-15.661-19.922-13.876 0.927-26.669 21.855-40.331 22.399z"/>
+ <path fill="#8c8c8c" d="m159.903 133.674c-8.963 0.323-21.961-24.165-23.001-15.994-0.426 6.912-0.363 7.102-0.713 14.236-0.753 5.445-3.821 7.554-9.071 15.775-2.969 4.807-4.768 9.604-5.875 14.192-0.232 3.881 1.128 6.766 0.234 9.615-0.638 1.87-3.452 2.972-5.455 5.911-4.455 10.413-13.862 23.853-16.153 33.845-2.537 10.849-5.537 17.625-4.935 31.963 0.393 12.115-0.042 9.701 4.285 13.505 4.133 3.68 9.057 7.409 15.814 13.38 7.116 6.188 22.48 17.248 24.447 20.574 2.109 3.398 2.301 11.662 1.049 14.113-1.235 2.396-12.496 4.104-12.425 4.104-0.071 0 9.622 13.114 11.552 15.045 1.86 1.858 9.763 10.552 42.319 4.509 18.604-3.449 34.288-13.467 42.589-23.949 10.377-11.682 4.04-16.766 5.721-23.15 2.486-9.35 13.474-12.959 15.316-23.542 0.214-1.483 0.89-2.611 2.213-4.793 2.126-3.339 1.468-9.507 1.573-15.158 0.333-15.224-1.611-30.208-5.062-41.276-3.181-10.37-8.186-17.664-12.459-27.085-8.563-18.789-8.275-27.35-16.014-39.258-7.796-12.192-5.151-19.893-15.545-19.233-13.707 0.961-26.763 22.134-40.404 22.671z"/>
+ <path fill="#939393" d="m159.886 134.759c-8.885 0.316-21.516-23.829-22.691-16.197-0.454 6.451-0.479 6.708-0.899 13.482-0.903 5.339-4.038 7.784-9.157 15.849-2.957 4.818-4.642 9.552-5.595 14.021-0.05 4.037 1.799 6.852 0.9 9.637-0.693 1.928-3.941 2.919-6.273 6.129-4.756 10.32-13.941 23.77-16.201 33.587-2.555 10.858-5.579 17.608-4.928 31.92 0.399 11.644-0.151 9.581 4.162 13.424 4.096 3.697 9.111 7.494 15.854 13.451 7.099 6.17 22.541 17.256 24.498 20.569 2.1 3.387 2.344 11.75 1.101 14.186-1.223 2.387-12.476 4.163-12.404 4.163-0.071 0 9.573 13.001 11.5 14.929 1.857 1.856 9.729 10.488 42.229 4.461 18.625-3.449 34.579-13.396 42.467-23.973 9.774-11.412 3.523-16.764 5.195-23.115 2.479-9.334 14.022-12.959 15.858-23.527 0.214-1.479 0.924-2.601 2.238-4.772 2.134-3.373 1.445-9.522 1.571-15.151 0.399-15.273-1.587-30.201-5.036-41.237-3.185-10.342-8.184-17.625-12.446-27.03-8.548-18.76-8.288-27.333-16.034-39.204-7.598-11.837-5.289-19.221-15.428-18.544-13.543 0.994-26.863 22.413-40.481 22.942z"/>
+ <path fill="#9b9b9b" d="m159.868 135.844c-8.805 0.308-21.068-23.492-22.381-16.401-0.481 5.991-0.594 6.314-1.085 12.73-1.053 5.232-4.253 8.013-9.243 15.922-2.946 4.829-4.515 9.5-5.314 13.85 0.133 4.194 2.471 6.937 1.565 9.658-0.749 1.986-4.43 2.866-7.091 6.347-5.056 10.229-14.021 23.689-16.249 33.329-2.572 10.868-5.621 17.591-4.921 31.876 0.405 11.172-0.261 9.463 4.04 13.346 4.058 3.713 9.166 7.58 15.892 13.521 7.082 6.155 22.601 17.265 24.548 20.567 2.092 3.373 2.388 11.834 1.152 14.256-1.21 2.377-12.454 4.222-12.383 4.222-0.071 0 9.523 12.888 11.45 14.813 1.854 1.854 9.692 10.424 42.138 4.412 18.645-3.447 34.871-13.324 42.345-23.996 9.171-11.143 3.007-16.762 4.669-23.08 2.472-9.317 14.572-12.959 16.401-23.514 0.214-1.473 0.958-2.588 2.265-4.75 2.142-3.408 1.421-9.539 1.568-15.145 0.466-15.324-1.564-30.196-5.012-41.198-3.187-10.313-8.179-17.587-12.433-26.976-8.533-18.73-8.301-27.316-16.054-39.149-7.401-11.482-5.426-18.548-15.313-17.855-13.373 1.029-26.958 22.694-40.554 23.215z"/>
+ <path fill="#a3a3a3" d="m159.851 136.929c-8.727 0.301-20.622-23.156-22.071-16.604-0.509 5.529-0.71 5.919-1.271 11.976-1.203 5.126-4.47 8.243-9.328 15.996-2.936 4.84-4.39 9.448-5.036 13.679 0.316 4.351 3.143 7.023 2.231 9.68-0.804 2.043-4.919 2.812-7.908 6.563-5.356 10.137-14.101 23.607-16.298 33.072-2.589 10.877-5.661 17.574-4.913 31.832 0.412 10.699-0.37 9.342 3.918 13.268 4.021 3.729 9.221 7.664 15.93 13.59 7.064 6.139 22.661 17.271 24.599 20.563 2.081 3.363 2.43 11.922 1.204 14.33-1.198 2.365-12.434 4.278-12.363 4.278-0.07 0 9.477 12.774 11.399 14.697 1.851 1.851 9.659 10.36 42.048 4.364 18.666-3.447 35.162-13.254 42.223-24.021 8.568-10.873 2.49-16.761 4.144-23.045 2.464-9.301 15.121-12.958 16.943-23.498 0.215-1.467 0.992-2.579 2.29-4.729 2.148-3.441 1.398-9.553 1.566-15.139 0.532-15.373-1.541-30.189-4.987-41.158-3.188-10.285-8.174-17.549-12.419-26.921-8.518-18.701-8.313-27.3-16.073-39.096-7.204-11.126-5.564-17.875-15.196-17.165-13.21 1.064-27.058 22.975-40.632 23.488z"/>
+ <path fill="#aaa" d="m159.834 138.014c-8.646 0.293-20.176-22.819-21.761-16.808-0.536 5.069-0.826 5.526-1.457 11.224-1.354 5.02-4.687 8.472-9.416 16.069-2.924 4.851-4.262 9.396-4.756 13.508 0.501 4.507 3.814 7.109 2.897 9.702-0.858 2.1-5.406 2.759-8.725 6.782-5.657 10.045-14.181 23.524-16.347 32.812-2.606 10.888-5.703 17.557-4.906 31.787 0.418 10.229-0.479 9.225 3.795 13.189 3.984 3.745 9.275 7.749 15.968 13.66 7.048 6.124 22.723 17.279 24.651 20.559 2.07 3.352 2.472 12.008 1.255 14.402-1.186 2.355-12.414 4.337-12.343 4.337-0.071 0 9.428 12.66 11.348 14.581 1.85 1.848 9.624 10.297 41.958 4.314 18.687-3.444 35.453-13.18 42.102-24.043 7.965-10.602 1.973-16.758 3.616-23.01 2.457-9.283 15.67-12.957 17.487-23.482 0.214-1.461 1.026-2.568 2.315-4.709 2.155-3.477 1.375-9.568 1.563-15.131 0.6-15.424-1.518-30.184-4.963-41.119-3.192-10.257-8.17-17.511-12.405-26.866-8.502-18.672-8.328-27.284-16.095-39.042-7.005-10.771-5.701-17.203-15.078-16.476-13.04 1.098-27.152 23.255-40.703 23.76z"/>
+ <path fill="#b2b2b2" d="m159.816 139.099c-8.567 0.286-19.729-22.483-21.45-17.012-0.563 4.608-0.942 5.132-1.643 10.471-1.506 4.914-4.904 8.701-9.502 16.143-2.913 4.862-4.137 9.344-4.477 13.336 0.685 4.665 4.486 7.195 3.564 9.725-0.915 2.157-5.897 2.705-9.543 6.999-5.958 9.953-14.262 23.443-16.396 32.554-2.624 10.898-5.745 17.54-4.9 31.744 0.426 9.757-0.588 9.105 3.674 13.111 3.945 3.761 9.33 7.834 16.006 13.729 7.032 6.109 22.783 17.288 24.702 20.557 2.06 3.338 2.515 12.094 1.306 14.473-1.173 2.346-12.392 4.395-12.321 4.395-0.07 0 9.379 12.549 11.296 14.465 1.847 1.848 9.591 10.234 41.868 4.268 18.706-3.444 35.745-13.11 41.979-24.066 7.361-10.332 1.456-16.757 3.091-22.974 2.45-9.269 16.219-12.958 18.03-23.47 0.213-1.455 1.06-2.557 2.34-4.688 2.164-3.509 1.352-9.583 1.562-15.124 0.665-15.473-1.494-30.177-4.938-41.08-3.195-10.228-8.166-17.472-12.393-26.811-8.486-18.642-8.341-27.267-16.114-38.987-6.809-10.416-5.838-16.531-14.962-15.787-12.873 1.129-27.25 23.531-40.779 24.029z"/>
+ <path fill="#bababa" d="m159.799 140.184c-8.487 0.279-19.282-22.146-21.141-17.215-0.591 4.147-1.057 4.737-1.828 9.717-1.656 4.808-5.121 8.931-9.588 16.217-2.902 4.873-4.01 9.292-4.197 13.165 0.868 4.822 5.158 7.281 4.23 9.747-0.971 2.215-6.385 2.651-10.361 7.216-6.258 9.861-14.339 23.36-16.442 32.297-2.643 10.906-5.787 17.521-4.894 31.699 0.432 9.285-0.697 8.986 3.552 13.032 3.908 3.776 9.384 7.919 16.043 13.799 7.016 6.093 22.845 17.296 24.753 20.552 2.051 3.328 2.559 12.18 1.358 14.547-1.161 2.334-12.372 4.451-12.301 4.451-0.071 0 9.33 12.436 11.245 14.35 1.844 1.844 9.555 10.17 41.777 4.219 18.727-3.443 36.036-13.039 41.857-24.09 6.759-10.063 0.939-16.756 2.565-22.939 2.442-9.25 16.768-12.957 18.572-23.453 0.213-1.451 1.095-2.547 2.365-4.668 2.171-3.543 1.329-9.599 1.56-15.117 0.732-15.522-1.471-30.172-4.913-41.042-3.197-10.2-8.161-17.433-12.379-26.756-8.471-18.612-8.354-27.25-16.135-38.933-6.609-10.061-5.976-15.858-14.845-15.098-12.706 1.165-27.347 23.813-40.853 24.303z"/>
+ <path fill="#c1c1c1" d="m159.781 141.269c-8.408 0.271-18.837-21.81-20.83-17.419-0.619 3.687-1.173 4.344-2.014 8.965-1.808 4.701-5.338 9.16-9.674 16.29-2.892 4.884-3.885 9.24-3.918 12.994 1.052 4.978 5.829 7.367 4.896 9.769-1.026 2.272-6.874 2.598-11.178 7.434-6.56 9.769-14.419 23.277-16.491 32.039-2.66 10.916-5.829 17.504-4.887 31.656 0.438 8.813-0.807 8.867 3.43 12.953 3.87 3.793 9.438 8.004 16.082 13.868 6.997 6.077 22.904 17.304 24.803 20.55 2.041 3.314 2.601 12.266 1.409 14.617-1.149 2.324-12.351 4.51-12.28 4.51-0.07 0 9.282 12.321 11.194 14.233 1.841 1.842 9.521 10.106 41.688 4.17 18.746-3.44 36.326-12.967 41.734-24.112 6.156-9.793 0.423-16.754 2.038-22.904 2.438-9.235 17.318-12.957 19.117-23.438 0.212-1.444 1.128-2.536 2.39-4.647 2.18-3.578 1.306-9.613 1.558-15.11 0.799-15.571-1.447-30.165-4.889-41.002-3.2-10.172-8.156-17.395-12.364-26.701-8.456-18.583-8.367-27.234-16.155-38.88-6.413-9.705-6.114-15.185-14.729-14.408-12.541 1.197-27.445 24.091-40.93 24.573z"/>
+ <path fill="#c9c9c9" d="m159.764 142.354c-8.329 0.264-18.392-21.473-20.521-17.622-0.646 3.225-1.289 3.949-2.2 8.211-1.957 4.596-5.555 9.39-9.761 16.364-2.879 4.895-3.757 9.188-3.638 12.823 1.235 5.135 6.502 7.453 5.562 9.791-1.081 2.329-7.362 2.544-11.995 7.651-6.859 9.677-14.499 23.195-16.54 31.78-2.677 10.927-5.87 17.488-4.879 31.611 0.444 8.344-0.916 8.748 3.307 12.875 3.834 3.81 9.492 8.09 16.121 13.939 6.98 6.061 22.965 17.311 24.854 20.545 2.031 3.303 2.643 12.352 1.461 14.69-1.137 2.313-12.33 4.567-12.26 4.567-0.07 0 9.233 12.209 11.143 14.117 1.839 1.84 9.486 10.043 41.599 4.122 18.767-3.44 36.618-12.896 41.612-24.137 5.554-9.522-0.094-16.751 1.513-22.868 2.43-9.219 17.866-12.957 19.659-23.424 0.213-1.439 1.162-2.525 2.415-4.627 2.188-3.612 1.282-9.629 1.556-15.104 0.865-15.621-1.424-30.158-4.864-40.962-3.202-10.144-8.153-17.357-12.351-26.646-8.441-18.554-8.381-27.218-16.176-38.826-6.216-9.35-6.251-14.513-14.612-13.719-12.374 1.235-27.543 24.375-41.005 24.849z"/>
+ <path fill="#d1d1d1" d="m159.747 143.439c-8.25 0.256-17.944-21.137-20.21-17.826-0.675 2.765-1.406 3.555-2.386 7.459-2.108 4.489-5.772 9.619-9.847 16.437-2.869 4.906-3.631 9.136-3.358 12.652 1.419 5.292 7.174 7.538 6.228 9.812-1.137 2.387-7.852 2.491-12.813 7.869-7.161 9.586-14.579 23.114-16.588 31.522-2.695 10.938-5.912 17.471-4.873 31.568 0.451 7.871-1.025 8.629 3.185 12.797 3.796 3.824 9.547 8.174 16.158 14.008 6.964 6.047 23.026 17.32 24.905 20.541 2.021 3.292 2.686 12.439 1.513 14.764-1.125 2.303-12.31 4.625-12.239 4.625-0.07 0 9.186 12.094 11.092 14.002 1.836 1.836 9.45 9.978 41.509 4.072 18.786-3.439 36.909-12.824 41.49-24.16 4.948-9.252-0.611-16.748 0.985-22.832 2.423-9.203 18.415-12.957 20.203-23.41 0.212-1.434 1.196-2.514 2.44-4.605 2.193-3.646 1.259-9.645 1.553-15.098 0.932-15.67-1.4-30.151-4.84-40.922-3.205-10.115-8.148-17.319-12.336-26.592-8.427-18.524-8.396-27.201-16.197-38.771-6.017-8.995-6.388-13.84-14.495-13.03-12.207 1.266-27.64 24.652-41.079 25.118z"/>
+ <path fill="#d8d8d8" d="m159.729 144.524c-8.17 0.249-17.498-20.8-19.9-18.03-0.702 2.304-1.521 3.162-2.571 6.706-2.259 4.383-5.988 9.848-9.933 16.511-2.858 4.917-3.504 9.084-3.079 12.48 1.604 5.449 7.846 7.625 6.895 9.835-1.193 2.444-8.342 2.438-13.631 8.087-7.461 9.493-14.658 23.031-16.637 31.262-2.712 10.947-5.953 17.455-4.865 31.524 0.458 7.399-1.135 8.511 3.063 12.718 3.758 3.842 9.601 8.26 16.196 14.078 6.946 6.031 23.087 17.328 24.956 20.538 2.011 3.28 2.729 12.524 1.563 14.835-1.112 2.293-12.289 4.684-12.218 4.684-0.071 0 9.136 11.981 11.04 13.886 1.834 1.834 9.417 9.913 41.419 4.024 18.807-3.438 37.2-12.752 41.368-24.184 4.346-8.982-1.128-16.747 0.46-22.798 2.416-9.187 18.964-12.956 20.746-23.394 0.211-1.429 1.229-2.504 2.465-4.586 2.202-3.681 1.236-9.658 1.551-15.091 0.998-15.72-1.377-30.146-4.814-40.884-3.208-10.086-8.145-17.28-12.323-26.536-8.411-18.495-8.408-27.185-16.217-38.717-5.82-8.64-6.526-13.168-14.38-12.341-12.04 1.303-27.736 24.934-41.154 25.393z"/>
+ <path fill="#e0e0e0" d="m159.712 145.609c-8.091 0.241-17.052-20.464-19.59-18.233-0.729 1.843-1.637 2.767-2.757 5.953-2.409 4.276-6.206 10.077-10.02 16.584-2.847 4.928-3.378 9.032-2.8 12.309 1.787 5.606 8.519 7.711 7.561 9.857-1.248 2.502-8.829 2.384-14.448 8.304-7.761 9.402-14.738 22.95-16.684 31.006-2.731 10.955-5.996 17.436-4.859 31.48 0.464 6.928-1.244 8.389 2.939 12.639 3.722 3.857 9.656 8.344 16.234 14.148 6.932 6.014 23.148 17.336 25.008 20.533 2 3.268 2.771 12.611 1.615 14.907-1.1 2.282-12.268 4.741-12.198 4.741-0.069 0 9.089 11.867 10.989 13.77 1.831 1.831 9.382 9.85 41.329 3.977 18.827-3.438 37.492-12.683 41.246-24.207 3.743-8.715-1.646-16.746-0.066-22.762 2.409-9.171 19.514-12.957 21.289-23.381 0.211-1.422 1.265-2.494 2.49-4.564 2.21-3.715 1.213-9.674 1.549-15.084 1.065-15.77-1.354-30.139-4.791-40.844-3.21-10.058-8.14-17.241-12.309-26.481-8.396-18.466-8.421-27.168-16.237-38.664-5.622-8.284-6.663-12.495-14.262-11.651-11.872 1.335-27.833 25.212-41.228 25.663z"/>
+ <path fill="#e8e8e8" d="m159.694 146.694c-8.012 0.234-16.605-20.127-19.279-18.437-0.757 1.383-1.753 2.373-2.943 5.2-2.56 4.171-6.423 10.307-10.105 16.658-2.835 4.939-3.251 8.979-2.52 12.138 1.97 5.763 9.189 7.796 8.226 9.879-1.303 2.559-9.318 2.33-15.265 8.521-8.063 9.31-14.818 22.867-16.733 30.748-2.748 10.967-6.037 17.419-4.853 31.436 0.472 6.457-1.353 8.271 2.818 12.562 3.685 3.873 9.711 8.429 16.273 14.218 6.913 6 23.207 17.344 25.058 20.529 1.991 3.257 2.814 12.697 1.666 14.98-1.087 2.271-12.247 4.799-12.177 4.799-0.07 0 9.04 11.755 10.938 13.654 1.829 1.828 9.349 9.785 41.239 3.926 18.847-3.435 37.783-12.609 41.124-24.229 3.14-8.444-2.161-16.743-0.592-22.728 2.401-9.152 20.062-12.955 21.831-23.364 0.211-1.417 1.298-2.483 2.516-4.544 2.217-3.748 1.19-9.689 1.547-15.076 1.132-15.82-1.331-30.133-4.766-40.806-3.213-10.03-8.136-17.203-12.296-26.427-8.38-18.436-8.435-27.151-16.257-38.609-5.425-7.929-6.802-11.822-14.146-10.962-11.706 1.368-27.931 25.491-41.304 25.934z"/>
+ <path fill="#efefef" d="m159.677 147.779c-7.934 0.226-16.16-19.791-18.97-18.64-0.785 0.921-1.869 1.979-3.13 4.447-2.71 4.064-6.639 10.536-10.19 16.731-2.824 4.95-3.125 8.928-2.24 11.967 2.152 5.919 9.86 7.882 8.892 9.901-1.358 2.616-9.808 2.277-16.083 8.739-8.363 9.218-14.896 22.784-16.781 30.489-2.766 10.977-6.079 17.402-4.846 31.393 0.478 5.984-1.462 8.152 2.696 12.482 3.646 3.889 9.765 8.514 16.311 14.287 6.896 5.983 23.269 17.352 25.109 20.526 1.98 3.245 2.855 12.782 1.718 15.052-1.076 2.262-12.227 4.857-12.156 4.857-0.07 0 8.991 11.641 10.887 13.537 1.826 1.826 9.313 9.723 41.148 3.879 18.868-3.434 38.074-12.538 41.002-24.254 2.537-8.174-2.678-16.741-1.119-22.69 2.396-9.138 20.612-12.957 22.375-23.351 0.212-1.412 1.332-2.473 2.541-4.523 2.226-3.783 1.166-9.704 1.545-15.07 1.197-15.869-1.307-30.125-4.741-40.766-3.215-10.002-8.131-17.165-12.282-26.372-8.365-18.407-8.447-27.135-16.277-38.555-5.228-7.574-6.938-11.15-14.029-10.272-11.541 1.402-28.029 25.771-41.38 26.206z"/>
+ <path fill="#f7f7f7" d="m159.66 148.864c-7.854 0.219-15.714-19.454-18.66-18.844-0.812 0.461-1.983 1.585-3.314 3.694-2.86 3.958-6.856 10.766-10.278 16.805-2.813 4.961-2.998 8.876-1.96 11.796 2.337 6.076 10.533 7.968 9.558 9.923-1.415 2.673-10.296 2.223-16.899 8.956-8.664 9.126-14.978 22.702-16.83 30.23-2.783 10.986-6.121 17.386-4.839 31.349 0.484 5.515-1.571 8.033 2.573 12.403 3.609 3.906 9.82 8.6 16.35 14.357 6.88 5.969 23.329 17.36 25.16 20.523 1.971 3.232 2.898 12.869 1.77 15.124-1.063 2.252-12.206 4.915-12.136 4.915-0.07 0 8.942 11.527 10.835 13.422 1.824 1.822 9.279 9.658 41.059 3.83 18.889-3.434 38.366-12.467 40.881-24.278 1.934-7.903-3.195-16.739-1.646-22.655 2.388-9.121 21.161-12.955 22.918-23.336 0.211-1.404 1.366-2.461 2.566-4.502 2.232-3.816 1.143-9.719 1.542-15.063 1.265-15.92-1.283-30.12-4.717-40.727-3.219-9.974-8.128-17.127-12.269-26.317-8.349-18.378-8.461-27.119-16.298-38.501-5.029-7.219-7.075-10.478-13.912-9.584-11.373 1.438-28.126 26.053-41.454 26.48z"/>
+ <path fill="#fff" d="m159.642 149.949c-7.774 0.211-15.268-19.118-18.35-19.048-0.84 0-2.1 1.191-3.501 2.941-3.011 3.852-7.072 10.995-10.363 16.878-2.803 4.972-2.872 8.824-1.682 11.625 2.521 6.233 11.205 8.054 10.225 9.945-1.471 2.731-10.785 2.17-17.719 9.174-8.964 9.034-15.056 22.621-16.877 29.973-2.801 10.995-6.163 17.368-4.832 31.304 0.49 5.043-1.681 7.914 2.451 12.325 3.571 3.923 9.874 8.685 16.387 14.427 6.863 5.953 23.391 17.368 25.211 20.521 1.962 3.221 2.942 12.954 1.821 15.196-1.051 2.24-12.185 4.972-12.115 4.972-0.069 0 8.895 11.416 10.784 13.307 1.821 1.82 9.244 9.595 40.97 3.781 18.907-3.431 38.656-12.396 40.758-24.302 1.331-7.633-3.712-16.736-2.171-22.619 2.381-9.104 21.71-12.956 23.461-23.321 0.21-1.399 1.399-2.45 2.591-4.481 2.24-3.852 1.12-9.734 1.54-15.057 1.331-15.968-1.26-30.113-4.692-40.688-3.221-9.945-8.123-17.088-12.255-26.262-8.334-18.348-8.474-27.102-16.318-38.447-4.832-6.863-7.213-9.805-13.796-8.894-11.205 1.469-28.222 26.33-41.528 26.75z"/>
+ </g>
+ <path fill="#995900" d="m152.553 88.8575c5.256-0.648 12.456 0.648 15.769 3.096 3.096 2.304 5.256 3.528 8.063 4.464 9.433 3.096 21.816 4.536 21.24 13.032-0.648 10.151-3.6 14.688-12.024 17.351-6.768 2.088-18.863 13.824-28.224 13.824-4.176 0-10.008 0.216-13.392-1.008-3.24-1.152-7.776-6.624-13.104-11.016-5.328-4.32-10.296-8.928-10.439-14.976-0.217-6.407 3.96-8.496 9.863-13.607 3.097-2.736 8.712-7.272 12.601-9.288 3.599-1.799 5.903-1.439 9.647-1.872z"/>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#9e5f00" d="m165.068 78.951c5.225-0.644 12.384 0.645 15.677 3.079 3.078 2.29 5.227 3.51 8.018 4.438 9.375 3.078 21.729 4.529 21.159 12.973-0.641 10.09-3.669 14.581-12.041 17.223-6.723 2.073-18.768 13.589-28.07 13.64-4.21 0.032-9.926 0.234-13.287-0.977-3.215-1.142-7.737-6.608-13.031-10.969-5.291-4.292-10.26-8.774-10.317-14.765-0.153-6.252 3.912-8.411 9.773-13.488 3.071-2.71 8.594-7.303 12.463-9.333 3.563-1.803 5.933-1.392 9.656-1.821z"/>
+ <path fill="#a36400" d="m165.177 79.044c5.195-0.641 12.313 0.64 15.587 3.06 3.06 2.278 5.194 3.494 7.971 4.413 9.317 3.06 21.641 4.522 21.078 12.914-0.634 10.027-3.737 14.474-12.058 17.094-6.678 2.057-18.673 13.352-27.919 13.454-4.241 0.064-9.842 0.252-13.18-0.945-3.19-1.133-7.7-6.592-12.96-10.921-5.254-4.264-10.222-8.622-10.192-14.555-0.093-6.099 3.863-8.329 9.681-13.369 3.048-2.685 8.478-7.335 12.328-9.379 3.526-1.804 5.963-1.338 9.664-1.766z"/>
+ <path fill="#a86a00" d="m165.287 79.138c5.165-0.637 12.241 0.637 15.496 3.043 3.042 2.264 5.165 3.476 7.924 4.387 9.26 3.042 21.556 4.515 20.998 12.854-0.627 9.968-3.805 14.368-12.074 16.967-6.633 2.042-18.576 13.117-27.766 13.27-4.276 0.096-9.759 0.27-13.075-0.914-3.165-1.123-7.661-6.577-12.887-10.874-5.217-4.236-10.187-8.468-10.069-14.345-0.03-5.943 3.815-8.244 9.589-13.249 3.023-2.66 8.36-7.366 12.191-9.424 3.49-1.808 5.993-1.291 9.673-1.715z"/>
+ <path fill="#ad7000" d="m165.396 79.23c5.135-0.633 12.17 0.633 15.404 3.025 3.025 2.251 5.137 3.46 7.88 4.361 9.201 3.025 21.467 4.508 20.917 12.796-0.62 9.905-3.874 14.26-12.093 16.837-6.586 2.027-18.479 12.882-27.611 13.086-4.311 0.127-9.677 0.287-12.971-0.883-3.14-1.113-7.622-6.561-12.814-10.826-5.18-4.208-10.148-8.315-9.945-14.135 0.031-5.789 3.768-8.16 9.497-13.129 2.999-2.635 8.244-7.398 12.055-9.47 3.454-1.808 6.023-1.24 9.681-1.662z"/>
+ <path fill="#b27600" d="m165.506 79.325c5.105-0.63 12.099 0.629 15.314 3.007 3.007 2.237 5.104 3.442 7.832 4.335 9.145 3.007 21.38 4.501 20.837 12.737-0.614 9.844-3.943 14.154-12.109 16.709-6.541 2.011-18.385 12.645-27.46 12.9-4.342 0.16-9.592 0.306-12.862-0.851-3.115-1.103-7.584-6.545-12.743-10.779-5.144-4.18-10.112-8.162-9.821-13.924 0.092-5.634 3.718-8.076 9.405-13.009 2.975-2.609 8.126-7.429 11.919-9.514 3.416-1.812 6.052-1.192 9.688-1.611z"/>
+ <path fill="#b77b00" d="m165.615 79.417c5.075-0.626 12.026 0.626 15.224 2.989 2.989 2.225 5.075 3.425 7.786 4.31 9.087 2.989 21.292 4.494 20.756 12.678-0.606 9.781-4.012 14.046-12.126 16.581-6.496 1.997-18.289 12.41-27.307 12.716-4.376 0.191-9.51 0.323-12.758-0.82-3.09-1.094-7.546-6.53-12.671-10.732-5.106-4.152-10.074-8.008-9.697-13.713 0.155-5.479 3.67-7.992 9.313-12.889 2.951-2.585 8.011-7.461 11.783-9.56 3.38-1.814 6.083-1.143 9.697-1.56z"/>
+ <path fill="#bc8100" d="m165.725 79.511c5.044-0.622 11.954 0.622 15.133 2.972 2.971 2.211 5.045 3.408 7.739 4.284 9.029 2.971 21.205 4.487 20.675 12.619-0.6 9.719-4.079 13.939-12.143 16.451-6.45 1.982-18.192 12.175-27.153 12.532-4.41 0.223-9.428 0.341-12.653-0.789-3.065-1.084-7.507-6.514-12.598-10.684-5.069-4.124-10.038-7.855-9.574-13.504 0.217-5.324 3.622-7.908 9.222-12.77 2.926-2.559 7.894-7.492 11.646-9.605 3.344-1.816 6.113-1.092 9.706-1.506z"/>
+ <path fill="#c18700" d="m165.834 79.604c5.015-0.618 11.883 0.619 15.043 2.954 2.953 2.198 5.015 3.391 7.693 4.259 8.972 2.953 21.118 4.48 20.594 12.559-0.593 9.66-4.147 13.833-12.159 16.324-6.405 1.967-18.098 11.94-27.002 12.347-4.441 0.255-9.343 0.359-12.546-0.757-3.04-1.074-7.469-6.498-12.526-10.637-5.032-4.096-10-7.701-9.45-13.293 0.278-5.169 3.574-7.823 9.13-12.649 2.903-2.534 7.776-7.524 11.511-9.651 3.306-1.821 6.141-1.044 9.712-1.456z"/>
+ <path fill="#c68d00" d="m165.944 79.697c4.984-0.615 11.811 0.614 14.952 2.936 2.935 2.184 4.983 3.374 7.646 4.233 8.915 2.935 21.031 4.473 20.515 12.5-0.586 9.597-4.218 13.726-12.177 16.195-6.36 1.951-18.002 11.703-26.849 12.162-4.476 0.287-9.261 0.377-12.441-0.726-3.015-1.064-7.431-6.482-12.453-10.589-4.995-4.068-9.965-7.549-9.326-13.083 0.34-5.015 3.524-7.741 9.038-12.531 2.878-2.508 7.658-7.555 11.374-9.696 3.269-1.82 6.171-0.992 9.721-1.401z"/>
+ <path fill="#cc9200" d="m166.054 79.791c4.952-0.61 11.738 0.611 14.86 2.918 2.918 2.172 4.954 3.357 7.601 4.207 8.857 2.918 20.942 4.466 20.432 12.442-0.578 9.536-4.285 13.62-12.192 16.066-6.314 1.936-17.906 11.468-26.696 11.978-4.509 0.319-9.178 0.394-12.335-0.696-2.989-1.054-7.393-6.466-12.382-10.541-4.959-4.04-9.928-7.395-9.202-12.873 0.401-4.859 3.477-7.655 8.945-12.411 2.854-2.482 7.542-7.586 11.239-9.741 3.233-1.824 6.201-0.943 9.73-1.349z"/>
+ <path fill="#d19800" d="m166.163 79.883c4.923-0.606 11.668 0.608 14.771 2.901 2.9 2.158 4.924 3.339 7.554 4.181 8.801 2.9 20.855 4.459 20.352 12.383-0.571 9.474-4.353 13.512-12.21 15.938-6.269 1.921-17.81 11.233-26.543 11.793-4.542 0.351-9.094 0.413-12.229-0.664-2.965-1.044-7.354-6.45-12.311-10.494-4.921-4.012-9.89-7.241-9.079-12.662 0.465-4.705 3.431-7.571 8.855-12.29 2.83-2.458 7.425-7.618 11.102-9.787 3.197-1.827 6.231-0.893 9.738-1.299z"/>
+ <path fill="#d69e00" d="m166.273 79.978c4.893-0.603 11.596 0.603 14.679 2.882 2.883 2.145 4.895 3.323 7.507 4.156 8.744 2.882 20.77 4.452 20.272 12.324-0.565 9.412-4.422 13.406-12.228 15.81-6.224 1.905-17.714 10.996-26.39 11.608-4.576 0.383-9.012 0.431-12.124-0.633-2.94-1.034-7.316-6.434-12.237-10.446-4.884-3.984-9.854-7.089-8.955-12.452 0.525-4.551 3.382-7.489 8.764-12.171 2.805-2.432 7.307-7.649 10.965-9.832 3.16-1.829 6.261-0.845 9.747-1.246z"/>
+ <path fill="#dba300" d="m166.382 80.07c4.863-0.599 11.525 0.6 14.59 2.865 2.864 2.131 4.862 3.305 7.461 4.13 8.686 2.864 20.682 4.445 20.19 12.264-0.559 9.352-4.491 13.299-12.244 15.681-6.179 1.89-17.619 10.761-26.237 11.423-4.608 0.415-8.929 0.449-12.018-0.601-2.915-1.024-7.277-6.418-12.166-10.399-4.847-3.956-9.815-6.935-8.831-12.241 0.587-4.396 3.333-7.404 8.671-12.051 2.782-2.407 7.191-7.681 10.83-9.878 3.123-1.829 6.29-0.793 9.754-1.193z"/>
+ <path fill="#e0a900" d="m166.492 80.164c4.832-0.595 11.453 0.596 14.498 2.847 2.847 2.118 4.833 3.289 7.414 4.104 8.629 2.846 20.595 4.438 20.111 12.205-0.553 9.29-4.56 13.193-12.262 15.553-6.134 1.875-17.522 10.526-26.085 11.239-4.642 0.447-8.845 0.467-11.912-0.57-2.89-1.015-7.238-6.402-12.093-10.351-4.81-3.928-9.78-6.782-8.708-12.032 0.649-4.241 3.285-7.32 8.58-11.932 2.757-2.381 7.073-7.712 10.693-9.923 3.088-1.831 6.321-0.743 9.764-1.14z"/>
+ <path fill="#e5af00" d="m166.601 80.257c4.803-0.592 11.382 0.592 14.407 2.829 2.829 2.105 4.804 3.271 7.368 4.079 8.571 2.828 20.507 4.431 20.029 12.146-0.544 9.228-4.627 13.085-12.277 15.423-6.089 1.861-17.427 10.29-25.932 11.055-4.676 0.478-8.763 0.484-11.807-0.539-2.865-1.005-7.2-6.387-12.021-10.304-4.772-3.9-9.742-6.629-8.583-11.821 0.711-4.085 3.236-7.236 8.487-11.812 2.732-2.357 6.957-7.744 10.557-9.968 3.052-1.834 6.351-0.694 9.772-1.088z"/>
+ <path fill="#eab500" d="m166.711 80.351c4.772-0.588 11.31 0.589 14.317 2.811 2.811 2.092 4.771 3.254 7.321 4.054 8.514 2.81 20.42 4.424 19.948 12.087-0.538 9.165-4.695 12.979-12.294 15.295-6.044 1.845-17.332 10.054-25.779 10.869-4.708 0.511-8.68 0.503-11.7-0.507-2.84-0.995-7.163-6.371-11.949-10.257-4.736-3.872-9.706-6.475-8.46-11.61 0.773-3.931 3.188-7.152 8.396-11.692 2.709-2.331 6.839-7.775 10.421-10.013 3.013-1.839 6.38-0.645 9.779-1.037z"/>
+ <path fill="#efba00" d="m166.82 80.443c4.742-0.584 11.238 0.585 14.226 2.794 2.794 2.078 4.743 3.237 7.276 4.027 8.456 2.793 20.332 4.417 19.868 12.029-0.531 9.104-4.766 12.872-12.313 15.167-5.997 1.83-17.234 9.819-25.626 10.685-4.742 0.542-8.596 0.52-11.595-0.476-2.815-0.985-7.124-6.355-11.877-10.209-4.699-3.844-9.668-6.322-8.336-11.4 0.835-3.778 3.14-7.068 8.304-11.573 2.686-2.306 6.724-7.807 10.285-10.059 2.978-1.84 6.411-0.595 9.788-0.985z"/>
+ <path fill="#f4c000" d="m166.93 80.537c4.711-0.58 11.166 0.582 14.135 2.776 2.775 2.066 4.713 3.22 7.229 4.002 8.399 2.775 20.246 4.41 19.787 11.969-0.522 9.043-4.832 12.765-12.328 15.039-5.952 1.815-17.139 9.584-25.473 10.501-4.776 0.574-8.513 0.538-11.49-0.445-2.79-0.976-7.085-6.34-11.804-10.162-4.662-3.816-9.632-6.168-8.213-11.189 0.896-3.623 3.092-6.984 8.213-11.454 2.66-2.281 6.604-7.838 10.147-10.104 2.942-1.844 6.441-0.547 9.797-0.933z"/>
+ <path fill="#f9c600" d="m167.039 80.63c4.683-0.577 11.095 0.577 14.045 2.758 2.758 2.052 4.683 3.203 7.184 3.976 8.341 2.757 20.157 4.403 19.706 11.91-0.518 8.981-4.901 12.659-12.346 14.911-5.906 1.799-17.044 9.347-25.32 10.315-4.809 0.606-8.431 0.556-11.384-0.413-2.765-0.966-7.048-6.324-11.732-10.114-4.625-3.788-9.594-6.016-8.088-10.98 0.958-3.467 3.043-6.9 8.12-11.333 2.637-2.256 6.488-7.87 10.013-10.15 2.902-1.844 6.468-0.495 9.802-0.88z"/>
+ </g>
+ <path fill="#fc0" d="m154.744 90.7245c4.65-0.573 11.022 0.574 13.954 2.74 2.739 2.039 4.651 3.186 7.136 3.951 8.284 2.739 20.071 4.396 19.626 11.851-0.51 8.919-4.97 12.551-12.362 14.781-5.861 1.784-16.947 9.112-25.168 10.131-4.842 0.638-8.347 0.574-11.277-0.382-2.74-0.956-7.01-6.308-11.66-10.067-4.588-3.76-9.559-5.862-7.965-10.769 1.02-3.313 2.995-6.816 8.028-11.213 2.612-2.23 6.371-7.901 9.876-10.195 2.867-1.847 6.499-0.447 9.812-0.828z"/>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#fc0" d="m167.982 83.609c1.008 2.088 3.6 2.376 5.328 3.312 1.655 0.936 2.592 1.152 3.239 0.792 1.44-0.792 0.36-3.384-1.079-4.32-1.368-0.935-8.064-1.151-7.488 0.216z"/>
+ <path fill="#f9c600" d="m168.125 83.631c0.982 2.035 3.508 2.316 5.193 3.229 1.614 0.912 2.526 1.123 3.158 0.771 1.402-0.771 0.35-3.298-1.054-4.21-1.332-0.913-7.859-1.123-7.297 0.21z"/>
+ <path fill="#f4c000" d="m168.267 83.653c0.957 1.982 3.418 2.255 5.058 3.144 1.572 0.889 2.461 1.094 3.076 0.752 1.367-0.752 0.342-3.213-1.025-4.101-1.299-0.889-7.656-1.094-7.109 0.205z"/>
+ <path fill="#efba00" d="m168.409 83.674c0.932 1.929 3.327 2.195 4.924 3.06 1.53 0.865 2.395 1.064 2.993 0.732 1.331-0.732 0.333-3.127-0.998-3.992-1.264-0.864-7.451-1.064-6.919 0.2z"/>
+ <path fill="#eab500" d="m168.552 83.696c0.905 1.876 3.234 2.135 4.787 2.977 1.488 0.841 2.329 1.035 2.912 0.711 1.294-0.711 0.323-3.041-0.971-3.882-1.228-0.841-7.246-1.036-6.728 0.194z"/>
+ <path fill="#e5af00" d="m168.694 83.718c0.881 1.823 3.144 2.075 4.653 2.892 1.446 0.818 2.264 1.006 2.83 0.692 1.257-0.692 0.313-2.956-0.943-3.773-1.195-0.818-7.043-1.007-6.54 0.189z"/>
+ <path fill="#e0a900" d="m168.837 83.739c0.855 1.771 3.053 2.015 4.519 2.809 1.403 0.793 2.198 0.977 2.747 0.671 1.221-0.671 0.306-2.87-0.916-3.664-1.161-0.793-6.839-0.976-6.35 0.184z"/>
+ <path fill="#dba300" d="m168.979 83.761c0.829 1.718 2.962 1.955 4.383 2.725 1.363 0.77 2.132 0.948 2.666 0.651 1.184-0.651 0.296-2.784-0.889-3.554-1.125-0.77-6.634-0.948-6.16 0.178z"/>
+ <path fill="#d69e00" d="m169.121 83.782c0.804 1.665 2.871 1.895 4.249 2.641 1.32 0.747 2.066 0.918 2.583 0.631 1.148-0.631 0.287-2.698-0.861-3.444-1.091-0.746-6.43-0.919-5.971 0.172z"/>
+ <path fill="#d19800" d="m169.264 83.804c0.777 1.612 2.778 1.834 4.112 2.557 1.279 0.723 2.001 0.889 2.501 0.611 1.112-0.611 0.278-2.612-0.834-3.335-1.055-0.722-6.224-0.889-5.779 0.167z"/>
+ <path fill="#cc9200" d="m169.406 83.826c0.753 1.559 2.688 1.774 3.979 2.473 1.236 0.699 1.936 0.86 2.42 0.591 1.074-0.591 0.269-2.527-0.808-3.226-1.021-0.699-6.021-0.86-5.591 0.162z"/>
+ <path fill="#c68c00" d="m169.549 83.847c0.728 1.506 2.597 1.714 3.844 2.389 1.194 0.675 1.869 0.831 2.337 0.571 1.039-0.571 0.26-2.441-0.779-3.116-0.988-0.675-5.818-0.831-5.402 0.156z"/>
+ <path fill="#c18700" d="m169.691 83.869c0.702 1.453 2.506 1.654 3.709 2.305 1.152 0.652 1.803 0.802 2.254 0.551 1.002-0.551 0.251-2.355-0.751-3.006-0.953-0.652-5.613-0.802-5.212 0.15z"/>
+ <path fill="#bc8100" d="m169.833 83.89c0.677 1.4 2.415 1.594 3.574 2.221 1.111 0.628 1.738 0.772 2.173 0.531 0.965-0.531 0.241-2.27-0.725-2.897-0.917-0.627-5.408-0.772-5.022 0.145z"/>
+ <path fill="#b77b00" d="m169.976 83.912c0.65 1.347 2.322 1.533 3.438 2.137 1.069 0.604 1.673 0.743 2.091 0.511 0.93-0.511 0.233-2.184-0.696-2.788-0.884-0.603-5.205-0.743-4.833 0.14z"/>
+ <path fill="#b27500" d="m170.118 83.934c0.626 1.294 2.232 1.473 3.304 2.053 1.027 0.581 1.606 0.714 2.009 0.491 0.893-0.491 0.224-2.098-0.669-2.678-0.85-0.58-5.001-0.715-4.644 0.134z"/>
+ <path fill="#ad7000" d="m170.261 83.955c0.6 1.242 2.14 1.413 3.168 1.97 0.984 0.557 1.541 0.685 1.927 0.47 0.855-0.47 0.214-2.012-0.644-2.569-0.812-0.555-4.794-0.684-4.451 0.129z"/>
+ <path fill="#a86a00" d="m170.403 83.977c0.574 1.189 2.05 1.353 3.034 1.886 0.942 0.533 1.475 0.656 1.844 0.45 0.82-0.45 0.205-1.926-0.615-2.459-0.779-0.533-4.591-0.656-4.263 0.123z"/>
+ <path fill="#a36400" d="m170.545 83.998c0.55 1.136 1.959 1.292 2.899 1.802 0.901 0.509 1.41 0.626 1.762 0.43 0.783-0.43 0.197-1.841-0.587-2.35-0.745-0.508-4.387-0.626-4.074 0.118z"/>
+ <path fill="#9e5e00" d="m170.688 84.02c0.522 1.083 1.867 1.232 2.764 1.718 0.859 0.486 1.343 0.597 1.68 0.41 0.746-0.41 0.188-1.755-0.561-2.241-0.709-0.484-4.182-0.597-3.883 0.113z"/>
+ <path fill="#995900" d="m170.83 84.042c0.498 1.03 1.776 1.172 2.629 1.634 0.817 0.462 1.278 0.568 1.599 0.39 0.71-0.39 0.178-1.669-0.533-2.131-0.676-0.461-3.979-0.568-3.695 0.107z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#fc0" d="m152.875 86.29c-0.325 0.813 1.952 2.359 3.091 1.301 1.222-1.057 2.686-2.033 3.175-2.359 2.195-1.465 1.383-2.522-2.278-1.871-3.663 0.651-3.663 2.115-3.988 2.929z"/>
+ <path fill="#f9c600" d="m152.934 86.279c-0.318 0.794 1.906 2.304 3.019 1.271 1.193-1.033 2.623-1.986 3.102-2.305 2.145-1.431 1.351-2.463-2.226-1.828-3.578 0.637-3.578 2.067-3.895 2.862z"/>
+ <path fill="#f4c000" d="m152.993 86.269c-0.31 0.775 1.861 2.25 2.948 1.241 1.164-1.008 2.56-1.939 3.026-2.25 2.095-1.397 1.319-2.405-2.173-1.784-3.491 0.62-3.491 2.017-3.801 2.793z"/>
+ <path fill="#efba00" d="m153.051 86.258c-0.302 0.757 1.817 2.195 2.878 1.211 1.136-0.984 2.497-1.892 2.952-2.195 2.044-1.363 1.287-2.347-2.118-1.741-3.409 0.606-3.409 1.968-3.712 2.725z"/>
+ <path fill="#eab500" d="m153.11 86.248c-0.295 0.738 1.771 2.141 2.805 1.181 1.108-0.959 2.437-1.845 2.88-2.141 1.993-1.329 1.255-2.289-2.066-1.698-3.324 0.591-3.324 1.92-3.619 2.658z"/>
+ <path fill="#e5af00" d="m153.169 86.238c-0.287 0.719 1.727 2.086 2.733 1.151 1.08-0.935 2.374-1.798 2.807-2.086 1.942-1.296 1.224-2.23-2.015-1.655s-3.238 1.87-3.525 2.59z"/>
+ <path fill="#e0a900" d="m153.228 86.228c-0.28 0.7 1.681 2.032 2.661 1.121 1.052-0.91 2.312-1.751 2.732-2.031 1.893-1.262 1.191-2.172-1.961-1.611-3.152 0.559-3.152 1.82-3.432 2.521z"/>
+ <path fill="#dba300" d="m153.286 86.217c-0.271 0.681 1.636 1.977 2.591 1.09 1.023-0.886 2.25-1.704 2.659-1.977 1.84-1.228 1.159-2.114-1.909-1.568-3.068 0.547-3.068 1.773-3.341 2.455z"/>
+ <path fill="#d69e00" d="m153.345 86.207c-0.265 0.662 1.591 1.922 2.519 1.061 0.995-0.862 2.188-1.657 2.586-1.922 1.789-1.194 1.127-2.055-1.855-1.525-2.985 0.53-2.985 1.723-3.25 2.386z"/>
+ <path fill="#d19800" d="m153.404 86.197c-0.257 0.643 1.546 1.868 2.447 1.03 0.967-0.837 2.126-1.61 2.512-1.868 1.739-1.16 1.095-1.997-1.803-1.481-2.899 0.516-2.899 1.674-3.156 2.319z"/>
+ <path fill="#cc9200" d="m153.463 86.187c-0.25 0.625 1.5 1.813 2.375 1 0.939-0.813 2.064-1.563 2.439-1.813 1.688-1.126 1.063-1.938-1.75-1.438-2.814 0.5-2.814 1.625-3.064 2.251z"/>
+ <path fill="#c68c00" d="m153.521 86.176c-0.242 0.605 1.456 1.758 2.304 0.97 0.911-0.788 2.002-1.516 2.366-1.758 1.637-1.092 1.031-1.88-1.698-1.395-2.729 0.486-2.729 1.576-2.972 2.183z"/>
+ <path fill="#c18700" d="m153.58 86.166c-0.233 0.587 1.41 1.704 2.233 0.939 0.882-0.763 1.938-1.469 2.292-1.704 1.586-1.058 0.999-1.822-1.646-1.352-2.644 0.472-2.644 1.529-2.879 2.117z"/>
+ <path fill="#bc8100" d="m153.639 86.156c-0.228 0.568 1.364 1.649 2.16 0.91 0.854-0.739 1.878-1.422 2.219-1.649 1.536-1.024 0.967-1.764-1.593-1.308s-2.559 1.477-2.786 2.047z"/>
+ <path fill="#b77b00" d="m153.698 86.146c-0.22 0.549 1.32 1.594 2.089 0.879 0.825-0.715 1.815-1.375 2.146-1.595 1.484-0.99 0.935-1.705-1.54-1.265s-2.475 1.43-2.695 1.981z"/>
+ <path fill="#b27500" d="m153.756 86.135c-0.211 0.53 1.275 1.54 2.019 0.85 0.797-0.69 1.753-1.328 2.072-1.54 1.434-0.957 0.902-1.646-1.487-1.221s-2.391 1.38-2.604 1.911z"/>
+ <path fill="#ad7000" d="m153.815 86.125c-0.204 0.512 1.229 1.486 1.946 0.82 0.769-0.666 1.69-1.281 1.997-1.486 1.385-0.922 0.871-1.588-1.434-1.178s-2.304 1.331-2.509 1.844z"/>
+ <path fill="#a86a00" d="m153.874 86.114c-0.196 0.493 1.185 1.431 1.875 0.79 0.74-0.642 1.628-1.234 1.924-1.431 1.332-0.889 0.84-1.53-1.381-1.135s-2.221 1.283-2.418 1.776z"/>
+ <path fill="#a36400" d="m153.933 86.104c-0.189 0.474 1.139 1.376 1.803 0.759 0.712-0.617 1.566-1.187 1.851-1.376 1.281-0.855 0.808-1.472-1.329-1.092-2.135 0.38-2.135 1.234-2.325 1.709z"/>
+ <path fill="#9e5e00" d="m153.991 86.094c-0.181 0.455 1.095 1.322 1.732 0.729 0.684-0.592 1.504-1.14 1.776-1.321 1.231-0.821 0.775-1.414-1.274-1.048-2.051 0.364-2.051 1.184-2.234 1.64z"/>
+ <path fill="#995900" d="m154.05 86.083c-0.174 0.436 1.05 1.267 1.66 0.699 0.656-0.568 1.442-1.093 1.704-1.267 1.181-0.787 0.743-1.355-1.223-1.005s-1.966 1.136-2.141 1.573z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#fc0" d="m156.951 107.887c-0.229 2.858 6.343-4.286 6.743-4.915 0.856-1.543 3.715-5.886 4.172-7.715 0.857-3.2 2.401-5.543 1.429-8.915-0.343-1.086-2.742-1.372-3.829-0.687-3.086 1.829-2.629 4.058-2.972 6.115-1.143 5.831-5.143 11.717-5.543 16.117z"/>
+ <path fill="#ffcc02" d="m157.22 107.441c-0.22 2.787 6.178-4.188 6.566-4.802 0.833-1.506 3.614-5.745 4.056-7.529 0.831-3.122 2.333-5.408 1.382-8.695-0.337-1.058-2.678-1.333-3.735-0.663-3.006 1.788-2.557 3.96-2.889 5.967-1.105 5.685-4.997 11.431-5.38 15.722z"/>
+ <path fill="#ffcc05" d="m157.488 106.995c-0.209 2.715 6.014-4.091 6.392-4.69 0.811-1.469 3.513-5.603 3.941-7.342 0.804-3.043 2.264-5.273 1.331-8.474-0.329-1.031-2.61-1.295-3.64-0.64-2.927 1.747-2.486 3.863-2.806 5.818-1.068 5.541-4.851 11.146-5.218 15.328z"/>
+ <path fill="#ffcc07" d="m157.757 106.548c-0.198 2.645 5.85-3.993 6.217-4.577 0.785-1.431 3.409-5.461 3.824-7.156 0.779-2.964 2.196-5.138 1.282-8.253-0.322-1.003-2.543-1.257-3.545-0.618-2.847 1.706-2.414 3.766-2.722 5.67-1.031 5.398-4.706 10.862-5.056 14.934z"/>
+ <path fill="#ffcd0a" d="m158.026 106.102c-0.189 2.573 5.684-3.896 6.04-4.465 0.762-1.394 3.309-5.32 3.709-6.969 0.753-2.886 2.129-5.004 1.233-8.033-0.315-0.976-2.478-1.219-3.45-0.595-2.768 1.665-2.343 3.668-2.64 5.522-0.993 5.254-4.558 10.577-4.892 14.54z"/>
+ <path fill="#ffcd0c" d="m158.294 105.655c-0.179 2.503 5.52-3.798 5.865-4.351 0.738-1.357 3.207-5.179 3.594-6.783 0.727-2.807 2.061-4.869 1.185-7.813-0.309-0.948-2.411-1.18-3.356-0.572-2.687 1.623-2.271 3.571-2.556 5.374-0.958 5.11-4.414 10.291-4.732 14.145z"/>
+ <path fill="#ffcd0f" d="m158.563 105.209c-0.169 2.431 5.354-3.701 5.688-4.239 0.715-1.319 3.106-5.037 3.479-6.596 0.7-2.728 1.992-4.734 1.135-7.592-0.301-0.92-2.344-1.142-3.261-0.549-2.608 1.583-2.199 3.474-2.473 5.226-0.919 4.965-4.267 10.005-4.568 13.75z"/>
+ <path fill="#ffcd11" d="m158.831 104.762c-0.159 2.361 5.19-3.602 5.515-4.126 0.69-1.282 3.004-4.896 3.361-6.409 0.674-2.649 1.924-4.599 1.087-7.372-0.295-0.893-2.277-1.104-3.167-0.526-2.527 1.541-2.128 3.376-2.389 5.077-0.883 4.822-4.122 9.721-4.407 13.356z"/>
+ <path fill="#ffce14" d="m159.1 104.316c-0.149 2.289 5.024-3.505 5.338-4.014 0.667-1.244 2.901-4.754 3.247-6.223 0.646-2.571 1.854-4.464 1.037-7.151-0.287-0.865-2.211-1.065-3.072-0.504-2.448 1.5-2.056 3.279-2.306 4.929-0.845 4.679-3.976 9.437-4.244 12.963z"/>
+ <path fill="#ffce16" d="m159.369 103.869c-0.139 2.219 4.86-3.407 5.162-3.9 0.643-1.208 2.801-4.613 3.131-6.037 0.622-2.492 1.787-4.329 0.988-6.93-0.28-0.838-2.146-1.027-2.978-0.481-2.368 1.459-1.983 3.182-2.223 4.781-0.807 4.533-3.829 9.151-4.08 12.567z"/>
+ <path fill="#ffce19" d="m159.637 103.423c-0.13 2.147 4.695-3.31 4.986-3.788 0.62-1.17 2.699-4.471 3.016-5.85 0.596-2.414 1.719-4.195 0.939-6.71-0.273-0.81-2.079-0.989-2.883-0.458-2.289 1.418-1.913 3.084-2.139 4.632-0.77 4.391-3.684 8.866-3.919 12.174z"/>
+ <path fill="#ffce1c" d="m159.906 102.977c-0.119 2.076 4.531-3.213 4.811-3.676 0.597-1.133 2.599-4.33 2.899-5.664 0.57-2.335 1.651-4.06 0.891-6.49-0.267-0.782-2.012-0.95-2.787-0.435-2.21 1.377-1.842 2.987-2.057 4.484-0.734 4.247-3.539 8.582-3.757 11.781z"/>
+ <path fill="#ffcf1e" d="m160.174 102.53c-0.108 2.005 4.366-3.115 4.637-3.563 0.571-1.096 2.496-4.189 2.784-5.478 0.543-2.256 1.581-3.925 0.841-6.269-0.26-0.754-1.945-0.912-2.693-0.412-2.129 1.336-1.77 2.889-1.973 4.336-0.697 4.103-3.394 8.297-3.596 11.386z"/>
+ <path fill="#ffcf21" d="m160.443 102.084c-0.099 1.934 4.201-3.018 4.46-3.45 0.548-1.059 2.394-4.047 2.668-5.291 0.517-2.178 1.514-3.79 0.793-6.049-0.253-0.727-1.879-0.874-2.599-0.39-2.051 1.295-1.698 2.792-1.891 4.188-0.658 3.959-3.246 8.012-3.431 10.992z"/>
+ <path fill="#ffcf23" d="m160.712 101.637c-0.089 1.863 4.036-2.919 4.283-3.337 0.526-1.021 2.294-3.905 2.553-5.104 0.491-2.099 1.447-3.655 0.744-5.828-0.246-0.699-1.813-0.835-2.505-0.367-1.969 1.253-1.625 2.694-1.805 4.04-0.623 3.814-3.101 7.726-3.27 10.596z"/>
+ <path fill="#ffcf26" d="m160.98 101.191c-0.079 1.792 3.872-2.822 4.107-3.225 0.502-0.984 2.192-3.764 2.438-4.918 0.464-2.02 1.378-3.52 0.694-5.607-0.238-0.672-1.746-0.797-2.41-0.344-1.89 1.212-1.555 2.597-1.723 3.891-0.583 3.671-2.953 7.442-3.106 10.203z"/>
+ <path fill="#ffd028" d="m161.249 100.744c-0.068 1.721 3.707-2.724 3.933-3.112 0.478-0.947 2.091-3.623 2.321-4.731 0.439-1.942 1.311-3.386 0.646-5.387-0.232-0.645-1.68-0.758-2.316-0.321-1.81 1.171-1.481 2.5-1.639 3.743-0.548 3.527-2.809 7.156-2.945 9.808z"/>
+ <path fill="#ffd02b" d="m161.517 100.298c-0.06 1.65 3.543-2.627 3.757-2.999 0.454-0.91 1.989-3.481 2.206-4.545 0.413-1.863 1.242-3.25 0.597-5.167-0.225-0.617-1.613-0.72-2.221-0.298-1.73 1.13-1.411 2.402-1.557 3.595-0.509 3.383-2.662 6.871-2.782 9.414z"/>
+ <path fill="#ffd02d" d="m161.786 99.852c-0.049 1.579 3.377-2.529 3.581-2.887 0.431-0.872 1.887-3.34 2.091-4.359 0.387-1.784 1.173-3.116 0.547-4.946-0.217-0.589-1.546-0.682-2.126-0.275-1.649 1.089-1.339 2.305-1.472 3.446-0.474 3.24-2.518 6.587-2.621 9.021z"/>
+ <path fill="#ffd030" d="m162.055 99.405c-0.039 1.508 3.212-2.432 3.404-2.773 0.407-0.835 1.786-3.199 1.976-4.172 0.359-1.706 1.104-2.981 0.499-4.726-0.211-0.562-1.481-0.644-2.032-0.253-1.571 1.048-1.268 2.208-1.389 3.298-0.436 3.096-2.372 6.302-2.458 8.626z"/>
+ <path fill="#ffd133" d="m162.323 98.958c-0.029 1.437 3.048-2.334 3.23-2.661 0.383-0.798 1.684-3.057 1.858-3.986 0.334-1.627 1.037-2.846 0.45-4.505-0.204-0.534-1.414-0.605-1.938-0.23-1.49 1.007-1.195 2.11-1.306 3.15-0.397 2.953-2.224 6.018-2.294 8.232z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#fc0" d="m179.646 95.994c-3.168 3.456-5.4 6.767-7.2 9-1.872 2.304-6.48 5.04-4.176 7.704 1.943 2.376 9.936-1.944 16.128-6.552 6.12-4.608 15.696-8.711 11.016-13.967-2.448-2.664-8.208-2.088-10.439-0.648-1.729 1.078-2.737 1.655-5.329 4.463z"/>
+ <path fill="#ffcc02" d="m179.782 96.147c-3.118 3.378-5.313 6.628-7.086 8.809-1.841 2.249-6.375 4.945-4.13 7.534 1.893 2.31 9.724-1.943 15.795-6.469 6.001-4.525 15.38-8.574 10.82-13.682-2.387-2.588-8.022-1.998-10.209-0.584-1.692 1.062-2.665 1.67-5.19 4.392z"/>
+ <path fill="#ffcc05" d="m179.919 96.3c-3.068 3.3-5.227 6.488-6.973 8.619-1.81 2.193-6.271 4.85-4.085 7.364 1.843 2.243 9.513-1.943 15.463-6.386 5.882-4.442 15.064-8.437 10.623-13.396-2.323-2.513-7.835-1.907-9.978-0.52-1.655 1.044-2.593 1.684-5.05 4.319z"/>
+ <path fill="#ffcc07" d="m180.055 96.454c-3.02 3.222-5.14 6.347-6.859 8.428-1.78 2.138-6.166 4.754-4.04 7.194 1.793 2.177 9.302-1.942 15.131-6.303 5.762-4.359 14.748-8.299 10.427-13.11-2.261-2.437-7.648-1.817-9.747-0.456-1.619 1.025-2.522 1.698-4.912 4.247z"/>
+ <path fill="#ffcd0a" d="m180.191 96.607c-2.97 3.143-5.052 6.207-6.745 8.237-1.749 2.082-6.063 4.659-3.994 7.023 1.743 2.111 9.09-1.941 14.798-6.219 5.644-4.276 14.433-8.162 10.231-12.824-2.199-2.361-7.463-1.727-9.518-0.392-1.581 1.008-2.45 1.713-4.772 4.175z"/>
+ <path fill="#ffcd0c" d="m180.327 96.761c-2.92 3.065-4.965 6.066-6.631 8.047-1.718 2.027-5.957 4.564-3.949 6.853 1.693 2.044 8.878-1.94 14.466-6.136 5.524-4.194 14.116-8.024 10.034-12.538-2.137-2.286-7.275-1.636-9.285-0.328-1.546 0.988-2.38 1.726-4.635 4.102z"/>
+ <path fill="#ffcd0f" d="m180.464 96.914c-2.871 2.987-4.879 5.926-6.518 7.857-1.688 1.971-5.854 4.468-3.903 6.683 1.643 1.978 8.666-1.94 14.133-6.053 5.404-4.111 13.801-7.887 9.839-12.251-2.075-2.21-7.091-1.546-9.056-0.264-1.509 0.969-2.308 1.738-4.495 4.028z"/>
+ <path fill="#ffcd11" d="m180.6 97.067c-2.821 2.909-4.792 5.786-6.404 7.667-1.657 1.916-5.748 4.373-3.858 6.512 1.593 1.912 8.455-1.938 13.802-5.969 5.284-4.028 13.484-7.75 9.641-11.966-2.012-2.134-6.902-1.456-8.823-0.199-1.474 0.951-2.238 1.752-4.358 3.955z"/>
+ <path fill="#ffce14" d="m180.736 97.221c-2.771 2.83-4.705 5.645-6.29 7.476-1.626 1.86-5.644 4.278-3.813 6.342 1.542 1.845 8.244-1.938 13.47-5.886 5.166-3.945 13.169-7.612 9.444-11.68-1.949-2.059-6.716-1.365-8.592-0.135-1.437 0.933-2.166 1.766-4.219 3.883z"/>
+ <path fill="#ffce16" d="m180.872 97.375c-2.722 2.752-4.617 5.504-6.176 7.286-1.595 1.805-5.539 4.182-3.767 6.172 1.49 1.779 8.031-1.937 13.136-5.803 5.046-3.862 12.853-7.475 9.249-11.394-1.889-1.983-6.53-1.274-8.362-0.071-1.4 0.914-2.095 1.779-4.08 3.81z"/>
+ <path fill="#ffce19" d="m181.009 97.528c-2.673 2.674-4.53 5.364-6.063 7.095-1.564 1.749-5.435 4.087-3.722 6.001 1.44 1.713 7.82-1.936 12.804-5.719 4.927-3.78 12.537-7.338 9.052-11.108-1.825-1.907-6.343-1.185-8.13-0.007-1.364 0.896-2.024 1.793-3.941 3.738z"/>
+ <path fill="#ffce1c" d="m181.145 97.682c-2.623 2.595-4.444 5.225-5.949 6.904-1.534 1.693-5.33 3.992-3.676 5.831 1.39 1.646 7.608-1.935 12.471-5.636 4.808-3.697 12.221-7.2 8.856-10.822-1.764-1.832-6.157-1.094-7.9 0.057-1.327 0.878-1.952 1.807-3.802 3.666z"/>
+ <path fill="#ffcf1e" d="m181.281 97.835c-2.573 2.517-4.357 5.084-5.835 6.714-1.503 1.638-5.226 3.896-3.631 5.661 1.34 1.58 7.396-1.935 12.139-5.553 4.689-3.614 11.905-7.063 8.659-10.536-1.701-1.756-5.97-1.004-7.668 0.121-1.291 0.86-1.881 1.821-3.664 3.593z"/>
+ <path fill="#ffcf21" d="m181.417 97.988c-2.522 2.439-4.27 4.944-5.721 6.524-1.472 1.582-5.121 3.801-3.586 5.491 1.29 1.513 7.186-1.934 11.807-5.47 4.569-3.531 11.589-6.926 8.463-10.25-1.639-1.68-5.783-0.914-7.438 0.186-1.254 0.84-1.809 1.834-3.525 3.519z"/>
+ <path fill="#ffcf23" d="m181.554 98.142c-2.476 2.361-4.185 4.803-5.608 6.333-1.441 1.527-5.017 3.706-3.54 5.32 1.24 1.447 6.974-1.933 11.474-5.386 4.45-3.448 11.273-6.788 8.268-9.964-1.577-1.605-5.599-0.823-7.207 0.25-1.22 0.822-1.74 1.848-3.387 3.447z"/>
+ <path fill="#ffcf26" d="m181.69 98.295c-2.425 2.283-4.098 4.663-5.494 6.143-1.411 1.471-4.912 3.61-3.495 5.15 1.19 1.381 6.763-1.932 11.142-5.303 4.331-3.366 10.957-6.65 8.07-9.679-1.514-1.529-5.411-0.732-6.976 0.313-1.182 0.805-1.667 1.863-3.247 3.376z"/>
+ <path fill="#ffd028" d="m181.826 98.449c-2.375 2.204-4.009 4.522-5.38 5.952-1.38 1.416-4.808 3.515-3.449 4.98 1.14 1.314 6.551-1.932 10.81-5.22 4.211-3.283 10.641-6.513 7.874-9.393-1.452-1.454-5.226-0.642-6.745 0.378-1.147 0.786-1.597 1.876-3.11 3.303z"/>
+ <path fill="#ffd02b" d="m181.962 98.602c-2.324 2.127-3.922 4.382-5.266 5.762-1.349 1.36-4.703 3.42-3.404 4.809 1.089 1.248 6.34-1.93 10.478-5.136 4.092-3.2 10.325-6.376 7.677-9.106-1.389-1.378-5.038-0.552-6.513 0.441-1.111 0.768-1.526 1.89-2.972 3.23z"/>
+ <path fill="#ffd02d" d="m182.099 98.756c-2.276 2.048-3.836 4.241-5.153 5.571-1.318 1.305-4.599 3.324-3.359 4.639 1.039 1.182 6.128-1.93 10.146-5.053 3.973-3.117 10.009-6.238 7.48-8.82-1.328-1.303-4.852-0.462-6.282 0.506-1.074 0.748-1.454 1.903-2.832 3.157z"/>
+ <path fill="#ffd030" d="m182.235 98.909c-2.228 1.97-3.749 4.101-5.039 5.381-1.288 1.249-4.494 3.229-3.313 4.469 0.988 1.115 5.916-1.929 9.813-4.97 3.853-3.034 9.693-6.101 7.285-8.535-1.267-1.227-4.666-0.371-6.052 0.57-1.038 0.731-1.384 1.918-2.694 3.085z"/>
+ <path fill="#ffd133" d="m182.371 99.063c-2.177 1.892-3.662 3.96-4.925 5.19-1.257 1.193-4.39 3.133-3.268 4.298 0.938 1.049 5.704-1.928 9.479-4.886 3.734-2.952 9.377-5.963 7.088-8.249-1.203-1.151-4.479-0.281-5.821 0.634-0.999 0.713-1.31 1.931-2.553 3.013z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#fff" d="m186.414 168.569c0.864-2.808 28.872-9.432 33.48-7.272 4.536 2.16 26.279 33.768 22.392 35.496-3.888 1.657-12.24-10.512-24.408-16.128s-32.328-9.216-31.464-12.096z"/>
+ <path fill="#f9f9f9" d="m187.239 168.626c0.848-2.761 28.145-9.076 32.69-6.997 4.476 2.079 25.768 32.897 21.943 34.591-3.824 1.625-11.965-10.346-23.94-15.874-11.976-5.527-31.541-8.89-30.693-11.72z"/>
+ <path fill="#f4f4f4" d="m188.063 168.683c0.832-2.714 27.418-8.72 31.899-6.722 4.417 1.998 25.259 32.026 21.497 33.685-3.76 1.595-11.689-10.18-23.474-15.619-11.782-5.438-30.754-8.565-29.922-11.344z"/>
+ <path fill="#efefef" d="m188.888 168.74c0.814-2.668 26.69-8.364 31.109-6.447 4.357 1.917 24.746 31.155 21.049 32.779-3.695 1.563-11.416-10.014-23.007-15.364-11.59-5.349-29.967-8.239-29.151-10.968z"/>
+ <path fill="#eaeaea" d="m189.712 168.797c0.801-2.621 25.964-8.009 30.32-6.173 4.299 1.837 24.235 30.285 20.603 31.874-3.633 1.532-11.142-9.847-22.54-15.109-11.4-5.261-29.182-7.914-28.383-10.592z"/>
+ <path fill="#e5e5e5" d="m190.537 168.853c0.783-2.573 25.236-7.652 29.53-5.897 4.239 1.756 23.723 29.414 20.155 30.968-3.569 1.501-10.867-9.681-22.074-14.854-11.206-5.172-28.395-7.589-27.611-10.217z"/>
+ <path fill="#e0e0e0" d="m191.361 168.91c0.768-2.527 24.51-7.296 28.74-5.622 4.18 1.675 23.212 28.543 19.708 30.063-3.505 1.469-10.593-9.516-21.607-14.6-11.014-5.083-27.608-7.263-26.841-9.841z"/>
+ <path fill="#dbdbdb" d="m192.186 168.967c0.751-2.48 23.781-6.941 27.95-5.347 4.119 1.593 22.7 27.671 19.26 29.157-3.441 1.438-10.318-9.349-21.141-14.345-10.821-4.994-26.821-6.938-26.069-9.465z"/>
+ <path fill="#d6d6d6" d="m193.01 169.024c0.735-2.433 23.057-6.585 27.16-5.073 4.062 1.513 22.19 26.801 18.813 28.252-3.377 1.407-10.043-9.183-20.673-14.09-10.629-4.906-26.035-6.612-25.3-9.089z"/>
+ <path fill="#d1d1d1" d="m193.835 169.081c0.72-2.387 22.328-6.229 26.37-4.798 4.001 1.432 21.678 25.93 18.365 27.346-3.313 1.376-9.768-9.017-20.206-13.835-10.437-4.817-25.248-6.287-24.529-8.713z"/>
+ <path fill="#ccc" d="m194.659 169.137c0.703-2.339 21.603-5.873 25.58-4.521 3.942 1.351 21.167 25.059 17.918 26.44-3.249 1.345-9.493-8.851-19.739-13.58-10.245-4.729-24.462-5.963-23.759-8.339z"/>
+ <path fill="#c6c6c6" d="m195.484 169.194c0.687-2.292 20.874-5.517 24.79-4.247 3.882 1.27 20.655 24.188 17.47 25.535-3.185 1.314-9.219-8.685-19.271-13.326-10.054-4.639-23.676-5.636-22.989-7.962z"/>
+ <path fill="#c1c1c1" d="m196.308 169.251c0.671-2.246 20.147-5.161 24-3.973 3.822 1.19 20.145 23.318 17.022 24.63-3.121 1.283-8.943-8.519-18.805-13.071-9.859-4.551-22.888-5.311-22.217-7.586z"/>
+ <path fill="#bcbcbc" d="m197.133 169.308c0.654-2.199 19.421-4.805 23.21-3.698 3.764 1.109 19.634 22.447 16.575 23.724-3.057 1.252-8.669-8.353-18.338-12.816-9.668-4.462-22.102-4.985-21.447-7.21z"/>
+ <path fill="#b7b7b7" d="m197.957 169.365c0.64-2.152 18.693-4.45 22.42-3.423 3.705 1.027 19.122 21.575 16.129 22.818-2.993 1.221-8.395-8.186-17.872-12.561-9.476-4.373-21.315-4.66-20.677-6.834z"/>
+ <path fill="#b2b2b2" d="m198.782 169.421c0.622-2.105 17.966-4.093 21.63-3.147 3.646 0.946 18.61 20.704 15.681 21.912-2.93 1.19-8.12-8.02-17.404-12.306-9.284-4.284-20.53-4.335-19.907-6.459z"/>
+ <path fill="#adadad" d="m199.606 169.478c0.606-2.058 17.239-3.737 20.84-2.873 3.586 0.866 18.099 19.834 15.234 21.008-2.866 1.158-7.847-7.855-16.938-12.052-9.091-4.196-19.742-4.009-19.136-6.083z"/>
+ <path fill="#a8a8a8" d="m200.431 169.535c0.59-2.011 16.512-3.382 20.05-2.598 3.525 0.785 17.588 18.963 14.786 20.102-2.803 1.127-7.571-7.688-16.472-11.797-8.898-4.107-18.955-3.684-18.364-5.707z"/>
+ <path fill="#a3a3a3" d="m201.255 169.592c0.574-1.965 15.785-3.026 19.261-2.323 3.467 0.704 17.076 18.092 14.339 19.196-2.738 1.096-7.296-7.522-16.004-11.542-8.707-4.018-18.17-3.358-17.596-5.331z"/>
+ <path fill="#9e9e9e" d="m202.08 169.649c0.559-1.918 15.059-2.67 18.47-2.048 3.407 0.623 16.565 17.221 13.892 18.29-2.674 1.065-7.022-7.356-15.537-11.287-8.515-3.929-17.383-3.033-16.825-4.955z"/>
+ <path fill="#999" d="m202.904 169.705c0.542-1.871 14.331-2.314 17.68-1.773 3.349 0.542 16.055 16.35 13.444 17.385-2.61 1.034-6.747-7.19-15.07-11.032-8.322-3.841-16.596-2.708-16.054-4.58z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#fff" d="m151.134 211.625c2.881 0.144 0.145 16.271 0.145 32.903s2.231 22.464 0.144 24.552-5.688-5.399-5.688-22.031c-0.001-16.632 2.519-35.568 5.399-35.424z"/>
+ <path fill="#f9f9f9" d="m151.105 212.016c2.783 0.162 0.109 16.052 0.097 32.419-0.012 16.366 2.188 22.208 0.164 24.237-2.02 2.029-5.561-5.383-5.546-21.752 0.012-16.367 2.502-35.065 5.285-34.904z"/>
+ <path fill="#f4f4f4" d="m151.075 212.407c2.687 0.18 0.076 15.832 0.051 31.934-0.023 16.102 2.143 21.951 0.185 23.924-1.953 1.968-5.435-5.367-5.405-21.473 0.024-16.103 2.484-34.564 5.169-34.385z"/>
+ <path fill="#efefef" d="m151.046 212.797c2.588 0.197 0.041 15.613 0.004 31.449-0.036 15.836 2.098 21.694 0.204 23.609-1.886 1.907-5.308-5.352-5.263-21.195 0.037-15.835 2.467-34.058 5.055-33.863z"/>
+ <path fill="#eaeaea" d="m151.017 213.189c2.49 0.214 0.007 15.392-0.043 30.962-0.05 15.571 2.052 21.439 0.224 23.297-1.818 1.848-5.181-5.334-5.122-20.916 0.049-15.571 2.45-33.557 4.941-33.343z"/>
+ <path fill="#e5e5e5" d="m150.987 213.581c2.394 0.23-0.027 15.17-0.089 30.477s2.007 21.182 0.244 22.982c-1.751 1.787-5.055-5.32-4.98-20.638 0.061-15.305 2.431-33.053 4.825-32.821z"/>
+ <path fill="#e0e0e0" d="m150.958 213.971c2.297 0.248-0.062 14.951-0.136 29.99-0.074 15.041 1.962 20.927 0.264 22.668-1.683 1.728-4.928-5.301-4.839-20.356 0.074-15.04 2.414-32.551 4.711-32.302z"/>
+ <path fill="#dbdbdb" d="m150.928 214.362c2.199 0.266-0.096 14.73-0.182 29.506-0.087 14.775 1.915 20.67 0.282 22.354-1.615 1.667-4.8-5.286-4.696-20.078 0.087-14.776 2.397-32.048 4.596-31.782z"/>
+ <path fill="#d6d6d6" d="m150.899 214.752c2.102 0.283-0.13 14.511-0.229 29.021-0.099 14.511 1.87 20.413 0.303 22.04-1.549 1.607-4.674-5.27-4.556-19.799 0.1-14.51 2.38-31.545 4.482-31.262z"/>
+ <path fill="#d1d1d1" d="m150.87 215.144c2.005 0.301-0.165 14.29-0.274 28.535-0.112 14.245 1.824 20.155 0.321 21.725-1.479 1.548-4.547-5.252-4.413-19.519 0.11-14.245 2.361-31.043 4.366-30.741z"/>
+ <path fill="#ccc" d="m150.84 215.536c1.908 0.317-0.197 14.069-0.32 28.049-0.124 13.979 1.779 19.899 0.342 21.412-1.413 1.486-4.42-5.238-4.272-19.242 0.122-13.979 2.343-30.54 4.25-30.219z"/>
+ <path fill="#c6c6c6" d="m150.811 215.926c1.811 0.334-0.233 13.85-0.368 27.564-0.136 13.713 1.735 19.643 0.362 21.096-1.346 1.428-4.293-5.219-4.131-18.961 0.136-13.712 2.327-30.035 4.137-29.699z"/>
+ <path fill="#c1c1c1" d="m150.781 216.317c1.714 0.354-0.267 13.629-0.414 27.078s1.69 19.387 0.382 20.783c-1.277 1.367-4.166-5.203-3.989-18.682 0.148-13.449 2.308-29.533 4.021-29.179z"/>
+ <path fill="#bcbcbc" d="m150.752 216.708c1.616 0.371-0.301 13.41-0.461 26.594-0.161 13.184 1.646 19.13 0.402 20.469-1.211 1.307-4.04-5.188-3.847-18.402 0.16-13.185 2.29-29.033 3.906-28.661z"/>
+ <path fill="#b7b7b7" d="m150.723 217.099c1.519 0.387-0.336 13.188-0.509 26.106-0.173 12.92 1.601 18.875 0.423 20.156-1.144 1.246-3.913-5.171-3.706-18.123 0.172-12.919 2.273-28.529 3.792-28.139z"/>
+ <path fill="#b2b2b2" d="m150.693 217.491c1.422 0.404-0.37 12.969-0.554 25.621-0.186 12.653 1.555 18.617 0.441 19.842-1.076 1.187-3.786-5.156-3.563-17.846 0.184-12.652 2.255-28.024 3.676-27.617z"/>
+ <path fill="#adadad" d="m150.664 217.881c1.325 0.422-0.404 12.748-0.601 25.136-0.198 12.388 1.51 18.36 0.462 19.528-1.008 1.125-3.66-5.139-3.423-17.566 0.197-12.389 2.238-27.521 3.562-27.098z"/>
+ <path fill="#a8a8a8" d="m150.634 218.272c1.229 0.439-0.438 12.527-0.646 24.65-0.21 12.123 1.464 18.104 0.48 19.213-0.939 1.066-3.531-5.121-3.279-17.285 0.208-12.123 2.219-27.019 3.445-26.578z"/>
+ <path fill="#a3a3a3" d="m150.605 218.663c1.13 0.457-0.474 12.309-0.694 24.166-0.222 11.857 1.419 17.848 0.501 18.899-0.873 1.006-3.405-5.106-3.139-17.009 0.222-11.855 2.202-26.515 3.332-26.056z"/>
+ <path fill="#9e9e9e" d="m150.576 219.054c1.033 0.474-0.507 12.088-0.741 23.68-0.234 11.593 1.374 17.591 0.521 18.585-0.806 0.946-3.279-5.089-2.997-16.729 0.233-11.591 2.184-26.011 3.217-25.536z"/>
+ <path fill="#999" d="m150.546 219.444c0.937 0.492-0.541 11.868-0.787 23.195-0.246 11.326 1.329 17.335 0.541 18.271-0.737 0.885-3.151-5.074-2.855-16.449 0.245-11.328 2.166-25.509 3.101-25.017z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#fff" d="m157.434 167.161c1.735 0.192 12.437-2.218 12.822-1.254 0.386 0.772-6.651 2.893-8.966 5.303-0.771 0.771-2.796 2.603-4.049 2.41-0.964-0.096-1.543-2.121-2.989-3.664-3.471-3.47-5.688-3.181-5.013-4.531 0.579-1.06 5.207 1.446 8.195 1.736z"/>
+ <path fill="#fbfbfb" d="m157.479 167.201c1.7 0.188 12.176-2.171 12.554-1.227 0.377 0.755-6.512 2.832-8.778 5.191-0.755 0.755-2.736 2.549-3.964 2.36-0.942-0.094-1.51-2.077-2.926-3.587-3.398-3.397-5.568-3.115-4.907-4.436 0.565-1.038 5.096 1.415 8.021 1.699z"/>
+ <path fill="#f8f8f8" d="m157.525 167.241c1.663 0.184 11.914-2.124 12.283-1.201 0.369 0.739-6.372 2.771-8.589 5.08-0.738 0.739-2.679 2.494-3.879 2.309-0.924-0.092-1.479-2.032-2.863-3.51-3.325-3.324-5.449-3.048-4.803-4.341 0.555-1.015 4.988 1.385 7.851 1.663z"/>
+ <path fill="#f5f5f5" d="m157.57 167.281c1.626 0.18 11.652-2.078 12.014-1.175 0.361 0.723-6.231 2.711-8.4 4.969-0.723 0.722-2.619 2.439-3.793 2.258-0.903-0.09-1.446-1.987-2.802-3.433-3.252-3.251-5.329-2.981-4.695-4.245 0.54-0.993 4.876 1.354 7.676 1.626z"/>
+ <path fill="#f2f2f2" d="m157.615 167.321c1.59 0.176 11.391-2.031 11.745-1.148 0.352 0.706-6.093 2.649-8.212 4.856-0.707 0.707-2.562 2.385-3.709 2.208-0.883-0.088-1.413-1.943-2.738-3.356-3.179-3.178-5.209-2.914-4.591-4.15 0.529-0.971 4.768 1.324 7.505 1.59z"/>
+ <path fill="#efefef" d="m157.66 167.361c1.554 0.172 11.13-1.985 11.475-1.122 0.346 0.69-5.952 2.589-8.022 4.745-0.69 0.69-2.503 2.33-3.624 2.157-0.863-0.086-1.381-1.898-2.675-3.279-3.106-3.105-5.09-2.847-4.486-4.055 0.517-0.948 4.658 1.294 7.332 1.554z"/>
+ <path fill="#ebebeb" d="m157.705 167.401c1.518 0.168 10.868-1.938 11.206-1.096 0.336 0.674-5.813 2.528-7.835 4.634-0.674 0.674-2.444 2.275-3.539 2.106-0.842-0.084-1.348-1.853-2.612-3.202-3.032-3.032-4.97-2.78-4.38-3.959 0.505-0.926 4.549 1.263 7.16 1.517z"/>
+ <path fill="#e8e8e8" d="m157.751 167.441c1.48 0.164 10.606-1.892 10.936-1.069 0.329 0.657-5.673 2.467-7.646 4.522-0.658 0.657-2.385 2.22-3.453 2.055-0.822-0.082-1.315-1.809-2.549-3.124-2.96-2.96-4.851-2.714-4.275-3.865 0.491-0.904 4.438 1.233 6.987 1.481z"/>
+ <path fill="#e5e5e5" d="m157.796 167.481c1.444 0.16 10.346-1.845 10.666-1.043 0.32 0.641-5.532 2.406-7.458 4.41-0.641 0.642-2.325 2.166-3.367 2.005-0.803-0.08-1.284-1.764-2.486-3.047-2.887-2.887-4.732-2.647-4.17-3.769 0.48-0.882 4.329 1.202 6.815 1.444z"/>
+ <path fill="#e2e2e2" d="m157.841 167.521c1.407 0.156 10.083-1.799 10.397-1.017 0.312 0.625-5.394 2.346-7.271 4.299-0.625 0.625-2.267 2.111-3.282 1.954-0.782-0.078-1.251-1.719-2.423-2.97-2.814-2.814-4.612-2.58-4.065-3.674 0.469-0.859 4.221 1.172 6.644 1.408z"/>
+ <path fill="#dfdfdf" d="m157.886 167.56c1.37 0.152 9.821-1.751 10.127-0.99 0.304 0.609-5.254 2.285-7.081 4.188-0.609 0.609-2.208 2.056-3.198 1.903-0.761-0.076-1.218-1.675-2.36-2.893-2.741-2.741-4.492-2.513-3.959-3.579 0.456-0.837 4.111 1.142 6.471 1.371z"/>
+ <path fill="#dbdbdb" d="m157.931 167.6c1.335 0.148 9.561-1.704 9.857-0.963 0.296 0.592-5.114 2.223-6.893 4.076-0.593 0.593-2.149 2.001-3.113 1.853-0.741-0.074-1.186-1.631-2.297-2.817-2.668-2.667-4.373-2.446-3.854-3.483 0.445-0.815 4.003 1.111 6.3 1.334z"/>
+ <path fill="#d8d8d8" d="m157.977 167.64c1.298 0.144 9.299-1.658 9.587-0.937 0.288 0.576-4.974 2.163-6.704 3.964-0.576 0.577-2.091 1.947-3.027 1.803-0.721-0.072-1.153-1.586-2.234-2.74-2.596-2.594-4.253-2.379-3.748-3.388 0.431-0.792 3.891 1.081 6.126 1.298z"/>
+ <path fill="#d5d5d5" d="m158.022 167.68c1.261 0.14 9.037-1.611 9.317-0.911 0.28 0.56-4.834 2.102-6.516 3.853-0.56 0.561-2.032 1.892-2.942 1.752-0.7-0.07-1.12-1.541-2.172-2.663-2.521-2.521-4.133-2.312-3.643-3.292 0.421-0.77 3.784 1.05 5.956 1.261z"/>
+ <path fill="#d2d2d2" d="m158.067 167.72c1.225 0.136 8.775-1.564 9.049-0.884 0.271 0.543-4.695 2.041-6.327 3.741-0.545 0.544-1.974 1.837-2.857 1.701-0.682-0.068-1.09-1.497-2.109-2.585-2.449-2.449-4.014-2.246-3.538-3.198 0.407-0.748 3.673 1.02 5.782 1.225z"/>
+ <path fill="#cfcfcf" d="m158.112 167.76c1.188 0.132 8.515-1.518 8.779-0.858 0.264 0.527-4.555 1.98-6.139 3.63-0.527 0.528-1.915 1.782-2.772 1.65-0.66-0.066-1.057-1.452-2.046-2.508-2.376-2.376-3.895-2.179-3.433-3.103 0.397-0.725 3.565 0.99 5.611 1.189z"/>
+ <path fill="#ccc" d="m158.157 167.8c1.152 0.128 8.253-1.472 8.51-0.832 0.255 0.511-4.415 1.92-5.95 3.518-0.512 0.512-1.855 1.728-2.688 1.6-0.64-0.064-1.023-1.407-1.983-2.431-2.303-2.303-3.773-2.112-3.326-3.007 0.383-0.703 3.454 0.959 5.437 1.152z"/>
+ <path fill="#c8c8c8" d="m158.203 167.84c1.115 0.124 7.991-1.425 8.239-0.805 0.248 0.494-4.274 1.858-5.761 3.406-0.496 0.496-1.798 1.673-2.603 1.549-0.62-0.063-0.992-1.363-1.921-2.354-2.229-2.229-3.655-2.045-3.221-2.912 0.372-0.681 3.346 0.929 5.267 1.116z"/>
+ <path fill="#c5c5c5" d="m158.248 167.88c1.079 0.12 7.73-1.379 7.97-0.779 0.239 0.478-4.135 1.798-5.572 3.295-0.479 0.479-1.739 1.618-2.518 1.498-0.6-0.06-0.959-1.318-1.857-2.277-2.157-2.157-3.535-1.978-3.116-2.816 0.359-0.659 3.235 0.898 5.093 1.079z"/>
+ <path fill="#c2c2c2" d="m158.293 167.92c1.042 0.116 7.469-1.332 7.701-0.753 0.231 0.462-3.995 1.737-5.385 3.184-0.463 0.463-1.68 1.563-2.432 1.447-0.579-0.058-0.927-1.273-1.796-2.2-2.084-2.084-3.415-1.911-3.01-2.721 0.348-0.636 3.127 0.868 4.922 1.043z"/>
+ <path fill="#bfbfbf" d="m158.338 167.959c1.007 0.112 7.207-1.285 7.432-0.726 0.223 0.446-3.855 1.676-5.196 3.072-0.447 0.447-1.621 1.509-2.347 1.397-0.56-0.056-0.895-1.229-1.732-2.123-2.011-2.011-3.296-1.844-2.905-2.626 0.334-0.614 3.016 0.838 4.748 1.006z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m194.253 11.922c-1.222 2.631-3.812 23.214-0.248 20.892 3.594-2.341 13.57-5.312 19.886-7.013 7.003-1.886-17.188-19.463-19.638-13.879z"/>
+ <path fill="#060606" d="m194.485 12.307c-1.21 2.594-3.704 22.255-0.234 20.007 3.491-2.262 13.077-5.1 19.039-6.782 6.59-1.905-16.436-18.609-18.805-13.225z"/>
+ <path fill="#0c0c0c" d="m194.717 12.691c-1.198 2.557-3.595 21.296-0.221 19.124 3.391-2.184 12.586-4.888 18.194-6.551 6.177-1.924-15.684-17.757-17.973-12.573z"/>
+ <path fill="#131313" d="m194.949 13.076c-1.187 2.52-3.487 20.337-0.207 18.239 3.288-2.105 12.093-4.676 17.348-6.321 5.763-1.941-14.933-16.903-17.141-11.918z"/>
+ <path fill="#191919" d="m195.181 13.46c-1.177 2.483-3.379 19.378-0.193 17.355 3.187-2.027 11.6-4.464 16.502-6.09 5.349-1.959-14.182-16.05-16.309-11.265z"/>
+ <path fill="#1f1f1f" d="m195.413 13.845c-1.164 2.446-3.27 18.419-0.18 16.471 3.086-1.949 11.107-4.252 15.657-5.859 4.935-1.978-13.43-15.198-15.477-10.612z"/>
+ <path fill="#262626" d="m195.645 14.229c-1.153 2.409-3.162 17.46-0.166 15.586 2.983-1.87 10.616-4.04 14.811-5.628 4.521-1.995-12.679-14.344-14.645-9.958z"/>
+ <path fill="#2c2c2c" d="m195.878 14.614c-1.142 2.372-3.055 16.501-0.152 14.702 2.882-1.792 10.123-3.828 13.965-5.398 4.107-2.013-11.929-13.49-13.813-9.304z"/>
+ <path fill="#333" d="m196.11 14.999c-1.131 2.335-2.946 15.542-0.14 13.817 2.78-1.713 9.631-3.616 13.119-5.167 3.695-2.031-11.175-12.637-12.979-8.65z"/>
+ <path fill="#393939" d="m196.342 15.383c-1.118 2.299-2.838 14.583-0.126 12.934 2.68-1.636 9.139-3.404 12.274-4.937 3.28-2.049-10.425-11.784-12.148-7.997z"/>
+ <path fill="#3f3f3f" d="m196.574 15.768c-1.108 2.261-2.729 13.624-0.112 12.049 2.577-1.557 8.646-3.192 11.429-4.706 2.865-2.068-9.675-10.931-11.317-7.343z"/>
+ <path fill="#464646" d="m196.806 16.152c-1.097 2.225-2.622 12.665-0.1 11.165 2.477-1.479 8.154-2.98 10.583-4.475 2.453-2.086-8.922-10.078-10.483-6.69z"/>
+ <path fill="#4c4c4c" d="m197.038 16.537c-1.085 2.188-2.513 11.706-0.085 10.28 2.374-1.4 7.661-2.768 9.737-4.244 2.039-2.104-8.171-9.225-9.652-6.036z"/>
+ <path fill="#525252" d="m197.27 16.921c-1.073 2.151-2.405 10.747-0.071 9.396 2.272-1.322 7.168-2.556 8.891-4.013 1.625-2.122-7.42-8.371-8.82-5.383z"/>
+ <path fill="#595959" d="m197.502 17.306c-1.062 2.113-2.297 9.788-0.058 8.512 2.172-1.244 6.677-2.344 8.046-3.783 1.211-2.14-6.669-7.518-7.988-4.729z"/>
+ <path fill="#5f5f5f" d="m197.734 17.69c-1.05 2.077-2.188 8.829-0.044 7.627 2.069-1.165 6.184-2.132 7.2-3.552 0.797-2.157-5.917-6.664-7.156-4.075z"/>
+ <path fill="#666" d="m197.966 18.075c-1.038 2.04-2.079 7.87-0.029 6.743 1.968-1.087 5.69-1.92 6.354-3.321 0.382-2.176-5.167-5.812-6.325-3.422z"/>
+ <path fill="#6c6c6c" d="m198.198 18.459c-1.027 2.003-1.972 6.911-0.017 5.859 1.866-1.008 5.198-1.708 5.509-3.09-0.03-2.194-4.415-4.959-5.492-2.769z"/>
+ <path fill="#727272" d="m198.43 18.844c-1.017 1.966-1.863 5.952-0.003 4.975 1.765-0.93 4.706-1.496 4.662-2.86-0.443-2.212-3.662-4.106-4.659-2.115z"/>
+ <path fill="#797979" d="m198.662 19.228c-1.004 1.929-1.755 4.993 0.011 4.09 1.663-0.852 4.215-1.284 3.817-2.629-0.858-2.23-2.912-3.251-3.828-1.461z"/>
+ <path fill="#7f7f7f" d="m198.894 19.612c-0.993 1.892-1.647 4.034 0.023 3.206 1.563-0.773 3.723-1.072 2.973-2.398-1.272-2.248-2.161-2.399-2.996-0.808z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m143.502 46.386c-0.72 2.16 8.712 5.112 10.801 6.984 2.808 2.52 3.023 7.488 6.336 5.472 2.159-1.296 0.504-4.176-3.456-8.568-5.833-6.481-13.033-5.689-13.681-3.888z"/>
+ <path fill="#050505" d="m143.991 46.582c-0.716 2.073 8.275 4.9 10.336 6.741 2.745 2.457 2.961 7.249 6.146 5.313 2.094-1.254 0.449-4.072-3.343-8.28-5.574-6.203-12.491-5.505-13.139-3.774z"/>
+ <path fill="#0a0a0a" d="m144.479 46.779c-0.71 1.987 7.839 4.688 9.873 6.498 2.682 2.394 2.897 7.009 5.956 5.154 2.028-1.212 0.395-3.968-3.228-7.993-5.319-5.926-11.953-5.321-12.601-3.659z"/>
+ <path fill="#0f0f0f" d="m144.967 46.976c-0.704 1.9 7.403 4.476 9.41 6.254 2.62 2.33 2.835 6.77 5.766 4.995 1.964-1.171 0.342-3.864-3.112-7.706-5.064-5.647-11.415-5.137-12.064-3.543z"/>
+ <path fill="#141414" d="m145.456 47.172c-0.701 1.813 6.966 4.263 8.946 6.011 2.557 2.266 2.772 6.53 5.575 4.835 1.897-1.129 0.287-3.76-2.998-7.418-4.807-5.369-10.874-4.952-11.523-3.428z"/>
+ <path fill="#191919" d="m145.944 47.369c-0.696 1.726 6.53 4.051 8.483 5.768 2.493 2.203 2.71 6.291 5.385 4.676 1.833-1.087 0.231-3.656-2.884-7.13-4.551-5.093-10.335-4.769-10.984-3.314z"/>
+ <path fill="#1e1e1e" d="m146.433 47.565c-0.692 1.64 6.093 3.839 8.019 5.525 2.431 2.14 2.647 6.052 5.194 4.517 1.768-1.046 0.179-3.552-2.77-6.843-4.293-4.814-9.794-4.585-10.443-3.199z"/>
+ <path fill="#232323" d="m146.921 47.762c-0.686 1.553 5.657 3.627 7.558 5.282 2.367 2.076 2.583 5.813 5.003 4.357 1.702-1.003 0.124-3.448-2.654-6.555-4.04-4.537-9.257-4.401-9.907-3.084z"/>
+ <path fill="#282828" d="m147.409 47.959c-0.681 1.466 5.221 3.415 7.094 5.039 2.305 2.013 2.521 5.573 4.813 4.198 1.637-0.962 0.07-3.344-2.54-6.268-3.782-4.26-8.717-4.218-9.367-2.969z"/>
+ <path fill="#2d2d2d" d="m147.898 48.156c-0.677 1.379 4.784 3.203 6.63 4.795 2.242 1.949 2.457 5.333 4.622 4.039 1.572-0.92 0.016-3.24-2.425-5.98-3.526-3.983-8.177-4.034-8.827-2.854z"/>
+ <path fill="#333" d="m148.386 48.353c-0.673 1.292 4.348 2.99 6.167 4.552 2.179 1.886 2.394 5.095 4.432 3.88 1.506-0.878-0.038-3.136-2.312-5.693-3.268-3.705-7.636-3.85-8.287-2.739z"/>
+ <path fill="#383838" d="m148.875 48.549c-0.668 1.206 3.911 2.778 5.703 4.309 2.116 1.823 2.331 4.855 4.242 3.721 1.439-0.836-0.093-3.032-2.197-5.405-3.013-3.428-7.098-3.667-7.748-2.625z"/>
+ <path fill="#3d3d3d" d="m149.363 48.746c-0.662 1.119 3.475 2.566 5.24 4.065 2.053 1.759 2.268 4.616 4.052 3.562 1.375-0.795-0.147-2.928-2.082-5.118-2.757-3.15-6.559-3.483-7.21-2.509z"/>
+ <path fill="#424242" d="m149.851 48.942c-0.657 1.032 3.039 2.354 4.776 3.823 1.99 1.696 2.205 4.376 3.861 3.402 1.31-0.753-0.201-2.824-1.967-4.831-2.5-2.871-6.018-3.298-6.67-2.394z"/>
+ <path fill="#474747" d="m150.34 49.139c-0.652 0.946 2.603 2.142 4.313 3.58 1.927 1.632 2.143 4.137 3.671 3.243 1.244-0.712-0.256-2.72-1.853-4.543-2.245-2.595-5.48-3.115-6.131-2.28z"/>
+ <path fill="#4c4c4c" d="m150.828 49.336c-0.647 0.859 2.166 1.93 3.851 3.336 1.863 1.569 2.079 3.898 3.48 3.084 1.179-0.67-0.31-2.616-1.739-4.255-1.988-2.317-4.94-2.932-5.592-2.165z"/>
+ <path fill="#515151" d="m151.317 49.533c-0.645 0.772 1.729 1.718 3.386 3.093 1.802 1.505 2.018 3.658 3.29 2.925 1.114-0.628-0.364-2.512-1.624-3.968-1.732-2.04-4.4-2.748-5.052-2.05z"/>
+ <path fill="#565656" d="m151.805 49.729c-0.639 0.685 1.293 1.505 2.923 2.85 1.738 1.442 1.954 3.419 3.1 2.766 1.048-0.586-0.418-2.408-1.509-3.681-1.476-1.762-3.862-2.563-4.514-1.935z"/>
+ <path fill="#5b5b5b" d="m152.293 49.926c-0.633 0.598 0.857 1.293 2.46 2.606 1.677 1.379 1.892 3.18 2.91 2.606 0.983-0.544-0.473-2.304-1.395-3.393-1.22-1.483-3.322-2.379-3.975-1.819z"/>
+ <path fill="#606060" d="m152.782 50.123c-0.629 0.512 0.42 1.081 1.996 2.363 1.613 1.315 1.828 2.94 2.719 2.447 0.918-0.502-0.525-2.2-1.28-3.105-0.963-1.207-2.782-2.196-3.435-1.705z"/>
+ <path fill="#666" d="m153.27 50.319c-0.624 0.425-0.017 0.869 1.533 2.12 1.55 1.252 1.765 2.701 2.528 2.288 0.853-0.461-0.581-2.096-1.166-2.818-0.706-0.929-2.242-2.012-2.895-1.59z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m193.47 45.594c-0.072 1.08 2.951 1.728 4.896 2.448 1.944 0.648 5.76 3.24 7.56 5.256 1.801 1.944 5.688 7.704 6.553 6.192 0.863-1.368-2.017-5.328-2.809-6.984s-3.239-5.256-7.128-6.48c-3.384-1.008-9-1.297-9.072-0.432z"/>
+ <path fill="#060606" d="m193.779 45.67c-0.071 1.05 2.869 1.685 4.758 2.387 1.891 0.633 5.598 3.161 7.345 5.122 1.747 1.893 5.535 7.493 6.376 6.027 0.84-1.328-1.936-5.173-2.738-6.795-0.8-1.622-3.175-5.09-6.952-6.301-3.282-0.995-8.718-1.28-8.789-0.44z"/>
+ <path fill="#0c0c0c" d="m194.088 45.747c-0.07 1.021 2.785 1.641 4.62 2.327 1.836 0.618 5.436 3.081 7.131 4.988 1.692 1.842 5.382 7.282 6.198 5.862 0.814-1.288-1.855-5.019-2.67-6.607-0.808-1.587-3.11-4.925-6.776-6.122-3.178-0.983-8.433-1.264-8.503-0.448z"/>
+ <path fill="#131313" d="m194.397 45.824c-0.071 0.991 2.702 1.598 4.481 2.267 1.782 0.603 5.272 3.001 6.916 4.854 1.64 1.791 5.229 7.071 6.022 5.697 0.788-1.248-1.776-4.865-2.603-6.418-0.815-1.554-3.044-4.759-6.599-5.942-3.073-0.973-8.148-1.251-8.217-0.458z"/>
+ <path fill="#191919" d="m194.706 45.9c-0.069 0.961 2.618 1.555 4.345 2.206 1.728 0.588 5.109 2.922 6.7 4.721 1.586 1.74 5.075 6.86 5.846 5.531 0.764-1.207-1.696-4.711-2.532-6.23-0.824-1.519-2.979-4.593-6.424-5.763-2.972-0.959-7.866-1.234-7.935-0.465z"/>
+ <path fill="#1f1f1f" d="m195.015 45.977c-0.07 0.931 2.534 1.511 4.207 2.146 1.672 0.573 4.945 2.843 6.485 4.586 1.531 1.689 4.921 6.649 5.668 5.367 0.738-1.167-1.616-4.557-2.464-6.042-0.832-1.485-2.914-4.428-6.247-5.583-2.868-0.948-7.581-1.219-7.649-0.474z"/>
+ <path fill="#262626" d="m195.324 46.054c-0.069 0.901 2.451 1.468 4.069 2.085 1.618 0.557 4.784 2.763 6.271 4.453 1.479 1.638 4.769 6.438 5.491 5.201 0.714-1.127-1.536-4.402-2.396-5.854-0.839-1.451-2.848-4.263-6.07-5.404-2.765-0.934-7.298-1.202-7.365-0.481z"/>
+ <path fill="#2c2c2c" d="m195.632 46.13c-0.067 0.872 2.369 1.424 3.933 2.025 1.563 0.542 4.621 2.684 6.056 4.318 1.424 1.587 4.615 6.228 5.315 5.036 0.688-1.086-1.456-4.248-2.326-5.665-0.848-1.416-2.783-4.097-5.896-5.224-2.662-0.923-7.015-1.187-7.082-0.49z"/>
+ <path fill="#333" d="m195.941 46.207c-0.068 0.842 2.285 1.381 3.794 1.964 1.51 0.527 4.458 2.605 5.842 4.185 1.37 1.536 4.461 6.016 5.138 4.871 0.662-1.046-1.377-4.093-2.258-5.476-0.855-1.382-2.718-3.932-5.718-5.045-2.56-0.911-6.732-1.172-6.798-0.499z"/>
+ <path fill="#393939" d="m196.25 46.284c-0.066 0.813 2.202 1.338 3.656 1.904 1.456 0.512 4.296 2.525 5.627 4.051 1.317 1.485 4.308 5.805 4.961 4.706 0.638-1.006-1.296-3.939-2.188-5.288-0.863-1.348-2.652-3.766-5.542-4.866-2.457-0.899-6.449-1.157-6.514-0.507z"/>
+ <path fill="#3f3f3f" d="m196.559 46.36c-0.067 0.783 2.118 1.295 3.518 1.844 1.402 0.497 4.133 2.446 5.412 3.917 1.263 1.434 4.155 5.594 4.785 4.541 0.612-0.966-1.217-3.785-2.12-5.1-0.872-1.313-2.587-3.6-5.366-4.687-2.353-0.886-6.165-1.141-6.229-0.515z"/>
+ <path fill="#464646" d="m196.868 46.437c-0.065 0.753 2.035 1.251 3.38 1.783 1.349 0.482 3.972 2.367 5.197 3.783 1.21 1.383 4.002 5.383 4.608 4.375 0.588-0.926-1.137-3.63-2.052-4.911-0.879-1.279-2.521-3.435-5.189-4.507-2.25-0.874-5.881-1.125-5.944-0.523z"/>
+ <path fill="#4c4c4c" d="m197.177 46.514c-0.066 0.723 1.95 1.208 3.241 1.723 1.293 0.467 3.809 2.287 4.983 3.649 1.155 1.332 3.848 5.172 4.431 4.21 0.563-0.885-1.057-3.476-1.982-4.723-0.888-1.245-2.456-3.269-5.014-4.328-2.146-0.862-5.597-1.109-5.659-0.531z"/>
+ <path fill="#525252" d="m197.486 46.591c-0.066 0.693 1.868 1.164 3.104 1.662 1.239 0.452 3.646 2.208 4.769 3.515 1.102 1.281 3.695 4.961 4.254 4.045 0.537-0.845-0.976-3.321-1.913-4.534-0.896-1.21-2.391-3.103-4.838-4.148-2.044-0.851-5.315-1.095-5.376-0.54z"/>
+ <path fill="#595959" d="m197.795 46.667c-0.064 0.664 1.784 1.121 2.968 1.602 1.184 0.437 3.481 2.128 4.552 3.381 1.049 1.23 3.542 4.75 4.078 3.88 0.512-0.805-0.897-3.167-1.846-4.346-0.902-1.176-2.325-2.938-4.66-3.969-1.942-0.838-5.031-1.078-5.092-0.548z"/>
+ <path fill="#5f5f5f" d="m198.104 46.744c-0.065 0.634 1.701 1.078 2.829 1.541 1.13 0.421 3.318 2.049 4.338 3.248 0.994 1.179 3.388 4.539 3.899 3.715 0.487-0.765-0.815-3.013-1.775-4.157-0.911-1.142-2.261-2.772-4.485-3.79-1.837-0.826-4.746-1.064-4.806-0.557z"/>
+ <path fill="#666" d="m198.413 46.821c-0.063 0.604 1.617 1.034 2.691 1.481 1.076 0.406 3.157 1.969 4.123 3.113 0.94 1.128 3.234 4.328 3.724 3.55 0.462-0.725-0.737-2.858-1.707-3.969-0.919-1.108-2.195-2.606-4.309-3.61-1.734-0.814-4.463-1.049-4.522-0.565z"/>
+ <path fill="#6c6c6c" d="m198.721 46.897c-0.063 0.574 1.534 0.991 2.554 1.42 1.021 0.391 2.994 1.89 3.908 2.979 0.887 1.077 3.082 4.117 3.548 3.384 0.436-0.685-0.657-2.704-1.64-3.78-0.927-1.074-2.13-2.44-4.132-3.431-1.631-0.8-4.179-1.031-4.238-0.572z"/>
+ <path fill="#727272" d="m199.03 46.974c-0.063 0.544 1.451 0.948 2.416 1.36 0.967 0.376 2.831 1.811 3.694 2.846 0.833 1.026 2.928 3.906 3.369 3.219 0.411-0.644-0.576-2.549-1.569-3.592-0.936-1.04-2.064-2.275-3.956-3.251-1.528-0.79-3.896-1.017-3.954-0.582z"/>
+ <path fill="#797979" d="m199.339 47.051c-0.062 0.515 1.368 0.904 2.278 1.299 0.913 0.361 2.669 1.731 3.479 2.712 0.779 0.975 2.774 3.695 3.193 3.054 0.386-0.604-0.497-2.396-1.501-3.403-0.942-1.005-1.999-2.11-3.78-3.072-1.424-0.778-3.612-1.002-3.669-0.59z"/>
+ <path fill="#7f7f7f" d="m199.648 47.127c-0.063 0.485 1.284 0.861 2.14 1.239 0.859 0.346 2.506 1.652 3.265 2.578 0.726 0.924 2.621 3.484 3.017 2.889 0.361-0.564-0.417-2.241-1.432-3.215-0.951-0.971-1.935-1.944-3.604-2.893-1.323-0.765-3.33-0.986-3.386-0.598z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#995900" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-20.017 11.592-31.104 19.152-13.968 9.576-18.792 13.824-23.328 18.359-7.056 7.057-13.752 9.432-24.48 9.432s-15.552-2.231-18.863-5.184c-3.313-2.88-6.984-10.225-6.624-21.168 0.288-10.872 3.744-20.809 5.399-37.729 0.721-7.271 0.648-16.271 0.648-24.264 0-10.08 0.144-18.648 2.304-19.943 3.889-2.448 4.752-2.592 9.36-2.592 4.607 0 6.696 0.287 8.208 1.799 1.439 1.44 0.864 4.752 0.359 9.433-0.432 4.681 1.801 6.192 4.032 8.136 2.232 1.872 4.248 4.248 11.305 4.824 7.056 0.504 9.647-0.648 12.96-2.736 3.312-2.088 7.991-5.832 9.72-7.992 1.656-2.088 5.76-9.287 6.552-9.287 0.719 0 5.472-1.656 8.136 2.232z"/>
+ <path fill="#9e5e00" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-20.12 11.556-31.26 19.008-13.885 9.371-18.903 13.54-23.521 17.902-6.912 6.74-13.414 9.084-23.915 9.019-10.411-0.047-15.116-2.181-18.414-5.118-3.297-2.867-6.931-9.966-6.613-20.578 0.205-10.851 3.701-20.683 5.256-37.279 0.666-7.379 0.407-16.303 0.335-24.375-0.076-10.068-0.072-18.627 2.084-19.922 3.889-2.444 4.752-2.592 9.36-2.592 4.607 0 6.7 0.291 8.208 1.799 1.491 1.492 0.767 4.887 0.205 9.408-0.63 4.658 1.458 6.486 3.795 8.607 2.34 2.059 4.489 4.471 11.534 5.021 7.232 0.482 10.015-0.832 13.362-3.106 3.303-2.207 7.773-5.903 9.513-8.168 1.641-2.132 5.727-9.386 6.519-9.386 0.719 0 5.472-1.656 8.136 2.232z"/>
+ <path fill="#a36400" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-20.226 11.52-31.414 18.863-13.803 9.166-19.016 13.256-23.717 17.447-6.768 6.422-13.075 8.733-23.35 8.604-10.094-0.094-14.682-2.131-17.964-5.055-3.283-2.852-6.876-9.705-6.603-19.987 0.122-10.828 3.657-20.556 5.112-36.828 0.612-7.487 0.165-16.336 0.021-24.487-0.15-10.058-0.287-18.605 1.865-19.9 3.889-2.44 4.752-2.592 9.36-2.592 4.607 0 6.703 0.295 8.208 1.799 1.541 1.541 0.67 5.02 0.051 9.383-0.828 4.637 1.116 6.781 3.556 9.078 2.448 2.248 4.731 4.695 11.766 5.221 7.409 0.461 10.383-1.016 13.767-3.477 3.29-2.326 7.552-5.977 9.302-8.346 1.627-2.175 5.695-9.482 6.487-9.482 0.72-0.001 5.473-1.657 8.137 2.231z"/>
+ <path fill="#a86a00" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-20.329 11.484-31.568 18.72-13.721 8.961-19.127 12.972-23.911 16.989-6.624 6.105-12.737 8.384-22.785 8.189-9.777-0.141-14.245-2.08-17.514-4.99-3.27-2.836-6.822-9.445-6.591-19.396 0.038-10.807 3.613-20.43 4.968-36.378 0.558-7.597-0.076-16.368-0.292-24.599-0.228-10.047-0.504-18.584 1.645-19.879 3.889-2.438 4.752-2.592 9.36-2.592 4.607 0 6.707 0.299 8.208 1.799 1.591 1.593 0.573 5.152-0.104 9.357-1.025 4.615 0.774 7.077 3.319 9.551 2.556 2.434 4.972 4.918 11.995 5.418 7.585 0.439 10.75-1.199 14.17-3.849 3.279-2.444 7.333-6.048 9.093-8.521 1.613-2.219 5.663-9.58 6.455-9.58 0.719 0.001 5.472-1.655 8.136 2.233z"/>
+ <path fill="#ad7000" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-20.434 11.447-31.724 18.576-13.637 8.756-19.238 12.687-24.106 16.531-6.479 5.789-12.397 8.035-22.219 7.776-9.461-0.188-13.809-2.03-17.063-4.925-3.254-2.823-6.769-9.188-6.581-18.807-0.043-10.785 3.571-20.305 4.824-35.928 0.504-7.705-0.316-16.402-0.604-24.711-0.303-10.037-0.72-18.563 1.425-19.857 3.889-2.434 4.752-2.592 9.36-2.592 4.607 0 6.711 0.303 8.208 1.799 1.642 1.643 0.475 5.285-0.259 9.332-1.225 4.594 0.432 7.373 3.082 10.022 2.664 2.621 5.212 5.142 12.225 5.616 7.762 0.418 11.117-1.383 14.573-4.219 3.269-2.563 7.113-6.121 8.885-8.698 1.598-2.261 5.63-9.677 6.422-9.677 0.719 0.002 5.472-1.654 8.136 2.234z"/>
+ <path fill="#b27500" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-20.538 11.412-31.879 18.432-13.554 8.551-19.35 12.402-24.3 16.074-6.336 5.473-12.06 7.686-21.654 7.361-9.144-0.233-13.374-1.979-16.613-4.859-3.24-2.809-6.714-8.928-6.57-18.216-0.126-10.765 3.528-20.179 4.68-35.478 0.45-7.813-0.558-16.435-0.918-24.822-0.378-10.026-0.936-18.541 1.206-19.836 3.889-2.43 4.752-2.592 9.36-2.592 4.607 0 6.714 0.305 8.208 1.799 1.691 1.693 0.378 5.418-0.414 9.307-1.422 4.572 0.09 7.668 2.844 10.494 2.772 2.808 5.454 5.363 12.456 5.814 7.938 0.396 11.484-1.566 14.977-4.591 3.258-2.682 6.894-6.192 8.676-8.874 1.584-2.304 5.598-9.773 6.39-9.773 0.718 0 5.471-1.656 8.135 2.232z"/>
+ <path fill="#b77b00" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-20.643 11.376-32.033 18.288-13.472 8.345-19.461 12.118-24.494 15.616-6.192 5.156-11.723 7.338-21.089 6.949-8.827-0.281-12.938-1.93-16.164-4.795-3.226-2.795-6.66-8.67-6.56-17.627-0.209-10.742 3.485-20.052 4.536-35.027 0.396-7.92-0.799-16.467-1.23-24.934-0.454-10.015-1.152-18.52 0.985-19.814 3.889-2.426 4.752-2.592 9.36-2.592 4.607 0 6.718 0.31 8.208 1.799 1.743 1.744 0.281 5.553-0.569 9.281-1.62 4.551-0.252 7.963 2.607 10.967 2.88 2.994 5.694 5.586 12.686 6.012 8.115 0.373 11.852-1.75 15.379-4.961 3.248-2.801 6.676-6.264 8.469-9.051 1.568-2.348 5.564-9.871 6.356-9.871 0.72 0 5.473-1.656 8.137 2.232z"/>
+ <path fill="#bc8100" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-20.747 11.34-32.188 18.145-13.389 8.139-19.574 11.834-24.689 15.159-6.048 4.839-11.383 6.988-20.523 6.534-8.51-0.328-12.503-1.879-15.714-4.73-3.211-2.779-6.606-8.41-6.549-17.035-0.292-10.721 3.441-19.926 4.393-34.578 0.342-8.028-1.041-16.498-1.545-25.045-0.529-10.004-1.368-18.498 0.767-19.793 3.889-2.422 4.752-2.592 9.36-2.592 4.607 0 6.721 0.313 8.208 1.799 1.793 1.793 0.184 5.686-0.723 9.256-1.818 4.529-0.595 8.259 2.367 11.438 2.988 3.184 5.938 5.811 12.917 6.211 8.291 0.352 12.22-1.934 15.783-5.332 3.236-2.92 6.454-6.336 8.258-9.227 1.556-2.391 5.533-9.969 6.325-9.969 0.72-0.001 5.473-1.657 8.137 2.231z"/>
+ <path fill="#c18700" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-20.852 11.304-32.343 18-13.306 7.936-19.685 11.549-24.883 14.703-5.904 4.521-11.045 6.638-19.959 6.119-8.193-0.375-12.067-1.828-15.264-4.666-3.197-2.764-6.553-8.149-6.537-16.444-0.375-10.699 3.397-19.8 4.248-34.128 0.288-8.137-1.282-16.531-1.858-25.156-0.604-9.994-1.584-18.477 0.547-19.771 3.889-2.42 4.752-2.592 9.36-2.592 4.607 0 6.725 0.316 8.208 1.799 1.843 1.845 0.087 5.818-0.878 9.231-2.017 4.507-0.937 8.554 2.131 11.909 3.096 3.369 6.178 6.033 13.146 6.408 8.468 0.33 12.587-2.117 16.187-5.703 3.225-3.038 6.235-6.408 8.049-9.402 1.541-2.436 5.501-10.066 6.293-10.066 0.72-0.001 5.473-1.657 8.137 2.231z"/>
+ <path fill="#c68c00" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-20.955 11.268-32.498 17.855-13.223 7.73-19.796 11.266-25.077 14.246-5.761 4.204-10.706 6.289-19.394 5.707-7.877-0.423-11.631-1.779-14.813-4.602-3.183-2.751-6.498-7.891-6.527-15.855-0.457-10.677 3.355-19.674 4.104-33.678 0.234-8.244-1.521-16.563-2.17-25.268-0.681-9.983-1.8-18.455 0.327-19.75 3.889-2.416 4.752-2.592 9.36-2.592 4.607 0 6.729 0.32 8.208 1.799 1.894 1.895-0.011 5.951-1.033 9.207-2.214 4.484-1.278 8.848 1.895 12.379 3.203 3.558 6.418 6.258 13.377 6.607 8.644 0.309 12.952-2.301 16.589-6.074 3.215-3.156 6.016-6.479 7.841-9.58 1.526-2.477 5.468-10.162 6.26-10.162 0.718 0.001 5.471-1.655 8.135 2.233z"/>
+ <path fill="#cc9200" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-21.061 11.232-32.652 17.712-13.141 7.524-19.908 10.979-25.272 13.788-5.616 3.888-10.368 5.939-18.828 5.292-7.56-0.468-11.195-1.728-14.363-4.536-3.168-2.736-6.444-7.632-6.517-15.264-0.54-10.656 3.313-19.549 3.96-33.229 0.181-8.352-1.764-16.596-2.483-25.38-0.757-9.972-2.017-18.433 0.107-19.728 3.889-2.412 4.752-2.592 9.36-2.592 4.607 0 6.731 0.323 8.208 1.799 1.944 1.945-0.108 6.084-1.188 9.181-2.412 4.464-1.62 9.144 1.656 12.853 3.313 3.744 6.66 6.479 13.608 6.803 8.819 0.289 13.319-2.483 16.991-6.443 3.204-3.275 5.797-6.553 7.633-9.756 1.512-2.52 5.436-10.26 6.228-10.26 0.719 0 5.472-1.656 8.136 2.232z"/>
+ <path fill="#d19800" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-21.164 11.195-32.808 17.568-13.057 7.318-20.019 10.695-25.466 13.33-5.473 3.571-10.03 5.592-18.263 4.879-7.243-0.516-10.761-1.678-13.914-4.472-3.153-2.722-6.391-7.372-6.506-14.674-0.623-10.634 3.27-19.422 3.816-32.778 0.126-8.459-2.005-16.627-2.797-25.49-0.832-9.961-2.232-18.412-0.112-19.707 3.889-2.408 4.752-2.592 9.36-2.592 4.607 0 6.736 0.328 8.208 1.799 1.995 1.996-0.205 6.219-1.343 9.156-2.61 4.442-1.962 9.438 1.419 13.323 3.42 3.931 6.9 6.703 13.838 7.002 8.997 0.267 13.687-2.668 17.395-6.815 3.194-3.395 5.577-6.623 7.424-9.932 1.497-2.564 5.403-10.357 6.195-10.357 0.721 0 5.474-1.656 8.138 2.232z"/>
+ <path fill="#d69e00" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-21.27 11.16-32.962 17.424-12.975 7.114-20.132 10.412-25.661 12.874-5.327 3.254-9.69 5.242-17.697 4.464-6.927-0.563-10.325-1.627-13.464-4.406-3.14-2.707-6.337-7.113-6.494-14.084-0.706-10.611 3.225-19.295 3.672-32.328 0.072-8.567-2.247-16.66-3.111-25.603-0.907-9.95-2.448-18.39-0.331-19.685 3.889-2.404 4.752-2.592 9.36-2.592 4.607 0 6.739 0.332 8.208 1.799 2.045 2.045-0.302 6.352-1.497 9.131-2.808 4.421-2.304 9.734 1.18 13.795 3.528 4.119 7.144 6.927 14.069 7.199 9.173 0.246 14.055-2.851 17.799-7.185 3.182-3.514 5.356-6.696 7.214-10.108 1.483-2.607 5.371-10.455 6.163-10.455 0.719 0 5.472-1.656 8.136 2.232z"/>
+ <path fill="#dba300" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-21.373 11.124-33.116 17.279-12.893 6.91-20.243 10.127-25.855 12.418-5.184 2.937-9.353 4.892-17.133 4.05-6.609-0.609-9.889-1.577-13.014-4.343-3.125-2.692-6.282-6.854-6.483-13.492-0.789-10.592 3.182-19.17 3.528-31.879 0.018-8.676-2.488-16.692-3.425-25.713-0.982-9.94-2.664-18.369-0.551-19.664 3.889-2.401 4.752-2.592 9.36-2.592 4.607 0 6.743 0.334 8.208 1.799 2.095 2.097-0.399 6.484-1.652 9.105-3.006 4.398-2.646 10.029 0.943 14.268 3.636 4.305 7.384 7.148 14.299 7.397 9.349 0.224 14.422-3.034 18.202-7.558 3.171-3.631 5.137-6.768 7.005-10.284 1.469-2.649 5.339-10.552 6.131-10.552 0.72 0.001 5.473-1.655 8.137 2.233z"/>
+ <path fill="#e0a900" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-21.478 11.088-33.271 17.136-12.81 6.704-20.354 9.843-26.05 11.96-5.04 2.62-9.015 4.543-16.567 3.637-6.293-0.656-9.453-1.527-12.563-4.277-3.11-2.68-6.229-6.596-6.474-12.903-0.871-10.569 3.141-19.044 3.384-31.428-0.035-8.784-2.728-16.726-3.735-25.826-1.06-9.929-2.88-18.347-0.771-19.642 3.889-2.397 4.752-2.592 9.36-2.592 4.607 0 6.747 0.338 8.208 1.799 2.146 2.146-0.497 6.617-1.808 9.08-3.203 4.377-2.987 10.324 0.706 14.738 3.744 4.493 7.624 7.373 14.529 7.596 9.526 0.203 14.789-3.217 18.605-7.926 3.161-3.752 4.918-6.841 6.797-10.463 1.454-2.693 5.306-10.648 6.098-10.648 0.719-0.001 5.472-1.657 8.136 2.231z"/>
+ <path fill="#e5af00" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-21.582 11.052-33.427 16.992-12.726 6.498-20.466 9.558-26.244 11.502-4.896 2.304-8.676 4.193-16.002 3.222-5.976-0.702-9.018-1.476-12.114-4.212-3.096-2.664-6.174-6.336-6.462-12.313-0.953-10.547 3.097-18.918 3.24-30.978-0.09-8.892-2.97-16.758-4.05-25.938-1.134-9.918-3.096-18.324-0.99-19.619 3.889-2.395 4.752-2.592 9.36-2.592 4.607 0 6.75 0.342 8.208 1.799 2.196 2.197-0.594 6.75-1.962 9.055-3.402 4.355-3.33 10.619 0.468 15.21 3.852 4.681 7.866 7.597 14.76 7.794 9.702 0.18 15.156-3.402 19.008-8.298 3.15-3.87 4.698-6.912 6.589-10.638 1.439-2.736 5.273-10.746 6.065-10.746 0.72 0 5.473-1.656 8.137 2.232z"/>
+ <path fill="#eab500" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-21.687 11.016-33.582 16.848-12.643 6.293-20.576 9.274-26.438 11.045-4.752 1.987-8.338 3.846-15.438 2.809-5.658-0.75-8.582-1.426-11.664-4.147-3.08-2.649-6.119-6.077-6.45-11.722-1.037-10.526 3.053-18.792 3.096-30.528-0.144-9-3.211-16.79-4.363-26.049-1.21-9.907-3.312-18.304-1.21-19.599 3.889-2.391 4.752-2.592 9.36-2.592 4.607 0 6.754 0.346 8.208 1.799 2.247 2.248-0.691 6.885-2.117 9.029-3.6 4.336-3.672 10.916 0.231 15.682 3.96 4.867 8.106 7.82 14.989 7.992 9.879 0.158 15.523-3.586 19.411-8.668 3.141-3.99 4.479-6.984 6.38-10.814 1.426-2.78 5.241-10.844 6.033-10.844 0.721-0.001 5.474-1.657 8.138 2.231z"/>
+ <path fill="#efba00" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-21.791 10.98-33.735 16.703-12.562 6.089-20.69 8.99-26.633 10.589-4.608 1.67-7.999 3.496-14.872 2.394-5.343-0.796-8.147-1.375-11.215-4.082-3.066-2.636-6.065-5.818-6.439-11.132-1.12-10.504 3.009-18.666 2.952-30.077-0.198-9.109-3.453-16.822-4.677-26.162-1.285-9.896-3.528-18.281-1.43-19.576 3.889-2.387 4.752-2.592 9.36-2.592 4.607 0 6.757 0.35 8.208 1.799 2.297 2.297-0.788 7.018-2.271 9.004-3.798 4.314-4.014 11.211-0.008 16.154 4.068 5.055 8.35 8.043 15.221 8.189 10.056 0.137 15.892-3.77 19.815-9.039 3.128-4.107 4.258-7.057 6.17-10.99 1.411-2.824 5.209-10.941 6.001-10.941 0.72-0.001 5.473-1.657 8.137 2.231z"/>
+ <path fill="#f4c000" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-21.896 10.943-33.891 16.561-12.478 5.883-20.801 8.704-26.827 10.131-4.464 1.353-7.661 3.146-14.307 1.979-5.025-0.843-7.711-1.325-10.765-4.019-3.053-2.621-6.012-5.558-6.429-10.541-1.203-10.482 2.966-18.539 2.809-29.627-0.253-9.217-3.694-16.855-4.99-26.272-1.361-9.886-3.744-18.261-1.649-19.556 3.889-2.383 4.752-2.592 9.36-2.592 4.607 0 6.761 0.353 8.208 1.799 2.347 2.349-0.885 7.15-2.426 8.979-3.996 4.291-4.356 11.505-0.245 16.625 4.176 5.241 8.59 8.265 15.451 8.388 10.23 0.115 16.258-3.953 20.218-9.41 3.117-4.227 4.039-7.129 5.961-11.168 1.396-2.865 5.177-11.037 5.969-11.037 0.72 0 5.473-1.656 8.137 2.232z"/>
+ <path fill="#f9c600" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-21.999 10.908-34.046 16.416-12.395 5.678-20.912 8.421-27.021 9.674-4.32 1.036-7.322 2.797-13.741 1.566-4.709-0.891-7.275-1.275-10.314-3.953-3.037-2.607-5.958-5.299-6.419-9.951-1.284-10.461 2.925-18.414 2.664-29.178-0.306-9.324-3.934-16.887-5.302-26.385-1.437-9.875-3.96-18.238-1.869-19.533 3.889-2.379 4.752-2.592 9.36-2.592 4.607 0 6.765 0.356 8.208 1.799 2.397 2.398-0.983 7.283-2.581 8.955-4.194 4.269-4.698 11.799-0.482 17.096 4.284 5.429 8.83 8.488 15.682 8.586 10.407 0.094 16.625-4.137 20.621-9.781 3.106-4.345 3.819-7.199 5.753-11.344 1.382-2.909 5.144-11.135 5.936-11.135 0.718 0 5.471-1.656 8.135 2.232z"/>
+ <path fill="#fc0" d="m303.631 262.529c4.464 6.479-0.145 14.904 3.096 20.089 5.328 8.496 16.056 17.063 20.16 19.439 2.952 1.8 7.128 3.527 6.983 8.783-0.216 5.977-3.168 7.561-4.823 9.217-3.313 3.313-22.104 10.872-34.2 16.271-12.313 5.473-21.024 8.137-27.217 9.217-4.176 0.72-6.983 2.447-13.176 1.151-4.392-0.937-6.84-1.224-9.864-3.888-3.023-2.592-5.903-5.04-6.407-9.359-1.368-10.441 2.88-18.289 2.52-28.729-0.36-9.432-4.176-16.92-5.616-26.496-1.512-9.864-4.176-18.217-2.088-19.512 3.889-2.377 4.752-2.592 9.36-2.592 4.607 0 6.768 0.359 8.208 1.799 2.448 2.449-1.08 7.416-2.736 8.929-4.392 4.248-5.04 12.096-0.72 17.567 4.392 5.617 9.072 8.713 15.912 8.785 10.584 0.071 16.992-4.32 21.023-10.152 3.097-4.465 3.601-7.272 5.544-11.521 1.368-2.952 5.112-11.231 5.904-11.231 0.72 0.001 5.473-1.655 8.137 2.233z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#fc0" d="m236.263 275.762c-0.709-0.258-3.932-15.209-2.191-16.239 3.351-1.997 4.253-2.319 8.377-2.319s6.057 0.322 7.346 1.61c2.126 2.127-1.159 6.702-2.448 7.991-3.738 3.673-10.375 9.215-11.084 8.957z"/>
+ <path fill="#ffcc02" d="m236.492 275.286c-0.77-0.326-4.102-14.719-2.368-15.742 3.344-1.992 4.278-2.211 8.322-2.211 4.124 0 5.976 0.269 7.282 1.602 2.105 2.136-1.119 6.572-2.398 7.852-3.727 3.663-10.081 8.811-10.838 8.499z"/>
+ <path fill="#ffcc05" d="m236.721 274.809c-0.832-0.393-4.273-14.229-2.547-15.247 3.339-1.983 4.306-2.101 8.269-2.101 4.124 0 5.896 0.213 7.217 1.592 2.087 2.146-1.076 6.443-2.346 7.714-3.718 3.653-9.788 8.409-10.593 8.042z"/>
+ <path fill="#ffcc07" d="m236.949 274.333c-0.892-0.461-4.443-13.74-2.722-14.75 3.331-1.979 4.33-1.992 8.213-1.992 4.124 0 5.814 0.158 7.151 1.582 2.068 2.156-1.033 6.316-2.294 7.574-3.707 3.644-9.494 8.007-10.348 7.586z"/>
+ <path fill="#ffcd0a" d="m237.178 273.855c-0.954-0.528-4.614-13.249-2.9-14.254 3.326-1.972 4.355-1.882 8.158-1.882 4.124 0 5.734 0.104 7.089 1.572 2.048 2.166-0.993 6.187-2.243 7.437-3.699 3.634-9.202 7.605-10.104 7.127z"/>
+ <path fill="#ffcd0c" d="m237.407 273.377c-1.015-0.596-4.785-12.758-3.077-13.758 3.319-1.965 4.382-1.771 8.104-1.771 4.124 0 5.653 0.049 7.023 1.563 2.029 2.176-0.951 6.057-2.19 7.299-3.69 3.623-8.91 7.201-9.86 6.667z"/>
+ <path fill="#ffcd0f" d="m237.636 272.901c-1.077-0.662-4.956-12.27-3.256-13.261 3.313-1.959 4.408-1.663 8.05-1.663 4.124 0 5.573-0.006 6.959 1.553 2.01 2.186-0.908 5.93-2.14 7.159-3.679 3.614-8.615 6.8-9.613 6.212z"/>
+ <path fill="#ffcd11" d="m237.864 272.424c-1.137-0.731-5.126-11.779-3.431-12.766 3.306-1.951 4.433-1.553 7.994-1.553 4.123 0 5.492-0.061 6.895 1.543 1.991 2.193-0.867 5.801-2.089 7.021-3.669 3.606-8.321 6.397-9.369 5.755z"/>
+ <path fill="#ffce14" d="m238.093 271.948c-1.197-0.799-5.297-11.289-3.607-12.27 3.299-1.946 4.459-1.443 7.938-1.443 4.124 0 5.412-0.115 6.83 1.533 1.973 2.204-0.824 5.671-2.037 6.883-3.659 3.596-8.028 5.993-9.124 5.297z"/>
+ <path fill="#ffce16" d="m238.322 271.471c-1.26-0.867-5.468-10.801-3.786-11.773 3.293-1.939 4.485-1.334 7.884-1.334 4.124 0 5.332-0.171 6.767 1.524 1.953 2.213-0.783 5.542-1.985 6.743-3.651 3.586-7.736 5.591-8.88 4.84z"/>
+ <path fill="#ffce19" d="m238.551 270.995c-1.32-0.935-5.639-10.312-3.963-11.277 3.286-1.934 4.511-1.225 7.829-1.225 4.124 0 5.252-0.226 6.702 1.514 1.934 2.224-0.741 5.414-1.934 6.605-3.64 3.576-7.442 5.187-8.634 4.383z"/>
+ <path fill="#ffce1c" d="m238.779 270.517c-1.382-1.002-5.809-9.821-4.14-10.781 3.279-1.926 4.535-1.114 7.774-1.114 4.124 0 5.171-0.279 6.637 1.504 1.914 2.233-0.698 5.285-1.882 6.467-3.63 3.566-7.148 4.784-8.389 3.924z"/>
+ <path fill="#ffcf1e" d="m239.008 270.04c-1.442-1.068-5.979-9.33-4.316-10.283 3.272-1.922 4.562-1.006 7.72-1.006 4.124 0 5.09-0.334 6.572 1.494 1.895 2.244-0.657 5.156-1.83 6.328-3.622 3.556-6.857 4.383-8.146 3.467z"/>
+ <path fill="#ffcf21" d="m239.237 269.563c-1.505-1.137-6.151-8.841-4.495-9.788 3.267-1.914 4.588-0.896 7.666-0.896 4.124 0 5.009-0.389 6.508 1.486 1.875 2.252-0.616 5.025-1.778 6.188-3.613 3.548-6.564 3.981-7.901 3.01z"/>
+ <path fill="#ffcf23" d="m239.466 269.086c-1.565-1.205-6.321-8.352-4.672-9.293 3.262-1.906 4.613-0.785 7.61-0.785 4.124 0 4.93-0.444 6.444 1.476 1.855 2.261-0.573 4.897-1.728 6.052-3.601 3.537-6.269 3.575-7.654 2.55z"/>
+ <path fill="#ffcf26" d="m239.694 268.61c-1.627-1.273-6.492-7.861-4.849-8.796 3.255-1.901 4.64-0.677 7.556-0.677 4.124 0 4.849-0.499 6.379 1.466 1.837 2.271-0.531 4.769-1.675 5.912-3.593 3.528-5.977 3.174-7.411 2.095z"/>
+ <path fill="#ffd028" d="m239.923 268.133c-1.688-1.34-6.663-7.373-5.025-8.301 3.248-1.895 4.665-0.566 7.501-0.566 4.124 0 4.768-0.555 6.314 1.456 1.817 2.28-0.489 4.64-1.624 5.774-3.583 3.519-5.684 2.771-7.166 1.637z"/>
+ <path fill="#ffd02b" d="m240.152 267.657c-1.749-1.408-6.834-6.883-5.203-7.805 3.241-1.889 4.69-0.457 7.446-0.457 4.124 0 4.687-0.609 6.25 1.447 1.798 2.289-0.448 4.51-1.573 5.635-3.572 3.509-5.39 2.367-6.92 1.18z"/>
+ <path fill="#ffd02d" d="m240.381 267.178c-1.811-1.475-7.005-6.391-5.381-7.307 3.235-1.881 4.717-0.348 7.393-0.348 4.124 0 4.606-0.664 6.185 1.438 1.779 2.299-0.405 4.381-1.521 5.496-3.564 3.501-5.098 1.965-6.676 0.721z"/>
+ <path fill="#ffd030" d="m240.609 266.702c-1.871-1.543-7.175-5.902-5.557-6.811 3.228-1.875 4.741-0.238 7.336-0.238 4.124 0 4.526-0.719 6.122 1.428 1.759 2.31-0.364 4.252-1.471 5.357-3.552 3.49-4.803 1.562-6.43 0.264z"/>
+ <path fill="#ffd133" d="m240.838 266.225c-1.933-1.611-7.346-5.413-5.734-6.314 3.222-1.869 4.768-0.129 7.281-0.129 4.124 0 4.446-0.773 6.058 1.418 1.74 2.318-0.322 4.123-1.418 5.219-3.545 3.48-4.512 1.16-6.187-0.194z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#fc0" d="m302.769 263.374c3.742 5.461-0.062 12.76 2.638 17.117-6.809-6.258-9.938-8.834-19.324 0.367 2.577-3.742 3.129-6.257 4.725-9.814 1.104-2.455 4.354-9.57 5.029-9.57 0.613-0.001 4.724-1.35 6.932 1.9z"/>
+ <path fill="#ffcc02" d="m302.73 263.413c3.655 5.334 0.035 12.601 2.578 16.723-6.668-6.098-9.729-8.666-18.908 0.322 2.421-3.527 3.025-6.094 4.605-9.592 1.122-2.462 4.243-9.302 4.951-9.311 0.628-0.008 4.617-1.318 6.774 1.858z"/>
+ <path fill="#ffcc05" d="m302.691 263.45c3.568 5.209 0.132 12.441 2.517 16.332-6.526-5.938-9.519-8.5-18.492 0.277 2.268-3.314 2.924-5.934 4.488-9.372 1.141-2.468 4.132-9.032 4.873-9.052 0.641-0.013 4.508-1.284 6.614 1.815z"/>
+ <path fill="#ffcc07" d="m302.652 263.487c3.48 5.086 0.229 12.282 2.457 15.939-6.386-5.777-9.311-8.332-18.076 0.232 2.111-3.1 2.819-5.771 4.369-9.15 1.158-2.475 4.021-8.762 4.795-8.791 0.655-0.019 4.399-1.254 6.455 1.77z"/>
+ <path fill="#ffcd0a" d="m302.614 263.524c3.393 4.96 0.323 12.123 2.396 15.549-6.245-5.617-9.102-8.164-17.66 0.188 1.955-2.887 2.716-5.611 4.251-8.93 1.176-2.481 3.91-8.494 4.716-8.533 0.67-0.027 4.291-1.219 6.297 1.726z"/>
+ <path fill="#ffcd0c" d="m302.575 263.562c3.306 4.835 0.419 11.964 2.335 15.155-6.104-5.457-8.891-7.996-17.244 0.143 1.8-2.673 2.613-5.449 4.133-8.707 1.194-2.488 3.8-8.225 4.638-8.273 0.684-0.036 4.182-1.189 6.138 1.682z"/>
+ <path fill="#ffcd0f" d="m302.536 263.599c3.219 4.71 0.517 11.805 2.275 14.765-5.963-5.299-8.683-7.83-16.828 0.098 1.644-2.461 2.51-5.289 4.015-8.486 1.212-2.496 3.688-7.956 4.559-8.016 0.698-0.04 4.075-1.155 5.979 1.639z"/>
+ <path fill="#ffcd11" d="m302.497 263.637c3.131 4.585 0.612 11.645 2.216 14.371-5.822-5.137-8.474-7.661-16.413 0.053 1.489-2.245 2.406-5.125 3.896-8.264 1.229-2.504 3.576-7.688 4.479-7.756 0.714-0.046 3.968-1.123 5.822 1.596z"/>
+ <path fill="#ffce14" d="m302.458 263.674c3.044 4.459 0.708 11.486 2.154 13.979-5.681-4.978-8.263-7.493-15.996 0.009 1.334-2.033 2.303-4.965 3.779-8.043 1.247-2.511 3.464-7.418 4.4-7.498 0.728-0.052 3.859-1.089 5.663 1.553z"/>
+ <path fill="#ffce16" d="m302.42 263.711c2.956 4.336 0.804 11.328 2.094 13.588-5.54-4.817-8.055-7.326-15.58-0.036 1.178-1.819 2.199-4.804 3.659-7.822 1.267-2.517 3.354-7.149 4.323-7.237 0.741-0.061 3.75-1.058 5.504 1.507z"/>
+ <path fill="#ffce19" d="m302.381 263.749c2.868 4.211 0.9 11.168 2.033 13.196-5.398-4.657-7.845-7.159-15.164-0.081 1.022-1.605 2.097-4.642 3.542-7.601 1.283-2.524 3.241-6.88 4.244-6.979 0.755-0.067 3.642-1.026 5.345 1.465z"/>
+ <path fill="#ffce1c" d="m302.342 263.788c2.78 4.084 0.997 11.008 1.973 12.803-5.258-4.498-7.635-6.991-14.748-0.127 0.867-1.391 1.994-4.479 3.424-7.379 1.302-2.531 3.13-6.61 4.166-6.719 0.768-0.074 3.532-0.992 5.185 1.422z"/>
+ <path fill="#ffcf1e" d="m302.302 263.825c2.693 3.959 1.093 10.85 1.913 12.411-5.117-4.338-7.427-6.825-14.333-0.172 0.713-1.177 1.891-4.317 3.307-7.157 1.318-2.537 3.018-6.342 4.086-6.461 0.784-0.08 3.426-0.959 5.027 1.379z"/>
+ <path fill="#ffcf21" d="m302.263 263.862c2.606 3.834 1.188 10.689 1.853 12.02-4.976-4.178-7.217-6.657-13.916-0.217 0.556-0.963 1.786-4.156 3.188-6.936 1.337-2.545 2.906-6.072 4.008-6.202 0.797-0.086 3.318-0.927 4.867 1.335z"/>
+ <path fill="#ffcf23" d="m302.225 263.899c2.519 3.71 1.285 10.531 1.791 11.628-4.835-4.019-7.008-6.489-13.5-0.262 0.4-0.75 1.684-3.994 3.068-6.714 1.356-2.553 2.797-5.805 3.931-5.943 0.813-0.093 3.209-0.895 4.71 1.291z"/>
+ <path fill="#ffcf26" d="m302.186 263.937c2.431 3.584 1.381 10.371 1.73 11.235-4.693-3.857-6.798-6.322-13.084-0.307 0.245-0.535 1.58-3.832 2.951-6.492 1.373-2.56 2.686-5.535 3.852-5.685 0.828-0.1 3.101-0.861 4.551 1.249z"/>
+ <path fill="#ffd028" d="m302.147 263.974c2.344 3.46 1.477 10.213 1.671 10.845-4.553-3.699-6.589-6.156-12.668-0.354 0.089-0.321 1.477-3.67 2.832-6.271 1.392-2.565 2.574-5.267 3.772-5.425 0.842-0.104 2.994-0.828 4.393 1.205z"/>
+ <path fill="#ffd02b" d="m302.108 264.012c2.257 3.334 1.573 10.053 1.61 10.451-4.412-3.537-6.38-5.987-12.253-0.396-0.064-0.109 1.374-3.51 2.716-6.05 1.408-2.573 2.462-4.997 3.693-5.166 0.856-0.112 2.886-0.796 4.234 1.161z"/>
+ <path fill="#ffd02d" d="m302.069 264.049c2.17 3.209 1.67 9.894 1.55 10.061-4.271-3.379-6.17-5.82-11.836-0.441-0.221 0.104 1.271-3.35 2.596-5.83 1.428-2.58 2.352-4.728 3.615-4.906 0.87-0.12 2.777-0.765 4.075 1.116z"/>
+ <path fill="#ffd030" d="m302.03 264.086c2.082 3.084 1.767 9.736 1.49 9.668-4.131-3.219-5.961-5.652-11.42-0.486-0.377 0.318 1.167-3.188 2.478-5.607 1.445-2.586 2.239-4.459 3.536-4.647 0.884-0.127 2.669-0.732 3.916 1.072z"/>
+ <path fill="#ffd133" d="m301.991 264.124c1.995 2.959 1.862 9.576 1.43 9.277-3.989-3.059-5.752-5.486-11.005-0.531-0.532 0.531 1.064-3.027 2.36-5.387 1.463-2.594 2.128-4.189 3.458-4.389 0.899-0.133 2.561-0.699 3.757 1.03z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#fc0" d="m305.862 283.481c5.977 7.848 17.064 16.271 21.024 18.576 2.88 1.656 7.056 3.6 6.983 8.783-0.144 5.904-3.168 7.561-4.823 9.217-3.313 3.313-22.177 10.943-34.2 16.271-12.24 5.4-21.097 8.209-27.217 9.217-4.104 0.647-7.056 2.375-13.176 1.151-4.32-0.864-6.912-1.296-9.864-3.888-2.951-2.52-5.976-5.184-6.407-9.359-1.225-10.369 3.672-16.921 8.424-25.921 3.888-7.2 11.735-8.64 16.632-7.991 17.568 2.375 16.416-8.641 21.24-13.465 4.464-4.463 17.208-8.064 21.384-2.591z"/>
+ <path fill="#ffcc02" d="m305.81 283.553c5.962 7.83 17.024 16.234 20.975 18.533 2.873 1.652 7.039 3.592 6.969 8.764-0.145 5.891-3.161 7.542-4.813 9.195-3.304 3.304-22.34 10.992-34.24 16.088-12.259 5.176-20.647 7.873-26.802 8.959-4.077 0.684-7.156 2.394-13.258 1.177-4.304-0.854-6.767-1.231-9.707-3.812-2.939-2.51-5.756-4.961-6.185-9.117-1.211-10.34 3.365-16.657 8.044-25.65 3.89-7.375 11.791-8.434 16.665-7.777 17.514 2.414 16.206-8.959 21.02-13.772 4.452-4.454 17.166-8.047 21.332-2.588z"/>
+ <path fill="#ffcc05" d="m305.76 283.627c5.946 7.812 16.982 16.195 20.925 18.488 2.866 1.648 7.022 3.584 6.951 8.743-0.144 5.876-3.153 7.524-4.801 9.173-3.298 3.297-22.506 11.043-34.28 15.907-12.279 4.949-20.201 7.538-26.389 8.699-4.051 0.721-7.256 2.413-13.341 1.202-4.286-0.846-6.619-1.168-9.55-3.733-2.924-2.501-5.536-4.741-5.959-8.877-1.198-10.312 3.058-16.394 7.664-25.379 3.89-7.552 11.845-8.229 16.698-7.563 17.458 2.453 15.995-9.279 20.797-14.08 4.443-4.443 17.127-8.026 21.285-2.58z"/>
+ <path fill="#ffcc07" d="m305.707 283.702c5.935 7.791 16.943 16.156 20.876 18.444 2.859 1.644 7.007 3.574 6.935 8.722-0.144 5.862-3.146 7.508-4.79 9.151-3.288 3.289-22.668 11.093-34.319 15.726-12.298 4.723-19.753 7.202-25.974 8.44-4.024 0.756-7.357 2.431-13.423 1.226-4.27-0.836-6.473-1.101-9.394-3.654-2.911-2.492-5.317-4.52-5.735-8.635-1.185-10.285 2.75-16.133 7.284-25.109 3.892-7.727 11.9-8.023 16.731-7.35 17.402 2.494 15.785-9.599 20.575-14.389 4.433-4.432 17.087-8.007 21.234-2.572z"/>
+ <path fill="#ffcd0a" d="m305.655 283.774c5.92 7.773 16.904 16.119 20.826 18.4 2.854 1.642 6.99 3.566 6.919 8.703-0.143 5.848-3.138 7.488-4.779 9.129-3.28 3.281-22.832 11.143-34.358 15.543-12.317 4.498-19.307 6.867-25.561 8.182-3.997 0.793-7.457 2.45-13.506 1.251-4.252-0.828-6.325-1.036-9.236-3.577-2.896-2.482-5.096-4.298-5.51-8.393-1.172-10.258 2.443-15.869 6.904-24.84 3.892-7.901 11.955-7.818 16.763-7.135 17.349 2.532 15.576-9.918 20.355-14.697 4.422-4.422 17.046-7.987 21.183-2.566z"/>
+ <path fill="#ffcd0c" d="m305.603 283.847c5.906 7.756 16.864 16.081 20.777 18.358 2.846 1.635 6.973 3.557 6.901 8.68-0.142 5.835-3.131 7.472-4.767 9.107-3.273 3.273-22.997 11.193-34.399 15.361-12.337 4.273-18.858 6.533-25.146 7.924-3.971 0.829-7.557 2.469-13.588 1.276-4.234-0.82-6.179-0.972-9.078-3.5-2.884-2.474-4.878-4.076-5.287-8.152-1.158-10.229 2.137-15.606 6.523-24.569 3.895-8.076 12.01-7.611 16.797-6.92 17.293 2.571 15.366-10.236 20.134-15.004 4.412-4.411 17.006-7.969 21.133-2.561z"/>
+ <path fill="#ffcd0f" d="m305.552 283.92c5.892 7.736 16.824 16.043 20.728 18.313 2.839 1.634 6.957 3.55 6.886 8.66-0.142 5.821-3.124 7.454-4.756 9.086-3.266 3.267-23.161 11.243-34.438 15.179-12.356 4.047-18.411 6.198-24.733 7.666-3.943 0.865-7.656 2.486-13.67 1.301-4.218-0.812-6.032-0.908-8.922-3.422-2.869-2.465-4.656-3.855-5.063-7.91-1.145-10.203 1.83-15.344 6.145-24.299 3.895-8.252 12.064-7.408 16.83-6.707 17.237 2.61 15.154-10.557 19.912-15.313 4.399-4.399 16.963-7.949 21.081-2.554z"/>
+ <path fill="#ffcd11" d="m305.501 283.993c5.877 7.719 16.782 16.004 20.678 18.271 2.833 1.628 6.939 3.54 6.869 8.639-0.143 5.807-3.116 7.436-4.745 9.064-3.257 3.258-23.324 11.293-34.479 14.996-12.375 3.822-17.963 5.863-24.319 7.408-3.917 0.9-7.757 2.504-13.752 1.324-4.2-0.802-5.886-0.842-8.765-3.344-2.856-2.454-4.438-3.633-4.838-7.669-1.132-10.173 1.521-15.081 5.764-24.029 3.896-8.426 12.119-7.2 16.863-6.491 17.183 2.649 14.945-10.875 19.689-15.621 4.391-4.389 16.926-7.93 21.035-2.548z"/>
+ <path fill="#ffce14" d="m305.448 284.066c5.863 7.701 16.743 15.966 20.629 18.228 2.826 1.625 6.924 3.531 6.853 8.619-0.142 5.793-3.108 7.418-4.733 9.043-3.25 3.25-23.489 11.342-34.518 14.813-12.396 3.598-17.517 5.529-23.905 7.148-3.891 0.938-7.857 2.524-13.834 1.351-4.185-0.793-5.74-0.776-8.609-3.267-2.841-2.444-4.217-3.412-4.613-7.426-1.118-10.146 1.216-14.818 5.385-23.76 3.896-8.602 12.174-6.996 16.896-6.277 17.128 2.688 14.735-11.195 19.468-15.929 4.378-4.38 16.883-7.911 20.981-2.543z"/>
+ <path fill="#ffce16" d="m305.396 284.139c5.85 7.682 16.703 15.928 20.579 18.184 2.82 1.62 6.907 3.523 6.837 8.598-0.141 5.779-3.101 7.4-4.722 9.021-3.242 3.242-23.653 11.393-34.559 14.631-12.414 3.372-17.068 5.194-23.491 6.891-3.862 0.975-7.957 2.543-13.917 1.375-4.167-0.783-5.592-0.713-8.451-3.188-2.827-2.437-3.997-3.19-4.389-7.187-1.105-10.117 0.908-14.555 5.004-23.487 3.898-8.776 12.229-6.79 16.928-6.063 17.074 2.728 14.525-11.515 19.248-16.236 4.37-4.371 16.845-7.894 20.933-2.539z"/>
+ <path fill="#ffce19" d="m305.344 284.211c5.836 7.664 16.663 15.891 20.529 18.141 2.813 1.617 6.892 3.516 6.82 8.578-0.14 5.765-3.093 7.382-4.71 8.999-3.235 3.233-23.817 11.442-34.598 14.448-12.434 3.146-16.621 4.859-23.077 6.633-3.837 1.01-8.058 2.561-13.999 1.4-4.15-0.775-5.446-0.648-8.295-3.111-2.814-2.426-3.777-2.969-4.164-6.944-1.094-10.09 0.601-14.293 4.624-23.22 3.898-8.951 12.282-6.584 16.961-5.848 17.019 2.767 14.314-11.834 19.025-16.545 4.361-4.359 16.806-7.872 20.884-2.531z"/>
+ <path fill="#ffce1c" d="m305.292 284.286c5.822 7.646 16.623 15.852 20.481 18.096 2.806 1.613 6.874 3.507 6.804 8.558-0.141 5.751-3.086 7.364-4.699 8.978-3.227 3.227-23.981 11.492-34.638 14.267-12.453 2.921-16.173 4.524-22.663 6.374-3.81 1.046-8.158 2.578-14.082 1.424-4.133-0.766-5.299-0.583-8.137-3.033-2.801-2.416-3.558-2.748-3.94-6.703-1.08-10.062 0.293-14.029 4.244-22.947 3.9-9.127 12.338-6.379 16.994-5.635 16.964 2.805 14.105-12.152 18.805-16.853 4.348-4.351 16.763-7.856 20.831-2.526z"/>
+ <path fill="#ffcf1e" d="m305.241 284.358c5.808 7.627 16.582 15.814 20.432 18.053 2.799 1.609 6.856 3.498 6.787 8.536-0.141 5.738-3.079 7.347-4.688 8.957-3.219 3.218-24.145 11.541-34.677 14.084-12.473 2.695-15.727 4.188-22.25 6.115-3.783 1.083-8.258 2.599-14.163 1.448-4.116-0.756-5.153-0.518-7.981-2.954-2.786-2.408-3.337-2.526-3.716-6.462-1.066-10.034-0.013-13.766 3.864-22.678 3.901-9.303 12.393-6.172 17.027-5.42 16.908 2.844 13.896-12.473 18.583-17.16 4.338-4.337 16.723-7.835 20.782-2.519z"/>
+ <path fill="#ffcf21" d="m305.189 284.431c5.793 7.608 16.542 15.776 20.382 18.009 2.792 1.605 6.84 3.49 6.771 8.516-0.14 5.725-3.071 7.33-4.677 8.936-3.211 3.211-24.309 11.591-34.717 13.902-12.491 2.47-15.278 3.854-21.836 5.856-3.756 1.119-8.357 2.616-14.246 1.474-4.099-0.748-5.006-0.453-7.823-2.877-2.772-2.398-3.118-2.306-3.492-6.22-1.053-10.007-0.319-13.505 3.484-22.407 3.903-9.479 12.448-5.969 17.062-5.207 16.853 2.883 13.684-12.791 18.36-17.469 4.328-4.328 16.683-7.819 20.732-2.513z"/>
+ <path fill="#ffcf23" d="m305.137 284.504c5.778 7.59 16.503 15.736 20.332 17.965 2.786 1.602 6.825 3.482 6.755 8.496-0.139 5.709-3.064 7.311-4.665 8.912-3.204 3.203-24.474 11.642-34.759 13.721-12.51 2.244-14.829 3.52-21.421 5.598-3.729 1.155-8.457 2.635-14.327 1.499-4.082-0.739-4.86-0.389-7.667-2.8-2.76-2.389-2.897-2.083-3.268-5.979-1.04-9.979-0.627-13.24 3.104-22.138 3.903-9.653 12.503-5.762 17.093-4.991 16.799 2.922 13.475-13.111 18.141-17.777 4.318-4.316 16.643-7.799 20.682-2.506z"/>
+ <path fill="#ffcf26" d="m305.086 284.579c5.765 7.57 16.463 15.697 20.282 17.92 2.779 1.599 6.809 3.474 6.738 8.476-0.139 5.696-3.056 7.293-4.654 8.892-3.194 3.194-24.637 11.69-34.797 13.536-12.529 2.021-14.382 3.185-21.007 5.341-3.703 1.191-8.559 2.652-14.411 1.523-4.065-0.73-4.713-0.324-7.509-2.723-2.745-2.379-2.679-1.861-3.043-5.735-1.027-9.952-0.936-12.979 2.724-21.868 3.905-9.828 12.557-5.557 17.126-4.777 16.744 2.961 13.265-13.431 17.919-18.084 4.307-4.309 16.602-7.783 20.632-2.501z"/>
+ <path fill="#ffd028" d="m305.033 284.651c5.752 7.553 16.423 15.66 20.234 17.878 2.771 1.593 6.791 3.464 6.722 8.454-0.139 5.682-3.049 7.275-4.643 8.869-3.188 3.188-24.801 11.74-34.838 13.355-12.548 1.793-13.935 2.85-20.593 5.082-3.676 1.228-8.658 2.67-14.493 1.547-4.048-0.721-4.565-0.258-7.353-2.644-2.731-2.37-2.457-1.64-2.818-5.495-1.014-9.923-1.242-12.716 2.345-21.597 3.905-10.004 12.611-5.351 17.158-4.563 16.689 3 13.055-13.75 17.698-18.393 4.297-4.295 16.562-7.761 20.581-2.493z"/>
+ <path fill="#ffd02b" d="m304.982 284.724c5.737 7.534 16.382 15.622 20.184 17.834 2.766 1.59 6.774 3.456 6.705 8.433-0.138 5.67-3.041 7.26-4.631 8.85-3.18 3.179-24.966 11.789-34.877 13.172-12.568 1.568-13.487 2.515-20.179 4.824-3.65 1.263-8.759 2.688-14.575 1.572-4.031-0.713-4.42-0.195-7.196-2.566-2.718-2.361-2.238-1.42-2.594-5.254-1.001-9.896-1.549-12.453 1.964-21.328 3.907-10.178 12.666-5.145 17.192-4.348 16.634 3.039 12.844-14.068 17.476-18.701 4.285-4.286 16.521-7.743 20.531-2.488z"/>
+ <path fill="#ffd02d" d="m304.93 284.797c5.723 7.516 16.342 15.584 20.135 17.789 2.758 1.588 6.758 3.449 6.688 8.414-0.138 5.654-3.034 7.24-4.619 8.826-3.173 3.172-25.13 11.84-34.918 12.99-12.587 1.344-13.039 2.18-19.766 4.564-3.622 1.301-8.856 2.709-14.657 1.599-4.014-0.704-4.272-0.13-7.039-2.489-2.702-2.352-2.018-1.197-2.369-5.012-0.987-9.868-1.855-12.189 1.584-21.057 3.908-10.354 12.722-4.94 17.226-4.135 16.578 3.078 12.634-14.389 17.254-19.009 4.275-4.273 16.481-7.721 20.481-2.48z"/>
+ <path fill="#ffd030" d="m304.879 284.87c5.709 7.498 16.302 15.547 20.085 17.748 2.752 1.582 6.741 3.438 6.673 8.391-0.139 5.642-3.027 7.224-4.609 8.806-3.164 3.164-25.293 11.89-34.956 12.808-12.606 1.119-12.592 1.844-19.352 4.308-3.596 1.336-8.958 2.726-14.739 1.622-3.997-0.695-4.127-0.065-6.882-2.411-2.69-2.343-1.799-0.976-2.146-4.771-0.974-9.84-2.163-11.928 1.204-20.787 3.91-10.529 12.777-4.734 17.258-3.92 16.524 3.117 12.424-14.707 17.034-19.316 4.263-4.267 16.439-7.706 20.43-2.478z"/>
+ <path fill="#ffd133" d="m304.826 284.943c5.695 7.479 16.263 15.509 20.036 17.703 2.745 1.579 6.726 3.431 6.656 8.372-0.137 5.627-3.02 7.205-4.597 8.783-3.157 3.156-25.458 11.939-34.997 12.625-12.626 0.893-12.145 1.51-18.938 4.049-3.569 1.373-9.058 2.745-14.822 1.646-3.979-0.686-3.979 0-6.725-2.332-2.676-2.334-1.578-0.756-1.921-4.529-0.961-9.813-2.471-11.665 0.824-20.516 3.91-10.705 12.83-4.529 17.29-3.707 16.47 3.156 12.215-15.027 16.813-19.625 4.255-4.253 16.401-7.684 20.381-2.469z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#995900" d="m52.494 273.618c-6.479 4.68-22.896 4.248-27.072 9.719-4.104 5.473 0.145 13.393 0.072 28.08 0 6.265-1.08 11.017-1.8 14.832-1.008 4.824-1.656 8.209 0.36 11.664 3.672 6.121 9.575 7.633 43.344 14.688 18.072 3.744 35.136 13.464 46.584 14.399 11.448 0.865 13.896-2.951 20.88-9.144 6.912-6.192 9.144-4.248 8.928-17.856-0.216-13.535-8.928-17.567-18.792-33.191s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-8.208 13.248-14.688 17.929z"/>
+ <path fill="#9e5e00" d="m52.598 273.905c-6.397 4.702-22.475 3.788-27.062 9.512-4.154 5.414 0.228 13.276 0.098 27.955-0.025 6.23-1.152 10.881-1.937 14.877-1.037 4.871-1.678 8.201 0.349 11.619 3.787 6.162 9.695 7.123 43.456 14.168 18.061 3.737 34.541 13.307 46.343 14.112 11.186 0.792 13.564-2.829 20.463-8.96 6.896-6.195 9.024-4.277 8.858-17.406-0.075-13.521-8.305-17.349-18.169-32.973s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-8.153 13.479-14.583 18.216z"/>
+ <path fill="#a36400" d="m52.703 274.193c-6.314 4.724-22.054 3.327-27.051 9.304-4.204 5.356 0.31 13.16 0.123 27.828-0.051 6.198-1.225 10.748-2.074 14.924-1.065 4.918-1.699 8.194 0.339 11.571 3.902 6.206 9.813 6.617 43.567 13.651 18.05 3.73 33.948 13.146 46.101 13.824 10.923 0.72 13.234-2.707 20.045-8.777 6.885-6.199 8.907-4.305 8.792-16.956 0.064-13.507-7.683-17.129-17.547-32.753s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-8.1 13.709-14.479 18.504z"/>
+ <path fill="#a86a00" d="m52.807 274.481c-6.231 4.745-21.633 2.866-27.04 9.094-4.255 5.299 0.393 13.047 0.148 27.702-0.076 6.167-1.297 10.616-2.211 14.972-1.095 4.965-1.721 8.188 0.328 11.524 4.018 6.25 9.932 6.108 43.679 13.134 18.039 3.721 33.354 12.988 45.86 13.535 10.659 0.648 12.902-2.585 19.627-8.593 6.869-6.203 8.788-4.335 8.723-16.507 0.205-13.492-7.06-16.909-16.924-32.533s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-8.045 13.94-14.374 18.792z"/>
+ <path fill="#ad7000" d="m52.912 274.769c-6.149 4.767-21.211 2.405-27.029 8.885-4.306 5.242 0.476 12.931 0.173 27.576-0.101 6.136-1.368 10.483-2.347 15.019-1.123 5.012-1.742 8.181 0.316 11.478 4.133 6.293 10.052 5.603 43.791 12.614 18.028 3.716 32.76 12.83 45.619 13.248 10.396 0.576 12.57-2.463 19.21-8.409 6.854-6.206 8.668-4.363 8.653-16.056 0.347-13.479-6.437-16.69-16.301-32.314s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.991 14.169-14.269 19.079z"/>
+ <path fill="#b27500" d="m53.016 275.057c-6.066 4.787-20.79 1.943-27.019 8.676-4.355 5.184 0.559 12.816 0.198 27.45-0.126 6.103-1.44 10.351-2.484 15.065-1.151 5.059-1.764 8.172 0.307 11.431 4.248 6.336 10.17 5.094 43.901 12.096 18.019 3.708 32.166 12.672 45.378 12.96 10.135 0.504 12.24-2.34 18.792-8.227 6.841-6.209 8.551-4.391 8.586-15.605 0.486-13.464-5.813-16.47-15.678-32.094s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.937 14.399-14.165 19.368z"/>
+ <path fill="#b77b00" d="m53.121 275.344c-5.983 4.811-20.369 1.484-27.008 8.469-4.406 5.126 0.641 12.7 0.224 27.324-0.151 6.068-1.512 10.216-2.621 15.111-1.181 5.105-1.785 8.166 0.295 11.385 4.363 6.379 10.289 4.586 44.014 11.576 18.008 3.701 31.572 12.515 45.137 12.672 9.872 0.433 11.909-2.217 18.375-8.041 6.825-6.215 8.431-4.422 8.518-15.156 0.627-13.45-5.191-16.251-15.056-31.875s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.884 14.631-14.062 19.655z"/>
+ <path fill="#bc8100" d="m53.225 275.633c-5.9 4.832-19.948 1.023-26.997 8.259-4.457 5.069 0.724 12.585 0.249 27.198-0.177 6.037-1.584 10.082-2.758 15.158-1.21 5.152-1.808 8.158 0.284 11.338 4.479 6.422 10.407 4.078 44.125 11.059 17.997 3.693 30.979 12.355 44.896 12.384 9.608 0.36 11.578-2.095 17.957-7.858 6.812-6.217 8.313-4.449 8.45-14.707 0.766-13.435-4.569-16.03-14.434-31.654s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.829 14.859-13.956 19.943z"/>
+ <path fill="#c18700" d="m53.329 275.92c-5.817 4.854-19.526 0.563-26.985 8.051-4.507 5.011 0.807 12.47 0.273 27.072-0.201 6.004-1.655 9.949-2.894 15.205-1.239 5.199-1.829 8.151 0.273 11.291 4.594 6.465 10.526 3.57 44.236 10.541 17.985 3.686 30.384 12.196 44.655 12.096 9.345 0.287 11.245-1.973 17.539-7.676 6.797-6.221 8.193-4.479 8.381-14.256 0.906-13.42-3.946-15.812-13.811-31.436s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.774 15.094-13.851 20.232z"/>
+ <path fill="#c68c00" d="m53.433 276.209c-5.734 4.875-19.104 0.101-26.975 7.84-4.558 4.955 0.89 12.355 0.299 26.947-0.227 5.973-1.728 9.816-3.031 15.252-1.267 5.246-1.851 8.145 0.263 11.244 4.709 6.508 10.646 3.063 44.349 10.022 17.975 3.679 29.79 12.038 44.413 11.808 9.083 0.217 10.915-1.851 17.122-7.492 6.782-6.224 8.074-4.506 8.313-13.806 1.048-13.405-3.323-15.592-13.188-31.216s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.722 15.322-13.749 20.521z"/>
+ <path fill="#cc9200" d="m53.538 276.497c-5.651 4.896-18.684-0.359-26.964 7.633-4.607 4.896 0.972 12.24 0.324 26.82-0.252 5.939-1.8 9.684-3.168 15.299-1.296 5.293-1.872 8.137 0.252 11.196 4.824 6.552 10.764 2.556 44.46 9.505 17.964 3.672 29.196 11.879 44.172 11.52 8.82 0.144 10.584-1.729 16.704-7.309 6.768-6.228 7.956-4.535 8.244-13.355 1.188-13.393-2.7-15.372-12.564-30.996s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.668 15.551-13.644 20.807z"/>
+ <path fill="#d19800" d="m53.642 276.786c-5.569 4.918-18.263-0.82-26.953 7.424-4.658 4.838 1.055 12.123 0.35 26.693-0.277 5.907-1.872 9.551-3.305 15.346-1.325 5.34-1.894 8.129 0.241 11.15 4.938 6.596 10.883 2.048 44.571 8.984 17.953 3.666 28.602 11.723 43.931 11.232 8.558 0.072 10.253-1.605 16.287-7.123 6.753-6.232 7.837-4.566 8.175-12.906 1.329-13.379-2.077-15.153-11.941-30.777s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.614 15.783-13.54 21.097z"/>
+ <path fill="#d69e00" d="m53.747 277.073c-5.486 4.94-17.842-1.281-26.942 7.215-4.709 4.781 1.138 12.01 0.374 26.568-0.302 5.875-1.943 9.417-3.441 15.393-1.354 5.387-1.915 8.123 0.23 11.104 5.055 6.639 11.002 1.541 44.684 8.467 17.942 3.658 28.008 11.563 43.688 10.944 8.296 0 9.923-1.483 15.869-6.941 6.74-6.235 7.72-4.593 8.108-12.456 1.468-13.363-1.455-14.933-11.319-30.557s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.56 16.012-13.435 21.383z"/>
+ <path fill="#dba300" d="m53.851 277.361c-5.403 4.962-17.421-1.741-26.932 7.007-4.759 4.723 1.221 11.893 0.399 26.441-0.327 5.843-2.016 9.283-3.578 15.439-1.383 5.434-1.937 8.115 0.22 11.057 5.169 6.682 11.12 1.033 44.795 7.949 17.932 3.649 27.414 11.404 43.448 10.656 8.031-0.072 9.59-1.361 15.451-6.758 6.725-6.238 7.6-4.623 8.039-12.006 1.609-13.35-0.832-14.714-10.696-30.338s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.504 16.245-13.33 21.673z"/>
+ <path fill="#e0a900" d="m53.956 277.649c-5.321 4.982-16.999-2.203-26.921 6.797-4.81 4.666 1.303 11.779 0.425 26.316-0.353 5.811-2.088 9.15-3.715 15.486-1.411 5.48-1.959 8.108 0.208 11.01 5.285 6.725 11.239 0.525 44.907 7.431 17.92 3.644 26.819 11.246 43.207 10.368 7.769-0.145 9.259-1.239 15.034-6.574 6.71-6.242 7.479-4.65 7.97-11.557 1.75-13.334-0.209-14.493-10.073-30.117s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.452 16.474-13.226 21.96z"/>
+ <path fill="#e5af00" d="m54.06 277.937c-5.238 5.004-16.578-2.664-26.91 6.588-4.86 4.608 1.386 11.664 0.45 26.19-0.378 5.777-2.16 9.018-3.853 15.533-1.439 5.526-1.979 8.101 0.198 10.963 5.4 6.768 11.358 0.018 45.018 6.912 17.91 3.635 26.227 11.088 42.967 10.08 7.506-0.217 8.928-1.117 14.615-6.391 6.696-6.246 7.362-4.68 7.902-11.105 1.89-13.32 0.414-14.274-9.45-29.898s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.397 16.704-13.121 22.248z"/>
+ <path fill="#eab500" d="m54.165 278.225c-5.155 5.025-16.157-3.124-26.899 6.38-4.91 4.55 1.469 11.548 0.476 26.063-0.403 5.746-2.232 8.885-3.989 15.58-1.469 5.573-2.002 8.094 0.188 10.916 5.515 6.812 11.477-0.49 45.129 6.394 17.899 3.629 25.633 10.93 42.725 9.792 7.243-0.288 8.598-0.993 14.199-6.206 6.681-6.25 7.243-4.709 7.833-10.656 2.031-13.306 1.037-14.055-8.827-29.679s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.346 16.935-13.019 22.536z"/>
+ <path fill="#efba00" d="m54.269 278.513c-5.073 5.048-15.736-3.585-26.889 6.171-4.961 4.492 1.552 11.434 0.5 25.938-0.428 5.713-2.304 8.752-4.125 15.627-1.498 5.621-2.023 8.086 0.176 10.869 5.631 6.854 11.596-0.996 45.241 5.875 17.889 3.623 25.038 10.771 42.483 9.504 6.981-0.359 8.266-0.871 13.781-6.022 6.668-6.253 7.125-4.737 7.766-10.206 2.17-13.291 1.659-13.835-8.205-29.459s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.289 17.164-12.912 22.823z"/>
+ <path fill="#f4c000" d="m54.374 278.801c-4.99 5.068-15.314-4.047-26.878 5.962-5.012 4.435 1.634 11.317 0.525 25.812-0.453 5.682-2.376 8.618-4.263 15.674-1.526 5.668-2.045 8.08 0.166 10.822 5.745 6.898 11.714-1.505 45.353 5.357 17.878 3.613 24.444 10.613 42.243 9.216 6.717-0.433 7.934-0.749 13.362-5.839 6.653-6.258 7.007-4.768 7.697-9.756 2.312-13.277 2.282-13.616-7.582-29.24s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.235 17.395-12.807 23.112z"/>
+ <path fill="#f9c600" d="m54.477 279.088c-4.906 5.092-14.893-4.506-26.866 5.754-5.062 4.377 1.717 11.203 0.551 25.686-0.479 5.648-2.448 8.485-4.399 15.721-1.555 5.715-2.066 8.072 0.155 10.775 5.86 6.941 11.833-2.012 45.464 4.839 17.867 3.607 23.851 10.454 42.001 8.929 6.455-0.504 7.604-0.627 12.946-5.656 6.638-6.26 6.886-4.795 7.628-9.307 2.452-13.262 2.905-13.396-6.959-29.02s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.182 17.626-12.705 23.399z"/>
+ <path fill="#fc0" d="m54.582 279.377c-4.823 5.111-14.472-4.969-26.855 5.543-5.112 4.32 1.8 11.088 0.576 25.561-0.504 5.616-2.521 8.352-4.536 15.768-1.584 5.76-2.088 8.064 0.144 10.729 5.977 6.984 11.952-2.52 45.576 4.32 17.856 3.6 23.256 10.295 41.76 8.64 6.192-0.576 7.272-0.504 12.528-5.472 6.624-6.264 6.768-4.824 7.56-8.856 2.592-13.248 3.528-13.176-6.336-28.8s-11.448-18.504-18-28.872c-6.552-10.224-19.512-28.8-26.928-29.017-5.904-0.144-9.216 3.024-12.888 6.769s-7.129 17.855-12.601 23.687z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#fc0" d="m57.701 285.002c-4.278 4.539-18.28-1.36-24.892 3.631-4.732 3.5 2.398 7.908 1.426 20.873-0.389 4.926-3.824 5.834-2.398 12.64 1.103 5.121 2.27 4.991 4.214 7.325 5.315 6.223 4.084 1.686 34.355 7.777 16.011 3.242 20.938 9.271 37.596 7.779 5.575-0.518 6.612-0.453 11.279-4.926 5.964-5.575 2.917-4.408 3.565-7.973 2.269-11.863 0.453-13.938-8.428-28.004-8.88-14.066-8.102-14.844-13.936-24.113-5.834-9.141-13.872-25.799-20.549-25.93-5.25-0.129-8.297 2.723-11.603 6.094-3.305 3.372-5.768 19.643-10.629 24.827z"/>
+ <path fill="#ffcc02" d="m57.995 285.094c-4.461 4.701-18.604-1.196-25.06 3.705-4.701 3.514 2.578 8.05 1.608 20.68-0.4 4.896-3.733 5.877-2.458 12.634 0.986 5.093 2.357 4.938 4.334 7.201 5.686 6.131 4.673 1.826 34.119 7.743 15.918 3.211 20.815 9.215 37.372 7.732 5.541-0.513 6.479-0.463 11.155-4.849 5.865-5.407 2.858-4.287 3.412-8.058 2.066-11.787 0.442-13.981-8.188-27.649-8.83-13.983-8.143-14.698-13.941-23.913-5.8-9.085-13.701-25.805-20.338-25.934-5.219-0.129-8.248 2.705-11.533 6.057-3.287 3.352-5.644 19.505-10.482 24.651z"/>
+ <path fill="#ffcc05" d="m58.289 285.185c-4.644 4.866-18.93-1.031-25.229 3.78-4.669 3.527 2.759 8.191 1.792 20.488-0.413 4.866-3.642 5.918-2.519 12.625 0.872 5.066 2.447 4.887 4.455 7.078 6.057 6.04 5.263 1.969 33.883 7.709 15.825 3.18 20.693 9.159 37.148 7.686 5.508-0.506 6.345-0.471 11.03-4.77 5.768-5.24 2.803-4.168 3.26-8.142 1.865-11.716 0.432-14.027-7.949-27.298-8.78-13.899-8.183-14.553-13.948-23.713-5.764-9.03-13.529-25.811-20.126-25.938-5.188-0.129-8.198 2.688-11.465 6.021-3.266 3.331-5.517 19.368-10.332 24.474z"/>
+ <path fill="#ffcc07" d="m58.584 285.276c-4.827 5.03-19.255-0.865-25.397 3.855-4.639 3.541 2.938 8.332 1.975 20.295-0.425 4.838-3.551 5.961-2.578 12.619 0.757 5.039 2.536 4.834 4.575 6.955 6.428 5.949 5.852 2.108 33.646 7.674 15.733 3.147 20.571 9.103 36.925 7.639 5.475-0.501 6.211-0.48 10.905-4.693 5.67-5.072 2.745-4.045 3.107-8.224 1.663-11.642 0.42-14.073-7.711-26.946-8.73-13.814-8.223-14.406-13.952-23.511-5.729-8.976-13.358-25.817-19.916-25.944-5.156-0.127-8.148 2.674-11.396 5.984s-5.391 19.229-10.183 24.297z"/>
+ <path fill="#ffcd0a" d="m58.878 285.368c-5.01 5.193-19.579-0.701-25.565 3.93-4.607 3.555 3.117 8.475 2.157 20.102-0.437 4.809-3.459 6.004-2.639 12.613 0.643 5.011 2.626 4.781 4.695 6.83 6.799 5.857 6.442 2.25 33.411 7.64 15.641 3.118 20.45 9.048 36.701 7.593 5.44-0.494 6.076-0.488 10.781-4.615 5.57-4.904 2.688-3.926 2.954-8.308 1.462-11.569 0.409-14.118-7.472-26.593-8.68-13.732-8.263-14.262-13.958-23.311-5.695-8.922-13.188-25.824-19.705-25.951-5.125-0.127-8.1 2.658-11.326 5.949-3.227 3.289-5.266 19.091-10.034 24.121z"/>
+ <path fill="#ffcd0c" d="m59.173 285.458c-5.193 5.358-19.905-0.535-25.734 4.008-4.577 3.566 3.297 8.613 2.34 19.908-0.449 4.778-3.368 6.045-2.698 12.605 0.526 4.982 2.715 4.729 4.815 6.707 7.17 5.766 7.031 2.391 33.175 7.604 15.549 3.088 20.328 8.994 36.477 7.547 5.408-0.488 5.943-0.498 10.657-4.537 5.472-4.737 2.63-3.805 2.802-8.393 1.261-11.494 0.398-14.162-7.232-26.24-8.63-13.647-8.304-14.117-13.964-23.109-5.66-8.867-13.017-25.829-19.494-25.956-5.094-0.126-8.05 2.642-11.257 5.912-3.21 3.27-5.142 18.955-9.887 23.944z"/>
+ <path fill="#ffcd0f" d="m59.467 285.547c-5.376 5.523-20.229-0.369-25.903 4.084-4.545 3.58 3.478 8.756 2.523 19.715-0.461 4.75-3.277 6.088-2.758 12.599 0.411 4.955 2.804 4.677 4.936 6.584 7.541 5.675 7.621 2.532 32.938 7.569 15.456 3.056 20.206 8.938 36.253 7.5 5.375-0.482 5.811-0.506 10.533-4.459 5.374-4.57 2.572-3.686 2.649-8.477 1.058-11.42 0.387-14.209-6.995-25.888-8.58-13.563-8.344-13.972-13.969-22.909-5.626-8.813-12.846-25.834-19.283-25.961-5.063-0.125-8 2.625-11.188 5.877-3.188 3.251-5.015 18.818-9.736 23.766z"/>
+ <path fill="#ffcd11" d="m59.76 285.639c-5.559 5.688-20.555-0.205-26.071 4.158-4.515 3.594 3.657 8.896 2.706 19.521-0.473 4.721-3.186 6.131-2.818 12.594 0.297 4.926 2.894 4.623 5.057 6.459 7.911 5.584 8.21 2.674 32.703 7.535 15.362 3.025 20.084 8.883 36.027 7.453 5.342-0.477 5.677-0.515 10.409-4.381 5.275-4.402 2.516-3.564 2.497-8.561 0.855-11.348 0.375-14.254-6.756-25.535-8.53-13.479-8.385-13.825-13.976-22.709-5.59-8.758-12.673-25.842-19.071-25.965-5.031-0.125-7.951 2.608-11.119 5.838-3.168 3.232-4.888 18.684-9.588 23.593z"/>
+ <path fill="#ffce14" d="m60.055 285.73c-5.742 5.851-20.88-0.04-26.24 4.233-4.483 3.607 3.837 9.039 2.889 19.33-0.485 4.69-3.095 6.172-2.878 12.584 0.182 4.9 2.982 4.572 5.177 6.338 8.282 5.492 8.8 2.814 32.467 7.498 15.271 2.996 19.962 8.828 35.804 7.408 5.309-0.472 5.543-0.523 10.285-4.304 5.177-4.235 2.458-3.444 2.344-8.644 0.654-11.273 0.364-14.299-6.518-25.184-8.479-13.395-8.425-13.679-13.98-22.507-5.556-8.704-12.502-25.849-18.86-25.972-5-0.123-7.901 2.593-11.05 5.803s-4.765 18.547-9.44 23.417z"/>
+ <path fill="#ffce16" d="m60.349 285.821c-5.925 6.016-21.205 0.125-26.408 4.309-4.453 3.621 4.017 9.18 3.07 19.137-0.496 4.662-3.002 6.215-2.938 12.579 0.066 4.872 3.072 4.518 5.298 6.213 8.653 5.401 9.389 2.956 32.23 7.464 15.178 2.964 19.84 8.771 35.58 7.361 5.275-0.465 5.409-0.532 10.159-4.225 5.079-4.068 2.401-3.324 2.192-8.729 0.452-11.2 0.354-14.346-6.279-24.831-8.429-13.312-8.464-13.534-13.985-22.306-5.521-8.65-12.331-25.854-18.649-25.978-4.969-0.123-7.853 2.577-10.98 5.767-3.129 3.19-4.637 18.409-9.29 23.239z"/>
+ <path fill="#ffce19" d="m60.643 285.911c-6.107 6.18-21.529 0.291-26.577 4.385-4.421 3.635 4.197 9.322 3.254 18.945-0.508 4.631-2.911 6.256-2.997 12.571-0.049 4.845 3.161 4.465 5.418 6.089 9.023 5.309 9.979 3.098 31.994 7.43 15.085 2.934 19.718 8.717 35.355 7.314 5.242-0.459 5.276-0.541 10.036-4.148 4.98-3.899 2.344-3.203 2.039-8.811 0.25-11.127 0.342-14.391-6.04-24.479-8.38-13.228-8.505-13.389-13.991-22.105-5.486-8.596-12.16-25.859-18.438-25.982-4.938-0.123-7.803 2.561-10.911 5.73-3.109 3.17-4.513 18.272-9.142 23.061z"/>
+ <path fill="#ffce1c" d="m60.938 286.002c-6.291 6.344-21.855 0.455-26.746 4.459-4.391 3.648 4.377 9.463 3.437 18.752-0.521 4.603-2.82 6.299-3.058 12.564-0.163 4.817 3.251 4.413 5.539 5.965 9.395 5.219 10.567 3.24 31.758 7.396 14.993 2.903 19.597 8.661 35.132 7.269 5.209-0.453 5.143-0.55 9.912-4.07 4.882-3.732 2.286-3.082 1.887-8.895 0.048-11.053 0.329-14.436-5.802-24.126-8.33-13.144-8.545-13.243-13.997-21.905-5.451-8.541-11.988-25.865-18.228-25.988-4.906-0.121-7.753 2.545-10.843 5.695-3.088 3.149-4.385 18.132-8.991 22.884z"/>
+ <path fill="#ffcf1e" d="m61.232 286.092c-6.473 6.51-22.18 0.621-26.914 4.535-4.359 3.662 4.557 9.604 3.619 18.559-0.532 4.574-2.729 6.342-3.117 12.559-0.278 4.789 3.34 4.36 5.659 5.842 9.766 5.127 11.157 3.381 31.521 7.359 14.9 2.872 19.475 8.607 34.908 7.223 5.176-0.447 5.008-0.559 9.787-3.992 4.784-3.565 2.229-2.963 1.735-8.979-0.155-10.98 0.317-14.482-5.564-23.773-8.279-13.061-8.585-13.098-14.002-21.705-5.416-8.485-11.817-25.871-18.017-25.992-4.875-0.121-7.704 2.527-10.773 5.658-3.068 3.128-4.26 17.995-8.842 22.706z"/>
+ <path fill="#ffcf21" d="m61.526 286.184c-6.655 6.673-22.505 0.785-27.082 4.611-4.328 3.674 4.736 9.744 3.802 18.365-0.544 4.543-2.638 6.383-3.178 12.551-0.394 4.761 3.43 4.308 5.78 5.718 10.136 5.036 11.746 3.522 31.285 7.325 14.808 2.841 19.353 8.551 34.685 7.176 5.142-0.441 4.875-0.567 9.663-3.914 4.685-3.398 2.171-2.842 1.582-9.063-0.357-10.906 0.307-14.527-5.325-23.422-8.23-12.976-8.625-12.951-14.008-21.503-5.382-8.432-11.646-25.878-17.806-25.999-4.844-0.119-7.654 2.512-10.704 5.622-3.049 3.109-4.134 17.859-8.694 22.533z"/>
+ <path fill="#ffcf23" d="m61.821 286.275c-6.839 6.837-22.83 0.95-27.251 4.687-4.298 3.688 4.916 9.886 3.984 18.172-0.557 4.515-2.546 6.426-3.237 12.545-0.509 4.732 3.519 4.255 5.9 5.594 10.507 4.945 12.336 3.663 31.05 7.29 14.715 2.812 19.23 8.496 34.46 7.129 5.108-0.435 4.74-0.575 9.538-3.835 4.587-3.23 2.113-2.723 1.43-9.146-0.559-10.834 0.296-14.572-5.086-23.069-8.18-12.892-8.666-12.806-14.014-21.302-5.347-8.377-11.476-25.885-17.595-26.004-4.813-0.119-7.605 2.496-10.635 5.584-3.029 3.088-4.008 17.722-8.544 22.355z"/>
+ <path fill="#ffcf26" d="m62.115 286.366c-7.021 7.002-23.154 1.115-27.42 4.762-4.266 3.701 5.096 10.027 4.168 17.979-0.568 4.486-2.455 6.469-3.297 12.538-0.624 4.706 3.607 4.203 6.021 5.472 10.878 4.852 12.925 3.803 30.813 7.254 14.622 2.78 19.108 8.441 34.236 7.084 5.075-0.43 4.606-0.584 9.413-3.759 4.489-3.063 2.058-2.601 1.277-9.229-0.761-10.76 0.284-14.618-4.848-22.717-8.129-12.809-8.706-12.66-14.019-21.102-5.313-8.322-11.304-25.891-17.384-26.01-4.781-0.118-7.556 2.48-10.566 5.55-3.008 3.068-3.881 17.584-8.394 22.178z"/>
+ <path fill="#ffd028" d="m62.409 286.456c-7.204 7.166-23.479 1.281-27.588 4.838-4.235 3.715 5.275 10.168 4.351 17.787-0.58 4.455-2.364 6.51-3.357 12.53-0.738 4.679 3.697 4.149 6.142 5.347 11.249 4.763 13.515 3.946 30.577 7.221 14.529 2.748 18.986 8.386 34.012 7.037 5.043-0.424 4.474-0.594 9.289-3.68 4.392-2.897 2-2.481 1.125-9.314-0.963-10.686 0.273-14.664-4.608-22.364-8.079-12.726-8.746-12.517-14.024-20.901-5.277-8.269-11.133-25.896-17.173-26.015-4.75-0.116-7.506 2.464-10.497 5.513-2.992 3.047-3.759 17.447-8.249 22.001z"/>
+ <path fill="#ffd02b" d="m62.704 286.547c-7.388 7.33-23.805 1.445-27.757 4.912-4.204 3.729 5.455 10.311 4.533 17.594-0.593 4.427-2.272 6.553-3.417 12.523-0.854 4.651 3.786 4.098 6.262 5.225 11.619 4.671 14.104 4.087 30.341 7.185 14.438 2.72 18.864 8.33 33.787 6.991 5.01-0.418 4.341-0.604 9.166-3.604 4.292-2.729 1.942-2.359 0.972-9.396-1.163-10.613 0.263-14.709-4.369-22.012-8.03-12.641-8.787-12.371-14.03-20.7-5.243-8.214-10.962-25.903-16.962-26.021-4.719-0.117-7.457 2.447-10.428 5.477-2.972 3.029-3.632 17.313-8.098 21.826z"/>
+ <path fill="#ffd02d" d="m62.998 286.638c-7.57 7.493-24.13 1.61-27.925 4.987-4.174 3.742 5.635 10.451 4.716 17.4-0.604 4.398-2.182 6.596-3.478 12.518-0.969 4.623 3.875 4.045 6.382 5.101 11.991 4.579 14.694 4.228 30.105 7.149 14.345 2.688 18.743 8.275 33.563 6.944 4.977-0.411 4.207-0.61 9.042-3.524 4.193-2.562 1.884-2.239 0.819-9.481-1.366-10.538 0.251-14.754-4.133-21.659-7.979-12.557-8.825-12.224-14.034-20.498-5.208-8.16-10.791-25.91-16.751-26.027-4.688-0.114-7.407 2.433-10.358 5.441-2.951 3.01-3.505 17.174-7.948 21.649z"/>
+ <path fill="#ffd030" d="m63.292 286.729c-7.753 7.658-24.454 1.775-28.094 5.063-4.142 3.756 5.815 10.594 4.899 17.209-0.616 4.367-2.09 6.638-3.537 12.51-1.084 4.596 3.964 3.992 6.502 4.977 12.362 4.488 15.283 4.369 29.869 7.115 14.252 2.656 18.621 8.22 33.34 6.898 4.943-0.406 4.073-0.621 8.917-3.447 4.095-2.395 1.828-2.119 0.667-9.565-1.568-10.465 0.239-14.8-3.894-21.307-7.929-12.474-8.866-12.078-14.04-20.298-5.173-8.105-10.619-25.916-16.54-26.031-4.656-0.115-7.357 2.415-10.289 5.404-2.932 2.988-3.38 17.036-7.8 21.472z"/>
+ <path fill="#ffd133" d="m63.587 286.82c-7.937 7.822-24.78 1.94-28.263 5.138-4.111 3.77 5.995 10.734 5.081 17.016-0.627 4.339-1.998 6.68-3.597 12.504-1.199 4.568 4.054 3.939 6.623 4.854 12.732 4.396 15.873 4.51 29.633 7.08 14.16 2.625 18.499 8.164 33.116 6.852 4.909-0.4 3.939-0.629 8.793-3.369 3.997-2.227 1.77-1.999 0.514-9.648-1.771-10.393 0.228-14.846-3.654-20.955-7.88-12.39-8.907-11.934-14.046-20.098-5.139-8.051-10.448-25.922-16.329-26.037-4.625-0.113-7.309 2.398-10.221 5.368s-3.254 16.897-7.65 21.295z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m88.782 218.681c-0.936 2.088-1.728 20.017 2.952 27 4.68 6.911 3.312 10.872-1.872 5.616-5.4-5.112-8.928-12.816-9-18.145 0-3.096 2.376-15.84 3.313-17.208 1.007-1.44 5.327 1.153 4.607 2.737z"/>
+ <path fill="#030303" d="m88.692 219.032c-0.903 2.34-1.656 19.698 3.01 26.668 4.665 6.901 3.186 10.49-1.84 5.356-5.23-4.997-8.607-12.47-8.741-17.787-0.043-3.114 2.236-15.419 3.133-16.791 0.961-1.394 5.126 0.989 4.438 2.554z"/>
+ <path fill="#070707" d="m88.602 219.379c-0.871 2.593-1.584 19.383 3.067 26.338 4.65 6.891 3.06 10.109-1.808 5.098-5.062-4.882-8.287-12.125-8.481-17.432-0.087-3.131 2.095-14.998 2.952-16.373 0.915-1.345 4.926 0.828 4.27 2.369z"/>
+ <path fill="#0b0b0b" d="m88.512 219.729c-0.839 2.844-1.513 19.066 3.124 26.006 4.638 6.881 2.935 9.729-1.774 4.84-4.893-4.768-7.967-11.779-8.223-17.076-0.129-3.149 1.955-14.576 2.772-15.955 0.868-1.299 4.724 0.665 4.101 2.185z"/>
+ <path fill="#0f0f0f" d="m88.422 220.079c-0.806 3.096-1.439 18.748 3.183 25.674 4.623 6.869 2.809 9.347-1.742 4.58-4.724-4.651-7.646-11.434-7.963-16.719-0.173-3.168 1.814-14.154 2.592-15.537 0.819-1.252 4.52 0.504 3.93 2.002z"/>
+ <path fill="#131313" d="m88.332 220.426c-0.773 3.349-1.368 18.433 3.24 25.345 4.608 6.858 2.682 8.964-1.71 4.319-4.554-4.535-7.326-11.088-7.704-16.361-0.216-3.186 1.674-13.734 2.412-15.12 0.774-1.206 4.32 0.343 3.762 1.817z"/>
+ <path fill="#161616" d="m88.242 220.777c-0.741 3.601-1.296 18.115 3.298 25.013 4.594 6.848 2.556 8.582-1.678 4.061-4.385-4.421-7.006-10.742-7.444-16.006-0.26-3.203 1.533-13.313 2.231-14.702 0.728-1.16 4.118 0.18 3.593 1.634z"/>
+ <path fill="#1a1a1a" d="m88.152 221.125c-0.709 3.853-1.224 17.799 3.355 24.682 4.579 6.837 2.43 8.201-1.646 3.802-4.216-4.306-6.686-10.397-7.186-15.649-0.303-3.221 1.394-12.892 2.052-14.285 0.682-1.112 3.918 0.018 3.425 1.45z"/>
+ <path fill="#1e1e1e" d="m88.062 221.475c-0.677 4.104-1.152 17.482 3.413 24.35 4.564 6.826 2.304 7.82-1.613 3.543-4.046-4.191-6.365-10.051-6.927-15.293-0.345-3.24 1.253-12.47 1.872-13.867 0.634-1.066 3.716-0.144 3.255 1.267z"/>
+ <path fill="#222" d="m87.972 221.825c-0.645 4.355-1.08 17.164 3.47 24.018 4.551 6.816 2.179 7.439-1.58 3.285-3.877-4.076-6.044-9.707-6.667-14.938-0.389-3.258 1.112-12.049 1.691-13.449 0.587-1.019 3.514-0.306 3.086 1.084z"/>
+ <path fill="#262626" d="m87.883 222.172c-0.612 4.609-1.009 16.849 3.527 23.688 4.536 6.804 2.052 7.056-1.548 3.024-3.708-3.961-5.724-9.36-6.408-14.58-0.432-3.276 0.973-11.629 1.513-13.032 0.54-0.971 3.311-0.467 2.916 0.9z"/>
+ <path fill="#2a2a2a" d="m87.792 222.523c-0.579 4.86-0.936 16.531 3.586 23.356 4.521 6.793 1.926 6.675-1.516 2.765-3.539-3.845-5.403-9.015-6.148-14.224-0.476-3.293 0.831-11.207 1.332-12.614 0.493-0.925 3.11-0.63 2.746 0.717z"/>
+ <path fill="#2d2d2d" d="m87.702 222.872c-0.547 5.112-0.864 16.215 3.644 23.025 4.507 6.783 1.8 6.293-1.483 2.506-3.369-3.73-5.083-8.669-5.89-13.867-0.519-3.312 0.691-10.785 1.152-12.197 0.446-0.878 2.908-0.792 2.577 0.533z"/>
+ <path fill="#313131" d="m87.612 223.221c-0.515 5.363-0.792 15.898 3.701 22.693 4.492 6.772 1.674 5.912-1.451 2.248-3.2-3.615-4.763-8.324-5.63-13.512-0.563-3.33 0.551-10.363 0.972-11.779 0.399-0.831 2.707-0.953 2.408 0.35z"/>
+ <path fill="#353535" d="m87.522 223.57c-0.482 5.616-0.72 15.581 3.758 22.363 4.479 6.761 1.549 5.53-1.418 1.987-3.031-3.5-4.442-7.978-5.371-13.154-0.604-3.348 0.41-9.943 0.792-11.361 0.352-0.785 2.506-1.115 2.239 0.165z"/>
+ <path fill="#393939" d="m87.432 223.918c-0.45 5.869-0.648 15.265 3.815 22.033 4.464 6.75 1.422 5.147-1.386 1.728-2.862-3.384-4.122-7.632-5.112-12.798-0.647-3.366 0.271-9.522 0.612-10.944 0.307-0.737 2.305-1.278 2.071-0.019z"/>
+ <path fill="#3d3d3d" d="m87.343 224.269c-0.418 6.12-0.576 14.946 3.873 21.7 4.45 6.74 1.296 4.767-1.354 1.469-2.692-3.27-3.802-7.286-4.853-12.441-0.691-3.383 0.129-9.101 0.432-10.527 0.26-0.691 2.103-1.44 1.902-0.201z"/>
+ <path fill="#414141" d="m87.252 224.618c-0.385 6.373-0.504 14.631 3.932 21.369 4.436 6.729 1.17 4.385-1.321 1.211-2.523-3.154-3.481-6.941-4.594-12.086-0.734-3.402-0.011-8.68 0.252-10.109 0.212-0.644 1.901-1.602 1.731-0.385z"/>
+ <path fill="#444" d="m87.162 224.967c-0.353 6.623-0.432 14.314 3.989 21.037 4.421 6.719 1.044 4.004-1.289 0.951-2.354-3.039-3.161-6.595-4.334-11.729-0.778-3.42-0.151-8.258 0.071-9.691 0.166-0.597 1.7-1.763 1.563-0.568z"/>
+ <path fill="#484848" d="m87.072 225.316c-0.32 6.876-0.36 13.997 4.047 20.707 4.406 6.707 0.918 3.622-1.257 0.692-2.186-2.924-2.841-6.25-4.075-11.373-0.82-3.438-0.292-7.838-0.108-9.273 0.119-0.551 1.498-1.926 1.393-0.753z"/>
+ <path fill="#4c4c4c" d="m86.982 225.665c-0.288 7.129-0.288 13.68 4.104 20.377 4.393 6.695 0.792 3.24-1.224 0.432s-2.52-5.904-3.816-11.016c-0.863-3.457-0.432-7.416-0.287-8.856 0.071-0.505 1.295-2.089 1.223-0.937z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m88.782 218.681c4.32-9.433 6.696-19.584 12.888-29.448 6.12-9.792 3.672-13.608-0.863-8.64-4.536 4.968-9.504 15.48-9.504 15.48s-5.832 9.216-7.128 19.872c-0.217 1.8 3.887 4.248 4.607 2.736z"/>
+ <path fill="#020202" d="m88.968 218.071c4.279-9.5 6.615-19.246 12.586-28.802 5.901-9.488 3.608-13.279-0.764-8.472-4.403 4.847-9.302 15.236-9.368 15.381 0 0-5.637 8.993-6.877 19.239-0.209 1.771 3.72 4.145 4.423 2.654z"/>
+ <path fill="#050505" d="m89.152 217.459c4.239-9.566 6.535-18.908 12.284-28.155 5.683-9.183 3.547-12.95-0.663-8.303-4.27 4.725-9.099 14.991-9.231 15.282 0 0-5.442 8.766-6.627 18.605-0.201 1.741 3.554 4.042 4.237 2.571z"/>
+ <path fill="#070707" d="m89.338 216.849c4.199-9.634 6.454-18.572 11.981-27.51 5.465-8.878 3.484-12.621-0.563-8.135-4.137 4.605-8.896 14.748-9.096 15.184 0 0-5.247 8.542-6.376 17.972-0.192 1.713 3.387 3.937 4.054 2.489z"/>
+ <path fill="#0a0a0a" d="m89.522 216.239c4.159-9.701 6.375-18.234 11.68-26.865 5.247-8.573 3.422-12.292-0.461-7.966-4.005 4.483-8.693 14.503-8.96 15.085 0 0-5.053 8.317-6.126 17.337-0.186 1.684 3.219 3.837 3.867 2.409z"/>
+ <path fill="#0c0c0c" d="m89.708 215.627c4.118-9.769 6.294-17.897 11.376-26.218 5.029-8.268 3.36-11.962-0.359-7.797-3.871 4.361-8.49 14.259-8.823 14.985 0 0-4.858 8.093-5.876 16.706-0.179 1.653 3.051 3.731 3.682 2.324z"/>
+ <path fill="#0f0f0f" d="m89.893 215.017c4.077-9.836 6.213-17.56 11.073-25.573 4.812-7.963 3.298-11.633-0.259-7.629-3.738 4.241-8.288 14.015-8.688 14.887 0 0-4.663 7.868-5.625 16.072-0.169 1.624 2.886 3.628 3.499 2.243z"/>
+ <path fill="#111" d="m90.078 214.407c4.037-9.904 6.133-17.224 10.772-24.928 4.593-7.658 3.234-11.304-0.159-7.46-3.605 4.119-8.085 13.771-8.551 14.788 0 0-4.47 7.645-5.375 15.439-0.162 1.594 2.718 3.524 3.313 2.161z"/>
+ <path fill="#141414" d="m90.263 213.795c3.997-9.971 6.052-16.885 10.47-24.281 4.374-7.353 3.172-10.975-0.059-7.291-3.472 3.998-7.882 13.526-8.415 14.689 0 0-4.273 7.418-5.124 14.805-0.154 1.565 2.551 3.421 3.128 2.078z"/>
+ <path fill="#161616" d="m90.449 213.184c3.956-10.037 5.972-16.548 10.167-23.635 4.156-7.048 3.11-10.645 0.043-7.123-3.34 3.877-7.68 13.283-8.279 14.591 0 0-4.08 7.194-4.874 14.172-0.147 1.535 2.383 3.317 2.943 1.995z"/>
+ <path fill="#191919" d="m90.634 212.573c3.916-10.105 5.892-16.209 9.865-22.989 3.938-6.743 3.047-10.316 0.144-6.954-3.207 3.755-7.477 13.038-8.143 14.491 0 0-3.886 6.969-4.624 13.54-0.139 1.506 2.217 3.213 2.758 1.912z"/>
+ <path fill="#1c1c1c" d="m90.819 211.963c3.875-10.174 5.811-15.874 9.563-22.344 3.721-6.438 2.984-9.987 0.244-6.785-3.073 3.634-7.274 12.793-8.007 14.392 0 0-3.69 6.745-4.373 12.905-0.131 1.477 2.049 3.112 2.573 1.832z"/>
+ <path fill="#1e1e1e" d="m91.004 211.352c3.836-10.24 5.73-15.536 9.262-21.698 3.501-6.133 2.922-9.658 0.344-6.617-2.94 3.513-7.071 12.55-7.87 14.293 0 0-3.496 6.521-4.123 12.272-0.124 1.447 1.881 3.009 2.387 1.75z"/>
+ <path fill="#212121" d="m91.189 210.741c3.795-10.307 5.649-15.198 8.958-21.052 3.284-5.828 2.859-9.328 0.445-6.448-2.808 3.392-6.868 12.305-7.734 14.195 0 0-3.301 6.295-3.872 11.639-0.115 1.418 1.715 2.904 2.203 1.666z"/>
+ <path fill="#232323" d="m91.375 210.129c3.754-10.373 5.569-14.86 8.655-20.405 3.066-5.523 2.797-8.999 0.547-6.279-2.675 3.27-6.666 12.061-7.599 14.097 0 0-3.106 6.07-3.622 11.004-0.107 1.388 1.548 2.801 2.019 1.583z"/>
+ <path fill="#262626" d="m91.559 209.519c3.714-10.44 5.489-14.523 8.354-19.76 2.847-5.218 2.735-8.67 0.647-6.111-2.542 3.149-6.463 11.817-7.462 13.997 0 0-2.912 5.848-3.372 10.373-0.099 1.357 1.381 2.697 1.833 1.501z"/>
+ <path fill="#282828" d="m91.745 208.909c3.674-10.509 5.408-14.187 8.051-19.115 2.629-4.913 2.672-8.341 0.748-5.942-2.409 3.028-6.261 11.573-7.326 13.898 0 0-2.717 5.621-3.121 9.738-0.092 1.33 1.213 2.595 1.648 1.421z"/>
+ <path fill="#2b2b2b" d="m91.929 208.297c3.634-10.575 5.328-13.848 7.75-18.468 2.41-4.608 2.609-8.011 0.848-5.773-2.275 2.906-6.058 11.328-7.189 13.799 0 0-2.522 5.397-2.871 9.106-0.084 1.299 1.046 2.491 1.462 1.336z"/>
+ <path fill="#2d2d2d" d="m92.115 207.687c3.593-10.643 5.247-13.512 7.447-17.823 2.191-4.303 2.547-7.682 0.949-5.605-2.144 2.786-5.855 11.085-7.055 13.701 0 0-2.327 5.172-2.62 8.473-0.076 1.269 0.881 2.386 1.279 1.254z"/>
+ <path fill="#303030" d="m92.301 207.077c3.552-10.71 5.167-13.175 7.145-17.178 1.974-3.998 2.484-7.353 1.05-5.436-2.011 2.664-5.652 10.84-6.918 13.602 0 0-2.133 4.947-2.37 7.839-0.07 1.24 0.712 2.283 1.093 1.173z"/>
+ <path fill="#333" d="m92.485 206.465c3.513-10.778 5.087-12.837 6.843-16.531s2.422-7.024 1.15-5.268c-1.877 2.543-5.449 10.596-6.781 13.502 0 0-1.938 4.724-2.12 7.207-0.061 1.211 0.545 2.18 0.908 1.09z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m273.03 225.592c2.088-5.903 1.872-20.951-3.456-30.671-1.872-3.528-3.672-7.632-4.752-7.848-1.152-0.216-3.24 2.088-3.024 2.448 0.288 0.576 10.009 14.256 7.992 32.832-0.144 1.513 2.736 4.608 3.24 3.239z"/>
+ <path fill="#030303" d="m272.936 224.831c2.025-5.719 1.753-20.379-3.344-29.663-1.815-3.407-3.535-7.374-4.595-7.59-1.122-0.214-3.125 2.022-2.925 2.367 0.258 0.562 9.605 13.778 7.729 31.753-0.129 1.487 2.647 4.456 3.135 3.133z"/>
+ <path fill="#070707" d="m272.841 224.068c1.967-5.532 1.637-19.808-3.228-28.654-1.762-3.286-3.401-7.115-4.44-7.332-1.091-0.211-3.009 1.956-2.824 2.287 0.227 0.548 9.201 13.299 7.466 30.672-0.118 1.463 2.555 4.305 3.026 3.027z"/>
+ <path fill="#0b0b0b" d="m272.747 223.305c1.904-5.348 1.518-19.235-3.115-27.645-1.706-3.165-3.265-6.856-4.282-7.073-1.062-0.21-2.896 1.889-2.727 2.206 0.197 0.534 8.799 12.821 7.203 29.592-0.103 1.44 2.466 4.153 2.921 2.92z"/>
+ <path fill="#0f0f0f" d="m272.652 222.542c1.843-5.162 1.398-18.662-3.001-26.635-1.65-3.044-3.13-6.598-4.127-6.815-1.03-0.207-2.779 1.823-2.626 2.126 0.167 0.52 8.396 12.341 6.94 28.511-0.09 1.416 2.376 4.001 2.814 2.813z"/>
+ <path fill="#131313" d="m272.558 221.779c1.78-4.976 1.279-18.09-2.889-25.626-1.595-2.923-2.994-6.34-3.97-6.557-1-0.205-2.664 1.756-2.527 2.045 0.137 0.506 7.993 11.861 6.679 27.432-0.078 1.392 2.285 3.849 2.707 2.706z"/>
+ <path fill="#161616" d="m272.463 221.016c1.721-4.79 1.163-17.518-2.773-24.617-1.539-2.802-2.858-6.081-3.814-6.299-0.969-0.203-2.548 1.691-2.427 1.965 0.106 0.492 7.59 11.383 6.415 26.352-0.065 1.369 2.194 3.697 2.599 2.599z"/>
+ <path fill="#1a1a1a" d="m272.369 220.254c1.659-4.605 1.044-16.946-2.66-23.609-1.483-2.681-2.723-5.822-3.658-6.04-0.938-0.201-2.434 1.624-2.326 1.884 0.074 0.478 7.185 10.904 6.15 25.271-0.051 1.344 2.106 3.546 2.494 2.494z"/>
+ <path fill="#1e1e1e" d="m272.274 219.491c1.598-4.42 0.926-16.373-2.546-22.6-1.43-2.56-2.587-5.564-3.501-5.782-0.908-0.198-2.319 1.558-2.229 1.804 0.044 0.464 6.782 10.425 5.889 24.19-0.037 1.321 2.016 3.396 2.387 2.388z"/>
+ <path fill="#222" d="m272.18 218.728c1.535-4.233 0.807-15.802-2.434-21.59-1.373-2.439-2.452-5.306-3.345-5.524-0.877-0.197-2.203 1.491-2.128 1.723 0.014 0.45 6.379 9.947 5.625 23.111-0.023 1.297 1.927 3.242 2.282 2.28z"/>
+ <path fill="#262626" d="m272.085 217.965c1.476-4.049 0.69-15.229-2.318-20.582-1.317-2.317-2.316-5.046-3.188-5.265-0.847-0.194-2.088 1.425-2.029 1.642-0.016 0.436 5.977 9.468 5.362 22.032-0.011 1.272 1.835 3.089 2.173 2.173z"/>
+ <path fill="#2a2a2a" d="m271.991 217.202c1.413-3.861 0.571-14.656-2.206-19.572-1.262-2.196-2.18-4.788-3.032-5.007-0.815-0.191-1.973 1.36-1.929 1.562-0.047 0.422 5.573 8.988 5.099 20.951 0.003 1.247 1.746 2.939 2.068 2.066z"/>
+ <path fill="#2d2d2d" d="m271.896 216.439c1.353-3.677 0.453-14.084-2.091-18.563-1.207-2.076-2.045-4.529-2.876-4.749-0.786-0.19-1.858 1.293-1.83 1.481-0.077 0.408 5.17 8.51 4.836 19.87 0.017 1.226 1.656 2.789 1.961 1.961z"/>
+ <path fill="#313131" d="m271.802 215.676c1.29-3.491 0.334-13.512-1.979-17.553-1.151-1.956-1.909-4.272-2.72-4.493-0.755-0.187-1.742 1.227-1.73 1.401-0.106 0.394 4.768 8.031 4.573 18.791 0.031 1.202 1.567 2.637 1.856 1.854z"/>
+ <path fill="#353535" d="m271.707 214.915c1.23-3.307 0.217-12.94-1.864-16.545-1.096-1.834-1.773-4.014-2.563-4.234-0.725-0.185-1.627 1.16-1.631 1.32-0.138 0.38 4.364 7.552 4.311 17.71 0.042 1.176 1.475 2.485 1.747 1.749z"/>
+ <path fill="#393939" d="m271.613 214.151c1.168-3.119 0.098-12.367-1.751-15.535-1.04-1.714-1.638-3.755-2.407-3.976-0.693-0.183-1.512 1.094-1.531 1.24-0.168 0.366 3.962 7.074 4.049 16.63 0.055 1.153 1.384 2.332 1.64 1.641z"/>
+ <path fill="#3d3d3d" d="m271.518 213.388c1.106-2.935-0.021-11.796-1.638-14.527-0.983-1.592-1.502-3.496-2.25-3.716-0.664-0.181-1.396 1.028-1.432 1.159-0.198 0.352 3.558 6.594 3.785 15.549 0.07 1.13 1.296 2.183 1.535 1.535z"/>
+ <path fill="#414141" d="m271.424 212.625c1.047-2.75-0.139-11.223-1.522-13.518-0.93-1.471-1.367-3.238-2.094-3.459-0.635-0.178-1.282 0.962-1.333 1.079-0.229 0.338 3.153 6.114 3.521 14.47 0.083 1.106 1.205 2.03 1.428 1.428z"/>
+ <path fill="#444" d="m271.329 211.862c0.985-2.563-0.256-10.65-1.409-12.508-0.874-1.35-1.23-2.979-1.938-3.2-0.604-0.177-1.166 0.895-1.233 0.998-0.259 0.324 2.751 5.636 3.259 13.39 0.096 1.082 1.115 1.876 1.321 1.32z"/>
+ <path fill="#484848" d="m271.235 211.099c0.923-2.377-0.375-10.077-1.296-11.499-0.818-1.229-1.097-2.72-1.781-2.942-0.573-0.174-1.052 0.829-1.134 0.918-0.289 0.309 2.348 5.156 2.996 12.309 0.11 1.058 1.025 1.726 1.215 1.214z"/>
+ <path fill="#4c4c4c" d="m271.14 210.336c0.861-2.192-0.493-9.506-1.183-10.49-0.763-1.107-0.96-2.463-1.625-2.684-0.542-0.172-0.936 0.762-1.034 0.836-0.319 0.297 1.945 4.68 2.733 11.229 0.124 1.035 0.936 1.576 1.109 1.109z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m264.822 187.073c-10.224-13.968-23.472-18.504-22.104-14.112 0 0 10.152 5.76 19.08 16.56 1.728 2.088 4.608-0.288 3.024-2.448z"/>
+ <path fill="#030303" d="m264.372 186.687c-9.924-13.495-22.894-17.912-21.539-13.7 0.018 0.012 9.901 5.614 18.609 16.071 1.678 2.016 4.467-0.285 2.93-2.371z"/>
+ <path fill="#070707" d="m263.922 186.3c-9.624-13.022-22.316-17.319-20.975-13.287 0.036 0.023 9.652 5.467 18.139 15.582 1.628 1.943 4.325-0.283 2.836-2.295z"/>
+ <path fill="#0b0b0b" d="m263.472 185.913c-9.324-12.549-21.739-16.726-20.41-12.874 0.053 0.034 9.4 5.32 17.668 15.093 1.578 1.871 4.183-0.28 2.742-2.219z"/>
+ <path fill="#0f0f0f" d="m263.022 185.527c-9.024-12.077-21.162-16.134-19.847-12.462 0.071 0.045 9.151 5.174 17.198 14.604 1.529 1.798 4.043-0.278 2.649-2.142z"/>
+ <path fill="#131313" d="m262.571 185.14c-8.723-11.603-20.583-15.541-19.28-12.049 0.088 0.056 8.901 5.027 16.728 14.114 1.477 1.726 3.899-0.275 2.552-2.065z"/>
+ <path fill="#161616" d="m262.121 184.753c-8.423-11.13-20.006-14.948-18.716-11.636 0.106 0.067 8.651 4.88 16.257 13.625 1.428 1.654 3.759-0.272 2.459-1.989z"/>
+ <path fill="#1a1a1a" d="m261.671 184.367c-8.124-10.658-19.428-14.356-18.15-11.224 0.124 0.078 8.399 4.734 15.785 13.136 1.378 1.581 3.617-0.27 2.365-1.912z"/>
+ <path fill="#1e1e1e" d="m261.221 183.98c-7.824-10.185-18.851-13.763-17.588-10.811 0.143 0.089 8.151 4.587 15.315 12.647 1.33 1.508 3.477-0.267 2.273-1.836z"/>
+ <path fill="#222" d="m260.771 183.593c-7.524-9.711-18.273-13.17-17.022-10.398 0.159 0.1 7.9 4.44 14.844 12.158 1.279 1.436 3.335-0.265 2.178-1.76z"/>
+ <path fill="#262626" d="m260.321 183.206c-7.224-9.238-17.695-12.578-16.458-9.985 0.177 0.111 7.65 4.293 14.374 11.668 1.229 1.364 3.193-0.262 2.084-1.683z"/>
+ <path fill="#2a2a2a" d="m259.871 182.82c-6.924-8.766-17.118-11.986-15.893-9.573 0.193 0.122 7.398 4.147 13.902 11.179 1.18 1.291 3.053-0.259 1.991-1.606z"/>
+ <path fill="#2d2d2d" d="m259.42 182.433c-6.623-8.293-16.539-11.393-15.328-9.16 0.214 0.133 7.15 4 13.434 10.69 1.128 1.219 2.909-0.257 1.894-1.53z"/>
+ <path fill="#313131" d="m258.97 182.046c-6.323-7.819-15.963-10.8-14.764-8.747 0.23 0.144 6.899 3.853 12.962 10.201 1.08 1.146 2.769-0.254 1.802-1.454z"/>
+ <path fill="#353535" d="m258.52 181.66c-6.023-7.347-15.384-10.208-14.199-8.335 0.248 0.155 6.649 3.707 12.492 9.712 1.028 1.073 2.627-0.252 1.707-1.377z"/>
+ <path fill="#393939" d="m258.07 181.273c-5.723-6.874-14.807-9.615-13.634-7.922 0.265 0.166 6.398 3.56 12.021 9.222 0.978 1.002 2.485-0.249 1.613-1.3z"/>
+ <path fill="#3d3d3d" d="m257.62 180.886c-5.423-6.401-14.229-9.021-13.07-7.509 0.283 0.177 6.149 3.413 11.552 8.733 0.927 0.929 2.343-0.246 1.518-1.224z"/>
+ <path fill="#414141" d="m257.17 180.5c-5.124-5.928-13.65-8.43-12.505-7.097 0.301 0.188 5.898 3.267 11.079 8.244 0.879 0.856 2.203-0.244 1.426-1.147z"/>
+ <path fill="#444" d="m256.719 180.113c-4.823-5.455-13.073-7.837-11.94-6.684 0.319 0.199 5.649 3.12 10.609 7.755 0.829 0.784 2.061-0.241 1.331-1.071z"/>
+ <path fill="#484848" d="m256.269 179.726c-4.523-4.982-12.495-7.244-11.375-6.271 0.336 0.21 5.397 2.973 10.138 7.266 0.779 0.711 1.92-0.239 1.237-0.995z"/>
+ <path fill="#4c4c4c" d="m255.819 179.339c-4.223-4.509-11.918-6.652-10.812-5.859 0.354 0.222 5.148 2.827 9.668 6.777 0.73 0.639 1.779-0.236 1.144-0.918z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m273.03 225.592c0.144 6.265-5.76 22.248-7.992 21.673-2.52-0.576 0.504-5.257 2.809-13.177 0.936-3.312 1.655-11.447 1.943-11.735 0.936-0.936 3.24 1.728 3.24 3.239z"/>
+ <path fill="#050505" d="m272.846 226.116c0.103 6.082-5.638 21.594-7.797 21.018-2.422-0.567 0.548-5.146 2.814-12.928 0.899-3.178 1.595-10.947 1.887-11.25 0.914-0.927 3.147 1.527 3.096 3.16z"/>
+ <path fill="#0a0a0a" d="m272.662 226.637c0.063 5.902-5.514 20.939-7.601 20.363-2.323-0.558 0.592-5.035 2.82-12.681 0.861-3.041 1.534-10.446 1.83-10.761 0.89-0.917 3.054 1.327 2.951 3.079z"/>
+ <path fill="#0f0f0f" d="m272.478 227.159c0.021 5.721-5.392 20.284-7.405 19.711-2.224-0.55 0.635-4.927 2.827-12.434 0.824-2.907 1.473-9.945 1.773-10.273 0.866-0.911 2.959 1.125 2.805 2.996z"/>
+ <path fill="#141414" d="m272.294 227.68c-0.02 5.539-5.268 19.63-7.21 19.057-2.125-0.541 0.68-4.816 2.835-12.186 0.786-2.771 1.412-9.445 1.717-9.786 0.84-0.901 2.863 0.924 2.658 2.915z"/>
+ <path fill="#191919" d="m272.11 228.202c-0.063 5.359-5.146 18.977-7.014 18.403-2.027-0.532 0.722-4.706 2.841-11.938 0.748-2.637 1.351-8.944 1.659-9.299 0.818-0.893 2.77 0.722 2.514 2.834z"/>
+ <path fill="#1e1e1e" d="m271.926 228.723c-0.103 5.179-5.021 18.322-6.818 17.75-1.928-0.523 0.766-4.596 2.848-11.691 0.711-2.5 1.29-8.443 1.603-8.811 0.792-0.885 2.674 0.522 2.367 2.752z"/>
+ <path fill="#232323" d="m271.742 229.245c-0.144 4.998-4.9 17.668-6.623 17.096-1.83-0.514 0.811-4.485 2.854-11.442 0.673-2.366 1.229-7.944 1.545-8.325 0.771-0.876 2.583 0.32 2.224 2.671z"/>
+ <path fill="#282828" d="m271.558 229.766c-0.186 4.816-4.777 17.013-6.428 16.443-1.731-0.506 0.854-4.377 2.86-11.196 0.636-2.231 1.168-7.443 1.488-7.837 0.749-0.866 2.49 0.119 2.08 2.59z"/>
+ <path fill="#2d2d2d" d="m271.374 230.288c-0.226 4.637-4.653 16.359-6.231 15.789-1.632-0.496 0.896-4.266 2.866-10.947 0.6-2.098 1.107-6.943 1.433-7.351 0.722-0.859 2.393-0.081 1.932 2.509z"/>
+ <path fill="#333" d="m271.19 230.809c-0.268 4.456-4.531 15.705-6.036 15.136-1.534-0.489 0.94-4.155 2.873-10.7 0.561-1.961 1.046-6.443 1.375-6.863 0.7-0.848 2.3-0.282 1.788 2.427z"/>
+ <path fill="#383838" d="m271.006 231.331c-0.308 4.275-4.407 15.051-5.841 14.482-1.435-0.48 0.984-4.046 2.88-10.453 0.524-1.826 0.985-5.941 1.318-6.375 0.676-0.84 2.206-0.483 1.643 2.346z"/>
+ <path fill="#3d3d3d" d="m270.822 231.853c-0.35 4.093-4.285 14.396-5.645 13.828-1.338-0.472 1.027-3.937 2.886-10.206 0.485-1.691 0.924-5.441 1.261-5.887 0.653-0.832 2.113-0.684 1.498 2.265z"/>
+ <path fill="#424242" d="m270.638 232.374c-0.392 3.914-4.162 13.742-5.45 13.176-1.238-0.463 1.072-3.826 2.893-9.959 0.449-1.555 0.863-4.94 1.204-5.399 0.629-0.824 2.019-0.886 1.353 2.182z"/>
+ <path fill="#474747" d="m270.454 232.896c-0.432 3.731-4.039 13.087-5.254 12.521-1.14-0.453 1.115-3.715 2.9-9.711 0.411-1.42 0.802-4.439 1.146-4.912 0.606-0.815 1.925-1.086 1.208 2.102z"/>
+ <path fill="#4c4c4c" d="m270.27 233.417c-0.474 3.553-3.916 12.434-5.06 11.869-1.041-0.445 1.159-3.606 2.907-9.463 0.373-1.287 0.741-3.941 1.089-4.427 0.583-0.806 1.832-1.287 1.064 2.021z"/>
+ <path fill="#515151" d="m270.086 233.939c-0.514 3.37-3.793 11.778-4.862 11.214-0.942-0.436 1.201-3.496 2.913-9.216 0.336-1.151 0.68-3.438 1.032-3.938 0.558-0.797 1.736-1.489 0.917 1.94z"/>
+ <path fill="#565656" d="m269.902 234.461c-0.555 3.188-3.671 11.123-4.667 10.56-0.844-0.429 1.246-3.386 2.919-8.968 0.298-1.016 0.619-2.939 0.976-3.451 0.535-0.788 1.643-1.689 0.772 1.859z"/>
+ <path fill="#5b5b5b" d="m269.718 234.982c-0.597 3.009-3.548 10.47-4.472 9.907-0.745-0.42 1.29-3.275 2.926-8.721 0.262-0.881 0.559-2.438 0.919-2.963 0.511-0.781 1.549-1.89 0.627 1.777z"/>
+ <path fill="#606060" d="m269.534 235.504c-0.638 2.828-3.425 9.814-4.276 9.252-0.646-0.409 1.333-3.166 2.933-8.473 0.224-0.746 0.497-1.938 0.862-2.476 0.487-0.769 1.454-2.09 0.481 1.697z"/>
+ <path fill="#666" d="m269.35 236.025c-0.68 2.647-3.303 9.161-4.081 8.599-0.548-0.4 1.377-3.056 2.938-8.225 0.187-0.611 0.437-1.438 0.806-1.988 0.464-0.763 1.361-2.293 0.337 1.614z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m251.07 187.865c-1.537 1.622-2.903 9.991 0.938 12.893 3.844 2.818 10.59-2.391 10.59-5.379-0.086-6.746-9.991-9.222-11.528-7.514z"/>
+ <path fill="#010101" d="m251.207 188.006c-1.559 1.611-2.876 9.823 0.857 12.667 3.731 2.764 10.349-2.273 10.384-5.279-0.047-6.576-9.681-9.083-11.241-7.388z"/>
+ <path fill="#030303" d="m251.344 188.146c-1.582 1.601-2.85 9.653 0.774 12.438 3.62 2.709 10.109-2.154 10.178-5.177-0.007-6.404-9.37-8.943-10.952-7.261z"/>
+ <path fill="#050505" d="m251.481 188.287c-1.604 1.589-2.823 9.484 0.691 12.211 3.511 2.653 9.869-2.037 9.975-5.078 0.031-6.232-9.061-8.802-10.666-7.133z"/>
+ <path fill="#070707" d="m251.617 188.427c-1.626 1.579-2.795 9.316 0.611 11.984 3.397 2.6 9.629-1.918 9.768-4.976 0.071-6.062-8.751-8.664-10.379-7.008z"/>
+ <path fill="#090909" d="m251.754 188.567c-1.648 1.568-2.768 9.146 0.529 11.758 3.287 2.543 9.389-1.802 9.563-4.875 0.109-5.892-8.441-8.525-10.092-6.883z"/>
+ <path fill="#0b0b0b" d="m251.891 188.708c-1.671 1.557-2.741 8.978 0.445 11.529 3.177 2.489 9.15-1.683 9.358-4.774 0.15-5.72-8.13-8.385-9.803-6.755z"/>
+ <path fill="#0d0d0d" d="m252.028 188.848c-1.694 1.546-2.715 8.809 0.364 11.302 3.064 2.435 8.908-1.565 9.152-4.673 0.189-5.549-7.82-8.245-9.516-6.629z"/>
+ <path fill="#0f0f0f" d="m252.165 188.989c-1.716 1.535-2.688 8.64 0.282 11.074 2.953 2.38 8.669-1.447 8.948-4.572 0.226-5.378-7.512-8.106-9.23-6.502z"/>
+ <path fill="#111" d="m252.301 189.129c-1.737 1.524-2.659 8.471 0.2 10.847 2.844 2.325 8.431-1.33 8.743-4.471 0.266-5.207-7.201-7.966-8.943-6.376z"/>
+ <path fill="#131313" d="m252.438 189.269c-1.76 1.514-2.633 8.304 0.118 10.619 2.73 2.271 8.189-1.212 8.538-4.369 0.305-5.036-6.892-7.827-8.656-6.25z"/>
+ <path fill="#151515" d="m252.575 189.41c-1.783 1.503-2.606 8.133 0.036 10.391 2.62 2.216 7.949-1.094 8.332-4.268 0.344-4.865-6.581-7.687-8.368-6.123z"/>
+ <path fill="#161616" d="m252.712 189.55c-1.805 1.492-2.58 7.965-0.046 10.164 2.508 2.162 7.709-0.975 8.127-4.167 0.383-4.694-6.272-7.548-8.081-5.997z"/>
+ <path fill="#181818" d="m252.849 189.691c-1.828 1.481-2.554 7.796-0.129 9.937 2.397 2.105 7.47-0.857 7.922-4.066 0.423-4.524-5.961-7.409-7.793-5.871z"/>
+ <path fill="#1a1a1a" d="m252.985 189.831c-1.85 1.47-2.525 7.626-0.21 9.708 2.286 2.053 7.229-0.74 7.717-3.964 0.461-4.352-5.652-7.269-7.507-5.744z"/>
+ <path fill="#1c1c1c" d="m253.122 189.971c-1.872 1.46-2.499 7.459-0.292 9.482 2.175 1.996 6.989-0.623 7.511-3.865 0.501-4.18-5.341-7.128-7.219-5.617z"/>
+ <path fill="#1e1e1e" d="m253.259 190.112c-1.895 1.448-2.472 7.289-0.375 9.254 2.064 1.942 6.75-0.504 7.308-3.763 0.539-4.01-5.033-6.99-6.933-5.491z"/>
+ <path fill="#202020" d="m253.396 190.252c-1.917 1.438-2.445 7.122-0.457 9.027 1.953 1.888 6.51-0.386 7.102-3.662 0.578-3.839-4.722-6.85-6.645-5.365z"/>
+ <path fill="#222" d="m253.533 190.393c-1.939 1.426-2.418 6.951-0.539 8.799 1.841 1.832 6.271-0.268 6.896-3.561 0.618-3.668-4.412-6.711-6.357-5.238z"/>
+ <path fill="#242424" d="m253.669 190.533c-1.961 1.416-2.391 6.783-0.621 8.572 1.731 1.776 6.03-0.149 6.692-3.46 0.657-3.497-4.102-6.571-6.071-5.112z"/>
+ <path fill="#262626" d="m253.806 190.673c-1.984 1.405-2.364 6.615-0.703 8.344 1.619 1.724 5.79-0.032 6.485-3.358 0.697-3.326-3.791-6.432-5.782-4.986z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m250.71 256.698c1.513 1.512 2.809-2.232 4.32-3.457 1.512-1.224 3.96-3.888 8.856-3.888s4.535-0.144 4.319-2.017c-0.144-1.799-1.584-1.655-5.903-1.008-4.32 0.576-7.2 2.809-8.929 4.824-1.654 1.945-3.527 4.681-2.663 5.546z"/>
+ <path fill="#050505" d="m251.043 256.331c1.459 1.449 2.703-2.121 4.205-3.308 1.501-1.187 3.931-3.731 8.64-3.731 4.71-0.002 4.415-0.129 4.209-1.94-0.139-1.743-1.543-1.593-5.749-0.979-4.207 0.543-7.029 2.703-8.712 4.648-1.616 1.877-3.438 4.474-2.593 5.31z"/>
+ <path fill="#0a0a0a" d="m251.376 255.961c1.406 1.389 2.6-2.008 4.089-3.156 1.491-1.148 3.901-3.576 8.425-3.577 4.521-0.001 4.293-0.11 4.096-1.862-0.132-1.688-1.501-1.531-5.594-0.951-4.093 0.51-6.857 2.596-8.495 4.471-1.575 1.812-3.348 4.271-2.521 5.075z"/>
+ <path fill="#0f0f0f" d="m251.709 255.594c1.354 1.326 2.494-1.895 3.975-3.008 1.479-1.111 3.871-3.42 8.207-3.422s4.171-0.094 3.984-1.785c-0.126-1.631-1.46-1.467-5.438-0.924-3.979 0.479-6.687 2.492-8.28 4.297-1.533 1.747-3.257 4.066-2.448 4.842z"/>
+ <path fill="#141414" d="m252.042 255.226c1.302 1.265 2.39-1.783 3.858-2.856 1.47-1.076 3.842-3.266 7.991-3.268s4.05-0.077 3.873-1.709c-0.119-1.575-1.419-1.404-5.284-0.895-3.865 0.444-6.515 2.385-8.063 4.121-1.49 1.678-3.165 3.861-2.375 4.607z"/>
+ <path fill="#191919" d="m252.374 254.859c1.249 1.202 2.285-1.671 3.744-2.708 1.458-1.037 3.813-3.109 7.774-3.111 3.963-0.004 3.929-0.061 3.761-1.633-0.113-1.519-1.377-1.341-5.128-0.867-3.751 0.414-6.344 2.279-7.847 3.945-1.449 1.613-3.075 3.656-2.304 4.374z"/>
+ <path fill="#1e1e1e" d="m252.707 254.491c1.196 1.141 2.182-1.559 3.628-2.558 1.448-1 3.783-2.954 7.56-2.957 3.775-0.003 3.806-0.043 3.648-1.556-0.106-1.461-1.336-1.277-4.974-0.838-3.637 0.379-6.172 2.174-7.63 3.77-1.408 1.546-2.984 3.451-2.232 4.139z"/>
+ <path fill="#232323" d="m253.04 254.124c1.144 1.078 2.076-1.447 3.514-2.41 1.437-0.961 3.753-2.797 7.342-2.799 3.589-0.004 3.685-0.027 3.537-1.48-0.101-1.405-1.294-1.215-4.818-0.811-3.524 0.349-6.001 2.068-7.415 3.594-1.367 1.48-2.894 3.245-2.16 3.906z"/>
+ <path fill="#282828" d="m253.373 253.754c1.09 1.018 1.972-1.334 3.398-2.258 1.426-0.926 3.723-2.642 7.125-2.646 3.402-0.005 3.563-0.011 3.426-1.403-0.095-1.349-1.253-1.15-4.664-0.781-3.409 0.314-5.829 1.961-7.198 3.418-1.325 1.415-2.803 3.041-2.087 3.67z"/>
+ <path fill="#2d2d2d" d="m253.706 253.388c1.038 0.954 1.866-1.222 3.282-2.11 1.416-0.887 3.694-2.486 6.909-2.49 3.217-0.004 3.441 0.008 3.313-1.326-0.088-1.293-1.212-1.088-4.508-0.754-3.296 0.283-5.658 1.857-6.981 3.244-1.284 1.345-2.712 2.836-2.015 3.436z"/>
+ <path fill="#333" d="m254.039 253.02c0.985 0.893 1.762-1.109 3.167-1.96s3.664-2.33 6.693-2.335c3.029-0.006 3.32 0.023 3.202-1.249-0.082-1.237-1.171-1.024-4.354-0.726-3.182 0.25-5.485 1.75-6.765 3.066-1.243 1.282-2.622 2.634-1.943 3.204z"/>
+ <path fill="#383838" d="m254.372 252.652c0.933 0.831 1.657-0.998 3.051-1.81 1.396-0.813 3.636-2.174 6.478-2.18 2.843-0.006 3.199 0.039 3.09-1.172-0.076-1.181-1.129-0.963-4.198-0.697-3.068 0.217-5.314 1.644-6.55 2.891-1.202 1.214-2.531 2.427-1.871 2.968z"/>
+ <path fill="#3d3d3d" d="m254.704 252.284c0.88 0.771 1.553-0.885 2.938-1.66 1.383-0.775 3.604-2.019 6.26-2.024 2.656-0.007 3.078 0.058 2.979-1.095-0.069-1.125-1.088-0.899-4.044-0.67-2.954 0.185-5.144 1.539-6.333 2.715-1.16 1.148-2.441 2.222-1.8 2.734z"/>
+ <path fill="#424242" d="m255.037 251.917c0.827 0.707 1.448-0.773 2.821-1.51 1.373-0.738 3.576-1.863 6.044-1.871 2.47-0.007 2.956 0.074 2.867-1.018-0.063-1.068-1.046-0.836-3.889-0.641-2.841 0.151-4.973 1.433-6.116 2.539-1.119 1.083-2.35 2.018-1.727 2.501z"/>
+ <path fill="#474747" d="m255.37 251.549c0.774 0.645 1.344-0.661 2.706-1.362 1.362-0.7 3.545-1.706 5.828-1.713 2.283-0.009 2.834 0.09 2.754-0.942-0.057-1.012-1.005-0.773-3.733-0.613-2.727 0.119-4.801 1.328-5.899 2.365-1.078 1.013-2.26 1.81-1.656 2.265z"/>
+ <path fill="#4c4c4c" d="m255.703 251.181c0.721 0.584 1.239-0.55 2.59-1.212 1.353-0.663 3.517-1.551 5.612-1.559 2.096-0.009 2.713 0.107 2.643-0.865-0.051-0.955-0.964-0.709-3.577-0.584-2.613 0.086-4.631 1.222-5.686 2.188-1.035 0.949-2.168 1.607-1.582 2.032z"/>
+ <path fill="#515151" d="m256.036 250.813c0.669 0.521 1.134-0.436 2.476-1.063 1.341-0.625 3.485-1.395 5.395-1.402 1.91-0.01 2.591 0.124 2.531-0.789-0.044-0.898-0.922-0.646-3.423-0.557-2.499 0.055-4.458 1.117-5.469 2.014-0.994 0.882-2.077 1.402-1.51 1.797z"/>
+ <path fill="#565656" d="m256.369 250.446c0.616 0.459 1.029-0.324 2.36-0.912 1.33-0.589 3.456-1.24 5.178-1.248 1.723-0.01 2.47 0.141 2.42-0.713-0.039-0.842-0.881-0.582-3.268-0.527-2.387 0.021-4.287 1.01-5.252 1.836-0.953 0.816-1.987 1.199-1.438 1.564z"/>
+ <path fill="#5b5b5b" d="m256.701 250.079c0.564 0.397 0.926-0.213 2.245-0.764s3.427-1.084 4.963-1.093c1.536-0.011 2.348 0.157 2.307-0.636-0.031-0.785-0.839-0.52-3.112-0.5-2.271-0.01-4.116 0.906-5.035 1.662-0.913 0.751-1.898 0.993-1.368 1.331z"/>
+ <path fill="#606060" d="m257.034 249.709c0.511 0.336 0.821-0.1 2.13-0.612 1.309-0.515 3.397-0.929 4.746-0.938 1.351-0.01 2.227 0.176 2.196-0.558-0.026-0.729-0.799-0.457-2.958-0.472-2.158-0.043-3.945 0.799-4.82 1.486-0.87 0.682-1.805 0.788-1.294 1.094z"/>
+ <path fill="#666" d="m257.367 249.342c0.458 0.273 0.716 0.012 2.014-0.465 1.299-0.476 3.368-0.771 4.53-0.781 1.163-0.012 2.105 0.191 2.084-0.482-0.02-0.673-0.757-0.393-2.803-0.443-2.044-0.076-3.773 0.693-4.604 1.311-0.828 0.616-1.714 0.582-1.221 0.86z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m270.222 247.265c0 2.304 4.68 3.096 9.144 3.743 4.392 0.648 7.92 1.513 8.136 6.121 0.216 4.535-0.936 7.775 1.08 7.416 4.32-0.793 5.904-5.473 5.832-7.633 0-2.16-3.168-6.047-8.855-8.207-4.177-1.584-7.2-2.305-10.872-2.449-4.897-0.214-4.465 1.009-4.465 1.009z"/>
+ <path fill="#030303" d="m270.348 247.304c0.012 2.239 4.643 2.97 9.049 3.639 4.352 0.675 7.75 1.511 8.11 6.002 0.345 4.415-0.854 7.515 1.136 7.188 4.145-0.739 5.688-5.2 5.607-7.332-0.015-2.151-3.118-5.91-8.733-8.038-4.129-1.563-7.11-2.298-10.74-2.452-4.754-0.217-4.438 0.952-4.429 0.993z"/>
+ <path fill="#070707" d="m270.474 247.342c0.023 2.174 4.605 2.845 8.953 3.533 4.312 0.701 7.58 1.508 8.087 5.885 0.471 4.295-0.771 7.252 1.189 6.963 3.97-0.689 5.472-4.93 5.384-7.033-0.028-2.145-3.068-5.773-8.61-7.87-4.082-1.543-7.022-2.291-10.609-2.456-4.612-0.218-4.412 0.896-4.394 0.978z"/>
+ <path fill="#0b0b0b" d="m270.6 247.379c0.035 2.109 4.568 2.721 8.857 3.431 4.271 0.728 7.411 1.506 8.063 5.767 0.599 4.174-0.689 6.988 1.245 6.735 3.795-0.638 5.257-4.66 5.159-6.733-0.044-2.137-3.019-5.636-8.488-7.701-4.035-1.522-6.933-2.285-10.478-2.459-4.469-0.219-4.384 0.837-4.358 0.96z"/>
+ <path fill="#0f0f0f" d="m270.726 247.418c0.047 2.043 4.531 2.595 8.762 3.324 4.232 0.754 7.242 1.504 8.039 5.649 0.725 4.052-0.608 6.728 1.299 6.509 3.621-0.586 5.041-4.389 4.937-6.436-0.06-2.127-2.971-5.496-8.367-7.53-3.988-1.502-6.842-2.278-10.346-2.464-4.328-0.22-4.359 0.784-4.324 0.948z"/>
+ <path fill="#131313" d="m270.852 247.458c0.059 1.978 4.495 2.469 8.667 3.219 4.19 0.781 7.071 1.502 8.014 5.531 0.854 3.932-0.526 6.465 1.354 6.283 3.445-0.535 4.824-4.119 4.712-6.137-0.074-2.119-2.92-5.359-8.245-7.361-3.941-1.482-6.753-2.271-10.213-2.469-4.186-0.221-4.333 0.726-4.289 0.934z"/>
+ <path fill="#161616" d="m270.978 247.495c0.07 1.914 4.458 2.344 8.57 3.115 4.151 0.807 6.903 1.5 7.99 5.413 0.98 3.812-0.443 6.202 1.409 6.056 3.271-0.482 4.609-3.848 4.488-5.836-0.088-2.111-2.871-5.222-8.123-7.193-3.894-1.461-6.663-2.264-10.081-2.471-4.043-0.223-4.306 0.67-4.253 0.916z"/>
+ <path fill="#1a1a1a" d="m271.104 247.534c0.082 1.848 4.422 2.219 8.476 3.01 4.111 0.832 6.734 1.498 7.965 5.295 1.108 3.69-0.36 5.94 1.464 5.83 3.097-0.433 4.394-3.578 4.265-5.537-0.104-2.104-2.821-5.084-8-7.023-3.848-1.441-6.575-2.26-9.949-2.477-3.905-0.223-4.283 0.613-4.221 0.902z"/>
+ <path fill="#1e1e1e" d="m271.23 247.573c0.094 1.781 4.385 2.092 8.381 2.904 4.07 0.859 6.563 1.495 7.939 5.178 1.236 3.568-0.278 5.678 1.52 5.602 2.921-0.381 4.177-3.305 4.04-5.237-0.118-2.097-2.771-4.946-7.878-6.854-3.8-1.42-6.485-2.252-9.817-2.479-3.762-0.226-4.256 0.556-4.185 0.886z"/>
+ <path fill="#222" d="m271.356 247.61c0.105 1.717 4.348 1.969 8.285 2.801 4.029 0.885 6.395 1.493 7.916 5.059 1.362 3.449-0.197 5.416 1.573 5.377 2.746-0.329 3.962-3.036 3.816-4.939-0.133-2.087-2.722-4.808-7.756-6.684-3.753-1.4-6.396-2.247-9.686-2.484-3.618-0.227-4.227 0.499-4.148 0.87z"/>
+ <path fill="#262626" d="m271.482 247.648c0.118 1.651 4.311 1.843 8.189 2.694 3.99 0.914 6.226 1.492 7.892 4.943 1.491 3.328-0.115 5.152 1.629 5.149 2.571-0.278 3.746-2.765 3.592-4.64-0.146-2.08-2.672-4.672-7.634-6.516-3.705-1.38-6.306-2.24-9.553-2.488-3.478-0.225-4.203 0.446-4.115 0.858z"/>
+ <path fill="#2a2a2a" d="m271.608 247.687c0.129 1.587 4.273 1.716 8.094 2.59 3.95 0.938 6.056 1.489 7.867 4.825 1.618 3.206-0.033 4.891 1.684 4.922 2.396-0.227 3.53-2.494 3.368-4.34-0.162-2.072-2.623-4.534-7.512-6.348-3.658-1.358-6.216-2.232-9.421-2.492-3.336-0.226-4.177 0.39-4.08 0.843z"/>
+ <path fill="#2d2d2d" d="m271.734 247.725c0.141 1.521 4.237 1.591 7.999 2.484 3.909 0.967 5.886 1.488 7.842 4.707 1.746 3.086 0.05 4.629 1.739 4.697 2.221-0.176 3.313-2.225 3.144-4.041-0.177-2.064-2.572-4.396-7.389-6.178-3.612-1.339-6.128-2.227-9.29-2.497-3.194-0.227-4.151 0.334-4.045 0.828z"/>
+ <path fill="#313131" d="m271.86 247.763c0.153 1.456 4.2 1.466 7.903 2.381 3.869 0.991 5.717 1.485 7.817 4.589 1.873 2.965 0.132 4.365 1.794 4.469 2.046-0.123 3.099-1.953 2.92-3.742-0.191-2.055-2.523-4.258-7.267-6.008-3.565-1.318-6.038-2.22-9.158-2.5-3.051-0.229-4.124 0.276-4.009 0.811z"/>
+ <path fill="#353535" d="m271.986 247.801c0.165 1.392 4.163 1.341 7.808 2.275 3.829 1.018 5.547 1.483 7.794 4.473 2 2.843 0.213 4.103 1.848 4.242 1.873-0.074 2.883-1.683 2.696-3.443-0.206-2.047-2.474-4.12-7.145-5.838-3.517-1.299-5.948-2.215-9.026-2.506-2.91-0.229-4.099 0.221-3.975 0.797z"/>
+ <path fill="#393939" d="m272.112 247.84c0.176 1.326 4.126 1.215 7.712 2.17 3.789 1.045 5.378 1.482 7.771 4.354 2.127 2.723 0.295 3.842 1.901 4.016 1.698-0.021 2.667-1.412 2.474-3.144-0.222-2.038-2.426-3.981-7.023-5.669-3.47-1.277-5.859-2.208-8.894-2.509-2.769-0.231-4.074 0.164-3.941 0.782z"/>
+ <path fill="#3d3d3d" d="m272.238 247.877c0.188 1.262 4.089 1.09 7.617 2.066 3.749 1.071 5.208 1.479 7.745 4.236 2.255 2.602 0.377 3.578 1.957 3.789 1.522 0.029 2.451-1.141 2.249-2.844-0.236-2.033-2.375-3.846-6.901-5.5-3.423-1.258-5.769-2.203-8.762-2.514-2.626-0.231-4.046 0.109-3.905 0.767z"/>
+ <path fill="#414141" d="m272.364 247.917c0.2 1.195 4.052 0.965 7.522 1.961 3.708 1.098 5.037 1.477 7.72 4.119 2.383 2.479 0.46 3.315 2.012 3.562 1.348 0.081 2.236-0.87 2.025-2.545-0.251-2.022-2.325-3.707-6.778-5.331-3.377-1.237-5.681-2.195-8.631-2.518-2.485-0.233-4.02 0.05-3.87 0.752z"/>
+ <path fill="#444" d="m272.49 247.956c0.212 1.129 4.015 0.838 7.426 1.855 3.668 1.124 4.869 1.475 7.696 4 2.51 2.359 0.542 3.055 2.067 3.336 1.173 0.133 2.02-0.6 1.801-2.246-0.266-2.016-2.276-3.568-6.656-5.16-3.329-1.218-5.591-2.189-8.499-2.521-2.343-0.235-3.994-0.007-3.835 0.736z"/>
+ <path fill="#484848" d="m272.616 247.993c0.223 1.065 3.979 0.715 7.331 1.752 3.628 1.15 4.699 1.473 7.671 3.883 2.638 2.238 0.624 2.791 2.122 3.108 0.998 0.185 1.804-0.329 1.577-1.946-0.28-2.008-2.227-3.432-6.534-4.992-3.282-1.196-5.501-2.182-8.367-2.525-2.201-0.235-3.968-0.064-3.8 0.72z"/>
+ <path fill="#4c4c4c" d="m272.742 248.032c0.235 1 3.941 0.588 7.235 1.645 3.588 1.178 4.529 1.472 7.646 3.766 2.766 2.118 0.706 2.529 2.177 2.883 0.823 0.235 1.589-0.059 1.354-1.648-0.295-1.998-2.177-3.293-6.412-4.822-3.235-1.176-5.412-2.176-8.235-2.529-2.059-0.239-3.942-0.119-3.765 0.705z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#4c4c4c" d="m287.565 252.854c1.646 1 1.353 2.059 2.412 2.766 0.528 0.352 1.412 0.352 0.882-1-0.706-1.588-1.294-2.472-4.941-3.943-2.353-0.941-1.882 0.059 1.647 2.177z"/>
+ <path fill="#505050" d="m287.609 252.868c1.605 0.975 1.32 2.008 2.353 2.696 0.517 0.343 1.377 0.343 0.86-0.976-0.689-1.549-1.262-2.41-4.82-3.846-2.295-0.917-1.836 0.059 1.607 2.126z"/>
+ <path fill="#545454" d="m287.652 252.879c1.567 0.951 1.287 1.957 2.294 2.629 0.503 0.334 1.343 0.334 0.839-0.951-0.671-1.51-1.23-2.35-4.699-3.749-2.238-0.893-1.79 0.058 1.566 2.071z"/>
+ <path fill="#575757" d="m287.696 252.891c1.526 0.926 1.254 1.908 2.235 2.563 0.489 0.325 1.308 0.325 0.816-0.928-0.653-1.471-1.199-2.289-4.578-3.652-2.179-0.872-1.743 0.055 1.527 2.017z"/>
+ <path fill="#5b5b5b" d="m287.74 252.903c1.485 0.902 1.22 1.857 2.175 2.494 0.479 0.318 1.274 0.318 0.796-0.902-0.637-1.432-1.167-2.229-4.457-3.556-2.122-0.849-1.697 0.054 1.486 1.964z"/>
+ <path fill="#5f5f5f" d="m287.783 252.915c1.446 0.879 1.188 1.808 2.117 2.426 0.464 0.31 1.239 0.31 0.773-0.877-0.619-1.394-1.136-2.168-4.336-3.459-2.064-0.826-1.65 0.052 1.446 1.91z"/>
+ <path fill="#636363" d="m287.827 252.926c1.405 0.854 1.154 1.758 2.057 2.359 0.452 0.301 1.205 0.301 0.754-0.853-0.603-1.354-1.104-2.108-4.216-3.363-2.007-0.801-1.605 0.053 1.405 1.857z"/>
+ <path fill="#676767" d="m287.871 252.94c1.364 0.828 1.121 1.705 1.998 2.29 0.438 0.292 1.17 0.292 0.731-0.828-0.585-1.315-1.072-2.047-4.095-3.267-1.948-0.779-1.558 0.05 1.366 1.805z"/>
+ <path fill="#6b6b6b" d="m287.914 252.952c1.325 0.805 1.088 1.655 1.94 2.223 0.425 0.283 1.135 0.283 0.709-0.803-0.568-1.277-1.041-1.988-3.974-3.17-1.891-0.757-1.512 0.047 1.325 1.75z"/>
+ <path fill="#6e6e6e" d="m287.958 252.963c1.284 0.779 1.056 1.605 1.88 2.156 0.413 0.274 1.102 0.274 0.688-0.779-0.551-1.238-1.009-1.926-3.853-3.073-1.833-0.733-1.466 0.046 1.285 1.696z"/>
+ <path fill="#727272" d="m288.002 252.976c1.243 0.755 1.021 1.554 1.821 2.087 0.399 0.266 1.066 0.266 0.666-0.755-0.533-1.199-0.977-1.865-3.731-2.976-1.776-0.71-1.421 0.045 1.244 1.644z"/>
+ <path fill="#767676" d="m288.045 252.989c1.203 0.73 0.989 1.504 1.763 2.02 0.387 0.257 1.031 0.257 0.645-0.731-0.516-1.159-0.946-1.805-3.61-2.879-1.72-0.688-1.376 0.042 1.202 1.59z"/>
+ <path fill="#7a7a7a" d="m288.089 253c1.163 0.705 0.955 1.453 1.703 1.951 0.373 0.25 0.997 0.25 0.623-0.705-0.499-1.121-0.914-1.744-3.489-2.783-1.661-0.664-1.329 0.041 1.163 1.537z"/>
+ <path fill="#7e7e7e" d="m288.133 253.012c1.122 0.682 0.923 1.404 1.644 1.885 0.361 0.24 0.962 0.24 0.602-0.682-0.481-1.082-0.882-1.684-3.367-2.687-1.605-0.64-1.285 0.041 1.121 1.484z"/>
+ <path fill="#828282" d="m288.176 253.025c1.082 0.657 0.89 1.353 1.586 1.815 0.348 0.232 0.927 0.232 0.578-0.656-0.463-1.043-0.85-1.623-3.245-2.59-1.547-0.618-1.237 0.039 1.081 1.431z"/>
+ <path fill="#858585" d="m288.22 253.038c1.042 0.631 0.855 1.301 1.524 1.748 0.335 0.223 0.894 0.223 0.559-0.633-0.446-1.005-0.818-1.563-3.125-2.492-1.488-0.596-1.19 0.037 1.042 1.377z"/>
+ <path fill="#898989" d="m288.264 253.049c1.001 0.607 0.821 1.252 1.466 1.681 0.322 0.214 0.857 0.214 0.536-0.608-0.43-0.965-0.786-1.502-3.004-2.396-1.43-0.573-1.144 0.034 1.002 1.323z"/>
+ <path fill="#8d8d8d" d="m288.307 253.061c0.961 0.584 0.79 1.201 1.407 1.613 0.309 0.205 0.823 0.205 0.515-0.584-0.412-0.926-0.755-1.441-2.883-2.299-1.373-0.548-1.098 0.034 0.961 1.27z"/>
+ <path fill="#919191" d="m288.351 253.073c0.921 0.559 0.756 1.151 1.348 1.547 0.296 0.196 0.789 0.196 0.493-0.56-0.395-0.888-0.723-1.381-2.762-2.204-1.315-0.525-1.052 0.033 0.921 1.217z"/>
+ <path fill="#959595" d="m288.395 253.084c0.88 0.535 0.723 1.102 1.289 1.479 0.282 0.189 0.754 0.189 0.471-0.534-0.377-0.849-0.691-1.321-2.641-2.106-1.257-0.505-1.006 0.031 0.881 1.161z"/>
+ <path fill="#999" d="m288.438 253.097c0.84 0.51 0.689 1.05 1.229 1.409 0.271 0.181 0.721 0.181 0.45-0.51-0.36-0.81-0.66-1.26-2.52-2.01-1.199-0.48-0.959 0.031 0.841 1.111z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path d="m222.275 107.427c-0.738 0.902 0.574 8.365 5.412 13.285 4.839 4.838 7.791 4.838 9.759 2.706 3.771-4.018 0.738-7.791-1.558-10.415-2.297-2.624-5.248-1.722-7.955-4.346-2.706-2.624-4.592-2.46-5.658-1.23z"/>
+ <path fill="#050505" d="m222.345 107.494c-0.732 0.895 0.569 8.3 5.369 13.182 4.803 4.801 7.731 4.801 9.685 2.685 3.742-3.987 0.731-7.73-1.546-10.334-2.278-2.604-5.208-1.709-7.894-4.312-2.684-2.604-4.556-2.441-5.614-1.221z"/>
+ <path fill="#0a0a0a" d="m222.416 107.561c-0.727 0.888 0.565 8.235 5.328 13.079 4.763 4.763 7.67 4.763 9.607 2.664 3.713-3.956 0.727-7.67-1.534-10.253-2.26-2.584-5.166-1.696-7.831-4.279-2.664-2.583-4.521-2.422-5.57-1.211z"/>
+ <path fill="#0f0f0f" d="m222.486 107.628c-0.721 0.881 0.561 8.17 5.286 12.976 4.726 4.725 7.608 4.725 9.532 2.642 3.684-3.924 0.72-7.609-1.522-10.172-2.243-2.563-5.126-1.682-7.77-4.244-2.643-2.563-4.485-2.403-5.526-1.202z"/>
+ <path fill="#141414" d="m222.556 107.695c-0.716 0.874 0.557 8.105 5.243 12.872 4.689 4.688 7.55 4.688 9.456 2.622 3.655-3.893 0.716-7.549-1.51-10.091-2.224-2.543-5.085-1.669-7.707-4.211-2.621-2.543-4.449-2.384-5.482-1.192z"/>
+ <path fill="#191919" d="m222.627 107.762c-0.71 0.867 0.552 8.04 5.202 12.769 4.65 4.65 7.488 4.65 9.379 2.601 3.626-3.862 0.71-7.489-1.497-10.011s-5.044-1.655-7.646-4.177-4.414-2.364-5.438-1.182z"/>
+ <path fill="#1e1e1e" d="m222.697 107.829c-0.704 0.86 0.547 7.975 5.16 12.666 4.613 4.612 7.428 4.612 9.304 2.579 3.596-3.83 0.703-7.427-1.486-9.929-2.188-2.502-5.003-1.642-7.584-4.143-2.579-2.502-4.378-2.346-5.394-1.173z"/>
+ <path fill="#232323" d="m222.767 107.896c-0.697 0.853 0.543 7.91 5.117 12.562 4.576 4.575 7.367 4.575 9.229 2.559 3.567-3.8 0.698-7.367-1.473-9.848-2.171-2.482-4.963-1.629-7.522-4.11s-4.343-2.326-5.351-1.163z"/>
+ <path fill="#282828" d="m222.838 107.963c-0.691 0.846 0.538 7.845 5.076 12.459 4.537 4.537 7.307 4.537 9.152 2.538 3.537-3.769 0.691-7.307-1.461-9.768-2.154-2.461-4.922-1.615-7.461-4.076-2.538-2.461-4.307-2.307-5.306-1.153z"/>
+ <path fill="#2d2d2d" d="m222.908 108.03c-0.686 0.839 0.534 7.78 5.034 12.355 4.5 4.499 7.246 4.499 9.076 2.516 3.509-3.737 0.686-7.246-1.449-9.686-2.135-2.441-4.881-1.602-7.399-4.042-2.516-2.44-4.271-2.287-5.262-1.143z"/>
+ <path fill="#333" d="m222.978 108.096c-0.681 0.832 0.529 7.715 4.992 12.253 4.463 4.462 7.186 4.462 9.001 2.496 3.479-3.706 0.68-7.186-1.438-9.605-2.118-2.42-4.841-1.588-7.337-4.008s-4.235-2.27-5.218-1.136z"/>
+ <path fill="#383838" d="m223.048 108.163c-0.674 0.825 0.526 7.65 4.95 12.15 4.425 4.425 7.125 4.425 8.925 2.475 3.45-3.675 0.676-7.125-1.425-9.525-2.099-2.4-4.8-1.575-7.274-3.975-2.475-2.399-4.201-2.25-5.176-1.125z"/>
+ <path fill="#3d3d3d" d="m223.119 108.23c-0.669 0.818 0.521 7.585 4.908 12.047 4.387 4.387 7.063 4.387 8.849 2.453 3.42-3.643 0.669-7.064-1.413-9.443-2.082-2.38-4.759-1.562-7.214-3.941-2.453-2.38-4.164-2.231-5.13-1.116z"/>
+ <path fill="#424242" d="m223.189 108.297c-0.663 0.811 0.516 7.52 4.866 11.944 4.35 4.349 7.004 4.349 8.772 2.432 3.392-3.612 0.663-7.004-1.4-9.363-2.064-2.359-4.719-1.548-7.151-3.907-2.433-2.359-4.129-2.212-5.087-1.106z"/>
+ <path fill="#474747" d="m223.259 108.364c-0.656 0.804 0.513 7.455 4.824 11.84 4.313 4.312 6.944 4.312 8.697 2.412 3.362-3.582 0.658-6.944-1.388-9.282-2.046-2.339-4.679-1.535-7.09-3.874-2.411-2.338-4.093-2.192-5.043-1.096z"/>
+ <path fill="#4c4c4c" d="m223.33 108.431c-0.651 0.797 0.507 7.39 4.782 11.737 4.274 4.274 6.882 4.274 8.621 2.39 3.332-3.55 0.651-6.883-1.377-9.201s-4.636-1.521-7.028-3.839c-2.39-2.318-4.057-2.174-4.998-1.087z"/>
+ <path fill="#515151" d="m223.4 108.498c-0.646 0.79 0.503 7.325 4.74 11.634 4.236 4.236 6.821 4.236 8.545 2.369 3.304-3.519 0.646-6.822-1.364-9.12s-4.596-1.508-6.966-3.806c-2.369-2.298-4.022-2.154-4.955-1.077z"/>
+ <path fill="#565656" d="m223.47 108.565c-0.641 0.783 0.499 7.26 4.697 11.53 4.199 4.199 6.763 4.199 8.471 2.349 3.273-3.488 0.64-6.762-1.353-9.039-1.993-2.278-4.556-1.495-6.905-3.773-2.347-2.277-3.985-2.135-4.91-1.067z"/>
+ <path fill="#5b5b5b" d="m223.541 108.632c-0.635 0.776 0.493 7.195 4.656 11.427 4.161 4.161 6.701 4.161 8.393 2.327 3.245-3.456 0.636-6.701-1.34-8.958-1.975-2.257-4.514-1.48-6.843-3.738-2.327-2.257-3.95-2.116-4.866-1.058z"/>
+ <path fill="#606060" d="m223.611 108.699c-0.629 0.769 0.489 7.13 4.614 11.324 4.124 4.123 6.64 4.123 8.317 2.306 3.215-3.425 0.628-6.641-1.328-8.877-1.957-2.237-4.474-1.468-6.78-3.705-2.306-2.236-3.915-2.097-4.823-1.048z"/>
+ <path fill="#666" d="m223.681 108.765c-0.623 0.762 0.484 7.065 4.571 11.221 4.086 4.086 6.58 4.086 8.242 2.285 3.187-3.394 0.623-6.58-1.315-8.796-1.939-2.217-4.434-1.455-6.72-3.671-2.284-2.216-3.878-2.078-4.778-1.039z"/>
+ </g>
+ <g transform="translate(-12.4048,10.0005)">
+ <path fill="#fc0" d="m137.79 109.277c1.978 1.366 2.031 1.607 4.948 3.514 4.64 3.768 12.885 4.616 16.922 4.75 9.233 1.467 25.738-7.161 32.273-11.111 3.291-2.463 9.38-7.551 11.659-7.637 1.405 1.485-0.66 1.792-3.587 3.775-3.906 2.779-7.25 5.156-13.172 8.515-6.338 3.316-16.078 8.794-28.548 8.054-6.542-0.959-6.566-1.024-10.606-3.086-2.4-1.732-7.901-4.608-9.889-6.774z"/>
+ <linearGradient id="al" x1="129.342" gradientUnits="userSpaceOnUse" x2="195.598" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.305" y2="259.305">
+ <stop stop-color="#FAC700" offset="0"/>
+ <stop stop-color="#F7C400" offset=".415"/>
+ <stop stop-color="#F7C400" offset="1"/>
+ </linearGradient>
+ <path fill="url(#al)" d="m137.742 109.259c1.926 1.274 2.165 1.643 5.083 3.554 4.616 3.734 12.716 4.616 16.796 4.763 9.365 1.452 26.05-7.294 32.356-11.159 3.357-2.506 9.344-7.498 11.595-7.604 1.365 1.472-0.728 1.768-3.688 3.814-3.889 2.753-7.119 5.065-12.972 8.383-6.29 3.291-16.078 8.795-28.536 8.104-6.561-0.945-6.851-1.07-10.758-3.079-2.468-1.755-7.876-4.587-9.876-6.776z"/>
+ <linearGradient id="am" x1="129.293" gradientUnits="userSpaceOnUse" x2="195.554" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.311" y2="259.311">
+ <stop stop-color="#F6C200" offset="0"/>
+ <stop stop-color="#EFBC00" offset=".415"/>
+ <stop stop-color="#EFBC00" offset="1"/>
+ </linearGradient>
+ <path fill="url(#am)" d="m137.693 109.24c1.876 1.183 2.3 1.68 5.218 3.595 4.593 3.7 12.548 4.616 16.67 4.776 9.498 1.437 26.364-7.428 32.44-11.207 3.425-2.55 9.308-7.444 11.528-7.57 1.326 1.457-0.795 1.743-3.788 3.854-3.87 2.725-6.99 4.973-12.771 8.25-6.243 3.266-16.078 8.796-28.525 8.154-6.579-0.931-7.134-1.117-10.908-3.073-2.536-1.779-7.852-4.567-9.864-6.779z"/>
+ <linearGradient id="an" x1="129.245" gradientUnits="userSpaceOnUse" x2="195.51" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.317" y2="259.317">
+ <stop stop-color="#F1BD00" offset="0"/>
+ <stop stop-color="#E8B500" offset=".415"/>
+ <stop stop-color="#E8B500" offset="1"/>
+ </linearGradient>
+ <path fill="url(#an)" d="m137.645 109.222c1.825 1.091 2.434 1.715 5.352 3.635 4.569 3.665 12.38 4.615 16.544 4.789 9.631 1.422 26.677-7.562 32.524-11.255 3.491-2.594 9.271-7.392 11.463-7.538 1.287 1.442-0.861 1.718-3.889 3.893-3.853 2.699-6.86 4.882-12.57 8.119-6.195 3.241-16.078 8.797-28.513 8.203-6.6-0.916-7.418-1.163-11.061-3.066-2.603-1.801-7.826-4.546-9.85-6.78z"/>
+ <linearGradient id="ao" x1="129.196" gradientUnits="userSpaceOnUse" x2="195.465" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.322" y2="259.322">
+ <stop stop-color="#EDB800" offset="0"/>
+ <stop stop-color="#E0AD00" offset=".415"/>
+ <stop stop-color="#E0AD00" offset="1"/>
+ </linearGradient>
+ <path fill="url(#ao)" d="m137.596 109.203c1.774 1 2.568 1.752 5.487 3.676 4.545 3.631 12.211 4.615 16.418 4.801 9.764 1.408 26.989-7.695 32.608-11.302 3.557-2.637 9.236-7.338 11.396-7.505 1.247 1.427-0.928 1.693-3.99 3.932-3.833 2.672-6.729 4.791-12.369 7.986-6.148 3.217-16.078 8.799-28.501 8.254-6.619-0.902-7.702-1.21-11.21-3.059-2.672-1.825-7.803-4.525-9.839-6.783z"/>
+ <linearGradient id="ap" x1="129.148" gradientUnits="userSpaceOnUse" x2="195.422" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.327" y2="259.327">
+ <stop stop-color="#E9B300" offset="0"/>
+ <stop stop-color="#D8A500" offset=".415"/>
+ <stop stop-color="#D8A500" offset="1"/>
+ </linearGradient>
+ <path fill="url(#ap)" d="m137.548 109.184c1.724 0.909 2.703 1.788 5.622 3.717 4.522 3.597 12.043 4.615 16.292 4.814 9.896 1.393 27.303-7.829 32.692-11.35 3.624-2.681 9.2-7.286 11.331-7.472 1.208 1.412-0.995 1.668-4.092 3.972-3.814 2.644-6.6 4.698-12.168 7.853-6.101 3.192-16.077 8.8-28.489 8.303-6.638-0.887-7.986-1.256-11.361-3.052-2.741-1.848-7.779-4.504-9.827-6.785z"/>
+ <linearGradient id="aq" x1="129.099" gradientUnits="userSpaceOnUse" x2="195.379" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.332" y2="259.332">
+ <stop stop-color="#E4AE00" offset="0"/>
+ <stop stop-color="#D19E00" offset=".415"/>
+ <stop stop-color="#D19E00" offset="1"/>
+ </linearGradient>
+ <path fill="url(#aq)" d="m137.499 109.166c1.673 0.817 2.838 1.824 5.757 3.757 4.499 3.562 11.875 4.614 16.166 4.827 10.029 1.378 27.615-7.963 32.776-11.398 3.691-2.725 9.164-7.232 11.265-7.439 1.169 1.397-1.061 1.644-4.191 4.01-3.796 2.618-6.469 4.608-11.968 7.722-6.053 3.167-16.077 8.801-28.478 8.353-6.657-0.873-8.27-1.303-11.512-3.046-2.809-1.87-7.755-4.483-9.815-6.786z"/>
+ <linearGradient id="ar" x1="129.051" gradientUnits="userSpaceOnUse" x2="195.338" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.336" y2="259.336">
+ <stop stop-color="#E0A900" offset="0"/>
+ <stop stop-color="#C99600" offset=".415"/>
+ <stop stop-color="#C99600" offset="1"/>
+ </linearGradient>
+ <path fill="url(#ar)" d="m137.451 109.147c1.622 0.726 2.972 1.86 5.892 3.798 4.475 3.528 11.705 4.614 16.04 4.84 10.161 1.362 27.929-8.097 32.859-11.447 3.757-2.767 9.128-7.178 11.2-7.405 1.13 1.382-1.128 1.619-4.294 4.049-3.777 2.592-6.339 4.517-11.767 7.589-6.005 3.143-16.077 8.803-28.466 8.404-6.676-0.859-8.553-1.35-11.663-3.039-2.876-1.894-7.729-4.462-9.801-6.789z"/>
+ <linearGradient id="w" x1="129.003" gradientUnits="userSpaceOnUse" x2="195.296" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.34" y2="259.34">
+ <stop stop-color="#DCA400" offset="0"/>
+ <stop stop-color="#C18E00" offset=".415"/>
+ <stop stop-color="#C18E00" offset="1"/>
+ </linearGradient>
+ <path fill="url(#w)" d="m137.403 109.129c1.571 0.634 3.105 1.896 6.026 3.838 4.45 3.494 11.536 4.614 15.913 4.852 10.295 1.348 28.241-8.23 32.943-11.494 3.824-2.811 9.092-7.125 11.134-7.373 1.092 1.367-1.194 1.594-4.394 4.088-3.759 2.565-6.209 4.425-11.566 7.457-5.957 3.118-16.077 8.804-28.454 8.453-6.694-0.844-8.837-1.396-11.813-3.032-2.945-1.916-7.706-4.44-9.789-6.789z"/>
+ <linearGradient id="x" x1="128.954" gradientUnits="userSpaceOnUse" x2="195.255" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.343" y2="259.343">
+ <stop stop-color="#D79F00" offset="0"/>
+ <stop stop-color="#BA8700" offset=".415"/>
+ <stop stop-color="#BA8700" offset="1"/>
+ </linearGradient>
+ <path fill="url(#x)" d="m137.354 109.11c1.521 0.543 3.241 1.932 6.161 3.879 4.428 3.459 11.368 4.613 15.788 4.865 10.427 1.333 28.554-8.364 33.026-11.542 3.892-2.855 9.057-7.073 11.068-7.339 1.052 1.353-1.261 1.569-4.495 4.127-3.74 2.538-6.078 4.334-11.365 7.325-5.91 3.093-16.077 8.805-28.441 8.503-6.716-0.83-9.121-1.443-11.966-3.026-3.012-1.939-7.68-4.42-9.776-6.792z"/>
+ <linearGradient id="y" x1="128.906" gradientUnits="userSpaceOnUse" x2="195.216" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.348" y2="259.348">
+ <stop stop-color="#D39B00" offset="0"/>
+ <stop stop-color="#B27F00" offset=".415"/>
+ <stop stop-color="#B27F00" offset="1"/>
+ </linearGradient>
+ <path fill="url(#y)" d="m137.306 109.091c1.47 0.451 3.375 1.969 6.296 3.919 4.403 3.426 11.2 4.614 15.662 4.879 10.559 1.318 28.865-8.498 33.109-11.59 3.958-2.899 9.021-7.02 11.003-7.306 1.013 1.337-1.328 1.544-4.596 4.167-3.722 2.511-5.949 4.242-11.165 7.192-5.862 3.068-16.077 8.807-28.43 8.553-6.734-0.816-9.405-1.489-12.116-3.019-3.08-1.963-7.656-4.4-9.763-6.795z"/>
+ <linearGradient id="z" x1="128.857" gradientUnits="userSpaceOnUse" x2="195.176" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.35" y2="259.35">
+ <stop stop-color="#CF9600" offset="0"/>
+ <stop stop-color="#a70" offset=".415"/>
+ <stop stop-color="#a70" offset="1"/>
+ </linearGradient>
+ <path fill="url(#z)" d="m137.257 109.073c1.421 0.359 3.511 2.005 6.432 3.959 4.38 3.392 11.032 4.614 15.536 4.892 10.691 1.303 29.179-8.631 33.193-11.638 4.024-2.942 8.984-6.967 10.938-7.274 0.973 1.323-1.396 1.521-4.697 4.206-3.703 2.484-5.818 4.151-10.964 7.06-5.814 3.043-16.077 8.808-28.418 8.603-6.753-0.802-9.689-1.535-12.268-3.012-3.149-1.986-7.632-4.378-9.752-6.796z"/>
+ <linearGradient id="aa" x1="128.809" gradientUnits="userSpaceOnUse" x2="195.137" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.354" y2="259.354">
+ <stop stop-color="#CA9100" offset="0"/>
+ <stop stop-color="#A37000" offset=".415"/>
+ <stop stop-color="#A37000" offset="1"/>
+ </linearGradient>
+ <path fill="url(#aa)" d="m137.209 109.054c1.368 0.268 3.645 2.041 6.565 4 4.356 3.357 10.864 4.613 15.41 4.904 10.824 1.289 29.493-8.764 33.277-11.685 4.092-2.986 8.948-6.914 10.871-7.241 0.935 1.308-1.461 1.496-4.797 4.245-3.685 2.457-5.688 4.06-10.763 6.928-5.768 3.018-16.077 8.809-28.407 8.653-6.771-0.788-9.972-1.582-12.418-3.006-3.216-2.008-7.607-4.357-9.738-6.798z"/>
+ <linearGradient id="ab" x1="128.76" gradientUnits="userSpaceOnUse" x2="195.099" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.356" y2="259.356">
+ <stop stop-color="#C68C00" offset="0"/>
+ <stop stop-color="#9B6800" offset=".415"/>
+ <stop stop-color="#9B6800" offset="1"/>
+ </linearGradient>
+ <path fill="url(#ab)" d="m137.16 109.036c1.318 0.176 3.779 2.077 6.701 4.04 4.333 3.323 10.695 4.613 15.284 4.917 10.957 1.274 29.805-8.898 33.36-11.733 4.158-3.03 8.912-6.86 10.807-7.208 0.894 1.292-1.528 1.471-4.899 4.284-3.666 2.43-5.558 3.968-10.562 6.796-5.72 2.993-16.077 8.81-28.396 8.702-6.791-0.773-10.256-1.628-12.568-2.999-3.285-2.031-7.583-4.336-9.727-6.799z"/>
+ <linearGradient id="ac" x1="128.712" gradientUnits="userSpaceOnUse" x2="195.062" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.358" y2="259.358">
+ <stop stop-color="#C28700" offset="0"/>
+ <stop stop-color="#936000" offset=".415"/>
+ <stop stop-color="#936000" offset="1"/>
+ </linearGradient>
+ <path fill="url(#ac)" d="m137.112 109.017c1.267 0.085 3.912 2.113 6.835 4.081 4.31 3.289 10.527 4.613 15.158 4.93 11.09 1.258 30.118-9.032 33.445-11.782 4.225-3.072 8.877-6.807 10.739-7.174 0.855 1.278-1.595 1.446-5 4.323-3.647 2.404-5.427 3.877-10.36 6.663-5.673 2.969-16.077 8.812-28.384 8.753-6.811-0.759-10.54-1.675-12.719-2.992-3.353-2.055-7.559-4.315-9.714-6.802z"/>
+ <linearGradient id="ad" x1="128.663" gradientUnits="userSpaceOnUse" x2="195.025" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.36" y2="259.36">
+ <stop stop-color="#BD8200" offset="0"/>
+ <stop stop-color="#8C5900" offset=".415"/>
+ <stop stop-color="#8C5900" offset="1"/>
+ </linearGradient>
+ <path fill="url(#ad)" d="m137.063 108.998c1.217-0.006 4.047 2.15 6.97 4.122 4.286 3.254 10.359 4.612 15.032 4.943 11.223 1.243 30.431-9.166 33.53-11.83 4.289-3.116 8.84-6.753 10.673-7.141 0.815 1.263-1.661 1.421-5.101 4.362-3.63 2.377-5.298 3.785-10.161 6.531-5.624 2.944-16.075 8.813-28.37 8.802-6.83-0.744-10.824-1.721-12.87-2.985-3.422-2.077-7.535-4.294-9.703-6.804z"/>
+ <linearGradient id="ae" x1="128.615" gradientUnits="userSpaceOnUse" x2="194.989" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.362" y2="259.362">
+ <stop stop-color="#B97D00" offset="0"/>
+ <stop stop-color="#845100" offset=".415"/>
+ <stop stop-color="#845100" offset="1"/>
+ </linearGradient>
+ <path fill="url(#ae)" d="m137.015 108.98c1.166-0.098 4.181 2.185 7.104 4.162 4.262 3.22 10.19 4.612 14.906 4.955 11.354 1.229 30.743-9.299 33.613-11.877 4.356-3.16 8.804-6.7 10.607-7.108 0.776 1.248-1.728 1.397-5.202 4.401-3.61 2.35-5.167 3.694-9.96 6.399-5.576 2.919-16.076 8.814-28.358 8.852-6.85-0.73-11.108-1.768-13.021-2.979-3.489-2.1-7.51-4.273-9.689-6.805z"/>
+ <linearGradient id="af" x1="128.567" gradientUnits="userSpaceOnUse" x2="194.954" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.364" y2="259.364">
+ <stop stop-color="#B57800" offset="0"/>
+ <stop stop-color="#7C4900" offset=".415"/>
+ <stop stop-color="#7C4900" offset="1"/>
+ </linearGradient>
+ <path fill="url(#af)" d="m136.967 108.961c1.115-0.189 4.315 2.222 7.239 4.203 4.239 3.186 10.021 4.612 14.78 4.968 11.488 1.214 31.057-9.433 33.697-11.925 4.424-3.203 8.768-6.647 10.542-7.075 0.736 1.233-1.795 1.372-5.303 4.44-3.593 2.323-5.038 3.603-9.759 6.266-5.529 2.895-16.077 8.816-28.348 8.903-6.868-0.716-11.392-1.815-13.172-2.972-3.557-2.124-7.485-4.252-9.676-6.808z"/>
+ <linearGradient id="ah" x1="128.518" gradientUnits="userSpaceOnUse" x2="194.918" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.366" y2="259.366">
+ <stop stop-color="#B07300" offset="0"/>
+ <stop stop-color="#754200" offset=".415"/>
+ <stop stop-color="#754200" offset="1"/>
+ </linearGradient>
+ <path fill="url(#ah)" d="m136.918 108.943c1.064-0.281 4.45 2.257 7.374 4.243 4.216 3.151 9.854 4.611 14.654 4.981 11.621 1.199 31.37-9.567 33.781-11.973 4.49-3.247 8.731-6.594 10.476-7.042 0.698 1.218-1.861 1.347-5.403 4.479-3.573 2.296-4.906 3.511-9.558 6.134-5.48 2.87-16.076 8.817-28.336 8.952-6.887-0.701-11.675-1.861-13.323-2.965-3.626-2.146-7.462-4.231-9.665-6.809z"/>
+ <linearGradient id="ai" x1="128.47" gradientUnits="userSpaceOnUse" x2="194.886" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.367" y2="259.367">
+ <stop stop-color="#AC6E00" offset="0"/>
+ <stop stop-color="#6D3A00" offset=".415"/>
+ <stop stop-color="#6D3A00" offset="1"/>
+ </linearGradient>
+ <path fill="url(#ai)" d="m136.87 108.924c1.013-0.372 4.584 2.294 7.509 4.284 4.191 3.117 9.685 4.611 14.528 4.994 11.753 1.184 31.682-9.701 33.864-12.021 4.557-3.291 8.695-6.542 10.411-7.009 0.657 1.203-1.929 1.322-5.504 4.518-3.557 2.269-4.777 3.42-9.358 6.002-5.434 2.845-16.076 8.818-28.324 9.002-6.907-0.687-11.959-1.908-13.474-2.959-3.694-2.169-7.437-4.21-9.652-6.811z"/>
+ <linearGradient id="aj" x1="128.421" gradientUnits="userSpaceOnUse" x2="194.85" gradientTransform="matrix(1,0,0,-1,8.3999,368.3)" y1="259.369" y2="259.369">
+ <stop stop-color="#A86A00" offset="0"/>
+ <stop stop-color="#663200" offset=".415"/>
+ <stop stop-color="#663200" offset="1"/>
+ </linearGradient>
+ <path fill="url(#aj)" d="m136.821 108.905c0.963-0.464 4.719 2.33 7.644 4.324 4.168 3.083 9.517 4.611 14.402 5.007 11.886 1.169 31.995-9.834 33.948-12.069 4.624-3.334 8.66-6.487 10.345-6.976 0.619 1.188-1.995 1.298-5.604 4.558-3.537 2.242-4.647 3.328-9.157 5.869-5.386 2.82-16.076 8.82-28.313 9.052-6.926-0.673-12.243-1.954-13.625-2.952-3.762-2.192-7.413-4.189-9.64-6.813z"/>
+ </g>
+ </g>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/media/US_states_by_total_state_tax_revenue.svg b/www/wiki/tests/phpunit/data/media/US_states_by_total_state_tax_revenue.svg
new file mode 100644
index 00000000..9afea859
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/US_states_by_total_state_tax_revenue.svg
@@ -0,0 +1,248 @@
+<ns0:svg height="592.78998" id="svg2275" version="1.0" width="958.69" ns1:docbase="C:\Users\Adam\Desktop" ns1:docname="Blank_US_Map_with_borders.svg" ns1:version="0.32" ns2:output_extension="org.inkscape.output.svg.inkscape" ns2:version="0.46" xmlns:ns0="http://www.w3.org/2000/svg" xmlns:ns1="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:ns2="http://www.inkscape.org/namespaces/inkscape">
+ <ns0:metadata id="metadata2625">
+ <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+ <ns4:Work rdf:about="" xmlns:ns4="http://creativecommons.org/ns#">
+ <ns5:format xmlns:ns5="http://purl.org/dc/elements/1.1/">image/svg+xml</ns5:format>
+ <ns5:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" xmlns:ns5="http://purl.org/dc/elements/1.1/" />
+ </ns4:Work>
+ </rdf:RDF>
+ </ns0:metadata>
+ <ns0:defs id="defs2623">
+ <ns2:perspective id="perspective226" ns1:type="inkscape:persp3d" ns2:persp3d-origin="479.345 : 197.59666 : 1" ns2:vp_x="0 : 296.39499 : 1" ns2:vp_y="0 : 1000 : 0" ns2:vp_z="958.69 : 296.39499 : 1" />
+ </ns0:defs>
+ <ns1:namedview bordercolor="#666666" borderopacity="1.0" gridtolerance="10.0" guidetolerance="10.0" id="base" objecttolerance="10.0" pagecolor="#ffffff" showgrid="false" ns2:current-layer="svg2275" ns2:cx="479.345" ns2:cy="299.1307" ns2:pageopacity="0.0" ns2:pageshadow="2" ns2:window-height="820" ns2:window-width="1440" ns2:window-x="-8" ns2:window-y="-8" ns2:zoom="0.99941554" />
+ <ns0:path d="M 798.49579,591.98217 L 799.73403,593.07468 L 802.54072,590.88965 L 807.98899,586.51962 L 811.78627,582.48573 L 814.3453,575.59451 L 815.3359,573.82969 L 815.501,570.30004 L 814.75805,570.80427 L 813.76746,573.74564 L 812.28156,578.53588 L 808.97958,583.99844 L 804.52191,588.36847 L 801.05483,590.38542 L 798.49579,591.98217 z M 784.71002,597.19259 L 787.18651,596.52028 L 788.5073,596.26817 L 789.99319,593.83102 L 792.38713,592.15024 L 793.70792,592.65448 L 795.44146,592.99063 L 795.8542,594.08315 L 792.30458,595.34374 L 788.012,596.85644 L 785.61807,598.11703 L 784.71002,597.19259 z M 657.3254,482.07418 L 660.96585,481.47149 L 667.07449,479.28647 L 673.18314,478.78224 L 677.6408,478.10993 L 685.40042,479.95879 L 693.65535,483.99266 L 695.30633,485.50536 L 698.2781,486.6819 L 699.92909,488.69884 L 700.25929,491.55616 L 703.56126,490.21154 L 707.52362,490.21154 L 711.15578,488.1946 L 714.95305,484.49689 L 718.08992,484.66497 L 718.58522,483.48842 L 717.75972,482.47995 L 717.92482,480.46302 L 722.05228,479.62263 L 724.69386,479.62263 L 727.66563,481.13533 L 731.95819,482.64803 L 734.43467,486.51382 L 737.24134,487.52229 L 738.39703,491.05193 L 741.8641,492.73271 L 743.51508,495.42195 L 745.49627,496.09427 L 750.77942,497.43889 L 752.1002,500.63237 L 755.23708,504.49816 L 755.23708,514.41476 L 753.75119,519.28902 L 754.08139,522.14634 L 755.40217,527.18868 L 757.21826,531.39063 L 758.04375,530.8864 L 759.52964,526.18021 L 756.88806,525.17175 L 756.55786,524.49943 L 758.20885,523.82712 L 762.83161,524.83559 L 762.9967,526.51637 L 759.69473,532.23102 L 757.54845,534.75219 L 761.18062,538.61798 L 763.8222,541.81146 L 766.79397,547.35803 L 769.76574,551.3919 L 771.91202,556.60232 L 773.7281,556.93847 L 775.37909,554.75346 L 777.19517,555.93001 L 779.83675,560.13195 L 780.49714,563.82967 L 783.63401,568.36777 L 784.4595,567.02315 L 788.42187,567.3593 L 792.05403,569.7124 L 795.5211,575.09089 L 796.34659,578.62053 L 796.67679,581.64593 L 797.83248,582.6544 L 799.15327,583.15863 L 801.62975,582.15016 L 803.11563,580.46938 L 807.078,580.3013 L 810.21487,578.7886 L 813.02154,575.42704 L 812.52624,573.41011 L 812.19605,570.88894 L 812.85644,568.87201 L 812.52624,566.85507 L 815.00272,565.51045 L 815.33292,561.98081 L 814.67252,560.13195 L 814.17723,547.69419 L 812.85644,539.79453 L 808.23368,531.22255 L 804.60152,525.17175 L 801.95994,519.62517 L 798.98817,516.59977 L 796.0164,508.86819 L 796.84189,507.52356 L 797.99758,506.17894 L 796.34659,503.15354 L 792.21913,499.28775 L 787.26618,493.5731 L 783.46891,487.01806 L 778.02066,477.26954 L 774.21165,467.14054 L 771.56179,458.12552" id="FL_Gulf" style="fill:#cccccc;fill-opacity:0;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 772.07835,458.61245 L 774.21165,467.14054 L 778.02066,477.26954 L 783.46891,487.01806 L 787.26618,493.5731 L 792.21913,499.28775 L 796.34659,503.15354 L 797.99758,506.17894 L 796.84189,507.52356 L 796.0164,508.86819 L 798.98817,516.59977 L 801.95994,519.62517 L 804.60152,525.17175 L 808.23368,531.22255 L 812.85644,539.79453 L 814.17723,547.69419 L 814.67252,560.13195 L 815.33292,561.98081 L 815.00272,565.51045 L 812.52624,566.85507 L 812.85644,568.87201 L 812.19605,570.88894 L 812.52624,573.41011 L 813.02154,575.42704 L 810.21487,578.7886 L 807.078,580.3013 L 803.11563,580.46938 L 801.62975,582.15016 L 799.15327,583.15863 L 797.83248,582.6544 L 796.67679,581.64593 L 796.34659,578.62053 L 795.5211,575.09089 L 792.05403,569.7124 L 788.42187,567.3593 L 784.4595,567.02315 L 783.63401,568.36777 L 780.49714,563.82967 L 779.83675,560.13195 L 777.19517,555.93001 L 775.37909,554.75346 L 773.7281,556.93847 L 771.91202,556.60232 L 769.76574,551.3919 L 766.79397,547.35803 L 763.8222,541.81146 L 761.18062,538.61798 L 757.54845,534.75219 L 759.69473,532.23102 L 762.9967,526.51637 L 762.83161,524.83559 L 758.20885,523.82712 L 756.55786,524.49943 L 756.88806,525.17175 L 759.52964,526.18021 L 758.04375,530.8864 L 757.21826,531.39063 L 755.40217,527.18868 L 754.08139,522.14634 L 753.75119,519.28902 L 755.23708,514.41476 L 755.23708,504.49816 L 752.1002,500.63237 L 750.77942,497.43889 L 745.49627,496.09427 L 743.51508,495.42195 L 741.8641,492.73271 L 738.39703,491.05193 L 737.24134,487.52229 L 734.43467,486.51382 L 731.95819,482.64803 L 727.66563,481.13533 L 724.69386,479.62263 L 722.05228,479.62263 L 717.92482,480.46302 L 717.75972,482.47995 L 718.58522,483.48842 L 718.08992,484.66497 L 714.95305,484.49689 L 711.15578,488.1946 L 707.52362,490.21154 L 703.56126,490.21154 L 700.25929,491.55616 L 699.92909,488.69884 L 698.2781,486.6819 L 695.30633,485.50536 L 693.65535,483.99266 L 685.40042,479.95879 L 677.6408,478.10993 L 673.18314,478.78224 L 667.07449,479.28647 L 660.96585,481.47149 L 657.41261,482.10878 L 657.16963,473.73948 L 654.52806,471.72255 L 652.71197,469.87369 L 653.04217,466.6802 L 663.44338,465.33558 L 689.52898,462.31017 L 696.46313,461.63786 L 702.73688,461.46977 L 705.37846,465.50365 L 706.86434,467.01635 L 714.95419,467.18443 L 726.00404,466.51212 L 747.97393,465.1675 L 753.53546,464.46636 L 758.21005,464.49519 L 758.37515,467.52059 L 761.01673,468.36098 L 761.34692,463.82287 L 759.69594,459.11668 L 760.85163,457.4359 L 766.79518,458.27629 L 772.07835,458.61245 z M 784.71002,597.19259 L 787.18651,596.52028 L 788.5073,596.26817 L 789.99319,593.83102 L 792.38713,592.15024 L 793.70792,592.65448 L 795.44146,592.99063 L 795.8542,594.08315 L 792.30458,595.34374 L 788.012,596.85644 L 785.61807,598.11703 L 784.71002,597.19259 z M 798.49579,591.98217 L 799.73403,593.07468 L 802.54072,590.88965 L 807.98899,586.51962 L 811.78627,582.48573 L 814.3453,575.59451 L 815.3359,573.82969 L 815.501,570.30004 L 814.75805,570.80427 L 813.76746,573.74564 L 812.28156,578.53588 L 808.97958,583.99844 L 804.52191,588.36847 L 801.05483,590.38542 L 798.49579,591.98217 z " id="FL" style="fill:#501616" />
+ <ns0:path d="M 777.85557,425.66962 L 776.04071,426.6776 L 773.39913,425.33297 L 772.73874,423.14795 L 771.41795,419.45024 L 769.10656,417.26521 L 766.46499,416.5929 L 764.814,411.55057 L 762.00732,405.33167 L 757.71476,403.31473 L 755.56847,401.29779 L 754.24768,398.60854 L 752.1014,396.5916 L 749.79002,395.24697 L 747.47863,392.22157 L 744.34176,389.86848 L 739.71899,388.01961 L 739.2237,386.50691 L 736.74722,383.48151 L 736.25192,381.9688 L 732.78485,376.5903 L 729.31778,376.75838 L 725.19031,374.2372 L 723.86952,372.89258 L 723.53932,371.04372 L 724.36481,369.02679 L 726.67619,368.01831 L 726.346,365.8333 L 732.61975,363.14405 L 741.86527,358.43786 L 749.29471,357.59747 L 766.13479,357.09323 L 768.44617,359.11017 L 770.09715,362.47174 L 774.55482,361.9675 L 787.43252,360.45479 L 790.4043,361.29519 L 803.282,369.19486 L 813.60504,377.63896 L 808.06859,383.31398 L 805.42701,389.70094 L 804.93171,396.25598 L 803.28073,397.09637 L 802.12504,399.95369 L 799.64856,400.62601 L 797.50228,404.32372 L 794.69561,407.18104 L 792.38423,410.71068 L 790.73325,411.55107 L 787.10108,415.08071 L 784.12931,415.24879 L 785.1199,418.61034 L 780.00185,424.32499 L 777.85557,425.66962 z " id="SC" style="fill:#e9afaf" />
+ <ns0:path d="M 704.71806,368.52255 L 699.7651,369.36294 L 691.17997,370.53949 L 682.42974,371.46391 L 682.42974,373.73297 L 682.59484,375.91799 L 683.25523,379.44763 L 686.72231,387.68346 L 689.19879,397.93623 L 690.68467,404.3232 L 692.33566,409.36554 L 693.82155,416.5929 L 695.96784,423.14795 L 698.60941,426.6776 L 699.10471,430.20724 L 701.0859,431.04763 L 701.251,433.23265 L 699.4349,438.27499 L 698.93961,441.63656 L 698.77451,443.65349 L 700.4255,448.19161 L 700.7557,453.73818 L 699.9302,456.25936 L 700.5906,457.09975 L 702.07649,457.94014 L 702.73688,461.46977 L 705.37846,465.50365 L 706.86434,467.01635 L 714.95419,467.18443 L 726.00404,466.51212 L 747.97393,465.1675 L 753.53546,464.46636 L 758.21005,464.49519 L 758.37515,467.52059 L 761.01673,468.36098 L 761.34692,463.82287 L 759.69594,459.11668 L 760.85163,457.4359 L 766.79518,458.27629 L 771.87844,458.60669 L 771.08653,452.05785 L 773.39791,441.63702 L 774.88379,437.26699 L 774.3885,434.57775 L 778.51596,427.3504 L 777.90454,425.66937 L 776.04071,426.6776 L 773.39913,425.33297 L 772.73874,423.14795 L 771.41795,419.45024 L 769.10656,417.26521 L 766.46499,416.5929 L 764.814,411.55057 L 762.00732,405.33167 L 757.71476,403.31473 L 755.56847,401.29779 L 754.24768,398.60854 L 752.1014,396.5916 L 749.79002,395.24697 L 747.47863,392.22157 L 744.34176,389.86848 L 739.71899,388.01961 L 739.2237,386.50691 L 736.74722,383.48151 L 736.25192,381.9688 L 732.78485,376.5903 L 729.31778,376.75838 L 725.19031,374.2372 L 723.86952,372.89258 L 723.53932,371.04372 L 724.36481,369.02679 L 726.67619,368.01831 L 726.51109,365.64481 L 724.69501,366.16945 L 718.75146,367.17792 L 711.65221,368.01831 L 704.71806,368.52255 z " id="GA" style="fill:#d35f5f" />
+ <ns0:path d="M 639.33795,481.63956 L 637.68799,465.83981 L 634.88131,446.34274 L 635.04641,431.71994 L 635.8719,399.44893 L 635.7068,382.13688 L 635.87539,375.46299 L 643.79664,375.07759 L 672.19362,372.38834 L 682.58068,371.46391 L 682.42974,373.73297 L 682.59484,375.91799 L 683.25523,379.44763 L 686.72231,387.68346 L 689.19879,397.93623 L 690.68467,404.3232 L 692.33566,409.36554 L 693.82155,416.5929 L 695.96784,423.14795 L 698.60941,426.6776 L 699.10471,430.20724 L 701.0859,431.04763 L 701.251,433.23265 L 699.4349,438.27499 L 698.93961,441.63656 L 698.77451,443.65349 L 700.4255,448.19161 L 700.7557,453.73818 L 699.9302,456.25936 L 700.5906,457.09975 L 702.07649,457.94014 L 702.90198,461.63786 L 696.46313,461.63786 L 689.52898,462.31017 L 663.44338,465.33558 L 653.04217,466.6802 L 652.71197,469.87369 L 654.52806,471.72255 L 657.16963,473.73948 L 657.76284,481.98993 L 651.05994,484.66497 L 648.25327,484.32881 L 651.05994,482.31188 L 651.05994,481.30341 L 647.92307,475.08453 L 645.61169,474.41221 L 644.12581,478.95032 L 642.80502,481.80764 L 642.14462,481.63956 L 639.33795,481.63956 z " id="AL" style="fill:#e9afaf" />
+ <ns0:path d="M 850.23842,306.65958 L 851.98478,311.54471 L 855.61694,318.26782 L 858.09342,320.78899 L 858.75382,323.14208 L 856.27734,323.31016 L 857.10283,323.98247 L 856.77263,328.3525 L 854.13106,329.69712 L 853.47066,331.88214 L 852.14988,334.90754 L 848.35261,336.58832 L 845.87614,336.25216 L 844.39025,336.08408 L 842.73926,334.73946 L 843.06946,336.08408 L 843.06946,337.09255 L 845.05064,337.09255 L 845.87614,338.43717 L 843.89495,344.99221 L 848.18751,344.99221 L 848.84791,346.67299 L 851.15929,344.3199 L 852.48007,343.81567 L 850.49889,347.51338 L 847.36202,352.55572 L 846.04123,352.55572 L 844.88554,352.05149 L 842.07887,352.7238 L 836.79572,355.24497 L 830.19178,360.79154 L 826.72471,365.6658 L 824.74353,372.38892 L 824.24824,374.91008 L 819.46038,375.41432 L 813.43993,377.723 L 803.282,369.19486 L 790.4043,361.29519 L 787.43252,360.45479 L 774.55482,361.9675 L 770.09715,362.47174 L 768.44617,359.11017 L 766.13479,357.09323 L 749.29471,357.59747 L 741.86527,358.43786 L 732.61975,363.14405 L 726.346,365.8333 L 724.69501,366.16945 L 718.75146,367.17792 L 711.65221,368.01831 L 704.71806,368.52255 L 705.04826,363.4802 L 706.86434,361.9675 L 709.67103,361.29519 L 710.33142,357.42939 L 714.62399,354.57206 L 718.58636,353.05935 L 722.87893,349.36164 L 727.33659,347.17662 L 727.99698,343.98313 L 731.95935,339.94926 L 732.61975,339.78119 C 732.61975,339.78119 732.61975,340.95773 733.44524,340.95773 C 734.27073,340.95773 735.42643,341.29389 735.42643,341.29389 L 737.73781,337.59616 L 739.88409,336.92385 L 742.19547,337.26001 L 743.84646,333.56229 L 746.81824,330.87303 L 747.31353,328.68802 L 747.31353,324.57011 L 751.9363,325.32646 L 759.22415,323.98183 L 775.38031,321.96489 L 792.88078,319.27565 L 813.92151,315.35219 L 833.49506,311.37597 L 845.21707,308.35056 L 850.23842,306.65958 z M 854.21672,340.95692 L 856.85831,338.3517 L 860.07773,335.66244 L 861.64617,334.99013 L 861.81127,332.88915 L 861.15088,326.50217 L 859.66499,324.06503 L 859.00459,322.13213 L 859.74753,321.88001 L 862.55422,327.59468 L 862.96697,332.21684 L 862.80187,335.74649 L 859.33479,337.34323 L 856.44555,339.86441 L 855.28987,341.125 L 854.21672,340.95692 z " id="NC" style="fill:#c83737" />
+ <ns0:path d="M 712.3126,329.69649 L 659.31592,334.90691 L 643.2212,336.75577 L 638.50172,337.28883 L 634.55111,337.26001 L 634.55111,341.29389 L 625.96598,341.79812 L 618.86673,342.47043 L 607.53473,342.52544 L 607.26436,348.59252 L 605.08072,355.11718 L 604.06449,358.25292 L 602.68706,362.80789 L 602.35687,365.49714 L 598.22939,367.85023 L 599.71528,371.54796 L 598.72469,376.08606 L 597.15628,377.85089 L 605.49374,377.76685 L 630.09345,375.74991 L 635.54175,375.58184 L 643.79664,375.07759 L 672.19362,372.38834 L 682.58068,371.54796 L 691.17997,370.53949 L 699.7651,369.36294 L 704.71806,368.52255 L 705.04826,363.4802 L 706.86434,361.9675 L 709.67103,361.29519 L 710.33142,357.42939 L 714.62399,354.57206 L 718.58636,353.05935 L 722.87893,349.36164 L 727.33659,347.17662 L 727.99698,343.98313 L 731.95935,339.94926 L 732.61975,339.78119 C 732.61975,339.78119 732.61975,340.95773 733.44524,340.95773 C 734.27073,340.95773 735.42643,341.29389 735.42643,341.29389 L 737.73781,337.59616 L 739.88409,336.92385 L 742.19547,337.26001 L 743.84646,333.56229 L 746.81824,330.87303 L 747.31353,328.68802 L 747.49366,324.59981 L 745.16725,324.65414 L 742.69078,326.67109 L 734.60093,326.83916 L 722.3505,328.8153 L 712.3126,329.69649 z " id="TN" style="fill:#de8787" />
+ <ns0:path d="M 893.09433,183.30123 L 892.6011,178.92994 L 891.77561,174.39182 L 890.04208,168.25697 L 895.90308,166.66023 L 897.55407,167.83677 L 901.02115,172.37489 L 903.99187,176.99768 L 901.01902,178.59507 L 899.69824,178.42699 L 898.54255,180.27585 L 896.06607,182.29279 L 893.09433,183.30123 z " id="RI" style="fill:#f4d7d7" />
+ <ns0:path d="M 893.58963,183.30123 L 892.6011,178.92994 L 891.77561,174.39182 L 890.12463,168.17293 L 884.84146,169.34947 L 862.55312,174.30778 L 863.21351,177.75339 L 864.6994,185.31692 L 864.6994,193.72083 L 863.54371,196.07393 L 865.41508,198.26677 L 870.47581,194.73055 L 874.10797,191.36899 L 876.08916,189.18398 L 876.91465,189.85629 L 879.72132,188.34359 L 885.00447,187.16705 L 893.58963,183.30123 z " id="CT" style="fill:#de8787" />
+ <ns0:path d="M 919.55232,177.09192 L 921.77043,176.37882 L 922.23741,174.59609 L 923.28809,174.71493 L 924.33877,177.09192 L 923.0546,177.56732 L 919.08535,177.68617 L 919.55232,177.09192 z M 909.97943,177.92387 L 912.31427,175.19033 L 913.94868,175.19033 L 915.81656,176.73537 L 913.36497,177.80501 L 911.14686,178.87466 L 909.97943,177.92387 z M 874.44023,155.06282 L 892.27091,150.69278 L 894.5823,150.02047 L 896.72858,146.65891 L 900.54482,144.92957 L 903.4955,149.51759 L 901.01902,154.89608 L 900.68883,156.40879 L 902.67001,159.09803 L 903.8257,158.25764 L 905.64178,158.25764 L 907.95316,160.94689 L 911.91552,167.16577 L 915.54769,167.67001 L 917.85907,166.66154 L 919.67515,164.81268 L 918.84966,161.95536 L 916.70338,160.27458 L 915.21749,161.11497 L 914.2269,159.77034 L 914.7222,159.26611 L 916.86848,159.09803 L 918.68456,159.93842 L 920.66574,162.45959 L 921.65633,165.48499 L 921.98653,168.00616 L 917.69397,169.51886 L 913.73161,171.5358 L 909.76924,176.24198 L 907.78806,177.75468 L 907.78806,176.74621 L 910.26454,175.23351 L 910.75983,173.38466 L 909.93434,170.19118 L 906.96257,171.70388 L 906.13708,173.21658 L 906.63237,175.56967 L 903.82678,177.08172 L 901.02115,172.37489 L 897.55407,167.83677 L 895.90308,166.66023 L 890.04208,168.25697 L 884.84146,169.34947 L 862.55312,174.30778 L 861.56253,168.34101 L 862.22292,157.33189 L 867.50608,156.40745 L 874.44023,155.06282" id="MA" style="fill:#c83737" />
+ <ns0:path d="M 943.28423,76.73985 L 945.26541,78.924863 L 947.57679,82.790656 L 947.57679,84.807591 L 945.43051,89.68185 L 943.44933,90.354162 L 939.98226,93.547643 L 935.02931,99.262292 C 935.02931,99.262292 934.36891,99.262292 933.70852,99.262292 C 933.04813,99.262292 932.71793,97.077279 932.71793,97.077279 L 930.90185,97.245357 L 929.91126,98.758058 L 927.43478,100.27076 L 926.44419,101.78346 L 928.09517,103.29616 L 927.59988,103.96847 L 927.10458,106.8258 L 925.1234,106.65772 L 925.1234,104.97694 L 924.7932,103.63232 L 923.30732,103.96847 L 921.49123,100.60692 L 919.34495,101.95154 L 920.66574,103.46424 L 920.99594,104.64079 L 920.17045,105.98541 L 920.50064,109.17889 L 920.66574,110.85967 L 919.01476,113.54892 L 916.04298,114.05315 L 915.71279,117.07855 L 910.26454,120.27203 L 908.94375,120.77627 L 907.29277,119.26356 L 904.15589,122.96128 L 905.14649,126.32284 L 903.6606,127.66746 L 903.4955,132.20556 L 901.88477,140.12915 L 899.37016,138.9273 L 898.87486,135.73381 L 894.91249,134.55727 L 894.5823,131.69993 L 887.15284,107.32858 L 882.28553,91.967581 L 884.77927,91.608771 L 886.32526,92.034941 L 886.32526,89.345695 L 887.15075,83.631045 L 889.79233,78.756786 L 891.27821,74.554837 L 889.29703,72.033669 L 889.29703,65.814786 L 890.12252,64.806318 L 890.94802,61.948993 L 890.78292,60.436292 L 890.61782,55.393954 L 892.4339,50.351617 L 895.40568,41.107331 L 897.55196,36.737305 L 898.87274,36.737305 L 900.19353,36.905383 L 900.19353,38.081928 L 901.51432,40.435019 L 904.32099,41.107331 L 905.14649,40.266941 L 905.14649,39.258474 L 909.27395,36.233071 L 911.09003,34.384214 L 912.57592,34.552292 L 918.68456,37.073461 L 920.66574,38.081928 L 929.91126,69.176344 L 936.0199,69.176344 L 936.84539,71.193279 L 937.01049,76.235617 L 939.98226,78.588708 L 940.80775,78.588708 L 940.97285,78.084474 L 940.47756,76.907928 L 943.28423,76.73985 z M 921.90732,108.08415 L 923.47577,106.48741 L 924.87911,107.57992 L 925.45696,110.1011 L 923.72342,111.02553 L 921.90732,108.08415 z M 928.75894,101.94929 L 930.57502,103.88219 C 930.57502,103.88219 931.89582,103.96623 931.89582,103.63007 C 931.89582,103.29391 932.14346,101.52909 932.14346,101.52909 L 933.05151,100.6887 L 932.22602,98.839833 L 930.16228,99.596189 L 928.75894,101.94929 z " id="ME" style="fill:#f4d7d7" />
+ <ns0:path d="M 900.54588,144.88986 L 900.85393,143.29871 L 901.96733,139.87704 L 899.37016,138.9273 L 898.87486,135.73381 L 894.91249,134.55727 L 894.5823,131.69993 L 887.15284,107.32858 L 882.45357,92.208279 L 881.5374,92.203019 L 880.87701,93.883799 L 880.21662,93.379565 L 879.22603,92.371097 L 877.74014,94.388032 L 876.76354,100.09176 L 877.08182,105.98396 L 879.063,108.84129 L 879.063,113.04325 L 875.26572,117.2452 L 872.62415,118.42176 L 872.62415,119.5983 L 873.77984,121.44716 L 873.77984,130.35531 L 872.95434,139.93577 L 872.78925,144.97812 L 873.77984,146.32275 L 873.61474,151.02894 L 873.11944,152.8778 L 874.60533,154.97877 L 892.27091,150.69278 L 894.5823,150.02047 L 896.72858,146.65891 L 900.54588,144.88986 z " id="NH" style="fill:#f4d7d7" />
+ <ns0:path d="M 862.38802,157.584 L 861.56253,151.70126 L 858.42565,140.27193 L 857.76525,139.93577 L 854.79347,138.59115 L 855.61896,135.56574 L 854.79347,133.38072 L 852.1519,128.67453 L 853.14249,124.64065 L 852.31699,119.26214 L 849.84051,112.53901 L 849.0178,107.42109 L 876.75058,99.933872 L 877.08182,105.98396 L 879.063,108.84129 L 879.063,113.04325 L 875.26572,117.2452 L 872.62415,118.42176 L 872.62415,119.5983 L 873.77984,121.44716 L 873.77984,130.35531 L 872.95434,139.93577 L 872.78925,144.97812 L 873.77984,146.32275 L 873.61474,151.02894 L 873.11944,152.8778 L 874.60533,154.97877 L 867.50608,156.40745 L 862.38802,157.584 z " id="VT" style="fill:#f4d7d7" />
+ <ns0:path d="M 846.20833,194.22506 L 845.05264,193.21659 L 842.41105,193.04851 L 840.09968,191.03158 L 837.62319,185.485 L 834.55471,184.51732 L 832.17493,182.29151 L 813.18856,186.49346 L 769.27227,195.56969 L 760.19184,197.08239 L 759.43798,189.88537 L 762.17121,188.00743 L 763.492,186.83089 L 764.48259,185.15011 L 766.29867,183.97356 L 768.27985,182.12471 L 768.77515,180.44393 L 770.92143,177.5866 L 772.07712,176.57814 L 771.91202,175.56967 L 770.59123,172.37619 L 768.77515,172.20811 L 766.79397,165.82115 L 769.76574,163.97229 L 774.2234,162.45959 L 778.35086,161.11497 L 781.65283,160.61073 L 788.09167,160.44266 L 790.07285,161.78728 L 791.72384,161.95536 L 793.87012,160.61073 L 796.51169,159.43419 L 801.79484,158.92995 L 803.94112,157.0811 L 805.75721,153.71954 L 807.40819,151.7026 L 809.55447,151.7026 L 811.53565,150.52606 L 811.70075,148.17297 L 810.21487,145.98795 L 809.88467,144.47525 L 811.04036,142.29024 L 811.04036,140.77754 L 809.22428,140.77754 L 807.40819,139.93715 L 806.5827,138.7606 L 806.4176,136.07136 L 812.36115,130.35671 L 813.02154,129.51632 L 814.50743,126.49092 L 817.4792,121.78473 L 820.28587,117.91894 L 822.43215,115.39777 L 824.89861,113.49969 L 828.0455,112.20429 L 833.65885,110.85967 L 836.96082,111.02775 L 841.58358,109.51505 L 849.30966,107.36166 L 849.84051,112.53901 L 852.31699,119.26214 L 853.14249,124.64065 L 852.1519,128.67453 L 854.79347,133.38072 L 855.61896,135.56574 L 854.79347,138.59115 L 857.76525,139.93577 L 858.42565,140.27193 L 861.56253,151.70126 L 862.05782,157.07976 L 861.56253,168.34101 L 862.38802,174.05567 L 863.21351,177.75339 L 864.6994,185.31692 L 864.6994,193.72083 L 863.54371,196.07393 L 865.42216,198.14582 L 865.19266,199.77289 L 863.21147,201.62175 L 863.54167,202.96637 L 864.86246,202.63021 L 866.34835,201.28559 L 868.65972,198.59634 L 869.81541,197.92403 L 871.4664,198.59634 L 873.77778,198.76442 L 881.8676,194.73055 L 884.83937,191.87323 L 886.16016,190.36053 L 890.45272,192.0413 L 886.98565,195.73902 L 883.02329,198.76442 L 875.75896,204.31099 L 873.11738,205.31946 L 867.17384,207.3364 L 863.04638,208.51294 L 861.49899,207.95886 L 860.90212,204.47784 L 861.39742,201.62051 L 861.23232,199.4355 L 858.59075,198.25894 L 853.96798,197.25047 L 850.0056,196.07393 L 846.20833,194.22506 z " id="NY" style="fill:#280b0b" />
+ <ns0:path d="M 846.20833,194.22506 L 844.06205,196.74624 L 844.06205,199.93973 L 842.08086,203.13321 L 841.91576,204.814 L 843.23656,206.15862 L 843.07146,208.6798 L 840.76007,209.85635 L 841.58556,212.71367 L 841.75066,213.89023 L 844.55734,214.22639 L 845.54794,216.91563 L 849.18011,219.43681 L 851.65659,221.11759 L 851.65659,221.95798 L 848.35462,225.15147 L 846.70362,227.50456 L 845.21774,230.3619 L 842.90636,231.70652 L 841.66812,232.46288 L 841.42046,233.72347 L 840.79828,236.43369 L 841.91377,238.76697 L 845.21574,241.79237 L 850.1687,244.14546 L 854.29616,244.81777 L 854.46126,246.33047 L 853.63576,247.33894 L 853.96596,250.19627 L 854.79145,250.19627 L 856.93773,247.6751 L 857.76322,242.63276 L 860.5699,238.43081 L 863.70677,231.70769 L 864.86246,225.99305 L 864.20207,224.8165 L 864.03697,215.06798 L 862.38598,211.53834 L 861.23029,212.37873 L 858.42362,212.71489 L 857.92832,212.21066 L 859.08401,211.20219 L 861.23029,209.18525 L 861.29469,208.048 L 860.90212,204.47784 L 861.39742,201.62051 L 861.23232,199.4355 L 858.59075,198.25894 L 853.96798,197.25047 L 850.0056,196.07393 L 846.20833,194.22506 z " id="NJ" style="fill:#a02c2c" />
+ <ns0:path d="M 841.75066,232.37883 L 842.90636,231.70652 L 845.21774,230.3619 L 846.70362,227.50456 L 848.35462,225.15147 L 851.65659,221.95798 L 851.65659,221.11759 L 849.18011,219.43681 L 845.54794,216.91563 L 844.55734,214.22639 L 841.75066,213.89023 L 841.58556,212.71367 L 840.76007,209.85635 L 843.07146,208.6798 L 843.23656,206.15862 L 841.91576,204.814 L 842.08086,203.13321 L 844.06205,199.93973 L 844.06205,196.74624 L 846.45598,194.22507 L 845.05264,193.21659 L 842.41105,193.04851 L 840.09968,191.03158 L 837.62319,185.485 L 834.55471,184.51732 L 832.17493,182.29151 L 813.18856,186.49346 L 769.27227,195.56969 L 760.19184,197.08239 L 759.68563,189.71729 L 754.08139,195.57094 L 752.7606,196.07518 L 748.46894,199.20351 L 751.4416,219.10066 L 753.16482,230.27806 L 756.81257,250.30417 L 761.54207,249.5232 L 773.73965,247.96108 L 812.47286,239.9916 L 827.66544,237.0562 L 836.14231,235.36944 L 837.45809,234.05962 L 839.60438,232.37883 L 841.75066,232.37883 z " id="PA" style="fill:#782121" />
+ <ns0:path d="M 840.59298,235.90964 L 841.42046,233.72347 L 841.66812,232.37883 L 839.60438,232.37883 L 837.45809,234.05962 L 835.9722,235.57232 L 837.45809,239.94236 L 839.76948,245.8251 L 841.91576,255.9098 L 843.56675,262.46486 L 848.68482,262.29678 L 854.95755,261.03674 L 852.64517,253.38975 L 851.65458,253.89398 L 848.02242,251.37281 L 846.20633,246.49855 L 844.22515,242.80084 L 841.91377,241.79237 L 839.76749,238.09466 L 840.59298,235.90964 z " id="DE" style="fill:#f4d7d7" />
+ <ns0:path d="M 854.95655,260.95325 L 848.68482,262.29678 L 843.56675,262.46486 L 841.91576,255.9098 L 839.76948,245.8251 L 837.45809,239.94236 L 836.14231,235.36944 L 827.66544,237.0562 L 812.47286,239.9916 L 774.22495,247.84224 L 775.38031,253.05285 L 776.37091,258.93558 L 776.7011,258.59942 L 778.84739,256.07825 L 781.15877,252.88476 L 783.63525,252.71668 L 785.12115,251.20398 L 786.93723,248.51473 L 788.25802,249.18705 L 791.22979,248.85089 L 793.87137,246.66588 L 795.92094,245.15492 L 797.80542,244.65068 L 799.48473,245.82549 L 802.45651,247.33819 L 804.43769,249.18705 L 805.67593,250.78379 L 809.88595,252.5486 L 809.88595,255.57402 L 815.49931,256.91864 L 817.48049,258.26326 L 818.47108,256.24633 L 820.78247,257.92711 L 819.29657,261.28868 L 818.96637,264.146 L 817.15029,266.83525 L 817.15029,269.02027 L 817.81068,270.86913 L 822.98233,272.27864 L 827.38511,272.21447 L 830.52198,273.22294 L 832.66826,273.5591 L 833.65885,271.37408 L 832.17296,269.18907 L 832.17296,267.34021 L 829.69649,265.1552 L 827.55021,259.44055 L 828.87099,253.89398 L 828.70589,251.70897 L 827.38511,250.36434 C 827.38511,250.36434 828.87099,248.68356 828.87099,248.01125 C 828.87099,247.33894 829.36629,245.82624 829.36629,245.82624 L 831.34747,244.48162 L 833.32865,242.80084 L 833.82395,243.8093 L 832.33806,245.49008 L 831.01727,249.35588 L 831.34747,250.53242 L 833.16355,250.86858 L 833.65885,256.58323 L 831.51257,257.59169 L 831.84277,261.28941 L 832.33806,261.12133 L 833.49375,259.1044 L 835.14473,260.95325 L 833.49375,262.29788 L 833.16355,265.82751 L 835.80513,269.35715 L 839.76749,269.86138 L 841.41848,269.02099 L 844.72045,274.39949 L 846.53653,274.90372 L 846.53653,278.60143 L 844.22515,283.64377 L 843.72986,290.87112 L 845.21574,294.40076 L 846.70163,294.56884 L 848.68281,290.19881 L 849.5083,286.5011 L 849.6734,279.10567 L 852.81027,274.06333 L 854.95655,266.83598 L 854.95655,260.95325 z M 838.20212,271.12031 L 839.3578,273.72552 L 839.5229,275.57439 L 840.67859,277.50729 C 840.67859,277.50729 841.58664,276.58285 841.58664,276.2467 C 841.58664,275.91054 840.8437,273.05321 840.8437,273.05321 L 840.10075,270.61606 L 838.20212,271.12031 z " id="MD" style="fill:#d35f5f" />
+ <ns0:path d="M 822.59725,272.21447 L 827.38511,272.21447 L 830.52198,273.22294 L 832.66826,273.5591 L 833.65885,271.37408 L 832.17296,269.18907 L 832.17296,267.34021 L 829.69649,265.1552 L 827.55021,259.44055 L 828.87099,253.89398 L 828.70589,251.70897 L 827.38511,250.36434 C 827.38511,250.36434 828.87099,248.68356 828.87099,248.01125 C 828.87099,247.33894 829.36629,245.82624 829.36629,245.82624 L 831.34747,244.48162 L 833.32865,242.80084 L 833.82395,243.8093 L 832.33806,245.49008 L 831.01727,249.35588 L 831.34747,250.53242 L 833.16355,250.86858 L 833.65885,256.58323 L 831.51257,257.59169 L 831.84277,261.28941 L 832.33806,261.12133 L 833.49375,259.1044 L 835.14473,260.95325 L 833.49375,262.29788 L 833.16355,265.82751 L 835.80513,269.35715 L 839.76749,269.86138 L 841.41848,269.02099 L 844.72045,274.39949 L 846.53653,274.90372 L 846.53653,278.60143 L 844.22515,283.64377 L 843.72986,290.87112 L 845.21574,294.40076 L 846.70163,294.56884 L 848.68281,290.19881 L 849.5083,286.5011 L 849.6734,279.10567 L 852.81027,274.06333 L 854.95655,266.83598 L 854.95655,260.95325 M 838.20212,271.12031 L 839.3578,273.72552 L 839.5229,275.57439 L 840.67859,277.50729 C 840.67859,277.50729 841.58664,276.58285 841.58664,276.2467 C 841.58664,275.91054 840.8437,273.05321 840.8437,273.05321 L 840.10075,270.61606 L 838.20212,271.12031 z " id="MD_Atlantic" style="fill:#0000ff;fill-opacity:0;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 774.24466,247.91204 L 775.38031,253.05285 L 776.37091,258.93558 L 776.7011,258.59942 L 778.84739,256.07825 L 781.15877,252.88476 L 783.63525,252.71668 L 785.12115,251.20398 L 786.93723,248.51473 L 788.25802,249.18705 L 791.22979,248.85089 L 793.87137,246.66588 L 795.92094,245.15492 L 797.80542,244.65068 L 799.48473,245.82549 L 802.45651,247.33819 L 804.43769,249.18705 L 805.84103,250.53167 L 804.7679,255.7421 L 798.98943,252.5486 L 794.36667,250.69975 L 794.20157,256.24633 L 793.70628,258.43134 L 792.05529,261.28868 L 791.39489,262.96946 L 788.25802,265.49063 L 787.76272,267.84373 L 784.29564,268.17988 L 783.96545,271.37336 L 782.80976,277.08802 L 780.16818,277.08802 L 778.84739,276.24763 L 777.1964,273.3903 L 775.38031,273.55838 L 775.05012,278.09649 L 772.90384,284.9877 L 767.78577,296.24894 L 768.61127,297.59356 L 768.44617,300.45089 L 766.29989,302.46782 L 764.814,302.13167 L 761.51202,304.65285 L 758.87045,303.64438 L 757.05436,308.51864 C 757.05436,308.51864 753.25709,309.35903 752.59669,309.52711 C 751.9363,309.69518 750.12022,308.18248 750.12022,308.18248 L 747.64373,310.53557 L 745.00215,311.2079 L 742.03037,310.36749 L 740.70958,309.02287 L 738.47074,305.8795 L 735.26132,303.81246 L 732.61975,300.95512 L 729.64798,297.08933 L 728.98758,294.73623 L 726.346,293.22353 L 725.5205,291.54275 L 725.27285,286.0802 L 727.50168,285.99616 L 729.48288,285.15577 L 729.64798,282.29845 L 731.29896,280.78574 L 731.46406,275.57532 L 732.45465,271.54144 L 733.77544,270.86913 L 735.09623,272.04567 L 735.59153,273.89453 L 737.40761,272.88606 L 737.90291,271.20529 L 736.74722,269.35643 L 736.74722,266.83525 L 737.73781,265.49063 L 740.04919,261.96099 L 741.36998,260.44829 L 743.51627,260.95252 L 745.82765,259.27173 L 748.96452,255.7421 L 751.27591,251.70821 L 751.6061,245.82549 L 752.1014,240.61506 L 752.1014,235.74079 L 750.94571,232.54731 L 751.9363,231.0346 L 753.24707,229.68997 L 756.81257,250.30417 L 761.54207,249.5232 L 774.24466,247.91204 z " id="WV" style="fill:#f4d7d7" />
+ <ns0:path d="M 738.61165,306.09768 L 735.42643,309.35903 L 731.13386,313.05675 L 726.51109,318.60333 L 724.69501,320.45219 L 724.69501,322.6372 L 720.73264,324.82222 L 714.95419,328.35186 L 712.28688,329.81359 L 659.31592,334.90691 L 643.2212,336.75577 L 638.50172,337.28883 L 634.55111,337.26001 L 634.55111,341.29389 L 625.96598,341.79812 L 618.86673,342.47043 L 608.21432,342.68419 L 609.24067,341.39196 L 611.46709,339.55999 L 613.56845,338.3715 L 613.80194,335.04372 L 614.73588,333.14213 L 613.09488,330.50244 L 613.91377,328.51994 L 616.22516,326.67109 L 618.37144,325.99877 L 621.17811,327.3434 L 624.81029,328.68802 L 625.96598,328.35186 L 626.13108,325.99877 L 624.81029,323.47759 L 625.14049,321.1245 L 627.12167,319.6118 L 629.76325,318.93949 L 631.41424,318.26718 L 630.58875,316.41831 L 629.92835,314.40138 L 631.08404,313.56099 L 632.15718,310.11537 L 635.2115,308.35056 L 641.15506,307.34209 L 644.78724,306.83786 L 646.27312,308.85479 L 648.08921,309.69518 L 649.90529,306.33362 L 652.87707,304.82092 L 654.85825,306.5017 L 655.68375,307.67825 L 657.83004,307.17401 L 657.66493,303.64438 L 660.63671,301.96359 L 661.7924,301.1232 L 662.94809,302.80398 L 667.73595,302.80398 L 668.56145,300.61896 L 668.23125,298.26587 L 671.20302,294.56815 L 675.99089,290.53428 L 676.48618,285.82809 L 679.29287,285.49193 L 683.25523,283.64307 L 686.06192,281.62613 L 685.73171,279.60919 L 684.24582,278.09649 L 684.82367,275.82744 L 689.03369,275.57532 L 691.51017,274.73493 L 694.48195,276.41571 L 696.13293,280.95382 L 702.07649,281.28997 L 703.89257,283.13884 L 706.03885,283.30692 L 708.51534,281.79422 L 711.65221,282.29845 L 712.973,283.81115 L 715.77968,281.12189 L 717.59577,279.77727 L 719.24675,279.77727 L 719.90714,282.63461 L 721.72324,283.64307 L 725.3554,285.82809 L 725.5205,291.54275 L 726.346,293.22353 L 728.98758,294.73623 L 729.64798,297.08933 L 732.61975,300.95512 L 735.26132,303.81246 L 738.61165,306.09768 z " id="KY" style="fill:#e9afaf" />
+ <ns0:path d="M 748.46982,198.97029 L 741.20371,203.30253 L 737.24134,205.65562 L 733.77427,209.52141 L 729.64681,213.55528 L 726.34484,214.39567 L 723.37307,214.8999 L 717.75972,217.58915 L 715.61344,217.75723 L 712.14638,214.56375 L 706.86322,215.23606 L 704.22165,213.72336 L 701.78994,212.3189 L 696.79333,213.05024 L 686.39211,214.73102 L 678.46737,215.99161 L 679.78816,231.20268 L 681.60425,245.48932 L 684.24582,269.86066 L 684.82367,275.82744 L 689.03369,275.57532 L 691.51017,274.73493 L 694.48195,276.41571 L 696.13293,280.95382 L 702.07649,281.28997 L 703.89257,283.13884 L 706.03885,283.30692 L 708.51534,281.79422 L 711.65221,282.29845 L 712.973,283.81115 L 715.77968,281.12189 L 717.59577,279.77727 L 719.24675,279.77727 L 719.90714,282.63461 L 721.72324,283.64307 L 725.27285,286.0802 L 727.50168,285.99616 L 729.48288,285.15577 L 729.64798,282.29845 L 731.29896,280.78574 L 731.46406,275.57532 L 732.45465,271.54144 L 733.77544,270.86913 L 735.09623,272.04567 L 735.59153,273.89453 L 737.40761,272.88606 L 737.90291,271.20529 L 736.74722,269.35643 L 736.74722,266.83525 L 737.73781,265.49063 L 740.04919,261.96099 L 741.36998,260.44829 L 743.51627,260.95252 L 745.82765,259.27173 L 748.96452,255.7421 L 751.27591,251.70821 L 751.6061,245.82549 L 752.1014,240.61506 L 752.1014,235.74079 L 750.94571,232.54731 L 751.9363,231.0346 L 753.33994,230.27806 L 751.4416,219.10066 L 748.46982,198.97029 z " id="OH" style="fill:#c83737" />
+ <ns0:path d="M 594.42414,81.655837 L 596.29202,79.516552 L 598.51013,78.684606 L 603.99703,74.643722 L 606.33188,74.049474 L 606.79885,74.524879 L 601.54544,79.873103 L 598.1599,81.893534 L 596.05854,82.844333 L 594.42414,81.655837 z M 682.43117,115.05941 L 683.09156,117.66462 L 686.39354,117.8327 L 687.71434,116.57211 C 687.71434,116.57211 687.63178,115.05941 687.30159,114.89133 C 686.97139,114.72326 685.6506,112.95843 685.6506,112.95843 L 683.42177,113.21054 L 681.77077,113.37862 L 681.44058,114.55518 L 682.43117,115.05941 z M 713.13697,180.61201 L 709.835,172.04003 L 707.52362,162.62767 L 705.04714,159.26611 L 702.40557,157.41725 L 700.75458,158.5938 L 696.79222,160.44266 L 694.81104,165.65307 L 692.00436,169.51886 L 690.84867,170.19118 L 689.36279,169.51886 C 689.36279,169.51886 686.72121,168.00616 686.88631,167.33385 C 687.05141,166.66154 687.38161,162.12343 687.38161,162.12343 L 690.84867,160.77881 L 691.67417,157.24918 L 692.33456,154.55993 L 694.81104,152.87915 L 694.48084,142.45832 L 692.82985,140.10523 L 691.50907,139.26484 L 690.68357,137.07982 L 691.50907,136.23943 L 693.16005,136.57559 L 693.32515,134.89481 L 690.84867,132.54172 L 689.52789,129.85247 L 686.88631,129.85247 L 682.26355,128.33977 L 676.6502,124.81014 L 673.84353,124.81014 L 673.18314,125.48245 L 672.19255,124.97821 L 669.05568,122.62512 L 666.0839,124.47398 L 663.11213,126.82707 L 663.44233,130.52479 L 664.43292,130.86094 L 666.5792,131.36518 L 667.07449,132.20556 L 664.43292,133.04595 L 661.79134,133.38211 L 660.30546,135.23097 L 659.97526,137.41598 L 660.30546,139.09676 L 660.63565,144.81141 L 657.00349,146.99642 L 656.34309,146.82834 L 656.34309,142.45832 L 657.66388,139.93715 L 658.32427,137.41598 L 657.49878,136.57559 L 655.5176,137.41598 L 654.52701,141.78601 L 651.72034,142.96255 L 649.90425,144.97949 L 649.73915,145.98795 L 650.39955,146.82834 L 649.73915,149.51759 L 647.42778,150.02182 L 647.42778,151.19837 L 648.25327,153.71954 L 647.09758,160.1065 L 645.44659,164.30845 L 646.10699,169.18271 L 646.60228,170.35925 L 645.77679,172.88042 L 645.44659,173.72081 L 645.1164,176.57814 L 648.74856,182.79702 L 651.72034,189.52014 L 653.20622,194.56247 L 652.38073,199.43673 L 651.39014,205.65562 L 648.91366,211.03411 L 648.58347,213.89143 L 645.43483,217.12572 L 644.67586,217.92491 L 649.40999,217.75643 L 671.86343,215.40333 L 678.13717,214.73102 L 678.46737,215.99161 L 686.39211,214.73102 L 696.79333,213.05024 L 702.12014,212.57103 L 700.75458,211.37027 L 700.91968,209.85756 L 703.06596,205.99177 L 705.10906,204.18493 L 704.88204,198.9325 L 706.51299,197.27212 L 707.62681,196.91556 L 707.85382,193.21785 L 709.42225,190.0664 L 710.49539,190.69668 L 710.66049,191.36899 L 711.48598,191.53707 L 713.46716,190.5286 L 713.13697,180.61201 z M 578.8376,112.43927 L 580.72799,111.3639 L 583.53467,110.52351 L 587.16683,108.17042 L 587.16683,107.16195 L 587.82723,106.48964 L 593.93587,105.48118 L 596.41235,103.46424 L 600.87001,101.27923 L 601.03511,99.934604 L 603.01629,96.909201 L 604.83237,96.068811 L 606.15316,94.219954 L 608.46454,91.866863 L 612.9222,89.345695 L 617.71005,88.841461 L 618.86574,90.018006 L 618.53554,91.026474 L 614.73828,92.034941 L 613.25239,95.228422 L 610.94101,96.068811 L 610.44572,98.58998 L 607.96924,101.95154 L 607.63904,104.64079 L 608.46454,105.14502 L 609.45513,103.96847 L 613.08729,100.94307 L 614.40808,102.28769 L 616.71946,102.28769 L 620.02143,103.29616 L 621.50732,104.47271 L 622.9932,107.66619 L 625.79988,110.52351 L 629.76224,110.35543 L 631.24813,109.34697 L 632.89911,110.69159 L 634.5501,111.19582 L 635.87088,110.35543 L 637.02657,110.35543 L 638.67756,109.34697 L 642.80502,105.64925 L 646.27209,104.47271 L 653.04112,104.13655 L 657.66388,102.11962 L 660.30546,100.77499 L 661.79134,100.94307 L 661.79134,106.8258 L 662.28664,107.16195 L 665.25841,108.00234 L 667.23959,107.49811 L 673.51333,105.81733 L 674.66902,104.64079 L 676.15491,105.14502 L 676.15491,112.37237 L 679.45688,115.56585 L 680.77767,116.23816 L 682.09845,117.24663 L 680.77767,117.58279 L 679.95217,117.24663 L 676.15491,116.7424 L 674.00863,117.41471 L 671.69725,117.24663 L 668.39528,118.75933 L 666.5792,118.75933 L 660.63565,117.41471 L 655.3525,117.58279 L 653.37132,120.27203 L 646.27209,120.94434 L 643.79561,121.78473 L 642.63992,124.97821 L 641.31913,126.15476 L 640.82384,125.98668 L 639.33795,124.3059 L 634.71519,126.82707 L 634.0548,126.82707 L 632.89911,125.14629 L 632.07362,125.31437 L 630.09244,129.85247 L 629.10185,134.05442 L 625.27363,142.51293 L 623.6083,141.31962 L 622.20739,139.89342 L 620.57299,129.197 L 616.83724,128.00851 L 615.43633,125.63153 L 602.59467,122.77914 L 600.02634,121.59066 L 591.62089,119.21367 L 583.21544,118.02518 L 578.8376,112.43927 z " id="MI" style="fill:#c83737" />
+ <ns0:path d="M 363.20447,145.98954 L 351.44763,144.98362 L 318.67723,141.55738 L 302.09981,139.41809 L 273.14771,135.13952 L 252.83454,132.04945 L 251.38528,143.66906 L 247.4644,168.89268 L 242.09425,200.50655 L 240.53069,211.44019 L 238.82546,223.80098 L 245.48808,224.7662 L 262.73518,227.14318 L 271.75391,228.36645 L 292.76047,230.93187 L 330.81813,235.21041 L 355.80134,237.34972 L 360.23755,191.23625 L 361.87193,164.85174 L 363.20447,145.98954 z " id="WY" style="fill:#f4d7d7" />
+ <ns0:path d="M 365.51098,123.96764 L 366.33647,111.87386 L 368.64299,85.935913 L 370.0439,70.247815 L 371.33142,55.452236 L 338.69364,52.032396 L 308.81082,48.334682 L 278.92799,44.132734 L 245.9083,38.586162 L 227.08707,35.056526 L 193.6675,27.848581 L 189.09322,50.043547 L 192.59549,57.887585 L 191.19458,62.64155 L 193.06246,67.395514 L 196.33125,68.821708 L 200.067,79.518133 L 203.80276,83.321298 L 204.26973,84.509794 L 207.772,85.698291 L 208.23897,87.837565 L 201.00095,106.14034 L 201.00095,108.75502 L 203.56928,112.08279 L 204.50321,112.08279 L 209.40639,108.99272 L 210.10685,107.80422 L 211.74124,108.51732 L 211.50775,113.98438 L 214.30957,127.05779 L 217.34487,129.67247 L 218.2788,130.38556 L 220.14668,132.76254 L 219.67972,136.32802 L 220.38017,139.89349 L 221.5476,140.84429 L 223.88244,138.4673 L 226.68426,138.4673 L 229.95305,140.13119 L 232.52138,139.1804 L 236.7241,139.1804 L 240.45985,140.84429 L 243.26167,140.36889 L 243.72864,137.27881 L 246.76394,136.56572 L 248.16485,137.99191 L 248.63182,141.31968 L 251.26853,143.90677 L 252.83454,132.04945 L 273.14771,135.13952 L 302.09981,139.41809 L 318.67723,141.55738 L 351.44763,144.98362 L 363.16317,146.23449 L 364.89307,129.69427 L 365.51098,123.96764 z " id="MT" style="fill:#f4d7d7" />
+ <ns0:path d="M 144.08485,180.96023 L 148.93381,161.76187 L 153.37002,143.34026 L 154.77093,138.94284 L 157.33926,132.76269 L 156.0551,130.38571 L 153.48676,130.50455 L 152.66957,129.43491 L 153.13654,128.24642 L 153.48676,125.0375 L 158.03971,119.33273 L 159.90759,118.85734 L 161.07501,117.66885 L 161.65873,114.34107 L 162.59266,113.62798 L 166.5619,107.56668 L 170.53114,103.05041 L 170.76463,99.128389 L 167.26235,96.394856 L 165.91982,91.819167 L 166.32842,81.776422 L 170.06418,64.662142 L 174.61712,43.031605 L 178.46962,29.00742 L 179.24775,25.053259 L 193.6675,27.848581 L 189.09322,50.043547 L 192.59549,57.887585 L 191.19458,62.64155 L 193.06246,67.395514 L 196.33125,68.821708 L 200.067,79.518133 L 203.80276,83.321298 L 204.26973,84.509794 L 207.772,85.698291 L 208.23897,87.837565 L 201.00095,106.14034 L 201.00095,108.75502 L 203.56928,112.08279 L 204.50321,112.08279 L 209.40639,108.99272 L 210.10685,107.80422 L 211.74124,108.51732 L 211.50775,113.98438 L 214.30957,127.05779 L 217.34487,129.67247 L 218.2788,130.38556 L 220.14668,132.76254 L 219.67972,136.32802 L 220.38017,139.89349 L 221.5476,140.84429 L 223.88244,138.4673 L 226.68426,138.4673 L 229.95305,140.13119 L 232.52138,139.1804 L 236.7241,139.1804 L 240.45985,140.84429 L 243.26167,140.36889 L 243.72864,137.27881 L 246.76394,136.56572 L 248.16485,137.99191 L 248.63182,141.31968 L 251.31689,143.45897 L 247.4644,168.89268 L 242.21095,200.38777 L 237.30774,199.55589 L 228.78555,198.12969 L 218.27875,196.22811 L 206.02081,194.08882 L 193.0624,191.65242 L 184.89044,189.57255 L 175.43432,187.67098 L 165.51122,185.65054 L 144.08485,180.96023 z " id="ID" style="fill:#f4d7d7" />
+ <ns0:path d="M 95.99889,2.9536428 L 100.45655,4.4663441 L 110.36246,7.3236687 L 119.11268,9.3406038 L 139.58489,15.223331 L 163.02887,21.106058 L 179.49525,24.969183 L 178.46962,29.00742 L 174.61712,43.031605 L 170.06418,64.662142 L 166.32842,81.776422 L 166.13328,91.861195 L 151.85237,88.313121 L 136.44238,84.628799 L 120.68217,84.747642 L 120.21521,83.321459 L 114.61157,85.460744 L 110.05862,84.866496 L 107.60703,83.202605 L 106.32286,83.915707 L 101.53644,83.678 L 99.785303,82.251817 L 94.415149,80.112532 L 93.597952,80.231386 L 89.161743,78.686338 L 87.177124,80.587926 L 80.873036,80.231386 L 74.802439,75.952816 L 75.50289,75.12087 L 75.736374,67.039124 L 73.401527,62.998262 L 69.198802,62.404014 L 68.498351,59.789335 L 66.094359,59.304248 L 64.13488,57.747045 L 62.318797,58.755513 L 60.007419,55.73011 L 60.337616,52.704708 L 63.14429,52.368552 L 64.795274,48.166604 L 62.153699,46.990058 L 62.318797,43.124266 L 66.776456,42.451954 L 63.969782,39.59463 L 62.483896,32.199201 L 63.14429,29.173799 L 63.14429,20.93798 L 61.328206,17.576422 L 63.639585,7.8279025 L 65.785865,8.3321363 L 68.262342,11.357539 L 71.069016,14.046786 L 74.370985,16.063721 L 78.993743,18.248734 L 82.130616,18.921045 L 85.102388,20.433747 L 88.569459,21.442214 L 90.880838,21.274136 L 90.880838,18.752967 L 92.201625,17.576422 L 94.347905,16.231799 L 94.678102,17.408344 L 95.008299,19.257201 L 92.696921,19.761435 L 92.366724,21.946448 L 94.182807,23.459149 L 95.338496,25.980318 L 95.99889,27.997253 L 97.484776,27.829175 L 97.649875,26.484552 L 96.659284,25.139928 L 96.163989,21.77837 L 96.989481,19.929513 L 96.329087,18.416812 L 96.329087,16.063721 L 98.14517,12.366006 L 96.989481,9.6767597 L 94.513004,4.634422 L 94.843201,3.7940324 L 95.99889,2.9536428 z M 86.341086,9.169955 L 88.404826,9.001877 L 88.900121,10.430545 L 90.468562,8.7497548 L 92.862495,8.7497548 L 93.687987,10.3465 L 92.119546,12.111324 L 92.779951,12.951724 L 92.037002,15.052704 L 90.63366,15.472893 C 90.63366,15.472893 89.725613,15.556938 89.725613,15.220782 C 89.725613,14.884626 91.21151,12.531524 91.21151,12.531524 L 89.477971,11.943246 L 89.147774,13.455958 L 88.404826,14.12827 L 86.836382,11.775168 L 86.341086,9.169955 z " id="WA" style="fill:#d35f5f" />
+ <ns0:path d="M 224.65378,521.59843 L 226.52879,518.16091 L 228.7163,517.84841 L 229.0288,518.62966 L 226.99754,521.59843 L 224.65378,521.59843 z M 234.49758,518.00466 L 240.43511,520.50467 L 242.46637,520.19217 L 244.02887,516.44215 L 243.40387,513.16089 L 239.34135,512.69214 L 235.43508,514.41089 L 234.49758,518.00466 z M 264.18522,527.69221 L 267.77898,533.00473 L 270.12274,532.69223 L 271.2165,532.22348 L 272.62275,533.47348 L 276.21652,533.31723 L 277.15403,531.91098 L 274.34151,530.19222 L 272.4665,526.59845 L 270.43524,523.16094 L 264.81022,525.97345 L 264.18522,527.69221 z M 283.71656,536.286 L 284.96656,534.41099 L 289.49783,535.34849 L 290.12284,534.87974 L 296.06036,535.50474 L 295.74786,536.75475 L 293.24785,538.161 L 289.02908,537.8485 L 283.71656,536.286 z M 288.87283,541.28602 L 290.74784,545.03604 L 293.7166,543.94228 L 294.0291,542.37977 L 292.4666,540.34851 L 288.87283,540.03601 L 288.87283,541.28602 z M 295.59161,540.19226 L 297.77912,537.37975 L 302.31039,539.72351 L 306.52916,540.81727 L 310.74793,543.47353 L 310.74793,545.34854 L 307.31042,547.0673 L 302.62289,548.0048 L 300.27913,546.59854 L 295.59161,540.19226 z M 311.68544,555.19233 L 313.24794,553.94233 L 316.52921,555.50484 L 323.87299,558.94235 L 327.15426,560.97361 L 328.71676,563.31737 L 330.59177,567.53614 L 334.49804,570.03615 L 334.18554,571.28616 L 330.43552,574.41117 L 326.373,575.81743 L 324.96675,575.19243 L 321.99798,576.91118 L 319.65422,580.0362 L 317.46671,582.84871 L 315.74795,582.69246 L 312.31044,580.19245 L 311.99794,575.81743 L 312.62294,573.47367 L 311.06043,568.00489 L 309.02917,566.28613 L 308.87292,563.78612 L 311.06043,562.84862 L 313.09169,559.87986 L 313.56044,558.94235 L 311.99794,557.22359 L 311.68544,555.19233 z " id="HI_Pacific" style="fill:none;fill-opacity:0.26666697;stroke:#80b0f0;stroke-width:1pt" />
+ <ns0:path d="M 224.65378,521.59843 L 226.52879,518.16091 L 228.7163,517.84841 L 229.0288,518.62966 L 226.99754,521.59843 L 224.65378,521.59843 z M 234.49758,518.00466 L 240.43511,520.50467 L 242.46637,520.19217 L 244.02887,516.44215 L 243.40387,513.16089 L 239.34135,512.69214 L 235.43508,514.41089 L 234.49758,518.00466 z M 264.18522,527.69221 L 267.77898,533.00473 L 270.12274,532.69223 L 271.2165,532.22348 L 272.62275,533.47348 L 276.21652,533.31723 L 277.15403,531.91098 L 274.34151,530.19222 L 272.4665,526.59845 L 270.43524,523.16094 L 264.81022,525.97345 L 264.18522,527.69221 z M 283.71656,536.286 L 284.96656,534.41099 L 289.49783,535.34849 L 290.12284,534.87974 L 296.06036,535.50474 L 295.74786,536.75475 L 293.24785,538.161 L 289.02908,537.8485 L 283.71656,536.286 z M 288.87283,541.28602 L 290.74784,545.03604 L 293.7166,543.94228 L 294.0291,542.37977 L 292.4666,540.34851 L 288.87283,540.03601 L 288.87283,541.28602 z M 295.59161,540.19226 L 297.77912,537.37975 L 302.31039,539.72351 L 306.52916,540.81727 L 310.74793,543.47353 L 310.74793,545.34854 L 307.31042,547.0673 L 302.62289,548.0048 L 300.27913,546.59854 L 295.59161,540.19226 z M 311.68544,555.19233 L 313.24794,553.94233 L 316.52921,555.50484 L 323.87299,558.94235 L 327.15426,560.97361 L 328.71676,563.31737 L 330.59177,567.53614 L 334.49804,570.03615 L 334.18554,571.28616 L 330.43552,574.41117 L 326.373,575.81743 L 324.96675,575.19243 L 321.99798,576.91118 L 319.65422,580.0362 L 317.46671,582.84871 L 315.74795,582.69246 L 312.31044,580.19245 L 311.99794,575.81743 L 312.62294,573.47367 L 311.06043,568.00489 L 309.02917,566.28613 L 308.87292,563.78612 L 311.06043,562.84862 L 313.09169,559.87986 L 313.56044,558.94235 L 311.99794,557.22359 L 311.68544,555.19233 z " id="HI" style="fill:#e9afaf" />
+ <ns0:path d="M 365.08234,342.9472 L 388.2557,344.07626 L 420.00963,345.26475 L 418.60872,369.98537 L 418.14175,388.52584 L 418.37523,390.18973 L 422.81144,393.9929 L 424.9128,395.18139 L 425.61327,394.94369 L 426.31372,392.80441 L 427.71463,394.70599 L 429.81599,394.70599 L 429.81599,393.2798 L 432.6178,394.70599 L 432.15084,398.74687 L 436.35356,398.98457 L 438.92189,400.17306 L 443.12462,400.88615 L 445.69295,402.78774 L 448.02779,400.64846 L 451.53007,401.36155 L 454.0984,404.92703 L 455.03233,404.92703 L 455.03233,407.30401 L 457.36718,408.0171 L 459.70203,405.64012 L 461.5699,406.35321 L 464.13823,406.35321 L 465.07218,408.9679 L 469.97536,410.86948 L 471.37627,410.15638 L 473.24415,405.87781 L 474.41156,405.87781 L 475.57899,408.0171 L 479.78172,408.73019 L 483.51747,410.15638 L 486.55277,411.10718 L 488.42065,410.15638 L 489.1211,407.54171 L 493.55731,407.54171 L 495.65868,408.49249 L 498.46049,406.35321 L 499.62792,406.35321 L 500.32837,408.0171 L 504.53109,408.0171 L 506.16549,405.87781 L 508.03337,406.35321 L 510.13473,408.9679 L 513.40351,410.86948 L 516.6723,411.82028 L 519.47412,413.12761 L 521.80896,415.14805 L 524.84426,413.72186 L 527.64608,414.91035 L 528.33194,426.45357 L 528.34653,436.5409 L 529.04699,446.28652 L 529.74745,450.3274 L 532.31578,454.60597 L 533.24971,459.83533 L 537.68592,465.54009 L 537.9194,468.86787 L 538.61987,469.58096 L 537.9194,478.3758 L 534.88411,483.60516 L 536.5185,485.74444 L 535.81804,488.35912 L 535.11759,495.96547 L 533.71668,499.29324 L 534.00598,503.01936 L 527.40119,504.83432 L 517.33018,509.5405 L 516.33959,511.55743 L 513.69802,513.57437 L 511.55174,515.08707 L 510.23095,515.92746 L 504.4525,521.47403 L 501.64583,523.65904 L 496.19758,527.0206 L 490.41913,529.54177 L 483.98029,533.07141 L 482.16421,534.58411 L 476.22066,538.28182 L 472.7536,538.95414 L 468.79123,544.66878 L 464.66377,545.00494 L 463.67318,547.02188 L 465.98456,549.03881 L 464.49867,554.75346 L 463.17788,559.45964 L 462.0222,563.49351 L 461.1967,568.19969 L 462.0222,570.72086 L 463.83828,577.94821 L 464.82887,584.33517 L 466.64495,587.1925 L 465.65436,588.7052 L 462.51749,590.72214 L 456.73904,586.68827 L 451.1257,585.51172 L 449.80491,586.01595 L 446.50294,585.34364 L 442.21038,582.15016 L 436.92723,580.97362 L 429.1676,577.44398 L 427.02132,573.41011 L 425.70053,566.68699 L 422.39856,564.67006 L 421.73817,562.31697 L 422.39856,561.64466 L 422.72876,558.11502 L 421.40797,557.44271 L 420.74758,556.43424 L 422.06837,551.89614 L 420.41738,549.54304 L 417.11541,548.19842 L 413.64834,543.66032 L 410.01618,536.76912 L 405.72362,534.07988 L 405.88872,532.06294 L 400.44047,519.28902 L 399.61497,514.91899 L 397.79889,512.90206 L 397.63379,511.38936 L 391.52515,505.84279 L 388.88357,502.6493 L 388.88357,501.47276 L 386.242,499.28775 L 379.30786,498.1112 L 371.71333,497.43889 L 368.57646,495.0858 L 363.9537,496.93466 L 360.32154,498.44736 L 358.01016,501.80891 L 357.01957,505.67471 L 352.56191,512.06167 L 350.08543,514.58284 L 347.44386,513.57437 L 345.62777,512.39782 L 343.64659,511.72551 L 339.68423,509.37242 L 339.68423,508.70011 L 337.86815,506.68317 L 332.585,504.49816 L 324.99047,496.43042 L 322.67909,491.55616 L 322.67909,483.15227 L 319.37712,476.42915 L 318.88182,473.57182 L 317.23084,472.56336 L 316.07515,470.37834 L 310.9571,468.19333 L 309.63631,466.51255 L 302.37198,458.27673 L 301.05119,454.91517 L 296.26332,452.56207 L 294.77744,448.02394 L 292.13584,444.99855 L 290.15467,444.49434 L 289.49163,439.63101 L 297.66367,440.34413 L 327.31615,443.19648 L 356.96871,444.86037 L 359.30356,420.13975 L 363.27277,362.37936 L 364.90719,342.88781 L 366.3081,342.91754 M 467.38967,586.18345 L 466.81183,578.788 L 464.00514,571.30851 L 463.42729,563.99709 L 464.99573,555.42509 L 468.38027,548.28176 L 471.92989,542.65112 L 475.14933,538.95339 L 475.80972,539.20552 L 470.9393,546.09673 L 466.48163,552.90391 L 464.41788,559.79513 L 464.08769,565.17365 L 464.99573,571.56063 L 467.63732,579.04012 L 468.13261,584.41863 L 468.29771,585.93134 L 467.38967,586.18345 z " id="TX" style="fill:#280b0b" />
+ <ns0:path d="M 140.74058,399.1133 L 144.96457,398.27159 L 146.48222,396.01346 L 147.06593,393.16108 L 143.33019,392.56684 L 142.86321,391.73489 L 143.33019,389.95216 L 143.44692,383.89086 L 145.54828,383.17775 L 148.46685,380.32538 L 149.05056,375.21486 L 150.56821,371.4117 L 152.55282,369.39125 L 156.0551,367.60852 L 157.68948,366.18234 L 157.80623,363.80536 L 156.75555,363.21111 L 155.93835,362.14147 L 154.65419,356.08016 L 151.85237,350.96965 L 152.44317,347.57226 L 149.86775,344.19525 L 135.04148,320.54427 L 115.19528,290.35661 L 91.963567,255.17727 L 79.318752,235.85757 L 80.989783,229.03046 L 88.111072,202.05171 L 96.399776,169.3682 L 82.624178,165.56503 L 68.848582,161.99955 L 56.006928,157.72098 L 48.301935,155.58169 L 36.627704,152.49162 L 29.426941,149.98419 L 27.813217,154.89608 L 27.648119,162.62767 L 22.364968,174.89736 L 19.228097,177.5866 L 18.8979,178.76315 L 17.081817,179.60354 L 15.595931,183.97356 L 14.770438,187.33512 L 17.577112,191.70515 L 19.228097,196.07518 L 20.383786,199.77289 L 20.053589,206.49601 L 18.237506,209.68949 L 17.577112,215.74029 L 16.586521,219.60608 L 18.402605,223.63995 L 21.209279,228.34614 L 23.520657,233.38847 L 24.841445,237.59042 L 24.511248,240.95198 L 24.181051,241.45621 L 24.181051,243.64123 L 29.959497,250.19627 L 29.464202,252.71743 L 28.803808,255.07053 L 28.143414,257.08746 L 28.308513,265.65943 L 30.454793,269.52523 L 32.435974,272.21447 L 35.242648,272.71871 L 36.233239,275.57603 L 35.07755,279.27375 L 32.93127,280.95453 L 31.775581,280.95453 L 30.950088,284.9884 L 31.445384,288.0138 L 34.747353,292.5519 L 36.398338,298.09847 L 37.884224,302.97273 L 39.205012,306.16621 L 42.672079,312.21702 L 44.157966,314.90627 L 44.653261,317.93167 L 46.304246,318.94014 L 46.304246,321.4613 L 45.478753,323.47824 L 43.66267,330.87367 L 43.167375,332.8906 L 45.643852,335.74793 L 49.936412,336.25216 L 54.559169,338.10102 L 58.521532,340.28603 L 61.493305,340.28603 L 64.465077,343.47951 L 67.106653,348.52185 L 68.262342,350.87494 L 72.224705,353.05995 L 77.177659,353.90034 L 78.663546,356.08536 L 79.32394,359.44692 L 77.838053,360.11923 L 78.16825,361.12769 L 81.470222,361.96808 L 84.276896,362.13616 L 87.248668,367.01042 L 91.211035,371.38045 L 92.036527,373.73354 L 94.678102,378.10356 L 95.008299,381.46512 L 95.008299,391.21364 L 95.503595,393.0625 L 105.7397,394.5752 L 125.88171,397.43253 L 140.74058,399.1133 z M 50.26694,346.75563 L 51.587732,348.35237 L 51.422633,349.697 L 48.120652,349.61296 L 47.542806,348.35237 L 46.88241,346.83966 L 50.26694,346.75563 z M 52.248128,346.75563 L 53.48637,346.08332 L 57.118549,348.26833 L 60.255432,349.52892 L 59.347387,350.20124 L 54.724613,349.94912 L 53.073623,348.26833 L 52.248128,346.75563 z M 73.380807,367.34524 L 75.19689,369.78238 L 76.022393,370.79086 L 77.590834,371.37912 L 78.168673,369.86642 L 77.178082,368.01756 L 74.453952,365.91658 L 73.380807,366.08465 L 73.380807,367.34524 z M 71.89491,376.33744 L 73.711004,379.61497 L 74.949248,381.63192 L 73.463351,381.88403 L 72.142563,380.62344 C 72.142563,380.62344 71.399615,379.11074 71.399615,378.69054 C 71.399615,378.27035 71.399615,376.42148 71.399615,376.42148 L 71.89491,376.33744 z " id="CA" style="fill:#280b0b" />
+ <ns0:path d="M 141.11208,399.22238 L 138.4292,401.4664 L 138.099,402.9791 L 138.5943,403.98756 L 157.91082,415.08071 L 170.2932,422.98037 L 185.31716,431.8885 L 202.4874,442.30933 L 215.03489,444.8305 L 242.33612,448.47703 L 244.42909,434.63935 L 248.26156,406.64864 L 255.37454,351.33455 L 259.72234,319.30647 L 233.45531,315.31482 L 205.67064,310.56085 L 171.53056,303.99238 L 168.54652,322.80241 L 168.07955,323.2778 L 166.32842,326.01134 L 163.76009,325.89248 L 162.47592,323.04011 L 159.67411,322.68356 L 158.74017,321.49507 L 157.80623,321.49507 L 156.87229,322.08932 L 154.88767,323.15896 L 154.77093,330.40875 L 154.53744,332.19149 L 153.95374,345.26489 L 152.43609,347.52302 L 151.85237,350.96965 L 154.65419,356.08016 L 155.93835,362.14147 L 156.75555,363.21111 L 157.80623,363.80536 L 157.68948,366.18234 L 156.0551,367.60852 L 152.55282,369.39125 L 150.56821,371.4117 L 149.05056,375.21486 L 148.46685,380.32538 L 145.54828,383.17775 L 143.44692,383.89086 L 143.33019,389.95216 L 142.86321,391.73489 L 143.33019,392.56684 L 147.06593,393.16108 L 146.48222,396.01346 L 144.96457,398.27159 L 141.11208,399.22238 z " id="AZ" style="fill:#de8787" />
+ <ns0:path d="M 144.08485,180.96023 L 165.51122,185.65054 L 175.43432,187.67098 L 184.89044,189.57255 L 193.12078,191.77126 L 191.89498,197.53545 L 188.27597,215.71936 L 184.42347,236.99336 L 182.43886,246.26359 L 180.22075,260.40663 L 176.83522,277.63975 L 173.56644,293.44668 L 171.55619,304.36486 L 168.54652,322.80241 L 168.07955,323.2778 L 166.32842,326.01134 L 163.76009,325.89248 L 162.47592,323.04011 L 159.67411,322.68356 L 158.74017,321.49507 L 157.80623,321.49507 L 156.87229,322.08932 L 154.88767,323.15896 L 154.77093,330.40875 L 154.53744,332.19149 L 153.95374,345.26489 L 152.43963,347.54764 L 149.86775,344.19525 L 135.04148,320.54427 L 115.19528,290.35661 L 91.963567,255.17727 L 79.318752,235.85757 L 80.989783,229.03046 L 88.111072,202.05171 L 96.166292,169.45944 L 130.48854,177.92533 L 144.49761,181.0154" id="NV" style="fill:#e9afaf" />
+ <ns0:path d="M 259.6056,319.59339 L 233.45531,315.31482 L 205.67064,310.56085 L 171.45228,304.13508 L 173.56644,293.44668 L 176.83522,277.63975 L 180.22075,260.40663 L 182.43886,246.26359 L 184.42347,236.99336 L 188.27597,215.71936 L 191.89498,197.53545 L 193.03322,191.74155 L 206.02081,194.08882 L 218.27875,196.22811 L 228.78555,198.12969 L 237.30774,199.55589 L 242.21095,200.38777 L 240.53069,211.44019 L 238.82546,223.80098 L 245.48808,224.7662 L 262.73518,227.14318 L 272.10412,228.36648 L 268.94498,251.37398 L 265.6762,274.66841 L 261.84373,303.76606 L 260.30605,315.31482 L 259.6056,319.59339 z " id="UT" style="fill:#e9afaf" />
+ <ns0:path d="M 384.05299,331.95365 L 388.2557,263.49654 L 389.8901,240.2021 L 355.80134,237.34972 L 330.81813,235.21041 L 292.76047,230.93187 L 271.63008,228.31722 L 268.94498,251.37398 L 265.6762,274.66841 L 261.84373,303.76606 L 260.30605,315.31482 L 259.72234,319.35569 L 294.86179,323.63426 L 332.58567,328.23704 L 366.04543,330.52751 L 372.84567,331.2406 L 384.5199,331.83485" id="CO" style="fill:#e9afaf" />
+ <ns0:path d="M 290.31977,444.66242 L 289.49163,439.63101 L 297.66367,440.34413 L 327.31615,443.19648 L 356.96871,444.86037 L 359.30356,420.13975 L 363.27277,362.37936 L 364.90719,342.88781 L 366.3081,342.91754 L 366.29351,330.73549 L 332.58567,328.23704 L 294.86179,323.63426 L 259.66398,319.35569 L 255.37454,351.33455 L 248.26156,406.64864 L 244.42909,434.63935 L 242.33612,448.47703 L 258.12559,450.54515 L 259.44637,440.12432 L 276.45152,442.81356 L 290.31977,444.66242 z " id="NM" style="fill:#e9afaf" />
+ <ns0:path d="M 144.38087,180.54003 L 148.93381,161.76187 L 153.37002,143.34026 L 154.77093,138.94284 L 157.33926,132.76269 L 156.0551,130.38571 L 153.48676,130.50455 L 152.66957,129.43491 L 153.13654,128.24642 L 153.48676,125.0375 L 158.03971,119.33273 L 159.90759,118.85734 L 161.07501,117.66885 L 161.65873,114.34107 L 162.59266,113.62798 L 166.5619,107.56668 L 170.53114,103.05041 L 170.76463,99.128389 L 167.26235,96.394856 L 166.19165,92.03947 L 151.85237,88.313121 L 136.44238,84.628799 L 120.68217,84.747642 L 120.21521,83.321459 L 114.61157,85.460744 L 110.05862,84.866496 L 107.60703,83.202605 L 106.32286,83.915707 L 101.53644,83.678 L 99.785303,82.251817 L 94.415149,80.112532 L 93.597952,80.231386 L 89.161743,78.686338 L 87.177124,80.587926 L 80.873036,80.231386 L 74.802439,75.952816 L 75.50289,75.12087 L 75.736374,67.039124 L 73.401527,62.998262 L 69.198802,62.404014 L 68.498351,59.789335 L 66.094359,59.304248 L 60.172517,61.44476 L 57.861139,68.167876 L 54.559169,78.588708 L 51.2572,85.311824 L 46.139147,99.934604 L 39.535209,114.05315 L 31.280285,127.16323 L 29.299104,130.18863 L 28.473611,139.09676 L 27.152823,145.31564 L 29.426941,149.98419 L 36.627704,152.49162 L 48.301935,155.58169 L 56.006928,157.72098 L 68.848582,161.99955 L 82.624178,165.56503 L 96.399776,169.60589 M 144.08485,180.96023 L 96.166292,169.45944 L 130.48854,177.92533 L 144.49761,181.0154" id="OR" style="fill:#e9afaf" />
+ <ns0:path d="M 482.58353,129.91009 L 481.88308,121.11525 L 480.0152,113.50891 L 478.14732,99.484714 L 477.68036,89.263683 L 475.81248,85.698205 L 474.17808,80.468846 L 474.17808,69.772421 L 474.87853,65.731548 L 472.88209,60.014242 L 442.87077,59.427825 L 423.88445,58.755513 L 396.8083,57.410889 L 371.33142,55.452236 L 370.0439,70.247815 L 368.64299,85.935913 L 366.33647,111.87386 L 365.67607,124.49947 L 423.04492,128.24621 L 482.58353,129.91009 z " id="ND" style="fill:#f4d7d7" />
+ <ns0:path d="M 484.10703,208.42015 L 483.13305,206.29529 L 481.4161,203.35887 L 483.28398,198.8426 L 484.6849,192.90014 L 481.88308,190.76086 L 481.4161,187.90848 L 482.35005,185.29379 L 484.21793,185.29379 L 484.6849,178.16284 L 484.45141,146.54897 L 483.98444,143.4589 L 479.78172,139.89342 L 478.61429,137.99183 L 478.61429,136.32794 L 480.71565,134.66406 L 482.11657,133.23787 L 482.4668,129.91009 L 423.04492,128.24621 L 365.67608,124.20534 L 364.89307,129.69427 L 363.24575,146.19243 L 361.87193,164.85174 L 360.23755,191.5928 L 376.1145,192.66245 L 396.66115,193.85093 L 414.87297,195.03943 L 439.15538,196.22791 L 450.12916,195.75252 L 452.23052,198.1295 L 457.1337,201.21959 L 458.30112,202.17037 L 462.73733,200.74418 L 466.70658,200.26879 L 469.50839,200.03109 L 471.37627,201.45728 L 476.51293,203.12117 L 479.54822,204.78505 L 480.0152,206.44894 L 480.94914,208.58823 L 482.81702,208.58823 L 484.10703,208.42015 z " id="SD" style="fill:#f4d7d7" />
+ <ns0:path d="M 496.12564,252.80011 L 497.52656,255.41479 L 497.29307,257.79177 L 499.8614,261.83265 L 503.13018,266.11122 L 496.82609,266.11122 L 451.76325,265.63581 L 410.43676,264.20963 L 388.13897,263.37769 L 389.8901,240.2021 L 355.80134,237.34972 L 360.23755,191.5928 L 376.1145,192.66245 L 396.66115,193.85093 L 414.87297,195.03943 L 439.15538,196.22791 L 450.12916,195.75252 L 452.23052,198.1295 L 457.1337,201.21959 L 458.30112,202.17037 L 462.73733,200.74418 L 466.70658,200.26879 L 469.50839,200.03109 L 471.37627,201.45728 L 476.51293,203.12117 L 479.54822,204.78505 L 480.0152,206.44894 L 480.94914,208.58823 L 482.81702,208.58823 L 484.45141,208.46938 L 485.61883,214.05529 L 488.42065,221.89933 L 489.35459,226.891 L 491.68943,230.69417 L 492.38988,236.16123 L 494.02428,240.4398 L 494.25776,247.33306 L 496.23653,253.05224" id="NE" style="fill:#f4d7d7" />
+ <ns0:path d="M 580.12177,205.73586 L 580.18014,207.63744 L 582.51499,208.35053 L 583.44892,209.53902 L 583.91589,211.44061 L 587.88513,215.00608 L 588.58559,217.38307 L 587.88513,220.94855 L 586.01725,224.75171 L 585.3168,227.36639 L 582.98196,229.26798 L 581.11408,229.98108 L 575.74393,231.40726 L 575.04347,233.30885 L 574.34302,235.44814 L 575.04347,236.87433 L 576.91135,238.53822 L 576.67787,242.81678 L 574.80999,244.48067 L 574.10954,246.14456 L 574.10954,248.99694 L 572.24166,249.47233 L 570.60726,250.66083 L 570.37378,252.08702 L 570.60726,254.22631 L 568.85613,256.06846 L 565.4706,252.56242 L 564.30317,250.18543 L 556.3647,250.89852 L 546.32485,251.37392 L 520.40805,252.32472 L 506.63246,252.56242 L 497.05959,252.80011 L 495.9476,252.92617 L 494.25776,247.33306 L 494.02428,240.4398 L 492.38988,236.16123 L 491.68943,230.69417 L 489.35459,226.891 L 488.42065,221.89933 L 485.61883,214.05529 L 484.45141,208.46938 L 483.0505,206.21125 L 481.4161,203.35887 L 483.28398,198.8426 L 484.6849,192.90014 L 481.88308,190.76086 L 481.4161,187.90848 L 482.35005,185.29379 L 484.10119,185.29379 L 495.89216,185.29379 L 546.55834,184.5807 L 565.00364,183.86761 L 569.20636,183.74877 L 569.90681,187.19538 L 572.24166,188.85927 L 572.47514,190.28546 L 570.37378,193.85093 L 570.60726,197.17871 L 573.1756,201.21959 L 575.74393,202.40807 L 578.77923,202.88347 L 580.12177,205.73586 z " id="IA" style="fill:#e9afaf" />
+ <ns0:path d="M 639.20393,481.84625 L 638.01716,483.15227 L 632.73401,483.15227 L 631.24813,482.31188 L 629.10185,481.97572 L 622.16771,483.99266 L 620.35163,483.15227 L 617.71005,487.52229 L 616.58406,488.3312 L 615.43633,485.74444 L 614.2689,481.70357 L 610.76664,478.3758 L 611.93406,470.53175 L 611.23361,469.58096 L 609.36573,469.81866 L 600.96028,470.53175 L 576.2109,471.24485 L 575.74393,469.58096 L 576.44438,461.26152 L 579.94666,454.84366 L 585.3168,445.33573 L 584.38287,443.19645 L 585.55029,443.19645 L 586.25075,439.86868 L 583.91589,437.96709 L 584.14938,436.0655 L 582.04802,431.31154 L 581.75616,425.75534 L 583.15706,422.99209 L 582.74847,418.47583 L 581.34756,415.38574 L 582.74847,413.95956 L 581.34756,411.82028 L 581.81454,409.91869 L 582.74847,403.50083 L 585.78377,400.64846 L 585.08332,398.50917 L 588.81908,393.0421 L 591.62089,392.09132 L 591.62089,389.47664 L 590.92044,388.05044 L 593.72225,382.58339 L 596.52407,381.39489 L 596.63379,377.84737 L 605.49374,377.76685 L 630.09345,375.74991 L 635.69855,375.51222 L 635.7068,382.13688 L 635.8719,399.44893 L 635.04641,431.71994 L 634.88131,446.34274 L 637.68799,465.83981 L 639.20393,481.84625 z " id="MS" style="fill:#e9afaf" />
+ <ns0:path d="M 632.23973,310.19942 L 632.07463,306.16555 L 632.56993,301.45935 L 634.88131,298.43395 L 636.6974,294.40007 L 639.33898,290.03004 L 638.84368,283.97923 L 637.0276,281.12189 L 636.6974,277.76034 L 637.52289,272.04567 L 637.0276,264.81831 L 635.7068,248.17858 L 634.38601,232.21115 L 633.3949,220.02589 L 636.53128,220.95071 L 638.01716,221.95917 L 639.17285,221.62302 L 641.31913,219.60608 L 644.20888,217.92491 L 649.40999,217.75643 L 671.86343,215.40333 L 678.13717,214.73102 L 678.30227,215.90756 L 679.78816,231.20268 L 681.60425,245.48932 L 684.24582,269.86066 L 684.74112,275.7434 L 684.24582,278.09649 L 685.73171,279.60919 L 686.06192,281.62613 L 683.25523,283.64307 L 679.29287,285.49193 L 676.48618,285.82809 L 675.99089,290.53428 L 671.20302,294.56815 L 668.23125,298.26587 L 668.56145,300.61896 L 667.73595,302.80398 L 662.94809,302.80398 L 661.7924,301.1232 L 660.63671,301.96359 L 657.66493,303.64438 L 657.83004,307.17401 L 655.68375,307.67825 L 654.85825,306.5017 L 652.87707,304.82092 L 649.90529,306.33362 L 648.08921,309.69518 L 646.27312,308.85479 L 644.78724,306.83786 L 641.15506,307.34209 L 635.2115,308.35056 L 632.23973,310.19942 z " id="IN" style="fill:#de8787" />
+ <ns0:path d="M 632.07463,310.03134 L 632.07463,306.16555 L 632.56993,301.45935 L 634.88131,298.43395 L 636.6974,294.40007 L 639.33898,290.03004 L 638.84368,283.97923 L 637.0276,281.12189 L 636.6974,277.76034 L 637.52289,272.04567 L 637.0276,264.81831 L 635.7068,248.17858 L 634.38601,232.21115 L 633.56001,220.10992 L 632.23872,219.26993 L 631.41322,216.58068 L 630.09244,212.71489 L 628.44145,210.86603 L 626.95557,208.17679 L 626.71703,202.46993 L 616.60376,203.83427 L 588.81909,205.617 L 579.94666,205.17133 L 580.18014,207.63744 L 582.51499,208.35053 L 583.44892,209.53902 L 583.91589,211.44061 L 587.88513,215.00608 L 588.58559,217.38307 L 587.88513,220.94855 L 586.01725,224.75171 L 585.3168,227.36639 L 582.98196,229.26798 L 581.11408,229.98108 L 575.74393,231.40726 L 575.04347,233.30885 L 574.34302,235.44814 L 575.04347,236.87433 L 576.91135,238.53822 L 576.67787,242.81678 L 574.80999,244.48067 L 574.10954,246.14456 L 574.10954,248.99694 L 572.24166,249.47233 L 570.60726,250.66083 L 570.37378,252.08702 L 570.60726,254.22631 L 568.85613,255.59306 L 567.80545,258.50488 L 568.27242,262.30804 L 570.60726,269.91439 L 578.07878,277.75843 L 583.68241,281.56161 L 583.44892,286.07787 L 584.38287,287.50407 L 590.92044,287.97946 L 593.72225,289.40566 L 593.0218,293.20882 L 590.68696,299.38898 L 589.98649,302.71676 L 592.32134,306.75762 L 598.85891,312.22469 L 603.52862,312.93778 L 605.62997,318.16715 L 607.73133,321.49492 L 606.7974,324.58499 L 608.43179,328.86356 L 610.29967,331.00285 L 613.32836,330.65102 L 613.91377,328.51994 L 616.22516,326.67109 L 618.37144,325.99877 L 621.17811,327.3434 L 624.81029,328.68802 L 625.96598,328.35186 L 626.13108,325.99877 L 624.81029,323.47759 L 625.14049,321.1245 L 627.12167,319.6118 L 629.76325,318.93949 L 631.41424,318.26718 L 630.58875,316.41831 L 629.92835,314.40138 L 631.08404,313.56099 L 632.07463,310.03134 z " id="IL" style="fill:#782121" />
+ <ns0:path d="M 482.35005,129.91009 L 481.88308,121.11525 L 480.0152,113.50891 L 478.14732,99.484714 L 477.68036,89.263683 L 475.81248,85.698205 L 474.17808,80.468846 L 474.17808,69.772421 L 474.87853,65.731548 L 473.01887,60.063466 L 503.79211,60.100136 L 504.1223,51.528162 L 504.7827,51.360084 L 507.09408,51.864318 L 509.07526,52.704708 L 509.90075,58.419357 L 511.38664,64.806318 L 513.03762,66.487097 L 517.99058,66.487097 L 518.32077,67.999799 L 524.75961,68.335954 L 524.75961,70.520967 L 529.71257,70.520967 L 530.04276,69.176344 L 531.19845,67.999799 L 533.50983,67.327487 L 534.83062,68.335954 L 537.80239,68.335954 L 541.76476,71.025201 L 547.21301,73.54637 L 549.68948,74.050604 L 550.18478,73.042136 L 551.67066,72.537902 L 552.16596,75.563305 L 554.80753,76.907928 L 555.30283,76.403695 L 556.62362,76.571773 L 556.62362,78.756786 L 559.26519,79.765253 L 562.40206,79.765253 L 564.05305,78.924863 L 567.35502,75.563305 L 569.99659,75.059071 L 570.82209,76.907928 L 571.31738,78.252552 L 572.30797,78.252552 L 573.29856,77.412162 L 582.37898,77.076006 L 584.19506,80.269487 L 584.85546,80.269487 L 585.58425,79.142165 L 590.11857,78.756786 L 589.49346,81.126733 L 585.47097,83.036786 L 576.02857,87.25913 L 571.15228,89.345695 L 568.01541,92.034941 L 565.53894,95.732656 L 563.22756,99.766526 L 561.41147,100.60692 L 556.78872,105.81733 L 555.46793,105.98541 L 552.00086,109.17889 L 552.70347,109.74365 L 549.82713,112.55812 L 549.59365,115.4105 L 549.59365,124.20534 L 548.42622,125.86923 L 543.05607,129.91009 L 540.72123,136.09025 L 541.18819,136.32794 L 543.75652,138.46723 L 544.45698,141.79501 L 542.5891,145.12278 L 542.5891,149.16365 L 543.05607,156.0569 L 546.09137,159.14699 L 549.59365,159.14699 L 551.46153,162.47476 L 554.96379,162.95015 L 558.93303,168.89261 L 566.17105,173.17118 L 568.27242,176.02356 L 569.20636,183.86761 L 565.00364,183.86761 L 546.55834,184.5807 L 495.89216,185.29379 L 484.10119,185.29379 L 484.6849,178.16284 L 484.45141,146.54897 L 483.98444,143.4589 L 479.78172,139.89342 L 478.61429,137.99183 L 478.61429,136.32794 L 480.71565,134.66406 L 482.11657,133.23787 L 482.35005,129.91009 z " id="MN" style="fill:#d35f5f" />
+ <ns0:path d="M 626.6436,202.64577 L 626.79047,198.26019 L 625.13948,193.55401 L 624.47909,187.16705 L 623.3234,184.64588 L 624.31399,181.4524 L 625.13948,178.42699 L 626.62537,175.73775 L 625.96497,172.20811 L 625.30458,168.5104 L 625.79988,166.66154 L 627.78106,164.14037 L 627.94616,161.28305 L 627.12066,159.93842 L 627.78106,157.24918 L 628.27635,153.88762 L 631.08303,148.00489 L 634.0548,140.94562 L 634.2199,138.59253 L 633.8897,137.58406 L 633.06421,138.08829 L 628.77165,144.64333 L 625.96497,148.84528 L 623.98379,150.69414 L 623.1583,153.04723 L 621.67241,153.88762 L 620.51673,155.90455 L 619.03084,155.5684 L 618.86574,153.71954 L 620.18653,151.19837 L 622.33281,146.32411 L 624.14889,144.64333 L 625.27363,142.26081 L 623.6083,141.31962 L 622.20739,139.89342 L 620.57299,129.197 L 616.83724,128.00851 L 615.43633,125.63153 L 602.59467,122.77914 L 600.02634,121.59066 L 591.62089,119.21367 L 583.21544,118.02518 L 578.9573,112.40581 L 578.41662,113.71699 L 577.26093,113.54892 L 576.60053,112.37237 L 573.79386,111.53198 L 572.63817,111.70006 L 570.82209,112.70853 L 569.8315,112.03621 L 570.49189,110.01928 L 572.47307,106.8258 L 573.62876,105.64925 L 571.64758,104.13655 L 569.5013,104.97694 L 566.52953,106.99388 L 558.935,110.35543 L 555.96322,111.02775 L 552.99145,110.52351 L 551.98885,109.6104 L 549.82713,112.55812 L 549.59365,115.4105 L 549.59365,124.20534 L 548.42622,125.86923 L 543.05607,129.91009 L 540.72123,136.09025 L 541.18819,136.32794 L 543.75652,138.46723 L 544.45698,141.79501 L 542.5891,145.12278 L 542.5891,149.16365 L 543.05607,156.0569 L 546.09137,159.14699 L 549.59365,159.14699 L 551.46153,162.47476 L 554.96379,162.95015 L 558.93303,168.89261 L 566.17105,173.17118 L 568.27242,176.02356 L 569.20636,183.74877 L 569.90681,187.19538 L 572.24166,188.85927 L 572.47514,190.28546 L 570.37378,193.85093 L 570.60726,197.17871 L 573.1756,201.21959 L 575.74393,202.40807 L 578.77923,202.88347 L 580.03422,205.49817 L 589.40281,205.49815 L 616.60376,203.83427 L 626.6436,202.64577 z " id="WI" style="fill:#de8787" />
+ <ns0:path d="M 568.73938,255.89021 L 565.4706,252.56242 L 564.30317,250.18543 L 556.3647,250.89852 L 546.32485,251.37392 L 520.40805,252.32472 L 506.63246,252.56242 L 498.57727,252.68126 L 496.24239,252.80011 L 497.52656,255.41479 L 497.29307,257.79177 L 499.8614,261.83265 L 503.01343,266.11122 L 506.16549,268.96359 L 508.50034,269.20129 L 509.90124,270.15209 L 509.90124,273.24216 L 508.03337,274.90605 L 507.56639,277.28304 L 509.66775,280.84852 L 512.23609,283.93859 L 514.80442,285.84018 L 516.20533,297.96278 L 515.50487,334.68718 L 515.73836,339.55999 L 516.20533,346.69094 L 540.02077,346.21554 L 563.83621,345.50245 L 585.08332,344.55165 L 596.29058,344.07626 L 598.15846,347.16634 L 597.69149,349.54332 L 594.4227,352.3957 L 593.72225,355.48577 L 600.02634,355.96118 L 605.163,355.24808 L 607.26436,348.59252 L 607.11843,342.73921 L 610.06619,341.22388 L 611.46709,339.55999 L 613.56845,338.3715 L 613.80194,335.04372 L 614.73588,333.14213 L 613.33497,330.61659 L 610.29967,331.00285 L 608.43179,328.86356 L 606.7974,324.58499 L 607.73133,321.49492 L 605.62997,318.16715 L 603.52862,312.93778 L 598.85891,312.22469 L 592.32134,306.75762 L 589.98649,302.71676 L 590.68696,299.38898 L 593.0218,293.20882 L 593.72225,289.40566 L 590.92044,287.97946 L 584.38287,287.50407 L 583.44892,286.07787 L 583.68241,281.56161 L 578.07878,277.75843 L 570.60726,269.91439 L 568.27242,262.30804 L 567.80545,258.50488 L 568.73938,255.89021 z " id="MO" style="fill:#de8787" />
+ <ns0:path d="M 604.99844,354.98628 L 600.02634,355.96118 L 593.72225,355.48577 L 594.4227,352.3957 L 597.69149,349.54332 L 598.15846,347.16634 L 596.29058,344.07626 L 585.08332,344.55165 L 563.83621,345.50245 L 540.02077,346.21554 L 516.20533,346.69094 L 517.83972,353.82189 L 517.83971,362.37904 L 519.24063,373.78867 L 519.47412,413.12761 L 521.80896,415.14805 L 524.84426,413.72186 L 527.64608,414.91035 L 528.31735,426.58728 L 550.99455,426.55757 L 570.60726,425.60677 L 581.63941,425.75534 L 583.15706,422.99209 L 582.74847,418.47583 L 581.34756,415.38574 L 582.74847,413.95956 L 581.34756,411.82028 L 581.81454,409.91869 L 582.74847,403.50083 L 585.78377,400.64846 L 585.08332,398.50917 L 588.81908,393.0421 L 591.62089,392.09132 L 591.62089,389.47664 L 590.92044,388.05044 L 593.72225,382.58339 L 596.52407,381.39489 L 596.22104,377.59524 L 598.72469,376.42223 L 599.71528,371.54796 L 598.22939,367.85023 L 602.35687,365.49714 L 602.68706,362.80789 L 604.06449,358.25292 L 604.99844,354.98628 z " id="AR" style="fill:#e9afaf" />
+ <ns0:path d="M 383.76113,331.71594 L 372.84567,331.2406 L 366.27891,330.73549 L 366.54158,330.94347 L 365.98708,342.94722 L 388.2557,344.07626 L 420.00963,345.26475 L 418.60872,369.98537 L 418.14175,388.52584 L 418.37523,390.18973 L 422.81144,393.9929 L 424.9128,395.18139 L 425.61327,394.94369 L 426.31372,392.80441 L 427.71463,394.70599 L 429.81599,394.70599 L 429.81599,393.2798 L 432.6178,394.70599 L 432.15084,398.74687 L 436.35356,398.98457 L 438.92189,400.17306 L 443.12462,400.88615 L 445.69295,402.78774 L 448.02779,400.64846 L 451.53007,401.36155 L 454.0984,404.92703 L 455.03233,404.92703 L 455.03233,407.30401 L 457.36718,408.0171 L 459.70203,405.64012 L 461.5699,406.35321 L 464.13823,406.35321 L 465.07218,408.9679 L 469.97536,410.86948 L 471.37627,410.15638 L 473.24415,405.87781 L 474.41156,405.87781 L 475.57899,408.0171 L 479.78172,408.73019 L 483.51747,410.15638 L 486.55277,411.10718 L 488.42065,410.15638 L 489.1211,407.54171 L 493.55731,407.54171 L 495.65868,408.49249 L 498.46049,406.35321 L 499.62792,406.35321 L 500.32837,408.0171 L 504.53109,408.0171 L 506.16549,405.87781 L 508.03337,406.35321 L 510.13473,408.9679 L 513.40351,410.86948 L 516.6723,411.82028 L 519.47412,413.48417 L 519.24063,373.78867 L 517.83971,362.37904 L 517.83972,353.82189 L 516.20533,346.69094 L 515.73836,339.55999 L 515.50487,334.92488 L 501.9627,335.75681 L 454.56566,335.28143 L 408.56892,333.14212 L 383.76113,331.71594 z " id="OK" style="fill:#e9afaf" />
+ <ns0:path d="M 515.50487,335.04372 L 501.9627,335.75681 L 454.56566,335.28143 L 408.56892,333.14212 L 383.90706,331.83479 L 388.13897,263.37769 L 410.43676,264.20963 L 451.76325,265.63581 L 496.82609,266.11122 L 503.01343,266.11122 L 506.16549,268.96359 L 508.50034,269.20129 L 509.90124,270.15209 L 509.90124,273.24216 L 508.03337,274.90605 L 507.56639,277.28304 L 509.66775,280.84852 L 512.23609,283.93859 L 514.80442,285.84018 L 516.20533,297.96278 L 515.50487,335.04372 z " id="KS" style="fill:#e9afaf" />
+ <ns0:path d="M 616.71945,488.11058 L 615.43633,485.74444 L 614.2689,481.70357 L 610.76664,478.3758 L 611.93406,470.53175 L 611.23361,469.58096 L 609.36573,469.81866 L 600.96028,470.53175 L 576.2109,471.24485 L 575.74393,469.58096 L 576.44438,461.26152 L 579.94666,454.84366 L 585.3168,445.33573 L 584.38287,443.19645 L 585.55029,443.19645 L 586.25075,439.86868 L 583.91589,437.96709 L 584.14938,436.0655 L 582.04802,431.31154 L 581.69779,425.60677 L 570.60726,425.60677 L 550.99455,426.55757 L 528.31735,426.58728 L 528.34653,436.5409 L 529.04699,446.28652 L 529.74745,450.3274 L 532.31578,454.60597 L 533.24971,459.83533 L 537.68592,465.54009 L 537.9194,468.86787 L 538.61987,469.58096 L 537.9194,478.3758 L 534.88411,483.60516 L 536.5185,485.74444 L 535.81804,488.35912 L 535.11759,495.96547 L 533.71668,499.29324 L 533.84174,503.05325 L 538.62788,501.47276 L 546.88281,501.1366 L 557.44911,504.83432 L 564.05305,506.01086 L 567.85031,504.49816 L 571.15228,505.67471 L 574.45425,506.68317 L 575.27974,504.49816 L 571.97778,503.32162 L 569.3362,503.82585 L 566.52953,502.14507 C 566.52953,502.14507 566.69462,500.80045 567.35502,500.63237 C 568.01541,500.46429 570.49189,499.6239 570.49189,499.6239 L 572.30797,501.1366 L 574.12406,500.12814 L 577.42603,500.80045 L 578.91191,503.32162 L 579.24211,505.67471 L 583.86487,506.01086 L 585.68095,507.85972 L 584.85546,509.5405 L 583.53467,510.38089 L 585.18565,512.06167 L 593.77077,515.75938 L 597.40294,514.41476 L 598.39353,511.89359 L 601.03511,511.22128 L 602.85119,509.70858 L 604.17198,510.71704 L 604.99747,513.74245 L 602.68609,514.58284 L 603.34648,515.25515 L 606.81355,513.91053 L 609.12493,510.38089 L 609.95042,509.87666 L 607.80414,509.5405 L 608.62964,507.85972 L 608.46454,506.34702 L 610.61082,505.84279 L 611.76651,504.49816 L 612.4269,505.33855 C 612.4269,505.33855 612.2618,508.53203 613.08729,508.53203 C 613.91279,508.53203 617.37985,509.20434 617.37985,509.20434 L 621.50732,511.22128 L 622.49791,512.73398 L 625.46968,512.73398 L 626.62537,513.74245 L 628.93675,510.54897 L 628.93675,509.03627 L 627.61596,509.03627 L 624.14889,506.17894 L 618.20535,505.33855 L 614.90338,502.98546 L 616.05907,500.12814 L 618.37045,500.46429 L 618.53554,499.79198 L 616.71946,498.78351 L 616.71946,498.27928 L 620.02143,498.27928 L 621.83751,495.0858 L 620.51673,493.06886 L 620.18653,490.21154 L 618.70064,490.37962 L 616.71946,492.56463 L 616.05907,495.25388 L 612.9222,494.58156 L 611.93161,492.73271 L 613.74769,490.71577 L 615.81141,488.86693 L 616.71945,488.11058 z " id="LA" style="fill:#de8787" />
+ <ns0:path d="M 817.62464,258.28441 L 818.55858,256.38283 L 820.84673,258.09426 L 819.7727,260.66139 L 818.09161,259.04505 L 817.62464,258.28441 z " id="path6656" style="fill:#0000ff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+ <ns0:g id="g8180" style="fill:#cccccc">
+ <ns0:path d="M 738.7284,305.32516 L 740.70958,309.02287 L 742.03037,310.36749 L 745.00215,311.2079 L 747.64373,310.53557 L 750.12022,308.18248 C 750.12022,308.18248 751.9363,309.69518 752.59669,309.52711 C 753.25709,309.35903 757.05436,308.51864 757.05436,308.51864 L 758.87045,303.64438 L 761.51202,304.65285 L 764.814,302.13167 L 766.29989,302.46782 L 768.44617,300.45089 L 768.61127,297.59356 L 767.78577,296.24894 L 772.90384,284.9877 L 775.05012,278.09649 L 775.38031,273.55838 L 777.1964,273.3903 L 778.84739,276.24763 L 780.16818,277.08802 L 782.80976,277.08802 L 783.96545,271.37336 L 784.29564,268.17988 L 787.76272,267.84373 L 788.25802,265.49063 L 791.39489,262.96946 L 792.05529,261.28868 L 793.70628,258.43134 L 794.20157,256.24633 L 794.36667,250.69975 L 798.98943,252.5486 L 804.7679,255.7421 L 805.59338,250.44764 L 809.88595,252.5486 L 809.88595,255.57402 L 815.49931,256.91864 L 817.48049,258.26326 L 818.47108,256.24633 L 820.78247,257.92711 L 819.29657,261.28868 L 818.96637,264.146 L 817.15029,266.83525 L 817.15029,269.02027 L 817.81068,270.86913 L 822.98233,272.27864 L 824.90863,273.89525 L 830.19178,274.23141 L 832.83336,276.5845 L 836.13533,277.25681 L 837.45611,278.60143 L 836.96082,283.30762 L 837.95141,284.31608 L 838.11651,286.66917 L 839.43729,288.85419 L 839.2722,290.70305 L 835.97023,289.5265 L 835.97023,290.53497 L 837.95141,292.21575 L 837.95141,293.39229 L 839.43729,294.56884 L 840.75808,296.24962 L 840.92318,298.60271 L 838.6118,300.11541 L 838.942,300.61964 L 841.58358,300.11541 L 844.88554,299.4431 L 846.04123,299.27502 L 850.20356,306.58097 L 845.21707,308.35056 L 833.49506,311.37597 L 813.92151,315.35219 L 792.88078,319.27565 L 775.38031,321.96489 L 759.22415,323.98183 L 751.9363,325.32646 L 747.32856,324.68385 L 745.16725,324.65414 L 742.69078,326.67109 L 734.60093,326.83916 L 722.3505,328.8153 L 712.13922,329.78475 L 714.95419,328.35186 L 720.73264,324.82222 L 724.69501,322.6372 L 724.69501,320.45219 L 726.51109,318.60333 L 731.13386,313.05675 L 735.42643,309.35903 L 738.7284,305.32516 z " id="VA" style="fill:#d35f5f" />
+ <ns0:path d="M 845.69487,293.77543 L 844.35758,290.76684 L 844.75381,282.95122 L 847.26331,278.51396 L 847.13123,274.21116 L 853.07478,271.9253 L 852.41438,274.21116 L 849.24449,278.3795 L 848.92256,286.54808 L 848.17135,289.94326 L 846.33876,293.85948 L 845.69487,293.77543 z " id="path3106" style="fill:#d35f5f" ns1:nodetypes="cccccccccccc" />
+ </ns0:g>
+ <ns0:path d="M 467.38967,586.18345 L 466.81183,578.788 L 464.00514,571.30851 L 463.42729,563.99709 L 464.99573,555.42509 L 468.38027,548.28176 L 471.92989,542.65112 L 475.14933,538.95339 L 475.80972,539.20552 L 470.9393,546.09673 L 466.48163,552.90391 L 464.41788,559.79513 L 464.08769,565.17365 L 464.99573,571.56063 L 467.63732,579.04012 L 468.13261,584.41863 L 468.29771,585.93134 L 467.38967,586.18345 z M 465.65436,588.7052 L 466.64495,587.1925 L 464.82887,584.33517 L 463.83828,577.94821 L 462.0222,570.72086 L 461.1967,568.19969 L 462.0222,563.49351 L 463.17788,559.45964 L 464.49867,554.75346 L 465.98456,549.03881 L 463.67318,547.02188 L 464.66377,545.00494 L 468.79123,544.66878 L 472.7536,538.95414 L 476.22066,538.28182 L 482.16421,534.58411 L 483.98029,533.07141 L 490.41913,529.54177 L 496.19758,527.0206 L 501.64583,523.65904 L 504.4525,521.47403 L 510.23095,515.92746 L 511.55174,515.08707 L 513.69802,513.57437 L 516.33959,511.55743 L 517.33018,509.5405 L 527.40119,504.83432 L 534.17024,502.98546" id="TX_Gulf" style="fill:none;fill-opacity:0.26666697;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 900.52373,145.31564 L 900.85393,143.29871 L 902.00961,139.60099" id="NH_Atlantic" style="fill:#0000ff;fill-opacity:0;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 919.55232,177.09192 L 921.77043,176.37882 L 922.23741,174.59609 L 923.28809,174.71493 L 924.33877,177.09192 L 923.0546,177.56732 L 919.08535,177.68617 L 919.55232,177.09192 z M 909.97943,177.92387 L 912.31427,175.19033 L 913.94868,175.19033 L 915.81656,176.73537 L 913.36497,177.80501 L 911.14686,178.87466 L 909.97943,177.92387 z M 903.66061,177.08236 L 906.63237,175.56967 L 906.13708,173.21658 L 906.96257,171.70388 L 909.93434,170.19118 L 910.75983,173.38466 L 910.26454,175.23351 L 907.78806,176.74621 L 907.78806,177.75468 L 909.76924,176.24198 L 913.73161,171.5358 L 917.69397,169.51886 L 921.98653,168.00616 L 921.65633,165.48499 L 920.66574,162.45959 L 918.68456,159.93842 L 916.86848,159.09803 L 914.7222,159.26611 L 914.2269,159.77034 L 915.21749,161.11497 L 916.70338,160.27458 L 918.84966,161.95536 L 919.67515,164.81268 L 917.85907,166.66154 L 915.54769,167.67001 L 911.91552,167.16577 L 907.95316,160.94689 L 905.64178,158.25764 L 903.8257,158.25764 L 902.67001,159.09803 L 900.68883,156.40879 L 901.01902,154.89608 L 903.4955,149.51759 L 900.35862,144.81139" id="MA_Atlantic" style="fill:#0000ff;fill-opacity:0;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 771.67854,458.60093 L 771.08653,452.05785 L 773.39791,441.63702 L 774.88379,437.26699 L 774.3885,434.57775 L 778.51596,427.3504 L 777.85557,425.66962" id="GA_Atlantic" style="fill:#6666e6;fill-opacity:0;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 777.85557,425.66962 L 780.00185,424.32499 L 785.1199,418.61034 L 784.12931,415.24879 L 787.10108,415.08071 L 790.73325,411.55107 L 792.38423,410.71068 L 794.69561,407.18104 L 797.50228,404.32372 L 799.64856,400.62601 L 802.12504,399.95369 L 803.28073,397.09637 L 804.93171,396.25598 L 805.42701,389.70094 L 808.06859,383.31398 L 813.51684,377.43125" id="SC_Atlantic" style="fill:#0000ff;fill-opacity:0;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 813.18663,377.59933 L 819.46038,375.41432 L 824.24824,374.91008 L 824.74353,372.38892 L 826.72471,365.6658 L 830.19178,360.79154 L 836.79572,355.24497 L 842.07887,352.7238 L 844.88554,352.05149 L 846.04123,352.55572 L 847.36202,352.55572 L 850.49889,347.51338 L 852.48007,343.81567 L 851.15929,344.3199 L 848.84791,346.67299 L 848.18751,344.99221 L 843.89495,344.99221 L 845.87614,338.43717 L 845.05064,337.09255 L 843.06946,337.09255 L 843.06946,336.08408 L 842.73926,334.73946 L 844.39025,336.08408 L 845.87614,336.25216 L 848.35261,336.58832 L 852.14988,334.90754 L 853.47066,331.88214 L 854.13106,329.69712 L 856.77263,328.3525 L 857.10283,323.98247 L 856.27734,323.31016 L 858.75382,323.14208 L 858.09342,320.78899 L 855.61694,318.26782 L 851.98478,311.54471 L 850.1687,306.50237 M 854.21672,340.95692 L 856.85831,338.3517 L 860.07773,335.66244 L 861.64617,334.99013 L 861.81127,332.88915 L 861.15088,326.50217 L 859.66499,324.06503 L 859.00459,322.13213 L 859.74753,321.88001 L 862.55422,327.59468 L 862.96697,332.21684 L 862.80187,335.74649 L 859.33479,337.34323 L 856.44555,339.86441 L 855.28987,341.125 L 854.21672,340.95692 z " id="NC_Atlantic" style="fill:#0000ff;fill-opacity:0;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 850.1687,306.50237 L 846.04123,299.27502 L 844.88554,299.4431 L 841.58358,300.11541 L 838.942,300.61964 L 838.6118,300.11541 L 840.92318,298.60271 L 840.75808,296.24962 L 839.43729,294.56884 L 837.95141,293.39229 L 837.95141,292.21575 L 835.97023,290.53497 L 835.97023,289.5265 L 839.2722,290.70305 L 839.43729,288.85419 L 838.11651,286.66917 L 837.95141,284.31608 L 836.96082,283.30762 L 837.45611,278.60143 L 836.13533,277.25681 L 832.83336,276.5845 L 830.19178,274.23141 L 824.90863,273.89525 L 822.59725,272.21447" id="VA_Atlantic" style="fill:#0000ff;fill-opacity:0;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 893.09433,183.30123 L 896.06607,182.29279 L 898.54255,180.27585 L 899.69824,178.42699 L 901.01902,178.59507 L 903.99082,176.91428" id="RI_Atlantic" style="fill:#0000ff;fill-opacity:0;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 865.2752,198.17615 L 870.47581,194.73055 L 874.10797,191.36899 L 876.08916,189.18398 L 876.91465,189.85629 L 879.72132,188.34359 L 885.00447,187.16705 L 893.58963,183.30123" id="CT_Atlantic" style="fill:#0000ff;fill-opacity:0;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 840.75808,236.41389 L 841.91377,238.76697 L 845.21574,241.79237 L 850.1687,244.14546 L 854.29616,244.81777 L 854.46126,246.33047 L 853.63576,247.33894 L 853.96596,250.19627 L 854.79145,250.19627 L 856.93773,247.6751 L 857.76322,242.63276 L 860.5699,238.43081 L 863.70677,231.70769 L 864.86246,225.99305 L 864.20207,224.8165 L 864.03697,215.06798 L 862.38598,211.53834 L 861.23029,212.37873 L 858.42362,212.71489 L 857.92832,212.21066 L 859.08401,211.20219 L 861.23029,209.18525 L 861.06519,207.84063" id="NJ_Atlantic" style="fill:#0000ff;fill-opacity:0;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 861.06519,207.84063 L 863.04638,208.51294 L 867.17384,207.3364 L 873.11738,205.31946 L 875.75896,204.31099 L 883.02329,198.76442 L 886.98565,195.73902 L 890.45272,192.0413 L 886.16016,190.36053 L 884.83937,191.87323 L 881.8676,194.73055 L 873.77778,198.76442 L 871.4664,198.59634 L 869.81541,197.92403 L 868.65972,198.59634 L 866.34835,201.28559 L 864.86246,202.63021 L 863.54167,202.96637 L 863.21147,201.62175 L 865.19266,199.77289 L 865.52285,197.75595" id="NY_Atlantic" style="fill:#0000ff;fill-opacity:0;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 854.95655,260.95325 L 852.64517,253.38975 L 851.65458,253.89398 L 848.02242,251.37281 L 846.20633,246.49855 L 844.22515,242.80084 L 841.91377,241.79237 L 839.76749,238.09466 L 840.59298,235.90964" id="DE_Atlantic" style="fill:#cccccc;fill-opacity:0.26666697;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 29.464207,149.85375 L 27.813223,154.89608 L 27.648124,162.62767 L 22.364973,174.89736 L 19.228102,177.5866 L 18.897905,178.76315 L 17.081822,179.60354 L 15.595936,183.97356 L 14.770444,187.33512 L 17.577118,191.70515 L 19.228102,196.07518 L 20.383791,199.77289 L 20.053595,206.49601 L 18.237511,209.68949 L 17.577118,215.74029 L 16.586527,219.60608 L 18.40261,223.63995 L 21.209284,228.34614 L 23.520662,233.38847 L 24.84145,237.59042 L 24.511253,240.95198 L 24.181056,241.45621 L 24.181056,243.64123 L 29.959503,250.19627 L 29.464207,252.71743 L 28.803813,255.07053 L 28.14342,257.08746 L 28.308518,265.65943 L 30.454798,269.52523 L 32.43598,272.21447 L 35.242654,272.71871 L 36.233244,275.57603 L 35.077555,279.27375 L 32.931275,280.95453 L 31.775586,280.95453 L 30.950093,284.9884 L 31.445389,288.0138 L 34.747358,292.5519 L 36.398343,298.09847 L 37.884229,302.97273 L 39.205017,306.16621 L 42.672085,312.21702 L 44.157971,314.90627 L 44.653266,317.93167 L 46.304251,318.94014 L 46.304251,321.4613 L 45.478759,323.47824 L 43.662676,330.87367 L 43.16738,332.8906 L 45.643857,335.74793 L 49.936417,336.25216 L 54.559175,338.10102 L 58.521538,340.28603 L 61.49331,340.28603 L 64.465083,343.47951 L 67.106658,348.52185 L 68.262347,350.87494 L 72.224711,353.05995 L 77.177665,353.90034 L 78.663551,356.08536 L 79.323945,359.44692 L 77.838059,360.11923 L 78.168256,361.12769 L 81.470225,361.96808 L 84.276899,362.13616 L 87.248671,367.01042 L 91.211035,371.38045 L 92.036527,373.73354 L 94.678102,378.10356 L 95.008299,381.46512 L 95.008299,391.21364 L 95.503595,393.0625 M 50.266945,346.75563 L 51.587737,348.35237 L 51.422639,349.697 L 48.120658,349.61296 L 47.542811,348.35237 L 46.882415,346.83966 L 50.266945,346.75563 z M 52.248133,346.75563 L 53.486376,346.08332 L 57.118555,348.26833 L 60.255437,349.52892 L 59.347393,350.20124 L 54.724619,349.94912 L 53.073629,348.26833 L 52.248133,346.75563 z M 73.380812,367.34524 L 75.196895,369.78238 L 76.022398,370.79086 L 77.590839,371.37912 L 78.168678,369.86642 L 77.178087,368.01756 L 74.453957,365.91658 L 73.380812,366.08465 L 73.380812,367.34524 z M 71.894915,376.33744 L 73.711009,379.61497 L 74.949253,381.63192 L 73.463356,381.88403 L 72.142568,380.62344 C 72.142568,380.62344 71.39962,379.11074 71.39962,378.69054 C 71.39962,378.27035 71.39962,376.42148 71.39962,376.42148 L 71.894915,376.33744 z " id="CA_Pacific" style="fill:none;fill-opacity:0.26666697;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 95.99889,2.9536428 L 94.843201,3.7940324 L 94.513004,4.634422 L 96.989481,9.6767597 L 98.14517,12.366006 L 96.329087,16.063721 L 96.329087,18.416812 L 96.989481,19.929513 L 96.163989,21.77837 L 96.659284,25.139928 L 97.649875,26.484552 L 97.484776,27.829175 L 95.99889,27.997253 L 95.338496,25.980318 L 94.182807,23.459149 L 92.366724,21.946448 L 92.696921,19.761435 L 95.008299,19.257201 L 94.678102,17.408344 L 94.347905,16.231799 L 92.201625,17.576422 L 90.880838,18.752967 L 90.880838,21.274136 L 88.569459,21.442214 L 85.102391,20.433747 L 82.130619,18.921045 L 78.993748,18.248734 L 74.370991,16.063721 L 71.069021,14.046786 L 68.262347,11.357539 L 65.78587,8.3321363 L 63.63959,7.8279025 L 61.328212,17.576422 L 63.144295,20.93798 L 63.144295,29.173799 L 62.483901,32.199201 L 63.969787,39.59463 L 66.776461,42.451954 L 62.318803,43.124266 L 62.153704,46.990058 L 64.79528,48.166604 L 63.144295,52.368552 L 60.337621,52.704708 L 60.007424,55.73011 L 62.318803,58.755513 L 64.134886,57.747045 L 66.446264,59.427825 M 86.341089,9.169955 L 88.404826,9.001877 L 88.900121,10.430545 L 90.468562,8.7497548 L 92.862495,8.7497548 L 93.687987,10.3465 L 92.119546,12.111324 L 92.779951,12.951724 L 92.037002,15.052704 L 90.63366,15.472893 C 90.63366,15.472893 89.725613,15.556938 89.725613,15.220782 C 89.725613,14.884626 91.21151,12.531524 91.21151,12.531524 L 89.477971,11.943246 L 89.147774,13.455958 L 88.404826,14.12827 L 86.836385,11.775168 L 86.341089,9.169955 z " id="WA_Pacific" style="fill:none;fill-opacity:0.26666697;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 66.446264,59.427825 L 60.172522,61.44476 L 57.861144,68.167876 L 54.559175,78.588708 L 51.257205,85.311824 L 46.139153,99.934604 L 39.535214,114.05315 L 31.28029,127.16323 L 29.299109,130.18863 L 28.473616,139.09676 L 27.152829,145.31564 L 29.464207,149.85375" id="OR_Pacific" style="fill:none;fill-opacity:0.26666697;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 639.33795,481.63956 L 642.14462,481.63956 L 642.80502,481.80764 L 644.12581,478.95032 L 645.61169,474.41221 L 647.92307,475.08453 L 651.05994,481.30341 L 651.05994,482.31188 L 648.25327,484.32881 L 651.05994,484.66497 L 658.02586,481.83647" id="AL_Gulf" style="fill:none;fill-opacity:0.26666697;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 616.38926,488.36268 L 617.71005,487.52229 L 620.35163,483.15227 L 622.16771,483.99266 L 629.10185,481.97572 L 631.24813,482.31188 L 632.73401,483.15227 L 638.01716,483.15227 L 639.33795,481.63956" id="MS_Gulf" style="fill:none;fill-opacity:0.26666697;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:path d="M 533.67494,503.06949 L 538.62788,501.47276 L 546.88281,501.1366 L 557.44911,504.83432 L 564.05305,506.01086 L 567.85031,504.49816 L 571.15228,505.67471 L 574.45425,506.68317 L 575.27974,504.49816 L 571.97778,503.32162 L 569.3362,503.82585 L 566.52953,502.14507 C 566.52953,502.14507 566.69462,500.80045 567.35502,500.63237 C 568.01541,500.46429 570.49189,499.6239 570.49189,499.6239 L 572.30797,501.1366 L 574.12406,500.12814 L 577.42603,500.80045 L 578.91191,503.32162 L 579.24211,505.67471 L 583.86487,506.01086 L 585.68095,507.85972 L 584.85546,509.5405 L 583.53467,510.38089 L 585.18565,512.06167 L 593.77077,515.75938 L 597.40294,514.41476 L 598.39353,511.89359 L 601.03511,511.22128 L 602.85119,509.70858 L 604.17198,510.71704 L 604.99747,513.74245 L 602.68609,514.58284 L 603.34648,515.25515 L 606.81355,513.91053 L 609.12493,510.38089 L 609.95042,509.87666 L 607.80414,509.5405 L 608.62964,507.85972 L 608.46454,506.34702 L 610.61082,505.84279 L 611.76651,504.49816 L 612.4269,505.33855 C 612.4269,505.33855 612.2618,508.53203 613.08729,508.53203 C 613.91279,508.53203 617.37985,509.20434 617.37985,509.20434 L 621.50732,511.22128 L 622.49791,512.73398 L 625.46968,512.73398 L 626.62537,513.74245 L 628.93675,510.54897 L 628.93675,509.03627 L 627.61596,509.03627 L 624.14889,506.17894 L 618.20535,505.33855 L 614.90338,502.98546 L 616.05907,500.12814 L 618.37045,500.46429 L 618.53554,499.79198 L 616.71946,498.78351 L 616.71946,498.27928 L 620.02143,498.27928 L 621.83751,495.0858 L 620.51673,493.06886 L 620.18653,490.21154 L 618.70064,490.37962 L 616.71946,492.56463 L 616.05907,495.25388 L 612.9222,494.58156 L 611.93161,492.73271 L 613.74769,490.71577 L 616.38926,488.36268 L 617.2973,487.77441" id="LA_Gulf" style="fill:none;fill-opacity:0.26666697;stroke:#80b0f0;stroke-width:1.06612186pt" />
+ <ns0:g id="Great_Lakes_borders" style="fill:none;stroke:#80b0f0" transform="matrix(1.0566302,0,0,1.0756987,-45.325399,-166.80506)">
+ <ns0:path d="M 652.1875,357.8125 L 649.84375,359.21875 L 647.8125,361.09375 L 646.71875,361.40625 L 645.3125,360.46875 L 642.18749,359.53125" id="IN_Great_Lakes" style="fill:none;stroke:#80b0f0;stroke-width:1pt" />
+ <ns0:path d="M 712.53906,338.43751 L 712.8125,334.6875 L 714.29687,331.75782 L 715.50782,330.39062 M 717.85156,323.16407 L 714.6875,315 L 712.5,306.25 L 710.15625,303.125 L 707.65625,301.40625 L 706.09375,302.5 L 702.34375,304.21875 L 700.46875,309.0625 L 697.8125,312.65625 L 696.71875,313.28125 L 695.3125,312.65625 C 695.3125,312.65625 692.8125,311.25 692.96875,310.625 C 693.125,310 693.4375,305.78125 693.4375,305.78125 L 696.71875,304.53125 L 697.5,301.25 L 698.125,298.75 L 700.46875,297.1875 L 700.15625,287.5 L 698.59375,285.3125 L 697.34375,284.53125 L 696.5625,282.5 L 697.34375,281.71875 L 698.90625,282.03125 L 699.0625,280.46875 L 696.71875,278.28125 L 695.46875,275.78125 L 692.96875,275.78125 L 688.59375,274.375 L 683.28125,271.09375 L 680.625,271.09375 L 680,271.71875 L 679.0625,271.25 L 676.09375,269.0625 L 673.28125,270.78125 L 670.46875,272.96875 L 670.78125,276.40625 L 671.71875,276.71875 L 673.75,277.1875 L 674.21875,277.96875 L 671.71875,278.75 L 669.21875,279.0625 L 667.8125,280.78125 L 667.5,282.8125 L 667.8125,284.375 L 668.125,289.6875 L 664.6875,291.71875 L 664.0625,291.5625 L 664.0625,287.5 L 665.3125,285.15625 L 665.9375,282.8125 L 665.15625,282.03125 L 663.28125,282.8125 L 662.34375,286.875 L 659.6875,287.96875 L 657.96875,289.84375 L 657.8125,290.78125 L 658.4375,291.5625 L 657.8125,294.0625 L 655.625,294.53125 L 655.625,295.625 L 656.40625,297.96875 L 655.3125,303.90625 L 653.75,307.8125 L 654.375,312.34375 L 654.84375,313.4375 L 654.0625,315.78125 L 653.75,316.5625 L 653.4375,319.21875 L 656.875,325 L 659.6875,331.25 L 661.09375,335.9375 L 660.3125,340.46875 L 659.375,346.25 L 657.03125,351.25 L 656.71875,353.90625 L 654.84375,356.25 L 652.1875,357.8125 M 605.4621,230.97629 L 607.22987,228.98755 L 609.3291,228.21415 L 614.52193,224.45763 L 616.73164,223.9052 L 617.17359,224.34715 L 612.20173,229.31901 L 608.99764,231.19726 L 607.0089,232.08115 L 605.4621,230.97629 z M 634.68749,287.50003 L 638.28125,279.6875 L 639.21875,275.78125 L 641.09375,271.5625 L 641.875,271.40625 L 642.96875,272.96875 L 643.59375,272.96875 L 647.96875,270.625 L 649.375,272.1875 L 649.84375,272.34375 L 651.09375,271.25 L 652.1875,268.28125 L 654.53125,267.5 L 661.25,266.875 L 663.125,264.375 L 668.125,264.21875 L 673.75,265.46875 L 675.46875,265.46875 L 678.59375,264.0625 L 680.78125,264.21875 L 682.8125,263.59375 L 686.40625,264.0625 L 687.1875,264.375 L 688.4375,264.0625 L 687.1875,263.125 L 685.9375,262.5 L 682.8125,259.53125 L 682.8125,252.8125 L 681.40625,252.34375 L 680.3125,253.4375 L 674.375,255 L 672.5,255.46875 L 669.6875,254.6875 L 669.21875,254.375 L 669.21875,248.90625 L 667.8125,248.75 L 665.3125,250 L 660.9375,251.875 L 654.53125,252.1875 L 651.25,253.28125 L 647.34375,256.71875 L 645.78125,257.65625 L 644.6875,257.65625 L 643.4375,258.4375 L 641.875,257.96875 L 640.3125,256.71875 L 638.90625,257.65625 L 635.15625,257.8125 L 632.5,255.15625 L 631.09375,252.1875 L 629.6875,251.09375 L 626.5625,250.15625 L 624.375,250.15625 L 623.125,248.90625 L 619.6875,251.71875 L 618.75,252.8125 L 617.96875,252.34375 L 618.28125,249.84375 L 620.625,246.71875 L 621.09375,244.375 L 623.28125,243.59375 L 624.6875,240.625 L 628.28125,239.6875 L 628.59375,238.75 L 627.5,237.65625 L 622.96875,238.125 L 618.75,240.46875 L 616.5625,242.65625 L 615.3125,244.375 L 613.59375,245.15625 L 611.71875,247.96875 L 611.5625,249.21875 L 607.34375,251.25 L 605,253.125 L 599.21875,254.0625 L 598.59375,254.6875 L 598.59375,255.625 L 595.15625,257.8125 L 592.5,258.59375 L 590.9375,259.53125 M 688.75238,262.0292 L 689.37738,264.45108 L 692.50239,264.60733 L 693.7524,263.43545 C 693.7524,263.43545 693.67427,262.0292 693.36177,261.87295 C 693.04927,261.7167 691.79927,260.07607 691.79927,260.07607 L 689.68989,260.31044 L 688.12738,260.46669 L 687.81488,261.56045 L 688.75238,262.0292 z M 707.34375,352.8125 L 706.09375,351.5625 L 706.25,350.15625 L 708.28125,346.5625 L 710.42969,344.60937" id="MI_Great_Lakes" style="fill:none;stroke:#80b0f0;stroke-width:1pt" />
+ <ns0:path d="M 762.03126,331.40624 L 756.5625,336.875 L 755.3125,337.34375 L 751.25001,340.46875" id="PA_Great_Lakes" style="fill:none;stroke:#80b0f0;stroke-width:1pt" />
+ <ns0:path d="M 773.4375,318.28125 L 773.59375,319.21875 L 772.5,320.15625 L 770.46875,322.8125 L 770,324.375 L 768.125,326.09375 L 766.40625,327.1875 L 765.46875,328.75 L 764.21875,329.84375 L 761.56251,331.71874 M 823.75,260.46875 L 821.25,262.34375 L 819.21875,264.6875 L 816.5625,268.28125 L 813.75,272.65625 L 812.34375,275.46875 L 811.71875,276.25 L 806.09375,281.5625 L 806.25,284.0625 L 807.03125,285.15625 L 808.75,285.9375 L 810.46875,285.9375 L 810.46875,287.34375 L 809.375,289.375 L 809.6875,290.78125 L 811.09375,292.8125 L 810.9375,295 L 809.0625,296.09375 L 807.03125,296.09375 L 805.46875,297.96875 L 803.75,301.09375 L 801.71875,302.8125 L 796.71875,303.28125 L 794.21875,304.375 L 792.1875,305.625 L 790.625,305.46875 L 788.75,304.21875 L 782.65625,304.375 L 779.53125,304.84375 L 775.625,306.09375 L 771.40625,307.5 L 768.59375,309.21875" id="NY_Great_Lakes" style="fill:none;stroke:#80b0f0;stroke-width:1pt" />
+ <ns0:path d="M 751.40626,340.46875 L 744.375,344.0625 L 740.625,346.25 L 737.34375,349.84375 L 733.4375,353.59375 L 730.3125,354.375 L 727.5,354.84375 L 722.1875,357.34375 L 720.15625,357.5 L 716.875,354.53125 L 711.875,355.15625 L 709.375,353.75 L 706.71874,352.34374" id="OH_Great_Lakes" style="fill:none;stroke:#80b0f0;stroke-width:1pt" />
+ <ns0:path d="M 642.5,359.6875 L 641.25,358.90625 L 640.46875,356.40625 L 639.21875,352.8125 L 637.65625,351.09375 L 636.25,348.59375 L 636.09375,343.12504" id="IL_Great_Lakes" style="fill:none;stroke:#80b0f0;stroke-width:1pt" />
+ <ns0:path d="M 590.9375,259.53125 L 590.3125,260.78125 L 589.21875,260.625 L 588.59375,259.53125 L 585.9375,258.75 L 584.84375,258.90625 L 583.125,259.84375 L 582.1875,259.21875 L 582.8125,257.34375 L 584.6875,254.375 L 585.78125,253.28125 L 583.90625,251.875 L 581.875,252.65625 L 579.0625,254.53125 L 571.875,257.65625 L 569.0625,258.28125 L 566.25,257.8125 L 565.46875,256.875 M 636.25,343.43744 L 636.09375,339.375 L 634.53125,335 L 633.90625,329.0625 L 632.8125,326.71875 L 633.75,323.75 L 634.53125,320.9375 L 635.9375,318.4375 L 635.3125,315.15625 L 634.6875,311.71875 L 635.15625,310 L 637.03125,307.65625 L 637.1875,305 L 636.40625,303.75 L 637.03125,301.25 L 637.5,298.125 L 640.15625,292.65625 L 642.96875,286.09375 L 643.125,283.90625 L 642.8125,282.96875 L 642.03125,283.4375 L 637.96875,289.53125 L 635.3125,293.4375 L 633.4375,295.15625 L 632.65625,297.34375 L 631.25,298.125 L 630.15625,300 L 628.75,299.6875 L 628.59375,297.96875 L 629.84375,295.625 L 631.875,291.09375 L 633.59375,289.53125 L 634.68749,287.03127" id="WI_Great_Lakes" style="fill:none;stroke:#80b0f0;stroke-width:1pt" />
+ <ns0:path d="M 565.9375,257.34374 L 565.3125,256.5625 L 568.59375,253.59375 L 569.84375,253.4375 L 574.21875,248.59375 L 575.9375,247.8125 L 578.125,244.0625 L 580.46875,240.625 L 583.4375,238.125 L 587.5,236.40625 L 595,232.8125 L 597.8125,232.03125 C 597.8125,232.03125 600.78125,230.3125 601.09375,229.6875 C 601.40625,229.0625 601.71875,228.28125 601.71875,228.28125" id="MN_Great_Lakes" style="fill:none;stroke:#80b0f0;stroke-width:1pt" />
+ </ns0:g>
+ <ns0:path d="M 152.15345,458.16063 L 151.84095,540.66102 L 153.40345,541.59852 L 156.37222,541.75477 L 157.77847,540.66102 L 160.27848,540.66102 L 160.43474,543.47353 L 167.15352,550.03606 L 167.62227,552.53607 L 170.90353,550.66106 L 171.52854,550.50481 L 171.84104,547.53605 L 173.24729,545.97354 L 174.34105,545.81729 L 176.21606,544.41103 L 179.18482,546.44229 L 179.80983,549.25481 L 181.68483,550.34856 L 182.77859,552.69232 L 186.52861,554.41108 L 189.80987,560.19236 L 192.46613,563.94237 L 194.65364,566.59864 L 196.0599,570.1924 L 200.90367,571.91116 L 205.9037,573.94242 L 206.8412,578.16119 L 207.30995,581.12995 L 206.37245,584.41122 L 204.65369,586.59873 L 203.09118,585.81748 L 201.68493,582.84871 L 199.02866,581.44246 L 197.30991,580.3487 L 196.52865,581.12995 L 197.93491,583.78622 L 198.09116,587.37998 L 196.9974,587.84873 L 195.1224,585.97373 L 193.09114,584.72372 L 193.55989,586.28623 L 194.80989,588.00499 L 194.02864,588.78624 C 194.02864,588.78624 193.24739,588.47374 192.77864,587.84873 C 192.30988,587.22373 190.74738,584.56747 190.74738,584.56747 L 189.80987,582.37996 C 189.80987,582.37996 189.49737,583.62997 188.87237,583.31746 C 188.24736,583.00496 187.62236,581.91121 187.62236,581.91121 L 189.34112,580.0362 L 187.93486,578.62994 L 187.93486,573.78617 L 187.15361,573.78617 L 186.37236,577.06743 L 185.2786,577.53619 L 184.3411,573.94242 L 183.71609,570.34865 L 182.93484,569.8799 L 183.24734,575.34868 L 183.24734,576.44243 L 181.84108,575.19243 L 178.40357,569.41115 L 176.37231,568.9424 L 175.74731,565.34863 L 174.1848,562.53612 L 172.62229,561.44236 L 172.62229,559.25485 L 174.65355,558.00485 L 174.1848,557.69235 L 171.68479,558.31735 L 168.40352,555.97359 L 165.90351,553.16107 L 161.21599,550.66106 L 157.30972,548.16105 L 158.55973,545.03604 L 158.55973,543.47353 L 156.84097,545.03604 L 154.02846,546.12979 L 150.43469,545.03604 L 144.96591,542.69228 L 139.65339,542.69228 L 139.02839,543.16103 L 132.77836,539.41101 L 130.7471,539.09851 L 128.09084,533.47348 L 124.65332,533.78598 L 121.2158,535.19224 L 121.68456,539.56726 L 122.77831,536.75475 L 123.71582,537.06725 L 122.30956,541.28602 L 125.43457,538.62976 L 126.05958,540.19226 L 122.30956,544.41103 L 121.05955,544.09853 L 120.5908,542.22352 L 119.3408,541.44227 L 118.09079,542.53603 L 115.43453,540.81727 L 112.46576,542.84853 L 110.74701,544.87979 L 107.46574,546.91105 L 102.93447,546.75479 L 102.46572,544.72354 L 106.05948,544.09853 L 106.05948,542.84853 L 103.87197,542.22352 L 104.80948,539.87976 L 106.99699,536.12975 L 106.99699,534.41099 L 107.15324,533.62973 L 111.37201,531.44222 L 112.30951,532.69223 L 114.96578,532.69223 L 113.71577,530.19222 L 110.122,529.87972 L 105.27823,532.53598 L 102.93447,535.81724 L 101.21571,538.31726 L 100.12196,540.50477 L 96.059441,541.91102 L 93.090671,544.41103 L 92.778171,545.97354 L 94.965681,546.91105 L 95.746941,548.9423 L 93.090671,552.06732 L 86.840651,556.12984 L 79.340608,560.19236 L 77.309348,561.28611 L 72.153076,562.37987 L 66.996796,564.56738 L 68.715556,565.81738 L 67.309296,567.22364 L 66.840546,568.31739 L 64.184286,567.37989 L 61.059276,567.53614 L 60.278016,569.72365 L 59.340516,569.72365 L 59.653016,567.37989 L 56.215496,568.6299 L 53.402986,569.5674 L 50.121721,568.31739 L 47.309208,570.1924 L 44.184194,570.1924 L 42.152934,571.44241 L 40.590427,572.22366 L 38.559168,571.91116 L 36.059156,570.81741 L 33.871646,571.44241 L 32.934142,572.37991 L 31.371634,571.28616 L 31.371634,569.41115 L 34.340398,568.16114 L 40.434176,568.78615 L 44.652946,567.22364 L 46.684205,565.19238 L 49.496718,564.56738 L 51.215476,563.78612 L 53.871739,563.94237 L 55.434246,565.19238 L 56.371746,564.87988 L 58.559256,562.22362 L 61.528026,561.28611 L 64.809286,560.66111 L 66.059296,560.34861 L 66.684296,560.81736 L 67.465556,560.81736 L 68.715556,557.22359 L 72.621826,555.81734 L 74.496836,552.22357 L 76.684348,547.84855 L 78.246858,546.44229 L 78.559358,543.94228 L 76.996848,545.19229 L 73.715576,545.81729 L 73.090576,543.47353 L 71.840576,543.16103 L 70.903066,544.09853 L 70.746816,546.91105 L 69.340556,546.75479 L 67.934306,541.12977 L 66.684296,542.37977 L 65.590546,541.91102 L 65.278046,540.03601 L 61.371776,540.19226 L 59.340516,541.28602 L 56.840506,540.97352 L 58.246756,539.56726 L 58.715506,537.06725 L 58.090506,535.19224 L 59.496766,534.25474 L 60.746766,534.09849 L 60.121766,532.37973 L 60.121766,528.16096 L 59.184266,527.22345 L 58.403006,528.62971 L 52.465482,528.62971 L 51.059226,527.3797 L 50.434223,523.62969 L 48.402963,520.19217 L 48.402963,519.25467 L 50.434223,518.47341 L 50.590473,516.44215 L 51.684228,515.3484 L 50.902975,514.87965 L 49.652969,515.3484 L 48.559214,512.69214 L 49.496718,507.84836 L 53.871739,504.72335 L 56.371746,503.16084 L 58.246756,499.56708 L 60.903026,498.31707 L 63.403036,499.41083 L 63.715536,501.75459 L 66.059296,501.44208 L 69.184306,499.09832 L 70.746816,499.72333 L 71.684316,500.34833 L 73.246826,500.34833 L 75.434338,499.09832 L 76.215598,494.87955 C 76.215598,494.87955 76.528098,492.06704 77.153098,491.59829 C 77.778098,491.12954 78.090598,490.66079 78.090598,490.66079 L 76.996848,488.78578 L 74.496836,489.56703 L 71.371816,490.34828 L 69.496806,489.87953 L 66.059296,488.16077 L 61.215526,488.00452 L 57.778006,484.41076 L 58.246756,480.66074 L 58.871766,478.31698 L 56.840506,476.59822 L 54.965494,473.00445 L 55.434246,472.2232 L 61.996776,471.75445 L 64.028036,471.75445 L 64.965536,472.69195 L 65.590546,472.69195 L 65.434296,471.12944 L 69.184306,470.50444 L 71.684316,470.81694 L 73.090576,471.9107 L 71.684316,473.94196 L 71.215566,475.34821 L 73.871836,476.91072 L 78.715608,478.62948 L 80.434368,477.69198 L 78.246858,473.47321 L 77.309348,470.34819 L 78.246858,469.56694 L 74.965588,467.69193 L 74.496836,466.59817 L 74.965588,465.03567 L 74.184336,461.28565 L 71.371816,456.75438 L 69.028056,452.69186 L 71.840576,450.81685 L 74.965588,450.81685 L 76.684348,451.44185 L 80.746868,451.2856 L 84.340631,447.84809 L 85.434391,444.87932 L 89.028161,442.53556 L 90.590661,443.47307 L 93.246921,442.84806 L 96.840691,440.8168 L 97.934451,440.66055 L 98.871951,441.44181 L 103.24697,441.28556 L 105.90323,438.31679 L 106.99699,438.31679 L 110.4345,440.66055 L 112.30951,442.69181 L 111.84076,443.78557 L 112.46576,444.87932 L 114.02827,443.31682 L 117.77829,443.62932 L 118.09079,447.22308 L 119.9658,448.62934 L 126.84083,449.25434 L 132.93461,453.31686 L 134.34086,452.37936 L 139.34089,454.87937 L 141.37215,454.25437 L 143.24716,453.47311 L 147.93468,455.34812 L 152.15345,458.16063 z M 40.902929,486.12951 L 42.934188,491.28579 L 42.777937,492.22329 L 39.965424,491.91079 L 38.246666,488.00452 L 36.527908,486.59827 L 34.184147,486.59827 L 34.027897,484.09825 L 35.746655,481.75449 L 36.84041,484.09825 L 38.246666,485.50451 L 40.902929,486.12951 z M 38.402917,518.47341 L 41.996684,519.25467 L 45.59045,520.19217 L 46.371704,521.12968 L 44.809197,524.72344 L 41.840433,524.56719 L 38.559168,521.12968 L 38.402917,518.47341 z M 18.402824,504.8796 L 19.49658,507.37961 L 20.590335,508.94212 L 19.49658,509.72337 L 17.46532,506.75461 L 17.46532,504.8796 L 18.402824,504.8796 z M 5.1215129,575.50493 L 8.4027779,573.31742 L 11.684043,572.37991 L 14.184055,572.69241 L 14.652807,574.25492 L 16.527816,574.72367 L 18.402824,572.84867 L 18.090323,571.28616 L 20.746585,570.66116 L 23.559098,573.16117 L 22.465343,574.87992 L 18.246574,575.97368 L 15.590311,575.50493 L 11.996545,574.41117 L 7.7777749,575.81743 L 6.2152679,576.12993 L 5.1215129,575.50493 z M 52.465482,571.12991 L 54.027989,573.00492 L 56.059246,571.44241 L 54.652992,570.1924 L 52.465482,571.12991 z M 55.277995,574.09867 L 56.371746,571.91116 L 58.403006,572.22366 L 57.621756,574.09867 L 55.277995,574.09867 z M 78.090598,572.22366 L 79.496858,573.94242 L 80.434368,572.84867 L 79.653108,570.97366 L 78.090598,572.22366 z M 86.528141,560.19236 L 87.621901,565.81738 L 90.434411,566.59864 L 95.278181,563.78612 L 99.496951,561.28611 L 97.934451,558.94235 L 98.403201,556.59859 L 96.371941,557.8486 L 93.559431,557.06734 L 95.121931,555.97359 L 96.996941,556.75484 L 100.74696,555.03608 L 101.21571,553.62983 L 98.871951,552.84857 L 99.653201,550.97356 L 96.996941,552.84857 L 92.465671,556.28609 L 87.778151,559.0986 L 86.528141,560.19236 z M 127.46583,540.97352 L 129.80959,539.56726 L 128.87209,537.8485 L 127.15333,538.78601 L 127.46583,540.97352 z " id="AK" style="fill:#f4d7d7" />
+ <ns0:g id="g16325" style="stroke:#000000;stroke-opacity:1">
+ <ns0:g id="g5778" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-opacity:1">
+ <ns0:path d="M 816.92419,258.09425 C 817.71859,258.70718 818.14466,259.56702 819.77271,260.56631 L 819.77271,260.61385" id="path6654" style="fill:#000000;fill-opacity:0;fill-rule:evenodd;stroke:#000000;stroke-width:1.33265233;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" ns1:nodetypes="ccc" />
+ <ns0:g id="g4679" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-opacity:1">
+ <ns0:g id="g3580" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-opacity:1">
+ <ns0:g id="State_borders_old" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-opacity:1" transform="matrix(1.0566302,0,0,1.0756987,-45.325399,-166.80506)">
+ <ns0:path d="M 389.29574,462.33445 L 395.75915,462.99736 L 406.8077,463.54979" id="CO_OK" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 389.79293,462.72114 L 389.2405,473.88019" id="NM_OK" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 389.90342,473.82495 L 388.24613,473.82494 L 386.69931,491.94483 L 382.94283,545.64053 L 380.73312,568.62152 L 352.66979,567.07472 L 324.60654,564.42309 L 316.87248,563.76016 L 317.5354,568.62152" id="NM_TX" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 299.63678,367.31684 L 319.96612,369.74752 L 355.98408,373.72497 L 379.62831,375.71374" id="WY_CO" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 379.62831,375.71374 L 411.89008,378.36539 L 410.34328,400.02056" id="NE_CO" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 410.34328,400.02056 L 406.36581,463.66023" id="CO_KS" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 379.62831,375.71374 L 383.82676,332.84535" id="WY_NE" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 383.82676,332.84535 L 385.37355,308.31756 L 386.63467,290.78272" id="WY_SD" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 386.7128,291.15996 L 388.23277,275.63418 L 388.81756,270.31054" id="MT_SD" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 388.81755,270.85742 L 389.59881,259.06782 L 391.78171,234.95517 L 393.10754,220.37107 L 394.43337,206.67087" id="MT_ND" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 389.13006,270.75248 L 443.26797,274.28802 L 499.6156,275.83481" id="ND_SD" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 490.55578,211.09029 L 492.32355,216.17262 L 491.66064,219.92913 L 491.66064,229.87283 L 493.20744,234.73419 L 494.97521,238.04876 L 495.41715,247.55052 L 497.18492,260.58781 L 498.95269,267.65888 L 499.39463,275.83481" id="ND_MN" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 499.39463,275.83481 L 499.17366,278.92841 L 497.84783,280.25424 L 495.85909,281.80103 L 495.85909,283.34783 L 496.96395,285.1156 L 500.94143,288.43017 L 501.38337,291.30279 L 501.60434,320.69194 L 501.1624,327.32107" id="SD_MN" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 501.1624,327.32107 L 499.39463,327.32107 L 498.51074,329.75176 L 498.95269,332.40341 L 501.60434,334.39215 L 500.27851,339.91643 L 498.51074,344.11488 L 500.05754,346.76653 L 501.60434,348.97624" id="SD_IA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 501.60434,348.97624 L 499.83657,348.97624 L 498.0688,348.97624 L 497.18492,346.9875 L 496.74297,345.4407 L 493.87035,343.89391 L 489.00899,342.34711 L 487.24122,341.02128 L 484.58957,341.24225 L 480.83306,341.68419 L 476.63461,343.01002 L 475.52975,342.12614 L 470.88936,339.25351 L 468.90062,337.0438 L 458.51498,337.48574 L 435.53399,336.38089 L 418.29824,335.27603 L 398.85279,334.17118 L 383.82676,333.50826" id="SD_NE" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 500.94143,327.32107 L 512.21095,327.32107 L 560.16167,326.65816 L 577.61839,325.99525 L 581.59587,325.99525" id="MN_IA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 581.59587,325.99525 L 580.71198,318.7032 L 578.72324,316.05155 L 571.87314,312.07407 L 568.11663,306.54979 L 564.80207,306.10785 L 563.0343,303.01426 L 559.71973,303.01426 L 556.84711,300.14163 L 556.40516,293.73347 L 556.40516,289.97696 L 558.17293,286.88337 L 557.51002,283.78977 L 555.07934,281.80103 L 554.6374,281.58006 L 556.84711,275.83481 L 561.92944,272.07831 L 563.0343,270.53151 L 563.0343,262.35558 L 563.25527,259.70393 L 566.01741,256.8313" id="MN_WI" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 590.71092,259.59344 L 594.85413,264.78626 L 602.80909,265.89112 L 610.76405,268.10083 L 613.19473,269.20568 L 625.34814,271.85734 L 626.67397,274.06705 L 630.2095,275.1719 L 631.7563,285.1156 L 633.08213,286.44143 L 634.62893,287.60152" id="WI_MI" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 581.59587,325.77428 L 582.25878,329.08884 L 584.46849,330.63564 L 584.68946,331.96147 L 582.70072,335.27603 L 582.92169,338.36963 L 585.35238,342.12614 L 587.78306,343.23099 L 590.65568,343.67293 L 591.92627,346.3246" id="IA_WI" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 591.76054,345.88265 L 601.04136,346.10361 L 626.453,344.55682 L 635.95475,343.45196" id="WI_IL" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 591.76054,345.71692 L 591.98151,348.09236 L 594.19122,348.75527 L 595.0751,349.86012 L 595.51704,351.62789 L 599.27355,354.94246 L 599.93647,357.15217 L 599.27355,360.46674 L 597.50578,364.00227 L 596.84287,366.43295 L 594.63316,368.20072 L 592.86539,368.86364 L 587.78306,370.18946 L 587.12014,371.95723 L 586.45723,373.94597 L 587.12014,375.2718 L 588.88791,376.8186 L 588.66694,380.79607 L 586.89917,382.34287 L 586.23626,383.88967 L 586.23626,386.54132 L 584.46849,386.98326 L 582.92169,388.08812 L 582.70072,389.41395 L 582.92169,391.40269 L 581.15393,393.05996" id="IA_IL" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 581.3749,393.17045 L 578.06033,389.85589 L 576.95547,387.64618 L 569.44246,388.30909 L 559.9407,388.75103 L 535.41291,389.63492 L 522.37562,389.85589 L 513.31581,390.07686 L 511.98998,390.07686" id="IA_MO" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 512.53686,390.31124 L 510.66415,384.99453 L 510.44318,378.58636 L 508.89638,374.60888 L 508.23347,369.52655 L 506.02376,365.99101 L 505.13988,361.35062 L 502.48822,354.05857 L 501.1624,348.75527" id="NE_IA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 410.12231,399.79959 L 431.33554,400.68347 L 470.44713,402.00929 L 513.09483,402.45124 L 519.06105,402.45124" id="NE_KS" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 519.06105,402.45124 L 515.96746,398.47376 L 513.53678,394.71725 L 513.75775,392.50754 L 512.43192,390.07686" id="NE_MO" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 518.84008,402.45124 L 521.93368,405.10289 L 524.14339,405.32386 L 525.46921,406.20775 L 525.46921,409.08037 L 523.70145,410.62717 L 523.2595,412.83688 L 525.24824,416.15145 L 527.67893,419.02407 L 530.10961,420.79184 L 531.43543,432.06136 L 530.77252,466.53285" id="KS_MO" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 530.77252,466.53285 L 517.95614,467.19576 L 473.09935,466.75383 L 429.56781,464.76507 L 406.08959,463.43925" id="KS_OK" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 388.4119,473.88015 L 410.34328,474.92975 L 440.39535,476.03461 L 439.06952,499.0156 L 438.62758,516.25134 L 438.84855,517.79814 L 443.047,521.33368 L 445.03574,522.43853 L 445.69866,522.21756 L 446.36157,520.22882 L 447.6874,521.99659 L 449.67614,521.99659 L 449.67614,520.67076 L 452.32779,521.99659 L 451.88585,525.7531 L 455.86333,525.97407 L 458.29401,527.07893 L 462.27149,527.74184 L 464.70217,529.50961 L 466.91188,527.52087 L 470.22645,528.18378 L 472.65713,531.49835 L 473.54101,531.49835 L 473.54101,533.70806 L 475.75072,534.37097 L 477.96043,532.16126 L 479.7282,532.82417 L 482.15888,532.82417 L 483.04277,535.25486 L 487.68316,537.02262 L 489.00899,536.35971 L 490.77676,532.38223 L 491.88161,532.38223 L 492.98647,534.37097 L 496.96395,535.03388 L 500.49948,536.35971 L 503.37211,537.2436 L 505.13988,536.35971 L 505.80279,533.92903 L 510.00124,533.92903 L 511.98998,534.81291 L 514.64163,532.82417 L 515.74649,532.82417 L 516.4094,534.37097 L 520.38688,534.37097 L 521.93368,532.38223 L 523.70145,532.82417 L 525.69019,535.25486 L 528.78378,537.02262 L 531.87738,537.90651 L 534.52903,539.45331" id="OK_TX" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 534.52903,539.45331 L 534.30806,502.55125 L 532.98222,491.94454 L 532.98223,483.98957 L 531.43543,477.36043" id="OK_AR" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 531.43543,477.36043 L 530.99349,470.7313 L 530.77252,465.86994" id="OK_MO" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 531.43543,477.36043 L 553.97448,476.91849 L 576.51353,476.25558 L 596.6219,475.37169 L 607.22851,474.92975 L 608.99628,477.80238 L 608.55434,480.01209 L 605.46074,482.66374 L 604.79783,485.53636 L 610.76405,485.97831 L 615.62541,485.31539" id="MO_AR" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 615.62541,485.31539 L 617.61415,479.1282 L 617.61415,473.38295" id="MO_TN" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 617.33793,473.99063 L 620.26581,472.2781 L 621.59163,470.7313 L 623.58037,469.62645 L 623.80134,466.53285 L 624.68523,464.76508 L 623.13842,462.27915" id="MO_KY" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 623.58037,462.55537 L 620.48678,462.77634 L 618.71901,460.7876 L 617.17221,456.81012 L 618.05609,453.9375 L 616.06735,450.84391 L 614.07862,445.98254 L 609.65919,445.31963 L 603.472,440.23729 L 601.26229,436.48079 L 601.92521,433.38719 L 604.13492,427.64194 L 604.79783,424.10641 L 602.14618,422.78058 L 595.95899,422.33864 L 595.0751,421.01281 L 595.29607,416.81436 L 589.99277,413.27882 L 582.92169,405.98678 L 580.71198,398.9157 L 580.27004,395.38017 L 581.3749,392.28657" id="MO_IL" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 534.52903,538.79039 L 536.73874,541.0001 L 539.61136,539.67428 L 542.26302,540.77913 L 542.92593,551.38574" id="TX_AR" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 542.92593,551.38574 L 542.92593,560.8875 L 543.58884,569.94731 L 544.25176,573.70382 L 546.68244,577.6813 L 547.56632,582.54267 L 551.76477,587.84597 L 551.98574,590.93957 L 552.64866,591.60248 L 551.98574,599.77841 L 549.11312,604.63977 L 550.65992,606.62851 L 549.997,609.05919 L 549.33409,616.13027 L 548.00826,619.22386 L 548.28448,622.70416" id="TX_LA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 542.87069,551.88293 L 564.36012,551.60672 L 582.92169,550.72283 L 593.30733,550.72283" id="AR_LA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 593.52831,550.72283 L 593.74928,556.02614 L 595.73802,560.44556 L 595.51704,562.21333 L 597.72676,563.9811 L 597.06384,567.07469 L 595.95899,567.07469 L 596.84287,569.06343 L 591.76054,577.90227 L 588.44597,583.86849 L 587.78306,591.60248 L 588.225,593.14928 L 611.64793,592.48636 L 619.60289,591.82345 L 621.37066,591.60248 L 622.03357,592.48636 L 620.92872,599.77841 L 624.24328,602.872 L 625.34814,606.62851 L 626.61872,609.00395" id="LA_MS" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 593.41781,550.99905 L 594.79888,548.29215 L 594.41219,544.0937 L 593.08636,541.22107 L 594.41219,539.89525 L 593.08636,537.90651 L 593.52831,536.13874 L 594.41219,530.17252 L 597.28481,527.52087 L 596.6219,525.53213 L 600.15744,520.44979 L 602.80909,519.56591 L 602.80909,517.13523 L 602.14618,515.8094 L 604.79783,510.72707 L 607.44948,509.62221 L 607.44948,506.08667" id="AR_MS" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 642.50096,359.68676 L 643.28221,370.93678 L 644.53221,385.78055 L 645.78222,401.24932 L 646.25097,407.96808 L 645.46972,413.28059 L 645.78222,416.40559 L 647.50097,419.06185 L 647.96972,424.68686 L 645.46972,428.74936 L 643.75096,432.49937 L 641.56346,435.31187 L 641.09471,439.68688 L 641.09471,443.28063" id="IL_IN" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 641.09471,443.28063 L 640.15721,446.56189 L 639.06346,447.34314 L 639.68846,449.21814 L 640.46971,450.9369 L 638.90721,451.5619 L 636.4072,452.1869 L 634.5322,453.59315 L 634.2197,455.78065 L 635.4697,458.12441 L 635.31345,460.31191 L 634.2197,460.62441 L 630.78219,459.37441 L 628.12594,458.12441 L 626.09469,458.74941 L 623.90718,460.46816 L 623.12593,462.34316" id="IL_KY" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 641.25096,443.43688 L 644.06346,441.71813 L 649.68847,440.78063 L 653.12598,440.31188 L 654.53223,442.18688 L 656.25098,442.96813 L 657.96973,439.84313 L 660.78224,438.43688 L 662.65724,439.99938 L 663.43849,441.09313 L 665.46975,440.62438 L 665.31349,437.34313 L 668.126,435.78062 L 669.21975,434.99937 L 670.3135,436.56187 L 674.84476,436.56187 L 675.62601,434.53062 L 675.31351,432.34312 L 678.12601,428.90561 L 682.65727,425.15561 L 683.12602,420.7806 L 685.78228,420.4681 L 689.53228,418.74935 L 692.18854,416.87434 L 691.87603,414.99934 L 690.46978,413.59309 L 690.93853,411.40559" id="IN_KY" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 690.93853,411.40559 L 690.46978,405.93683 L 687.96978,383.28054 L 686.25103,369.99927 L 684.84477,355.7805" id="IN_OH" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 684.84477,355.7805 L 684.68852,354.68675 L 678.75102,355.31175 L 657.50098,357.49926 L 652.96973,357.49926" id="IN_MI" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 615.46967,485.07202 L 614.58577,488.10878 L 613.28217,492.34321 L 612.96967,494.84321 L 609.06341,497.03071 L 610.46966,500.46822 L 609.53216,504.99948 L 606.87591,506.09323" id="AR_TN" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 607.65716,506.56198 L 615.93842,506.24948 L 639.21971,504.37448 L 642.96971,504.21823" id="TN_MS" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 644.51654,503.77628 L 644.53221,510.31198 L 644.68846,526.40576 L 643.90721,556.4058 L 643.75096,569.99957 L 646.40722,588.1246 L 647.9524,603.33611" id="MS_AL" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 642.65721,504.21823 L 652.18848,503.74947 L 679.06352,501.24947 L 689.03673,500.46822" id="TN_AL" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 199.86827,240.25868 L 200.31021,231.08839 L 203.84575,215.17847 L 208.15468,195.07011 L 211.8007,182.03283 L 212.5741,178.27632" id="WA_ID" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 105.11516,210.08273 L 107.72336,210.64857 L 108.38627,213.07925 L 112.36375,213.63168 L 114.57346,217.38818 L 114.35249,224.9012 L 113.68958,225.6746 L 119.43482,229.65208 L 125.40104,229.98353 L 127.27929,228.21576 L 131.47774,229.65208 L 132.25114,229.54159 L 137.33348,231.53033 L 138.99076,232.85615 L 143.52066,233.07713 L 144.736,232.41421 L 147.0562,233.96101 L 151.36513,234.51344 L 156.66844,232.5247 L 157.11038,233.85052 L 172.02592,233.74004 L 186.61001,237.16509 L 200.38279,240.66827" id="WA_OR" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 199.97875,240.59014 L 201.19409,244.6781 L 204.50866,247.21927 L 204.28769,250.86529 L 200.53118,255.06374 L 196.77467,260.6985 L 195.89079,261.36141 L 195.33836,264.45501 L 194.23351,265.55986 L 192.46574,266.0018 L 188.1568,271.30511 L 187.82535,274.28822 L 187.38341,275.39307 L 188.1568,276.38744 L 190.58749,276.27696 L 191.80283,278.48667 L 189.37215,284.23191 L 188.04632,288.31988 L 183.84787,305.44513 L 179.53894,322.90184" id="OR_ID" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 134.1294,312.29523 L 126.28493,342.89971 L 119.54531,367.97992 L 117.96384,374.32659 L 129.93095,392.28673 L 151.91756,424.99044 L 170.7001,453.05375 L 184.73175,475.04037 L 187.28633,478.33598" id="CA_NV" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 179.25879,323.29247 L 199.53681,327.65271 L 208.92808,329.53097 L 217.8774,331.29873 L 225.72187,333.17699" id="ID_NV" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 225.5009,333.28747 L 237.87528,335.49718 L 249.47626,337.48592 L 259.41995,339.25369 L 267.48539,340.57952 L 272.23627,341.24243" id="ID_UT" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 187.05195,478.02348 L 186.61001,481.33804 L 189.26166,486.08892 L 190.477,491.72368 L 191.2504,492.71805 L 192.24477,493.27048 L 192.13428,495.48019 L 190.58749,496.80601 L 187.27292,498.46329 L 185.39467,500.34155 L 183.95836,503.87708 L 183.40593,508.62796 L 180.64379,511.27961 L 178.65505,511.94253 L 178.54457,517.57729 L 178.10262,519.23457 L 178.54457,520.00797 L 182.0801,520.56039 L 181.52767,523.21205 L 180.09136,525.31127 L 176.44534,526.19515" id="CA_AZ" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 187.27292,478.24445 L 188.59875,476.03474 L 189.15117,463.88133 L 189.37215,462.22405 L 189.48263,455.48444 L 191.36088,454.49007 L 192.24477,453.93764 L 193.12865,453.93764 L 194.01254,455.04249 L 196.66419,455.37395 L 197.87953,458.0256 L 200.31021,458.13609 L 201.96749,455.59492 L 202.40943,455.15298 L 205.40595,437.74758" id="NV_AZ" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 205.28206,438.35918 L 207.16031,427.86306 L 210.2539,413.16849 L 213.45798,397.14809 L 215.55721,384.00032 L 217.43546,375.38245 L 221.08148,355.60554 L 224.50653,338.70126 L 225.61139,333.50844" id="NV_UT" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 70.710722,294.61753 L 77.560823,296.82724 L 88.609373,299.69986 L 95.901416,301.6886 L 108.05482,305.66608 L 121.09211,308.98065 L 134.1294,312.73715" id="OR_CA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 133.68746,312.68584 L 166.39117,320.47114 L 179.64943,323.34376" id="OR_NV" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 226.27435,180.81733 L 221.85493,201.58861 L 225.1695,208.88065 L 223.84367,213.30007 L 225.61144,217.71949 L 228.70504,219.04532 L 232.24057,228.98902 L 235.77611,232.52455 L 236.21805,233.62941 L 239.53262,234.73427 L 239.97456,236.723 L 233.12446,253.73778 L 233.12446,256.16846 L 235.55514,259.26205 L 236.43902,259.26205 L 241.07941,256.38943 L 241.74233,255.28457 L 243.28913,255.94749 L 243.06815,261.02982 L 245.71981,273.18323 L 248.59243,275.61391 L 249.47631,276.27682 L 251.24408,278.48653 L 250.80214,281.8011 L 251.46505,285.11566 L 252.56991,285.99955 L 254.77962,283.78984 L 257.43127,283.78984 L 260.52487,285.33664 L 262.95555,284.45275 L 266.93303,284.45275 L 270.46856,285.99955 L 273.12022,285.55761 L 273.56216,282.68498 L 276.43478,282.02207 L 277.76061,283.3479 L 278.20255,286.44149 L 280.63323,288.6512" id="ID_MT" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 280.76267,289.04183 L 282.18003,277.82362 L 301.40451,280.69624 L 328.80492,284.67372 L 344.49387,286.66246 L 375.50794,289.84759 L 386.47836,290.86091" id="MT_WY" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 280.8542,288.20926 L 277.0977,312.07413 L 272.01536,341.46328" id="ID_WY" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 272.01536,341.46328 L 270.5356,351.6275 L 268.92177,363.11844 L 275.2273,364.01574 L 291.55004,366.22545 L 300.53404,367.40842" id="UT_WY" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 300.29966,367.31689 L 297.42703,388.75109 L 294.33344,410.40625 L 290.70637,437.45625 L 289.2511,448.1923 L 288.58819,452.16978" id="UT_CO" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 205.06113,437.58569 L 237.54388,443.77288 L 263.83943,448.1923 L 288.80916,451.85728" id="UT_AZ" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 288.58819,451.94881 L 284.5839,481.67727 L 277.85214,533.09881 L 274.22507,559.11977 L 272.4573,571.93609" id="AZ_NM" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 288.80916,451.72784 L 321.95482,455.92629 L 357.65689,460.20517 L 389.35099,462.33445" id="CO_NM" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 685.15727,355.93675 L 692.50104,354.68675 L 702.3448,353.12425 L 707.42849,352.54501" id="MI_OH" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 691.09478,411.56184 L 695.00104,411.24934 L 697.34479,410.46809 L 700.1573,412.03059 L 701.7198,416.24934 L 707.34481,416.56184 L 709.06356,418.2806 L 711.09481,418.43685 L 713.43857,417.0306 L 716.40732,417.49935 L 717.65732,418.9056 L 720.31358,416.40559 L 722.03233,415.15559 L 723.59483,415.15559 L 724.21983,417.81185 L 725.93859,418.74935 L 729.37609,420.7806" id="OH_KY" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 729.37609,420.7806 L 729.53234,426.09311 L 730.31359,427.65561 L 732.8136,429.06186 L 733.4386,431.24937 L 736.2511,434.84312 L 738.7511,437.49938 L 741.92187,439.62379" id="KY_WV" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 742.03236,438.90563 L 738.90736,442.65563 L 734.84485,446.09314 L 730.46984,451.2494 L 728.75109,452.96815 L 728.75109,454.9994 L 725.00108,457.03065 L 719.53233,460.31191 L 716.70413,461.72601" id="KY_VA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 717.31181,461.61552 L 666.876,466.40567 L 651.64388,468.12442 L 647.17734,468.61997 L 643.43846,468.59317 L 643.43846,472.34318 L 635.31345,472.81193 L 628.59469,473.43693 L 618.12592,473.59318" id="KY_TN" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 665.15724,603.28087 L 664.84474,595.46836 L 662.34474,593.59336 L 660.62599,591.87461 L 660.93849,588.90585 L 670.78225,587.65585 L 695.46979,584.84335 L 702.0323,584.21835 L 708.12606,584.21835" id="AL_FL" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 708.12606,584.21835 L 707.34481,580.78084 L 705.93856,579.99959 L 705.31355,579.21834 L 706.09481,576.87458 L 705.78231,571.71833 L 704.2198,567.49957 L 704.37605,565.62457 L 704.8448,562.49956 L 706.56356,557.81206 L 706.40731,555.7808 L 704.5323,554.99955 L 704.06355,551.7183 L 701.56355,548.43704 L 699.5323,542.34328 L 698.12604,535.62452 L 696.56354,530.93702 L 695.15729,524.99951 L 692.81354,515.46824 L 689.53228,507.81198 L 688.90728,504.53073 L 688.75103,502.49947 L 688.75103,500.31197" id="AL_GA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 707.81356,583.90584 L 710.46981,587.8121 L 711.87606,589.21835 L 719.53233,589.3746 L 729.98996,588.7496 L 750.78237,587.4996 L 756.04583,586.8478 L 760.46989,586.8746 L 760.62614,589.6871 L 763.12614,590.46835 L 763.43864,586.2496 L 761.87614,581.87459 L 762.96989,580.31209 L 768.5949,581.09334 L 773.59491,581.40584" id="GA_FL" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 688.75103,500.46822 L 697.03229,499.53072 L 705.1573,498.43697 L 709.84481,497.65572" id="TN_GA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 709.84481,497.65572 L 716.40732,497.18696 L 723.12608,496.40571 L 728.75109,495.46821 L 730.46984,495.31196" id="NC_GA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 730.46984,495.31196 L 730.62609,497.18696 L 728.43859,498.12447 L 727.65734,499.99947 L 727.96984,501.71822 L 729.21984,502.96822 L 733.1261,505.31198 L 736.40735,505.15573 L 739.68861,510.15573 L 740.15736,511.56199 L 742.50111,514.37449 L 742.96986,515.78074 L 747.34487,517.4995 L 750.31362,519.687 L 752.50113,522.4995 L 754.68863,523.7495 L 756.71988,525.62451 L 757.96988,528.12451 L 760.00114,529.99951 L 764.06364,531.87452 L 766.7199,537.65578 L 768.2824,542.34328 L 770.7824,542.96828 L 772.96991,544.99954 L 774.21991,548.43704 L 774.84491,550.46829 L 777.34491,551.7183 L 779.37617,550.7808" id="GA_SC" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 730.15734,494.99946 L 736.2511,492.65571 L 745.00111,488.2807 L 752.03237,487.49945 L 767.9699,487.0307 L 770.1574,488.9057 L 771.7199,492.03071 L 775.93866,491.56196 L 788.12618,490.1557 L 790.93868,490.93696 L 803.1262,498.28072 L 812.97945,506.32368" id="NC_SC" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 709.84481,497.65572 L 710.15731,492.96821 L 711.87606,491.56196 L 714.53232,490.93696 L 715.15732,487.3432 L 719.21983,484.68695 L 722.96983,483.28069 L 727.03234,479.84319 L 731.25109,477.81194 L 731.87609,474.84318 L 735.6261,471.09318 L 736.2511,470.93693 C 736.2511,470.93693 736.2511,472.03068 737.03235,472.03068 C 737.8136,472.03068 738.90736,472.34318 738.90736,472.34318 L 741.09486,468.90567 L 743.12611,468.28067 L 745.31361,468.59317 L 746.87612,465.15567 L 749.68862,462.65566 L 750.15737,460.62441 L 750.31362,456.71815" id="TN_NC" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 717.03232,461.56191 L 726.53223,460.74273 L 738.1261,458.90566 L 745.78237,458.74941 L 748.12612,456.8744 L 750.34206,456.92964" id="VA_TN" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 750.00112,456.8744 L 754.53238,457.49941 L 761.42964,456.2494 L 776.71991,454.3744 L 793.28244,451.8744 L 813.19549,448.22704 L 831.71999,444.53064 L 842.81376,441.71813 L 847.56599,440.14615" id="VA_NC" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 741.65514,439.21813 L 743.90736,442.34313 L 745.15736,443.59313 L 747.96987,444.37439 L 750.46987,443.74938 L 752.81363,441.56188 C 752.81363,441.56188 754.53238,442.96813 755.15738,442.81188 C 755.78238,442.65563 759.37614,441.87438 759.37614,441.87438 L 761.09489,437.34313 L 763.59489,438.28063 L 766.7199,435.93687 L 768.12615,436.24937 L 770.1574,434.37437 L 770.31365,431.71812 L 769.5324,430.46812 L 774.37616,419.99935 L 776.40741,413.59309 L 776.71991,409.37433 L 778.43866,409.21808 L 780.00117,411.87434 L 781.25117,412.65559 L 783.75117,412.65559 L 784.84492,407.34308 L 785.15742,404.37433 L 788.43868,404.06183 L 788.90743,401.87432 L 791.87618,399.53057 L 792.50119,397.96807 L 794.06369,395.31181 L 794.53244,393.28056 L 794.68869,388.1243 L 799.06369,389.84305 L 804.53246,392.81181 L 805.46995,387.65555" id="WV_VA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 805.15745,388.1243 L 809.37621,389.84305 L 809.37621,392.65556 L 814.68872,393.90556 L 816.56372,395.15556 L 817.50122,393.28056 L 819.68873,394.84306 L 818.28247,397.96807 L 817.96997,400.62432 L 816.25122,403.12432 L 816.25122,405.15558 L 816.87622,406.87433 L 822.13512,408.2443" id="VA_MD" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 805.6262,388.28055 L 804.21995,386.71805 L 802.34495,384.9993 L 799.53245,383.59305 L 797.94314,382.50092 L 796.15966,382.96967 L 794.21994,384.3743 L 791.71993,386.40555 L 788.90743,386.71805 L 787.65743,386.09305 L 785.93868,388.59305 L 784.53242,389.9993 L 782.18867,390.15555 L 780.00117,393.12431 L 777.96991,395.46806 L 777.65741,395.78056 L 776.71991,390.31181 L 775.31366,385.3118" id="WV_MD" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 775.64512,385.53277 L 763.62333,387.03055 L 759.14731,387.75656 L 755.60717,368.59302" id="PA_WV" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 755.93863,368.59302 L 754.53238,369.84302 L 753.59488,371.24928 L 754.68863,374.21803 L 754.68863,378.74929 L 754.21988,383.59305 L 753.90738,389.0618 L 751.71987,392.81181 L 748.75112,396.09306 L 746.56362,397.65557 L 744.53236,397.18682 L 743.28236,398.59307 L 741.09486,401.87432 L 740.15736,403.12432 L 740.15736,405.46808 L 741.25111,407.18683 L 740.78236,408.74933 L 739.06361,409.68683 L 738.59485,407.96808 L 737.34485,406.87433 L 736.09485,407.49933 L 735.15735,411.24934 L 735.0011,416.09309 L 733.4386,417.49935 L 733.28235,420.1556 L 731.40734,420.93685 L 729.21984,421.24935" id="OH_WV" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 751.25169,340.03513 L 754.06419,358.74889 L 755.78295,369.68641" id="OH_PA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 761.7015,331.46032 L 762.34546,338.28009 L 770.93923,336.87384 L 812.50182,328.43632 L 830.47061,324.53006 L 832.72285,326.59924 L 835.62687,327.49882 L 837.97063,332.65508 L 840.15813,334.53008 L 842.65814,334.68633 L 843.90814,335.78009" id="NY_PA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 843.75189,335.62383 L 847.34565,337.34259 L 851.09566,338.43634 L 855.47067,339.37384 L 857.97067,340.4676 L 858.12692,342.49885 L 857.65817,345.15511 L 858.24689,348.66681" id="NY_NJ" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 844.06439,335.46758 L 841.72064,337.96759 L 841.72064,340.93635 L 839.84563,343.9051 L 839.68938,345.46761 L 840.93939,346.71761 L 840.78314,349.06137 L 838.59563,350.15512 L 839.37688,352.81137 L 839.53313,353.90513 L 842.18939,354.21763 L 843.12689,356.71763 L 846.5644,359.06139 L 848.90815,360.62389 L 848.90815,361.40514 L 845.78315,364.3739 L 844.22064,366.5614 L 842.81439,369.21766 L 840.62689,370.46766 L 839.53313,371.24891" id="PA_NJ" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 839.37688,371.09266 L 839.22063,372.34267 L 838.66983,374.88059" id="DE_NJ" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 839.53313,371.09266 L 837.50188,371.09266 L 835.47062,372.65517 L 834.06437,374.06142" id="NY_DE" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 834.06437,374.06142 L 835.47062,378.12393 L 837.65813,383.59269 L 839.68938,392.96771 L 841.25189,399.06148 L 846.09565,398.90523 L 852.03316,397.81147" id="MD_DE" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 834.38635,373.6842 L 826.20281,375.44087 L 811.82448,378.1697 L 774.68924,385.62395" id="PA_MD" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 862.06162,339.46537 L 860.15818,337.34259 L 861.25193,335.15508 L 861.25193,327.34257 L 859.84568,320.3113 L 859.06443,316.87379" id="NY_CT" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 859.06443,316.87379 L 858.28318,311.56128 L 858.75193,301.09251" id="NY_MA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 858.75193,301.09251 L 858.28318,296.0925 L 855.31442,285.46747 L 854.68942,285.15497 L 851.87691,283.90497 L 852.65816,281.09246 L 851.87691,279.06121 L 849.37691,274.6862 L 850.31441,270.93619 L 849.53316,265.93618 L 847.1894,259.68616 L 846.5644,254.8424" id="NY_VT" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 872.65821,247.81114 L 872.97071,253.5924 L 874.84571,256.24866 L 874.84571,260.15492 L 871.25195,264.06117 L 868.75195,265.15493 L 868.75195,266.24868 L 869.8457,267.96743 L 869.8457,276.2487 L 869.06445,285.15497 L 868.9082,289.84248 L 869.8457,291.09249 L 869.68945,295.46749 L 869.2207,297.18625 L 870.7832,299.06125" id="VT_NH" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 859.06443,301.56126 L 863.90819,300.46751 L 870.4707,299.2175" id="VT_MA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 870.4707,299.2175 L 887.34574,295.15499 L 889.53325,294.52999 L 891.5645,291.40499 L 895.50868,289.5947" id="NH_MA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 896.48246,285.35698 L 894.06451,284.21747 L 893.59575,281.24871 L 889.84575,280.15496 L 889.53325,277.4987 L 882.50198,254.8424 L 877.81447,240.46737" id="NH_ME" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 859.37693,317.34254 L 880.31447,312.49878 L 885.31449,311.40503" id="MA_CT" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 885.31449,311.40503 L 886.87699,317.18629 L 887.65824,321.40505 L 888.28324,325.46756" id="CT_RI" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ <ns0:path d="M 885.15824,311.56128 L 890.78325,309.99878 L 892.34575,311.09253 L 895.62701,315.31129 L 898.43952,319.6863" id="RI_MA" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:1pt;stroke-opacity:1" />
+ </ns0:g>
+ <ns0:g id="g3561" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-opacity:1">
+ <ns0:path d="M 242.11104,448.52821 L 258.12559,450.54515 L 259.44637,440.12432 L 276.45152,442.81356 L 290.31977,444.66242" id="NM_Mexico" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ <ns0:path d="M 289.98957,444.32625 L 292.13584,444.99855 L 294.77744,448.02394 L 296.26332,452.56207 L 301.05119,454.91517 L 302.37198,458.27673 L 309.63631,466.51255 L 310.9571,468.19333 L 316.07515,470.37834 L 317.23084,472.56336 L 318.88182,473.57182 L 319.37712,476.42915 L 322.67909,483.15227 L 322.67909,491.55616 L 324.99047,496.43042 L 332.585,504.49816 L 337.86815,506.68317 L 339.68423,508.70011 L 339.68423,509.37242 L 343.64659,511.72551 L 345.62777,512.39782 L 347.44386,513.57437 L 350.08543,514.58284 L 352.56191,512.06167 L 357.01957,505.67471 L 358.01016,501.80891 L 360.32154,498.44736 L 363.9537,496.93466 L 368.57646,495.0858 L 371.71333,497.43889 L 379.30786,498.1112 L 386.242,499.28775 L 388.88357,501.47276 L 388.88357,502.6493 L 391.52515,505.84279 L 397.63379,511.38936 L 397.79889,512.90206 L 399.61497,514.91899 L 400.44047,519.28902 L 405.88872,532.06294 L 405.72362,534.07988 L 410.01618,536.76912 L 413.64834,543.66032 L 417.11541,548.19842 L 420.41738,549.54304 L 422.06837,551.89614 L 420.74758,556.43424 L 421.40797,557.44271 L 422.72876,558.11502 L 422.39856,561.64466 L 421.73817,562.31697 L 422.39856,564.67006 L 425.70053,566.68699 L 427.02132,573.41011 L 429.1676,577.44398 L 436.92723,580.97362 L 442.21038,582.15016 L 446.50294,585.34364 L 449.80491,586.01595 L 451.1257,585.51172 L 456.73904,586.68827 L 462.51749,590.72214 L 465.65436,588.7052" id="TX_Mexico" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ <ns0:path d="M 95.503595,393.0625 L 105.7397,394.5752 L 125.88171,397.43253 L 140.74058,399.1133" id="CA_Mexico" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ <ns0:path d="M 140.74058,399.1133 L 138.4292,401.4664 L 138.099,402.9791 L 138.5943,403.98756 L 157.91082,415.08071 L 170.2932,422.98037 L 185.31716,431.8885 L 202.4874,442.30933 L 215.03489,444.8305 L 242.11104,448.52821" id="AZ_Mexico" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ <ns0:g id="g3547" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-opacity:1">
+ <ns0:path d="M 876.74955,100.10268 L 848.85548,107.51359" id="VT_Canada" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ <ns0:path d="M 943.28423,76.73985 L 940.47756,76.907928 L 940.97285,78.084474 L 940.80775,78.588708 L 939.98226,78.588708 L 937.01049,76.235617 L 936.84539,71.193279 L 936.0199,69.176344 L 929.91126,69.176344 L 920.66574,38.081928 L 918.68456,37.073461 L 912.57592,34.552292 L 911.09003,34.384214 L 909.27395,36.233071 L 905.14649,39.258474 L 905.14649,40.266941 L 904.32099,41.107331 L 901.51432,40.435019 L 900.19353,38.081928 L 900.19353,36.905383 L 898.87274,36.737305 L 897.55196,36.737305 L 895.40568,41.107331 L 892.4339,50.351617 L 890.61782,55.393954 L 890.78292,60.436292 L 890.94802,61.948993 L 890.12252,64.806318 L 889.29703,65.814786 L 889.29703,72.033669 L 891.27821,74.554837 L 889.79233,78.756786 L 887.15075,83.631045 L 886.32526,89.345695 L 886.32526,92.034941 L 884.77927,91.608771 L 881.71077,91.733617" id="ME_Canada" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ <ns0:path d="M 882.59051,91.36264 L 881.5374,92.203019 L 880.87701,93.883799 L 880.21662,93.379565 L 879.22603,92.371097 L 877.74014,94.388032 L 875.84148,100.77501" id="NH_Canada" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ <ns0:g id="g3537" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-opacity:1">
+ <ns0:path d="M 710.49539,188.67975 L 711.48598,189.52014 L 710.49539,190.69668 L 710.66049,191.36899 L 711.48598,191.53707 L 713.46716,190.5286 L 713.13697,180.61201 M 704.88204,204.47907 L 704.88204,198.9325 L 706.86322,196.91556 L 707.68872,196.57941" id="MI_Canada" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ <ns0:path d="M 766.79397,165.82115 L 768.77515,172.20811 L 770.59123,172.37619 L 771.91202,175.56967 M 849.4392,107.39474 L 841.58358,109.51505 L 836.96082,111.02775 L 833.65885,110.85967 L 828.0455,112.20429 L 824.7235,113.61855" id="NY_Canada" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-opacity:1" />
+ <ns0:g id="g3530" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-opacity:1">
+ <ns0:path d="M 179.70368,24.971818 L 163.02887,21.106058 L 139.58489,15.223331 L 119.11268,9.3406038 L 110.36246,7.3236687 L 100.45655,4.4663441 L 95.99889,2.9536428" id="WA_Canada" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ <ns0:path d="M 193.57209,27.997253 L 179.20868,25.139971" id="ID_Canada" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ <ns0:path d="M 371.21804,55.393954 L 338.69364,52.032396 L 308.81082,48.334682 L 278.92799,44.132734 L 245.9083,38.586162 L 227.08707,35.056526 L 193.57209,27.997253" id="MT_Canada" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ <ns0:path d="M 472.75352,59.76398 L 442.87077,59.427825 L 423.88445,58.755513 L 396.8083,57.410889 L 371.21804,55.393954" id="ND_Canada" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ <ns0:path d="M 590.4688,78.756786 L 585.35075,79.261019 L 584.85546,80.269487 L 584.19506,80.269487 L 582.37898,77.076006 L 573.29856,77.412162 L 572.30797,78.252552 L 571.31738,78.252552 L 570.82209,76.907928 L 569.99659,75.059071 L 567.35502,75.563305 L 564.05305,78.924863 L 562.40206,79.765253 L 559.26519,79.765253 L 556.62362,78.756786 L 556.62362,76.571773 L 555.30283,76.403695 L 554.80753,76.907928 L 552.16596,75.563305 L 551.67066,72.537902 L 550.18478,73.042136 L 549.68948,74.050604 L 547.21301,73.54637 L 541.76476,71.025201 L 537.80239,68.335954 L 534.83062,68.335954 L 533.50983,67.327487 L 531.19845,67.999799 L 530.04276,69.176344 L 529.71257,70.520967 L 524.75961,70.520967 L 524.75961,68.335954 L 518.32077,67.999799 L 517.99058,66.487097 L 513.03762,66.487097 L 511.38664,64.806318 L 509.90075,58.419357 L 509.07526,52.704708 L 507.09408,51.864318 L 504.7827,51.360084 L 504.1223,51.528162 L 503.79211,60.100136 L 472.09313,60.100136" id="MN_Canada" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-width:2.66530466;stroke-dasharray:none;stroke-opacity:1" />
+ </ns0:g>
+ </ns0:g>
+ </ns0:g>
+ </ns0:g>
+ </ns0:g>
+ <ns0:g id="g4675" style="fill:#000000;fill-opacity:0;stroke:#000000;stroke-opacity:1">
+ <ns0:path d="M 846.52085,274.72594 L 847.80814,273.69012 L 850.29359,272.77823 L 852.1404,272.10064 L 852.89317,271.82446 L 853.40684,271.87199" id="path3995" style="fill:#000000;fill-opacity:0;fill-rule:evenodd;stroke:#000000;stroke-width:1.33265233;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" ns1:nodetypes="cccccc" />
+ <ns0:path d="M 817.25107,257.90409 C 818.2317,258.85489 818.79206,259.61552 818.79206,259.61552 L 819.39913,260.85156 L 819.53922,260.70894" id="path5767" style="fill:#000000;fill-opacity:0;fill-rule:evenodd;stroke:#000000;stroke-width:0.41898587;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" ns1:nodetypes="cccc" />
+ </ns0:g>
+ </ns0:g>
+ </ns0:g>
+ <ns0:path d="M 152.15345,458.16063 L 151.84095,540.66102 L 153.40345,541.59852 L 156.37222,541.75477 L 157.77847,540.66102 L 160.27848,540.66102 L 160.43474,543.47353 L 167.15352,550.03606 L 167.62227,552.53607 L 170.90353,550.66106 L 171.52854,550.50481 L 171.84104,547.53605 L 173.24729,545.97354 L 174.34105,545.81729 L 176.21606,544.41103 L 179.18482,546.44229 L 179.80983,549.25481 L 181.68483,550.34856 L 182.77859,552.69232 L 186.52861,554.41108 L 189.80987,560.19236 L 192.46613,563.94237 L 194.65364,566.59864 L 196.0599,570.1924 L 200.90367,571.91116 L 205.9037,573.94242 L 206.8412,578.16119 L 207.30995,581.12995 L 206.37245,584.41122 L 204.34118,587.14562" id="AK_Canada" style="fill:none;stroke:#000000;stroke-width:2.5;stroke-dasharray:none;stroke-opacity:1" />
+ </ns0:g>
+ <ns0:path d="M 127.46583,540.97352 L 129.80959,539.56726 L 128.87209,537.8485 L 127.15333,538.78601 L 127.46583,540.97352 z M 86.528141,560.19236 L 87.621901,565.81738 L 90.434411,566.59864 L 95.278181,563.78612 L 99.496951,561.28611 L 97.934451,558.94235 L 98.403201,556.59859 L 96.371941,557.8486 L 93.559431,557.06734 L 95.121931,555.97359 L 96.996941,556.75484 L 100.74696,555.03608 L 101.21571,553.62983 L 98.871951,552.84857 L 99.653201,550.97356 L 96.996941,552.84857 L 92.465671,556.28609 L 87.778151,559.0986 L 86.528141,560.19236 z M 78.090601,572.22366 L 79.496861,573.94242 L 80.434371,572.84867 L 79.653111,570.97366 L 78.090601,572.22366 z M 55.278,574.09867 L 56.371751,571.91116 L 58.403011,572.22366 L 57.621761,574.09867 L 55.278,574.09867 z M 52.465487,571.12991 L 54.027994,573.00492 L 56.059251,571.44241 L 54.652997,570.1924 L 52.465487,571.12991 z M 5.1215179,575.50493 L 8.4027829,573.31742 L 11.684048,572.37991 L 14.18406,572.69241 L 14.652812,574.25492 L 16.527821,574.72367 L 18.402829,572.84867 L 18.090328,571.28616 L 20.74659,570.66116 L 23.559103,573.16117 L 22.465348,574.87992 L 18.246579,575.97368 L 15.590316,575.50493 L 11.99655,574.41117 L 7.7777799,575.81743 L 6.2152729,576.12993 L 5.1215179,575.50493 z M 18.402829,504.8796 L 19.496585,507.37961 L 20.59034,508.94212 L 19.496585,509.72337 L 17.465325,506.75461 L 17.465325,504.8796 L 18.402829,504.8796 z M 38.402922,518.47341 L 41.996689,519.25467 L 45.590455,520.19217 L 46.371709,521.12968 L 44.809202,524.72344 L 41.840438,524.56719 L 38.559173,521.12968 L 38.402922,518.47341 z M 40.902934,486.12951 L 42.934193,491.28579 L 42.777942,492.22329 L 39.965429,491.91079 L 38.246671,488.00452 L 36.527913,486.59827 L 34.184152,486.59827 L 34.027902,484.09825 L 35.74666,481.75449 L 36.840415,484.09825 L 38.246671,485.50451 L 40.902934,486.12951 z M 204.65369,586.59873 L 203.09118,585.81748 L 201.68493,582.84871 L 199.02866,581.44246 L 197.30991,580.3487 L 196.52865,581.12995 L 197.93491,583.78622 L 198.09116,587.37998 L 196.9974,587.84873 L 195.1224,585.97373 L 193.09114,584.72372 L 193.55989,586.28623 L 194.80989,588.00499 L 194.02864,588.78624 C 194.02864,588.78624 193.24739,588.47374 192.77864,587.84873 C 192.30988,587.22373 190.74738,584.56747 190.74738,584.56747 L 189.80987,582.37996 C 189.80987,582.37996 189.49737,583.62997 188.87237,583.31746 C 188.24736,583.00496 187.62236,581.91121 187.62236,581.91121 L 189.34112,580.0362 L 187.93486,578.62994 L 187.93486,573.78617 L 187.15361,573.78617 L 186.37236,577.06743 L 185.2786,577.53619 L 184.3411,573.94242 L 183.71609,570.34865 L 182.93484,569.8799 L 183.24734,575.34868 L 183.24734,576.44243 L 181.84108,575.19243 L 178.40357,569.41115 L 176.37231,568.9424 L 175.74731,565.34863 L 174.1848,562.53612 L 172.62229,561.44236 L 172.62229,559.25485 L 174.65355,558.00485 L 174.1848,557.69235 L 171.68479,558.31735 L 168.40352,555.97359 L 165.90351,553.16107 L 161.21599,550.66106 L 157.30972,548.16105 L 158.55973,545.03604 L 158.55973,543.47353 L 156.84097,545.03604 L 154.02846,546.12979 L 150.43469,545.03604 L 144.96591,542.69228 L 139.65339,542.69228 L 139.02839,543.16103 L 132.77836,539.41101 L 130.7471,539.09851 L 128.09084,533.47348 L 124.65332,533.78598 L 121.2158,535.19224 L 121.68456,539.56726 L 122.77831,536.75475 L 123.71582,537.06725 L 122.30956,541.28602 L 125.43457,538.62976 L 126.05958,540.19226 L 122.30956,544.41103 L 121.05955,544.09853 L 120.5908,542.22352 L 119.3408,541.44227 L 118.09079,542.53603 L 115.43453,540.81727 L 112.46576,542.84853 L 110.74701,544.87979 L 107.46574,546.91105 L 102.93447,546.75479 L 102.46572,544.72354 L 106.05948,544.09853 L 106.05948,542.84853 L 103.87197,542.22352 L 104.80948,539.87976 L 106.99699,536.12975 L 106.99699,534.41099 L 107.15324,533.62973 L 111.37201,531.44222 L 112.30951,532.69223 L 114.96578,532.69223 L 113.71577,530.19222 L 110.122,529.87972 L 105.27823,532.53598 L 102.93447,535.81724 L 101.21571,538.31726 L 100.12196,540.50477 L 96.059441,541.91102 L 93.090671,544.41103 L 92.778171,545.97354 L 94.965681,546.91105 L 95.746941,548.9423 L 93.090671,552.06732 L 86.840651,556.12984 L 79.340611,560.19236 L 77.309351,561.28611 L 72.153081,562.37987 L 66.996801,564.56738 L 68.715561,565.81738 L 67.309301,567.22364 L 66.840551,568.31739 L 64.184291,567.37989 L 61.059281,567.53614 L 60.278021,569.72365 L 59.340521,569.72365 L 59.653021,567.37989 L 56.215501,568.6299 L 53.402991,569.5674 L 50.121726,568.31739 L 47.309213,570.1924 L 44.184199,570.1924 L 42.152939,571.44241 L 40.590432,572.22366 L 38.559173,571.91116 L 36.059161,570.81741 L 33.871651,571.44241 L 32.934147,572.37991 L 31.371639,571.28616 L 31.371639,569.41115 L 34.340403,568.16114 L 40.434181,568.78615 L 44.652951,567.22364 L 46.68421,565.19238 L 49.496723,564.56738 L 51.215481,563.78612 L 53.871744,563.94237 L 55.434251,565.19238 L 56.371751,564.87988 L 58.559261,562.22362 L 61.528031,561.28611 L 64.809291,560.66111 L 66.059301,560.34861 L 66.684301,560.81736 L 67.465561,560.81736 L 68.715561,557.22359 L 72.621831,555.81734 L 74.496841,552.22357 L 76.684351,547.84855 L 78.246861,546.44229 L 78.559361,543.94228 L 76.996851,545.19229 L 73.715581,545.81729 L 73.090581,543.47353 L 71.840581,543.16103 L 70.903071,544.09853 L 70.746821,546.91105 L 69.340561,546.75479 L 67.934311,541.12977 L 66.684301,542.37977 L 65.590551,541.91102 L 65.278051,540.03601 L 61.371781,540.19226 L 59.340521,541.28602 L 56.840511,540.97352 L 58.246761,539.56726 L 58.715511,537.06725 L 58.090511,535.19224 L 59.496771,534.25474 L 60.746771,534.09849 L 60.121771,532.37973 L 60.121771,528.16096 L 59.184271,527.22345 L 58.403011,528.62971 L 52.465487,528.62971 L 51.059231,527.3797 L 50.434228,523.62969 L 48.402968,520.19217 L 48.402968,519.25467 L 50.434228,518.47341 L 50.590478,516.44215 L 51.684233,515.3484 L 50.90298,514.87965 L 49.652974,515.3484 L 48.559219,512.69214 L 49.496723,507.84836 L 53.871744,504.72335 L 56.371751,503.16084 L 58.246761,499.56708 L 60.903031,498.31707 L 63.403041,499.41083 L 63.715541,501.75459 L 66.059301,501.44208 L 69.184311,499.09832 L 70.746821,499.72333 L 71.684321,500.34833 L 73.246831,500.34833 L 75.434341,499.09832 L 76.215601,494.87955 C 76.215601,494.87955 76.528101,492.06704 77.153101,491.59829 C 77.778101,491.12954 78.090601,490.66079 78.090601,490.66079 L 76.996851,488.78578 L 74.496841,489.56703 L 71.371821,490.34828 L 69.496811,489.87953 L 66.059301,488.16077 L 61.215531,488.00452 L 57.778011,484.41076 L 58.246761,480.66074 L 58.871771,478.31698 L 56.840511,476.59822 L 54.965499,473.00445 L 55.434251,472.2232 L 61.996781,471.75445 L 64.028041,471.75445 L 64.965541,472.69195 L 65.590551,472.69195 L 65.434301,471.12944 L 69.184311,470.50444 L 71.684321,470.81694 L 73.090581,471.9107 L 71.684321,473.94196 L 71.215571,475.34821 L 73.871841,476.91072 L 78.715611,478.62948 L 80.434371,477.69198 L 78.246861,473.47321 L 77.309351,470.34819 L 78.246861,469.56694 L 74.965591,467.69193 L 74.496841,466.59817 L 74.965591,465.03567 L 74.184341,461.28565 L 71.371821,456.75438 L 69.028061,452.69186 L 71.840581,450.81685 L 74.965591,450.81685 L 76.684351,451.44185 L 80.746871,451.2856 L 84.340631,447.84809 L 85.434391,444.87932 L 89.028161,442.53556 L 90.590661,443.47307 L 93.246921,442.84806 L 96.840691,440.8168 L 97.934451,440.66055 L 98.871951,441.44181 L 103.24697,441.28556 L 105.90323,438.31679 L 106.99699,438.31679 L 110.4345,440.66055 L 112.30951,442.69181 L 111.84076,443.78557 L 112.46576,444.87932 L 114.02827,443.31682 L 117.77829,443.62932 L 118.09079,447.22308 L 119.9658,448.62934 L 126.84083,449.25434 L 132.93461,453.31686 L 134.34086,452.37936 L 139.34089,454.87937 L 141.37215,454.25437 L 143.24716,453.47311 L 147.93468,455.34812 L 152.15345,458.16063" id="AK_Pacific" style="fill:none;fill-opacity:0.26666697;stroke:#80b0f0;stroke-width:1pt" />
+ <ns0:g id="Frames" transform="translate(-18.307669,-131.99439)">
+ <ns0:path d="M 229.21212,631.12334 L 229.68087,688.43625 L 264.68114,723.74902 M 18.429285,562.99783 L 161.86786,563.31033 L 229.52462,631.59209 L 314.68151,631.74834 L 370.43191,685.18624 L 370.11941,723.43652" id="Inset_border" style="fill:none;fill-opacity:0.75;stroke:#000000;stroke-width:1.875;stroke-dasharray:none" ns1:nodetypes="ccccccccc" />
+ <ns0:rect height="590.28674" id="Outer_border" style="fill:none;stroke:#000000;stroke-width:2.5;stroke-dasharray:none" width="955.48639" x="19.444839" y="133.89751" />
+ </ns0:g>
+ <ns0:path d="M 822.91849,258.28198 A 4.1274123,3.62712 0 1 1 814.66366,258.28198 A 4.1274123,3.62712 0 1 1 822.91849,258.28198 z" id="DC" style="opacity:1;fill:#a02c2c;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" transform="matrix(0.9707988,0,0,1.0018987,24.345102,-0.4278704)" ns1:cx="818.79108" ns1:cy="258.28198" ns1:rx="4.1274123" ns1:ry="3.62712" ns1:type="arc" ns2:label="#DC" />
+</ns0:svg> \ No newline at end of file
diff --git a/www/wiki/tests/phpunit/data/media/Wikimedia-logo.svg b/www/wiki/tests/phpunit/data/media/Wikimedia-logo.svg
new file mode 100644
index 00000000..1e17acbe
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/Wikimedia-logo.svg
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Wikimedia logo" viewBox="-599 -599 1198 1198" width="1024" height="1024">
+<defs>
+ <clipPath id="mask">
+ <path d="M 47.5,-87.5 v 425 h -95 v -425 l -552,-552 v 1250 h 1199 v -1250 z"/>
+ </clipPath>
+</defs>
+<g clip-path="url(#mask)">
+ <circle id="green parts" fill="#396" r="336.5"/>
+ <circle id="blue arc" fill="none" stroke="#069" r="480.25" stroke-width="135.5"/>
+</g>
+<circle fill="#900" cy="-379.5" r="184.5" id="red circle"/>
+</svg> \ No newline at end of file
diff --git a/www/wiki/tests/phpunit/data/media/Xmp-exif-multilingual_test.jpg b/www/wiki/tests/phpunit/data/media/Xmp-exif-multilingual_test.jpg
new file mode 100644
index 00000000..f7b23025
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/Xmp-exif-multilingual_test.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/adobergb.jpg b/www/wiki/tests/phpunit/data/media/adobergb.jpg
new file mode 100644
index 00000000..470c2d63
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/adobergb.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/animated-xmp.gif b/www/wiki/tests/phpunit/data/media/animated-xmp.gif
new file mode 100644
index 00000000..fcba079d
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/animated-xmp.gif
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/animated.gif b/www/wiki/tests/phpunit/data/media/animated.gif
new file mode 100644
index 00000000..a8f248b3
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/animated.gif
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/broken_exif_date.jpg b/www/wiki/tests/phpunit/data/media/broken_exif_date.jpg
new file mode 100644
index 00000000..82f62f57
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/broken_exif_date.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/exif-gps.jpg b/www/wiki/tests/phpunit/data/media/exif-gps.jpg
new file mode 100644
index 00000000..40137340
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/exif-gps.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/exif-user-comment.jpg b/www/wiki/tests/phpunit/data/media/exif-user-comment.jpg
new file mode 100644
index 00000000..9f23966a
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/exif-user-comment.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/greyscale-na-png.png b/www/wiki/tests/phpunit/data/media/greyscale-na-png.png
new file mode 100644
index 00000000..4a4b7452
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/greyscale-na-png.png
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/greyscale-png.png b/www/wiki/tests/phpunit/data/media/greyscale-png.png
new file mode 100644
index 00000000..340a67b4
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/greyscale-png.png
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/iptc-invalid-psir.jpg b/www/wiki/tests/phpunit/data/media/iptc-invalid-psir.jpg
new file mode 100644
index 00000000..01b9acf3
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/iptc-invalid-psir.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/iptc-timetest-invalid.jpg b/www/wiki/tests/phpunit/data/media/iptc-timetest-invalid.jpg
new file mode 100644
index 00000000..b03e192a
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/iptc-timetest-invalid.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/iptc-timetest.jpg b/www/wiki/tests/phpunit/data/media/iptc-timetest.jpg
new file mode 100644
index 00000000..db9932ba
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/iptc-timetest.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-comment-binary.jpg b/www/wiki/tests/phpunit/data/media/jpeg-comment-binary.jpg
new file mode 100644
index 00000000..b467fe43
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-comment-binary.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-comment-iso8859-1.jpg b/www/wiki/tests/phpunit/data/media/jpeg-comment-iso8859-1.jpg
new file mode 100644
index 00000000..d9ffbac1
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-comment-iso8859-1.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-comment-multiple.jpg b/www/wiki/tests/phpunit/data/media/jpeg-comment-multiple.jpg
new file mode 100644
index 00000000..363c7385
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-comment-multiple.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-comment-utf.jpg b/www/wiki/tests/phpunit/data/media/jpeg-comment-utf.jpg
new file mode 100644
index 00000000..d6d35b4b
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-comment-utf.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-iptc-bad-hash.jpg b/www/wiki/tests/phpunit/data/media/jpeg-iptc-bad-hash.jpg
new file mode 100644
index 00000000..6464c5b8
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-iptc-bad-hash.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-iptc-good-hash.jpg b/www/wiki/tests/phpunit/data/media/jpeg-iptc-good-hash.jpg
new file mode 100644
index 00000000..ef970854
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-iptc-good-hash.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-padding-even.jpg b/www/wiki/tests/phpunit/data/media/jpeg-padding-even.jpg
new file mode 100644
index 00000000..c83c66bd
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-padding-even.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-padding-odd.jpg b/www/wiki/tests/phpunit/data/media/jpeg-padding-odd.jpg
new file mode 100644
index 00000000..25b93308
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-padding-odd.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-segment-loop1.jpg b/www/wiki/tests/phpunit/data/media/jpeg-segment-loop1.jpg
new file mode 100644
index 00000000..962f3fe0
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-segment-loop1.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-segment-loop2.jpg b/www/wiki/tests/phpunit/data/media/jpeg-segment-loop2.jpg
new file mode 100644
index 00000000..e3a7505c
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-segment-loop2.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-xmp-alt.jpg b/www/wiki/tests/phpunit/data/media/jpeg-xmp-alt.jpg
new file mode 100644
index 00000000..0e2c3f63
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-xmp-alt.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-xmp-psir.jpg b/www/wiki/tests/phpunit/data/media/jpeg-xmp-psir.jpg
new file mode 100644
index 00000000..4d19fcbe
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-xmp-psir.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/jpeg-xmp-psir.xmp b/www/wiki/tests/phpunit/data/media/jpeg-xmp-psir.xmp
new file mode 100644
index 00000000..fee6ee18
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/jpeg-xmp-psir.xmp
@@ -0,0 +1,35 @@
+<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
+<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 7.30'>
+<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
+
+ <rdf:Description rdf:about=''
+ xmlns:dc='http://purl.org/dc/elements/1.1/'>
+ <dc:identifier>jpeg-xmp-psir.jpg</dc:identifier>
+ </rdf:Description>
+</rdf:RDF>
+</x:xmpmeta>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+<?xpacket end='w'?> \ No newline at end of file
diff --git a/www/wiki/tests/phpunit/data/media/landscape-plain.jpg b/www/wiki/tests/phpunit/data/media/landscape-plain.jpg
new file mode 100644
index 00000000..cf296555
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/landscape-plain.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/missingprofile.jpg b/www/wiki/tests/phpunit/data/media/missingprofile.jpg
new file mode 100644
index 00000000..4085f0aa
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/missingprofile.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/nonanimated.gif b/www/wiki/tests/phpunit/data/media/nonanimated.gif
new file mode 100644
index 00000000..9e52a7f0
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/nonanimated.gif
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/portrait-rotated.jpg b/www/wiki/tests/phpunit/data/media/portrait-rotated.jpg
new file mode 100644
index 00000000..445feaed
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/portrait-rotated.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/rgb-na-png.png b/www/wiki/tests/phpunit/data/media/rgb-na-png.png
new file mode 100644
index 00000000..2f2a5ca0
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/rgb-na-png.png
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/rgb-png.png b/www/wiki/tests/phpunit/data/media/rgb-png.png
new file mode 100644
index 00000000..6f40cc92
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/rgb-png.png
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/say-test-mpeg1.mp3 b/www/wiki/tests/phpunit/data/media/say-test-mpeg1.mp3
new file mode 100644
index 00000000..b3a63183
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/say-test-mpeg1.mp3
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/say-test-mpeg2.5.mp3 b/www/wiki/tests/phpunit/data/media/say-test-mpeg2.5.mp3
new file mode 100644
index 00000000..e6743a80
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/say-test-mpeg2.5.mp3
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/say-test-mpeg2.mp3 b/www/wiki/tests/phpunit/data/media/say-test-mpeg2.mp3
new file mode 100644
index 00000000..8b2aaa2c
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/say-test-mpeg2.mp3
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/say-test-with-id3.mp3 b/www/wiki/tests/phpunit/data/media/say-test-with-id3.mp3
new file mode 100644
index 00000000..04205d50
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/say-test-with-id3.mp3
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/say-test.ogg b/www/wiki/tests/phpunit/data/media/say-test.ogg
new file mode 100644
index 00000000..5d814fb2
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/say-test.ogg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/say-test.opus b/www/wiki/tests/phpunit/data/media/say-test.opus
new file mode 100644
index 00000000..168d2188
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/say-test.opus
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/srgb.jpg b/www/wiki/tests/phpunit/data/media/srgb.jpg
new file mode 100644
index 00000000..f10ced0d
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/srgb.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/test.jpg b/www/wiki/tests/phpunit/data/media/test.jpg
new file mode 100644
index 00000000..cb084253
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/test.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/test.tiff b/www/wiki/tests/phpunit/data/media/test.tiff
new file mode 100644
index 00000000..6a36f760
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/test.tiff
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/tinyrgb.icc b/www/wiki/tests/phpunit/data/media/tinyrgb.icc
new file mode 100644
index 00000000..eab973f5
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/tinyrgb.icc
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/tinyrgb.jpg b/www/wiki/tests/phpunit/data/media/tinyrgb.jpg
new file mode 100644
index 00000000..63b687e3
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/tinyrgb.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/webp_animated.webp b/www/wiki/tests/phpunit/data/media/webp_animated.webp
new file mode 100644
index 00000000..25c6a4dd
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/webp_animated.webp
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/xmp.png b/www/wiki/tests/phpunit/data/media/xmp.png
new file mode 100644
index 00000000..6b9f7a87
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/xmp.png
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/yuv420.jpg b/www/wiki/tests/phpunit/data/media/yuv420.jpg
new file mode 100644
index 00000000..e741ca65
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/yuv420.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/media/yuv444.jpg b/www/wiki/tests/phpunit/data/media/yuv444.jpg
new file mode 100644
index 00000000..6bccefa7
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/media/yuv444.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/parser/320x240.ogv b/www/wiki/tests/phpunit/data/parser/320x240.ogv
new file mode 100644
index 00000000..79038206
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/parser/320x240.ogv
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/parser/LoremIpsum.djvu b/www/wiki/tests/phpunit/data/parser/LoremIpsum.djvu
new file mode 100644
index 00000000..42f47cd0
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/parser/LoremIpsum.djvu
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/parser/headbg.jpg b/www/wiki/tests/phpunit/data/parser/headbg.jpg
new file mode 100644
index 00000000..5491c6e4
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/parser/headbg.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/parser/wiki.png b/www/wiki/tests/phpunit/data/parser/wiki.png
new file mode 100644
index 00000000..8c421183
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/parser/wiki.png
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/registration/bad_spdx.json b/www/wiki/tests/phpunit/data/registration/bad_spdx.json
new file mode 100644
index 00000000..383ab047
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/registration/bad_spdx.json
@@ -0,0 +1,6 @@
+{
+ "name": "FooBar",
+ "license-name": "not a license identifier",
+ "manifest_version": 1
+}
+
diff --git a/www/wiki/tests/phpunit/data/registration/good.json b/www/wiki/tests/phpunit/data/registration/good.json
new file mode 100644
index 00000000..ad16c5e4
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/registration/good.json
@@ -0,0 +1,4 @@
+{
+ "name": "FooBar",
+ "manifest_version": 1
+}
diff --git a/www/wiki/tests/phpunit/data/registration/invalid.json b/www/wiki/tests/phpunit/data/registration/invalid.json
new file mode 100644
index 00000000..4d1fa589
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/registration/invalid.json
@@ -0,0 +1,5 @@
+{
+ "name": "FooBar",
+ "license-name": [ "array" ],
+ "manifest_version": 1
+}
diff --git a/www/wiki/tests/phpunit/data/registration/newer_manifest_version.json b/www/wiki/tests/phpunit/data/registration/newer_manifest_version.json
new file mode 100644
index 00000000..29c668ee
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/registration/newer_manifest_version.json
@@ -0,0 +1,4 @@
+{
+ "name": "FooBar",
+ "manifest_version": 999999
+}
diff --git a/www/wiki/tests/phpunit/data/registration/no_manifest_version.json b/www/wiki/tests/phpunit/data/registration/no_manifest_version.json
new file mode 100644
index 00000000..1a6119f7
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/registration/no_manifest_version.json
@@ -0,0 +1,3 @@
+{
+ "name": "FooBar"
+}
diff --git a/www/wiki/tests/phpunit/data/registration/notjson.txt b/www/wiki/tests/phpunit/data/registration/notjson.txt
new file mode 100644
index 00000000..d47b4607
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/registration/notjson.txt
@@ -0,0 +1 @@
+This is definitely not JSON.
diff --git a/www/wiki/tests/phpunit/data/registration/old_manifest_version.json b/www/wiki/tests/phpunit/data/registration/old_manifest_version.json
new file mode 100644
index 00000000..f50faa1b
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/registration/old_manifest_version.json
@@ -0,0 +1,4 @@
+{
+ "name": "FooBar",
+ "manifest_version": -2
+}
diff --git a/www/wiki/tests/phpunit/data/resourceloader/abc.gif b/www/wiki/tests/phpunit/data/resourceloader/abc.gif
new file mode 100644
index 00000000..5f454ca1
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/abc.gif
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/resourceloader/def.svg b/www/wiki/tests/phpunit/data/resourceloader/def.svg
new file mode 100644
index 00000000..6ad79174
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/def.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <g id="remove">
+ <path id="trash-can" d="M12 10h-1v6h1v-6zm-2 0h-1v6h1v-6zm4 0h-1v6h1v-6zm0-4v-1h-5v1h-3v3h1v7.966l1 1.031v-.074.077h6.984l.016-.018v.015l1-1.031v-7.966h1v-3h-3zm1 11h-7v-8h7v8zm1-9h-9v-1h9v1z"/>
+ </g>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/resourceloader/def_variantize.svg b/www/wiki/tests/phpunit/data/resourceloader/def_variantize.svg
new file mode 100644
index 00000000..bcbe8712
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/def_variantize.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="red">
+ <g xmlns:default="http://www.w3.org/2000/svg" id="remove">
+ <path id="trash-can" d="M12 10h-1v6h1v-6zm-2 0h-1v6h1v-6zm4 0h-1v6h1v-6zm0-4v-1h-5v1h-3v3h1v7.966l1 1.031v-.074.077h6.984l.016-.018v.015l1-1.031v-7.966h1v-3h-3zm1 11h-7v-8h7v8zm1-9h-9v-1h9v1z"/>
+ </g>
+</g></svg>
diff --git a/www/wiki/tests/phpunit/data/resourceloader/ghi.svg b/www/wiki/tests/phpunit/data/resourceloader/ghi.svg
new file mode 100644
index 00000000..02b4e387
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/ghi.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <path d="M16.5 13.1l-8.9 8.9c-.8-.8-.8-2 0-2.8l6.1-6.1-6-6.1c-.8-.8-.8-2 0-2.8l8.8 8.9z" id="path108"/>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/resourceloader/ghi_massage.svg b/www/wiki/tests/phpunit/data/resourceloader/ghi_massage.svg
new file mode 100644
index 00000000..bbd1a8d6
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/ghi_massage.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <path d="M 16.5 13.1 l -8.9 8.9 c -0.8 -0.8 -0.8 -2 0 -2.8 l 6.1 -6.1 -6 -6.1 c -0.8 -0.8 -0.8 -2 0 -2.8 l 8.8 8.9 z" id="path108"/>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/resourceloader/jkl.svg b/www/wiki/tests/phpunit/data/resourceloader/jkl.svg
new file mode 100644
index 00000000..f31ec095
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/jkl.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <path d="M7 13.1l8.9 8.9c.8-.8.8-2 0-2.8l-6.1-6.1 6-6.1c.8-.8.8-2 0-2.8l-8.8 8.9z"/>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/resourceloader/mno-ltr.svg b/www/wiki/tests/phpunit/data/resourceloader/mno-ltr.svg
new file mode 100644
index 00000000..bb2545c5
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/mno-ltr.svg
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <g id="help">
+ <path id="circle" d="M12.001 2.085c-5.478 0-9.916 4.438-9.916 9.916 0 5.476 4.438 9.914 9.916 9.914 5.476 0 9.914-4.438 9.914-9.914 0-5.478-4.438-9.916-9.914-9.916zm.001 18c-4.465 0-8.084-3.619-8.084-8.083 0-4.465 3.619-8.084 8.084-8.084 4.464 0 8.083 3.619 8.083 8.084 0 4.464-3.619 8.083-8.083 8.083z"/>
+ <g id="question-mark">
+ <path id="top" d="M11.766 6.688c-2.5 0-3.219 2.188-3.219 2.188l1.411.854s.298-.791.901-1.229c.516-.375 1.625-.625 2.219.125.701.885-.17 1.587-1.078 2.719-.953 1.186-1 3.655-1 3.655h1.969s.135-2.318 1.041-3.381c.603-.707 1.443-1.338 1.443-2.494s-1.187-2.437-3.687-2.437z"/>
+ <path id="bottom" d="M11 16h2v2h-2z"/>
+ </g>
+ </g>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/resourceloader/mno-rtl.svg b/www/wiki/tests/phpunit/data/resourceloader/mno-rtl.svg
new file mode 100644
index 00000000..255ae95b
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/mno-rtl.svg
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <g id="help">
+ <path id="circle" d="M12.001 2.085c-5.478 0-9.916 4.438-9.916 9.916 0 5.476 4.438 9.914 9.916 9.914 5.476 0 9.914-4.438 9.914-9.914 0-5.478-4.438-9.916-9.914-9.916zm.001 18c-4.465 0-8.084-3.619-8.084-8.083 0-4.465 3.619-8.084 8.084-8.084 4.464 0 8.083 3.619 8.083 8.084 0 4.464-3.619 8.083-8.083 8.083z"/>
+ <g id="question-mark" transform="translate(24, 0) scale(-1, 1)">
+ <path id="top" d="M11.766 6.688c-2.5 0-3.219 2.188-3.219 2.188l1.411.854s.298-.791.901-1.229c.516-.375 1.625-.625 2.219.125.701.885-.17 1.587-1.078 2.719-.953 1.186-1 3.655-1 3.655h1.969s.135-2.318 1.041-3.381c.603-.707 1.443-1.338 1.443-2.494s-1.187-2.437-3.687-2.437z"/>
+ <path id="bottom" d="M11 16h2v2h-2z"/>
+ </g>
+ </g>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/apex/icons.json b/www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/apex/icons.json
new file mode 100644
index 00000000..fdb4d127
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/apex/icons.json
@@ -0,0 +1,6 @@
+{
+ "prefix": "oo-ui-icon",
+ "images": {
+ "stu": { "file": "images/icons/stu.svg" }
+ }
+}
diff --git a/www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/apex/images/icons/stu.svg b/www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/apex/images/icons/stu.svg
new file mode 100644
index 00000000..27f14df1
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/apex/images/icons/stu.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <g id="stu">
+ <path id="stu-apex" d="M18.87 18.375l-3.987-3.99-.286-.17a5.774 5.774 0 0 0 1.082-3.372C15.67 7.616 13.06 5 9.84 5A5.843 5.843 0 0 0 4 10.844a5.84 5.84 0 0 0 5.842 5.842c1.26 0 2.423-.403 3.377-1.08l.16.286 3.99 3.987c.32.31.91.24 1.33-.18.41-.42.49-1.01.17-1.33zM9.837 14.56a3.72 3.72 0 0 1-3.718-3.717c0-2.05 1.67-3.72 3.72-3.72s3.72 1.668 3.72 3.72a3.722 3.722 0 0 1-3.72 3.718z"/>
+ </g>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/wikimediaui/icons.json b/www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/wikimediaui/icons.json
new file mode 100644
index 00000000..fdb4d127
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/wikimediaui/icons.json
@@ -0,0 +1,6 @@
+{
+ "prefix": "oo-ui-icon",
+ "images": {
+ "stu": { "file": "images/icons/stu.svg" }
+ }
+}
diff --git a/www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/wikimediaui/images/icons/stu.svg b/www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/wikimediaui/images/icons/stu.svg
new file mode 100644
index 00000000..fcaaa8d2
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/oouiimagemodule/wikimediaui/images/icons/stu.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <g id="stu">
+ <path id="stu-wikimediaui" d="M10.5 4a6.5 6.5 0 1 0 2.844 12.344L16 19c1.4 1.4 2.5 1.5 4 0l-4.438-4.438A6.426 6.426 0 0 0 17 10.5 6.5 6.5 0 0 0 10.5 4zm0 2a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9z"/>
+ </g>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/resourceloader/pqr-a.svg b/www/wiki/tests/phpunit/data/resourceloader/pqr-a.svg
new file mode 100644
index 00000000..4b828779
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/pqr-a.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <g id="bold-a">
+ <path d="M16 18h3l-5-12h-3l-5 12h3l1.25-3h4.5l1.25 3zm-4.917-5l1.417-3.4 1.417 3.4h-2.834z"/>
+ </g>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/resourceloader/pqr-b.svg b/www/wiki/tests/phpunit/data/resourceloader/pqr-b.svg
new file mode 100644
index 00000000..4f648203
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/pqr-b.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <g id="bold-b">
+ <path id="b" d="M7 18h6c2 0 4-1 4-3 0-1.064.011-1.975-1.989-3 2-.975 1.989-1.935 1.989-3 0-2-2-3-4-3h-6v12zm7-8c0 1.001 0 1-2 1h-2v-3h2c2 0 2 0 2 1v1zm-2 6h-2v-3h2c2 0 2 0 2 1v1s0 1-2 1z"/>
+ </g>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/resourceloader/pqr-f.svg b/www/wiki/tests/phpunit/data/resourceloader/pqr-f.svg
new file mode 100644
index 00000000..357d2e5d
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/pqr-f.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <g id="bold-f">
+ <path id="f" d="M16 8v-2h-8v12h3v-5h4v-2h-4v-3z"/>
+ </g>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/resourceloader/script-comment.js b/www/wiki/tests/phpunit/data/resourceloader/script-comment.js
new file mode 100644
index 00000000..46b60f9c
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/script-comment.js
@@ -0,0 +1,3 @@
+/* eslint-disable */
+mw.foo()
+// mw.bar();
diff --git a/www/wiki/tests/phpunit/data/resourceloader/script-nosemi.js b/www/wiki/tests/phpunit/data/resourceloader/script-nosemi.js
new file mode 100644
index 00000000..2b1550fa
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/resourceloader/script-nosemi.js
@@ -0,0 +1,2 @@
+/* eslint-disable */
+mw.foo()
diff --git a/www/wiki/tests/phpunit/data/templates/bad_partial.mustache b/www/wiki/tests/phpunit/data/templates/bad_partial.mustache
new file mode 100644
index 00000000..d2767f0f
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/templates/bad_partial.mustache
@@ -0,0 +1 @@
+Partial {{>nonexistenttemplate}} in here
diff --git a/www/wiki/tests/phpunit/data/templates/foobar.mustache b/www/wiki/tests/phpunit/data/templates/foobar.mustache
new file mode 100644
index 00000000..a0423896
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/templates/foobar.mustache
@@ -0,0 +1 @@
+hello world!
diff --git a/www/wiki/tests/phpunit/data/templates/foobar_args.mustache b/www/wiki/tests/phpunit/data/templates/foobar_args.mustache
new file mode 100644
index 00000000..cfbe3d0f
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/templates/foobar_args.mustache
@@ -0,0 +1 @@
+hello {{planet}}!
diff --git a/www/wiki/tests/phpunit/data/templates/has_partial.mustache b/www/wiki/tests/phpunit/data/templates/has_partial.mustache
new file mode 100644
index 00000000..504387a4
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/templates/has_partial.mustache
@@ -0,0 +1 @@
+Partial {{>foobar_args}} in here
diff --git a/www/wiki/tests/phpunit/data/templates/recurse.mustache b/www/wiki/tests/phpunit/data/templates/recurse.mustache
new file mode 100644
index 00000000..391f227e
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/templates/recurse.mustache
@@ -0,0 +1 @@
+r{{#r}}{{>recurse}}{{/r}} \ No newline at end of file
diff --git a/www/wiki/tests/phpunit/data/upload/buggynamespace-bad.svg b/www/wiki/tests/phpunit/data/upload/buggynamespace-bad.svg
new file mode 100644
index 00000000..974fac06
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/upload/buggynamespace-bad.svg
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 14948) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1"
+ id="svg2" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:svg="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:cc="http://creativecommons.org/ns#" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" inkscape:output_extension="org.inkscape.output.svg.inkscape" sodipodi:version="0.32" sodipodi:docname="India_location_map.svg" inkscape:version="0.46"
+ xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="1500px"
+ height="1614.844px" viewBox="0 0 1500 1614.844" enable-background="new 0 0 1500 1614.844" xml:space="preserve">
+<switch>
+ <foreignObject requiredExtensions="&ns_ai;" x="0" y="0" width="1" height="1">
+ <i:pgfRef xlink:href="#adobe_illustrator_pgf">
+ </i:pgfRef>
+ </foreignObject>
+ <g i:extraneous="self">
+ <circle cx="750" cy="750" r="500" />
+ </g>
+</switch>
+<i:pgf id="adobe_illustrator_pgf">
+ <![CDATA[
+ eJzsvWl3XjXyL3pf91r9HZ50MyQkfrw1awcIZCBAYyAQaEIzBMd+krjx1LYDzf/F+exXNUml/Qwx
+d1/++qv/+P2XX3/z1fc//9mvf/jyH796+/Lbc2Z9+eNXvzn/9Pbr77/64cfvvv/q7Ye//+53hNBH
++sFf/MXf/Nv/5ec/+38A7g2BFw==
+ ]]>
+</i:pgf>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/upload/buggynamespace-evilhtml.svg b/www/wiki/tests/phpunit/data/upload/buggynamespace-evilhtml.svg
new file mode 100644
index 00000000..f4be479e
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/upload/buggynamespace-evilhtml.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 14948) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
+ <!ENTITY ns_html "http://www.w3.org/1999/xhtml">
+]>
+<svg:svg version="1.1"
+ id="svg2"
+ xmlns="&ns_html;"
+ xmlns:svg="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="1500px"
+ height="1614.844px" viewBox="0 0 1500 1614.844" enable-background="new 0 0 1500 1614.844" xml:space="preserve">
+ <svg:g><div>foo</div></svg:g>
+</svg:svg>
diff --git a/www/wiki/tests/phpunit/data/upload/buggynamespace-okay.svg b/www/wiki/tests/phpunit/data/upload/buggynamespace-okay.svg
new file mode 100644
index 00000000..4a5c6aae
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/upload/buggynamespace-okay.svg
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:i="&amp;ns_ai;"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ version="1.1"
+ width="1500"
+ height="1614.844"
+ viewBox="0 0 1500 1614.844"
+ id="svg2"
+ xml:space="preserve"><metadata
+ id="metadata15"><rdf:RDF><cc:Work
+ rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
+ id="defs13" />
+<switch
+ id="switch3">
+ <foreignObject
+ id="foreignObject5"
+ height="1"
+ width="1"
+ y="0"
+ x="0"
+ requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/">
+ <i:pgfRef
+ xlink:href="#adobe_illustrator_pgf">
+ </i:pgfRef>
+ </foreignObject>
+ <g
+ id="g7">
+ <circle
+ cx="750"
+ cy="750"
+ r="500"
+ id="circle9" />
+ </g>
+</switch>
+<i:pgf
+ id="adobe_illustrator_pgf">
+
+ eJzsvWl3XjXyL3pf91r9HZ50MyQkfrw1awcIZCBAYyAQaEIzBMd+krjx1LYDzf/F+exXNUml/Qwx
+d1/++qv/+P2XX3/z1fc//9mvf/jyH796+/Lbc2Z9+eNXvzn/9Pbr77/64cfvvv/q7Ye//+53hNBH
++sFf/MXf/Nv/5ec/+38A7g2BFw==
+
+</i:pgf>
+</svg> \ No newline at end of file
diff --git a/www/wiki/tests/phpunit/data/upload/buggynamespace-okay2.svg b/www/wiki/tests/phpunit/data/upload/buggynamespace-okay2.svg
new file mode 100644
index 00000000..fe42310f
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/upload/buggynamespace-okay2.svg
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:i="&amp;#38;ns_ai;"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ version="1.1"
+ width="1500"
+ height="1614.844"
+ viewBox="0 0 1500 1614.844"
+ id="svg2"
+ xml:space="preserve"><metadata
+ id="metadata15"><rdf:RDF><cc:Work
+ rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
+ id="defs13" />
+<switch
+ id="switch3">
+ <foreignObject
+ requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/"
+ x="0"
+ y="0"
+ width="1"
+ height="1"
+ id="foreignObject5">
+ <i:pgfRef
+ xlink:href="#adobe_illustrator_pgf">
+ </i:pgfRef>
+ </foreignObject>
+ <g
+ id="g7">
+ <circle
+ cx="750"
+ cy="750"
+ r="500"
+ id="circle9" />
+ </g>
+</switch>
+<i:pgf
+ id="adobe_illustrator_pgf">
+
+ eJzsvWl3XjXyL3pf91r9HZ50MyQkfrw1awcIZCBAYyAQaEIzBMd+krjx1LYDzf/F+exXNUml/Qwx
+d1/++qv/+P2XX3/z1fc//9mvf/jyH796+/Lbc2Z9+eNXvzn/9Pbr77/64cfvvv/q7Ye//+53hNBH
++sFf/MXf/Nv/5ec/+38A7g2BFw==
+
+</i:pgf>
+</svg> \ No newline at end of file
diff --git a/www/wiki/tests/phpunit/data/upload/buggynamespace-original.svg b/www/wiki/tests/phpunit/data/upload/buggynamespace-original.svg
new file mode 100644
index 00000000..c61c91cf
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/upload/buggynamespace-original.svg
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 14948) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
+ <!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
+ <!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
+ <!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
+ <!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
+ <!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
+ <!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
+ <!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
+ <!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
+]>
+<svg version="1.1"
+ id="svg2" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:svg="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:cc="http://creativecommons.org/ns#" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" inkscape:output_extension="org.inkscape.output.svg.inkscape" sodipodi:version="0.32" sodipodi:docname="India_location_map.svg" inkscape:version="0.46"
+ xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="1500px"
+ height="1614.844px" viewBox="0 0 1500 1614.844" enable-background="new 0 0 1500 1614.844" xml:space="preserve">
+<switch>
+ <foreignObject requiredExtensions="&ns_ai;" x="0" y="0" width="1" height="1">
+ <i:pgfRef xlink:href="#adobe_illustrator_pgf">
+ </i:pgfRef>
+ </foreignObject>
+ <g i:extraneous="self">
+ <circle cx="750" cy="750" r="500" />
+ </g>
+</switch>
+<i:pgf id="adobe_illustrator_pgf">
+ <![CDATA[
+ eJzsvWl3XjXyL3pf91r9HZ50MyQkfrw1awcIZCBAYyAQaEIzBMd+krjx1LYDzf/F+exXNUml/Qwx
+d1/++qv/+P2XX3/z1fc//9mvf/jyH796+/Lbc2Z9+eNXvzn/9Pbr77/64cfvvv/q7Ye//+53hNBH
++sFf/MXf/Nv/5ec/+38A7g2BFw==
+ ]]>
+</i:pgf>
+</svg>
diff --git a/www/wiki/tests/phpunit/data/upload/headbg.jpg b/www/wiki/tests/phpunit/data/upload/headbg.jpg
new file mode 100644
index 00000000..5491c6e4
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/upload/headbg.jpg
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/xmp/1.result.php b/www/wiki/tests/phpunit/data/xmp/1.result.php
new file mode 100644
index 00000000..c91fe298
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/1.result.php
@@ -0,0 +1,8 @@
+<?php
+
+$result = [ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => '9'
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/1.xmp b/www/wiki/tests/phpunit/data/xmp/1.xmp
new file mode 100644
index 00000000..66e15427
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/1.xmp
@@ -0,0 +1,11 @@
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core
+ 4.1.3-c001 49.282696, Mon Apr 02 2007 21:16:10 ">
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/"
+ exif:DigitalZoomRatio="0/10">
+<exif:Flash rdf:parseType='Resource'>
+<exif:Fired>True</exif:Fired> <exif:Return>0</exif:Return> <exif:Mode>1</exif:Mode> <exif:Function>False</exif:Function> <exif:RedEyeMode>False</exif:RedEyeMode></exif:Flash> </rdf:Description> </rdf:RDF> </x:xmpmeta>
+
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/2.result.php b/www/wiki/tests/phpunit/data/xmp/2.result.php
new file mode 100644
index 00000000..c91fe298
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/2.result.php
@@ -0,0 +1,8 @@
+<?php
+
+$result = [ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => '9'
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/2.xmp b/www/wiki/tests/phpunit/data/xmp/2.xmp
new file mode 100644
index 00000000..0fa6a894
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/2.xmp
@@ -0,0 +1,12 @@
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core
+ 4.1.3-c001 49.282696, Mon Apr 02 2007 21:16:10 ">
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/"
+ exif:DigitalZoomRatio="0/10">
+<exif:Flash>
+<rdf:Description exif:Return="0">
+<exif:Fired>True</exif:Fired> <exif:Mode>1</exif:Mode> <exif:Function>False</exif:Function> <exif:RedEyeMode>False</exif:RedEyeMode></rdf:Description></exif:Flash> </rdf:Description> </rdf:RDF> </x:xmpmeta>
+
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/3-invalid.result.php b/www/wiki/tests/phpunit/data/xmp/3-invalid.result.php
new file mode 100644
index 00000000..701661f5
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/3-invalid.result.php
@@ -0,0 +1,7 @@
+<?php
+
+$result = [ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/3-invalid.xmp b/www/wiki/tests/phpunit/data/xmp/3-invalid.xmp
new file mode 100644
index 00000000..2425e254
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/3-invalid.xmp
@@ -0,0 +1,31 @@
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core
+ 4.1.3-c001 49.282696, Mon Apr 02 2007 21:16:10 ">
+<!--
+This file has an invalid flash compoenent (one of the values are a qualifier)
+-->
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/"
+>
+<exif:DigitalZoomRatio>
+
+<rdf:Description>
+<rdf:value>
+0/10
+</rdf:value>
+<exif:foobarbaz>fred</exif:foobarbaz>
+
+</rdf:Description>
+
+</exif:DigitalZoomRatio>
+
+<exif:Flash>
+<rdf:Description exif:Return="0">
+<exif:Mode><rdf:Description>
+<rdf:value>1</rdf:value>
+<exif:Fired>False</exif:Fired> <!-- qualifier. should be ignored-->
+</rdf:Description>
+</exif:Mode> <exif:Function>False</exif:Function> <exif:RedEyeMode>False</exif:RedEyeMode></rdf:Description></exif:Flash> </rdf:Description> </rdf:RDF> </x:xmpmeta>
+
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/3.result.php b/www/wiki/tests/phpunit/data/xmp/3.result.php
new file mode 100644
index 00000000..c91fe298
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/3.result.php
@@ -0,0 +1,8 @@
+<?php
+
+$result = [ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => '9'
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/3.xmp b/www/wiki/tests/phpunit/data/xmp/3.xmp
new file mode 100644
index 00000000..2cf19883
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/3.xmp
@@ -0,0 +1,29 @@
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core
+ 4.1.3-c001 49.282696, Mon Apr 02 2007 21:16:10 ">
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/"
+>
+<exif:DigitalZoomRatio>
+
+<rdf:Description>
+<rdf:value>
+0/10
+</rdf:value>
+<exif:foobarbaz>fred</exif:foobarbaz>
+
+</rdf:Description>
+
+</exif:DigitalZoomRatio>
+
+<exif:Flash>
+<rdf:Description exif:Return="0">
+<exif:Fired>True</exif:Fired>
+<exif:Mode><rdf:Description>
+<rdf:value>1</rdf:value>
+<exif:Fired>False</exif:Fired> <!-- qualifier. should be ignored-->
+</rdf:Description>
+</exif:Mode> <exif:Function>False</exif:Function> <exif:RedEyeMode>False</exif:RedEyeMode></rdf:Description></exif:Flash> </rdf:Description> </rdf:RDF> </x:xmpmeta>
+
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/4.result.php b/www/wiki/tests/phpunit/data/xmp/4.result.php
new file mode 100644
index 00000000..701661f5
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/4.result.php
@@ -0,0 +1,7 @@
+<?php
+
+$result = [ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/4.xmp b/www/wiki/tests/phpunit/data/xmp/4.xmp
new file mode 100644
index 00000000..29eb614b
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/4.xmp
@@ -0,0 +1,22 @@
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core
+ 4.1.3-c001 49.282696, Mon Apr 02 2007 21:16:10 ">
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<!-- Valid output is just the DigitalZoomRatio
+as the flash is a qualifier
+-->
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/">
+ <exif:DigitalZoomRatio>
+<rdf:Description>
+<rdf:value>
+0/10
+</rdf:value>
+<exif:Flash rdf:parseType='Resource'>
+<exif:Fired>True</exif:Fired> <exif:Return>0</exif:Return> <exif:Mode>1</exif:Mode> <exif:Function>False</exif:Function> <exif:RedEyeMode>False</exif:RedEyeMode></exif:Flash>
+</rdf:Description>
+</exif:DigitalZoomRatio>
+</rdf:Description> </rdf:RDF> </x:xmpmeta>
+
+
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/5.result.php b/www/wiki/tests/phpunit/data/xmp/5.result.php
new file mode 100644
index 00000000..701661f5
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/5.result.php
@@ -0,0 +1,7 @@
+<?php
+
+$result = [ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/5.xmp b/www/wiki/tests/phpunit/data/xmp/5.xmp
new file mode 100644
index 00000000..3cc61d68
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/5.xmp
@@ -0,0 +1,16 @@
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core
+ 4.1.3-c001 49.282696, Mon Apr 02 2007 21:16:10 ">
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/">
+ <exif:DigitalZoomRatio>
+<rdf:Description rdf:value="0/10">
+<exif:Flash rdf:parseType='Resource'>
+<exif:Fired>True</exif:Fired> <exif:Return>0</exif:Return> <exif:Mode>1</exif:Mode> <exif:Function>False</exif:Function> <exif:RedEyeMode>False</exif:RedEyeMode></exif:Flash>
+</rdf:Description>
+</exif:DigitalZoomRatio>
+</rdf:Description> </rdf:RDF> </x:xmpmeta>
+
+
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/6.result.php b/www/wiki/tests/phpunit/data/xmp/6.result.php
new file mode 100644
index 00000000..c91fe298
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/6.result.php
@@ -0,0 +1,8 @@
+<?php
+
+$result = [ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => '9'
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/6.xmp b/www/wiki/tests/phpunit/data/xmp/6.xmp
new file mode 100644
index 00000000..f435ab23
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/6.xmp
@@ -0,0 +1,18 @@
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core
+ 4.1.3-c001 49.282696, Mon Apr 02 2007 21:16:10 ">
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/">
+<exif:DigitalZoomRatio>
+0/10
+</exif:DigitalZoomRatio>
+</rdf:Description>
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/">
+
+<exif:Flash rdf:parseType='Resource'>
+<exif:Fired>True</exif:Fired> <exif:Return>0</exif:Return> <exif:Mode>1</exif:Mode> <exif:Function>False</exif:Function> <exif:RedEyeMode>False</exif:RedEyeMode></exif:Flash> </rdf:Description> </rdf:RDF> </x:xmpmeta>
+
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/7.result.php b/www/wiki/tests/phpunit/data/xmp/7.result.php
new file mode 100644
index 00000000..ad88df3e
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/7.result.php
@@ -0,0 +1,52 @@
+<?php
+$result = [
+ 'xmp-exif' =>
+ [
+ 'CameraOwnerName' => 'Me!',
+ ],
+ 'xmp-general' =>
+ [
+ 'LicenseUrl' => 'http://creativecommons.com/cc-by-2.9',
+ 'ImageDescription' =>
+ [
+ 'x-default' => 'Test image for the cc: xmp: xmpRights: namespaces in xmp',
+ '_type' => 'lang',
+ ],
+ 'ObjectName' =>
+ [
+ 'x-default' => 'xmp core/xmp rights/cc ns test',
+ '_type' => 'lang',
+ ],
+ 'DateTimeDigitized' => '2005:04:03',
+ 'Software' => 'The one true editor: Vi (ok i used gimp)',
+ 'Identifier' =>
+ [
+ 0 => 'http://example.com/identifierurl',
+ 1 => 'urn:sha1:342524abcdef',
+ '_type' => 'ul',
+ ],
+ 'Label' => 'Test image',
+ 'DateTimeMetadata' => '2011:05:12',
+ 'DateTime' => '2007:03:04 06:34:10',
+ 'Nickname' => 'My little xmp test image',
+ 'Rating' => '5',
+ 'RightsCertificate' => 'http://example.com/rights-certificate/',
+ 'Copyrighted' => 'True',
+ 'CopyrightOwner' =>
+ [
+ 0 => 'Bawolff is copyright owner',
+ '_type' => 'ul',
+ ],
+ 'UsageTerms' =>
+ [
+ 'x-default' => 'do whatever you want',
+ 'en-gb' => 'Do whatever you want in british english',
+ '_type' => 'lang',
+ ],
+ 'WebStatement' => 'http://example.com/web_statement',
+ ],
+ 'xmp-deprecated' =>
+ [
+ 'Identifier' => 'http://example.com/identifierurl/wrong',
+ ],
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/7.xmp b/www/wiki/tests/phpunit/data/xmp/7.xmp
new file mode 100644
index 00000000..e18e13d9
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/7.xmp
@@ -0,0 +1,67 @@
+<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
+<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 7.30'>
+<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
+
+ <rdf:Description rdf:about=''
+ xmlns:aux='http://ns.adobe.com/exif/1.0/aux/'>
+ <aux:OwnerName>Me!</aux:OwnerName>
+ </rdf:Description>
+
+ <rdf:Description rdf:about=''
+ xmlns:cc='http://creativecommons.org/ns#'>
+ <cc:license>http://creativecommons.com/cc-by-2.9</cc:license>
+ </rdf:Description>
+
+ <rdf:Description rdf:about=''
+ xmlns:dc='http://purl.org/dc/elements/1.1/'>
+ <dc:description>
+ <rdf:Alt>
+ <rdf:li xml:lang='x-default'>Test image for the cc: xmp: xmpRights: namespaces in xmp</rdf:li>
+ </rdf:Alt>
+ </dc:description>
+ <dc:identifier>http://example.com/identifierurl/wrong</dc:identifier>
+ <dc:title>
+ <rdf:Alt>
+ <rdf:li xml:lang='x-default'>xmp core/xmp rights/cc ns test</rdf:li>
+ </rdf:Alt>
+ </dc:title>
+ </rdf:Description>
+
+ <rdf:Description rdf:about=''
+ xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
+ <xmp:CreateDate>2005-04-03</xmp:CreateDate>
+ <xmp:CreatorTool>The one true editor: Vi (ok i used gimp)</xmp:CreatorTool>
+ <xmp:Identifier>
+ <rdf:Bag>
+ <rdf:li>http://example.com/identifierurl
+</rdf:li>
+ <rdf:li>urn:sha1:342524abcdef</rdf:li>
+ </rdf:Bag>
+ </xmp:Identifier>
+ <xmp:Label>Test image</xmp:Label>
+ <xmp:MetadataDate>2011-05-12</xmp:MetadataDate>
+ <xmp:ModifyDate>2007-03-04T12:34:10-06:00</xmp:ModifyDate>
+ <xmp:Nickname>My little xmp test image</xmp:Nickname>
+ <xmp:Rating>7</xmp:Rating>
+ </rdf:Description>
+
+ <rdf:Description rdf:about=''
+ xmlns:xmpRights='http://ns.adobe.com/xap/1.0/rights/'>
+ <xmpRights:Certificate>http://example.com/rights-certificate/</xmpRights:Certificate>
+ <xmpRights:Marked>True</xmpRights:Marked>
+ <xmpRights:Owner>
+ <rdf:Bag>
+ <rdf:li>Bawolff is copyright owner</rdf:li>
+ </rdf:Bag>
+ </xmpRights:Owner>
+ <xmpRights:UsageTerms>
+ <rdf:Alt>
+ <rdf:li xml:lang='x-default'>do whatever you want</rdf:li>
+ <rdf:li xml:lang='en-GB'>Do whatever you want in british english</rdf:li>
+ </rdf:Alt>
+ </xmpRights:UsageTerms>
+ <xmpRights:WebStatement>http://example.com/web_statement</xmpRights:WebStatement>
+ </rdf:Description>
+</rdf:RDF>
+</x:xmpmeta>
+<?xpacket end='r'?>
diff --git a/www/wiki/tests/phpunit/data/xmp/README b/www/wiki/tests/phpunit/data/xmp/README
new file mode 100644
index 00000000..bd949176
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/README
@@ -0,0 +1,3 @@
+This directory contains a bunch of XMP files
+as well as a bunch of php files containing what the
+parsed version of the XMP looks like.
diff --git a/www/wiki/tests/phpunit/data/xmp/bag-for-seq.result.php b/www/wiki/tests/phpunit/data/xmp/bag-for-seq.result.php
new file mode 100644
index 00000000..3d8eeb02
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/bag-for-seq.result.php
@@ -0,0 +1,10 @@
+<?php
+
+$result = [
+ 'xmp-general' => [
+ 'Artist' => [
+ '_type' => 'ul',
+ 0 => 'The author',
+ ]
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/bag-for-seq.xmp b/www/wiki/tests/phpunit/data/xmp/bag-for-seq.xmp
new file mode 100644
index 00000000..c6ed5b7c
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/bag-for-seq.xmp
@@ -0,0 +1 @@
+<?xpacket begin=""?> <x:xmpmeta xmlns:x="adobe:ns:meta/"> <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> <rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"> <dc:creator> <rdf:Bag> <rdf:li>The author</rdf:li> </rdf:Bag> </dc:creator> </rdf:Description> </rdf:RDF> </x:xmpmeta>
diff --git a/www/wiki/tests/phpunit/data/xmp/doctype-included.result.php b/www/wiki/tests/phpunit/data/xmp/doctype-included.result.php
new file mode 100644
index 00000000..d4ac6540
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/doctype-included.result.php
@@ -0,0 +1,3 @@
+<?php
+
+$result = [];
diff --git a/www/wiki/tests/phpunit/data/xmp/doctype-included.xmp b/www/wiki/tests/phpunit/data/xmp/doctype-included.xmp
new file mode 100644
index 00000000..8c946755
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/doctype-included.xmp
@@ -0,0 +1,12 @@
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <!DOCTYPE x:xmpmeta [ <!ENTITY lol "lollollollollollollollollollollol"> ]>
+<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core
+ 4.1.3-c001 49.282696, Mon Apr 02 2007 21:16:10 ">
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/"
+ exif:DigitalZoomRatio="0/10">
+<exif:Flash rdf:parseType='Resource'>
+<exif:Fired>True</exif:Fired> <exif:Return>0</exif:Return> <exif:Mode>1</exif:Mode> <exif:Function>False</exif:Function> <exif:RedEyeMode>False</exif:RedEyeMode></exif:Flash> </rdf:Description> </rdf:RDF> </x:xmpmeta>
+
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/doctype-not-included.xmp b/www/wiki/tests/phpunit/data/xmp/doctype-not-included.xmp
new file mode 100644
index 00000000..9a40b4b0
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/doctype-not-included.xmp
@@ -0,0 +1,11 @@
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core
+ 4.1.3-c001 49.282696, Mon Apr 02 2007 21:16:10 ">
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/"
+ exif:DigitalZoomRatio="0/10">
+<exif:Flash rdf:parseType='Resource'>
+<exif:Fired>True</exif:Fired> <exif:Return>0</exif:Return> <exif:Mode>1</exif:Mode> <exif:Function>False</exif:Function> <exif:RedEyeMode>False</exif:RedEyeMode></exif:Flash> </rdf:Description> </rdf:RDF> </x:xmpmeta>
+
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/flash.result.php b/www/wiki/tests/phpunit/data/xmp/flash.result.php
new file mode 100644
index 00000000..3150d5c3
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/flash.result.php
@@ -0,0 +1,8 @@
+<?php
+
+$result = [ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => '127'
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/flash.xmp b/www/wiki/tests/phpunit/data/xmp/flash.xmp
new file mode 100644
index 00000000..b1373cc2
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/flash.xmp
@@ -0,0 +1,11 @@
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core
+ 4.1.3-c001 49.282696, Mon Apr 02 2007 21:16:10 ">
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/"
+ exif:DigitalZoomRatio="0/10">
+<exif:Flash rdf:parseType='Resource'>
+<exif:Fired>True</exif:Fired> <exif:Return>3</exif:Return> <exif:Mode>3</exif:Mode> <exif:Function>True</exif:Function> <exif:RedEyeMode>True</exif:RedEyeMode></exif:Flash> </rdf:Description> </rdf:RDF> </x:xmpmeta>
+
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/gps.result.php b/www/wiki/tests/phpunit/data/xmp/gps.result.php
new file mode 100644
index 00000000..6bc35f53
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/gps.result.php
@@ -0,0 +1,11 @@
+<?php
+
+$result = [ 'xmp-exif' =>
+ [
+ 'GPSAltitude' => -3.14159265301,
+ 'GPSDOP' => '5/1',
+ 'GPSLatitude' => 88.51805555,
+ 'GPSLongitude' => -21.12356945,
+ 'GPSVersionID' => '2.2.0.0'
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/gps.xmp b/www/wiki/tests/phpunit/data/xmp/gps.xmp
new file mode 100644
index 00000000..e52d2c8a
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/gps.xmp
@@ -0,0 +1,17 @@
+<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
+<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 7.30'>
+<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
+
+ <rdf:Description rdf:about=''
+ xmlns:exif='http://ns.adobe.com/exif/1.0/'>
+ <exif:GPSAltitude>103993/33102</exif:GPSAltitude>
+ <exif:GPSAltitudeRef>1</exif:GPSAltitudeRef>
+ <exif:GPSDOP>5/1</exif:GPSDOP>
+ <exif:GPSLatitude>88,31.083333N</exif:GPSLatitude>
+ <exif:GPSLongitude>21,7.414167W</exif:GPSLongitude>
+ <exif:GPSVersionID>2.2.0.0</exif:GPSVersionID>
+ </rdf:Description>
+
+</rdf:RDF>
+</x:xmpmeta>
+<?xpacket end='w'?>
diff --git a/www/wiki/tests/phpunit/data/xmp/invalid-child-not-struct.result.php b/www/wiki/tests/phpunit/data/xmp/invalid-child-not-struct.result.php
new file mode 100644
index 00000000..701661f5
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/invalid-child-not-struct.result.php
@@ -0,0 +1,7 @@
+<?php
+
+$result = [ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/invalid-child-not-struct.xmp b/www/wiki/tests/phpunit/data/xmp/invalid-child-not-struct.xmp
new file mode 100644
index 00000000..6aa0c10b
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/invalid-child-not-struct.xmp
@@ -0,0 +1,12 @@
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core
+ 4.1.3-c001 49.282696, Mon Apr 02 2007 21:16:10 ">
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/"
+ exif:DigitalZoomRatio="0/10">
+<exif:Fired>True</exif:Fired> <exif:Return>0</exif:Return> <exif:Mode>1</exif:Mode> <exif:Function>False</exif:Function> <exif:RedEyeMode>False</exif:RedEyeMode>
+
+ </rdf:Description> </rdf:RDF> </x:xmpmeta>
+
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/no-namespace.result.php b/www/wiki/tests/phpunit/data/xmp/no-namespace.result.php
new file mode 100644
index 00000000..8e33102c
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/no-namespace.result.php
@@ -0,0 +1,7 @@
+<?php
+
+$result = [ 'xmp-exif' =>
+ [
+ 'FNumber' => '28/10',
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/no-namespace.xmp b/www/wiki/tests/phpunit/data/xmp/no-namespace.xmp
new file mode 100644
index 00000000..7d6cdb2f
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/no-namespace.xmp
@@ -0,0 +1,11 @@
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<!-- Testing it handles random non-namespaced properties in files ok.
+ Some older photoshop's did not include the rdf: prefix on about. -->
+<rdf:Description
+ about=""
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/"
+ exif:FNumber="28/10">
+</rdf:Description>
+</rdf:RDF>
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/no-recognized-props.result.php b/www/wiki/tests/phpunit/data/xmp/no-recognized-props.result.php
new file mode 100644
index 00000000..824a2422
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/no-recognized-props.result.php
@@ -0,0 +1,2 @@
+<?php
+$result = [];
diff --git a/www/wiki/tests/phpunit/data/xmp/no-recognized-props.xmp b/www/wiki/tests/phpunit/data/xmp/no-recognized-props.xmp
new file mode 100644
index 00000000..54e80901
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/no-recognized-props.xmp
@@ -0,0 +1,8 @@
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/not-exif-namespace"
+ exif:FNumber="2/10">
+</rdf:Description>
+</rdf:RDF>
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/utf16BE.result.php b/www/wiki/tests/phpunit/data/xmp/utf16BE.result.php
new file mode 100644
index 00000000..5e876d9b
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/utf16BE.result.php
@@ -0,0 +1,12 @@
+<?php
+
+$result = [
+ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ ],
+ 'xmp-general' =>
+ [
+ 'Label' => '􊯍'
+ ],
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/utf16BE.xmp b/www/wiki/tests/phpunit/data/xmp/utf16BE.xmp
new file mode 100644
index 00000000..0cf60d60
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/utf16BE.xmp
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/xmp/utf16LE.result.php b/www/wiki/tests/phpunit/data/xmp/utf16LE.result.php
new file mode 100644
index 00000000..5e876d9b
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/utf16LE.result.php
@@ -0,0 +1,12 @@
+<?php
+
+$result = [
+ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ ],
+ 'xmp-general' =>
+ [
+ 'Label' => '􊯍'
+ ],
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/utf16LE.xmp b/www/wiki/tests/phpunit/data/xmp/utf16LE.xmp
new file mode 100644
index 00000000..66d71f4c
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/utf16LE.xmp
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/xmp/utf32BE.result.php b/www/wiki/tests/phpunit/data/xmp/utf32BE.result.php
new file mode 100644
index 00000000..5e876d9b
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/utf32BE.result.php
@@ -0,0 +1,12 @@
+<?php
+
+$result = [
+ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ ],
+ 'xmp-general' =>
+ [
+ 'Label' => '􊯍'
+ ],
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/utf32BE.xmp b/www/wiki/tests/phpunit/data/xmp/utf32BE.xmp
new file mode 100644
index 00000000..06afdf92
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/utf32BE.xmp
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/xmp/utf32LE.result.php b/www/wiki/tests/phpunit/data/xmp/utf32LE.result.php
new file mode 100644
index 00000000..5e876d9b
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/utf32LE.result.php
@@ -0,0 +1,12 @@
+<?php
+
+$result = [
+ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ ],
+ 'xmp-general' =>
+ [
+ 'Label' => '􊯍'
+ ],
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/utf32LE.xmp b/www/wiki/tests/phpunit/data/xmp/utf32LE.xmp
new file mode 100644
index 00000000..bf2097fe
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/utf32LE.xmp
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/xmp/xmpExt.result.php b/www/wiki/tests/phpunit/data/xmp/xmpExt.result.php
new file mode 100644
index 00000000..c91fe298
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/xmpExt.result.php
@@ -0,0 +1,8 @@
+<?php
+
+$result = [ 'xmp-exif' =>
+ [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => '9'
+ ]
+];
diff --git a/www/wiki/tests/phpunit/data/xmp/xmpExt.xmp b/www/wiki/tests/phpunit/data/xmp/xmpExt.xmp
new file mode 100644
index 00000000..da0383f8
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/xmpExt.xmp
@@ -0,0 +1,13 @@
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core
+ 4.1.3-c001 49.282696, Mon Apr 02 2007 21:16:10 ">
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/"
+ xmlns:xmpNote="http://ns.adobe.com/xmp/note/"
+ exif:DigitalZoomRatio="0/10"
+ xmpNote:HasExtendedXMP="28C74E0AC2D796886759006FBE2E57B7">
+<exif:Flash rdf:parseType='Resource'>
+<exif:Fired>True</exif:Fired> <exif:Return>0</exif:Return> <exif:Mode>1</exif:Mode> <exif:Function>False</exif:Function> <exif:RedEyeMode>False</exif:RedEyeMode></exif:Flash> </rdf:Description> </rdf:RDF> </x:xmpmeta>
+
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/xmp/xmpExt2.xmp b/www/wiki/tests/phpunit/data/xmp/xmpExt2.xmp
new file mode 100644
index 00000000..060abb2c
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/xmp/xmpExt2.xmp
@@ -0,0 +1,8 @@
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<rdf:Description
+ rdf:about=""
+ xmlns:exif="http://ns.adobe.com/exif/1.0/"
+ exif:FNumber="2/10">
+</rdf:Description>
+</rdf:RDF>
+<?xpacket end="w"?>
diff --git a/www/wiki/tests/phpunit/data/zip/cd-gap.zip b/www/wiki/tests/phpunit/data/zip/cd-gap.zip
new file mode 100644
index 00000000..b5ae6ccd
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/zip/cd-gap.zip
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/zip/cd-truncated.zip b/www/wiki/tests/phpunit/data/zip/cd-truncated.zip
new file mode 100644
index 00000000..4d40d7d4
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/zip/cd-truncated.zip
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/zip/class-trailing-null.zip b/www/wiki/tests/phpunit/data/zip/class-trailing-null.zip
new file mode 100644
index 00000000..31dcf3d8
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/zip/class-trailing-null.zip
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/zip/class-trailing-slash.zip b/www/wiki/tests/phpunit/data/zip/class-trailing-slash.zip
new file mode 100644
index 00000000..9eb1f037
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/zip/class-trailing-slash.zip
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/zip/class.zip b/www/wiki/tests/phpunit/data/zip/class.zip
new file mode 100644
index 00000000..98a625b7
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/zip/class.zip
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/zip/empty.zip b/www/wiki/tests/phpunit/data/zip/empty.zip
new file mode 100644
index 00000000..15cb0ecb
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/zip/empty.zip
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/zip/looks-like-zip64.zip b/www/wiki/tests/phpunit/data/zip/looks-like-zip64.zip
new file mode 100644
index 00000000..7428cddd
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/zip/looks-like-zip64.zip
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/zip/nosig.zip b/www/wiki/tests/phpunit/data/zip/nosig.zip
new file mode 100644
index 00000000..a22c73a4
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/zip/nosig.zip
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/zip/split.zip b/www/wiki/tests/phpunit/data/zip/split.zip
new file mode 100644
index 00000000..6984ae6d
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/zip/split.zip
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/zip/trail.zip b/www/wiki/tests/phpunit/data/zip/trail.zip
new file mode 100644
index 00000000..50bcea12
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/zip/trail.zip
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/zip/wrong-cd-start-disk.zip b/www/wiki/tests/phpunit/data/zip/wrong-cd-start-disk.zip
new file mode 100644
index 00000000..59b45938
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/zip/wrong-cd-start-disk.zip
Binary files differ
diff --git a/www/wiki/tests/phpunit/data/zip/wrong-central-entry-sig.zip b/www/wiki/tests/phpunit/data/zip/wrong-central-entry-sig.zip
new file mode 100644
index 00000000..05329b43
--- /dev/null
+++ b/www/wiki/tests/phpunit/data/zip/wrong-central-entry-sig.zip
Binary files differ
diff --git a/www/wiki/tests/phpunit/docs/ExportDemoTest.php b/www/wiki/tests/phpunit/docs/ExportDemoTest.php
new file mode 100644
index 00000000..8288cae0
--- /dev/null
+++ b/www/wiki/tests/phpunit/docs/ExportDemoTest.php
@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * Test making sure the demo export xml is valid.
+ * This is NOT a unit test
+ *
+ * @group Dump
+ * @group large
+ */
+class ExportDemoTest extends DumpTestCase {
+
+ public function testExportDemo() {
+ $fname = "../../docs/export-demo.xml";
+ $version = WikiExporter::schemaVersion();
+ $dom = new DomDocument();
+ $dom->load( $fname );
+
+ // Ensure, the demo is for the current version
+ $this->assertEquals(
+ $dom->documentElement->getAttribute( 'version' ),
+ $version,
+ 'export-demo.xml should have the current version'
+ );
+
+ $this->assertTrue(
+ $dom->schemaValidate( "../../docs/export-" . $version . ".xsd" ),
+ "schemaValidate has found an error"
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/ActorMigrationTest.php b/www/wiki/tests/phpunit/includes/ActorMigrationTest.php
new file mode 100644
index 00000000..1b0c848b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/ActorMigrationTest.php
@@ -0,0 +1,695 @@
+<?php
+
+use MediaWiki\User\UserIdentity;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Database
+ * @covers ActorMigration
+ */
+class ActorMigrationTest extends MediaWikiLangTestCase {
+
+ protected $tablesUsed = [
+ 'revision',
+ 'revision_actor_temp',
+ 'ipblocks',
+ 'recentchanges',
+ 'actor',
+ ];
+
+ /**
+ * Create an ActorMigration for a particular stage
+ * @param int $stage
+ * @return ActorMigration
+ */
+ protected function makeMigration( $stage ) {
+ return new ActorMigration( $stage );
+ }
+
+ /**
+ * @dataProvider provideGetJoin
+ * @param int $stage
+ * @param string $key
+ * @param array $expect
+ */
+ public function testGetJoin( $stage, $key, $expect ) {
+ $m = $this->makeMigration( $stage );
+ $result = $m->getJoin( $key );
+ $this->assertEquals( $expect, $result );
+ }
+
+ public static function provideGetJoin() {
+ return [
+ 'Simple table, old' => [
+ MIGRATION_OLD, 'rc_user', [
+ 'tables' => [],
+ 'fields' => [
+ 'rc_user' => 'rc_user',
+ 'rc_user_text' => 'rc_user_text',
+ 'rc_actor' => 'NULL',
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Simple table, write-both' => [
+ MIGRATION_WRITE_BOTH, 'rc_user', [
+ 'tables' => [ 'actor_rc_user' => 'actor' ],
+ 'fields' => [
+ 'rc_user' => 'COALESCE( actor_rc_user.actor_user, rc_user )',
+ 'rc_user_text' => 'COALESCE( actor_rc_user.actor_name, rc_user_text )',
+ 'rc_actor' => 'rc_actor',
+ ],
+ 'joins' => [
+ 'actor_rc_user' => [ 'LEFT JOIN', 'actor_rc_user.actor_id = rc_actor' ],
+ ],
+ ],
+ ],
+ 'Simple table, write-new' => [
+ MIGRATION_WRITE_NEW, 'rc_user', [
+ 'tables' => [ 'actor_rc_user' => 'actor' ],
+ 'fields' => [
+ 'rc_user' => 'COALESCE( actor_rc_user.actor_user, rc_user )',
+ 'rc_user_text' => 'COALESCE( actor_rc_user.actor_name, rc_user_text )',
+ 'rc_actor' => 'rc_actor',
+ ],
+ 'joins' => [
+ 'actor_rc_user' => [ 'LEFT JOIN', 'actor_rc_user.actor_id = rc_actor' ],
+ ],
+ ],
+ ],
+ 'Simple table, new' => [
+ MIGRATION_NEW, 'rc_user', [
+ 'tables' => [ 'actor_rc_user' => 'actor' ],
+ 'fields' => [
+ 'rc_user' => 'actor_rc_user.actor_user',
+ 'rc_user_text' => 'actor_rc_user.actor_name',
+ 'rc_actor' => 'rc_actor',
+ ],
+ 'joins' => [
+ 'actor_rc_user' => [ 'JOIN', 'actor_rc_user.actor_id = rc_actor' ],
+ ],
+ ],
+ ],
+
+ 'ipblocks, old' => [
+ MIGRATION_OLD, 'ipb_by', [
+ 'tables' => [],
+ 'fields' => [
+ 'ipb_by' => 'ipb_by',
+ 'ipb_by_text' => 'ipb_by_text',
+ 'ipb_by_actor' => 'NULL',
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'ipblocks, write-both' => [
+ MIGRATION_WRITE_BOTH, 'ipb_by', [
+ 'tables' => [ 'actor_ipb_by' => 'actor' ],
+ 'fields' => [
+ 'ipb_by' => 'COALESCE( actor_ipb_by.actor_user, ipb_by )',
+ 'ipb_by_text' => 'COALESCE( actor_ipb_by.actor_name, ipb_by_text )',
+ 'ipb_by_actor' => 'ipb_by_actor',
+ ],
+ 'joins' => [
+ 'actor_ipb_by' => [ 'LEFT JOIN', 'actor_ipb_by.actor_id = ipb_by_actor' ],
+ ],
+ ],
+ ],
+ 'ipblocks, write-new' => [
+ MIGRATION_WRITE_NEW, 'ipb_by', [
+ 'tables' => [ 'actor_ipb_by' => 'actor' ],
+ 'fields' => [
+ 'ipb_by' => 'COALESCE( actor_ipb_by.actor_user, ipb_by )',
+ 'ipb_by_text' => 'COALESCE( actor_ipb_by.actor_name, ipb_by_text )',
+ 'ipb_by_actor' => 'ipb_by_actor',
+ ],
+ 'joins' => [
+ 'actor_ipb_by' => [ 'LEFT JOIN', 'actor_ipb_by.actor_id = ipb_by_actor' ],
+ ],
+ ],
+ ],
+ 'ipblocks, new' => [
+ MIGRATION_NEW, 'ipb_by', [
+ 'tables' => [ 'actor_ipb_by' => 'actor' ],
+ 'fields' => [
+ 'ipb_by' => 'actor_ipb_by.actor_user',
+ 'ipb_by_text' => 'actor_ipb_by.actor_name',
+ 'ipb_by_actor' => 'ipb_by_actor',
+ ],
+ 'joins' => [
+ 'actor_ipb_by' => [ 'JOIN', 'actor_ipb_by.actor_id = ipb_by_actor' ],
+ ],
+ ],
+ ],
+
+ 'Revision, old' => [
+ MIGRATION_OLD, 'rev_user', [
+ 'tables' => [],
+ 'fields' => [
+ 'rev_user' => 'rev_user',
+ 'rev_user_text' => 'rev_user_text',
+ 'rev_actor' => 'NULL',
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Revision, write-both' => [
+ MIGRATION_WRITE_BOTH, 'rev_user', [
+ 'tables' => [
+ 'temp_rev_user' => 'revision_actor_temp',
+ 'actor_rev_user' => 'actor',
+ ],
+ 'fields' => [
+ 'rev_user' => 'COALESCE( actor_rev_user.actor_user, rev_user )',
+ 'rev_user_text' => 'COALESCE( actor_rev_user.actor_name, rev_user_text )',
+ 'rev_actor' => 'temp_rev_user.revactor_actor',
+ ],
+ 'joins' => [
+ 'temp_rev_user' => [ 'LEFT JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
+ 'actor_rev_user' => [ 'LEFT JOIN', 'actor_rev_user.actor_id = temp_rev_user.revactor_actor' ],
+ ],
+ ],
+ ],
+ 'Revision, write-new' => [
+ MIGRATION_WRITE_NEW, 'rev_user', [
+ 'tables' => [
+ 'temp_rev_user' => 'revision_actor_temp',
+ 'actor_rev_user' => 'actor',
+ ],
+ 'fields' => [
+ 'rev_user' => 'COALESCE( actor_rev_user.actor_user, rev_user )',
+ 'rev_user_text' => 'COALESCE( actor_rev_user.actor_name, rev_user_text )',
+ 'rev_actor' => 'temp_rev_user.revactor_actor',
+ ],
+ 'joins' => [
+ 'temp_rev_user' => [ 'LEFT JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
+ 'actor_rev_user' => [ 'LEFT JOIN', 'actor_rev_user.actor_id = temp_rev_user.revactor_actor' ],
+ ],
+ ],
+ ],
+ 'Revision, new' => [
+ MIGRATION_NEW, 'rev_user', [
+ 'tables' => [
+ 'temp_rev_user' => 'revision_actor_temp',
+ 'actor_rev_user' => 'actor',
+ ],
+ 'fields' => [
+ 'rev_user' => 'actor_rev_user.actor_user',
+ 'rev_user_text' => 'actor_rev_user.actor_name',
+ 'rev_actor' => 'temp_rev_user.revactor_actor',
+ ],
+ 'joins' => [
+ 'temp_rev_user' => [ 'JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
+ 'actor_rev_user' => [ 'JOIN', 'actor_rev_user.actor_id = temp_rev_user.revactor_actor' ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetWhere
+ * @param int $stage
+ * @param string $key
+ * @param UserIdentity[] $users
+ * @param bool $useId
+ * @param array $expect
+ */
+ public function testGetWhere( $stage, $key, $users, $useId, $expect ) {
+ $expect['conds'] = '(' . implode( ') OR (', $expect['orconds'] ) . ')';
+
+ if ( count( $users ) === 1 ) {
+ $users = reset( $users );
+ }
+
+ $m = $this->makeMigration( $stage );
+ $result = $m->getWhere( $this->db, $key, $users, $useId );
+ $this->assertEquals( $expect, $result );
+ }
+
+ public function provideGetWhere() {
+ $makeUserIdentity = function ( $id, $name, $actor ) {
+ $u = $this->getMock( UserIdentity::class );
+ $u->method( 'getId' )->willReturn( $id );
+ $u->method( 'getName' )->willReturn( $name );
+ $u->method( 'getActorId' )->willReturn( $actor );
+ return $u;
+ };
+
+ $genericUser = [ $makeUserIdentity( 1, 'User1', 11 ) ];
+ $complicatedUsers = [
+ $makeUserIdentity( 1, 'User1', 11 ),
+ $makeUserIdentity( 2, 'User2', 12 ),
+ $makeUserIdentity( 3, 'User3', 0 ),
+ $makeUserIdentity( 0, '192.168.12.34', 34 ),
+ $makeUserIdentity( 0, '192.168.12.35', 0 ),
+ ];
+
+ return [
+ 'Simple table, old' => [
+ MIGRATION_OLD, 'rc_user', $genericUser, true, [
+ 'tables' => [],
+ 'orconds' => [ 'userid' => "rc_user = '1'" ],
+ 'joins' => [],
+ ],
+ ],
+ 'Simple table, write-both' => [
+ MIGRATION_WRITE_BOTH, 'rc_user', $genericUser, true, [
+ 'tables' => [],
+ 'orconds' => [
+ 'actor' => "rc_actor = '11'",
+ 'userid' => "rc_actor = '0' AND rc_user = '1'"
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Simple table, write-new' => [
+ MIGRATION_WRITE_NEW, 'rc_user', $genericUser, true, [
+ 'tables' => [],
+ 'orconds' => [
+ 'actor' => "rc_actor = '11'",
+ 'userid' => "rc_actor = '0' AND rc_user = '1'"
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Simple table, new' => [
+ MIGRATION_NEW, 'rc_user', $genericUser, true, [
+ 'tables' => [],
+ 'orconds' => [ 'actor' => "rc_actor = '11'" ],
+ 'joins' => [],
+ ],
+ ],
+
+ 'ipblocks, old' => [
+ MIGRATION_OLD, 'ipb_by', $genericUser, true, [
+ 'tables' => [],
+ 'orconds' => [ 'userid' => "ipb_by = '1'" ],
+ 'joins' => [],
+ ],
+ ],
+ 'ipblocks, write-both' => [
+ MIGRATION_WRITE_BOTH, 'ipb_by', $genericUser, true, [
+ 'tables' => [],
+ 'orconds' => [
+ 'actor' => "ipb_by_actor = '11'",
+ 'userid' => "ipb_by_actor = '0' AND ipb_by = '1'"
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'ipblocks, write-new' => [
+ MIGRATION_WRITE_NEW, 'ipb_by', $genericUser, true, [
+ 'tables' => [],
+ 'orconds' => [
+ 'actor' => "ipb_by_actor = '11'",
+ 'userid' => "ipb_by_actor = '0' AND ipb_by = '1'"
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'ipblocks, new' => [
+ MIGRATION_NEW, 'ipb_by', $genericUser, true, [
+ 'tables' => [],
+ 'orconds' => [ 'actor' => "ipb_by_actor = '11'" ],
+ 'joins' => [],
+ ],
+ ],
+
+ 'Revision, old' => [
+ MIGRATION_OLD, 'rev_user', $genericUser, true, [
+ 'tables' => [],
+ 'orconds' => [ 'userid' => "rev_user = '1'" ],
+ 'joins' => [],
+ ],
+ ],
+ 'Revision, write-both' => [
+ MIGRATION_WRITE_BOTH, 'rev_user', $genericUser, true, [
+ 'tables' => [
+ 'temp_rev_user' => 'revision_actor_temp',
+ ],
+ 'orconds' => [
+ 'actor' =>
+ "(temp_rev_user.revactor_actor IS NOT NULL) AND temp_rev_user.revactor_actor = '11'",
+ 'userid' => "temp_rev_user.revactor_actor IS NULL AND rev_user = '1'"
+ ],
+ 'joins' => [
+ 'temp_rev_user' => [ 'LEFT JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
+ ],
+ ],
+ ],
+ 'Revision, write-new' => [
+ MIGRATION_WRITE_NEW, 'rev_user', $genericUser, true, [
+ 'tables' => [
+ 'temp_rev_user' => 'revision_actor_temp',
+ ],
+ 'orconds' => [
+ 'actor' =>
+ "(temp_rev_user.revactor_actor IS NOT NULL) AND temp_rev_user.revactor_actor = '11'",
+ 'userid' => "temp_rev_user.revactor_actor IS NULL AND rev_user = '1'"
+ ],
+ 'joins' => [
+ 'temp_rev_user' => [ 'LEFT JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
+ ],
+ ],
+ ],
+ 'Revision, new' => [
+ MIGRATION_NEW, 'rev_user', $genericUser, true, [
+ 'tables' => [
+ 'temp_rev_user' => 'revision_actor_temp',
+ ],
+ 'orconds' => [ 'actor' => "temp_rev_user.revactor_actor = '11'" ],
+ 'joins' => [
+ 'temp_rev_user' => [ 'JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
+ ],
+ ],
+ ],
+
+ 'Multiple users, old' => [
+ MIGRATION_OLD, 'rc_user', $complicatedUsers, true, [
+ 'tables' => [],
+ 'orconds' => [
+ 'userid' => "rc_user IN ('1','2','3') ",
+ 'username' => "rc_user_text IN ('192.168.12.34','192.168.12.35') "
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Multiple users, write-both' => [
+ MIGRATION_WRITE_BOTH, 'rc_user', $complicatedUsers, true, [
+ 'tables' => [],
+ 'orconds' => [
+ 'actor' => "rc_actor IN ('11','12','34') ",
+ 'userid' => "rc_actor = '0' AND rc_user IN ('1','2','3') ",
+ 'username' => "rc_actor = '0' AND rc_user_text IN ('192.168.12.34','192.168.12.35') "
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Multiple users, write-new' => [
+ MIGRATION_WRITE_NEW, 'rc_user', $complicatedUsers, true, [
+ 'tables' => [],
+ 'orconds' => [
+ 'actor' => "rc_actor IN ('11','12','34') ",
+ 'userid' => "rc_actor = '0' AND rc_user IN ('1','2','3') ",
+ 'username' => "rc_actor = '0' AND rc_user_text IN ('192.168.12.34','192.168.12.35') "
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Multiple users, new' => [
+ MIGRATION_NEW, 'rc_user', $complicatedUsers, true, [
+ 'tables' => [],
+ 'orconds' => [ 'actor' => "rc_actor IN ('11','12','34') " ],
+ 'joins' => [],
+ ],
+ ],
+
+ 'Multiple users, no use ID, old' => [
+ MIGRATION_OLD, 'rc_user', $complicatedUsers, false, [
+ 'tables' => [],
+ 'orconds' => [
+ 'username' => "rc_user_text IN ('User1','User2','User3','192.168.12.34','192.168.12.35') "
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Multiple users, write-both' => [
+ MIGRATION_WRITE_BOTH, 'rc_user', $complicatedUsers, false, [
+ 'tables' => [],
+ 'orconds' => [
+ 'actor' => "rc_actor IN ('11','12','34') ",
+ 'username' => "rc_actor = '0' AND "
+ . "rc_user_text IN ('User1','User2','User3','192.168.12.34','192.168.12.35') "
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Multiple users, write-new' => [
+ MIGRATION_WRITE_NEW, 'rc_user', $complicatedUsers, false, [
+ 'tables' => [],
+ 'orconds' => [
+ 'actor' => "rc_actor IN ('11','12','34') ",
+ 'username' => "rc_actor = '0' AND "
+ . "rc_user_text IN ('User1','User2','User3','192.168.12.34','192.168.12.35') "
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Multiple users, new' => [
+ MIGRATION_NEW, 'rc_user', $complicatedUsers, false, [
+ 'tables' => [],
+ 'orconds' => [ 'actor' => "rc_actor IN ('11','12','34') " ],
+ 'joins' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideInsertRoundTrip
+ * @param string $table
+ * @param string $key
+ * @param string $pk
+ * @param array $extraFields
+ */
+ public function testInsertRoundTrip( $table, $key, $pk, $extraFields ) {
+ $u = $this->getTestUser()->getUser();
+ $user = $this->getMock( UserIdentity::class );
+ $user->method( 'getId' )->willReturn( $u->getId() );
+ $user->method( 'getName' )->willReturn( $u->getName() );
+ if ( $u->getActorId( $this->db ) ) {
+ $user->method( 'getActorId' )->willReturn( $u->getActorId() );
+ } else {
+ $this->db->insert(
+ 'actor',
+ [ 'actor_user' => $u->getId(), 'actor_name' => $u->getName() ],
+ __METHOD__
+ );
+ $user->method( 'getActorId' )->willReturn( $this->db->insertId() );
+ }
+
+ $stages = [
+ MIGRATION_OLD => [ MIGRATION_OLD, MIGRATION_WRITE_NEW ],
+ MIGRATION_WRITE_BOTH => [ MIGRATION_OLD, MIGRATION_NEW ],
+ MIGRATION_WRITE_NEW => [ MIGRATION_WRITE_BOTH, MIGRATION_NEW ],
+ MIGRATION_NEW => [ MIGRATION_WRITE_BOTH, MIGRATION_NEW ],
+ ];
+
+ $nameKey = $key . '_text';
+ $actorKey = $key === 'ipb_by' ? 'ipb_by_actor' : substr( $key, 0, -5 ) . '_actor';
+
+ foreach ( $stages as $writeStage => $readRange ) {
+ if ( $key === 'ipb_by' ) {
+ $extraFields['ipb_address'] = __CLASS__ . "#$writeStage";
+ }
+
+ $w = $this->makeMigration( $writeStage );
+ $usesTemp = $key === 'rev_user';
+
+ if ( $usesTemp ) {
+ list( $fields, $callback ) = $w->getInsertValuesWithTempTable( $this->db, $key, $user );
+ } else {
+ $fields = $w->getInsertValues( $this->db, $key, $user );
+ }
+
+ if ( $writeStage <= MIGRATION_WRITE_BOTH ) {
+ $this->assertSame( $user->getId(), $fields[$key], "old field, stage=$writeStage" );
+ $this->assertSame( $user->getName(), $fields[$nameKey], "old field, stage=$writeStage" );
+ } else {
+ $this->assertArrayNotHasKey( $key, $fields, "old field, stage=$writeStage" );
+ $this->assertArrayNotHasKey( $nameKey, $fields, "old field, stage=$writeStage" );
+ }
+ if ( $writeStage >= MIGRATION_WRITE_BOTH && !$usesTemp ) {
+ $this->assertSame( $user->getActorId(), $fields[$actorKey], "new field, stage=$writeStage" );
+ } else {
+ $this->assertArrayNotHasKey( $actorKey, $fields, "new field, stage=$writeStage" );
+ }
+
+ $this->db->insert( $table, $extraFields + $fields, __METHOD__ );
+ $id = $this->db->insertId();
+ if ( $usesTemp ) {
+ $callback( $id, $extraFields );
+ }
+
+ for ( $readStage = $readRange[0]; $readStage <= $readRange[1]; $readStage++ ) {
+ $r = $this->makeMigration( $readStage );
+
+ $queryInfo = $r->getJoin( $key );
+ $row = $this->db->selectRow(
+ [ $table ] + $queryInfo['tables'],
+ $queryInfo['fields'],
+ [ $pk => $id ],
+ __METHOD__,
+ [],
+ $queryInfo['joins']
+ );
+
+ $this->assertSame( $user->getId(), (int)$row->$key, "w=$writeStage, r=$readStage, id" );
+ $this->assertSame( $user->getName(), $row->$nameKey, "w=$writeStage, r=$readStage, name" );
+ $this->assertSame(
+ $readStage === MIGRATION_OLD || $writeStage === MIGRATION_OLD ? 0 : $user->getActorId(),
+ (int)$row->$actorKey,
+ "w=$writeStage, r=$readStage, actor"
+ );
+ }
+ }
+ }
+
+ public static function provideInsertRoundTrip() {
+ $db = wfGetDB( DB_REPLICA ); // for timestamps
+
+ $ipbfields = [
+ ];
+ $revfields = [
+ ];
+
+ return [
+ 'recentchanges' => [ 'recentchanges', 'rc_user', 'rc_id', [
+ 'rc_timestamp' => $db->timestamp(),
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Test',
+ 'rc_this_oldid' => 42,
+ 'rc_last_oldid' => 41,
+ 'rc_source' => 'test',
+ ] ],
+ 'ipblocks' => [ 'ipblocks', 'ipb_by', 'ipb_id', [
+ 'ipb_range_start' => '',
+ 'ipb_range_end' => '',
+ 'ipb_timestamp' => $db->timestamp(),
+ 'ipb_expiry' => $db->getInfinity(),
+ ] ],
+ 'revision' => [ 'revision', 'rev_user', 'rev_id', [
+ 'rev_page' => 42,
+ 'rev_text_id' => 42,
+ 'rev_len' => 0,
+ 'rev_timestamp' => $db->timestamp(),
+ ] ],
+ ];
+ }
+
+ public static function provideStages() {
+ return [
+ 'MIGRATION_OLD' => [ MIGRATION_OLD ],
+ 'MIGRATION_WRITE_BOTH' => [ MIGRATION_WRITE_BOTH ],
+ 'MIGRATION_WRITE_NEW' => [ MIGRATION_WRITE_NEW ],
+ 'MIGRATION_NEW' => [ MIGRATION_NEW ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideStages
+ * @param int $stage
+ * @expectedException InvalidArgumentException
+ * @expectedExceptionMessage Must use getInsertValuesWithTempTable() for rev_user
+ */
+ public function testInsertWrong( $stage ) {
+ $m = $this->makeMigration( $stage );
+ $m->getInsertValues( $this->db, 'rev_user', $this->getTestUser()->getUser() );
+ }
+
+ /**
+ * @dataProvider provideStages
+ * @param int $stage
+ * @expectedException InvalidArgumentException
+ * @expectedExceptionMessage Must use getInsertValues() for rc_user
+ */
+ public function testInsertWithTempTableWrong( $stage ) {
+ $m = $this->makeMigration( $stage );
+ $m->getInsertValuesWithTempTable( $this->db, 'rc_user', $this->getTestUser()->getUser() );
+ }
+
+ /**
+ * @dataProvider provideStages
+ * @param int $stage
+ */
+ public function testInsertWithTempTableDeprecated( $stage ) {
+ $wrap = TestingAccessWrapper::newFromClass( ActorMigration::class );
+ $wrap->formerTempTables += [ 'rc_user' => '1.30' ];
+
+ $this->hideDeprecated( 'ActorMigration::getInsertValuesWithTempTable for rc_user' );
+ $m = $this->makeMigration( $stage );
+ list( $fields, $callback )
+ = $m->getInsertValuesWithTempTable( $this->db, 'rc_user', $this->getTestUser()->getUser() );
+ $this->assertTrue( is_callable( $callback ) );
+ }
+
+ /**
+ * @dataProvider provideStages
+ * @param int $stage
+ * @expectedException InvalidArgumentException
+ * @expectedExceptionMessage $extra[rev_timestamp] is not provided
+ */
+ public function testInsertWithTempTableCallbackMissingFields( $stage ) {
+ $m = $this->makeMigration( $stage );
+ list( $fields, $callback )
+ = $m->getInsertValuesWithTempTable( $this->db, 'rev_user', $this->getTestUser()->getUser() );
+ $callback( 1, [] );
+ }
+
+ public function testInsertUserIdentity() {
+ $user = $this->getTestUser()->getUser();
+ $userIdentity = $this->getMock( UserIdentity::class );
+ $userIdentity->method( 'getId' )->willReturn( $user->getId() );
+ $userIdentity->method( 'getName' )->willReturn( $user->getName() );
+ $userIdentity->method( 'getActorId' )->willReturn( 0 );
+
+ list( $cFields, $cCallback ) = CommentStore::newKey( 'rev_comment' )
+ ->insertWithTempTable( $this->db, '' );
+ $m = $this->makeMigration( MIGRATION_WRITE_BOTH );
+ list( $fields, $callback ) =
+ $m->getInsertValuesWithTempTable( $this->db, 'rev_user', $userIdentity );
+ $extraFields = [
+ 'rev_page' => 42,
+ 'rev_text_id' => 42,
+ 'rev_len' => 0,
+ 'rev_timestamp' => $this->db->timestamp(),
+ ] + $cFields;
+ $this->db->insert( 'revision', $extraFields + $fields, __METHOD__ );
+ $id = $this->db->insertId();
+ $callback( $id, $extraFields );
+ $cCallback( $id );
+
+ $qi = Revision::getQueryInfo();
+ $row = $this->db->selectRow(
+ $qi['tables'], $qi['fields'], [ 'rev_id' => $id ], __METHOD__, [], $qi['joins']
+ );
+ $this->assertSame( $user->getId(), (int)$row->rev_user );
+ $this->assertSame( $user->getName(), $row->rev_user_text );
+ $this->assertSame( $user->getActorId(), (int)$row->rev_actor );
+
+ $m = $this->makeMigration( MIGRATION_WRITE_BOTH );
+ $fields = $m->getInsertValues( $this->db, 'dummy_user', $userIdentity );
+ $this->assertSame( $user->getId(), $fields['dummy_user'] );
+ $this->assertSame( $user->getName(), $fields['dummy_user_text'] );
+ $this->assertSame( $user->getActorId(), $fields['dummy_actor'] );
+ }
+
+ public function testConstructor() {
+ $m = ActorMigration::newMigration();
+ $this->assertInstanceOf( ActorMigration::class, $m );
+ $this->assertSame( $m, ActorMigration::newMigration() );
+ }
+
+ /**
+ * @dataProvider provideIsAnon
+ * @param int $stage
+ * @param string $isAnon
+ * @param string $isNotAnon
+ */
+ public function testIsAnon( $stage, $isAnon, $isNotAnon ) {
+ $m = $this->makeMigration( $stage );
+ $this->assertSame( $isAnon, $m->isAnon( 'foo' ) );
+ $this->assertSame( $isNotAnon, $m->isNotAnon( 'foo' ) );
+ }
+
+ public static function provideIsAnon() {
+ return [
+ 'MIGRATION_OLD' => [ MIGRATION_OLD, 'foo = 0', 'foo != 0' ],
+ 'MIGRATION_WRITE_BOTH' => [ MIGRATION_WRITE_BOTH, 'foo = 0', 'foo != 0' ],
+ 'MIGRATION_WRITE_NEW' => [ MIGRATION_WRITE_NEW, 'foo = 0', 'foo != 0' ],
+ 'MIGRATION_NEW' => [ MIGRATION_NEW, 'foo IS NULL', 'foo IS NOT NULL' ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/AutopromoteTest.php b/www/wiki/tests/phpunit/includes/AutopromoteTest.php
new file mode 100644
index 00000000..8c4a488e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/AutopromoteTest.php
@@ -0,0 +1,56 @@
+<?php
+
+/**
+ * @covers Autopromote
+ */
+class AutopromoteTest extends MediaWikiTestCase {
+ /**
+ * T157718: Verify Autopromote does not perform edit count lookup if requirement is 0 or invalid
+ *
+ * @see Autopromote::getAutopromoteGroups()
+ * @dataProvider provideEditCountsAndRequirements
+ * @param int $editCount edit count of user to be checked by Autopromote
+ * @param int $requirement edit count required to autopromote user
+ */
+ public function testEditCountLookupIsSkippedIfRequirementIsZero( $editCount, $requirement ) {
+ $this->setMwGlobals( [
+ 'wgAutopromote' => [
+ 'autoconfirmed' => [ APCOND_EDITCOUNT, $requirement ]
+ ]
+ ] );
+
+ /** @var PHPUnit_Framework_MockObject_MockObject|User $userMock */
+ $userMock = $this->getMock( User::class, [ 'getEditCount' ] );
+ if ( $requirement > 0 ) {
+ $userMock->expects( $this->once() )
+ ->method( 'getEditCount' )
+ ->willReturn( $editCount );
+ } else {
+ $userMock->expects( $this->never() )
+ ->method( 'getEditCount' );
+ }
+
+ $result = Autopromote::getAutopromoteGroups( $userMock );
+ if ( $editCount >= $requirement ) {
+ $this->assertContains(
+ 'autoconfirmed',
+ $result,
+ 'User must be promoted if they meet edit count requirement'
+ );
+ } else {
+ $this->assertNotContains(
+ 'autoconfirmed',
+ $result,
+ 'User must not be promoted if they fail edit count requirement'
+ );
+ }
+ }
+
+ public static function provideEditCountsAndRequirements() {
+ return [
+ 'user with sufficient editcount' => [ 100, 10 ],
+ 'user with insufficient editcount' => [ 4, 10 ],
+ 'edit count requirement set to 0' => [ 1, 0 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/BlockTest.php b/www/wiki/tests/phpunit/includes/BlockTest.php
new file mode 100644
index 00000000..19780a68
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/BlockTest.php
@@ -0,0 +1,463 @@
+<?php
+
+/**
+ * @group Database
+ * @group Blocking
+ */
+class BlockTest extends MediaWikiLangTestCase {
+
+ /**
+ * @return User
+ */
+ private function getUserForBlocking() {
+ $testUser = $this->getMutableTestUser();
+ $user = $testUser->getUser();
+ $user->addToDatabase();
+ TestUser::setPasswordForUser( $user, 'UTBlockeePassword' );
+ $user->saveSettings();
+ return $user;
+ }
+
+ /**
+ * @param User $user
+ *
+ * @return Block
+ * @throws MWException
+ */
+ private function addBlockForUser( User $user ) {
+ // Delete the last round's block if it's still there
+ $oldBlock = Block::newFromTarget( $user->getName() );
+ if ( $oldBlock ) {
+ // An old block will prevent our new one from saving.
+ $oldBlock->delete();
+ }
+
+ $blockOptions = [
+ 'address' => $user->getName(),
+ 'user' => $user->getId(),
+ 'by' => $this->getTestSysop()->getUser()->getId(),
+ 'reason' => 'Parce que',
+ 'expiry' => time() + 100500,
+ ];
+ $block = new Block( $blockOptions );
+
+ $block->insert();
+ // save up ID for use in assertion. Since ID is an autoincrement,
+ // its value might change depending on the order the tests are run.
+ // ApiBlockTest insert its own blocks!
+ if ( !$block->getId() ) {
+ throw new MWException( "Failed to insert block for BlockTest; old leftover block remaining?" );
+ }
+
+ $this->addXffBlocks();
+
+ return $block;
+ }
+
+ /**
+ * @covers Block::newFromTarget
+ */
+ public function testINewFromTargetReturnsCorrectBlock() {
+ $user = $this->getUserForBlocking();
+ $block = $this->addBlockForUser( $user );
+
+ $this->assertTrue(
+ $block->equals( Block::newFromTarget( $user->getName() ) ),
+ "newFromTarget() returns the same block as the one that was made"
+ );
+ }
+
+ /**
+ * @covers Block::newFromID
+ */
+ public function testINewFromIDReturnsCorrectBlock() {
+ $user = $this->getUserForBlocking();
+ $block = $this->addBlockForUser( $user );
+
+ $this->assertTrue(
+ $block->equals( Block::newFromID( $block->getId() ) ),
+ "newFromID() returns the same block as the one that was made"
+ );
+ }
+
+ /**
+ * per T28425
+ * @covers Block::__construct
+ */
+ public function testBug26425BlockTimestampDefaultsToTime() {
+ $user = $this->getUserForBlocking();
+ $block = $this->addBlockForUser( $user );
+ $madeAt = wfTimestamp( TS_MW );
+
+ // delta to stop one-off errors when things happen to go over a second mark.
+ $delta = abs( $madeAt - $block->mTimestamp );
+ $this->assertLessThan(
+ 2,
+ $delta,
+ "If no timestamp is specified, the block is recorded as time()"
+ );
+ }
+
+ /**
+ * CheckUser since being changed to use Block::newFromTarget started failing
+ * because the new function didn't accept empty strings like Block::load()
+ * had. Regression T31116.
+ *
+ * @dataProvider provideBug29116Data
+ * @covers Block::newFromTarget
+ */
+ public function testBug29116NewFromTargetWithEmptyIp( $vagueTarget ) {
+ $user = $this->getUserForBlocking();
+ $initialBlock = $this->addBlockForUser( $user );
+ $block = Block::newFromTarget( $user->getName(), $vagueTarget );
+
+ $this->assertTrue(
+ $initialBlock->equals( $block ),
+ "newFromTarget() returns the same block as the one that was made when "
+ . "given empty vagueTarget param " . var_export( $vagueTarget, true )
+ );
+ }
+
+ public static function provideBug29116Data() {
+ return [
+ [ null ],
+ [ '' ],
+ [ false ]
+ ];
+ }
+
+ /**
+ * @covers Block::prevents
+ */
+ public function testBlockedUserCanNotCreateAccount() {
+ $username = 'BlockedUserToCreateAccountWith';
+ $u = User::newFromName( $username );
+ $u->addToDatabase();
+ $userId = $u->getId();
+ $this->assertNotEquals( 0, $userId, 'sanity' );
+ TestUser::setPasswordForUser( $u, 'NotRandomPass' );
+ unset( $u );
+
+ // Sanity check
+ $this->assertNull(
+ Block::newFromTarget( $username ),
+ "$username should not be blocked"
+ );
+
+ // Reload user
+ $u = User::newFromName( $username );
+ $this->assertFalse(
+ $u->isBlockedFromCreateAccount(),
+ "Our sandbox user should be able to create account before being blocked"
+ );
+
+ // Foreign perspective (blockee not on current wiki)...
+ $blockOptions = [
+ 'address' => $username,
+ 'user' => $userId,
+ 'reason' => 'crosswiki block...',
+ 'timestamp' => wfTimestampNow(),
+ 'expiry' => $this->db->getInfinity(),
+ 'createAccount' => true,
+ 'enableAutoblock' => true,
+ 'hideName' => true,
+ 'blockEmail' => true,
+ 'byText' => 'm>MetaWikiUser',
+ ];
+ $block = new Block( $blockOptions );
+ $block->insert();
+
+ // Reload block from DB
+ $userBlock = Block::newFromTarget( $username );
+ $this->assertTrue(
+ (bool)$block->prevents( 'createaccount' ),
+ "Block object in DB should prevents 'createaccount'"
+ );
+
+ $this->assertInstanceOf(
+ Block::class,
+ $userBlock,
+ "'$username' block block object should be existent"
+ );
+
+ // Reload user
+ $u = User::newFromName( $username );
+ $this->assertTrue(
+ (bool)$u->isBlockedFromCreateAccount(),
+ "Our sandbox user '$username' should NOT be able to create account"
+ );
+ }
+
+ /**
+ * @covers Block::insert
+ */
+ public function testCrappyCrossWikiBlocks() {
+ // Delete the last round's block if it's still there
+ $oldBlock = Block::newFromTarget( 'UserOnForeignWiki' );
+ if ( $oldBlock ) {
+ // An old block will prevent our new one from saving.
+ $oldBlock->delete();
+ }
+
+ // Local perspective (blockee on current wiki)...
+ $user = User::newFromName( 'UserOnForeignWiki' );
+ $user->addToDatabase();
+ $userId = $user->getId();
+ $this->assertNotEquals( 0, $userId, 'sanity' );
+
+ // Foreign perspective (blockee not on current wiki)...
+ $blockOptions = [
+ 'address' => 'UserOnForeignWiki',
+ 'user' => $user->getId(),
+ 'reason' => 'crosswiki block...',
+ 'timestamp' => wfTimestampNow(),
+ 'expiry' => $this->db->getInfinity(),
+ 'createAccount' => true,
+ 'enableAutoblock' => true,
+ 'hideName' => true,
+ 'blockEmail' => true,
+ 'byText' => 'Meta>MetaWikiUser',
+ ];
+ $block = new Block( $blockOptions );
+
+ $res = $block->insert( $this->db );
+ $this->assertTrue( (bool)$res['id'], 'Block succeeded' );
+
+ $user = null; // clear
+
+ $block = Block::newFromID( $res['id'] );
+ $this->assertEquals(
+ 'UserOnForeignWiki',
+ $block->getTarget()->getName(),
+ 'Correct blockee name'
+ );
+ $this->assertEquals( $userId, $block->getTarget()->getId(), 'Correct blockee id' );
+ $this->assertEquals( 'Meta>MetaWikiUser', $block->getBlocker()->getName(),
+ 'Correct blocker name' );
+ $this->assertEquals( 'Meta>MetaWikiUser', $block->getByName(), 'Correct blocker name' );
+ $this->assertEquals( 0, $block->getBy(), 'Correct blocker id' );
+ }
+
+ protected function addXffBlocks() {
+ static $inited = false;
+
+ if ( $inited ) {
+ return;
+ }
+
+ $inited = true;
+
+ $blockList = [
+ [ 'target' => '70.2.0.0/16',
+ 'type' => Block::TYPE_RANGE,
+ 'desc' => 'Range Hardblock',
+ 'ACDisable' => false,
+ 'isHardblock' => true,
+ 'isAutoBlocking' => false,
+ ],
+ [ 'target' => '2001:4860:4001::/48',
+ 'type' => Block::TYPE_RANGE,
+ 'desc' => 'Range6 Hardblock',
+ 'ACDisable' => false,
+ 'isHardblock' => true,
+ 'isAutoBlocking' => false,
+ ],
+ [ 'target' => '60.2.0.0/16',
+ 'type' => Block::TYPE_RANGE,
+ 'desc' => 'Range Softblock with AC Disabled',
+ 'ACDisable' => true,
+ 'isHardblock' => false,
+ 'isAutoBlocking' => false,
+ ],
+ [ 'target' => '50.2.0.0/16',
+ 'type' => Block::TYPE_RANGE,
+ 'desc' => 'Range Softblock',
+ 'ACDisable' => false,
+ 'isHardblock' => false,
+ 'isAutoBlocking' => false,
+ ],
+ [ 'target' => '50.1.1.1',
+ 'type' => Block::TYPE_IP,
+ 'desc' => 'Exact Softblock',
+ 'ACDisable' => false,
+ 'isHardblock' => false,
+ 'isAutoBlocking' => false,
+ ],
+ ];
+
+ $blocker = $this->getTestUser()->getUser();
+ foreach ( $blockList as $insBlock ) {
+ $target = $insBlock['target'];
+
+ if ( $insBlock['type'] === Block::TYPE_IP ) {
+ $target = User::newFromName( IP::sanitizeIP( $target ), false )->getName();
+ } elseif ( $insBlock['type'] === Block::TYPE_RANGE ) {
+ $target = IP::sanitizeRange( $target );
+ }
+
+ $block = new Block();
+ $block->setTarget( $target );
+ $block->setBlocker( $blocker );
+ $block->mReason = $insBlock['desc'];
+ $block->mExpiry = 'infinity';
+ $block->prevents( 'createaccount', $insBlock['ACDisable'] );
+ $block->isHardblock( $insBlock['isHardblock'] );
+ $block->isAutoblocking( $insBlock['isAutoBlocking'] );
+ $block->insert();
+ }
+ }
+
+ public static function providerXff() {
+ return [
+ [ 'xff' => '1.2.3.4, 70.2.1.1, 60.2.1.1, 2.3.4.5',
+ 'count' => 2,
+ 'result' => 'Range Hardblock'
+ ],
+ [ 'xff' => '1.2.3.4, 50.2.1.1, 60.2.1.1, 2.3.4.5',
+ 'count' => 2,
+ 'result' => 'Range Softblock with AC Disabled'
+ ],
+ [ 'xff' => '1.2.3.4, 70.2.1.1, 50.1.1.1, 2.3.4.5',
+ 'count' => 2,
+ 'result' => 'Exact Softblock'
+ ],
+ [ 'xff' => '1.2.3.4, 70.2.1.1, 50.2.1.1, 50.1.1.1, 2.3.4.5',
+ 'count' => 3,
+ 'result' => 'Exact Softblock'
+ ],
+ [ 'xff' => '1.2.3.4, 70.2.1.1, 50.2.1.1, 2.3.4.5',
+ 'count' => 2,
+ 'result' => 'Range Hardblock'
+ ],
+ [ 'xff' => '1.2.3.4, 70.2.1.1, 60.2.1.1, 2.3.4.5',
+ 'count' => 2,
+ 'result' => 'Range Hardblock'
+ ],
+ [ 'xff' => '50.2.1.1, 60.2.1.1, 2.3.4.5',
+ 'count' => 2,
+ 'result' => 'Range Softblock with AC Disabled'
+ ],
+ [ 'xff' => '1.2.3.4, 50.1.1.1, 60.2.1.1, 2.3.4.5',
+ 'count' => 2,
+ 'result' => 'Exact Softblock'
+ ],
+ [ 'xff' => '1.2.3.4, <$A_BUNCH-OF{INVALID}TEXT\>, 60.2.1.1, 2.3.4.5',
+ 'count' => 1,
+ 'result' => 'Range Softblock with AC Disabled'
+ ],
+ [ 'xff' => '1.2.3.4, 50.2.1.1, 2001:4860:4001:802::1003, 2.3.4.5',
+ 'count' => 2,
+ 'result' => 'Range6 Hardblock'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providerXff
+ * @covers Block::getBlocksForIPList
+ * @covers Block::chooseBlock
+ */
+ public function testBlocksOnXff( $xff, $exCount, $exResult ) {
+ $user = $this->getUserForBlocking();
+ $this->addBlockForUser( $user );
+
+ $list = array_map( 'trim', explode( ',', $xff ) );
+ $xffblocks = Block::getBlocksForIPList( $list, true );
+ $this->assertEquals( $exCount, count( $xffblocks ), 'Number of blocks for ' . $xff );
+ $block = Block::chooseBlock( $xffblocks, $list );
+ $this->assertEquals( $exResult, $block->mReason, 'Correct block type for XFF header ' . $xff );
+ }
+
+ /**
+ * @covers Block::__construct
+ */
+ public function testDeprecatedConstructor() {
+ $this->hideDeprecated( 'Block::__construct with multiple arguments' );
+ $username = 'UnthinkablySecretRandomUsername';
+ $reason = 'being irrational';
+
+ # Set up the target
+ $u = User::newFromName( $username );
+ if ( $u->getId() == 0 ) {
+ $u->addToDatabase();
+ TestUser::setPasswordForUser( $u, 'TotallyObvious' );
+ }
+ unset( $u );
+
+ # Make sure the user isn't blocked
+ $this->assertNull(
+ Block::newFromTarget( $username ),
+ "$username should not be blocked"
+ );
+
+ # Perform the block
+ $block = new Block(
+ /* address */ $username,
+ /* user */ 0,
+ /* by */ $this->getTestSysop()->getUser()->getId(),
+ /* reason */ $reason,
+ /* timestamp */ 0,
+ /* auto */ false,
+ /* expiry */ 0
+ );
+ $block->insert();
+
+ # Check target
+ $this->assertEquals(
+ $block->getTarget()->getName(),
+ $username,
+ "Target should be set properly"
+ );
+
+ # Check supplied parameter
+ $this->assertEquals(
+ $block->mReason,
+ $reason,
+ "Reason should be non-default"
+ );
+
+ # Check default parameter
+ $this->assertFalse(
+ (bool)$block->prevents( 'createaccount' ),
+ "Account creation should not be blocked by default"
+ );
+ }
+
+ /**
+ * @covers Block::getSystemBlockType
+ * @covers Block::insert
+ * @covers Block::doAutoblock
+ */
+ public function testSystemBlocks() {
+ $user = $this->getUserForBlocking();
+ $this->addBlockForUser( $user );
+
+ $blockOptions = [
+ 'address' => $user->getName(),
+ 'reason' => 'test system block',
+ 'timestamp' => wfTimestampNow(),
+ 'expiry' => $this->db->getInfinity(),
+ 'byText' => 'MediaWiki default',
+ 'systemBlock' => 'test',
+ 'enableAutoblock' => true,
+ ];
+ $block = new Block( $blockOptions );
+
+ $this->assertSame( 'test', $block->getSystemBlockType() );
+
+ try {
+ $block->insert();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( MWException $ex ) {
+ $this->assertSame( 'Cannot insert a system block into the database', $ex->getMessage() );
+ }
+
+ try {
+ $block->doAutoblock( '192.0.2.2' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( MWException $ex ) {
+ $this->assertSame( 'Cannot autoblock from a system block', $ex->getMessage() );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/CommentStoreTest.php b/www/wiki/tests/phpunit/includes/CommentStoreTest.php
new file mode 100644
index 00000000..a5108976
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/CommentStoreTest.php
@@ -0,0 +1,778 @@
+<?php
+
+use Wikimedia\ScopedCallback;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Database
+ * @covers CommentStore
+ * @covers CommentStoreComment
+ */
+class CommentStoreTest extends MediaWikiLangTestCase {
+
+ protected $tablesUsed = [
+ 'revision',
+ 'revision_comment_temp',
+ 'ipblocks',
+ 'comment',
+ ];
+
+ /**
+ * Create a store for a particular stage
+ * @param int $stage
+ * @return CommentStore
+ */
+ protected function makeStore( $stage ) {
+ global $wgContLang;
+ $store = new CommentStore( $wgContLang, $stage );
+ return $store;
+ }
+
+ /**
+ * Create a store for a particular stage and key (for testing deprecated behaviour)
+ * @param int $stage
+ * @param string $key
+ * @return CommentStore
+ */
+ protected function makeStoreWithKey( $stage, $key ) {
+ $store = CommentStore::newKey( $key );
+ TestingAccessWrapper::newFromObject( $store )->stage = $stage;
+ return $store;
+ }
+
+ /**
+ * @dataProvider provideGetFields
+ * @param int $stage
+ * @param string $key
+ * @param array $expect
+ */
+ public function testGetFields_withKeyConstruction( $stage, $key, $expect ) {
+ $store = $this->makeStoreWithKey( $stage, $key );
+ $result = $store->getFields();
+ $this->assertEquals( $expect, $result );
+ }
+
+ /**
+ * @dataProvider provideGetFields
+ * @param int $stage
+ * @param string $key
+ * @param array $expect
+ */
+ public function testGetFields( $stage, $key, $expect ) {
+ $store = $this->makeStore( $stage );
+ $result = $store->getFields( $key );
+ $this->assertEquals( $expect, $result );
+ }
+
+ public static function provideGetFields() {
+ return [
+ 'Simple table, old' => [
+ MIGRATION_OLD, 'ipb_reason',
+ [ 'ipb_reason_text' => 'ipb_reason', 'ipb_reason_data' => 'NULL', 'ipb_reason_cid' => 'NULL' ],
+ ],
+ 'Simple table, write-both' => [
+ MIGRATION_WRITE_BOTH, 'ipb_reason',
+ [ 'ipb_reason_old' => 'ipb_reason', 'ipb_reason_id' => 'ipb_reason_id' ],
+ ],
+ 'Simple table, write-new' => [
+ MIGRATION_WRITE_NEW, 'ipb_reason',
+ [ 'ipb_reason_old' => 'ipb_reason', 'ipb_reason_id' => 'ipb_reason_id' ],
+ ],
+ 'Simple table, new' => [
+ MIGRATION_NEW, 'ipb_reason',
+ [ 'ipb_reason_id' => 'ipb_reason_id' ],
+ ],
+
+ 'Revision, old' => [
+ MIGRATION_OLD, 'rev_comment',
+ [
+ 'rev_comment_text' => 'rev_comment',
+ 'rev_comment_data' => 'NULL',
+ 'rev_comment_cid' => 'NULL',
+ ],
+ ],
+ 'Revision, write-both' => [
+ MIGRATION_WRITE_BOTH, 'rev_comment',
+ [ 'rev_comment_old' => 'rev_comment', 'rev_comment_pk' => 'rev_id' ],
+ ],
+ 'Revision, write-new' => [
+ MIGRATION_WRITE_NEW, 'rev_comment',
+ [ 'rev_comment_old' => 'rev_comment', 'rev_comment_pk' => 'rev_id' ],
+ ],
+ 'Revision, new' => [
+ MIGRATION_NEW, 'rev_comment',
+ [ 'rev_comment_pk' => 'rev_id' ],
+ ],
+
+ 'Image, old' => [
+ MIGRATION_OLD, 'img_description',
+ [
+ 'img_description_text' => 'img_description',
+ 'img_description_data' => 'NULL',
+ 'img_description_cid' => 'NULL',
+ ],
+ ],
+ 'Image, write-both' => [
+ MIGRATION_WRITE_BOTH, 'img_description',
+ [ 'img_description_old' => 'img_description', 'img_description_pk' => 'img_name' ],
+ ],
+ 'Image, write-new' => [
+ MIGRATION_WRITE_NEW, 'img_description',
+ [ 'img_description_old' => 'img_description', 'img_description_pk' => 'img_name' ],
+ ],
+ 'Image, new' => [
+ MIGRATION_NEW, 'img_description',
+ [ 'img_description_pk' => 'img_name' ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetJoin
+ * @param int $stage
+ * @param string $key
+ * @param array $expect
+ */
+ public function testGetJoin_withKeyConstruction( $stage, $key, $expect ) {
+ $store = $this->makeStoreWithKey( $stage, $key );
+ $result = $store->getJoin();
+ $this->assertEquals( $expect, $result );
+ }
+
+ /**
+ * @dataProvider provideGetJoin
+ * @param int $stage
+ * @param string $key
+ * @param array $expect
+ */
+ public function testGetJoin( $stage, $key, $expect ) {
+ $store = $this->makeStore( $stage );
+ $result = $store->getJoin( $key );
+ $this->assertEquals( $expect, $result );
+ }
+
+ public static function provideGetJoin() {
+ return [
+ 'Simple table, old' => [
+ MIGRATION_OLD, 'ipb_reason', [
+ 'tables' => [],
+ 'fields' => [
+ 'ipb_reason_text' => 'ipb_reason',
+ 'ipb_reason_data' => 'NULL',
+ 'ipb_reason_cid' => 'NULL',
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Simple table, write-both' => [
+ MIGRATION_WRITE_BOTH, 'ipb_reason', [
+ 'tables' => [ 'comment_ipb_reason' => 'comment' ],
+ 'fields' => [
+ 'ipb_reason_text' => 'COALESCE( comment_ipb_reason.comment_text, ipb_reason )',
+ 'ipb_reason_data' => 'comment_ipb_reason.comment_data',
+ 'ipb_reason_cid' => 'comment_ipb_reason.comment_id',
+ ],
+ 'joins' => [
+ 'comment_ipb_reason' => [ 'LEFT JOIN', 'comment_ipb_reason.comment_id = ipb_reason_id' ],
+ ],
+ ],
+ ],
+ 'Simple table, write-new' => [
+ MIGRATION_WRITE_NEW, 'ipb_reason', [
+ 'tables' => [ 'comment_ipb_reason' => 'comment' ],
+ 'fields' => [
+ 'ipb_reason_text' => 'COALESCE( comment_ipb_reason.comment_text, ipb_reason )',
+ 'ipb_reason_data' => 'comment_ipb_reason.comment_data',
+ 'ipb_reason_cid' => 'comment_ipb_reason.comment_id',
+ ],
+ 'joins' => [
+ 'comment_ipb_reason' => [ 'LEFT JOIN', 'comment_ipb_reason.comment_id = ipb_reason_id' ],
+ ],
+ ],
+ ],
+ 'Simple table, new' => [
+ MIGRATION_NEW, 'ipb_reason', [
+ 'tables' => [ 'comment_ipb_reason' => 'comment' ],
+ 'fields' => [
+ 'ipb_reason_text' => 'comment_ipb_reason.comment_text',
+ 'ipb_reason_data' => 'comment_ipb_reason.comment_data',
+ 'ipb_reason_cid' => 'comment_ipb_reason.comment_id',
+ ],
+ 'joins' => [
+ 'comment_ipb_reason' => [ 'JOIN', 'comment_ipb_reason.comment_id = ipb_reason_id' ],
+ ],
+ ],
+ ],
+
+ 'Revision, old' => [
+ MIGRATION_OLD, 'rev_comment', [
+ 'tables' => [],
+ 'fields' => [
+ 'rev_comment_text' => 'rev_comment',
+ 'rev_comment_data' => 'NULL',
+ 'rev_comment_cid' => 'NULL',
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Revision, write-both' => [
+ MIGRATION_WRITE_BOTH, 'rev_comment', [
+ 'tables' => [
+ 'temp_rev_comment' => 'revision_comment_temp',
+ 'comment_rev_comment' => 'comment',
+ ],
+ 'fields' => [
+ 'rev_comment_text' => 'COALESCE( comment_rev_comment.comment_text, rev_comment )',
+ 'rev_comment_data' => 'comment_rev_comment.comment_data',
+ 'rev_comment_cid' => 'comment_rev_comment.comment_id',
+ ],
+ 'joins' => [
+ 'temp_rev_comment' => [ 'LEFT JOIN', 'temp_rev_comment.revcomment_rev = rev_id' ],
+ 'comment_rev_comment' => [ 'LEFT JOIN',
+ 'comment_rev_comment.comment_id = temp_rev_comment.revcomment_comment_id' ],
+ ],
+ ],
+ ],
+ 'Revision, write-new' => [
+ MIGRATION_WRITE_NEW, 'rev_comment', [
+ 'tables' => [
+ 'temp_rev_comment' => 'revision_comment_temp',
+ 'comment_rev_comment' => 'comment',
+ ],
+ 'fields' => [
+ 'rev_comment_text' => 'COALESCE( comment_rev_comment.comment_text, rev_comment )',
+ 'rev_comment_data' => 'comment_rev_comment.comment_data',
+ 'rev_comment_cid' => 'comment_rev_comment.comment_id',
+ ],
+ 'joins' => [
+ 'temp_rev_comment' => [ 'LEFT JOIN', 'temp_rev_comment.revcomment_rev = rev_id' ],
+ 'comment_rev_comment' => [ 'LEFT JOIN',
+ 'comment_rev_comment.comment_id = temp_rev_comment.revcomment_comment_id' ],
+ ],
+ ],
+ ],
+ 'Revision, new' => [
+ MIGRATION_NEW, 'rev_comment', [
+ 'tables' => [
+ 'temp_rev_comment' => 'revision_comment_temp',
+ 'comment_rev_comment' => 'comment',
+ ],
+ 'fields' => [
+ 'rev_comment_text' => 'comment_rev_comment.comment_text',
+ 'rev_comment_data' => 'comment_rev_comment.comment_data',
+ 'rev_comment_cid' => 'comment_rev_comment.comment_id',
+ ],
+ 'joins' => [
+ 'temp_rev_comment' => [ 'JOIN', 'temp_rev_comment.revcomment_rev = rev_id' ],
+ 'comment_rev_comment' => [ 'JOIN',
+ 'comment_rev_comment.comment_id = temp_rev_comment.revcomment_comment_id' ],
+ ],
+ ],
+ ],
+
+ 'Image, old' => [
+ MIGRATION_OLD, 'img_description', [
+ 'tables' => [],
+ 'fields' => [
+ 'img_description_text' => 'img_description',
+ 'img_description_data' => 'NULL',
+ 'img_description_cid' => 'NULL',
+ ],
+ 'joins' => [],
+ ],
+ ],
+ 'Image, write-both' => [
+ MIGRATION_WRITE_BOTH, 'img_description', [
+ 'tables' => [
+ 'temp_img_description' => 'image_comment_temp',
+ 'comment_img_description' => 'comment',
+ ],
+ 'fields' => [
+ 'img_description_text' => 'COALESCE( comment_img_description.comment_text, img_description )',
+ 'img_description_data' => 'comment_img_description.comment_data',
+ 'img_description_cid' => 'comment_img_description.comment_id',
+ ],
+ 'joins' => [
+ 'temp_img_description' => [ 'LEFT JOIN', 'temp_img_description.imgcomment_name = img_name' ],
+ 'comment_img_description' => [ 'LEFT JOIN',
+ 'comment_img_description.comment_id = temp_img_description.imgcomment_description_id' ],
+ ],
+ ],
+ ],
+ 'Image, write-new' => [
+ MIGRATION_WRITE_NEW, 'img_description', [
+ 'tables' => [
+ 'temp_img_description' => 'image_comment_temp',
+ 'comment_img_description' => 'comment',
+ ],
+ 'fields' => [
+ 'img_description_text' => 'COALESCE( comment_img_description.comment_text, img_description )',
+ 'img_description_data' => 'comment_img_description.comment_data',
+ 'img_description_cid' => 'comment_img_description.comment_id',
+ ],
+ 'joins' => [
+ 'temp_img_description' => [ 'LEFT JOIN', 'temp_img_description.imgcomment_name = img_name' ],
+ 'comment_img_description' => [ 'LEFT JOIN',
+ 'comment_img_description.comment_id = temp_img_description.imgcomment_description_id' ],
+ ],
+ ],
+ ],
+ 'Image, new' => [
+ MIGRATION_NEW, 'img_description', [
+ 'tables' => [
+ 'temp_img_description' => 'image_comment_temp',
+ 'comment_img_description' => 'comment',
+ ],
+ 'fields' => [
+ 'img_description_text' => 'comment_img_description.comment_text',
+ 'img_description_data' => 'comment_img_description.comment_data',
+ 'img_description_cid' => 'comment_img_description.comment_id',
+ ],
+ 'joins' => [
+ 'temp_img_description' => [ 'JOIN', 'temp_img_description.imgcomment_name = img_name' ],
+ 'comment_img_description' => [ 'JOIN',
+ 'comment_img_description.comment_id = temp_img_description.imgcomment_description_id' ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ private function assertComment( $expect, $actual, $from ) {
+ $this->assertSame( $expect['text'], $actual->text, "text $from" );
+ $this->assertInstanceOf( get_class( $expect['message'] ), $actual->message,
+ "message class $from" );
+ $this->assertSame( $expect['message']->getKeysToTry(), $actual->message->getKeysToTry(),
+ "message keys $from" );
+ $this->assertEquals( $expect['message']->text(), $actual->message->text(),
+ "message rendering $from" );
+ $this->assertEquals( $expect['data'], $actual->data, "data $from" );
+ }
+
+ /**
+ * @dataProvider provideInsertRoundTrip
+ * @param string $table
+ * @param string $key
+ * @param string $pk
+ * @param string $extraFields
+ * @param string|Message $comment
+ * @param array|null $data
+ * @param array $expect
+ */
+ public function testInsertRoundTrip( $table, $key, $pk, $extraFields, $comment, $data, $expect ) {
+ $expectOld = [
+ 'text' => $expect['text'],
+ 'message' => new RawMessage( '$1', [ $expect['text'] ] ),
+ 'data' => null,
+ ];
+
+ $stages = [
+ MIGRATION_OLD => [ MIGRATION_OLD, MIGRATION_WRITE_NEW ],
+ MIGRATION_WRITE_BOTH => [ MIGRATION_OLD, MIGRATION_NEW ],
+ MIGRATION_WRITE_NEW => [ MIGRATION_WRITE_BOTH, MIGRATION_NEW ],
+ MIGRATION_NEW => [ MIGRATION_WRITE_BOTH, MIGRATION_NEW ],
+ ];
+
+ foreach ( $stages as $writeStage => $readRange ) {
+ if ( $key === 'ipb_reason' ) {
+ $extraFields['ipb_address'] = __CLASS__ . "#$writeStage";
+ }
+
+ $wstore = $this->makeStore( $writeStage );
+ $usesTemp = $key === 'rev_comment';
+
+ if ( $usesTemp ) {
+ list( $fields, $callback ) = $wstore->insertWithTempTable(
+ $this->db, $key, $comment, $data
+ );
+ } else {
+ $fields = $wstore->insert( $this->db, $key, $comment, $data );
+ }
+
+ if ( $writeStage <= MIGRATION_WRITE_BOTH ) {
+ $this->assertSame( $expect['text'], $fields[$key], "old field, stage=$writeStage" );
+ } else {
+ $this->assertArrayNotHasKey( $key, $fields, "old field, stage=$writeStage" );
+ }
+ if ( $writeStage >= MIGRATION_WRITE_BOTH && !$usesTemp ) {
+ $this->assertArrayHasKey( "{$key}_id", $fields, "new field, stage=$writeStage" );
+ } else {
+ $this->assertArrayNotHasKey( "{$key}_id", $fields, "new field, stage=$writeStage" );
+ }
+
+ $this->db->insert( $table, $extraFields + $fields, __METHOD__ );
+ $id = $this->db->insertId();
+ if ( $usesTemp ) {
+ $callback( $id );
+ }
+
+ for ( $readStage = $readRange[0]; $readStage <= $readRange[1]; $readStage++ ) {
+ $rstore = $this->makeStore( $readStage );
+
+ $fieldRow = $this->db->selectRow(
+ $table,
+ $rstore->getFields( $key ),
+ [ $pk => $id ],
+ __METHOD__
+ );
+
+ $queryInfo = $rstore->getJoin( $key );
+ $joinRow = $this->db->selectRow(
+ [ $table ] + $queryInfo['tables'],
+ $queryInfo['fields'],
+ [ $pk => $id ],
+ __METHOD__,
+ [],
+ $queryInfo['joins']
+ );
+
+ $this->assertComment(
+ $writeStage === MIGRATION_OLD || $readStage === MIGRATION_OLD ? $expectOld : $expect,
+ $rstore->getCommentLegacy( $this->db, $key, $fieldRow ),
+ "w=$writeStage, r=$readStage, from getFields()"
+ );
+ $this->assertComment(
+ $writeStage === MIGRATION_OLD || $readStage === MIGRATION_OLD ? $expectOld : $expect,
+ $rstore->getComment( $key, $joinRow ),
+ "w=$writeStage, r=$readStage, from getJoin()"
+ );
+ }
+ }
+ }
+
+ /**
+ * @dataProvider provideInsertRoundTrip
+ * @param string $table
+ * @param string $key
+ * @param string $pk
+ * @param string $extraFields
+ * @param string|Message $comment
+ * @param array|null $data
+ * @param array $expect
+ */
+ public function testInsertRoundTrip_withKeyConstruction(
+ $table, $key, $pk, $extraFields, $comment, $data, $expect
+ ) {
+ $expectOld = [
+ 'text' => $expect['text'],
+ 'message' => new RawMessage( '$1', [ $expect['text'] ] ),
+ 'data' => null,
+ ];
+
+ $stages = [
+ MIGRATION_OLD => [ MIGRATION_OLD, MIGRATION_WRITE_NEW ],
+ MIGRATION_WRITE_BOTH => [ MIGRATION_OLD, MIGRATION_NEW ],
+ MIGRATION_WRITE_NEW => [ MIGRATION_WRITE_BOTH, MIGRATION_NEW ],
+ MIGRATION_NEW => [ MIGRATION_WRITE_BOTH, MIGRATION_NEW ],
+ ];
+
+ foreach ( $stages as $writeStage => $readRange ) {
+ if ( $key === 'ipb_reason' ) {
+ $extraFields['ipb_address'] = __CLASS__ . "#$writeStage";
+ }
+
+ $wstore = $this->makeStoreWithKey( $writeStage, $key );
+ $usesTemp = $key === 'rev_comment';
+
+ if ( $usesTemp ) {
+ list( $fields, $callback ) = $wstore->insertWithTempTable(
+ $this->db, $comment, $data
+ );
+ } else {
+ $fields = $wstore->insert( $this->db, $comment, $data );
+ }
+
+ if ( $writeStage <= MIGRATION_WRITE_BOTH ) {
+ $this->assertSame( $expect['text'], $fields[$key], "old field, stage=$writeStage" );
+ } else {
+ $this->assertArrayNotHasKey( $key, $fields, "old field, stage=$writeStage" );
+ }
+ if ( $writeStage >= MIGRATION_WRITE_BOTH && !$usesTemp ) {
+ $this->assertArrayHasKey( "{$key}_id", $fields, "new field, stage=$writeStage" );
+ } else {
+ $this->assertArrayNotHasKey( "{$key}_id", $fields, "new field, stage=$writeStage" );
+ }
+
+ $this->db->insert( $table, $extraFields + $fields, __METHOD__ );
+ $id = $this->db->insertId();
+ if ( $usesTemp ) {
+ $callback( $id );
+ }
+
+ for ( $readStage = $readRange[0]; $readStage <= $readRange[1]; $readStage++ ) {
+ $rstore = $this->makeStoreWithKey( $readStage, $key );
+
+ $fieldRow = $this->db->selectRow(
+ $table,
+ $rstore->getFields(),
+ [ $pk => $id ],
+ __METHOD__
+ );
+
+ $queryInfo = $rstore->getJoin();
+ $joinRow = $this->db->selectRow(
+ [ $table ] + $queryInfo['tables'],
+ $queryInfo['fields'],
+ [ $pk => $id ],
+ __METHOD__,
+ [],
+ $queryInfo['joins']
+ );
+
+ $this->assertComment(
+ $writeStage === MIGRATION_OLD || $readStage === MIGRATION_OLD ? $expectOld : $expect,
+ $rstore->getCommentLegacy( $this->db, $fieldRow ),
+ "w=$writeStage, r=$readStage, from getFields()"
+ );
+ $this->assertComment(
+ $writeStage === MIGRATION_OLD || $readStage === MIGRATION_OLD ? $expectOld : $expect,
+ $rstore->getComment( $joinRow ),
+ "w=$writeStage, r=$readStage, from getJoin()"
+ );
+ }
+ }
+ }
+
+ public static function provideInsertRoundTrip() {
+ $db = wfGetDB( DB_REPLICA ); // for timestamps
+
+ $msgComment = new Message( 'parentheses', [ 'message comment' ] );
+ $textCommentMsg = new RawMessage( '$1', [ 'text comment' ] );
+ $nestedMsgComment = new Message( [ 'parentheses', 'rawmessage' ], [ new Message( 'mainpage' ) ] );
+ $ipbfields = [
+ 'ipb_range_start' => '',
+ 'ipb_range_end' => '',
+ 'ipb_timestamp' => $db->timestamp(),
+ 'ipb_expiry' => $db->getInfinity(),
+ ];
+ $revfields = [
+ 'rev_page' => 42,
+ 'rev_text_id' => 42,
+ 'rev_len' => 0,
+ 'rev_timestamp' => $db->timestamp(),
+ ];
+ $comStoreComment = new CommentStoreComment(
+ null, 'comment store comment', null, [ 'foo' => 'bar' ]
+ );
+
+ return [
+ 'Simple table, text comment' => [
+ 'ipblocks', 'ipb_reason', 'ipb_id', $ipbfields, 'text comment', null, [
+ 'text' => 'text comment',
+ 'message' => $textCommentMsg,
+ 'data' => null,
+ ]
+ ],
+ 'Simple table, text comment with data' => [
+ 'ipblocks', 'ipb_reason', 'ipb_id', $ipbfields, 'text comment', [ 'message' => 42 ], [
+ 'text' => 'text comment',
+ 'message' => $textCommentMsg,
+ 'data' => [ 'message' => 42 ],
+ ]
+ ],
+ 'Simple table, message comment' => [
+ 'ipblocks', 'ipb_reason', 'ipb_id', $ipbfields, $msgComment, null, [
+ 'text' => '(message comment)',
+ 'message' => $msgComment,
+ 'data' => null,
+ ]
+ ],
+ 'Simple table, message comment with data' => [
+ 'ipblocks', 'ipb_reason', 'ipb_id', $ipbfields, $msgComment, [ 'message' => 42 ], [
+ 'text' => '(message comment)',
+ 'message' => $msgComment,
+ 'data' => [ 'message' => 42 ],
+ ]
+ ],
+ 'Simple table, nested message comment' => [
+ 'ipblocks', 'ipb_reason', 'ipb_id', $ipbfields, $nestedMsgComment, null, [
+ 'text' => '(Main Page)',
+ 'message' => $nestedMsgComment,
+ 'data' => null,
+ ]
+ ],
+ 'Simple table, CommentStoreComment' => [
+ 'ipblocks', 'ipb_reason', 'ipb_id', $ipbfields, clone $comStoreComment, [ 'baz' => 'baz' ], [
+ 'text' => 'comment store comment',
+ 'message' => $comStoreComment->message,
+ 'data' => [ 'foo' => 'bar' ],
+ ]
+ ],
+
+ 'Revision, text comment' => [
+ 'revision', 'rev_comment', 'rev_id', $revfields, 'text comment', null, [
+ 'text' => 'text comment',
+ 'message' => $textCommentMsg,
+ 'data' => null,
+ ]
+ ],
+ 'Revision, text comment with data' => [
+ 'revision', 'rev_comment', 'rev_id', $revfields, 'text comment', [ 'message' => 42 ], [
+ 'text' => 'text comment',
+ 'message' => $textCommentMsg,
+ 'data' => [ 'message' => 42 ],
+ ]
+ ],
+ 'Revision, message comment' => [
+ 'revision', 'rev_comment', 'rev_id', $revfields, $msgComment, null, [
+ 'text' => '(message comment)',
+ 'message' => $msgComment,
+ 'data' => null,
+ ]
+ ],
+ 'Revision, message comment with data' => [
+ 'revision', 'rev_comment', 'rev_id', $revfields, $msgComment, [ 'message' => 42 ], [
+ 'text' => '(message comment)',
+ 'message' => $msgComment,
+ 'data' => [ 'message' => 42 ],
+ ]
+ ],
+ 'Revision, nested message comment' => [
+ 'revision', 'rev_comment', 'rev_id', $revfields, $nestedMsgComment, null, [
+ 'text' => '(Main Page)',
+ 'message' => $nestedMsgComment,
+ 'data' => null,
+ ]
+ ],
+ 'Revision, CommentStoreComment' => [
+ 'revision', 'rev_comment', 'rev_id', $revfields, clone $comStoreComment, [ 'baz' => 'baz' ], [
+ 'text' => 'comment store comment',
+ 'message' => $comStoreComment->message,
+ 'data' => [ 'foo' => 'bar' ],
+ ]
+ ],
+ ];
+ }
+
+ public function testGetCommentErrors() {
+ Wikimedia\suppressWarnings();
+ $reset = new ScopedCallback( 'Wikimedia\restoreWarnings' );
+
+ $store = $this->makeStore( MIGRATION_OLD );
+ $res = $store->getComment( 'dummy', [ 'dummy' => 'comment' ] );
+ $this->assertSame( '', $res->text );
+ $res = $store->getComment( 'dummy', [ 'dummy' => 'comment' ], true );
+ $this->assertSame( 'comment', $res->text );
+
+ $store = $this->makeStore( MIGRATION_NEW );
+ try {
+ $store->getComment( 'dummy', [ 'dummy' => 'comment' ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame( '$row does not contain fields needed for comment dummy', $ex->getMessage() );
+ }
+ $res = $store->getComment( 'dummy', [ 'dummy' => 'comment' ], true );
+ $this->assertSame( 'comment', $res->text );
+ try {
+ $store->getComment( 'dummy', [ 'dummy_id' => 1 ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ '$row does not contain fields needed for comment dummy and getComment(), '
+ . 'but does have fields for getCommentLegacy()',
+ $ex->getMessage()
+ );
+ }
+
+ $store = $this->makeStore( MIGRATION_NEW );
+ try {
+ $store->getComment( 'rev_comment', [ 'rev_comment' => 'comment' ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ '$row does not contain fields needed for comment rev_comment', $ex->getMessage()
+ );
+ }
+ $res = $store->getComment( 'rev_comment', [ 'rev_comment' => 'comment' ], true );
+ $this->assertSame( 'comment', $res->text );
+ try {
+ $store->getComment( 'rev_comment', [ 'rev_comment_pk' => 1 ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ '$row does not contain fields needed for comment rev_comment and getComment(), '
+ . 'but does have fields for getCommentLegacy()',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ public static function provideStages() {
+ return [
+ 'MIGRATION_OLD' => [ MIGRATION_OLD ],
+ 'MIGRATION_WRITE_BOTH' => [ MIGRATION_WRITE_BOTH ],
+ 'MIGRATION_WRITE_NEW' => [ MIGRATION_WRITE_NEW ],
+ 'MIGRATION_NEW' => [ MIGRATION_NEW ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideStages
+ * @param int $stage
+ * @expectedException InvalidArgumentException
+ * @expectedExceptionMessage Must use insertWithTempTable() for rev_comment
+ */
+ public function testInsertWrong( $stage ) {
+ $store = $this->makeStore( $stage );
+ $store->insert( $this->db, 'rev_comment', 'foo' );
+ }
+
+ /**
+ * @dataProvider provideStages
+ * @param int $stage
+ * @expectedException InvalidArgumentException
+ * @expectedExceptionMessage Must use insert() for ipb_reason
+ */
+ public function testInsertWithTempTableWrong( $stage ) {
+ $store = $this->makeStore( $stage );
+ $store->insertWithTempTable( $this->db, 'ipb_reason', 'foo' );
+ }
+
+ /**
+ * @dataProvider provideStages
+ * @param int $stage
+ */
+ public function testInsertWithTempTableDeprecated( $stage ) {
+ $wrap = TestingAccessWrapper::newFromClass( CommentStore::class );
+ $wrap->formerTempTables += [ 'ipb_reason' => '1.30' ];
+
+ $this->hideDeprecated( 'CommentStore::insertWithTempTable for ipb_reason' );
+ $store = $this->makeStore( $stage );
+ list( $fields, $callback ) = $store->insertWithTempTable( $this->db, 'ipb_reason', 'foo' );
+ $this->assertTrue( is_callable( $callback ) );
+ }
+
+ public function testInsertTruncation() {
+ $comment = str_repeat( '💣', 16400 );
+ $truncated1 = str_repeat( '💣', 63 ) . '...';
+ $truncated2 = str_repeat( '💣', CommentStore::COMMENT_CHARACTER_LIMIT - 3 ) . '...';
+
+ $store = $this->makeStore( MIGRATION_WRITE_BOTH );
+ $fields = $store->insert( $this->db, 'ipb_reason', $comment );
+ $this->assertSame( $truncated1, $fields['ipb_reason'] );
+ $stored = $this->db->selectField(
+ 'comment', 'comment_text', [ 'comment_id' => $fields['ipb_reason_id'] ], __METHOD__
+ );
+ $this->assertSame( $truncated2, $stored );
+ }
+
+ /**
+ * @expectedException OverflowException
+ * @expectedExceptionMessage Comment data is too long (65611 bytes, maximum is 65535)
+ */
+ public function testInsertTooMuchData() {
+ $store = $this->makeStore( MIGRATION_WRITE_BOTH );
+ $store->insert( $this->db, 'ipb_reason', 'foo', [
+ 'long' => str_repeat( '💣', 16400 )
+ ] );
+ }
+
+ public function testGetStore() {
+ $this->assertInstanceOf( CommentStore::class, CommentStore::getStore() );
+ }
+
+ public function testNewKey() {
+ $this->assertInstanceOf( CommentStore::class, CommentStore::newKey( 'dummy' ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/DeprecatedGlobalTest.php b/www/wiki/tests/phpunit/includes/DeprecatedGlobalTest.php
new file mode 100644
index 00000000..237e3fb7
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/DeprecatedGlobalTest.php
@@ -0,0 +1,81 @@
+<?php
+/**
+ * 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
+ */
+
+/**
+ * @covers DeprecatedGlobal
+ */
+class DeprecatedGlobalTest extends MediaWikiTestCase {
+
+ public function setUp() {
+ parent::setUp();
+ $this->oldErrorLevel = error_reporting( -1 );
+ }
+
+ public function tearDown() {
+ error_reporting( $this->oldErrorLevel );
+ parent::tearDown();
+ }
+
+ public function testObjectDeStub() {
+ global $wgDummy;
+
+ $wgDummy = new DeprecatedGlobal( 'wgDummy', new HashBagOStuff(), '1.30' );
+ $this->assertInstanceOf( DeprecatedGlobal::class, $wgDummy );
+
+ $this->hideDeprecated( '$wgDummy' );
+ // Trigger de-stubification
+ $wgDummy->get( 'foo' );
+
+ $this->assertInstanceOf( HashBagOStuff::class, $wgDummy );
+ }
+
+ public function testLazyLoad() {
+ global $wgDummyLazy;
+
+ $called = false;
+ $factory = function () use ( &$called ) {
+ $called = true;
+ return new HashBagOStuff();
+ };
+
+ $wgDummyLazy = new DeprecatedGlobal( 'wgDummyLazy', $factory, '1.30' );
+ $this->assertInstanceOf( DeprecatedGlobal::class, $wgDummyLazy );
+
+ $this->hideDeprecated( '$wgDummyLazy' );
+ $this->assertFalse( $called );
+ // Trigger de-stubification
+ $wgDummyLazy->get( 'foo' );
+ $this->assertTrue( $called );
+ $this->assertInstanceOf( HashBagOStuff::class, $wgDummyLazy );
+ }
+
+ /**
+ * @expectedException PHPUnit_Framework_Error
+ * @expectedExceptionMessage Use of $wgDummy1 was deprecated in MediaWiki 1.30
+ */
+ public function testWarning() {
+ global $wgDummy1;
+
+ $wgDummy1 = new DeprecatedGlobal( 'wgDummy1', new HashBagOStuff(), '1.30' );
+ $wgDummy1->get( 'foo' );
+ $this->assertInstanceOf( HashBagOStuff::class, $wgDummy1 );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/DiffHistoryBlobTest.php b/www/wiki/tests/phpunit/includes/DiffHistoryBlobTest.php
new file mode 100644
index 00000000..c267a30e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/DiffHistoryBlobTest.php
@@ -0,0 +1,45 @@
+<?php
+
+class DiffHistoryBlobTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->checkPHPExtension( 'hash' );
+ $this->checkPHPExtension( 'xdiff' );
+
+ if ( !function_exists( 'xdiff_string_rabdiff' ) ) {
+ $this->markTestSkipped( 'The version of xdiff extension is lower than 1.5.0' );
+ return;
+ }
+ }
+
+ /**
+ * Test for DiffHistoryBlob::xdiffAdler32()
+ * @dataProvider provideXdiffAdler32
+ * @covers DiffHistoryBlob::xdiffAdler32
+ */
+ public function testXdiffAdler32( $input ) {
+ $xdiffHash = substr( xdiff_string_rabdiff( $input, '' ), 0, 4 );
+ $dhb = new DiffHistoryBlob;
+ $myHash = $dhb->xdiffAdler32( $input );
+ $this->assertSame( bin2hex( $xdiffHash ), bin2hex( $myHash ),
+ "Hash of " . addcslashes( $input, "\0..\37!@\@\177..\377" ) );
+ }
+
+ public function provideXdiffAdler32() {
+ // Hack non-empty early return since PHPUnit expands this provider before running
+ // the setUp() which marks the test as skipped.
+ if ( !function_exists( 'xdiff_string_rabdiff' ) ) {
+ return [ [ '', 'Empty string' ] ];
+ }
+
+ return [
+ [ '', 'Empty string' ],
+ [ "\0", 'Null' ],
+ [ "\0\0\0", "Several nulls" ],
+ [ "Hello", "An ASCII string" ],
+ [ str_repeat( "x", 6000 ), "A string larger than xdiff's NMAX (5552)" ]
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/EditPageTest.php b/www/wiki/tests/phpunit/includes/EditPageTest.php
new file mode 100644
index 00000000..8f0826b5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/EditPageTest.php
@@ -0,0 +1,727 @@
+<?php
+
+/**
+ * @group Editing
+ *
+ * @group Database
+ * ^--- tell jenkins this test needs the database
+ *
+ * @group medium
+ * ^--- tell phpunit that these test cases may take longer than 2 seconds.
+ */
+class EditPageTest extends MediaWikiLangTestCase {
+
+ protected function setUp() {
+ global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang;
+
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgExtraNamespaces' => $wgExtraNamespaces,
+ 'wgNamespaceContentModels' => $wgNamespaceContentModels,
+ 'wgContentHandlers' => $wgContentHandlers,
+ 'wgContLang' => $wgContLang,
+ ] );
+
+ $wgExtraNamespaces[12312] = 'Dummy';
+ $wgExtraNamespaces[12313] = 'Dummy_talk';
+
+ $wgNamespaceContentModels[12312] = "testing";
+ $wgContentHandlers["testing"] = 'DummyContentHandlerForTesting';
+
+ MWNamespace::clearCaches();
+ $wgContLang->resetNamespaces(); # reset namespace cache
+ }
+
+ protected function tearDown() {
+ global $wgContLang;
+
+ MWNamespace::clearCaches();
+ $wgContLang->resetNamespaces(); # reset namespace cache
+ parent::tearDown();
+ }
+
+ /**
+ * @dataProvider provideExtractSectionTitle
+ * @covers EditPage::extractSectionTitle
+ */
+ public function testExtractSectionTitle( $section, $title ) {
+ $extracted = EditPage::extractSectionTitle( $section );
+ $this->assertEquals( $title, $extracted );
+ }
+
+ public static function provideExtractSectionTitle() {
+ return [
+ [
+ "== Test ==\n\nJust a test section.",
+ "Test"
+ ],
+ [
+ "An initial section, no header.",
+ false
+ ],
+ [
+ "An initial section with a fake heder (T34617)\n\n== Test == ??\nwtf",
+ false
+ ],
+ [
+ "== Section ==\nfollowed by a fake == Non-section == ??\nnoooo",
+ "Section"
+ ],
+ [
+ "== Section== \t\r\n followed by whitespace (T37051)",
+ 'Section',
+ ],
+ ];
+ }
+
+ protected function forceRevisionDate( WikiPage $page, $timestamp ) {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->update( 'revision',
+ [ 'rev_timestamp' => $dbw->timestamp( $timestamp ) ],
+ [ 'rev_id' => $page->getLatest() ] );
+
+ $page->clear();
+ }
+
+ /**
+ * User input text is passed to rtrim() by edit page. This is a simple
+ * wrapper around assertEquals() which calls rrtrim() to normalize the
+ * expected and actual texts.
+ * @param string $expected
+ * @param string $actual
+ * @param string $msg
+ */
+ protected function assertEditedTextEquals( $expected, $actual, $msg = '' ) {
+ $this->assertEquals( rtrim( $expected ), rtrim( $actual ), $msg );
+ }
+
+ /**
+ * Performs an edit and checks the result.
+ *
+ * @param string|Title $title The title of the page to edit
+ * @param string|null $baseText Some text to create the page with before attempting the edit.
+ * @param User|string|null $user The user to perform the edit as.
+ * @param array $edit An array of request parameters used to define the edit to perform.
+ * Some well known fields are:
+ * * wpTextbox1: the text to submit
+ * * wpSummary: the edit summary
+ * * wpEditToken: the edit token (will be inserted if not provided)
+ * * wpEdittime: timestamp of the edit's base revision (will be inserted
+ * if not provided)
+ * * wpStarttime: timestamp when the edit started (will be inserted if not provided)
+ * * wpSectionTitle: the section to edit
+ * * wpMinorEdit: mark as minor edit
+ * * wpWatchthis: whether to watch the page
+ * @param int|null $expectedCode The expected result code (EditPage::AS_XXX constants).
+ * Set to null to skip the check.
+ * @param string|null $expectedText The text expected to be on the page after the edit.
+ * Set to null to skip the check.
+ * @param string|null $message An optional message to show along with any error message.
+ *
+ * @return WikiPage The page that was just edited, useful for getting the edit's rev_id, etc.
+ */
+ protected function assertEdit( $title, $baseText, $user = null, array $edit,
+ $expectedCode = null, $expectedText = null, $message = null
+ ) {
+ if ( is_string( $title ) ) {
+ $ns = $this->getDefaultWikitextNS();
+ $title = Title::newFromText( $title, $ns );
+ }
+ $this->assertNotNull( $title );
+
+ if ( is_string( $user ) ) {
+ $user = User::newFromName( $user );
+
+ if ( $user->getId() === 0 ) {
+ $user->addToDatabase();
+ }
+ }
+
+ $page = WikiPage::factory( $title );
+
+ if ( $baseText !== null ) {
+ $content = ContentHandler::makeContent( $baseText, $title );
+ $page->doEditContent( $content, "base text for test" );
+ $this->forceRevisionDate( $page, '20120101000000' );
+
+ // sanity check
+ $page->clear();
+ $currentText = ContentHandler::getContentText( $page->getContent() );
+
+ # EditPage rtrim() the user input, so we alter our expected text
+ # to reflect that.
+ $this->assertEditedTextEquals( $baseText, $currentText );
+ }
+
+ if ( $user == null ) {
+ $user = $GLOBALS['wgUser'];
+ } else {
+ $this->setMwGlobals( 'wgUser', $user );
+ }
+
+ if ( !isset( $edit['wpEditToken'] ) ) {
+ $edit['wpEditToken'] = $user->getEditToken();
+ }
+
+ if ( !isset( $edit['wpEdittime'] ) ) {
+ $edit['wpEdittime'] = $page->exists() ? $page->getTimestamp() : '';
+ }
+
+ if ( !isset( $edit['wpStarttime'] ) ) {
+ $edit['wpStarttime'] = wfTimestampNow();
+ }
+
+ if ( !isset( $edit['wpUnicodeCheck'] ) ) {
+ $edit['wpUnicodeCheck'] = EditPage::UNICODE_CHECK;
+ }
+
+ $req = new FauxRequest( $edit, true ); // session ??
+
+ $article = new Article( $title );
+ $article->getContext()->setTitle( $title );
+ $ep = new EditPage( $article );
+ $ep->setContextTitle( $title );
+ $ep->importFormData( $req );
+
+ $bot = isset( $edit['bot'] ) ? (bool)$edit['bot'] : false;
+
+ // this is where the edit happens!
+ // Note: don't want to use EditPage::AttemptSave, because it messes with $wgOut
+ // and throws exceptions like PermissionsError
+ $status = $ep->internalAttemptSave( $result, $bot );
+
+ if ( $expectedCode !== null ) {
+ // check edit code
+ $this->assertEquals( $expectedCode, $status->value,
+ "Expected result code mismatch. $message" );
+ }
+
+ $page = WikiPage::factory( $title );
+
+ if ( $expectedText !== null ) {
+ // check resulting page text
+ $content = $page->getContent();
+ $text = ContentHandler::getContentText( $content );
+
+ # EditPage rtrim() the user input, so we alter our expected text
+ # to reflect that.
+ $this->assertEditedTextEquals( $expectedText, $text,
+ "Expected article text mismatch. $message" );
+ }
+
+ return $page;
+ }
+
+ public static function provideCreatePages() {
+ return [
+ [ 'expected article being created',
+ 'EditPageTest_testCreatePage',
+ null,
+ 'Hello World!',
+ EditPage::AS_SUCCESS_NEW_ARTICLE,
+ 'Hello World!'
+ ],
+ [ 'expected article not being created if empty',
+ 'EditPageTest_testCreatePage',
+ null,
+ '',
+ EditPage::AS_BLANK_ARTICLE,
+ null
+ ],
+ [ 'expected MediaWiki: page being created',
+ 'MediaWiki:January',
+ 'UTSysop',
+ 'Not January',
+ EditPage::AS_SUCCESS_NEW_ARTICLE,
+ 'Not January'
+ ],
+ [ 'expected not-registered MediaWiki: page not being created if empty',
+ 'MediaWiki:EditPageTest_testCreatePage',
+ 'UTSysop',
+ '',
+ EditPage::AS_BLANK_ARTICLE,
+ null
+ ],
+ [ 'expected registered MediaWiki: page being created even if empty',
+ 'MediaWiki:January',
+ 'UTSysop',
+ '',
+ EditPage::AS_SUCCESS_NEW_ARTICLE,
+ ''
+ ],
+ [ 'expected registered MediaWiki: page whose default content is empty'
+ . ' not being created if empty',
+ 'MediaWiki:Ipb-default-expiry',
+ 'UTSysop',
+ '',
+ EditPage::AS_BLANK_ARTICLE,
+ ''
+ ],
+ [ 'expected MediaWiki: page not being created if text equals default message',
+ 'MediaWiki:January',
+ 'UTSysop',
+ 'January',
+ EditPage::AS_BLANK_ARTICLE,
+ null
+ ],
+ [ 'expected empty article being created',
+ 'EditPageTest_testCreatePage',
+ null,
+ '',
+ EditPage::AS_SUCCESS_NEW_ARTICLE,
+ '',
+ true
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCreatePages
+ * @covers EditPage
+ */
+ public function testCreatePage(
+ $desc, $pageTitle, $user, $editText, $expectedCode, $expectedText, $ignoreBlank = false
+ ) {
+ $checkId = null;
+
+ $this->setMwGlobals( 'wgHooks', [
+ 'PageContentInsertComplete' => [ function (
+ WikiPage &$page, User &$user, Content $content,
+ $summary, $minor, $u1, $u2, &$flags, Revision $revision
+ ) {
+ // types/refs checked
+ } ],
+ 'PageContentSaveComplete' => [ function (
+ WikiPage &$page, User &$user, Content $content,
+ $summary, $minor, $u1, $u2, &$flags, Revision $revision,
+ Status &$status, $baseRevId
+ ) use ( &$checkId ) {
+ $checkId = $status->value['revision']->getId();
+ // types/refs checked
+ } ],
+ ] );
+
+ $edit = [ 'wpTextbox1' => $editText ];
+ if ( $ignoreBlank ) {
+ $edit['wpIgnoreBlankArticle'] = 1;
+ }
+
+ $page = $this->assertEdit( $pageTitle, null, $user, $edit, $expectedCode, $expectedText, $desc );
+
+ if ( $expectedCode != EditPage::AS_BLANK_ARTICLE ) {
+ $latest = $page->getLatest();
+ $page->doDeleteArticleReal( $pageTitle );
+
+ $this->assertGreaterThan( 0, $latest, "Page revision ID updated in object" );
+ $this->assertEquals( $latest, $checkId, "Revision in Status for hook" );
+ }
+ }
+
+ /**
+ * @dataProvider provideCreatePages
+ * @covers EditPage
+ */
+ public function testCreatePageTrx(
+ $desc, $pageTitle, $user, $editText, $expectedCode, $expectedText, $ignoreBlank = false
+ ) {
+ $checkIds = [];
+ $this->setMwGlobals( 'wgHooks', [
+ 'PageContentInsertComplete' => [ function (
+ WikiPage &$page, User &$user, Content $content,
+ $summary, $minor, $u1, $u2, &$flags, Revision $revision
+ ) {
+ // types/refs checked
+ } ],
+ 'PageContentSaveComplete' => [ function (
+ WikiPage &$page, User &$user, Content $content,
+ $summary, $minor, $u1, $u2, &$flags, Revision $revision,
+ Status &$status, $baseRevId
+ ) use ( &$checkIds ) {
+ $checkIds[] = $status->value['revision']->getId();
+ // types/refs checked
+ } ],
+ ] );
+
+ wfGetDB( DB_MASTER )->begin( __METHOD__ );
+
+ $edit = [ 'wpTextbox1' => $editText ];
+ if ( $ignoreBlank ) {
+ $edit['wpIgnoreBlankArticle'] = 1;
+ }
+
+ $page = $this->assertEdit(
+ $pageTitle, null, $user, $edit, $expectedCode, $expectedText, $desc );
+
+ $pageTitle2 = (string)$pageTitle . '/x';
+ $page2 = $this->assertEdit(
+ $pageTitle2, null, $user, $edit, $expectedCode, $expectedText, $desc );
+
+ wfGetDB( DB_MASTER )->commit( __METHOD__ );
+
+ $this->assertEquals( 0, DeferredUpdates::pendingUpdatesCount(), 'No deferred updates' );
+
+ if ( $expectedCode != EditPage::AS_BLANK_ARTICLE ) {
+ $latest = $page->getLatest();
+ $page->doDeleteArticleReal( $pageTitle );
+
+ $this->assertGreaterThan( 0, $latest, "Page #1 revision ID updated in object" );
+ $this->assertEquals( $latest, $checkIds[0], "Revision #1 in Status for hook" );
+
+ $latest2 = $page2->getLatest();
+ $page2->doDeleteArticleReal( $pageTitle2 );
+
+ $this->assertGreaterThan( 0, $latest2, "Page #2 revision ID updated in object" );
+ $this->assertEquals( $latest2, $checkIds[1], "Revision #2 in Status for hook" );
+ }
+ }
+
+ public function testUpdatePage() {
+ $checkIds = [];
+
+ $this->setMwGlobals( 'wgHooks', [
+ 'PageContentInsertComplete' => [ function (
+ WikiPage &$page, User &$user, Content $content,
+ $summary, $minor, $u1, $u2, &$flags, Revision $revision
+ ) {
+ // types/refs checked
+ } ],
+ 'PageContentSaveComplete' => [ function (
+ WikiPage &$page, User &$user, Content $content,
+ $summary, $minor, $u1, $u2, &$flags, Revision $revision,
+ Status &$status, $baseRevId
+ ) use ( &$checkIds ) {
+ $checkIds[] = $status->value['revision']->getId();
+ // types/refs checked
+ } ],
+ ] );
+
+ $text = "one";
+ $edit = [
+ 'wpTextbox1' => $text,
+ 'wpSummary' => 'first update',
+ ];
+
+ $page = $this->assertEdit( 'EditPageTest_testUpdatePage', "zero", null, $edit,
+ EditPage::AS_SUCCESS_UPDATE, $text,
+ "expected successfull update with given text" );
+ $this->assertGreaterThan( 0, $checkIds[0], "First event rev ID set" );
+
+ $this->forceRevisionDate( $page, '20120101000000' );
+
+ $text = "two";
+ $edit = [
+ 'wpTextbox1' => $text,
+ 'wpSummary' => 'second update',
+ ];
+
+ $this->assertEdit( 'EditPageTest_testUpdatePage', null, null, $edit,
+ EditPage::AS_SUCCESS_UPDATE, $text,
+ "expected successfull update with given text" );
+ $this->assertGreaterThan( 0, $checkIds[1], "Second edit hook rev ID set" );
+ $this->assertGreaterThan( $checkIds[0], $checkIds[1], "Second event rev ID is higher" );
+ }
+
+ public function testUpdatePageTrx() {
+ $text = "one";
+ $edit = [
+ 'wpTextbox1' => $text,
+ 'wpSummary' => 'first update',
+ ];
+
+ $page = $this->assertEdit( 'EditPageTest_testTrxUpdatePage', "zero", null, $edit,
+ EditPage::AS_SUCCESS_UPDATE, $text,
+ "expected successfull update with given text" );
+
+ $this->forceRevisionDate( $page, '20120101000000' );
+
+ $checkIds = [];
+ $this->setMwGlobals( 'wgHooks', [
+ 'PageContentSaveComplete' => [ function (
+ WikiPage &$page, User &$user, Content $content,
+ $summary, $minor, $u1, $u2, &$flags, Revision $revision,
+ Status &$status, $baseRevId
+ ) use ( &$checkIds ) {
+ $checkIds[] = $status->value['revision']->getId();
+ // types/refs checked
+ } ],
+ ] );
+
+ wfGetDB( DB_MASTER )->begin( __METHOD__ );
+
+ $text = "two";
+ $edit = [
+ 'wpTextbox1' => $text,
+ 'wpSummary' => 'second update',
+ ];
+
+ $this->assertEdit( 'EditPageTest_testTrxUpdatePage', null, null, $edit,
+ EditPage::AS_SUCCESS_UPDATE, $text,
+ "expected successfull update with given text" );
+
+ $text = "three";
+ $edit = [
+ 'wpTextbox1' => $text,
+ 'wpSummary' => 'third update',
+ ];
+
+ $this->assertEdit( 'EditPageTest_testTrxUpdatePage', null, null, $edit,
+ EditPage::AS_SUCCESS_UPDATE, $text,
+ "expected successfull update with given text" );
+
+ wfGetDB( DB_MASTER )->commit( __METHOD__ );
+
+ $this->assertGreaterThan( 0, $checkIds[0], "First event rev ID set" );
+ $this->assertGreaterThan( 0, $checkIds[1], "Second edit hook rev ID set" );
+ $this->assertGreaterThan( $checkIds[0], $checkIds[1], "Second event rev ID is higher" );
+ }
+
+ public static function provideSectionEdit() {
+ $text = 'Intro
+
+== one ==
+first section.
+
+== two ==
+second section.
+';
+
+ $sectionOne = '== one ==
+hello
+';
+
+ $newSection = '== new section ==
+
+hello
+';
+
+ $textWithNewSectionOne = preg_replace(
+ '/== one ==.*== two ==/ms',
+ "$sectionOne\n== two ==", $text
+ );
+
+ $textWithNewSectionAdded = "$text\n$newSection";
+
+ return [
+ [ # 0
+ $text,
+ '',
+ 'hello',
+ 'replace all',
+ 'hello'
+ ],
+
+ [ # 1
+ $text,
+ '1',
+ $sectionOne,
+ 'replace first section',
+ $textWithNewSectionOne,
+ ],
+
+ [ # 2
+ $text,
+ 'new',
+ 'hello',
+ 'new section',
+ $textWithNewSectionAdded,
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideSectionEdit
+ * @covers EditPage
+ */
+ public function testSectionEdit( $base, $section, $text, $summary, $expected ) {
+ $edit = [
+ 'wpTextbox1' => $text,
+ 'wpSummary' => $summary,
+ 'wpSection' => $section,
+ ];
+
+ $this->assertEdit( 'EditPageTest_testSectionEdit', $base, null, $edit,
+ EditPage::AS_SUCCESS_UPDATE, $expected,
+ "expected successfull update of section" );
+ }
+
+ public static function provideAutoMerge() {
+ $tests = [];
+
+ $tests[] = [ # 0: plain conflict
+ "Elmo", # base edit user
+ "one\n\ntwo\n\nthree\n",
+ [ # adam's edit
+ 'wpStarttime' => 1,
+ 'wpTextbox1' => "ONE\n\ntwo\n\nthree\n",
+ ],
+ [ # berta's edit
+ 'wpStarttime' => 2,
+ 'wpTextbox1' => "(one)\n\ntwo\n\nthree\n",
+ ],
+ EditPage::AS_CONFLICT_DETECTED, # expected code
+ "ONE\n\ntwo\n\nthree\n", # expected text
+ 'expected edit conflict', # message
+ ];
+
+ $tests[] = [ # 1: successful merge
+ "Elmo", # base edit user
+ "one\n\ntwo\n\nthree\n",
+ [ # adam's edit
+ 'wpStarttime' => 1,
+ 'wpTextbox1' => "ONE\n\ntwo\n\nthree\n",
+ ],
+ [ # berta's edit
+ 'wpStarttime' => 2,
+ 'wpTextbox1' => "one\n\ntwo\n\nTHREE\n",
+ ],
+ EditPage::AS_SUCCESS_UPDATE, # expected code
+ "ONE\n\ntwo\n\nTHREE\n", # expected text
+ 'expected automatic merge', # message
+ ];
+
+ $text = "Intro\n\n";
+ $text .= "== first section ==\n\n";
+ $text .= "one\n\ntwo\n\nthree\n\n";
+ $text .= "== second section ==\n\n";
+ $text .= "four\n\nfive\n\nsix\n\n";
+
+ // extract the first section.
+ $section = preg_replace( '/.*(== first section ==.*)== second section ==.*/sm', '$1', $text );
+
+ // generate expected text after merge
+ $expected = str_replace( 'one', 'ONE', str_replace( 'three', 'THREE', $text ) );
+
+ $tests[] = [ # 2: merge in section
+ "Elmo", # base edit user
+ $text,
+ [ # adam's edit
+ 'wpStarttime' => 1,
+ 'wpTextbox1' => str_replace( 'one', 'ONE', $section ),
+ 'wpSection' => '1'
+ ],
+ [ # berta's edit
+ 'wpStarttime' => 2,
+ 'wpTextbox1' => str_replace( 'three', 'THREE', $section ),
+ 'wpSection' => '1'
+ ],
+ EditPage::AS_SUCCESS_UPDATE, # expected code
+ $expected, # expected text
+ 'expected automatic section merge', # message
+ ];
+
+ // see whether it makes a difference who did the base edit
+ $testsWithAdam = array_map( function ( $test ) {
+ $test[0] = 'Adam'; // change base edit user
+ return $test;
+ }, $tests );
+
+ $testsWithBerta = array_map( function ( $test ) {
+ $test[0] = 'Berta'; // change base edit user
+ return $test;
+ }, $tests );
+
+ return array_merge( $tests, $testsWithAdam, $testsWithBerta );
+ }
+
+ /**
+ * @dataProvider provideAutoMerge
+ * @covers EditPage
+ */
+ public function testAutoMerge( $baseUser, $text, $adamsEdit, $bertasEdit,
+ $expectedCode, $expectedText, $message = null
+ ) {
+ $this->markTestSkippedIfNoDiff3();
+
+ // create page
+ $ns = $this->getDefaultWikitextNS();
+ $title = Title::newFromText( 'EditPageTest_testAutoMerge', $ns );
+ $page = WikiPage::factory( $title );
+
+ if ( $page->exists() ) {
+ $page->doDeleteArticle( "clean slate for testing" );
+ }
+
+ $baseEdit = [
+ 'wpTextbox1' => $text,
+ ];
+
+ $page = $this->assertEdit( 'EditPageTest_testAutoMerge', null,
+ $baseUser, $baseEdit, null, null, __METHOD__ );
+
+ $this->forceRevisionDate( $page, '20120101000000' );
+
+ $edittime = $page->getTimestamp();
+
+ // start timestamps for conflict detection
+ if ( !isset( $adamsEdit['wpStarttime'] ) ) {
+ $adamsEdit['wpStarttime'] = 1;
+ }
+
+ if ( !isset( $bertasEdit['wpStarttime'] ) ) {
+ $bertasEdit['wpStarttime'] = 2;
+ }
+
+ $starttime = wfTimestampNow();
+ $adamsTime = wfTimestamp(
+ TS_MW,
+ (int)wfTimestamp( TS_UNIX, $starttime ) + (int)$adamsEdit['wpStarttime']
+ );
+ $bertasTime = wfTimestamp(
+ TS_MW,
+ (int)wfTimestamp( TS_UNIX, $starttime ) + (int)$bertasEdit['wpStarttime']
+ );
+
+ $adamsEdit['wpStarttime'] = $adamsTime;
+ $bertasEdit['wpStarttime'] = $bertasTime;
+
+ $adamsEdit['wpSummary'] = 'Adam\'s edit';
+ $bertasEdit['wpSummary'] = 'Bertas\'s edit';
+
+ $adamsEdit['wpEdittime'] = $edittime;
+ $bertasEdit['wpEdittime'] = $edittime;
+
+ // first edit
+ $this->assertEdit( 'EditPageTest_testAutoMerge', null, 'Adam', $adamsEdit,
+ EditPage::AS_SUCCESS_UPDATE, null, "expected successfull update" );
+
+ // second edit
+ $this->assertEdit( 'EditPageTest_testAutoMerge', null, 'Berta', $bertasEdit,
+ $expectedCode, $expectedText, $message );
+ }
+
+ /**
+ * @depends testAutoMerge
+ */
+ public function testCheckDirectEditingDisallowed_forNonTextContent() {
+ $title = Title::newFromText( 'Dummy:NonTextPageForEditPage' );
+ $page = WikiPage::factory( $title );
+
+ $article = new Article( $title );
+ $article->getContext()->setTitle( $title );
+ $ep = new EditPage( $article );
+ $ep->setContextTitle( $title );
+
+ $user = $GLOBALS['wgUser'];
+
+ $edit = [
+ 'wpTextbox1' => serialize( 'non-text content' ),
+ 'wpEditToken' => $user->getEditToken(),
+ 'wpEdittime' => '',
+ 'wpStarttime' => wfTimestampNow(),
+ 'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
+ ];
+
+ $req = new FauxRequest( $edit, true );
+ $ep->importFormData( $req );
+
+ $this->setExpectedException(
+ MWException::class,
+ 'This content model is not supported: testing'
+ );
+
+ $ep->internalAttemptSave( $result, false );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/ExportTest.php b/www/wiki/tests/phpunit/includes/ExportTest.php
new file mode 100644
index 00000000..a5d35706
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/ExportTest.php
@@ -0,0 +1,67 @@
+<?php
+
+/**
+ * Test class for Export methods.
+ *
+ * @group Database
+ *
+ * @author Isaac Hutt <mhutti1@gmail.com>
+ */
+class ExportTest extends MediaWikiLangTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( [
+ 'wgCapitalLinks' => true,
+ ] );
+ }
+
+ /**
+ * @covers WikiExporter::pageByTitle
+ */
+ public function testPageByTitle() {
+ global $wgContLang;
+ $pageTitle = 'UTPage';
+
+ $exporter = new WikiExporter(
+ $this->db,
+ WikiExporter::FULL
+ );
+
+ $title = Title::newFromText( $pageTitle );
+
+ $sink = new DumpStringOutput;
+ $exporter->setOutputSink( $sink );
+ $exporter->openStream();
+ $exporter->pageByTitle( $title );
+ $exporter->closeStream();
+
+ // This throws error if invalid xml output
+ $xmlObject = simplexml_load_string( $sink );
+
+ /**
+ * Check namespaces match xml
+ */
+ $xmlNamespaces = (array)$xmlObject->siteinfo->namespaces->namespace;
+ $xmlNamespaces = str_replace( ' ', '_', $xmlNamespaces );
+ unset( $xmlNamespaces[ '@attributes' ] );
+ foreach ( $xmlNamespaces as &$namespaceObject ) {
+ if ( is_object( $namespaceObject ) ) {
+ $namespaceObject = '';
+ }
+ }
+
+ $actualNamespaces = (array)$wgContLang->getNamespaces();
+ $actualNamespaces = array_values( $actualNamespaces );
+ $this->assertEquals( $actualNamespaces, $xmlNamespaces );
+
+ // Check xml page title correct
+ $xmlTitle = (array)$xmlObject->page->title;
+ $this->assertEquals( $pageTitle, $xmlTitle[0] );
+
+ // Check xml page text is not empty
+ $text = (array)$xmlObject->page->revision->text;
+ $this->assertNotEquals( '', $text[0] );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/ExtraParserTest.php b/www/wiki/tests/phpunit/includes/ExtraParserTest.php
new file mode 100644
index 00000000..75ebd31a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/ExtraParserTest.php
@@ -0,0 +1,216 @@
+<?php
+
+/**
+ * Parser-related tests that don't suit for parserTests.txt
+ *
+ * @group Database
+ */
+class ExtraParserTest extends MediaWikiTestCase {
+
+ /** @var ParserOptions */
+ protected $options;
+ /** @var Parser */
+ protected $parser;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $contLang = Language::factory( 'en' );
+ $this->setMwGlobals( [
+ 'wgShowDBErrorBacktrace' => true,
+ 'wgCleanSignatures' => true,
+ ] );
+ $this->setUserLang( 'en' );
+ $this->setContentLang( $contLang );
+
+ // FIXME: This test should pass without setting global content language
+ $this->options = ParserOptions::newFromUserAndLang( new User, $contLang );
+ $this->options->setTemplateCallback( [ __CLASS__, 'statelessFetchTemplate' ] );
+ $this->parser = new Parser;
+
+ MagicWord::clearCache();
+ }
+
+ /**
+ * @see T10689
+ * @covers Parser::parse
+ */
+ public function testLongNumericLinesDontKillTheParser() {
+ $longLine = '1.' . str_repeat( '1234567890', 100000 ) . "\n";
+
+ $title = Title::newFromText( 'Unit test' );
+ $options = ParserOptions::newFromUser( new User() );
+ $this->assertEquals( "<p>$longLine</p>",
+ $this->parser->parse( $longLine, $title, $options )->getText( [ 'unwrap' => true ] ) );
+ }
+
+ /**
+ * Test the parser entry points
+ * @covers Parser::parse
+ */
+ public function testParse() {
+ $title = Title::newFromText( __FUNCTION__ );
+ $parserOutput = $this->parser->parse( "Test\n{{Foo}}\n{{Bar}}", $title, $this->options );
+ $this->assertEquals(
+ "<p>Test\nContent of <i>Template:Foo</i>\nContent of <i>Template:Bar</i>\n</p>",
+ $parserOutput->getText( [ 'unwrap' => true ] )
+ );
+ }
+
+ /**
+ * @covers Parser::preSaveTransform
+ */
+ public function testPreSaveTransform() {
+ $title = Title::newFromText( __FUNCTION__ );
+ $outputText = $this->parser->preSaveTransform(
+ "Test\r\n{{subst:Foo}}\n{{Bar}}",
+ $title,
+ new User(),
+ $this->options
+ );
+
+ $this->assertEquals( "Test\nContent of ''Template:Foo''\n{{Bar}}", $outputText );
+ }
+
+ /**
+ * @covers Parser::preprocess
+ */
+ public function testPreprocess() {
+ $title = Title::newFromText( __FUNCTION__ );
+ $outputText = $this->parser->preprocess( "Test\n{{Foo}}\n{{Bar}}", $title, $this->options );
+
+ $this->assertEquals(
+ "Test\nContent of ''Template:Foo''\nContent of ''Template:Bar''",
+ $outputText
+ );
+ }
+
+ /**
+ * cleanSig() makes all templates substs and removes tildes
+ * @covers Parser::cleanSig
+ */
+ public function testCleanSig() {
+ $title = Title::newFromText( __FUNCTION__ );
+ $outputText = $this->parser->cleanSig( "{{Foo}} ~~~~" );
+
+ $this->assertEquals( "{{SUBST:Foo}} ", $outputText );
+ }
+
+ /**
+ * cleanSig() should do nothing if disabled
+ * @covers Parser::cleanSig
+ */
+ public function testCleanSigDisabled() {
+ $this->setMwGlobals( 'wgCleanSignatures', false );
+
+ $title = Title::newFromText( __FUNCTION__ );
+ $outputText = $this->parser->cleanSig( "{{Foo}} ~~~~" );
+
+ $this->assertEquals( "{{Foo}} ~~~~", $outputText );
+ }
+
+ /**
+ * cleanSigInSig() just removes tildes
+ * @dataProvider provideStringsForCleanSigInSig
+ * @covers Parser::cleanSigInSig
+ */
+ public function testCleanSigInSig( $in, $out ) {
+ $this->assertEquals( Parser::cleanSigInSig( $in ), $out );
+ }
+
+ public static function provideStringsForCleanSigInSig() {
+ return [
+ [ "{{Foo}} ~~~~", "{{Foo}} " ],
+ [ "~~~", "" ],
+ [ "~~~~~", "" ],
+ ];
+ }
+
+ /**
+ * @covers Parser::getSection
+ */
+ public function testGetSection() {
+ $outputText2 = $this->parser->getSection(
+ "Section 0\n== Heading 1 ==\nSection 1\n=== Heading 2 ===\n"
+ . "Section 2\n== Heading 3 ==\nSection 3\n",
+ 2
+ );
+ $outputText1 = $this->parser->getSection(
+ "Section 0\n== Heading 1 ==\nSection 1\n=== Heading 2 ===\n"
+ . "Section 2\n== Heading 3 ==\nSection 3\n",
+ 1
+ );
+
+ $this->assertEquals( "=== Heading 2 ===\nSection 2", $outputText2 );
+ $this->assertEquals( "== Heading 1 ==\nSection 1\n=== Heading 2 ===\nSection 2", $outputText1 );
+ }
+
+ /**
+ * @covers Parser::replaceSection
+ */
+ public function testReplaceSection() {
+ $outputText = $this->parser->replaceSection(
+ "Section 0\n== Heading 1 ==\nSection 1\n=== Heading 2 ===\n"
+ . "Section 2\n== Heading 3 ==\nSection 3\n",
+ 1,
+ "New section 1"
+ );
+
+ $this->assertEquals( "Section 0\nNew section 1\n\n== Heading 3 ==\nSection 3", $outputText );
+ }
+
+ /**
+ * Templates and comments are not affected, but noinclude/onlyinclude is.
+ * @covers Parser::getPreloadText
+ */
+ public function testGetPreloadText() {
+ $title = Title::newFromText( __FUNCTION__ );
+ $outputText = $this->parser->getPreloadText(
+ "{{Foo}}<noinclude> censored</noinclude> information <!-- is very secret -->",
+ $title,
+ $this->options
+ );
+
+ $this->assertEquals( "{{Foo}} information <!-- is very secret -->", $outputText );
+ }
+
+ /**
+ * @param Title $title
+ * @param bool $parser
+ *
+ * @return array
+ */
+ static function statelessFetchTemplate( $title, $parser = false ) {
+ $text = "Content of ''" . $title->getFullText() . "''";
+ $deps = [];
+
+ return [
+ 'text' => $text,
+ 'finalTitle' => $title,
+ 'deps' => $deps ];
+ }
+
+ /**
+ * @covers Parser::parse
+ */
+ public function testTrackingCategory() {
+ $title = Title::newFromText( __FUNCTION__ );
+ $catName = wfMessage( 'broken-file-category' )->inContentLanguage()->text();
+ $cat = Title::makeTitleSafe( NS_CATEGORY, $catName );
+ $expected = [ $cat->getDBkey() ];
+ $parserOutput = $this->parser->parse( "[[file:nonexistent]]", $title, $this->options );
+ $result = $parserOutput->getCategoryLinks();
+ $this->assertEquals( $expected, $result );
+ }
+
+ /**
+ * @covers Parser::parse
+ */
+ public function testTrackingCategorySpecial() {
+ // Special pages shouldn't have tracking cats.
+ $title = SpecialPage::getTitleFor( 'Contributions' );
+ $parserOutput = $this->parser->parse( "[[file:nonexistent]]", $title, $this->options );
+ $result = $parserOutput->getCategoryLinks();
+ $this->assertEmpty( $result );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/FauxRequestTest.php b/www/wiki/tests/phpunit/includes/FauxRequestTest.php
new file mode 100644
index 00000000..9e7d6802
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/FauxRequestTest.php
@@ -0,0 +1,245 @@
+<?php
+
+use MediaWiki\Session\SessionManager;
+
+class FauxRequestTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ /**
+ * @covers FauxRequest::__construct
+ */
+ public function testConstructInvalidData() {
+ $this->setExpectedException( MWException::class, 'bogus data' );
+ $req = new FauxRequest( 'x' );
+ }
+
+ /**
+ * @covers FauxRequest::__construct
+ */
+ public function testConstructInvalidSession() {
+ $this->setExpectedException( MWException::class, 'bogus session' );
+ $req = new FauxRequest( [], false, 'x' );
+ }
+
+ /**
+ * @covers FauxRequest::__construct
+ */
+ public function testConstructWithSession() {
+ $session = SessionManager::singleton()->getEmptySession( new FauxRequest( [] ) );
+ $this->assertInstanceOf(
+ FauxRequest::class,
+ new FauxRequest( [], false, $session )
+ );
+ }
+
+ /**
+ * @covers FauxRequest::getText
+ */
+ public function testGetText() {
+ $req = new FauxRequest( [ 'x' => 'Value' ] );
+ $this->assertEquals( 'Value', $req->getText( 'x' ) );
+ $this->assertEquals( '', $req->getText( 'z' ) );
+ }
+
+ /**
+ * Integration test for parent method
+ * @covers FauxRequest::getVal
+ */
+ public function testGetVal() {
+ $req = new FauxRequest( [ 'crlf' => "A\r\nb" ] );
+ $this->assertSame( "A\r\nb", $req->getVal( 'crlf' ), 'CRLF' );
+ }
+
+ /**
+ * Integration test for parent method
+ * @covers FauxRequest::getRawVal
+ */
+ public function testGetRawVal() {
+ $req = new FauxRequest( [
+ 'x' => 'Value',
+ 'y' => [ 'a' ],
+ 'crlf' => "A\r\nb"
+ ] );
+ $this->assertSame( 'Value', $req->getRawVal( 'x' ) );
+ $this->assertSame( null, $req->getRawVal( 'z' ), 'Not found' );
+ $this->assertSame( null, $req->getRawVal( 'y' ), 'Array is ignored' );
+ $this->assertSame( "A\r\nb", $req->getRawVal( 'crlf' ), 'CRLF' );
+ }
+
+ /**
+ * @covers FauxRequest::getValues
+ */
+ public function testGetValues() {
+ $values = [ 'x' => 'Value', 'y' => '' ];
+ $req = new FauxRequest( $values );
+ $this->assertEquals( $values, $req->getValues() );
+ }
+
+ /**
+ * @covers FauxRequest::getQueryValues
+ */
+ public function testGetQueryValues() {
+ $values = [ 'x' => 'Value', 'y' => '' ];
+
+ $req = new FauxRequest( $values );
+ $this->assertEquals( $values, $req->getQueryValues() );
+ $req = new FauxRequest( $values, /*wasPosted*/ true );
+ $this->assertEquals( [], $req->getQueryValues() );
+ }
+
+ /**
+ * @covers FauxRequest::getMethod
+ */
+ public function testGetMethod() {
+ $req = new FauxRequest( [] );
+ $this->assertEquals( 'GET', $req->getMethod() );
+ $req = new FauxRequest( [], /*wasPosted*/ true );
+ $this->assertEquals( 'POST', $req->getMethod() );
+ }
+
+ /**
+ * @covers FauxRequest::wasPosted
+ */
+ public function testWasPosted() {
+ $req = new FauxRequest( [] );
+ $this->assertFalse( $req->wasPosted() );
+ $req = new FauxRequest( [], /*wasPosted*/ true );
+ $this->assertTrue( $req->wasPosted() );
+ }
+
+ /**
+ * @covers FauxRequest::getCookie
+ * @covers FauxRequest::setCookie
+ * @covers FauxRequest::setCookies
+ */
+ public function testCookies() {
+ $req = new FauxRequest();
+ $this->assertSame( null, $req->getCookie( 'z', '' ) );
+
+ $req->setCookie( 'x', 'Value', '' );
+ $this->assertEquals( 'Value', $req->getCookie( 'x', '' ) );
+
+ $req->setCookies( [ 'x' => 'One', 'y' => 'Two' ], '' );
+ $this->assertEquals( 'One', $req->getCookie( 'x', '' ) );
+ $this->assertEquals( 'Two', $req->getCookie( 'y', '' ) );
+ }
+
+ /**
+ * @covers FauxRequest::getCookie
+ * @covers FauxRequest::setCookie
+ * @covers FauxRequest::setCookies
+ */
+ public function testCookiesDefaultPrefix() {
+ global $wgCookiePrefix;
+ $oldPrefix = $wgCookiePrefix;
+ $wgCookiePrefix = '_';
+
+ $req = new FauxRequest();
+ $this->assertSame( null, $req->getCookie( 'z' ) );
+
+ $req->setCookie( 'x', 'Value' );
+ $this->assertEquals( 'Value', $req->getCookie( 'x' ) );
+
+ $wgCookiePrefix = $oldPrefix;
+ }
+
+ /**
+ * @covers FauxRequest::getRequestURL
+ */
+ public function testGetRequestURL() {
+ $req = new FauxRequest();
+ $this->setExpectedException( MWException::class );
+ $req->getRequestURL();
+ }
+
+ /**
+ * @covers FauxRequest::setRequestURL
+ * @covers FauxRequest::getRequestURL
+ */
+ public function testSetRequestURL() {
+ $req = new FauxRequest();
+ $req->setRequestURL( 'https://example.org' );
+ $this->assertEquals( 'https://example.org', $req->getRequestURL() );
+ }
+
+ /**
+ * @covers FauxRequest::__construct
+ * @covers FauxRequest::getProtocol
+ */
+ public function testProtocol() {
+ $req = new FauxRequest();
+ $this->assertEquals( 'http', $req->getProtocol() );
+ $req = new FauxRequest( [], false, null, 'http' );
+ $this->assertEquals( 'http', $req->getProtocol() );
+ $req = new FauxRequest( [], false, null, 'https' );
+ $this->assertEquals( 'https', $req->getProtocol() );
+ }
+
+ /**
+ * @covers FauxRequest::setHeader
+ * @covers FauxRequest::setHeaders
+ * @covers FauxRequest::getHeader
+ */
+ public function testGetSetHeader() {
+ $value = 'text/plain, text/html';
+
+ $request = new FauxRequest();
+ $request->setHeader( 'Accept', $value );
+
+ $this->assertEquals( $request->getHeader( 'Nonexistent' ), false );
+ $this->assertEquals( $request->getHeader( 'Accept' ), $value );
+ $this->assertEquals( $request->getHeader( 'ACCEPT' ), $value );
+ $this->assertEquals( $request->getHeader( 'accept' ), $value );
+ $this->assertEquals(
+ $request->getHeader( 'Accept', WebRequest::GETHEADER_LIST ),
+ [ 'text/plain', 'text/html' ]
+ );
+ }
+
+ /**
+ * @covers FauxRequest::initHeaders
+ */
+ public function testGetAllHeaders() {
+ $_SERVER['HTTP_TEST'] = 'Example';
+
+ $request = new FauxRequest();
+
+ $this->assertEquals(
+ [],
+ $request->getAllHeaders()
+ );
+
+ $this->assertEquals(
+ false,
+ $request->getHeader( 'test' )
+ );
+ }
+
+ /**
+ * @covers FauxRequest::__construct
+ * @covers FauxRequest::getSessionArray
+ */
+ public function testSessionData() {
+ $values = [ 'x' => 'Value', 'y' => '' ];
+
+ $req = new FauxRequest( [], false, /*session*/ $values );
+ $this->assertEquals( $values, $req->getSessionArray() );
+
+ $req = new FauxRequest();
+ $this->assertSame( null, $req->getSessionArray() );
+ }
+
+ /**
+ * @covers FauxRequest::getRawQueryString
+ * @covers FauxRequest::getRawPostString
+ * @covers FauxRequest::getRawInput
+ */
+ public function testDummies() {
+ $req = new FauxRequest();
+ $this->assertEquals( '', $req->getRawQueryString() );
+ $this->assertEquals( '', $req->getRawPostString() );
+ $this->assertEquals( '', $req->getRawInput() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/FauxResponseTest.php b/www/wiki/tests/phpunit/includes/FauxResponseTest.php
new file mode 100644
index 00000000..eac56fb8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/FauxResponseTest.php
@@ -0,0 +1,148 @@
+<?php
+/**
+ * Tests for the FauxResponse class
+ *
+ * Copyright @ 2011 Alexandre Emsenhuber
+ *
+ * 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
+ */
+
+class FauxResponseTest extends MediaWikiTestCase {
+ /** @var FauxResponse */
+ protected $response;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->response = new FauxResponse;
+ }
+
+ /**
+ * @covers FauxResponse::setCookie
+ * @covers FauxResponse::getCookie
+ * @covers FauxResponse::getCookieData
+ * @covers FauxResponse::getCookies
+ */
+ public function testCookie() {
+ $expire = time() + 100;
+ $cookie = [
+ 'value' => 'val',
+ 'path' => '/path',
+ 'domain' => 'domain',
+ 'secure' => true,
+ 'httpOnly' => false,
+ 'raw' => false,
+ 'expire' => $expire,
+ ];
+
+ $this->assertEquals( null, $this->response->getCookie( 'xkey' ), 'Non-existing cookie' );
+ $this->response->setCookie( 'key', 'val', $expire, [
+ 'prefix' => 'x',
+ 'path' => '/path',
+ 'domain' => 'domain',
+ 'secure' => 1,
+ 'httpOnly' => 0,
+ ] );
+ $this->assertEquals( 'val', $this->response->getCookie( 'xkey' ), 'Existing cookie' );
+ $this->assertEquals( $cookie, $this->response->getCookieData( 'xkey' ),
+ 'Existing cookie (data)' );
+ $this->assertEquals( [ 'xkey' => $cookie ], $this->response->getCookies(),
+ 'Existing cookies' );
+ }
+
+ /**
+ * @covers FauxResponse::getheader
+ * @covers FauxResponse::header
+ */
+ public function testHeader() {
+ $this->assertEquals( null, $this->response->getHeader( 'Location' ), 'Non-existing header' );
+
+ $this->response->header( 'Location: http://localhost/' );
+ $this->assertEquals(
+ 'http://localhost/',
+ $this->response->getHeader( 'Location' ),
+ 'Set header'
+ );
+
+ $this->response->header( 'Location: http://127.0.0.1/' );
+ $this->assertEquals(
+ 'http://127.0.0.1/',
+ $this->response->getHeader( 'Location' ),
+ 'Same header'
+ );
+
+ $this->response->header( 'Location: http://127.0.0.2/', false );
+ $this->assertEquals(
+ 'http://127.0.0.1/',
+ $this->response->getHeader( 'Location' ),
+ 'Same header with override disabled'
+ );
+
+ $this->response->header( 'Location: http://localhost/' );
+ $this->assertEquals(
+ 'http://localhost/',
+ $this->response->getHeader( 'LOCATION' ),
+ 'Get header case insensitive'
+ );
+ }
+
+ /**
+ * @covers FauxResponse::getStatusCode
+ */
+ public function testResponseCode() {
+ $this->response->header( 'HTTP/1.1 200' );
+ $this->assertEquals( 200, $this->response->getStatusCode(), 'Header with no message' );
+
+ $this->response->header( 'HTTP/1.x 201' );
+ $this->assertEquals(
+ 201,
+ $this->response->getStatusCode(),
+ 'Header with no message and protocol 1.x'
+ );
+
+ $this->response->header( 'HTTP/1.1 202 OK' );
+ $this->assertEquals( 202, $this->response->getStatusCode(), 'Normal header' );
+
+ $this->response->header( 'HTTP/1.x 203 OK' );
+ $this->assertEquals(
+ 203,
+ $this->response->getStatusCode(),
+ 'Normal header with no message and protocol 1.x'
+ );
+
+ $this->response->header( 'HTTP/1.x 204 OK', false, 205 );
+ $this->assertEquals(
+ 205,
+ $this->response->getStatusCode(),
+ 'Third parameter overrides the HTTP/... header'
+ );
+
+ $this->response->statusHeader( 210 );
+ $this->assertEquals(
+ 210,
+ $this->response->getStatusCode(),
+ 'Handle statusHeader method'
+ );
+
+ $this->response->header( 'Location: http://localhost/', false, 206 );
+ $this->assertEquals(
+ 206,
+ $this->response->getStatusCode(),
+ 'Third parameter with another header'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/FormOptionsInitializationTest.php b/www/wiki/tests/phpunit/includes/FormOptionsInitializationTest.php
new file mode 100644
index 00000000..0c853e08
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/FormOptionsInitializationTest.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * This file host two test case classes for the MediaWiki FormOptions class:
+ * - FormOptionsInitializationTest : tests initialization of the class.
+ * - FormOptionsTest : tests methods an on instance
+ *
+ * The split let us take advantage of setting up a fixture for the methods
+ * tests.
+ */
+
+/**
+ * Dummy class to makes FormOptions::$options public.
+ * Used by FormOptionsInitializationTest which need to verify the $options
+ * array is correctly set through the FormOptions::add() function.
+ */
+class FormOptionsExposed extends FormOptions {
+ public function getOptions() {
+ return $this->options;
+ }
+}
+
+/**
+ * Test class for FormOptions initialization
+ * Ensure the FormOptions::add() does what we want it to do.
+ *
+ * Copyright © 2011, Antoine Musso
+ *
+ * @author Antoine Musso
+ */
+class FormOptionsInitializationTest extends MediaWikiTestCase {
+ /**
+ * @var FormOptions
+ */
+ protected $object;
+
+ /**
+ * A new fresh and empty FormOptions object to test initialization
+ * with.
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->object = new FormOptionsExposed();
+ }
+
+ /**
+ * @covers FormOptionsExposed::add
+ */
+ public function testAddStringOption() {
+ $this->object->add( 'foo', 'string value' );
+ $this->assertEquals(
+ [
+ 'foo' => [
+ 'default' => 'string value',
+ 'consumed' => false,
+ 'type' => FormOptions::STRING,
+ 'value' => null,
+ ]
+ ],
+ $this->object->getOptions()
+ );
+ }
+
+ /**
+ * @covers FormOptionsExposed::add
+ */
+ public function testAddIntegers() {
+ $this->object->add( 'one', 1 );
+ $this->object->add( 'negone', -1 );
+ $this->assertEquals(
+ [
+ 'negone' => [
+ 'default' => -1,
+ 'value' => null,
+ 'consumed' => false,
+ 'type' => FormOptions::INT,
+ ],
+ 'one' => [
+ 'default' => 1,
+ 'value' => null,
+ 'consumed' => false,
+ 'type' => FormOptions::INT,
+ ]
+ ],
+ $this->object->getOptions()
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/FormOptionsTest.php b/www/wiki/tests/phpunit/includes/FormOptionsTest.php
new file mode 100644
index 00000000..2ee8b983
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/FormOptionsTest.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * This file host two test case classes for the MediaWiki FormOptions class:
+ * - FormOptionsInitializationTest : tests initialization of the class.
+ * - FormOptionsTest : tests methods an on instance
+ *
+ * The split let us take advantage of setting up a fixture for the methods
+ * tests.
+ */
+
+/**
+ * Test class for FormOptions methods.
+ *
+ * Copyright © 2011, Antoine Musso
+ *
+ * @author Antoine Musso
+ */
+class FormOptionsTest extends MediaWikiTestCase {
+ /**
+ * @var FormOptions
+ */
+ protected $object;
+
+ /**
+ * Instanciates a FormOptions object to play with.
+ * FormOptions::add() is tested by the class FormOptionsInitializationTest
+ * so we assume the function is well tested already an use it to create
+ * the fixture.
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->object = new FormOptions;
+ $this->object->add( 'string1', 'string one' );
+ $this->object->add( 'string2', 'string two' );
+ $this->object->add( 'integer', 0 );
+ $this->object->add( 'float', 0.0 );
+ $this->object->add( 'intnull', 0, FormOptions::INTNULL );
+ }
+
+ /** Helpers for testGuessType() */
+ /* @{ */
+ private function assertGuessBoolean( $data ) {
+ $this->guess( FormOptions::BOOL, $data );
+ }
+ private function assertGuessInt( $data ) {
+ $this->guess( FormOptions::INT, $data );
+ }
+ private function assertGuessFloat( $data ) {
+ $this->guess( FormOptions::FLOAT, $data );
+ }
+ private function assertGuessString( $data ) {
+ $this->guess( FormOptions::STRING, $data );
+ }
+ private function assertGuessArray( $data ) {
+ $this->guess( FormOptions::ARR, $data );
+ }
+
+ /** Generic helper */
+ private function guess( $expected, $data ) {
+ $this->assertEquals(
+ $expected,
+ FormOptions::guessType( $data )
+ );
+ }
+ /* @} */
+
+ /**
+ * Reuse helpers above assertGuessBoolean assertGuessInt assertGuessString
+ * @covers FormOptions::guessType
+ */
+ public function testGuessTypeDetection() {
+ $this->assertGuessBoolean( true );
+ $this->assertGuessBoolean( false );
+
+ $this->assertGuessInt( 0 );
+ $this->assertGuessInt( -5 );
+ $this->assertGuessInt( 5 );
+ $this->assertGuessInt( 0x0F );
+
+ $this->assertGuessFloat( 0.0 );
+ $this->assertGuessFloat( 1.5 );
+ $this->assertGuessFloat( 1e3 );
+
+ $this->assertGuessString( 'true' );
+ $this->assertGuessString( 'false' );
+ $this->assertGuessString( '5' );
+ $this->assertGuessString( '0' );
+ $this->assertGuessString( '1.5' );
+
+ $this->assertGuessArray( [ 'foo' ] );
+ }
+
+ /**
+ * @expectedException MWException
+ * @covers FormOptions::guessType
+ */
+ public function testGuessTypeOnNullThrowException() {
+ $this->object->guessType( null );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GitInfoTest.php b/www/wiki/tests/phpunit/includes/GitInfoTest.php
new file mode 100644
index 00000000..1037b370
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GitInfoTest.php
@@ -0,0 +1,102 @@
+<?php
+/**
+ * @covers GitInfo
+ */
+class GitInfoTest extends MediaWikiTestCase {
+
+ public static function setUpBeforeClass() {
+ mkdir( __DIR__ . '/../data/gitrepo' );
+ mkdir( __DIR__ . '/../data/gitrepo/1' );
+ mkdir( __DIR__ . '/../data/gitrepo/2' );
+ mkdir( __DIR__ . '/../data/gitrepo/3' );
+ mkdir( __DIR__ . '/../data/gitrepo/1/.git' );
+ mkdir( __DIR__ . '/../data/gitrepo/1/.git/refs' );
+ mkdir( __DIR__ . '/../data/gitrepo/1/.git/refs/heads' );
+ file_put_contents( __DIR__ . '/../data/gitrepo/1/.git/HEAD',
+ "ref: refs/heads/master\n" );
+ file_put_contents( __DIR__ . '/../data/gitrepo/1/.git/refs/heads/master',
+ "0123456789012345678901234567890123abcdef\n" );
+ file_put_contents( __DIR__ . '/../data/gitrepo/1/.git/packed-refs',
+ "abcdef6789012345678901234567890123456789 refs/heads/master\n" );
+ file_put_contents( __DIR__ . '/../data/gitrepo/2/.git',
+ "gitdir: ../1/.git\n" );
+ file_put_contents( __DIR__ . '/../data/gitrepo/3/.git',
+ 'gitdir: ' . __DIR__ . "/../data/gitrepo/1/.git\n" );
+ }
+
+ public static function tearDownAfterClass() {
+ wfRecursiveRemoveDir( __DIR__ . '/../data/gitrepo' );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( 'wgGitInfoCacheDirectory', __DIR__ . '/../data/gitinfo' );
+ }
+
+ protected function assertValidGitInfo( GitInfo $gitInfo ) {
+ $this->assertTrue( $gitInfo->cacheIsComplete() );
+ $this->assertEquals( 'refs/heads/master', $gitInfo->getHead() );
+ $this->assertEquals( '0123456789abcdef0123456789abcdef01234567',
+ $gitInfo->getHeadSHA1() );
+ $this->assertEquals( '1070884800', $gitInfo->getHeadCommitDate() );
+ $this->assertEquals( 'master', $gitInfo->getCurrentBranch() );
+ $this->assertContains( '0123456789abcdef0123456789abcdef01234567',
+ $gitInfo->getHeadViewUrl() );
+ }
+
+ public function testValidJsonData() {
+ global $IP;
+
+ $this->assertValidGitInfo( new GitInfo( "$IP/testValidJsonData" ) );
+ $this->assertValidGitInfo( new GitInfo( __DIR__ . "/../data/gitinfo/extension" ) );
+ }
+
+ public function testMissingJsonData() {
+ $dir = $GLOBALS['IP'] . '/testMissingJsonData';
+ $fixture = new GitInfo( $dir );
+
+ $this->assertFalse( $fixture->cacheIsComplete() );
+
+ $this->assertEquals( false, $fixture->getHead() );
+ $this->assertEquals( false, $fixture->getHeadSHA1() );
+ $this->assertEquals( false, $fixture->getHeadCommitDate() );
+ $this->assertEquals( false, $fixture->getCurrentBranch() );
+ $this->assertEquals( false, $fixture->getHeadViewUrl() );
+
+ // After calling all the outputs, the cache should be complete
+ $this->assertTrue( $fixture->cacheIsComplete() );
+ }
+
+ public function testReadingHead() {
+ $dir = __DIR__ . '/../data/gitrepo/1';
+ $fixture = new GitInfo( $dir );
+
+ $this->assertEquals( 'refs/heads/master', $fixture->getHead() );
+ $this->assertEquals( '0123456789012345678901234567890123abcdef', $fixture->getHeadSHA1() );
+ }
+
+ public function testIndirection() {
+ $dir = __DIR__ . '/../data/gitrepo/2';
+ $fixture = new GitInfo( $dir );
+
+ $this->assertEquals( 'refs/heads/master', $fixture->getHead() );
+ $this->assertEquals( '0123456789012345678901234567890123abcdef', $fixture->getHeadSHA1() );
+ }
+
+ public function testIndirection2() {
+ $dir = __DIR__ . '/../data/gitrepo/3';
+ $fixture = new GitInfo( $dir );
+
+ $this->assertEquals( 'refs/heads/master', $fixture->getHead() );
+ $this->assertEquals( '0123456789012345678901234567890123abcdef', $fixture->getHeadSHA1() );
+ }
+
+ public function testReadingPackedRefs() {
+ $dir = __DIR__ . '/../data/gitrepo/1';
+ unlink( __DIR__ . '/../data/gitrepo/1/.git/refs/heads/master' );
+ $fixture = new GitInfo( $dir );
+
+ $this->assertEquals( 'refs/heads/master', $fixture->getHead() );
+ $this->assertEquals( 'abcdef6789012345678901234567890123456789', $fixture->getHeadSHA1() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/GlobalTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/GlobalTest.php
new file mode 100644
index 00000000..ee4819fa
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/GlobalTest.php
@@ -0,0 +1,812 @@
+<?php
+
+/**
+ * @group Database
+ * @group GlobalFunctions
+ */
+class GlobalTest extends MediaWikiTestCase {
+ protected function setUp() {
+ parent::setUp();
+
+ $readOnlyFile = $this->getNewTempFile();
+ unlink( $readOnlyFile );
+
+ $this->setMwGlobals( [
+ 'wgReadOnlyFile' => $readOnlyFile,
+ 'wgUrlProtocols' => [
+ 'http://',
+ 'https://',
+ 'mailto:',
+ '//',
+ 'file://', # Non-default
+ ],
+ ] );
+ }
+
+ /**
+ * @dataProvider provideForWfArrayDiff2
+ * @covers ::wfArrayDiff2
+ */
+ public function testWfArrayDiff2( $a, $b, $expected ) {
+ $this->assertEquals(
+ wfArrayDiff2( $a, $b ), $expected
+ );
+ }
+
+ // @todo Provide more tests
+ public static function provideForWfArrayDiff2() {
+ // $a $b $expected
+ return [
+ [
+ [ 'a', 'b' ],
+ [ 'a', 'b' ],
+ [],
+ ],
+ [
+ [ [ 'a' ], [ 'a', 'b', 'c' ] ],
+ [ [ 'a' ], [ 'a', 'b' ] ],
+ [ 1 => [ 'a', 'b', 'c' ] ],
+ ],
+ ];
+ }
+
+ /*
+ * Test cases for random functions could hypothetically fail,
+ * even though they shouldn't.
+ */
+
+ /**
+ * @covers ::wfRandom
+ */
+ public function testRandom() {
+ $this->assertFalse(
+ wfRandom() == wfRandom()
+ );
+ }
+
+ /**
+ * @covers ::wfRandomString
+ */
+ public function testRandomString() {
+ $this->assertFalse(
+ wfRandomString() == wfRandomString()
+ );
+ $this->assertEquals(
+ strlen( wfRandomString( 10 ) ), 10
+ );
+ $this->assertTrue(
+ preg_match( '/^[0-9a-f]+$/i', wfRandomString() ) === 1
+ );
+ }
+
+ /**
+ * @covers ::wfUrlencode
+ */
+ public function testUrlencode() {
+ $this->assertEquals(
+ "%E7%89%B9%E5%88%A5:Contributions/Foobar",
+ wfUrlencode( "\xE7\x89\xB9\xE5\x88\xA5:Contributions/Foobar" ) );
+ }
+
+ /**
+ * @covers ::wfExpandIRI
+ */
+ public function testExpandIRI() {
+ $this->assertEquals(
+ "https://te.wikibooks.org/wiki/ఉబుంటు_వాడుకరి_మార్గదర్శని",
+ wfExpandIRI( "https://te.wikibooks.org/wiki/"
+ . "%E0%B0%89%E0%B0%AC%E0%B1%81%E0%B0%82%E0%B0%9F%E0%B1%81_"
+ . "%E0%B0%B5%E0%B0%BE%E0%B0%A1%E0%B1%81%E0%B0%95%E0%B0%B0%E0%B0%BF_"
+ . "%E0%B0%AE%E0%B0%BE%E0%B0%B0%E0%B1%8D%E0%B0%97%E0%B0%A6%E0%B0%B0"
+ . "%E0%B1%8D%E0%B0%B6%E0%B0%A8%E0%B0%BF" ) );
+ }
+
+ /**
+ * Intended to cover the relevant bits of ServiceWiring.php, as well as GlobalFunctions.php
+ * @covers ::wfReadOnly
+ */
+ public function testReadOnlyEmpty() {
+ global $wgReadOnly;
+ $wgReadOnly = null;
+
+ MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode()->clearCache();
+ $this->assertFalse( wfReadOnly() );
+ $this->assertFalse( wfReadOnly() );
+ }
+
+ /**
+ * Intended to cover the relevant bits of ServiceWiring.php, as well as GlobalFunctions.php
+ * @covers ::wfReadOnly
+ */
+ public function testReadOnlySet() {
+ global $wgReadOnly, $wgReadOnlyFile;
+
+ $readOnlyMode = MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode();
+ $readOnlyMode->clearCache();
+
+ $f = fopen( $wgReadOnlyFile, "wt" );
+ fwrite( $f, 'Message' );
+ fclose( $f );
+ $wgReadOnly = null; # Check on $wgReadOnlyFile
+
+ $this->assertTrue( wfReadOnly() );
+ $this->assertTrue( wfReadOnly() ); # Check cached
+
+ unlink( $wgReadOnlyFile );
+ $readOnlyMode->clearCache();
+ $this->assertFalse( wfReadOnly() );
+ $this->assertFalse( wfReadOnly() );
+ }
+
+ /**
+ * This behaviour could probably be deprecated. Several extensions rely on it as of 1.29.
+ * @covers ::wfReadOnlyReason
+ */
+ public function testReadOnlyGlobalChange() {
+ $this->assertFalse( wfReadOnlyReason() );
+ $this->setMwGlobals( [
+ 'wgReadOnly' => 'reason'
+ ] );
+ $this->assertSame( 'reason', wfReadOnlyReason() );
+ }
+
+ public static function provideArrayToCGI() {
+ return [
+ [ [], '' ], // empty
+ [ [ 'foo' => 'bar' ], 'foo=bar' ], // string test
+ [ [ 'foo' => '' ], 'foo=' ], // empty string test
+ [ [ 'foo' => 1 ], 'foo=1' ], // number test
+ [ [ 'foo' => true ], 'foo=1' ], // true test
+ [ [ 'foo' => false ], '' ], // false test
+ [ [ 'foo' => null ], '' ], // null test
+ [ [ 'foo' => 'A&B=5+6@!"\'' ], 'foo=A%26B%3D5%2B6%40%21%22%27' ], // urlencoding test
+ [
+ [ 'foo' => 'bar', 'baz' => 'is', 'asdf' => 'qwerty' ],
+ 'foo=bar&baz=is&asdf=qwerty'
+ ], // multi-item test
+ [ [ 'foo' => [ 'bar' => 'baz' ] ], 'foo%5Bbar%5D=baz' ],
+ [
+ [ 'foo' => [ 'bar' => 'baz', 'qwerty' => 'asdf' ] ],
+ 'foo%5Bbar%5D=baz&foo%5Bqwerty%5D=asdf'
+ ],
+ [ [ 'foo' => [ 'bar', 'baz' ] ], 'foo%5B0%5D=bar&foo%5B1%5D=baz' ],
+ [
+ [ 'foo' => [ 'bar' => [ 'bar' => 'baz' ] ] ],
+ 'foo%5Bbar%5D%5Bbar%5D=baz'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideArrayToCGI
+ * @covers ::wfArrayToCgi
+ */
+ public function testArrayToCGI( $array, $result ) {
+ $this->assertEquals( $result, wfArrayToCgi( $array ) );
+ }
+
+ /**
+ * @covers ::wfArrayToCgi
+ */
+ public function testArrayToCGI2() {
+ $this->assertEquals(
+ "baz=bar&foo=bar",
+ wfArrayToCgi(
+ [ 'baz' => 'bar' ],
+ [ 'foo' => 'bar', 'baz' => 'overridden value' ] ) );
+ }
+
+ public static function provideCgiToArray() {
+ return [
+ [ '', [] ], // empty
+ [ 'foo=bar', [ 'foo' => 'bar' ] ], // string
+ [ 'foo=', [ 'foo' => '' ] ], // empty string
+ [ 'foo', [ 'foo' => '' ] ], // missing =
+ [ 'foo=bar&qwerty=asdf', [ 'foo' => 'bar', 'qwerty' => 'asdf' ] ], // multiple value
+ [ 'foo=A%26B%3D5%2B6%40%21%22%27', [ 'foo' => 'A&B=5+6@!"\'' ] ], // urldecoding test
+ [ 'foo%5Bbar%5D=baz', [ 'foo' => [ 'bar' => 'baz' ] ] ],
+ [
+ 'foo%5Bbar%5D=baz&foo%5Bqwerty%5D=asdf',
+ [ 'foo' => [ 'bar' => 'baz', 'qwerty' => 'asdf' ] ]
+ ],
+ [ 'foo%5B0%5D=bar&foo%5B1%5D=baz', [ 'foo' => [ 0 => 'bar', 1 => 'baz' ] ] ],
+ [
+ 'foo%5Bbar%5D%5Bbar%5D=baz',
+ [ 'foo' => [ 'bar' => [ 'bar' => 'baz' ] ] ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCgiToArray
+ * @covers ::wfCgiToArray
+ */
+ public function testCgiToArray( $cgi, $result ) {
+ $this->assertEquals( $result, wfCgiToArray( $cgi ) );
+ }
+
+ public static function provideCgiRoundTrip() {
+ return [
+ [ '' ],
+ [ 'foo=bar' ],
+ [ 'foo=' ],
+ [ 'foo=bar&baz=biz' ],
+ [ 'foo=A%26B%3D5%2B6%40%21%22%27' ],
+ [ 'foo%5Bbar%5D=baz' ],
+ [ 'foo%5B0%5D=bar&foo%5B1%5D=baz' ],
+ [ 'foo%5Bbar%5D%5Bbar%5D=baz' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCgiRoundTrip
+ * @covers ::wfArrayToCgi
+ */
+ public function testCgiRoundTrip( $cgi ) {
+ $this->assertEquals( $cgi, wfArrayToCgi( wfCgiToArray( $cgi ) ) );
+ }
+
+ /**
+ * @covers ::mimeTypeMatch
+ */
+ public function testMimeTypeMatch() {
+ $this->assertEquals(
+ 'text/html',
+ mimeTypeMatch( 'text/html',
+ [ 'application/xhtml+xml' => 1.0,
+ 'text/html' => 0.7,
+ 'text/plain' => 0.3 ] ) );
+ $this->assertEquals(
+ 'text/*',
+ mimeTypeMatch( 'text/html',
+ [ 'image/*' => 1.0,
+ 'text/*' => 0.5 ] ) );
+ $this->assertEquals(
+ '*/*',
+ mimeTypeMatch( 'text/html',
+ [ '*/*' => 1.0 ] ) );
+ $this->assertNull(
+ mimeTypeMatch( 'text/html',
+ [ 'image/png' => 1.0,
+ 'image/svg+xml' => 0.5 ] ) );
+ }
+
+ /**
+ * @covers ::wfNegotiateType
+ */
+ public function testNegotiateType() {
+ $this->assertEquals(
+ 'text/html',
+ wfNegotiateType(
+ [ 'application/xhtml+xml' => 1.0,
+ 'text/html' => 0.7,
+ 'text/plain' => 0.5,
+ 'text/*' => 0.2 ],
+ [ 'text/html' => 1.0 ] ) );
+ $this->assertEquals(
+ 'application/xhtml+xml',
+ wfNegotiateType(
+ [ 'application/xhtml+xml' => 1.0,
+ 'text/html' => 0.7,
+ 'text/plain' => 0.5,
+ 'text/*' => 0.2 ],
+ [ 'application/xhtml+xml' => 1.0,
+ 'text/html' => 0.5 ] ) );
+ $this->assertEquals(
+ 'text/html',
+ wfNegotiateType(
+ [ 'text/html' => 1.0,
+ 'text/plain' => 0.5,
+ 'text/*' => 0.5,
+ 'application/xhtml+xml' => 0.2 ],
+ [ 'application/xhtml+xml' => 1.0,
+ 'text/html' => 0.5 ] ) );
+ $this->assertEquals(
+ 'text/html',
+ wfNegotiateType(
+ [ 'text/*' => 1.0,
+ 'image/*' => 0.7,
+ '*/*' => 0.3 ],
+ [ 'application/xhtml+xml' => 1.0,
+ 'text/html' => 0.5 ] ) );
+ $this->assertNull(
+ wfNegotiateType(
+ [ 'text/*' => 1.0 ],
+ [ 'application/xhtml+xml' => 1.0 ] ) );
+ }
+
+ /**
+ * @covers ::wfDebug
+ * @covers ::wfDebugMem
+ */
+ public function testDebugFunctionTest() {
+ $debugLogFile = $this->getNewTempFile();
+
+ $this->setMwGlobals( [
+ 'wgDebugLogFile' => $debugLogFile,
+ #  @todo FIXME: $wgDebugTimestamps should be tested
+ 'wgDebugTimestamps' => false
+ ] );
+
+ wfDebug( "This is a normal string" );
+ $this->assertEquals( "This is a normal string\n", file_get_contents( $debugLogFile ) );
+ unlink( $debugLogFile );
+
+ wfDebug( "This is nöt an ASCII string" );
+ $this->assertEquals( "This is nöt an ASCII string\n", file_get_contents( $debugLogFile ) );
+ unlink( $debugLogFile );
+
+ wfDebug( "\00305This has böth UTF and control chars\003" );
+ $this->assertEquals(
+ " 05This has böth UTF and control chars \n",
+ file_get_contents( $debugLogFile )
+ );
+ unlink( $debugLogFile );
+
+ wfDebugMem();
+ $this->assertGreaterThan(
+ 1000,
+ preg_replace( '/\D/', '', file_get_contents( $debugLogFile ) )
+ );
+ unlink( $debugLogFile );
+
+ wfDebugMem( true );
+ $this->assertGreaterThan(
+ 1000000,
+ preg_replace( '/\D/', '', file_get_contents( $debugLogFile ) )
+ );
+ unlink( $debugLogFile );
+ }
+
+ /**
+ * @covers ::wfClientAcceptsGzip
+ */
+ public function testClientAcceptsGzipTest() {
+ $settings = [
+ 'gzip' => true,
+ 'bzip' => false,
+ '*' => false,
+ 'compress, gzip' => true,
+ 'gzip;q=1.0' => true,
+ 'foozip' => false,
+ 'foo*zip' => false,
+ 'gzip;q=abcde' => true, // is this REALLY valid?
+ 'gzip;q=12345678.9' => true,
+ ' gzip' => true,
+ ];
+
+ if ( isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) {
+ $old_server_setting = $_SERVER['HTTP_ACCEPT_ENCODING'];
+ }
+
+ foreach ( $settings as $encoding => $expect ) {
+ $_SERVER['HTTP_ACCEPT_ENCODING'] = $encoding;
+
+ $this->assertEquals( $expect, wfClientAcceptsGzip( true ),
+ "'$encoding' => " . wfBoolToStr( $expect ) );
+ }
+
+ if ( isset( $old_server_setting ) ) {
+ $_SERVER['HTTP_ACCEPT_ENCODING'] = $old_server_setting;
+ }
+ }
+
+ /**
+ * @covers ::wfPercent
+ */
+ public function testWfPercentTest() {
+ $pcts = [
+ [ 6 / 7, '0.86%', 2, false ],
+ [ 3 / 3, '1%' ],
+ [ 22 / 7, '3.14286%', 5 ],
+ [ 3 / 6, '0.5%' ],
+ [ 1 / 3, '0%', 0 ],
+ [ 10 / 3, '0%', -1 ],
+ [ 3 / 4 / 5, '0.1%', 1 ],
+ [ 6 / 7 * 8, '6.8571428571%', 10 ],
+ ];
+
+ foreach ( $pcts as $pct ) {
+ if ( !isset( $pct[2] ) ) {
+ $pct[2] = 2;
+ }
+ if ( !isset( $pct[3] ) ) {
+ $pct[3] = true;
+ }
+
+ $this->assertEquals( wfPercent( $pct[0], $pct[2], $pct[3] ), $pct[1], $pct[1] );
+ }
+ }
+
+ /**
+ * test @see wfShorthandToInteger()
+ * @dataProvider provideShorthand
+ * @covers ::wfShorthandToInteger
+ */
+ public function testWfShorthandToInteger( $shorthand, $expected ) {
+ $this->assertEquals( $expected,
+ wfShorthandToInteger( $shorthand )
+ );
+ }
+
+ public static function provideShorthand() {
+ // Syntax: [ shorthand, expected integer ]
+ return [
+ # Null, empty ...
+ [ '', -1 ],
+ [ ' ', -1 ],
+ [ null, -1 ],
+
+ # Failures returns 0 :(
+ [ 'ABCDEFG', 0 ],
+ [ 'Ak', 0 ],
+
+ # Int, strings with spaces
+ [ 1, 1 ],
+ [ ' 1 ', 1 ],
+ [ 1023, 1023 ],
+ [ ' 1023 ', 1023 ],
+
+ # kilo, Mega, Giga
+ [ '1k', 1024 ],
+ [ '1K', 1024 ],
+ [ '1m', 1024 * 1024 ],
+ [ '1M', 1024 * 1024 ],
+ [ '1g', 1024 * 1024 * 1024 ],
+ [ '1G', 1024 * 1024 * 1024 ],
+
+ # Negatives
+ [ -1, -1 ],
+ [ -500, -500 ],
+ [ '-500', -500 ],
+ [ '-1k', -1024 ],
+
+ # Zeroes
+ [ '0', 0 ],
+ [ '0k', 0 ],
+ [ '0M', 0 ],
+ [ '0G', 0 ],
+ [ '-0', 0 ],
+ [ '-0k', 0 ],
+ [ '-0M', 0 ],
+ [ '-0G', 0 ],
+ ];
+ }
+
+ /**
+ * @covers ::wfMerge
+ */
+ public function testMerge_worksWithLessParameters() {
+ $this->markTestSkippedIfNoDiff3();
+
+ $mergedText = null;
+ $successfulMerge = wfMerge( "old1\n\nold2", "old1\n\nnew2", "new1\n\nold2", $mergedText );
+
+ $mergedText = null;
+ $conflictingMerge = wfMerge( 'old', 'old and mine', 'old and yours', $mergedText );
+
+ $this->assertEquals( true, $successfulMerge );
+ $this->assertEquals( false, $conflictingMerge );
+ }
+
+ /**
+ * @param string $old Text as it was in the database
+ * @param string $mine Text submitted while user was editing
+ * @param string $yours Text submitted by the user
+ * @param bool $expectedMergeResult Whether the merge should be a success
+ * @param string $expectedText Text after merge has been completed
+ * @param string $expectedMergeAttemptResult Diff3 output if conflicts occur
+ *
+ * @dataProvider provideMerge()
+ * @group medium
+ * @covers ::wfMerge
+ */
+ public function testMerge( $old, $mine, $yours, $expectedMergeResult, $expectedText,
+ $expectedMergeAttemptResult ) {
+ $this->markTestSkippedIfNoDiff3();
+
+ $mergedText = null;
+ $attemptMergeResult = null;
+ $isMerged = wfMerge( $old, $mine, $yours, $mergedText, $mergeAttemptResult );
+
+ $msg = 'Merge should be a ';
+ $msg .= $expectedMergeResult ? 'success' : 'failure';
+ $this->assertEquals( $expectedMergeResult, $isMerged, $msg );
+ $this->assertEquals( $expectedMergeAttemptResult, $mergeAttemptResult );
+
+ if ( $isMerged ) {
+ // Verify the merged text
+ $this->assertEquals( $expectedText, $mergedText,
+ 'is merged text as expected?' );
+ }
+ }
+
+ public static function provideMerge() {
+ $EXPECT_MERGE_SUCCESS = true;
+ $EXPECT_MERGE_FAILURE = false;
+
+ return [
+ // #0: clean merge
+ [
+ // old:
+ "one one one\n" . // trimmed
+ "\n" .
+ "two two two",
+
+ // mine:
+ "one one one ONE ONE\n" .
+ "\n" .
+ "two two two\n", // with tailing whitespace
+
+ // yours:
+ "one one one\n" .
+ "\n" .
+ "two two TWO TWO", // trimmed
+
+ // ok:
+ $EXPECT_MERGE_SUCCESS,
+
+ // result:
+ "one one one ONE ONE\n" .
+ "\n" .
+ "two two TWO TWO\n", // note: will always end in a newline
+
+ // mergeAttemptResult:
+ "",
+ ],
+
+ // #1: conflict, fail
+ [
+ // old:
+ "one one one", // trimmed
+
+ // mine:
+ "one one one ONE ONE\n" .
+ "\n" .
+ "bla bla\n" .
+ "\n", // with tailing whitespace
+
+ // yours:
+ "one one one\n" .
+ "\n" .
+ "two two", // trimmed
+
+ $EXPECT_MERGE_FAILURE,
+
+ // result:
+ null,
+
+ // mergeAttemptResult:
+ "1,3c\n" .
+ "one one one\n" .
+ "\n" .
+ "two two\n" .
+ ".\n",
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideMakeUrlIndexes()
+ * @covers ::wfMakeUrlIndexes
+ */
+ public function testMakeUrlIndexes( $url, $expected ) {
+ $index = wfMakeUrlIndexes( $url );
+ $this->assertEquals( $expected, $index, "wfMakeUrlIndexes(\"$url\")" );
+ }
+
+ public static function provideMakeUrlIndexes() {
+ return [
+ // Testcase for T30627
+ [
+ 'https://example.org/test.cgi?id=12345',
+ [ 'https://org.example./test.cgi?id=12345' ]
+ ],
+ [
+ // mailtos are handled special
+ // is this really right though? that final . probably belongs earlier?
+ 'mailto:wiki@wikimedia.org',
+ [ 'mailto:org.wikimedia@wiki.' ]
+ ],
+
+ // file URL cases per T30627...
+ [
+ // three slashes: local filesystem path Unix-style
+ 'file:///whatever/you/like.txt',
+ [ 'file://./whatever/you/like.txt' ]
+ ],
+ [
+ // three slashes: local filesystem path Windows-style
+ 'file:///c:/whatever/you/like.txt',
+ [ 'file://./c:/whatever/you/like.txt' ]
+ ],
+ [
+ // two slashes: UNC filesystem path Windows-style
+ 'file://intranet/whatever/you/like.txt',
+ [ 'file://intranet./whatever/you/like.txt' ]
+ ],
+ // Multiple-slash cases that can sorta work on Mozilla
+ // if you hack it just right are kinda pathological,
+ // and unreliable cross-platform or on IE which means they're
+ // unlikely to appear on intranets.
+ // Those will survive the algorithm but with results that
+ // are less consistent.
+
+ // protocol-relative URL cases per T31854...
+ [
+ '//example.org/test.cgi?id=12345',
+ [
+ 'http://org.example./test.cgi?id=12345',
+ 'https://org.example./test.cgi?id=12345'
+ ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideWfMatchesDomainList
+ * @covers ::wfMatchesDomainList
+ */
+ public function testWfMatchesDomainList( $url, $domains, $expected, $description ) {
+ $actual = wfMatchesDomainList( $url, $domains );
+ $this->assertEquals( $expected, $actual, $description );
+ }
+
+ public static function provideWfMatchesDomainList() {
+ $a = [];
+ $protocols = [ 'HTTP' => 'http:', 'HTTPS' => 'https:', 'protocol-relative' => '' ];
+ foreach ( $protocols as $pDesc => $p ) {
+ $a = array_merge( $a, [
+ [
+ "$p//www.example.com",
+ [],
+ false,
+ "No matches for empty domains array, $pDesc URL"
+ ],
+ [
+ "$p//www.example.com",
+ [ 'www.example.com' ],
+ true,
+ "Exact match in domains array, $pDesc URL"
+ ],
+ [
+ "$p//www.example.com",
+ [ 'example.com' ],
+ true,
+ "Match without subdomain in domains array, $pDesc URL"
+ ],
+ [
+ "$p//www.example2.com",
+ [ 'www.example.com', 'www.example2.com', 'www.example3.com' ],
+ true,
+ "Exact match with other domains in array, $pDesc URL"
+ ],
+ [
+ "$p//www.example2.com",
+ [ 'example.com', 'example2.com', 'example3,com' ],
+ true,
+ "Match without subdomain with other domains in array, $pDesc URL"
+ ],
+ [
+ "$p//www.example4.com",
+ [ 'example.com', 'example2.com', 'example3,com' ],
+ false,
+ "Domain not in array, $pDesc URL"
+ ],
+ [
+ "$p//nds-nl.wikipedia.org",
+ [ 'nl.wikipedia.org' ],
+ false,
+ "Non-matching substring of domain, $pDesc URL"
+ ],
+ ] );
+ }
+
+ return $a;
+ }
+
+ /**
+ * @covers ::wfMkdirParents
+ */
+ public function testWfMkdirParents() {
+ // Should not return true if file exists instead of directory
+ $fname = $this->getNewTempFile();
+ Wikimedia\suppressWarnings();
+ $ok = wfMkdirParents( $fname );
+ Wikimedia\restoreWarnings();
+ $this->assertFalse( $ok );
+ }
+
+ /**
+ * @dataProvider provideWfShellWikiCmdList
+ * @covers ::wfShellWikiCmd
+ */
+ public function testWfShellWikiCmd( $script, $parameters, $options,
+ $expected, $description
+ ) {
+ if ( wfIsWindows() ) {
+ // Approximation that's good enough for our purposes just now
+ $expected = str_replace( "'", '"', $expected );
+ }
+ $actual = wfShellWikiCmd( $script, $parameters, $options );
+ $this->assertEquals( $expected, $actual, $description );
+ }
+
+ public function wfWikiID() {
+ $this->setMwGlobals( [
+ 'wgDBname' => 'example',
+ 'wgDBprefix' => '',
+ ] );
+ $this->assertEquals(
+ wfWikiID(),
+ 'example'
+ );
+
+ $this->setMwGlobals( [
+ 'wgDBname' => 'example',
+ 'wgDBprefix' => 'mw_',
+ ] );
+ $this->assertEquals(
+ wfWikiID(),
+ 'example-mw_'
+ );
+ }
+
+ /**
+ * @covers ::wfMemcKey
+ */
+ public function testWfMemcKey() {
+ $cache = ObjectCache::getLocalClusterInstance();
+ $this->assertEquals(
+ $cache->makeKey( 'foo', 123, 'bar' ),
+ wfMemcKey( 'foo', 123, 'bar' )
+ );
+ }
+
+ /**
+ * @covers ::wfForeignMemcKey
+ */
+ public function testWfForeignMemcKey() {
+ $cache = ObjectCache::getLocalClusterInstance();
+ $keyspace = $this->readAttribute( $cache, 'keyspace' );
+ $this->assertEquals(
+ wfForeignMemcKey( $keyspace, '', 'foo', 'bar' ),
+ $cache->makeKey( 'foo', 'bar' )
+ );
+ }
+
+ /**
+ * @covers ::wfGlobalCacheKey
+ */
+ public function testWfGlobalCacheKey() {
+ $cache = ObjectCache::getLocalClusterInstance();
+ $this->assertEquals(
+ $cache->makeGlobalKey( 'foo', 123, 'bar' ),
+ wfGlobalCacheKey( 'foo', 123, 'bar' )
+ );
+ }
+
+ public static function provideWfShellWikiCmdList() {
+ global $wgPhpCli;
+
+ return [
+ [ 'eval.php', [ '--help', '--test' ], [],
+ "'$wgPhpCli' 'eval.php' '--help' '--test'",
+ "Called eval.php --help --test" ],
+ [ 'eval.php', [ '--help', '--test space' ], [ 'php' => 'php5' ],
+ "'php5' 'eval.php' '--help' '--test space'",
+ "Called eval.php --help --test with php option" ],
+ [ 'eval.php', [ '--help', '--test', 'X' ], [ 'wrapper' => 'MWScript.php' ],
+ "'$wgPhpCli' 'MWScript.php' 'eval.php' '--help' '--test' 'X'",
+ "Called eval.php --help --test with wrapper option" ],
+ [
+ 'eval.php',
+ [ '--help', '--test', 'y' ],
+ [ 'php' => 'php5', 'wrapper' => 'MWScript.php' ],
+ "'php5' 'MWScript.php' 'eval.php' '--help' '--test' 'y'",
+ "Called eval.php --help --test with wrapper and php option"
+ ],
+ ];
+ }
+ /* @todo many more! */
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/GlobalWithDBTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/GlobalWithDBTest.php
new file mode 100644
index 00000000..0765ab8b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/GlobalWithDBTest.php
@@ -0,0 +1,32 @@
+<?php
+
+/**
+ * @group GlobalFunctions
+ * @group Database
+ */
+class GlobalWithDBTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider provideWfIsBadImageList
+ * @covers ::wfIsBadImage
+ */
+ public function testWfIsBadImage( $name, $title, $blacklist, $expected, $desc ) {
+ $this->assertEquals( $expected, wfIsBadImage( $name, $title, $blacklist ), $desc );
+ }
+
+ public static function provideWfIsBadImageList() {
+ $blacklist = '* [[File:Bad.jpg]] except [[Nasty page]]';
+
+ return [
+ [ 'Bad.jpg', false, $blacklist, true,
+ 'Called on a bad image' ],
+ [ 'Bad.jpg', Title::makeTitle( NS_MAIN, 'A page' ), $blacklist, true,
+ 'Called on a bad image' ],
+ [ 'NotBad.jpg', false, $blacklist, false,
+ 'Called on a non-bad image' ],
+ [ 'Bad.jpg', Title::makeTitle( NS_MAIN, 'Nasty page' ), $blacklist, false,
+ 'Called on a bad image but is on a whitelisted page' ],
+ [ 'File:Bad.jpg', false, $blacklist, false,
+ 'Called on a bad image with File:' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/README b/www/wiki/tests/phpunit/includes/GlobalFunctions/README
new file mode 100644
index 00000000..0042bdac
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/README
@@ -0,0 +1,2 @@
+This directory hold tests for includes/GlobalFunctions.php file
+which is a pile of functions.
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfAppendQueryTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfAppendQueryTest.php
new file mode 100644
index 00000000..bb71610b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfAppendQueryTest.php
@@ -0,0 +1,79 @@
+<?php
+
+/**
+ * @group GlobalFunctions
+ * @covers ::wfAppendQuery
+ */
+class WfAppendQueryTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider provideAppendQuery
+ */
+ public function testAppendQuery( $url, $query, $expected, $message = null ) {
+ $this->assertEquals( $expected, wfAppendQuery( $url, $query ), $message );
+ }
+
+ public static function provideAppendQuery() {
+ return [
+ [
+ 'http://www.example.org/index.php',
+ '',
+ 'http://www.example.org/index.php',
+ 'No query'
+ ],
+ [
+ 'http://www.example.org/index.php',
+ [ 'foo' => 'bar' ],
+ 'http://www.example.org/index.php?foo=bar',
+ 'Set query array'
+ ],
+ [
+ 'http://www.example.org/index.php?foz=baz',
+ 'foo=bar',
+ 'http://www.example.org/index.php?foz=baz&foo=bar',
+ 'Set query string'
+ ],
+ [
+ 'http://www.example.org/index.php?foo=bar',
+ '',
+ 'http://www.example.org/index.php?foo=bar',
+ 'Empty string with query'
+ ],
+ [
+ 'http://www.example.org/index.php?foo=bar',
+ [ 'baz' => 'quux' ],
+ 'http://www.example.org/index.php?foo=bar&baz=quux',
+ 'Add query array'
+ ],
+ [
+ 'http://www.example.org/index.php?foo=bar',
+ 'baz=quux',
+ 'http://www.example.org/index.php?foo=bar&baz=quux',
+ 'Add query string'
+ ],
+ [
+ 'http://www.example.org/index.php?foo=bar',
+ [ 'baz' => 'quux', 'foo' => 'baz' ],
+ 'http://www.example.org/index.php?foo=bar&baz=quux&foo=baz',
+ 'Modify query array'
+ ],
+ [
+ 'http://www.example.org/index.php?foo=bar',
+ 'baz=quux&foo=baz',
+ 'http://www.example.org/index.php?foo=bar&baz=quux&foo=baz',
+ 'Modify query string'
+ ],
+ [
+ 'http://www.example.org/index.php#baz',
+ 'foo=bar',
+ 'http://www.example.org/index.php?foo=bar#baz',
+ 'URL with fragment'
+ ],
+ [
+ 'http://www.example.org/index.php?foo=bar#baz',
+ 'quux=blah',
+ 'http://www.example.org/index.php?foo=bar&quux=blah#baz',
+ 'URL with query string and fragment'
+ ]
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfArrayFilterTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfArrayFilterTest.php
new file mode 100644
index 00000000..1011a37c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfArrayFilterTest.php
@@ -0,0 +1,42 @@
+<?php
+
+/**
+ * @group GlobalFunctions
+ * @covers ::wfArrayFilter
+ * @covers ::wfArrayFilterByKey
+ */
+class WfArrayFilterTest extends \PHPUnit\Framework\TestCase {
+ public function testWfArrayFilter() {
+ $arr = [ 'a' => 1, 'b' => 2, 'c' => 3 ];
+ $filtered = wfArrayFilter( $arr, function ( $val, $key ) {
+ return $key !== 'b';
+ } );
+ $this->assertSame( [ 'a' => 1, 'c' => 3 ], $filtered );
+
+ $arr = [ 'a' => 1, 'b' => 2, 'c' => 3 ];
+ $filtered = wfArrayFilter( $arr, function ( $val, $key ) {
+ return $val !== 2;
+ } );
+ $this->assertSame( [ 'a' => 1, 'c' => 3 ], $filtered );
+
+ $arr = [ 'a', 'b', 'c' ];
+ $filtered = wfArrayFilter( $arr, function ( $val, $key ) {
+ return $key !== 0;
+ } );
+ $this->assertSame( [ 1 => 'b', 2 => 'c' ], $filtered );
+ }
+
+ public function testWfArrayFilterByKey() {
+ $arr = [ 'a' => 1, 'b' => 2, 'c' => 3 ];
+ $filtered = wfArrayFilterByKey( $arr, function ( $key ) {
+ return $key !== 'b';
+ } );
+ $this->assertSame( [ 'a' => 1, 'c' => 3 ], $filtered );
+
+ $arr = [ 'a', 'b', 'c' ];
+ $filtered = wfArrayFilterByKey( $arr, function ( $key ) {
+ return $key !== 0;
+ } );
+ $this->assertSame( [ 1 => 'b', 2 => 'c' ], $filtered );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfArrayPlus2dTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfArrayPlus2dTest.php
new file mode 100644
index 00000000..65b56ef4
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfArrayPlus2dTest.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * @group GlobalFunctions
+ * @covers ::wfArrayPlus2d
+ */
+class WfArrayPlus2dTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider provideArrays
+ */
+ public function testWfArrayPlus2d( $baseArray, $newValues, $expected, $testName ) {
+ $this->assertEquals(
+ $expected,
+ wfArrayPlus2d( $baseArray, $newValues ),
+ $testName
+ );
+ }
+
+ /**
+ * Provider for testing wfArrayPlus2d
+ *
+ * @return array
+ */
+ public static function provideArrays() {
+ return [
+ // target array, new values array, expected result
+ [
+ [ 0 => '1dArray' ],
+ [ 1 => '1dArray' ],
+ [ 0 => '1dArray', 1 => '1dArray' ],
+ "Test simple union of two arrays with different keys",
+ ],
+ [
+ [
+ 0 => [ 0 => '2dArray' ],
+ ],
+ [
+ 0 => [ 1 => '2dArray' ],
+ ],
+ [
+ 0 => [ 0 => '2dArray', 1 => '2dArray' ],
+ ],
+ "Test union of 2d arrays with different keys in the value array",
+ ],
+ [
+ [
+ 0 => [ 0 => '2dArray' ],
+ ],
+ [
+ 0 => [ 0 => '1dArray' ],
+ ],
+ [
+ 0 => [ 0 => '2dArray' ],
+ ],
+ "Test union of 2d arrays with same keys in the value array",
+ ],
+ [
+ [
+ 0 => [ 0 => [ 0 => '3dArray' ] ],
+ ],
+ [
+ 0 => [ 0 => [ 1 => '2dArray' ] ],
+ ],
+ [
+ 0 => [ 0 => [ 0 => '3dArray' ] ],
+ ],
+ "Test union of 3d array with different keys",
+ ],
+ [
+ [
+ 0 => [ 0 => [ 0 => '3dArray' ] ],
+ ],
+ [
+ 0 => [ 1 => [ 0 => '2dArray' ] ],
+ ],
+ [
+ 0 => [ 0 => [ 0 => '3dArray' ], 1 => [ 0 => '2dArray' ] ],
+ ],
+ "Test union of 3d array with different keys in the value array",
+ ],
+ [
+ [
+ 0 => [ 0 => [ 0 => '3dArray' ] ],
+ ],
+ [
+ 0 => [ 0 => [ 0 => '2dArray' ] ],
+ ],
+ [
+ 0 => [ 0 => [ 0 => '3dArray' ] ],
+ ],
+ "Test union of 3d array with same keys in the value array",
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php
new file mode 100644
index 00000000..7ddad369
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php
@@ -0,0 +1,112 @@
+<?php
+/**
+ * @group GlobalFunctions
+ * @covers ::wfAssembleUrl
+ */
+class WfAssembleUrlTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider provideURLParts
+ */
+ public function testWfAssembleUrl( $parts, $output ) {
+ $partsDump = print_r( $parts, true );
+ $this->assertEquals(
+ $output,
+ wfAssembleUrl( $parts ),
+ "Testing $partsDump assembles to $output"
+ );
+ }
+
+ /**
+ * Provider of URL parts for testing wfAssembleUrl()
+ *
+ * @return array
+ */
+ public static function provideURLParts() {
+ $schemes = [
+ '' => [],
+ '//' => [
+ 'delimiter' => '//',
+ ],
+ 'http://' => [
+ 'scheme' => 'http',
+ 'delimiter' => '://',
+ ],
+ ];
+
+ $hosts = [
+ '' => [],
+ 'example.com' => [
+ 'host' => 'example.com',
+ ],
+ 'example.com:123' => [
+ 'host' => 'example.com',
+ 'port' => 123,
+ ],
+ 'id@example.com' => [
+ 'user' => 'id',
+ 'host' => 'example.com',
+ ],
+ 'id@example.com:123' => [
+ 'user' => 'id',
+ 'host' => 'example.com',
+ 'port' => 123,
+ ],
+ 'id:key@example.com' => [
+ 'user' => 'id',
+ 'pass' => 'key',
+ 'host' => 'example.com',
+ ],
+ 'id:key@example.com:123' => [
+ 'user' => 'id',
+ 'pass' => 'key',
+ 'host' => 'example.com',
+ 'port' => 123,
+ ],
+ ];
+
+ $cases = [];
+ foreach ( $schemes as $scheme => $schemeParts ) {
+ foreach ( $hosts as $host => $hostParts ) {
+ foreach ( [ '', '/path' ] as $path ) {
+ foreach ( [ '', 'query' ] as $query ) {
+ foreach ( [ '', 'fragment' ] as $fragment ) {
+ $parts = array_merge(
+ $schemeParts,
+ $hostParts
+ );
+ $url = $scheme .
+ $host .
+ $path;
+
+ if ( $path ) {
+ $parts['path'] = $path;
+ }
+ if ( $query ) {
+ $parts['query'] = $query;
+ $url .= '?' . $query;
+ }
+ if ( $fragment ) {
+ $parts['fragment'] = $fragment;
+ $url .= '#' . $fragment;
+ }
+
+ $cases[] = [
+ $parts,
+ $url,
+ ];
+ }
+ }
+ }
+ }
+ }
+
+ $complexURL = 'http://id:key@example.org:321' .
+ '/over/there?name=ferret&foo=bar#nose';
+ $cases[] = [
+ wfParseUrl( $complexURL ),
+ $complexURL,
+ ];
+
+ return $cases;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php
new file mode 100644
index 00000000..78e09e60
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * @group GlobalFunctions
+ * @covers ::wfBaseName
+ */
+class WfBaseNameTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider providePaths
+ */
+ public function testBaseName( $fullpath, $basename ) {
+ $this->assertEquals( $basename, wfBaseName( $fullpath ),
+ "wfBaseName('$fullpath') => '$basename'" );
+ }
+
+ public static function providePaths() {
+ return [
+ [ '', '' ],
+ [ '/', '' ],
+ [ '\\', '' ],
+ [ '//', '' ],
+ [ '\\\\', '' ],
+ [ 'a', 'a' ],
+ [ 'aaaa', 'aaaa' ],
+ [ '/a', 'a' ],
+ [ '\\a', 'a' ],
+ [ '/aaaa', 'aaaa' ],
+ [ '\\aaaa', 'aaaa' ],
+ [ '/aaaa/', 'aaaa' ],
+ [ '\\aaaa\\', 'aaaa' ],
+ [ '\\aaaa\\', 'aaaa' ],
+ [
+ '/mnt/upload3/wikipedia/en/thumb/8/8b/'
+ . 'Zork_Grand_Inquisitor_box_cover.jpg/93px-Zork_Grand_Inquisitor_box_cover.jpg',
+ '93px-Zork_Grand_Inquisitor_box_cover.jpg'
+ ],
+ [ 'C:\\Progra~1\\Wikime~1\\Wikipe~1\\VIEWER.EXE', 'VIEWER.EXE' ],
+ [ 'Östergötland_coat_of_arms.png', 'Östergötland_coat_of_arms.png' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfEscapeShellArgTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfEscapeShellArgTest.php
new file mode 100644
index 00000000..7402054e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfEscapeShellArgTest.php
@@ -0,0 +1,43 @@
+<?php
+
+/**
+ * @group GlobalFunctions
+ * @covers ::wfEscapeShellArg
+ */
+class WfEscapeShellArgTest extends MediaWikiTestCase {
+ public function testSingleInput() {
+ if ( wfIsWindows() ) {
+ $expected = '"blah"';
+ } else {
+ $expected = "'blah'";
+ }
+
+ $actual = wfEscapeShellArg( 'blah' );
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public function testMultipleArgs() {
+ if ( wfIsWindows() ) {
+ $expected = '"foo" "bar" "baz"';
+ } else {
+ $expected = "'foo' 'bar' 'baz'";
+ }
+
+ $actual = wfEscapeShellArg( 'foo', 'bar', 'baz' );
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public function testMultipleArgsAsArray() {
+ if ( wfIsWindows() ) {
+ $expected = '"foo" "bar" "baz"';
+ } else {
+ $expected = "'foo' 'bar' 'baz'";
+ }
+
+ $actual = wfEscapeShellArg( [ 'foo', 'bar', 'baz' ] );
+
+ $this->assertEquals( $expected, $actual );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfExpandUrlTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfExpandUrlTest.php
new file mode 100644
index 00000000..1cd320fa
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfExpandUrlTest.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * @group GlobalFunctions
+ * @covers ::wfExpandUrl
+ */
+class WfExpandUrlTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider provideExpandableUrls
+ */
+ public function testWfExpandUrl( $fullUrl, $shortUrl, $defaultProto,
+ $server, $canServer, $httpsMode, $message
+ ) {
+ // Fake $wgServer, $wgCanonicalServer and $wgRequest->getProtocol()
+ $this->setMwGlobals( [
+ 'wgServer' => $server,
+ 'wgCanonicalServer' => $canServer,
+ 'wgRequest' => new FauxRequest( [], false, null, $httpsMode ? 'https' : 'http' )
+ ] );
+
+ $this->assertEquals( $fullUrl, wfExpandUrl( $shortUrl, $defaultProto ), $message );
+ }
+
+ /**
+ * Provider of URL examples for testing wfExpandUrl()
+ *
+ * @return array
+ */
+ public static function provideExpandableUrls() {
+ $modes = [ 'http', 'https' ];
+ $servers = [
+ 'http' => 'http://example.com',
+ 'https' => 'https://example.com',
+ 'protocol-relative' => '//example.com'
+ ];
+ $defaultProtos = [
+ 'http' => PROTO_HTTP,
+ 'https' => PROTO_HTTPS,
+ 'protocol-relative' => PROTO_RELATIVE,
+ 'current' => PROTO_CURRENT,
+ 'canonical' => PROTO_CANONICAL
+ ];
+
+ $retval = [];
+ foreach ( $modes as $mode ) {
+ $httpsMode = $mode == 'https';
+ foreach ( $servers as $serverDesc => $server ) {
+ foreach ( $modes as $canServerMode ) {
+ $canServer = "$canServerMode://example2.com";
+ foreach ( $defaultProtos as $protoDesc => $defaultProto ) {
+ $retval[] = [
+ 'http://example.com', 'http://example.com',
+ $defaultProto, $server, $canServer, $httpsMode,
+ "Testing fully qualified http URLs (no need to expand) "
+ . "(defaultProto: $protoDesc , wgServer: $server, "
+ . "wgCanonicalServer: $canServer, current request protocol: $mode )"
+ ];
+ $retval[] = [
+ 'https://example.com', 'https://example.com',
+ $defaultProto, $server, $canServer, $httpsMode,
+ "Testing fully qualified https URLs (no need to expand) "
+ . "(defaultProto: $protoDesc , wgServer: $server, "
+ . "wgCanonicalServer: $canServer, current request protocol: $mode )"
+ ];
+ # Would be nice to support this, see fixme on wfExpandUrl()
+ $retval[] = [
+ "wiki/FooBar", 'wiki/FooBar',
+ $defaultProto, $server, $canServer, $httpsMode,
+ "Test non-expandable relative URLs (defaultProto: $protoDesc, "
+ . "wgServer: $server, wgCanonicalServer: $canServer, "
+ . "current request protocol: $mode )"
+ ];
+
+ // Determine expected protocol
+ if ( $protoDesc == 'protocol-relative' ) {
+ $p = '';
+ } elseif ( $protoDesc == 'current' ) {
+ $p = "$mode:";
+ } elseif ( $protoDesc == 'canonical' ) {
+ $p = "$canServerMode:";
+ } else {
+ $p = $protoDesc . ':';
+ }
+ // Determine expected server name
+ if ( $protoDesc == 'canonical' ) {
+ $srv = $canServer;
+ } elseif ( $serverDesc == 'protocol-relative' ) {
+ $srv = $p . $server;
+ } else {
+ $srv = $server;
+ }
+
+ $retval[] = [
+ "$p//wikipedia.org", '//wikipedia.org',
+ $defaultProto, $server, $canServer, $httpsMode,
+ "Test protocol-relative URL (defaultProto: $protoDesc, "
+ . "wgServer: $server, wgCanonicalServer: $canServer, "
+ . "current request protocol: $mode )"
+ ];
+ $retval[] = [
+ "$srv/wiki/FooBar",
+ '/wiki/FooBar',
+ $defaultProto,
+ $server,
+ $canServer,
+ $httpsMode,
+ "Testing expanding URL beginning with / (defaultProto: $protoDesc, "
+ . "wgServer: $server, wgCanonicalServer: $canServer, "
+ . "current request protocol: $mode )"
+ ];
+ }
+ }
+ }
+ }
+
+ return $retval;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php
new file mode 100644
index 00000000..8a7bfa5a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php
@@ -0,0 +1,46 @@
+<?php
+
+/**
+ * @group GlobalFunctions
+ * @covers ::wfGetCaller
+ */
+class WfGetCallerTest extends MediaWikiTestCase {
+ public function testZero() {
+ $this->assertEquals( 'WfGetCallerTest->testZero', wfGetCaller( 1 ) );
+ }
+
+ function callerOne() {
+ return wfGetCaller();
+ }
+
+ public function testOne() {
+ $this->assertEquals( 'WfGetCallerTest->testOne', self::callerOne() );
+ }
+
+ static function intermediateFunction( $level = 2, $n = 0 ) {
+ if ( $n > 0 ) {
+ return self::intermediateFunction( $level, $n - 1 );
+ }
+
+ return wfGetCaller( $level );
+ }
+
+ public function testTwo() {
+ $this->assertEquals( 'WfGetCallerTest->testTwo', self::intermediateFunction() );
+ }
+
+ public function testN() {
+ $this->assertEquals( 'WfGetCallerTest->testN', self::intermediateFunction( 2, 0 ) );
+ $this->assertEquals(
+ 'WfGetCallerTest::intermediateFunction',
+ self::intermediateFunction( 1, 0 )
+ );
+
+ for ( $i = 0; $i < 10; $i++ ) {
+ $this->assertEquals(
+ 'WfGetCallerTest::intermediateFunction',
+ self::intermediateFunction( $i + 1, $i )
+ );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfParseUrlTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfParseUrlTest.php
new file mode 100644
index 00000000..b20cfb5c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfParseUrlTest.php
@@ -0,0 +1,157 @@
+<?php
+/**
+ * Copyright © 2013 Alexandre Emsenhuber
+ *
+ * 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
+ */
+
+/**
+ * @group GlobalFunctions
+ * @covers ::wfParseUrl
+ */
+class WfParseUrlTest extends MediaWikiTestCase {
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( 'wgUrlProtocols', [
+ '//',
+ 'http://',
+ 'https://',
+ 'file://',
+ 'mailto:',
+ ] );
+ }
+
+ /**
+ * @dataProvider provideURLs
+ */
+ public function testWfParseUrl( $url, $parts ) {
+ $this->assertEquals(
+ $parts,
+ wfParseUrl( $url )
+ );
+ }
+
+ /**
+ * Provider of URLs for testing wfParseUrl()
+ *
+ * @return array
+ */
+ public static function provideURLs() {
+ return [
+ [
+ '//example.org',
+ [
+ 'scheme' => '',
+ 'delimiter' => '//',
+ 'host' => 'example.org',
+ ]
+ ],
+ [
+ 'http://example.org',
+ [
+ 'scheme' => 'http',
+ 'delimiter' => '://',
+ 'host' => 'example.org',
+ ]
+ ],
+ [
+ 'https://example.org',
+ [
+ 'scheme' => 'https',
+ 'delimiter' => '://',
+ 'host' => 'example.org',
+ ]
+ ],
+ [
+ 'http://id:key@example.org:123/path?foo=bar#baz',
+ [
+ 'scheme' => 'http',
+ 'delimiter' => '://',
+ 'user' => 'id',
+ 'pass' => 'key',
+ 'host' => 'example.org',
+ 'port' => 123,
+ 'path' => '/path',
+ 'query' => 'foo=bar',
+ 'fragment' => 'baz',
+ ]
+ ],
+ [
+ 'file://example.org/etc/php.ini',
+ [
+ 'scheme' => 'file',
+ 'delimiter' => '://',
+ 'host' => 'example.org',
+ 'path' => '/etc/php.ini',
+ ]
+ ],
+ [
+ 'file:///etc/php.ini',
+ [
+ 'scheme' => 'file',
+ 'delimiter' => '://',
+ 'host' => '',
+ 'path' => '/etc/php.ini',
+ ]
+ ],
+ [
+ 'file:///c:/',
+ [
+ 'scheme' => 'file',
+ 'delimiter' => '://',
+ 'host' => '',
+ 'path' => '/c:/',
+ ]
+ ],
+ [
+ 'mailto:id@example.org',
+ [
+ 'scheme' => 'mailto',
+ 'delimiter' => ':',
+ 'host' => 'id@example.org',
+ 'path' => '',
+ ]
+ ],
+ [
+ 'mailto:id@example.org?subject=Foo',
+ [
+ 'scheme' => 'mailto',
+ 'delimiter' => ':',
+ 'host' => 'id@example.org',
+ 'path' => '',
+ 'query' => 'subject=Foo',
+ ]
+ ],
+ [
+ 'mailto:?subject=Foo',
+ [
+ 'scheme' => 'mailto',
+ 'delimiter' => ':',
+ 'host' => '',
+ 'path' => '',
+ 'query' => 'subject=Foo',
+ ]
+ ],
+ [
+ 'invalid://test/',
+ false
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php
new file mode 100644
index 00000000..eae5588b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php
@@ -0,0 +1,93 @@
+<?php
+
+/**
+ * @group GlobalFunctions
+ * @covers ::wfRemoveDotSegments
+ */
+class WfRemoveDotSegmentsTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider providePaths
+ */
+ public function testWfRemoveDotSegments( $inputPath, $outputPath ) {
+ $this->assertEquals(
+ $outputPath,
+ wfRemoveDotSegments( $inputPath ),
+ "Testing $inputPath expands to $outputPath"
+ );
+ }
+
+ /**
+ * Provider of URL paths for testing wfRemoveDotSegments()
+ *
+ * @return array
+ */
+ public static function providePaths() {
+ return [
+ [ '/a/b/c/./../../g', '/a/g' ],
+ [ 'mid/content=5/../6', 'mid/6' ],
+ [ '/a//../b', '/a/b' ],
+ [ '/.../a', '/.../a' ],
+ [ '.../a', '.../a' ],
+ [ '', '' ],
+ [ '/', '/' ],
+ [ '//', '//' ],
+ [ '.', '' ],
+ [ '..', '' ],
+ [ '...', '...' ],
+ [ '/.', '/' ],
+ [ '/..', '/' ],
+ [ './', '' ],
+ [ '../', '' ],
+ [ './a', 'a' ],
+ [ '../a', 'a' ],
+ [ '../../a', 'a' ],
+ [ '.././a', 'a' ],
+ [ './../a', 'a' ],
+ [ '././a', 'a' ],
+ [ '../../', '' ],
+ [ '.././', '' ],
+ [ './../', '' ],
+ [ '././', '' ],
+ [ '../..', '' ],
+ [ '../.', '' ],
+ [ './..', '' ],
+ [ './.', '' ],
+ [ '/../../a', '/a' ],
+ [ '/.././a', '/a' ],
+ [ '/./../a', '/a' ],
+ [ '/././a', '/a' ],
+ [ '/../../', '/' ],
+ [ '/.././', '/' ],
+ [ '/./../', '/' ],
+ [ '/././', '/' ],
+ [ '/../..', '/' ],
+ [ '/../.', '/' ],
+ [ '/./..', '/' ],
+ [ '/./.', '/' ],
+ [ 'b/../../a', '/a' ],
+ [ 'b/.././a', '/a' ],
+ [ 'b/./../a', '/a' ],
+ [ 'b/././a', 'b/a' ],
+ [ 'b/../../', '/' ],
+ [ 'b/.././', '/' ],
+ [ 'b/./../', '/' ],
+ [ 'b/././', 'b/' ],
+ [ 'b/../..', '/' ],
+ [ 'b/../.', '/' ],
+ [ 'b/./..', '/' ],
+ [ 'b/./.', 'b/' ],
+ [ '/b/../../a', '/a' ],
+ [ '/b/.././a', '/a' ],
+ [ '/b/./../a', '/a' ],
+ [ '/b/././a', '/b/a' ],
+ [ '/b/../../', '/' ],
+ [ '/b/.././', '/' ],
+ [ '/b/./../', '/' ],
+ [ '/b/././', '/b/' ],
+ [ '/b/../..', '/' ],
+ [ '/b/../.', '/' ],
+ [ '/b/./..', '/' ],
+ [ '/b/./.', '/b/' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php
new file mode 100644
index 00000000..fcd26f54
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php
@@ -0,0 +1,20 @@
+<?php
+
+/**
+ * @group GlobalFunctions
+ * @covers ::wfShellExec
+ */
+class WfShellExecTest extends MediaWikiTestCase {
+ public function testBug67870() {
+ $command = wfIsWindows()
+ // 333 = 331 + CRLF
+ ? ( 'for /l %i in (1, 1, 1001) do @echo ' . str_repeat( '*', 331 ) )
+ : 'printf "%-333333s" "*"';
+
+ // Test several times because it involves a race condition that may randomly succeed or fail
+ for ( $i = 0; $i < 10; $i++ ) {
+ $output = wfShellExec( $command );
+ $this->assertEquals( 333333, strlen( $output ) );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php
new file mode 100644
index 00000000..40b2e636
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php
@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * @group GlobalFunctions
+ * @covers ::wfShorthandToInteger
+ */
+class WfShorthandToIntegerTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider provideABunchOfShorthands
+ */
+ public function testWfShorthandToInteger( $input, $output, $description ) {
+ $this->assertEquals(
+ wfShorthandToInteger( $input ),
+ $output,
+ $description
+ );
+ }
+
+ public static function provideABunchOfShorthands() {
+ return [
+ [ '', -1, 'Empty string' ],
+ [ ' ', -1, 'String of spaces' ],
+ [ '1G', 1024 * 1024 * 1024, 'One gig uppercased' ],
+ [ '1g', 1024 * 1024 * 1024, 'One gig lowercased' ],
+ [ '1M', 1024 * 1024, 'One meg uppercased' ],
+ [ '1m', 1024 * 1024, 'One meg lowercased' ],
+ [ '1K', 1024, 'One kb uppercased' ],
+ [ '1k', 1024, 'One kb lowercased' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfStringToBoolTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfStringToBoolTest.php
new file mode 100644
index 00000000..7f56b605
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfStringToBoolTest.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * @group GlobalFunctions
+ * @covers ::wfStringToBool
+ */
+class WfStringToBoolTest extends MediaWikiTestCase {
+
+ public function getTestCases() {
+ return [
+ [ 'true', true ],
+ [ 'on', true ],
+ [ 'yes', true ],
+ [ 'TRUE', true ],
+ [ 'YeS', true ],
+ [ 'On', true ],
+ [ '1', true ],
+ [ '+1', true ],
+ [ '01', true ],
+ [ '-001', true ],
+ [ ' 1', true ],
+ [ '-1 ', true ],
+ [ '', false ],
+ [ '0', false ],
+ [ 'false', false ],
+ [ 'NO', false ],
+ [ 'NOT', false ],
+ [ 'never', false ],
+ [ '!&', false ],
+ [ '-0', false ],
+ [ '+0', false ],
+ [ 'forget about it', false ],
+ [ ' on', false ],
+ [ 'true ', false ],
+ ];
+ }
+
+ /**
+ * @dataProvider getTestCases
+ * @param string $str
+ * @param bool $bool
+ */
+ public function testStr2Bool( $str, $bool ) {
+ if ( $bool ) {
+ $this->assertTrue( wfStringToBool( $str ) );
+ } else {
+ $this->assertFalse( wfStringToBool( $str ) );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfThumbIsStandardTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfThumbIsStandardTest.php
new file mode 100644
index 00000000..bdba6a35
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfThumbIsStandardTest.php
@@ -0,0 +1,105 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group GlobalFunctions
+ * @covers ::wfThumbIsStandard
+ */
+class WfThumbIsStandardTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgThumbLimits' => [
+ 100,
+ 401
+ ],
+ 'wgImageLimits' => [
+ [ 300, 225 ],
+ [ 800, 600 ],
+ ],
+ ] );
+ }
+
+ public static function provideThumbParams() {
+ return [
+ // Thumb limits
+ [
+ 'Standard thumb width',
+ true,
+ [ 'width' => 100 ],
+ ],
+ [
+ 'Standard thumb width',
+ true,
+ [ 'width' => 401 ],
+ ],
+ // wfThumbIsStandard should match Linker::processResponsiveImages
+ // in its rounding behaviour.
+ [
+ 'Standard thumb width (HiDPI 1.5x) - incorrect rounding',
+ false,
+ [ 'width' => 601 ],
+ ],
+ [
+ 'Standard thumb width (HiDPI 1.5x)',
+ true,
+ [ 'width' => 602 ],
+ ],
+ [
+ 'Standard thumb width (HiDPI 2x)',
+ true,
+ [ 'width' => 802 ],
+ ],
+ [
+ 'Non-standard thumb width',
+ false,
+ [ 'width' => 300 ],
+ ],
+ // Image limits
+ // Note: Image limits are measured as pairs. Individual values
+ // may be non-standard based on the aspect ratio.
+ [
+ 'Standard image width/height pair',
+ true,
+ [ 'width' => 250, 'height' => 225 ],
+ ],
+ [
+ 'Standard image width/height pair',
+ true,
+ [ 'width' => 667, 'height' => 600 ],
+ ],
+ [
+ 'Standard image width where image does not fit aspect ratio',
+ false,
+ [ 'width' => 300 ],
+ ],
+ [
+ 'Implicit width from image width/height pair aspect ratio fit',
+ true,
+ // 2000x1800 fit inside 300x225 makes w=250
+ [ 'width' => 250 ],
+ ],
+ [
+ 'Height-only is always non-standard',
+ false,
+ [ 'height' => 225 ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideThumbParams
+ */
+ public function testIsStandard( $message, $expected, $params ) {
+ $handlers = MediaWikiServices::getInstance()->getMainConfig()->get( 'ParserTestMediaHandlers' );
+ $this->setService( 'MediaHandlerFactory', new MediaHandlerFactory( $handlers ) );
+ $this->assertSame(
+ $expected,
+ wfThumbIsStandard( new FakeDimensionFile( [ 2000, 1800 ], 'image/jpeg' ), $params ),
+ $message
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php
new file mode 100644
index 00000000..a70f136a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php
@@ -0,0 +1,194 @@
+<?php
+
+/**
+ * @group GlobalFunctions
+ * @covers ::wfTimestamp
+ */
+class WfTimestampTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider provideNormalTimestamps
+ */
+ public function testNormalTimestamps( $input, $format, $output, $desc ) {
+ $this->assertEquals( $output, wfTimestamp( $format, $input ), $desc );
+ }
+
+ public static function provideNormalTimestamps() {
+ $t = gmmktime( 12, 34, 56, 1, 15, 2001 );
+
+ return [
+ // TS_UNIX
+ [ $t, TS_MW, '20010115123456', 'TS_UNIX to TS_MW' ],
+ [ -30281104, TS_MW, '19690115123456', 'Negative TS_UNIX to TS_MW' ],
+ [ $t, TS_UNIX, 979562096, 'TS_UNIX to TS_UNIX' ],
+ [ $t, TS_DB, '2001-01-15 12:34:56', 'TS_UNIX to TS_DB' ],
+ [ $t + 0.01, TS_MW, '20010115123456', 'TS_UNIX float to TS_MW' ],
+
+ [ $t, TS_ISO_8601_BASIC, '20010115T123456Z', 'TS_ISO_8601_BASIC to TS_DB' ],
+
+ // TS_MW
+ [ '20010115123456', TS_MW, '20010115123456', 'TS_MW to TS_MW' ],
+ [ '20010115123456', TS_UNIX, 979562096, 'TS_MW to TS_UNIX' ],
+ [ '20010115123456', TS_DB, '2001-01-15 12:34:56', 'TS_MW to TS_DB' ],
+ [ '20010115123456', TS_ISO_8601_BASIC, '20010115T123456Z', 'TS_MW to TS_ISO_8601_BASIC' ],
+
+ // TS_DB
+ [ '2001-01-15 12:34:56', TS_MW, '20010115123456', 'TS_DB to TS_MW' ],
+ [ '2001-01-15 12:34:56', TS_UNIX, 979562096, 'TS_DB to TS_UNIX' ],
+ [ '2001-01-15 12:34:56', TS_DB, '2001-01-15 12:34:56', 'TS_DB to TS_DB' ],
+ [
+ '2001-01-15 12:34:56',
+ TS_ISO_8601_BASIC,
+ '20010115T123456Z',
+ 'TS_DB to TS_ISO_8601_BASIC'
+ ],
+
+ # rfc2822 section 3.3
+ [ '20010115123456', TS_RFC2822, 'Mon, 15 Jan 2001 12:34:56 GMT', 'TS_MW to TS_RFC2822' ],
+ [ 'Mon, 15 Jan 2001 12:34:56 GMT', TS_MW, '20010115123456', 'TS_RFC2822 to TS_MW' ],
+ [
+ ' Mon, 15 Jan 2001 12:34:56 GMT',
+ TS_MW,
+ '20010115123456',
+ 'TS_RFC2822 with leading space to TS_MW'
+ ],
+ [
+ '15 Jan 2001 12:34:56 GMT',
+ TS_MW,
+ '20010115123456',
+ 'TS_RFC2822 without optional day-of-week to TS_MW'
+ ],
+
+ # FWS = ([*WSP CRLF] 1*WSP) / obs-FWS ; Folding white space
+ # obs-FWS = 1*WSP *(CRLF 1*WSP) ; Section 4.2
+ [ 'Mon, 15 Jan 2001 12:34:56 GMT', TS_MW, '20010115123456', 'TS_RFC2822 to TS_MW' ],
+
+ # WSP = SP / HTAB ; rfc2234
+ [
+ "Mon, 15 Jan\x092001 12:34:56 GMT",
+ TS_MW,
+ '20010115123456',
+ 'TS_RFC2822 with HTAB to TS_MW'
+ ],
+ [
+ "Mon, 15 Jan\x09 \x09 2001 12:34:56 GMT",
+ TS_MW,
+ '20010115123456',
+ 'TS_RFC2822 with HTAB and SP to TS_MW'
+ ],
+ [
+ 'Sun, 6 Nov 94 08:49:37 GMT',
+ TS_MW,
+ '19941106084937',
+ 'TS_RFC2822 with obsolete year to TS_MW'
+ ],
+ ];
+ }
+
+ /**
+ * This test checks wfTimestamp() with values outside.
+ * It needs PHP 64 bits or PHP > 5.1.
+ * See r74778 and T27451
+ * @dataProvider provideOldTimestamps
+ */
+ public function testOldTimestamps( $input, $outputType, $output, $message ) {
+ $timestamp = wfTimestamp( $outputType, $input );
+ if ( substr( $output, 0, 1 ) === '/' ) {
+ // T66946: Day of the week calculations for very old
+ // timestamps varies from system to system.
+ $this->assertRegExp( $output, $timestamp, $message );
+ } else {
+ $this->assertEquals( $output, $timestamp, $message );
+ }
+ }
+
+ public static function provideOldTimestamps() {
+ return [
+ [
+ '19011213204554',
+ TS_RFC2822,
+ 'Fri, 13 Dec 1901 20:45:54 GMT',
+ 'Earliest time according to PHP documentation'
+ ],
+ [ '20380119031407', TS_RFC2822, 'Tue, 19 Jan 2038 03:14:07 GMT', 'Latest 32 bit time' ],
+ [ '19011213204552', TS_UNIX, '-2147483648', 'Earliest 32 bit unix time' ],
+ [ '20380119031407', TS_UNIX, '2147483647', 'Latest 32 bit unix time' ],
+ [ '19011213204552', TS_RFC2822, 'Fri, 13 Dec 1901 20:45:52 GMT', 'Earliest 32 bit time' ],
+ [
+ '19011213204551',
+ TS_RFC2822,
+ 'Fri, 13 Dec 1901 20:45:51 GMT', 'Earliest 32 bit time - 1'
+ ],
+ [ '20380119031408', TS_RFC2822, 'Tue, 19 Jan 2038 03:14:08 GMT', 'Latest 32 bit time + 1' ],
+ [ '19011212000000', TS_MW, '19011212000000', 'Convert to itself r74778#c10645' ],
+ [ '19011213204551', TS_UNIX, '-2147483649', 'Earliest 32 bit unix time - 1' ],
+ [ '20380119031408', TS_UNIX, '2147483648', 'Latest 32 bit unix time + 1' ],
+ [ '-2147483649', TS_MW, '19011213204551', '1901 negative unix time to MediaWiki' ],
+ [ '-5331871504', TS_MW, '18010115123456', '1801 negative unix time to MediaWiki' ],
+ [
+ '0117-08-09 12:34:56',
+ TS_RFC2822,
+ '/, 09 Aug 0117 12:34:56 GMT$/',
+ 'Death of Roman Emperor [[Trajan]]'
+ ],
+
+ /* @todo FIXME: 00 to 101 years are taken as being in [1970-2069] */
+ [ '-58979923200', TS_RFC2822, '/, 01 Jan 0101 00:00:00 GMT$/', '1/1/101' ],
+ [ '-62135596800', TS_RFC2822, 'Mon, 01 Jan 0001 00:00:00 GMT', 'Year 1' ],
+
+ /* It is not clear if we should generate a year 0 or not
+ * We are completely off RFC2822 requirement of year being
+ * 1900 or later.
+ */
+ [
+ '-62142076800',
+ TS_RFC2822,
+ 'Wed, 18 Oct 0000 00:00:00 GMT',
+ 'ISO 8601:2004 [[year 0]], also called [[1 BC]]'
+ ],
+ ];
+ }
+
+ /**
+ * @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3.1
+ * @dataProvider provideHttpDates
+ */
+ public function testHttpDate( $input, $output, $desc ) {
+ $this->assertEquals( $output, wfTimestamp( TS_MW, $input ), $desc );
+ }
+
+ public static function provideHttpDates() {
+ return [
+ [ 'Sun, 06 Nov 1994 08:49:37 GMT', '19941106084937', 'RFC 822 date' ],
+ [ 'Sunday, 06-Nov-94 08:49:37 GMT', '19941106084937', 'RFC 850 date' ],
+ [ 'Sun Nov 6 08:49:37 1994', '19941106084937', "ANSI C's asctime() format" ],
+ // See http://www.squid-cache.org/mail-archive/squid-users/200307/0122.html and r77171
+ [
+ 'Mon, 22 Nov 2010 14:12:42 GMT; length=52626',
+ '20101122141242',
+ 'Netscape extension to HTTP/1.0'
+ ],
+ ];
+ }
+
+ /**
+ * There are a number of assumptions in our codebase where wfTimestamp()
+ * should give the current date but it is not given a 0 there. See r71751 CR
+ */
+ public function testTimestampParameter() {
+ $now = wfTimestamp( TS_UNIX );
+ // We check that wfTimestamp doesn't return false (error) and use a LessThan assert
+ // for the cases where the test is run in a second boundary.
+
+ $zero = wfTimestamp( TS_UNIX, 0 );
+ $this->assertNotEquals( false, $zero );
+ $this->assertLessThan( 5, $zero - $now );
+
+ $empty = wfTimestamp( TS_UNIX, '' );
+ $this->assertNotEquals( false, $empty );
+ $this->assertLessThan( 5, $empty - $now );
+
+ $null = wfTimestamp( TS_UNIX, null );
+ $this->assertNotEquals( false, $null );
+ $this->assertLessThan( 5, $null - $now );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php
new file mode 100644
index 00000000..09c1040b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php
@@ -0,0 +1,125 @@
+<?php
+
+/**
+ * The function only need a string parameter and might react to IIS7.0
+ *
+ * @group GlobalFunctions
+ * @covers ::wfUrlencode
+ */
+class WfUrlencodeTest extends MediaWikiTestCase {
+ # ### TESTS ##############################################################
+
+ /**
+ * @dataProvider provideURLS
+ */
+ public function testEncodingUrlWith( $input, $expected ) {
+ $this->verifyEncodingFor( 'Apache', $input, $expected );
+ }
+
+ /**
+ * @dataProvider provideURLS
+ */
+ public function testEncodingUrlWithMicrosoftIis7( $input, $expected ) {
+ $this->verifyEncodingFor( 'Microsoft-IIS/7', $input, $expected );
+ }
+
+ # ### HELPERS #############################################################
+
+ /**
+ * Internal helper that actually run the test.
+ * Called by the public methods testEncodingUrlWith...()
+ */
+ private function verifyEncodingFor( $server, $input, $expectations ) {
+ $expected = $this->extractExpect( $server, $expectations );
+
+ // save up global
+ $old = isset( $_SERVER['SERVER_SOFTWARE'] )
+ ? $_SERVER['SERVER_SOFTWARE']
+ : null;
+ $_SERVER['SERVER_SOFTWARE'] = $server;
+ wfUrlencode( null );
+
+ // do the requested test
+ $this->assertEquals(
+ $expected,
+ wfUrlencode( $input ),
+ "Encoding '$input' for server '$server' should be '$expected'"
+ );
+
+ // restore global
+ if ( $old === null ) {
+ unset( $_SERVER['SERVER_SOFTWARE'] );
+ } else {
+ $_SERVER['SERVER_SOFTWARE'] = $old;
+ }
+ wfUrlencode( null );
+ }
+
+ /**
+ * Interprets the provider array. Return expected value depending
+ * the HTTP server name.
+ */
+ private function extractExpect( $server, $expectations ) {
+ if ( is_string( $expectations ) ) {
+ return $expectations;
+ } elseif ( is_array( $expectations ) ) {
+ if ( !array_key_exists( $server, $expectations ) ) {
+ throw new MWException( __METHOD__ . " expectation does not have any "
+ . "value for server name $server. Check the provider array.\n" );
+ } else {
+ return $expectations[$server];
+ }
+ } else {
+ throw new MWException( __METHOD__ . " given invalid expectation for "
+ . "'$server'. Should be a string or an array( <http server name> => <string> ).\n" );
+ }
+ }
+
+ # ### PROVIDERS ###########################################################
+
+ /**
+ * Format is either:
+ * [ 'input', 'expected' ];
+ * Or:
+ * [ 'input',
+ * [ 'Apache', 'expected' ],
+ * [ 'Microsoft-IIS/7', 'expected' ],
+ * ],
+ * If you want to add other HTTP server name, you will have to add a new
+ * testing method much like the testEncodingUrlWith() method above.
+ */
+ public static function provideURLS() {
+ return [
+ # ## RFC 1738 chars
+ // + is not safe
+ [ '+', '%2B' ],
+ // & and = not safe in queries
+ [ '&', '%26' ],
+ [ '=', '%3D' ],
+
+ [ ':', [
+ 'Apache' => ':',
+ 'Microsoft-IIS/7' => '%3A',
+ ] ],
+
+ // remaining chars do not need encoding
+ [
+ ';@$-_.!*',
+ ';@$-_.!*',
+ ],
+
+ # ## Other tests
+ // slash remain unchanged. %2F seems to break things
+ [ '/', '/' ],
+ // T105265
+ [ '~', '~' ],
+
+ // Other 'funnies' chars
+ [ '[]', '%5B%5D' ],
+ [ '<>', '%3C%3E' ],
+
+ // Apostrophe is encoded
+ [ '\'', '%27' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/HooksTest.php b/www/wiki/tests/phpunit/includes/HooksTest.php
new file mode 100644
index 00000000..efe92ec4
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/HooksTest.php
@@ -0,0 +1,253 @@
+<?php
+
+class HooksTest extends MediaWikiTestCase {
+
+ function setUp() {
+ global $wgHooks;
+ parent::setUp();
+ Hooks::clear( 'MediaWikiHooksTest001' );
+ unset( $wgHooks['MediaWikiHooksTest001'] );
+ }
+
+ public static function provideHooks() {
+ $i = new NothingClass();
+
+ return [
+ [
+ 'Object and method',
+ [ $i, 'someNonStatic' ],
+ 'changed-nonstatic',
+ 'changed-nonstatic'
+ ],
+ [ 'Object and no method', [ $i ], 'changed-onevent', 'original' ],
+ [
+ 'Object and method with data',
+ [ $i, 'someNonStaticWithData', 'data' ],
+ 'data',
+ 'original'
+ ],
+ [ 'Object and static method', [ $i, 'someStatic' ], 'changed-static', 'original' ],
+ [
+ 'Class::method static call',
+ [ 'NothingClass::someStatic' ],
+ 'changed-static',
+ 'original'
+ ],
+ [ 'Global function', [ 'NothingFunction' ], 'changed-func', 'original' ],
+ [ 'Global function with data', [ 'NothingFunctionData', 'data' ], 'data', 'original' ],
+ [ 'Closure', [ function ( &$foo, $bar ) {
+ $foo = 'changed-closure';
+
+ return true;
+ } ], 'changed-closure', 'original' ],
+ [ 'Closure with data', [ function ( $data, &$foo, $bar ) {
+ $foo = $data;
+
+ return true;
+ }, 'data' ], 'data', 'original' ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideHooks
+ * @covers ::wfRunHooks
+ */
+ public function testOldStyleHooks( $msg, array $hook, $expectedFoo, $expectedBar ) {
+ global $wgHooks;
+
+ $this->hideDeprecated( 'wfRunHooks' );
+ $foo = $bar = 'original';
+
+ $wgHooks['MediaWikiHooksTest001'][] = $hook;
+ wfRunHooks( 'MediaWikiHooksTest001', [ &$foo, &$bar ] );
+
+ $this->assertSame( $expectedFoo, $foo, $msg );
+ $this->assertSame( $expectedBar, $bar, $msg );
+ }
+
+ /**
+ * @dataProvider provideHooks
+ * @covers Hooks::register
+ * @covers Hooks::run
+ * @covers Hooks::callHook
+ */
+ public function testNewStyleHooks( $msg, $hook, $expectedFoo, $expectedBar ) {
+ $foo = $bar = 'original';
+
+ Hooks::register( 'MediaWikiHooksTest001', $hook );
+ Hooks::run( 'MediaWikiHooksTest001', [ &$foo, &$bar ] );
+
+ $this->assertSame( $expectedFoo, $foo, $msg );
+ $this->assertSame( $expectedBar, $bar, $msg );
+ }
+
+ /**
+ * @covers Hooks::isRegistered
+ * @covers Hooks::register
+ * @covers Hooks::getHandlers
+ * @covers Hooks::run
+ * @covers Hooks::callHook
+ */
+ public function testNewStyleHookInteraction() {
+ global $wgHooks;
+
+ $a = new NothingClass();
+ $b = new NothingClass();
+
+ $wgHooks['MediaWikiHooksTest001'][] = $a;
+ $this->assertTrue(
+ Hooks::isRegistered( 'MediaWikiHooksTest001' ),
+ 'Hook registered via $wgHooks should be noticed by Hooks::isRegistered'
+ );
+
+ Hooks::register( 'MediaWikiHooksTest001', $b );
+ $this->assertEquals(
+ 2,
+ count( Hooks::getHandlers( 'MediaWikiHooksTest001' ) ),
+ 'Hooks::getHandlers() should return hooks registered via wgHooks as well as Hooks::register'
+ );
+
+ $foo = 'quux';
+ $bar = 'qaax';
+
+ Hooks::run( 'MediaWikiHooksTest001', [ &$foo, &$bar ] );
+ $this->assertEquals(
+ 1,
+ $a->calls,
+ 'Hooks::run() should run hooks registered via wgHooks as well as Hooks::register'
+ );
+ $this->assertEquals(
+ 1,
+ $b->calls,
+ 'Hooks::run() should run hooks registered via wgHooks as well as Hooks::register'
+ );
+ }
+
+ /**
+ * @expectedException MWException
+ * @covers Hooks::run
+ * @covers Hooks::callHook
+ */
+ public function testUncallableFunction() {
+ Hooks::register( 'MediaWikiHooksTest001', 'ThisFunctionDoesntExist' );
+ Hooks::run( 'MediaWikiHooksTest001', [] );
+ }
+
+ /**
+ * @covers Hooks::run
+ * @covers Hooks::callHook
+ */
+ public function testFalseReturn() {
+ Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) {
+ return false;
+ } );
+ Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) {
+ $foo = 'test';
+
+ return true;
+ } );
+ $foo = 'original';
+ Hooks::run( 'MediaWikiHooksTest001', [ &$foo ] );
+ $this->assertSame( 'original', $foo, 'Hooks abort after a false return.' );
+ }
+
+ /**
+ * @covers Hooks::runWithoutAbort
+ * @covers Hooks::callHook
+ */
+ public function testRunWithoutAbort() {
+ $list = [];
+ Hooks::register( 'MediaWikiHooksTest001', function ( &$list ) {
+ $list[] = 1;
+ return true; // Explicit true
+ } );
+ Hooks::register( 'MediaWikiHooksTest001', function ( &$list ) {
+ $list[] = 2;
+ return; // Implicit null
+ } );
+ Hooks::register( 'MediaWikiHooksTest001', function ( &$list ) {
+ $list[] = 3;
+ // No return
+ } );
+
+ Hooks::runWithoutAbort( 'MediaWikiHooksTest001', [ &$list ] );
+ $this->assertSame( [ 1, 2, 3 ], $list, 'All hooks ran.' );
+ }
+
+ /**
+ * @covers Hooks::runWithoutAbort
+ * @covers Hooks::callHook
+ */
+ public function testRunWithoutAbortWarning() {
+ Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) {
+ return false;
+ } );
+ Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) {
+ $foo = 'test';
+ return true;
+ } );
+ $foo = 'original';
+
+ $this->setExpectedException(
+ UnexpectedValueException::class,
+ 'Invalid return from hook-MediaWikiHooksTest001-closure for ' .
+ 'unabortable MediaWikiHooksTest001'
+ );
+ Hooks::runWithoutAbort( 'MediaWikiHooksTest001', [ &$foo ] );
+ }
+
+ /**
+ * @expectedException FatalError
+ * @covers Hooks::run
+ */
+ public function testFatalError() {
+ Hooks::register( 'MediaWikiHooksTest001', function () {
+ return 'test';
+ } );
+ Hooks::run( 'MediaWikiHooksTest001', [] );
+ }
+}
+
+function NothingFunction( &$foo, &$bar ) {
+ $foo = 'changed-func';
+
+ return true;
+}
+
+function NothingFunctionData( $data, &$foo, &$bar ) {
+ $foo = $data;
+
+ return true;
+}
+
+class NothingClass {
+ public $calls = 0;
+
+ public static function someStatic( &$foo, &$bar ) {
+ $foo = 'changed-static';
+
+ return true;
+ }
+
+ public function someNonStatic( &$foo, &$bar ) {
+ $this->calls++;
+ $foo = 'changed-nonstatic';
+ $bar = 'changed-nonstatic';
+
+ return true;
+ }
+
+ public function onMediaWikiHooksTest001( &$foo, &$bar ) {
+ $this->calls++;
+ $foo = 'changed-onevent';
+
+ return true;
+ }
+
+ public function someNonStaticWithData( $data, &$foo, &$bar ) {
+ $this->calls++;
+ $foo = $data;
+
+ return true;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/HtmlTest.php b/www/wiki/tests/phpunit/includes/HtmlTest.php
new file mode 100644
index 00000000..6695fce3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/HtmlTest.php
@@ -0,0 +1,794 @@
+<?php
+
+class HtmlTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgUseMediaWikiUIEverywhere' => false,
+ ] );
+
+ $langObj = Language::factory( 'en' );
+
+ // Hardcode namespaces during test runs,
+ // so that html output based on existing namespaces
+ // can be properly evaluated.
+ $langObj->setNamespaces( [
+ -2 => 'Media',
+ -1 => 'Special',
+ 0 => '',
+ 1 => 'Talk',
+ 2 => 'User',
+ 3 => 'User_talk',
+ 4 => 'MyWiki',
+ 5 => 'MyWiki_Talk',
+ 6 => 'File',
+ 7 => 'File_talk',
+ 8 => 'MediaWiki',
+ 9 => 'MediaWiki_talk',
+ 10 => 'Template',
+ 11 => 'Template_talk',
+ 14 => 'Category',
+ 15 => 'Category_talk',
+ 100 => 'Custom',
+ 101 => 'Custom_talk',
+ ] );
+ $this->setUserLang( $langObj );
+ $this->setContentLang( $langObj );
+ }
+
+ /**
+ * @covers Html::element
+ * @covers Html::rawElement
+ * @covers Html::openElement
+ * @covers Html::closeElement
+ */
+ public function testElementBasics() {
+ $this->assertEquals(
+ '<img/>',
+ Html::element( 'img', null, '' ),
+ 'Self-closing tag for short-tag elements'
+ );
+
+ $this->assertEquals(
+ '<element></element>',
+ Html::element( 'element', null, null ),
+ 'Close tag for empty element (null, null)'
+ );
+
+ $this->assertEquals(
+ '<element></element>',
+ Html::element( 'element', [], '' ),
+ 'Close tag for empty element (array, string)'
+ );
+ }
+
+ public function dataXmlMimeType() {
+ return [
+ // ( $mimetype, $isXmlMimeType )
+ # HTML is not an XML MimeType
+ [ 'text/html', false ],
+ # XML is an XML MimeType
+ [ 'text/xml', true ],
+ [ 'application/xml', true ],
+ # XHTML is an XML MimeType
+ [ 'application/xhtml+xml', true ],
+ # Make sure other +xml MimeTypes are supported
+ # SVG is another random MimeType even though we don't use it
+ [ 'image/svg+xml', true ],
+ # Complete random other MimeTypes are not XML
+ [ 'text/plain', false ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataXmlMimeType
+ * @covers Html::isXmlMimeType
+ */
+ public function testXmlMimeType( $mimetype, $isXmlMimeType ) {
+ $this->assertEquals( $isXmlMimeType, Html::isXmlMimeType( $mimetype ) );
+ }
+
+ /**
+ * @covers Html::expandAttributes
+ */
+ public function testExpandAttributesSkipsNullAndFalse() {
+ # ## EMPTY ########
+ $this->assertEmpty(
+ Html::expandAttributes( [ 'foo' => null ] ),
+ 'skip keys with null value'
+ );
+ $this->assertEmpty(
+ Html::expandAttributes( [ 'foo' => false ] ),
+ 'skip keys with false value'
+ );
+ $this->assertEquals(
+ ' foo=""',
+ Html::expandAttributes( [ 'foo' => '' ] ),
+ 'keep keys with an empty string'
+ );
+ }
+
+ /**
+ * @covers Html::expandAttributes
+ */
+ public function testExpandAttributesForBooleans() {
+ $this->assertEquals(
+ '',
+ Html::expandAttributes( [ 'selected' => false ] ),
+ 'Boolean attributes do not generates output when value is false'
+ );
+ $this->assertEquals(
+ '',
+ Html::expandAttributes( [ 'selected' => null ] ),
+ 'Boolean attributes do not generates output when value is null'
+ );
+
+ $this->assertEquals(
+ ' selected=""',
+ Html::expandAttributes( [ 'selected' => true ] ),
+ 'Boolean attributes have no value when value is true'
+ );
+ $this->assertEquals(
+ ' selected=""',
+ Html::expandAttributes( [ 'selected' ] ),
+ 'Boolean attributes have no value when value is true (passed as numerical array)'
+ );
+ }
+
+ /**
+ * @covers Html::expandAttributes
+ */
+ public function testExpandAttributesForNumbers() {
+ $this->assertEquals(
+ ' value="1"',
+ Html::expandAttributes( [ 'value' => 1 ] ),
+ 'Integer value is cast to a string'
+ );
+ $this->assertEquals(
+ ' value="1.1"',
+ Html::expandAttributes( [ 'value' => 1.1 ] ),
+ 'Float value is cast to a string'
+ );
+ }
+
+ /**
+ * @covers Html::expandAttributes
+ */
+ public function testExpandAttributesForObjects() {
+ $this->assertEquals(
+ ' value="stringValue"',
+ Html::expandAttributes( [ 'value' => new HtmlTestValue() ] ),
+ 'Object value is converted to a string'
+ );
+ }
+
+ /**
+ * Test for Html::expandAttributes()
+ * Please note it output a string prefixed with a space!
+ * @covers Html::expandAttributes
+ */
+ public function testExpandAttributesVariousExpansions() {
+ # ## NOT EMPTY ####
+ $this->assertEquals(
+ ' empty_string=""',
+ Html::expandAttributes( [ 'empty_string' => '' ] ),
+ 'Empty string is always quoted'
+ );
+ $this->assertEquals(
+ ' key="value"',
+ Html::expandAttributes( [ 'key' => 'value' ] ),
+ 'Simple string value needs no quotes'
+ );
+ $this->assertEquals(
+ ' one="1"',
+ Html::expandAttributes( [ 'one' => 1 ] ),
+ 'Number 1 value needs no quotes'
+ );
+ $this->assertEquals(
+ ' zero="0"',
+ Html::expandAttributes( [ 'zero' => 0 ] ),
+ 'Number 0 value needs no quotes'
+ );
+ }
+
+ /**
+ * Html::expandAttributes has special features for HTML
+ * attributes that use space separated lists and also
+ * allows arrays to be used as values.
+ * @covers Html::expandAttributes
+ */
+ public function testExpandAttributesListValueAttributes() {
+ # ## STRING VALUES
+ $this->assertEquals(
+ ' class="redundant spaces here"',
+ Html::expandAttributes( [ 'class' => ' redundant spaces here ' ] ),
+ 'Normalization should strip redundant spaces'
+ );
+ $this->assertEquals(
+ ' class="foo bar"',
+ Html::expandAttributes( [ 'class' => 'foo bar foo bar bar' ] ),
+ 'Normalization should remove duplicates in string-lists'
+ );
+ # ## "EMPTY" ARRAY VALUES
+ $this->assertEquals(
+ ' class=""',
+ Html::expandAttributes( [ 'class' => [] ] ),
+ 'Value with an empty array'
+ );
+ $this->assertEquals(
+ ' class=""',
+ Html::expandAttributes( [ 'class' => [ null, '', ' ', ' ' ] ] ),
+ 'Array with null, empty string and spaces'
+ );
+ # ## NON-EMPTY ARRAY VALUES
+ $this->assertEquals(
+ ' class="foo bar"',
+ Html::expandAttributes( [ 'class' => [
+ 'foo',
+ 'bar',
+ 'foo',
+ 'bar',
+ 'bar',
+ ] ] ),
+ 'Normalization should remove duplicates in the array'
+ );
+ $this->assertEquals(
+ ' class="foo bar"',
+ Html::expandAttributes( [ 'class' => [
+ 'foo bar',
+ 'bar foo',
+ 'foo',
+ 'bar bar',
+ ] ] ),
+ 'Normalization should remove duplicates in string-lists in the array'
+ );
+ }
+
+ /**
+ * Test feature added by r96188, let pass attributes values as
+ * a PHP array. Restricted to class,rel, accesskey.
+ * @covers Html::expandAttributes
+ */
+ public function testExpandAttributesSpaceSeparatedAttributesWithBoolean() {
+ $this->assertEquals(
+ ' class="booltrue one"',
+ Html::expandAttributes( [ 'class' => [
+ 'booltrue' => true,
+ 'one' => 1,
+
+ # Method use isset() internally, make sure we do discard
+ # attributes values which have been assigned well known values
+ 'emptystring' => '',
+ 'boolfalse' => false,
+ 'zero' => 0,
+ 'null' => null,
+ ] ] )
+ );
+ }
+
+ /**
+ * How do we handle duplicate keys in HTML attributes expansion?
+ * We could pass a "class" the values: 'GREEN' and array( 'GREEN' => false )
+ * The latter will take precedence.
+ *
+ * Feature added by r96188
+ * @covers Html::expandAttributes
+ */
+ public function testValueIsAuthoritativeInSpaceSeparatedAttributesArrays() {
+ $this->assertEquals(
+ ' class=""',
+ Html::expandAttributes( [ 'class' => [
+ 'GREEN',
+ 'GREEN' => false,
+ 'GREEN',
+ ] ] )
+ );
+ }
+
+ /**
+ * @covers Html::expandAttributes
+ * @expectedException MWException
+ */
+ public function testExpandAttributes_ArrayOnNonListValueAttribute_ThrowsException() {
+ // Real-life test case found in the Popups extension (see Gerrit cf0fd64),
+ // when used with an outdated BetaFeatures extension (see Gerrit deda1e7)
+ Html::expandAttributes( [
+ 'src' => [
+ 'ltr' => 'ltr.svg',
+ 'rtl' => 'rtl.svg'
+ ]
+ ] );
+ }
+
+ /**
+ * @covers Html::namespaceSelector
+ * @covers Html::namespaceSelectorOptions
+ */
+ public function testNamespaceSelector() {
+ $this->assertEquals(
+ '<select id="namespace" name="namespace">' . "\n" .
+ '<option value="0">(Main)</option>' . "\n" .
+ '<option value="1">Talk</option>' . "\n" .
+ '<option value="2">User</option>' . "\n" .
+ '<option value="3">User talk</option>' . "\n" .
+ '<option value="4">MyWiki</option>' . "\n" .
+ '<option value="5">MyWiki Talk</option>' . "\n" .
+ '<option value="6">File</option>' . "\n" .
+ '<option value="7">File talk</option>' . "\n" .
+ '<option value="8">MediaWiki</option>' . "\n" .
+ '<option value="9">MediaWiki talk</option>' . "\n" .
+ '<option value="10">Template</option>' . "\n" .
+ '<option value="11">Template talk</option>' . "\n" .
+ '<option value="14">Category</option>' . "\n" .
+ '<option value="15">Category talk</option>' . "\n" .
+ '<option value="100">Custom</option>' . "\n" .
+ '<option value="101">Custom talk</option>' . "\n" .
+ '</select>',
+ Html::namespaceSelector(),
+ 'Basic namespace selector without custom options'
+ );
+
+ $this->assertEquals(
+ '<label for="mw-test-namespace">Select a namespace:</label>&#160;' .
+ '<select id="mw-test-namespace" name="wpNamespace">' . "\n" .
+ '<option value="all">all</option>' . "\n" .
+ '<option value="0">(Main)</option>' . "\n" .
+ '<option value="1">Talk</option>' . "\n" .
+ '<option value="2" selected="">User</option>' . "\n" .
+ '<option value="3">User talk</option>' . "\n" .
+ '<option value="4">MyWiki</option>' . "\n" .
+ '<option value="5">MyWiki Talk</option>' . "\n" .
+ '<option value="6">File</option>' . "\n" .
+ '<option value="7">File talk</option>' . "\n" .
+ '<option value="8">MediaWiki</option>' . "\n" .
+ '<option value="9">MediaWiki talk</option>' . "\n" .
+ '<option value="10">Template</option>' . "\n" .
+ '<option value="11">Template talk</option>' . "\n" .
+ '<option value="14">Category</option>' . "\n" .
+ '<option value="15">Category talk</option>' . "\n" .
+ '<option value="100">Custom</option>' . "\n" .
+ '<option value="101">Custom talk</option>' . "\n" .
+ '</select>',
+ Html::namespaceSelector(
+ [ 'selected' => '2', 'all' => 'all', 'label' => 'Select a namespace:' ],
+ [ 'name' => 'wpNamespace', 'id' => 'mw-test-namespace' ]
+ ),
+ 'Basic namespace selector with custom values'
+ );
+
+ $this->assertEquals(
+ '<label for="namespace">Select a namespace:</label>&#160;' .
+ '<select id="namespace" name="namespace">' . "\n" .
+ '<option value="0">(Main)</option>' . "\n" .
+ '<option value="1">Talk</option>' . "\n" .
+ '<option value="2">User</option>' . "\n" .
+ '<option value="3">User talk</option>' . "\n" .
+ '<option value="4">MyWiki</option>' . "\n" .
+ '<option value="5">MyWiki Talk</option>' . "\n" .
+ '<option value="6">File</option>' . "\n" .
+ '<option value="7">File talk</option>' . "\n" .
+ '<option value="8">MediaWiki</option>' . "\n" .
+ '<option value="9">MediaWiki talk</option>' . "\n" .
+ '<option value="10">Template</option>' . "\n" .
+ '<option value="11">Template talk</option>' . "\n" .
+ '<option value="14">Category</option>' . "\n" .
+ '<option value="15">Category talk</option>' . "\n" .
+ '<option value="100">Custom</option>' . "\n" .
+ '<option value="101">Custom talk</option>' . "\n" .
+ '</select>',
+ Html::namespaceSelector(
+ [ 'label' => 'Select a namespace:' ]
+ ),
+ 'Basic namespace selector with a custom label but no id attribtue for the <select>'
+ );
+ }
+
+ /**
+ * @covers Html::namespaceSelector
+ */
+ public function testCanFilterOutNamespaces() {
+ $this->assertEquals(
+ '<select id="namespace" name="namespace">' . "\n" .
+ '<option value="2">User</option>' . "\n" .
+ '<option value="4">MyWiki</option>' . "\n" .
+ '<option value="5">MyWiki Talk</option>' . "\n" .
+ '<option value="6">File</option>' . "\n" .
+ '<option value="7">File talk</option>' . "\n" .
+ '<option value="8">MediaWiki</option>' . "\n" .
+ '<option value="9">MediaWiki talk</option>' . "\n" .
+ '<option value="10">Template</option>' . "\n" .
+ '<option value="11">Template talk</option>' . "\n" .
+ '<option value="14">Category</option>' . "\n" .
+ '<option value="15">Category talk</option>' . "\n" .
+ '</select>',
+ Html::namespaceSelector(
+ [ 'exclude' => [ 0, 1, 3, 100, 101 ] ]
+ ),
+ 'Namespace selector namespace filtering.'
+ );
+ }
+
+ /**
+ * @covers Html::namespaceSelector
+ */
+ public function testCanDisableANamespaces() {
+ $this->assertEquals(
+ '<select id="namespace" name="namespace">' . "\n" .
+ '<option disabled="" value="0">(Main)</option>' . "\n" .
+ '<option disabled="" value="1">Talk</option>' . "\n" .
+ '<option disabled="" value="2">User</option>' . "\n" .
+ '<option disabled="" value="3">User talk</option>' . "\n" .
+ '<option disabled="" value="4">MyWiki</option>' . "\n" .
+ '<option value="5">MyWiki Talk</option>' . "\n" .
+ '<option value="6">File</option>' . "\n" .
+ '<option value="7">File talk</option>' . "\n" .
+ '<option value="8">MediaWiki</option>' . "\n" .
+ '<option value="9">MediaWiki talk</option>' . "\n" .
+ '<option value="10">Template</option>' . "\n" .
+ '<option value="11">Template talk</option>' . "\n" .
+ '<option value="14">Category</option>' . "\n" .
+ '<option value="15">Category talk</option>' . "\n" .
+ '<option value="100">Custom</option>' . "\n" .
+ '<option value="101">Custom talk</option>' . "\n" .
+ '</select>',
+ Html::namespaceSelector( [
+ 'disable' => [ 0, 1, 2, 3, 4 ]
+ ] ),
+ 'Namespace selector namespace disabling'
+ );
+ }
+
+ /**
+ * @dataProvider provideHtml5InputTypes
+ * @covers Html::element
+ */
+ public function testHtmlElementAcceptsNewHtml5TypesInHtml5Mode( $HTML5InputType ) {
+ $this->assertEquals(
+ '<input type="' . $HTML5InputType . '"/>',
+ Html::element( 'input', [ 'type' => $HTML5InputType ] ),
+ 'In HTML5, Html::element() should accept type="' . $HTML5InputType . '"'
+ );
+ }
+
+ /**
+ * @covers Html::warningBox
+ * @covers Html::messageBox
+ */
+ public function testWarningBox() {
+ $this->assertEquals(
+ Html::warningBox( 'warn' ),
+ '<div class="warningbox">warn</div>'
+ );
+ }
+
+ /**
+ * @covers Html::errorBox
+ * @covers Html::messageBox
+ */
+ public function testErrorBox() {
+ $this->assertEquals(
+ Html::errorBox( 'err' ),
+ '<div class="errorbox">err</div>'
+ );
+ $this->assertEquals(
+ Html::errorBox( 'err', 'heading' ),
+ '<div class="errorbox"><h2>heading</h2>err</div>'
+ );
+ }
+
+ /**
+ * @covers Html::successBox
+ * @covers Html::messageBox
+ */
+ public function testSuccessBox() {
+ $this->assertEquals(
+ Html::successBox( 'great' ),
+ '<div class="successbox">great</div>'
+ );
+ $this->assertEquals(
+ Html::successBox( '<script>beware no escaping!</script>' ),
+ '<div class="successbox"><script>beware no escaping!</script></div>'
+ );
+ }
+
+ /**
+ * List of input element types values introduced by HTML5
+ * Full list at https://www.w3.org/TR/html-markup/input.html
+ */
+ public static function provideHtml5InputTypes() {
+ $types = [
+ 'datetime',
+ 'datetime-local',
+ 'date',
+ 'month',
+ 'time',
+ 'week',
+ 'number',
+ 'range',
+ 'email',
+ 'url',
+ 'search',
+ 'tel',
+ 'color',
+ ];
+ $cases = [];
+ foreach ( $types as $type ) {
+ $cases[] = [ $type ];
+ }
+
+ return $cases;
+ }
+
+ /**
+ * Test out Html::element drops or enforces default value
+ * @covers Html::dropDefaults
+ * @dataProvider provideElementsWithAttributesHavingDefaultValues
+ */
+ public function testDropDefaults( $expected, $element, $attribs, $message = '' ) {
+ $this->assertEquals( $expected, Html::element( $element, $attribs ), $message );
+ }
+
+ public static function provideElementsWithAttributesHavingDefaultValues() {
+ # Use cases in a concise format:
+ # <expected>, <element name>, <array of attributes> [, <message>]
+ # Will be mapped to Html::element()
+ $cases = [];
+
+ # ## Generic cases, match $attribDefault static array
+ $cases[] = [ '<area/>',
+ 'area', [ 'shape' => 'rect' ]
+ ];
+
+ $cases[] = [ '<button type="submit"></button>',
+ 'button', [ 'formaction' => 'GET' ]
+ ];
+ $cases[] = [ '<button type="submit"></button>',
+ 'button', [ 'formenctype' => 'application/x-www-form-urlencoded' ]
+ ];
+
+ $cases[] = [ '<canvas></canvas>',
+ 'canvas', [ 'height' => '150' ]
+ ];
+ $cases[] = [ '<canvas></canvas>',
+ 'canvas', [ 'width' => '300' ]
+ ];
+ # Also check with numeric values
+ $cases[] = [ '<canvas></canvas>',
+ 'canvas', [ 'height' => 150 ]
+ ];
+ $cases[] = [ '<canvas></canvas>',
+ 'canvas', [ 'width' => 300 ]
+ ];
+
+ $cases[] = [ '<form></form>',
+ 'form', [ 'action' => 'GET' ]
+ ];
+ $cases[] = [ '<form></form>',
+ 'form', [ 'autocomplete' => 'on' ]
+ ];
+ $cases[] = [ '<form></form>',
+ 'form', [ 'enctype' => 'application/x-www-form-urlencoded' ]
+ ];
+
+ $cases[] = [ '<input/>',
+ 'input', [ 'formaction' => 'GET' ]
+ ];
+ $cases[] = [ '<input/>',
+ 'input', [ 'type' => 'text' ]
+ ];
+
+ $cases[] = [ '<keygen/>',
+ 'keygen', [ 'keytype' => 'rsa' ]
+ ];
+
+ $cases[] = [ '<link/>',
+ 'link', [ 'media' => 'all' ]
+ ];
+
+ $cases[] = [ '<menu></menu>',
+ 'menu', [ 'type' => 'list' ]
+ ];
+
+ $cases[] = [ '<script></script>',
+ 'script', [ 'type' => 'text/javascript' ]
+ ];
+
+ $cases[] = [ '<style></style>',
+ 'style', [ 'media' => 'all' ]
+ ];
+ $cases[] = [ '<style></style>',
+ 'style', [ 'type' => 'text/css' ]
+ ];
+
+ $cases[] = [ '<textarea></textarea>',
+ 'textarea', [ 'wrap' => 'soft' ]
+ ];
+
+ # ## SPECIFIC CASES
+
+ # <link type="text/css">
+ $cases[] = [ '<link/>',
+ 'link', [ 'type' => 'text/css' ]
+ ];
+
+ # <input> specific handling
+ $cases[] = [ '<input type="checkbox"/>',
+ 'input', [ 'type' => 'checkbox', 'value' => 'on' ],
+ 'Default value "on" is stripped of checkboxes',
+ ];
+ $cases[] = [ '<input type="radio"/>',
+ 'input', [ 'type' => 'radio', 'value' => 'on' ],
+ 'Default value "on" is stripped of radio buttons',
+ ];
+ $cases[] = [ '<input type="submit" value="Submit"/>',
+ 'input', [ 'type' => 'submit', 'value' => 'Submit' ],
+ 'Default value "Submit" is kept on submit buttons (for possible l10n issues)',
+ ];
+ $cases[] = [ '<input type="color"/>',
+ 'input', [ 'type' => 'color', 'value' => '' ],
+ ];
+ $cases[] = [ '<input type="range"/>',
+ 'input', [ 'type' => 'range', 'value' => '' ],
+ ];
+
+ # <button> specific handling
+ # see remarks on https://msdn.microsoft.com/library/ms535211(v=vs.85).aspx
+ $cases[] = [ '<button type="submit"></button>',
+ 'button', [ 'type' => 'submit' ],
+ 'According to standard the default type is "submit". '
+ . 'Depending on compatibility mode IE might use "button", instead.',
+ ];
+
+ # <select> specific handling
+ $cases[] = [ '<select multiple=""></select>',
+ 'select', [ 'size' => '4', 'multiple' => true ],
+ ];
+ # .. with numeric value
+ $cases[] = [ '<select multiple=""></select>',
+ 'select', [ 'size' => 4, 'multiple' => true ],
+ ];
+ $cases[] = [ '<select></select>',
+ 'select', [ 'size' => '1', 'multiple' => false ],
+ ];
+ # .. with numeric value
+ $cases[] = [ '<select></select>',
+ 'select', [ 'size' => 1, 'multiple' => false ],
+ ];
+
+ # Passing an array as value
+ $cases[] = [ '<a class="css-class-one css-class-two"></a>',
+ 'a', [ 'class' => [ 'css-class-one', 'css-class-two' ] ],
+ "dropDefaults accepts values given as an array"
+ ];
+
+ # FIXME: doDropDefault should remove defaults given in an array
+ # Expected should be '<a></a>'
+ $cases[] = [ '<a class=""></a>',
+ 'a', [ 'class' => [ '', '' ] ],
+ "dropDefaults accepts values given as an array"
+ ];
+
+ # Craft the Html elements
+ $ret = [];
+ foreach ( $cases as $case ) {
+ $ret[] = [
+ $case[0],
+ $case[1], $case[2],
+ isset( $case[3] ) ? $case[3] : ''
+ ];
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @covers Html::input
+ */
+ public function testWrapperInput() {
+ $this->assertEquals(
+ '<input type="radio" value="testval" name="testname"/>',
+ Html::input( 'testname', 'testval', 'radio' ),
+ 'Input wrapper with type and value.'
+ );
+ $this->assertEquals(
+ '<input name="testname"/>',
+ Html::input( 'testname' ),
+ 'Input wrapper with all default values.'
+ );
+ }
+
+ /**
+ * @covers Html::check
+ */
+ public function testWrapperCheck() {
+ $this->assertEquals(
+ '<input type="checkbox" value="1" name="testname"/>',
+ Html::check( 'testname' ),
+ 'Checkbox wrapper unchecked.'
+ );
+ $this->assertEquals(
+ '<input checked="" type="checkbox" value="1" name="testname"/>',
+ Html::check( 'testname', true ),
+ 'Checkbox wrapper checked.'
+ );
+ $this->assertEquals(
+ '<input type="checkbox" value="testval" name="testname"/>',
+ Html::check( 'testname', false, [ 'value' => 'testval' ] ),
+ 'Checkbox wrapper with a value override.'
+ );
+ }
+
+ /**
+ * @covers Html::radio
+ */
+ public function testWrapperRadio() {
+ $this->assertEquals(
+ '<input type="radio" value="1" name="testname"/>',
+ Html::radio( 'testname' ),
+ 'Radio wrapper unchecked.'
+ );
+ $this->assertEquals(
+ '<input checked="" type="radio" value="1" name="testname"/>',
+ Html::radio( 'testname', true ),
+ 'Radio wrapper checked.'
+ );
+ $this->assertEquals(
+ '<input type="radio" value="testval" name="testname"/>',
+ Html::radio( 'testname', false, [ 'value' => 'testval' ] ),
+ 'Radio wrapper with a value override.'
+ );
+ }
+
+ /**
+ * @covers Html::label
+ */
+ public function testWrapperLabel() {
+ $this->assertEquals(
+ '<label for="testid">testlabel</label>',
+ Html::label( 'testlabel', 'testid' ),
+ 'Label wrapper'
+ );
+ }
+
+ public static function provideSrcSetImages() {
+ return [
+ [ [], '', 'when there are no images, return empty string' ],
+ [
+ [ '1x' => '1x.png', '1.5x' => '1_5x.png', '2x' => '2x.png' ],
+ '1x.png 1x, 1_5x.png 1.5x, 2x.png 2x',
+ 'pixel depth keys may include a trailing "x"'
+ ],
+ [
+ [ '1' => '1x.png', '1.5' => '1_5x.png', '2' => '2x.png' ],
+ '1x.png 1x, 1_5x.png 1.5x, 2x.png 2x',
+ 'pixel depth keys may omit a trailing "x"'
+ ],
+ [
+ [ '1' => 'small.png', '1.5' => 'large.png', '2' => 'large.png' ],
+ 'small.png 1x, large.png 1.5x',
+ 'omit larger duplicates'
+ ],
+ [
+ [ '1' => 'small.png', '2' => 'large.png', '1.5' => 'large.png' ],
+ 'small.png 1x, large.png 1.5x',
+ 'omit larger duplicates in irregular order'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideSrcSetImages
+ * @covers Html::srcSet
+ */
+ public function testSrcSet( $images, $expected, $message ) {
+ $this->assertEquals( Html::srcSet( $images ), $expected, $message );
+ }
+}
+
+class HtmlTestValue {
+ function __toString() {
+ return 'stringValue';
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/LicensesTest.php b/www/wiki/tests/phpunit/includes/LicensesTest.php
new file mode 100644
index 00000000..0e96bf44
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/LicensesTest.php
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * @covers Licenses
+ */
+class LicensesTest extends MediaWikiTestCase {
+
+ public function testLicenses() {
+ $str = "
+* Free licenses:
+** GFDL|Debian disagrees
+";
+
+ $lc = new Licenses( [
+ 'fieldname' => 'FooField',
+ 'type' => 'select',
+ 'section' => 'description',
+ 'id' => 'wpLicense',
+ 'label' => 'A label text', # Note can't test label-message because $wgOut is not defined
+ 'name' => 'AnotherName',
+ 'licenses' => $str,
+ ] );
+ $this->assertThat( $lc, $this->isInstanceOf( Licenses::class ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/LinkFilterTest.php b/www/wiki/tests/phpunit/includes/LinkFilterTest.php
new file mode 100644
index 00000000..51b54d2c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/LinkFilterTest.php
@@ -0,0 +1,254 @@
+<?php
+
+use Wikimedia\Rdbms\LikeMatch;
+
+/**
+ * @covers LinkFilter
+ * @group Database
+ */
+class LinkFilterTest extends MediaWikiLangTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( 'wgUrlProtocols', [
+ 'http://',
+ 'https://',
+ 'ftp://',
+ 'irc://',
+ 'ircs://',
+ 'gopher://',
+ 'telnet://',
+ 'nntp://',
+ 'worldwind://',
+ 'mailto:',
+ 'news:',
+ 'svn://',
+ 'git://',
+ 'mms://',
+ '//',
+ ] );
+ }
+
+ /**
+ * createRegexFromLike($like)
+ *
+ * Takes an array as created by LinkFilter::makeLikeArray() and creates a regex from it
+ *
+ * @param array $like Array as created by LinkFilter::makeLikeArray()
+ * @return string Regex
+ */
+ function createRegexFromLIKE( $like ) {
+ $regex = '!^';
+
+ foreach ( $like as $item ) {
+ if ( $item instanceof LikeMatch ) {
+ if ( $item->toString() == '%' ) {
+ $regex .= '.*';
+ } elseif ( $item->toString() == '_' ) {
+ $regex .= '.';
+ }
+ } else {
+ $regex .= preg_quote( $item, '!' );
+ }
+
+ }
+
+ $regex .= '$!';
+
+ return $regex;
+ }
+
+ /**
+ * provideValidPatterns()
+ *
+ * @return array
+ */
+ public static function provideValidPatterns() {
+ return [
+ // Protocol, Search pattern, URL which matches the pattern
+ [ 'http://', '*.test.com', 'http://www.test.com' ],
+ [ 'http://', 'test.com:8080/dir/file', 'http://name:pass@test.com:8080/dir/file' ],
+ [ 'https://', '*.com', 'https://s.s.test..com:88/dir/file?a=1&b=2' ],
+ [ 'https://', '*.com', 'https://name:pass@secure.com/index.html' ],
+ [ 'http://', 'name:pass@test.com', 'http://test.com' ],
+ [ 'http://', 'test.com', 'http://name:pass@test.com' ],
+ [ 'http://', '*.test.com', 'http://a.b.c.test.com/dir/dir/file?a=6' ],
+ [ null, 'http://*.test.com', 'http://www.test.com' ],
+ [ 'mailto:', 'name@mail.test123.com', 'mailto:name@mail.test123.com' ],
+ [ '',
+ 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg',
+ 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg'
+ ],
+ [ '', 'http://name:pass@*.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg',
+ 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ],
+ [ '', 'http://name:wrongpass@*.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]',
+ 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ],
+ [ 'http://', 'name:pass@*.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg',
+ 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ],
+ [ '', 'http://name:pass@www.test.com:12345',
+ 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ],
+ [ 'ftp://', 'user:pass@ftp.test.com:1233/home/user/file;type=efw',
+ 'ftp://user:pass@ftp.test.com:1233/home/user/file;type=efw' ],
+ [ null, 'ftp://otheruser:otherpass@ftp.test.com:1233/home/user/file;type=',
+ 'ftp://user:pass@ftp.test.com:1233/home/user/file;type=efw' ],
+ [ null, 'ftp://@ftp.test.com:1233/home/user/file;type=',
+ 'ftp://user:pass@ftp.test.com:1233/home/user/file;type=efw' ],
+ [ null, 'ftp://ftp.test.com/',
+ 'ftp://user:pass@ftp.test.com/home/user/file;type=efw' ],
+ [ null, 'ftp://ftp.test.com/',
+ 'ftp://user:pass@ftp.test.com/home/user/file;type=efw' ],
+ [ null, 'ftp://*.test.com:222/',
+ 'ftp://user:pass@ftp.test.com:222/home' ],
+ [ 'irc://', '*.myserver:6667/', 'irc://test.myserver:6667/' ],
+ [ 'irc://', 'name:pass@*.myserver/', 'irc://test.myserver:6667/' ],
+ [ 'irc://', 'name:pass@*.myserver/', 'irc://other:@test.myserver:6667/' ],
+ [ '', 'irc://test/name,string,abc?msg=t', 'irc://test/name,string,abc?msg=test' ],
+ [ '', 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z',
+ 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z' ],
+ [ '', 'https://gerrit.wikimedia.org',
+ 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z' ],
+ [ 'mailto:', '*.test.com', 'mailto:name@pop3.test.com' ],
+ [ 'mailto:', 'test.com', 'mailto:name@test.com' ],
+ [ 'news:', 'test.1234afc@news.test.com', 'news:test.1234afc@news.test.com' ],
+ [ 'news:', '*.test.com', 'news:test.1234afc@news.test.com' ],
+ [ '', 'news:4df8kh$iagfewewf(at)newsbf02aaa.news.aol.com',
+ 'news:4df8kh$iagfewewf(at)newsbf02aaa.news.aol.com' ],
+ [ '', 'news:*.aol.com',
+ 'news:4df8kh$iagfewewf(at)newsbf02aaa.news.aol.com' ],
+ [ '', 'git://github.com/prwef/abc-def.git', 'git://github.com/prwef/abc-def.git' ],
+ [ 'git://', 'github.com/', 'git://github.com/prwef/abc-def.git' ],
+ [ 'git://', '*.github.com/', 'git://a.b.c.d.e.f.github.com/prwef/abc-def.git' ],
+ [ '', 'gopher://*.test.com/', 'gopher://gopher.test.com/0/v2/vstat' ],
+ [ 'telnet://', '*.test.com', 'telnet://shell.test.com/~home/' ],
+ [ '', 'http://test.com', 'http://test.com/index?arg=1' ],
+ [ 'http://', '*.test.com', 'http://www.test.com/index?arg=1' ],
+ [ '' ,
+ 'http://xx23124:__ffdfdef__@www.test.com:12345/dir' ,
+ 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg'
+ ],
+
+ // Tests for false positives
+ [ 'http://', 'test.com', 'http://www.test.com', false ],
+ [ 'http://', 'www1.test.com', 'http://www.test.com', false ],
+ [ 'http://', '*.test.com', 'http://www.test.t.com', false ],
+ [ '', 'http://test.com:8080', 'http://www.test.com:8080', false ],
+ [ '', 'https://test.com', 'http://test.com', false ],
+ [ '', 'http://test.com', 'https://test.com', false ],
+ [ 'http://', 'http://test.com', 'http://test.com', false ],
+ [ null, 'http://www.test.com', 'http://www.test.com:80', false ],
+ [ null, 'http://www.test.com:80', 'http://www.test.com', false ],
+ [ null, 'http://*.test.com:80', 'http://www.test.com', false ],
+ [ '', 'https://gerrit.wikimedia.org/r/#/XXX/status:open,n,z',
+ 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z', false ],
+ [ '', 'https://*.wikimedia.org/r/#/q/status:open,n,z',
+ 'https://gerrit.wikimedia.org/r/#/XXX/status:open,n,z', false ],
+ [ 'mailto:', '@test.com', '@abc.test.com', false ],
+ [ 'mailto:', 'mail@test.com', 'mail2@test.com', false ],
+ [ '', 'mailto:mail@test.com', 'mail2@test.com', false ],
+ [ '', 'mailto:@test.com', '@abc.test.com', false ],
+ [ 'ftp://', '*.co', 'ftp://www.co.uk', false ],
+ [ 'ftp://', '*.co', 'ftp://www.co.m', false ],
+ [ 'ftp://', '*.co/dir/', 'ftp://www.co/dir2/', false ],
+ [ 'ftp://', 'www.co/dir/', 'ftp://www.co/dir2/', false ],
+ [ 'ftp://', 'test.com/dir/', 'ftp://test.com/', false ],
+ [ '', 'http://test.com:8080/dir/', 'http://test.com:808/dir/', false ],
+ [ '', 'http://test.com/dir/index.html', 'http://test.com/dir/index.php', false ],
+
+ // These are false positives too and ideally shouldn't match, but that
+ // would require using regexes and RLIKE instead of LIKE
+ // [ null, 'http://*.test.com', 'http://www.test.com:80', false ],
+ // [ '', 'https://*.wikimedia.org/r/#/q/status:open,n,z',
+ // 'https://gerrit.wikimedia.org/XXX/r/#/q/status:open,n,z', false ],
+ ];
+ }
+
+ /**
+ * testMakeLikeArrayWithValidPatterns()
+ *
+ * Tests whether the LIKE clause produced by LinkFilter::makeLikeArray($pattern, $protocol)
+ * will find one of the URL indexes produced by wfMakeUrlIndexes($url)
+ *
+ * @dataProvider provideValidPatterns
+ *
+ * @param string $protocol Protocol, e.g. 'http://' or 'mailto:'
+ * @param string $pattern Search pattern to feed to LinkFilter::makeLikeArray
+ * @param string $url URL to feed to wfMakeUrlIndexes
+ * @param bool $shouldBeFound Should the URL be found? (defaults true)
+ */
+ function testMakeLikeArrayWithValidPatterns( $protocol, $pattern, $url, $shouldBeFound = true ) {
+ $indexes = wfMakeUrlIndexes( $url );
+ $likeArray = LinkFilter::makeLikeArray( $pattern, $protocol );
+
+ $this->assertTrue( $likeArray !== false,
+ "LinkFilter::makeLikeArray('$pattern', '$protocol') returned false on a valid pattern"
+ );
+
+ $regex = $this->createRegexFromLIKE( $likeArray );
+ $debugmsg = "Regex: '" . $regex . "'\n";
+ $debugmsg .= count( $indexes ) . " index(es) created by wfMakeUrlIndexes():\n";
+
+ $matches = 0;
+
+ foreach ( $indexes as $index ) {
+ $matches += preg_match( $regex, $index );
+ $debugmsg .= "\t'$index'\n";
+ }
+
+ if ( $shouldBeFound ) {
+ $this->assertTrue(
+ $matches > 0,
+ "Search pattern '$protocol$pattern' does not find url '$url' \n$debugmsg"
+ );
+ } else {
+ $this->assertFalse(
+ $matches > 0,
+ "Search pattern '$protocol$pattern' should not find url '$url' \n$debugmsg"
+ );
+ }
+ }
+
+ /**
+ * provideInvalidPatterns()
+ *
+ * @return array
+ */
+ public static function provideInvalidPatterns() {
+ return [
+ [ '' ],
+ [ '*' ],
+ [ 'http://*' ],
+ [ 'http://*/' ],
+ [ 'http://*/dir/file' ],
+ [ 'test.*.com' ],
+ [ 'http://test.*.com' ],
+ [ 'test.*.com' ],
+ [ 'http://*.test.*' ],
+ [ 'http://*test.com' ],
+ [ 'https://*' ],
+ [ '*://test.com' ],
+ [ 'mailto:name:pass@t*est.com' ],
+ [ 'http://*:888/' ],
+ [ '*http://' ],
+ [ 'test.com/*/index' ],
+ [ 'test.com/dir/index?arg=*' ],
+ ];
+ }
+
+ /**
+ * testMakeLikeArrayWithInvalidPatterns()
+ *
+ * Tests whether LinkFilter::makeLikeArray($pattern) will reject invalid search patterns
+ *
+ * @dataProvider provideInvalidPatterns
+ *
+ * @param string $pattern Invalid search pattern
+ */
+ function testMakeLikeArrayWithInvalidPatterns( $pattern ) {
+ $this->assertFalse(
+ LinkFilter::makeLikeArray( $pattern ),
+ "'$pattern' is not a valid pattern and should be rejected"
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/LinkerTest.php b/www/wiki/tests/phpunit/includes/LinkerTest.php
new file mode 100644
index 00000000..f9e2cc17
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/LinkerTest.php
@@ -0,0 +1,480 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group Database
+ */
+class LinkerTest extends MediaWikiLangTestCase {
+
+ /**
+ * @dataProvider provideCasesForUserLink
+ * @covers Linker::userLink
+ */
+ public function testUserLink( $expected, $userId, $userName, $altUserName = false, $msg = '' ) {
+ $this->setMwGlobals( [
+ 'wgArticlePath' => '/wiki/$1',
+ ] );
+
+ $this->assertEquals(
+ $expected,
+ Linker::userLink( $userId, $userName, $altUserName ),
+ $msg
+ );
+ }
+
+ public static function provideCasesForUserLink() {
+ # Format:
+ # - expected
+ # - userid
+ # - username
+ # - optional altUserName
+ # - optional message
+ return [
+
+ # ## ANONYMOUS USER ########################################
+ [
+ '<a href="/wiki/Special:Contributions/JohnDoe" '
+ . 'class="mw-userlink mw-anonuserlink" '
+ . 'title="Special:Contributions/JohnDoe"><bdi>JohnDoe</bdi></a>',
+ 0, 'JohnDoe', false,
+ ],
+ [
+ '<a href="/wiki/Special:Contributions/::1" '
+ . 'class="mw-userlink mw-anonuserlink" '
+ . 'title="Special:Contributions/::1"><bdi>::1</bdi></a>',
+ 0, '::1', false,
+ 'Anonymous with pretty IPv6'
+ ],
+ [
+ '<a href="/wiki/Special:Contributions/0:0:0:0:0:0:0:1" '
+ . 'class="mw-userlink mw-anonuserlink" '
+ . 'title="Special:Contributions/0:0:0:0:0:0:0:1"><bdi>::1</bdi></a>',
+ 0, '0:0:0:0:0:0:0:1', false,
+ 'Anonymous with almost pretty IPv6'
+ ],
+ [
+ '<a href="/wiki/Special:Contributions/0000:0000:0000:0000:0000:0000:0000:0001" '
+ . 'class="mw-userlink mw-anonuserlink" '
+ . 'title="Special:Contributions/0000:0000:0000:0000:0000:0000:0000:0001"><bdi>::1</bdi></a>',
+ 0, '0000:0000:0000:0000:0000:0000:0000:0001', false,
+ 'Anonymous with full IPv6'
+ ],
+ [
+ '<a href="/wiki/Special:Contributions/::1" '
+ . 'class="mw-userlink mw-anonuserlink" '
+ . 'title="Special:Contributions/::1"><bdi>AlternativeUsername</bdi></a>',
+ 0, '::1', 'AlternativeUsername',
+ 'Anonymous with pretty IPv6 and an alternative username'
+ ],
+
+ # IPV4
+ [
+ '<a href="/wiki/Special:Contributions/127.0.0.1" '
+ . 'class="mw-userlink mw-anonuserlink" '
+ . 'title="Special:Contributions/127.0.0.1"><bdi>127.0.0.1</bdi></a>',
+ 0, '127.0.0.1', false,
+ 'Anonymous with IPv4'
+ ],
+ [
+ '<a href="/wiki/Special:Contributions/127.0.0.1" '
+ . 'class="mw-userlink mw-anonuserlink" '
+ . 'title="Special:Contributions/127.0.0.1"><bdi>AlternativeUsername</bdi></a>',
+ 0, '127.0.0.1', 'AlternativeUsername',
+ 'Anonymous with IPv4 and an alternative username'
+ ],
+
+ # ## Regular user ##########################################
+ # TODO!
+ ];
+ }
+
+ /**
+ * @dataProvider provideCasesForFormatComment
+ * @covers Linker::formatComment
+ * @covers Linker::formatAutocomments
+ * @covers Linker::formatLinksInComment
+ */
+ public function testFormatComment(
+ $expected, $comment, $title = false, $local = false, $wikiId = null
+ ) {
+ $conf = new SiteConfiguration();
+ $conf->settings = [
+ 'wgServer' => [
+ 'enwiki' => '//en.example.org',
+ 'dewiki' => '//de.example.org',
+ ],
+ 'wgArticlePath' => [
+ 'enwiki' => '/w/$1',
+ 'dewiki' => '/w/$1',
+ ],
+ ];
+ $conf->suffixes = [ 'wiki' ];
+
+ $this->setMwGlobals( [
+ 'wgScript' => '/wiki/index.php',
+ 'wgArticlePath' => '/wiki/$1',
+ 'wgCapitalLinks' => true,
+ 'wgConf' => $conf,
+ ] );
+
+ if ( $title === false ) {
+ // We need a page title that exists
+ $title = Title::newFromText( 'Special:BlankPage' );
+ }
+
+ $this->assertEquals(
+ $expected,
+ Linker::formatComment( $comment, $title, $local, $wikiId )
+ );
+ }
+
+ public function provideCasesForFormatComment() {
+ $wikiId = 'enwiki'; // $wgConf has a fake entry for this
+
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ // Linker::formatComment
+ [
+ 'a&lt;script&gt;b',
+ 'a<script>b',
+ ],
+ [
+ 'a—b',
+ 'a&mdash;b',
+ ],
+ [
+ "&#039;&#039;&#039;not bolded&#039;&#039;&#039;",
+ "'''not bolded'''",
+ ],
+ [
+ "try &lt;script&gt;evil&lt;/scipt&gt; things",
+ "try <script>evil</scipt> things",
+ ],
+ // Linker::formatAutocomments
+ [
+ '<a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→</a>‎<span dir="auto"><span class="autocomment">autocomment</span></span>',
+ "/* autocomment */",
+ ],
+ [
+ '<a href="/wiki/Special:BlankPage#linkie.3F" title="Special:BlankPage">→</a>‎<span dir="auto"><span class="autocomment"><a href="/wiki/index.php?title=Linkie%3F&amp;action=edit&amp;redlink=1" class="new" title="Linkie? (page does not exist)">linkie?</a></span></span>',
+ "/* [[linkie?]] */",
+ ],
+ [
+ '<a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→</a>‎<span dir="auto"><span class="autocomment">autocomment: </span> post</span>',
+ "/* autocomment */ post",
+ ],
+ [
+ 'pre <a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→</a>‎<span dir="auto"><span class="autocomment">autocomment</span></span>',
+ "pre /* autocomment */",
+ ],
+ [
+ 'pre <a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→</a>‎<span dir="auto"><span class="autocomment">autocomment: </span> post</span>',
+ "pre /* autocomment */ post",
+ ],
+ [
+ '<a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→</a>‎<span dir="auto"><span class="autocomment">autocomment: </span> multiple? <a href="/wiki/Special:BlankPage#autocomment2" title="Special:BlankPage">→</a>‎<span dir="auto"><span class="autocomment">autocomment2: </span> </span></span>',
+ "/* autocomment */ multiple? /* autocomment2 */ ",
+ ],
+ [
+ '<a href="/wiki/Special:BlankPage#autocomment_containing_.2F.2A" title="Special:BlankPage">→</a>‎<span dir="auto"><span class="autocomment">autocomment containing /*: </span> T70361</span>',
+ "/* autocomment containing /* */ T70361"
+ ],
+ [
+ '<a href="/wiki/Special:BlankPage#autocomment_containing_.22quotes.22" title="Special:BlankPage">→</a>‎<span dir="auto"><span class="autocomment">autocomment containing &quot;quotes&quot;</span></span>',
+ "/* autocomment containing \"quotes\" */"
+ ],
+ [
+ '<a href="/wiki/Special:BlankPage#autocomment_containing_.3Cscript.3Etags.3C.2Fscript.3E" title="Special:BlankPage">→</a>‎<span dir="auto"><span class="autocomment">autocomment containing &lt;script&gt;tags&lt;/script&gt;</span></span>',
+ "/* autocomment containing <script>tags</script> */"
+ ],
+ [
+ '<a href="#autocomment">→</a>‎<span dir="auto"><span class="autocomment">autocomment</span></span>',
+ "/* autocomment */",
+ false, true
+ ],
+ [
+ '‎<span dir="auto"><span class="autocomment">autocomment</span></span>',
+ "/* autocomment */",
+ null
+ ],
+ [
+ '<a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→</a>‎<span dir="auto"><span class="autocomment">autocomment</span></span>',
+ "/* autocomment */",
+ false, false
+ ],
+ [
+ '<a class="external" rel="nofollow" href="//en.example.org/w/Special:BlankPage#autocomment">→</a>‎<span dir="auto"><span class="autocomment">autocomment</span></span>',
+ "/* autocomment */",
+ false, false, $wikiId
+ ],
+ // Linker::formatLinksInComment
+ [
+ 'abc <a href="/wiki/index.php?title=Link&amp;action=edit&amp;redlink=1" class="new" title="Link (page does not exist)">link</a> def',
+ "abc [[link]] def",
+ ],
+ [
+ 'abc <a href="/wiki/index.php?title=Link&amp;action=edit&amp;redlink=1" class="new" title="Link (page does not exist)">text</a> def',
+ "abc [[link|text]] def",
+ ],
+ [
+ 'abc <a href="/wiki/Special:BlankPage" title="Special:BlankPage">Special:BlankPage</a> def',
+ "abc [[Special:BlankPage|]] def",
+ ],
+ [
+ 'abc <a href="/wiki/index.php?title=%C4%84%C5%9B%C5%BC&amp;action=edit&amp;redlink=1" class="new" title="Ąśż (page does not exist)">ąśż</a> def',
+ "abc [[%C4%85%C5%9B%C5%BC]] def",
+ ],
+ [
+ 'abc <a href="/wiki/Special:BlankPage#section" title="Special:BlankPage">#section</a> def',
+ "abc [[#section]] def",
+ ],
+ [
+ 'abc <a href="/wiki/index.php?title=/subpage&amp;action=edit&amp;redlink=1" class="new" title="/subpage (page does not exist)">/subpage</a> def',
+ "abc [[/subpage]] def",
+ ],
+ [
+ 'abc <a href="/wiki/index.php?title=%22evil!%22&amp;action=edit&amp;redlink=1" class="new" title="&quot;evil!&quot; (page does not exist)">&quot;evil!&quot;</a> def',
+ "abc [[\"evil!\"]] def",
+ ],
+ [
+ 'abc [[&lt;script&gt;very evil&lt;/script&gt;]] def',
+ "abc [[<script>very evil</script>]] def",
+ ],
+ [
+ 'abc [[|]] def',
+ "abc [[|]] def",
+ ],
+ [
+ 'abc <a href="/wiki/index.php?title=Link&amp;action=edit&amp;redlink=1" class="new" title="Link (page does not exist)">link</a> def',
+ "abc [[link]] def",
+ false, false
+ ],
+ [
+ 'abc <a class="external" rel="nofollow" href="//en.example.org/w/Link">link</a> def',
+ "abc [[link]] def",
+ false, false, $wikiId
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @covers Linker::formatLinksInComment
+ * @dataProvider provideCasesForFormatLinksInComment
+ */
+ public function testFormatLinksInComment( $expected, $input, $wiki ) {
+ $conf = new SiteConfiguration();
+ $conf->settings = [
+ 'wgServer' => [
+ 'enwiki' => '//en.example.org'
+ ],
+ 'wgArticlePath' => [
+ 'enwiki' => '/w/$1',
+ ],
+ ];
+ $conf->suffixes = [ 'wiki' ];
+ $this->setMwGlobals( [
+ 'wgScript' => '/wiki/index.php',
+ 'wgArticlePath' => '/wiki/$1',
+ 'wgCapitalLinks' => true,
+ 'wgConf' => $conf,
+ ] );
+
+ $this->assertEquals(
+ $expected,
+ Linker::formatLinksInComment( $input, Title::newFromText( 'Special:BlankPage' ), false, $wiki )
+ );
+ }
+
+ public static function provideCasesForFormatLinksInComment() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ 'foo bar <a href="/wiki/Special:BlankPage" title="Special:BlankPage">Special:BlankPage</a>',
+ 'foo bar [[Special:BlankPage]]',
+ null,
+ ],
+ [
+ '<a class="external" rel="nofollow" href="//en.example.org/w/Foo%27bar">Foo\'bar</a>',
+ "[[Foo'bar]]",
+ 'enwiki',
+ ],
+ [
+ 'foo bar <a class="external" rel="nofollow" href="//en.example.org/w/Special:BlankPage">Special:BlankPage</a>',
+ 'foo bar [[Special:BlankPage]]',
+ 'enwiki',
+ ],
+ [
+ 'foo bar <a class="external" rel="nofollow" href="//en.example.org/w/File:Example">Image:Example</a>',
+ 'foo bar [[Image:Example]]',
+ 'enwiki',
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ public static function provideLinkBeginHook() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ // Modify $html
+ [
+ function ( $dummy, $title, &$html, &$attribs, &$query, &$options, &$ret ) {
+ $html = 'foobar';
+ },
+ '<a href="/wiki/Special:BlankPage" title="Special:BlankPage">foobar</a>'
+ ],
+ // Modify $attribs
+ [
+ function ( $dummy, $title, &$html, &$attribs, &$query, &$options, &$ret ) {
+ $attribs['bar'] = 'baz';
+ },
+ '<a href="/wiki/Special:BlankPage" title="Special:BlankPage" bar="baz">Special:BlankPage</a>'
+ ],
+ // Modify $query
+ [
+ function ( $dummy, $title, &$html, &$attribs, &$query, &$options, &$ret ) {
+ $query['bar'] = 'baz';
+ },
+ '<a href="/w/index.php?title=Special:BlankPage&amp;bar=baz" title="Special:BlankPage">Special:BlankPage</a>'
+ ],
+ // Force HTTP $options
+ [
+ function ( $dummy, $title, &$html, &$attribs, &$query, &$options, &$ret ) {
+ $options = [ 'http' ];
+ },
+ '<a href="http://example.org/wiki/Special:BlankPage" title="Special:BlankPage">Special:BlankPage</a>'
+ ],
+ // Force 'forcearticlepath' in $options
+ [
+ function ( $dummy, $title, &$html, &$attribs, &$query, &$options, &$ret ) {
+ $options = [ 'forcearticlepath' ];
+ $query['foo'] = 'bar';
+ },
+ '<a href="/wiki/Special:BlankPage?foo=bar" title="Special:BlankPage">Special:BlankPage</a>'
+ ],
+ // Abort early
+ [
+ function ( $dummy, $title, &$html, &$attribs, &$query, &$options, &$ret ) {
+ $ret = 'foobar';
+ return false;
+ },
+ 'foobar'
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @covers MediaWiki\Linker\LinkRenderer::runLegacyBeginHook
+ * @dataProvider provideLinkBeginHook
+ */
+ public function testLinkBeginHook( $callback, $expected ) {
+ $this->setMwGlobals( [
+ 'wgArticlePath' => '/wiki/$1',
+ 'wgServer' => '//example.org',
+ 'wgCanonicalServer' => 'http://example.org',
+ 'wgScriptPath' => '/w',
+ 'wgScript' => '/w/index.php',
+ ] );
+
+ $this->setMwGlobals( 'wgHooks', [ 'LinkBegin' => [ $callback ] ] );
+ $title = SpecialPage::getTitleFor( 'Blankpage' );
+ $out = Linker::link( $title );
+ $this->assertEquals( $expected, $out );
+ }
+
+ public static function provideLinkEndHook() {
+ return [
+ // Override $html
+ [
+ function ( $dummy, $title, $options, &$html, &$attribs, &$ret ) {
+ $html = 'foobar';
+ },
+ '<a href="/wiki/Special:BlankPage" title="Special:BlankPage">foobar</a>'
+ ],
+ // Modify $attribs
+ [
+ function ( $dummy, $title, $options, &$html, &$attribs, &$ret ) {
+ $attribs['bar'] = 'baz';
+ },
+ '<a href="/wiki/Special:BlankPage" title="Special:BlankPage" bar="baz">Special:BlankPage</a>'
+ ],
+ // Fully override return value and abort hook
+ [
+ function ( $dummy, $title, $options, &$html, &$attribs, &$ret ) {
+ $ret = 'blahblahblah';
+ return false;
+ },
+ 'blahblahblah'
+ ],
+
+ ];
+ }
+
+ /**
+ * @covers MediaWiki\Linker\LinkRenderer::buildAElement
+ * @dataProvider provideLinkEndHook
+ */
+ public function testLinkEndHook( $callback, $expected ) {
+ $this->setMwGlobals( [
+ 'wgArticlePath' => '/wiki/$1',
+ ] );
+
+ $this->setMwGlobals( 'wgHooks', [ 'LinkEnd' => [ $callback ] ] );
+
+ $title = SpecialPage::getTitleFor( 'Blankpage' );
+ $out = Linker::link( $title );
+ $this->assertEquals( $expected, $out );
+ }
+
+ /**
+ * @covers Linker::getLinkColour
+ */
+ public function testGetLinkColour() {
+ $this->hideDeprecated( 'Linker::getLinkColour' );
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+ $foobarTitle = Title::makeTitle( NS_MAIN, 'FooBar' );
+ $redirectTitle = Title::makeTitle( NS_MAIN, 'Redirect' );
+ $userTitle = Title::makeTitle( NS_USER, 'Someuser' );
+ $linkCache->addGoodLinkObj(
+ 1, // id
+ $foobarTitle,
+ 10, // len
+ 0 // redir
+ );
+ $linkCache->addGoodLinkObj(
+ 2, // id
+ $redirectTitle,
+ 10, // len
+ 1 // redir
+ );
+
+ $linkCache->addGoodLinkObj(
+ 3, // id
+ $userTitle,
+ 10, // len
+ 0 // redir
+ );
+
+ $this->assertEquals(
+ '',
+ Linker::getLinkColour( $foobarTitle, 0 )
+ );
+
+ $this->assertEquals(
+ 'stub',
+ Linker::getLinkColour( $foobarTitle, 20 )
+ );
+
+ $this->assertEquals(
+ 'mw-redirect',
+ Linker::getLinkColour( $redirectTitle, 0 )
+ );
+
+ $this->assertEquals(
+ '',
+ Linker::getLinkColour( $userTitle, 20 )
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/ListToggleTest.php b/www/wiki/tests/phpunit/includes/ListToggleTest.php
new file mode 100644
index 00000000..3574545e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/ListToggleTest.php
@@ -0,0 +1,49 @@
+<?php
+
+/**
+ * @covers ListToggle
+ */
+class ListToggleTest extends MediaWikiTestCase {
+
+ /**
+ * @covers ListToggle::__construct
+ */
+ public function testConstruct() {
+ $output = $this->getMockBuilder( OutputPage::class )
+ ->setMethods( null )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $listToggle = new ListToggle( $output );
+
+ $this->assertInstanceOf( ListToggle::class, $listToggle );
+ $this->assertContains( 'mediawiki.checkboxtoggle', $output->getModules() );
+ $this->assertContains( 'mediawiki.checkboxtoggle.styles', $output->getModuleStyles() );
+ }
+
+ /**
+ * @covers ListToggle::getHTML
+ */
+ public function testGetHTML() {
+ $output = $this->createMock( OutputPage::class );
+ $output->expects( $this->any() )
+ ->method( 'msg' )
+ ->will( $this->returnCallback( function ( $key ) {
+ return wfMessage( $key )->inLanguage( 'qqx' );
+ } ) );
+ $output->expects( $this->once() )
+ ->method( 'getLanguage' )
+ ->will( $this->returnValue( Language::factory( 'qqx' ) ) );
+
+ $listToggle = new ListToggle( $output );
+
+ $html = $listToggle->getHTML();
+ $this->assertEquals( '<div class="mw-checkbox-toggle-controls">' .
+ '(checkbox-select: <a class="mw-checkbox-all" role="button"' .
+ ' tabindex="0">(checkbox-all)</a>(comma-separator)' .
+ '<a class="mw-checkbox-none" role="button" tabindex="0">' .
+ '(checkbox-none)</a>(comma-separator)<a class="mw-checkbox-invert" ' .
+ 'role="button" tabindex="0">(checkbox-invert)</a>)</div>',
+ $html );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/MWNamespaceTest.php b/www/wiki/tests/phpunit/includes/MWNamespaceTest.php
new file mode 100644
index 00000000..15e2defc
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/MWNamespaceTest.php
@@ -0,0 +1,578 @@
+<?php
+/**
+ * @author Antoine Musso
+ * @copyright Copyright © 2011, Antoine Musso
+ * @file
+ */
+
+class MWNamespaceTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgContentNamespaces' => [ NS_MAIN ],
+ 'wgNamespacesWithSubpages' => [
+ NS_TALK => true,
+ NS_USER => true,
+ NS_USER_TALK => true,
+ ],
+ 'wgCapitalLinks' => true,
+ 'wgCapitalLinkOverrides' => [],
+ 'wgNonincludableNamespaces' => [],
+ ] );
+ }
+
+ /**
+ * @todo Write more texts, handle $wgAllowImageMoving setting
+ * @covers MWNamespace::isMovable
+ */
+ public function testIsMovable() {
+ $this->assertFalse( MWNamespace::isMovable( NS_SPECIAL ) );
+ }
+
+ private function assertIsSubject( $ns ) {
+ $this->assertTrue( MWNamespace::isSubject( $ns ) );
+ }
+
+ private function assertIsNotSubject( $ns ) {
+ $this->assertFalse( MWNamespace::isSubject( $ns ) );
+ }
+
+ /**
+ * Please make sure to change testIsTalk() if you change the assertions below
+ * @covers MWNamespace::isSubject
+ */
+ public function testIsSubject() {
+ // Special namespaces
+ $this->assertIsSubject( NS_MEDIA );
+ $this->assertIsSubject( NS_SPECIAL );
+
+ // Subject pages
+ $this->assertIsSubject( NS_MAIN );
+ $this->assertIsSubject( NS_USER );
+ $this->assertIsSubject( 100 ); # user defined
+
+ // Talk pages
+ $this->assertIsNotSubject( NS_TALK );
+ $this->assertIsNotSubject( NS_USER_TALK );
+ $this->assertIsNotSubject( 101 ); # user defined
+ }
+
+ private function assertIsTalk( $ns ) {
+ $this->assertTrue( MWNamespace::isTalk( $ns ) );
+ }
+
+ private function assertIsNotTalk( $ns ) {
+ $this->assertFalse( MWNamespace::isTalk( $ns ) );
+ }
+
+ /**
+ * Reverse of testIsSubject().
+ * Please update testIsSubject() if you change assertions below
+ * @covers MWNamespace::isTalk
+ */
+ public function testIsTalk() {
+ // Special namespaces
+ $this->assertIsNotTalk( NS_MEDIA );
+ $this->assertIsNotTalk( NS_SPECIAL );
+
+ // Subject pages
+ $this->assertIsNotTalk( NS_MAIN );
+ $this->assertIsNotTalk( NS_USER );
+ $this->assertIsNotTalk( 100 ); # user defined
+
+ // Talk pages
+ $this->assertIsTalk( NS_TALK );
+ $this->assertIsTalk( NS_USER_TALK );
+ $this->assertIsTalk( 101 ); # user defined
+ }
+
+ /**
+ * @covers MWNamespace::getSubject
+ */
+ public function testGetSubject() {
+ // Special namespaces are their own subjects
+ $this->assertEquals( NS_MEDIA, MWNamespace::getSubject( NS_MEDIA ) );
+ $this->assertEquals( NS_SPECIAL, MWNamespace::getSubject( NS_SPECIAL ) );
+
+ $this->assertEquals( NS_MAIN, MWNamespace::getSubject( NS_TALK ) );
+ $this->assertEquals( NS_USER, MWNamespace::getSubject( NS_USER_TALK ) );
+ }
+
+ /**
+ * Regular getTalk() calls
+ * Namespaces without a talk page (NS_MEDIA, NS_SPECIAL) are tested in
+ * the function testGetTalkExceptions()
+ * @covers MWNamespace::getTalk
+ */
+ public function testGetTalk() {
+ $this->assertEquals( NS_TALK, MWNamespace::getTalk( NS_MAIN ) );
+ $this->assertEquals( NS_TALK, MWNamespace::getTalk( NS_TALK ) );
+ $this->assertEquals( NS_USER_TALK, MWNamespace::getTalk( NS_USER ) );
+ $this->assertEquals( NS_USER_TALK, MWNamespace::getTalk( NS_USER_TALK ) );
+ }
+
+ /**
+ * Exceptions with getTalk()
+ * NS_MEDIA does not have talk pages. MediaWiki raise an exception for them.
+ * @expectedException MWException
+ * @covers MWNamespace::getTalk
+ */
+ public function testGetTalkExceptionsForNsMedia() {
+ $this->assertNull( MWNamespace::getTalk( NS_MEDIA ) );
+ }
+
+ /**
+ * Exceptions with getTalk()
+ * NS_SPECIAL does not have talk pages. MediaWiki raise an exception for them.
+ * @expectedException MWException
+ * @covers MWNamespace::getTalk
+ */
+ public function testGetTalkExceptionsForNsSpecial() {
+ $this->assertNull( MWNamespace::getTalk( NS_SPECIAL ) );
+ }
+
+ /**
+ * Regular getAssociated() calls
+ * Namespaces without an associated page (NS_MEDIA, NS_SPECIAL) are tested in
+ * the function testGetAssociatedExceptions()
+ * @covers MWNamespace::getAssociated
+ */
+ public function testGetAssociated() {
+ $this->assertEquals( NS_TALK, MWNamespace::getAssociated( NS_MAIN ) );
+ $this->assertEquals( NS_MAIN, MWNamespace::getAssociated( NS_TALK ) );
+ }
+
+ # ## Exceptions with getAssociated()
+ # ## NS_MEDIA and NS_SPECIAL do not have talk pages. MediaWiki raises
+ # ## an exception for them.
+ /**
+ * @expectedException MWException
+ * @covers MWNamespace::getAssociated
+ */
+ public function testGetAssociatedExceptionsForNsMedia() {
+ $this->assertNull( MWNamespace::getAssociated( NS_MEDIA ) );
+ }
+
+ /**
+ * @expectedException MWException
+ * @covers MWNamespace::getAssociated
+ */
+ public function testGetAssociatedExceptionsForNsSpecial() {
+ $this->assertNull( MWNamespace::getAssociated( NS_SPECIAL ) );
+ }
+
+ /**
+ * Test MWNamespace::equals
+ * Note if we add a namespace registration system with keys like 'MAIN'
+ * we should add tests here for equivilance on things like 'MAIN' == 0
+ * and 'MAIN' == NS_MAIN.
+ * @covers MWNamespace::equals
+ */
+ public function testEquals() {
+ $this->assertTrue( MWNamespace::equals( NS_MAIN, NS_MAIN ) );
+ $this->assertTrue( MWNamespace::equals( NS_MAIN, 0 ) ); // In case we make NS_MAIN 'MAIN'
+ $this->assertTrue( MWNamespace::equals( NS_USER, NS_USER ) );
+ $this->assertTrue( MWNamespace::equals( NS_USER, 2 ) );
+ $this->assertTrue( MWNamespace::equals( NS_USER_TALK, NS_USER_TALK ) );
+ $this->assertTrue( MWNamespace::equals( NS_SPECIAL, NS_SPECIAL ) );
+ $this->assertFalse( MWNamespace::equals( NS_MAIN, NS_TALK ) );
+ $this->assertFalse( MWNamespace::equals( NS_USER, NS_USER_TALK ) );
+ $this->assertFalse( MWNamespace::equals( NS_PROJECT, NS_TEMPLATE ) );
+ }
+
+ /**
+ * @covers MWNamespace::subjectEquals
+ */
+ public function testSubjectEquals() {
+ $this->assertSameSubject( NS_MAIN, NS_MAIN );
+ $this->assertSameSubject( NS_MAIN, 0 ); // In case we make NS_MAIN 'MAIN'
+ $this->assertSameSubject( NS_USER, NS_USER );
+ $this->assertSameSubject( NS_USER, 2 );
+ $this->assertSameSubject( NS_USER_TALK, NS_USER_TALK );
+ $this->assertSameSubject( NS_SPECIAL, NS_SPECIAL );
+ $this->assertSameSubject( NS_MAIN, NS_TALK );
+ $this->assertSameSubject( NS_USER, NS_USER_TALK );
+
+ $this->assertDifferentSubject( NS_PROJECT, NS_TEMPLATE );
+ $this->assertDifferentSubject( NS_SPECIAL, NS_MAIN );
+ }
+
+ /**
+ * @covers MWNamespace::subjectEquals
+ */
+ public function testSpecialAndMediaAreDifferentSubjects() {
+ $this->assertDifferentSubject(
+ NS_MEDIA, NS_SPECIAL,
+ "NS_MEDIA and NS_SPECIAL are different subject namespaces"
+ );
+ $this->assertDifferentSubject(
+ NS_SPECIAL, NS_MEDIA,
+ "NS_SPECIAL and NS_MEDIA are different subject namespaces"
+ );
+ }
+
+ public function provideHasTalkNamespace() {
+ return [
+ [ NS_MEDIA, false ],
+ [ NS_SPECIAL, false ],
+
+ [ NS_MAIN, true ],
+ [ NS_TALK, true ],
+ [ NS_USER, true ],
+ [ NS_USER_TALK, true ],
+
+ [ 100, true ],
+ [ 101, true ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideHasTalkNamespace
+ * @covers MWNamespace::hasTalkNamespace
+ *
+ * @param int $index
+ * @param bool $expected
+ */
+ public function testHasTalkNamespace( $index, $expected ) {
+ $actual = MWNamespace::hasTalkNamespace( $index );
+ $this->assertSame( $actual, $expected, "NS $index" );
+ }
+
+ /**
+ * @dataProvider provideHasTalkNamespace
+ * @covers MWNamespace::canTalk
+ *
+ * @param int $index
+ * @param bool $expected
+ */
+ public function testCanTalk( $index, $expected ) {
+ $actual = MWNamespace::canTalk( $index );
+ $this->assertSame( $actual, $expected, "NS $index" );
+ }
+
+ private function assertIsContent( $ns ) {
+ $this->assertTrue( MWNamespace::isContent( $ns ) );
+ }
+
+ private function assertIsNotContent( $ns ) {
+ $this->assertFalse( MWNamespace::isContent( $ns ) );
+ }
+
+ /**
+ * @covers MWNamespace::isContent
+ */
+ public function testIsContent() {
+ // NS_MAIN is a content namespace per DefaultSettings.php
+ // and per function definition.
+
+ $this->assertIsContent( NS_MAIN );
+
+ // Other namespaces which are not expected to be content
+
+ $this->assertIsNotContent( NS_MEDIA );
+ $this->assertIsNotContent( NS_SPECIAL );
+ $this->assertIsNotContent( NS_TALK );
+ $this->assertIsNotContent( NS_USER );
+ $this->assertIsNotContent( NS_CATEGORY );
+ $this->assertIsNotContent( 100 );
+ }
+
+ /**
+ * Similar to testIsContent() but alters the $wgContentNamespaces
+ * global variable.
+ * @covers MWNamespace::isContent
+ */
+ public function testIsContentAdvanced() {
+ global $wgContentNamespaces;
+
+ // Test that user defined namespace #252 is not content
+ $this->assertIsNotContent( 252 );
+
+ // Bless namespace # 252 as a content namespace
+ $wgContentNamespaces[] = 252;
+
+ $this->assertIsContent( 252 );
+
+ // Makes sure NS_MAIN was not impacted
+ $this->assertIsContent( NS_MAIN );
+ }
+
+ private function assertIsWatchable( $ns ) {
+ $this->assertTrue( MWNamespace::isWatchable( $ns ) );
+ }
+
+ private function assertIsNotWatchable( $ns ) {
+ $this->assertFalse( MWNamespace::isWatchable( $ns ) );
+ }
+
+ /**
+ * @covers MWNamespace::isWatchable
+ */
+ public function testIsWatchable() {
+ // Specials namespaces are not watchable
+ $this->assertIsNotWatchable( NS_MEDIA );
+ $this->assertIsNotWatchable( NS_SPECIAL );
+
+ // Core defined namespaces are watchables
+ $this->assertIsWatchable( NS_MAIN );
+ $this->assertIsWatchable( NS_TALK );
+
+ // Additional, user defined namespaces are watchables
+ $this->assertIsWatchable( 100 );
+ $this->assertIsWatchable( 101 );
+ }
+
+ private function assertHasSubpages( $ns ) {
+ $this->assertTrue( MWNamespace::hasSubpages( $ns ) );
+ }
+
+ private function assertHasNotSubpages( $ns ) {
+ $this->assertFalse( MWNamespace::hasSubpages( $ns ) );
+ }
+
+ /**
+ * @covers MWNamespace::hasSubpages
+ */
+ public function testHasSubpages() {
+ global $wgNamespacesWithSubpages;
+
+ // Special namespaces:
+ $this->assertHasNotSubpages( NS_MEDIA );
+ $this->assertHasNotSubpages( NS_SPECIAL );
+
+ // Namespaces without subpages
+ $this->assertHasNotSubpages( NS_MAIN );
+
+ $wgNamespacesWithSubpages[NS_MAIN] = true;
+ $this->assertHasSubpages( NS_MAIN );
+
+ $wgNamespacesWithSubpages[NS_MAIN] = false;
+ $this->assertHasNotSubpages( NS_MAIN );
+
+ // Some namespaces with subpages
+ $this->assertHasSubpages( NS_TALK );
+ $this->assertHasSubpages( NS_USER );
+ $this->assertHasSubpages( NS_USER_TALK );
+ }
+
+ /**
+ * @covers MWNamespace::getContentNamespaces
+ */
+ public function testGetContentNamespaces() {
+ global $wgContentNamespaces;
+
+ $this->assertEquals(
+ [ NS_MAIN ],
+ MWNamespace::getContentNamespaces(),
+ '$wgContentNamespaces is an array with only NS_MAIN by default'
+ );
+
+ # test !is_array( $wgcontentNamespaces )
+ $wgContentNamespaces = '';
+ $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
+
+ $wgContentNamespaces = false;
+ $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
+
+ $wgContentNamespaces = null;
+ $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
+
+ $wgContentNamespaces = 5;
+ $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
+
+ # test $wgContentNamespaces === []
+ $wgContentNamespaces = [];
+ $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
+
+ # test !in_array( NS_MAIN, $wgContentNamespaces )
+ $wgContentNamespaces = [ NS_USER, NS_CATEGORY ];
+ $this->assertEquals(
+ [ NS_MAIN, NS_USER, NS_CATEGORY ],
+ MWNamespace::getContentNamespaces(),
+ 'NS_MAIN is forced in $wgContentNamespaces even if unwanted'
+ );
+
+ # test other cases, return $wgcontentNamespaces as is
+ $wgContentNamespaces = [ NS_MAIN ];
+ $this->assertEquals(
+ [ NS_MAIN ],
+ MWNamespace::getContentNamespaces()
+ );
+
+ $wgContentNamespaces = [ NS_MAIN, NS_USER, NS_CATEGORY ];
+ $this->assertEquals(
+ [ NS_MAIN, NS_USER, NS_CATEGORY ],
+ MWNamespace::getContentNamespaces()
+ );
+ }
+
+ /**
+ * @covers MWNamespace::getSubjectNamespaces
+ */
+ public function testGetSubjectNamespaces() {
+ $subjectsNS = MWNamespace::getSubjectNamespaces();
+ $this->assertContains( NS_MAIN, $subjectsNS,
+ "Talk namespaces should have NS_MAIN" );
+ $this->assertNotContains( NS_TALK, $subjectsNS,
+ "Talk namespaces should have NS_TALK" );
+
+ $this->assertNotContains( NS_MEDIA, $subjectsNS,
+ "Talk namespaces should not have NS_MEDIA" );
+ $this->assertNotContains( NS_SPECIAL, $subjectsNS,
+ "Talk namespaces should not have NS_SPECIAL" );
+ }
+
+ /**
+ * @covers MWNamespace::getTalkNamespaces
+ */
+ public function testGetTalkNamespaces() {
+ $talkNS = MWNamespace::getTalkNamespaces();
+ $this->assertContains( NS_TALK, $talkNS,
+ "Subject namespaces should have NS_TALK" );
+ $this->assertNotContains( NS_MAIN, $talkNS,
+ "Subject namespaces should not have NS_MAIN" );
+
+ $this->assertNotContains( NS_MEDIA, $talkNS,
+ "Subject namespaces should not have NS_MEDIA" );
+ $this->assertNotContains( NS_SPECIAL, $talkNS,
+ "Subject namespaces should not have NS_SPECIAL" );
+ }
+
+ private function assertIsCapitalized( $ns ) {
+ $this->assertTrue( MWNamespace::isCapitalized( $ns ) );
+ }
+
+ private function assertIsNotCapitalized( $ns ) {
+ $this->assertFalse( MWNamespace::isCapitalized( $ns ) );
+ }
+
+ /**
+ * Some namespaces are always capitalized per code definition
+ * in MWNamespace::$alwaysCapitalizedNamespaces
+ * @covers MWNamespace::isCapitalized
+ */
+ public function testIsCapitalizedHardcodedAssertions() {
+ // NS_MEDIA and NS_FILE are treated the same
+ $this->assertEquals(
+ MWNamespace::isCapitalized( NS_MEDIA ),
+ MWNamespace::isCapitalized( NS_FILE ),
+ 'NS_MEDIA and NS_FILE have same capitalization rendering'
+ );
+
+ // Boths are capitalized by default
+ $this->assertIsCapitalized( NS_MEDIA );
+ $this->assertIsCapitalized( NS_FILE );
+
+ // Always capitalized namespaces
+ // @see MWNamespace::$alwaysCapitalizedNamespaces
+ $this->assertIsCapitalized( NS_SPECIAL );
+ $this->assertIsCapitalized( NS_USER );
+ $this->assertIsCapitalized( NS_MEDIAWIKI );
+ }
+
+ /**
+ * Follows up for testIsCapitalizedHardcodedAssertions() but alter the
+ * global $wgCapitalLink setting to have extended coverage.
+ *
+ * MWNamespace::isCapitalized() rely on two global settings:
+ * $wgCapitalLinkOverrides = []; by default
+ * $wgCapitalLinks = true; by default
+ * This function test $wgCapitalLinks
+ *
+ * Global setting correctness is tested against the NS_PROJECT and
+ * NS_PROJECT_TALK namespaces since they are not hardcoded nor specials
+ * @covers MWNamespace::isCapitalized
+ */
+ public function testIsCapitalizedWithWgCapitalLinks() {
+ global $wgCapitalLinks;
+
+ $this->assertIsCapitalized( NS_PROJECT );
+ $this->assertIsCapitalized( NS_PROJECT_TALK );
+
+ $wgCapitalLinks = false;
+
+ // hardcoded namespaces (see above function) are still capitalized:
+ $this->assertIsCapitalized( NS_SPECIAL );
+ $this->assertIsCapitalized( NS_USER );
+ $this->assertIsCapitalized( NS_MEDIAWIKI );
+
+ // setting is correctly applied
+ $this->assertIsNotCapitalized( NS_PROJECT );
+ $this->assertIsNotCapitalized( NS_PROJECT_TALK );
+ }
+
+ /**
+ * Counter part for MWNamespace::testIsCapitalizedWithWgCapitalLinks() now
+ * testing the $wgCapitalLinkOverrides global.
+ *
+ * @todo split groups of assertions in autonomous testing functions
+ * @covers MWNamespace::isCapitalized
+ */
+ public function testIsCapitalizedWithWgCapitalLinkOverrides() {
+ global $wgCapitalLinkOverrides;
+
+ // Test default settings
+ $this->assertIsCapitalized( NS_PROJECT );
+ $this->assertIsCapitalized( NS_PROJECT_TALK );
+
+ // hardcoded namespaces (see above function) are capitalized:
+ $this->assertIsCapitalized( NS_SPECIAL );
+ $this->assertIsCapitalized( NS_USER );
+ $this->assertIsCapitalized( NS_MEDIAWIKI );
+
+ // Hardcoded namespaces remains capitalized
+ $wgCapitalLinkOverrides[NS_SPECIAL] = false;
+ $wgCapitalLinkOverrides[NS_USER] = false;
+ $wgCapitalLinkOverrides[NS_MEDIAWIKI] = false;
+
+ $this->assertIsCapitalized( NS_SPECIAL );
+ $this->assertIsCapitalized( NS_USER );
+ $this->assertIsCapitalized( NS_MEDIAWIKI );
+
+ $wgCapitalLinkOverrides[NS_PROJECT] = false;
+ $this->assertIsNotCapitalized( NS_PROJECT );
+
+ $wgCapitalLinkOverrides[NS_PROJECT] = true;
+ $this->assertIsCapitalized( NS_PROJECT );
+
+ unset( $wgCapitalLinkOverrides[NS_PROJECT] );
+ $this->assertIsCapitalized( NS_PROJECT );
+ }
+
+ /**
+ * @covers MWNamespace::hasGenderDistinction
+ */
+ public function testHasGenderDistinction() {
+ // Namespaces with gender distinctions
+ $this->assertTrue( MWNamespace::hasGenderDistinction( NS_USER ) );
+ $this->assertTrue( MWNamespace::hasGenderDistinction( NS_USER_TALK ) );
+
+ // Other ones, "genderless"
+ $this->assertFalse( MWNamespace::hasGenderDistinction( NS_MEDIA ) );
+ $this->assertFalse( MWNamespace::hasGenderDistinction( NS_SPECIAL ) );
+ $this->assertFalse( MWNamespace::hasGenderDistinction( NS_MAIN ) );
+ $this->assertFalse( MWNamespace::hasGenderDistinction( NS_TALK ) );
+ }
+
+ /**
+ * @covers MWNamespace::isNonincludable
+ */
+ public function testIsNonincludable() {
+ global $wgNonincludableNamespaces;
+
+ $wgNonincludableNamespaces = [ NS_USER ];
+
+ $this->assertTrue( MWNamespace::isNonincludable( NS_USER ) );
+ $this->assertFalse( MWNamespace::isNonincludable( NS_TEMPLATE ) );
+ }
+
+ private function assertSameSubject( $ns1, $ns2, $msg = '' ) {
+ $this->assertTrue( MWNamespace::subjectEquals( $ns1, $ns2 ), $msg );
+ }
+
+ private function assertDifferentSubject( $ns1, $ns2, $msg = '' ) {
+ $this->assertFalse( MWNamespace::subjectEquals( $ns1, $ns2 ), $msg );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/MWTimestampTest.php b/www/wiki/tests/phpunit/includes/MWTimestampTest.php
new file mode 100644
index 00000000..9735eebd
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/MWTimestampTest.php
@@ -0,0 +1,243 @@
+<?php
+
+/**
+ * Tests timestamp parsing and output.
+ */
+class MWTimestampTest extends MediaWikiLangTestCase {
+ protected function setUp() {
+ parent::setUp();
+
+ // Avoid 'GetHumanTimestamp' hook and others
+ $this->setMwGlobals( 'wgHooks', [] );
+ }
+
+ /**
+ * @dataProvider provideHumanTimestampTests
+ * @covers MWTimestamp::getHumanTimestamp
+ */
+ public function testHumanTimestamp(
+ $tsTime, // The timestamp to format
+ $currentTime, // The time to consider "now"
+ $timeCorrection, // The time offset to use
+ $dateFormat, // The date preference to use
+ $expectedOutput, // The expected output
+ $desc // Description
+ ) {
+ $user = $this->createMock( User::class );
+ $user->expects( $this->any() )
+ ->method( 'getOption' )
+ ->with( 'timecorrection' )
+ ->will( $this->returnValue( $timeCorrection ) );
+
+ $user->expects( $this->any() )
+ ->method( 'getDatePreference' )
+ ->will( $this->returnValue( $dateFormat ) );
+
+ $tsTime = new MWTimestamp( $tsTime );
+ $currentTime = new MWTimestamp( $currentTime );
+
+ $this->assertEquals(
+ $expectedOutput,
+ $tsTime->getHumanTimestamp( $currentTime, $user ),
+ $desc
+ );
+ }
+
+ public static function provideHumanTimestampTests() {
+ return [
+ [
+ '20111231170000',
+ '20120101000000',
+ 'Offset|0',
+ 'mdy',
+ 'Yesterday at 17:00',
+ '"Yesterday" across years',
+ ],
+ [
+ '20120717190900',
+ '20120717190929',
+ 'Offset|0',
+ 'mdy',
+ 'just now',
+ '"Just now"',
+ ],
+ [
+ '20120717190900',
+ '20120717191530',
+ 'Offset|0',
+ 'mdy',
+ '6 minutes ago',
+ 'X minutes ago',
+ ],
+ [
+ '20121006173100',
+ '20121006173200',
+ 'Offset|0',
+ 'mdy',
+ '1 minute ago',
+ '"1 minute ago"',
+ ],
+ [
+ '20120617190900',
+ '20120717190900',
+ 'Offset|0',
+ 'mdy',
+ 'June 17',
+ 'Another month'
+ ],
+ [
+ '19910130151500',
+ '20120716193700',
+ 'Offset|0',
+ 'mdy',
+ '15:15, January 30, 1991',
+ 'Different year',
+ ],
+ [
+ '20120101050000',
+ '20120101080000',
+ 'Offset|-360',
+ 'mdy',
+ 'Yesterday at 23:00',
+ '"Yesterday" across years with time correction',
+ ],
+ [
+ '20120714184300',
+ '20120716184300',
+ 'Offset|-420',
+ 'mdy',
+ 'Saturday at 11:43',
+ 'Recent weekday with time correction',
+ ],
+ [
+ '20120714184300',
+ '20120715040000',
+ 'Offset|-420',
+ 'mdy',
+ '11:43',
+ 'Today at another time with time correction',
+ ],
+ [
+ '20120617190900',
+ '20120717190900',
+ 'Offset|0',
+ 'dmy',
+ '17 June',
+ 'Another month with dmy'
+ ],
+ [
+ '20120617190900',
+ '20120717190900',
+ 'Offset|0',
+ 'ISO 8601',
+ '06-17',
+ 'Another month with ISO-8601'
+ ],
+ [
+ '19910130151500',
+ '20120716193700',
+ 'Offset|0',
+ 'ISO 8601',
+ '1991-01-30T15:15:00',
+ 'Different year with ISO-8601',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideRelativeTimestampTests
+ * @covers MWTimestamp::getRelativeTimestamp
+ */
+ public function testRelativeTimestamp(
+ $tsTime, // The timestamp to format
+ $currentTime, // The time to consider "now"
+ $timeCorrection, // The time offset to use
+ $dateFormat, // The date preference to use
+ $expectedOutput, // The expected output
+ $desc // Description
+ ) {
+ $user = $this->createMock( User::class );
+ $user->expects( $this->any() )
+ ->method( 'getOption' )
+ ->with( 'timecorrection' )
+ ->will( $this->returnValue( $timeCorrection ) );
+
+ $tsTime = new MWTimestamp( $tsTime );
+ $currentTime = new MWTimestamp( $currentTime );
+
+ $this->assertEquals(
+ $expectedOutput,
+ $tsTime->getRelativeTimestamp( $currentTime, $user ),
+ $desc
+ );
+ }
+
+ public static function provideRelativeTimestampTests() {
+ return [
+ [
+ '20111231170000',
+ '20120101000000',
+ 'Offset|0',
+ 'mdy',
+ '7 hours ago',
+ '"Yesterday" across years',
+ ],
+ [
+ '20120717190900',
+ '20120717190929',
+ 'Offset|0',
+ 'mdy',
+ '29 seconds ago',
+ '"Just now"',
+ ],
+ [
+ '20120717190900',
+ '20120717191530',
+ 'Offset|0',
+ 'mdy',
+ '6 minutes and 30 seconds ago',
+ 'Combination of multiple units',
+ ],
+ [
+ '20121006173100',
+ '20121006173200',
+ 'Offset|0',
+ 'mdy',
+ '1 minute ago',
+ '"1 minute ago"',
+ ],
+ [
+ '19910130151500',
+ '20120716193700',
+ 'Offset|0',
+ 'mdy',
+ '2 decades, 1 year, 168 days, 2 hours, 8 minutes and 48 seconds ago',
+ 'A long time ago',
+ ],
+ [
+ '20120101050000',
+ '20120101080000',
+ 'Offset|-360',
+ 'mdy',
+ '3 hours ago',
+ '"Yesterday" across years with time correction',
+ ],
+ [
+ '20120714184300',
+ '20120716184300',
+ 'Offset|-420',
+ 'mdy',
+ '2 days ago',
+ 'Recent weekday with time correction',
+ ],
+ [
+ '20120714184300',
+ '20120715040000',
+ 'Offset|-420',
+ 'mdy',
+ '9 hours and 17 minutes ago',
+ 'Today at another time with time correction',
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/MediaWikiServicesTest.php b/www/wiki/tests/phpunit/includes/MediaWikiServicesTest.php
new file mode 100644
index 00000000..03588aec
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/MediaWikiServicesTest.php
@@ -0,0 +1,379 @@
+<?php
+
+use Mediawiki\Http\HttpRequestFactory;
+use MediaWiki\Interwiki\InterwikiLookup;
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Linker\LinkRendererFactory;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Services\DestructibleService;
+use MediaWiki\Services\SalvageableService;
+use MediaWiki\Services\ServiceDisabledException;
+use MediaWiki\Shell\CommandFactory;
+use MediaWiki\Storage\BlobStore;
+use MediaWiki\Storage\BlobStoreFactory;
+use MediaWiki\Storage\RevisionLookup;
+use MediaWiki\Storage\RevisionStore;
+use MediaWiki\Storage\SqlBlobStore;
+
+/**
+ * @covers MediaWiki\MediaWikiServices
+ *
+ * @group MediaWiki
+ */
+class MediaWikiServicesTest extends MediaWikiTestCase {
+
+ /**
+ * @return Config
+ */
+ private function newTestConfig() {
+ $globalConfig = new GlobalVarConfig();
+
+ $testConfig = new HashConfig();
+ $testConfig->set( 'ServiceWiringFiles', $globalConfig->get( 'ServiceWiringFiles' ) );
+ $testConfig->set( 'ConfigRegistry', $globalConfig->get( 'ConfigRegistry' ) );
+
+ return $testConfig;
+ }
+
+ /**
+ * @return MediaWikiServices
+ */
+ private function newMediaWikiServices( Config $config = null ) {
+ if ( $config === null ) {
+ $config = $this->newTestConfig();
+ }
+
+ $instance = new MediaWikiServices( $config );
+
+ // Load the default wiring from the specified files.
+ $wiringFiles = $config->get( 'ServiceWiringFiles' );
+ $instance->loadWiringFiles( $wiringFiles );
+
+ return $instance;
+ }
+
+ public function testGetInstance() {
+ $services = MediaWikiServices::getInstance();
+ $this->assertInstanceOf( MediaWikiServices::class, $services );
+ }
+
+ public function testForceGlobalInstance() {
+ $newServices = $this->newMediaWikiServices();
+ $oldServices = MediaWikiServices::forceGlobalInstance( $newServices );
+
+ $this->assertInstanceOf( MediaWikiServices::class, $oldServices );
+ $this->assertNotSame( $oldServices, $newServices );
+
+ $theServices = MediaWikiServices::getInstance();
+ $this->assertSame( $theServices, $newServices );
+
+ MediaWikiServices::forceGlobalInstance( $oldServices );
+
+ $theServices = MediaWikiServices::getInstance();
+ $this->assertSame( $theServices, $oldServices );
+ }
+
+ public function testResetGlobalInstance() {
+ $newServices = $this->newMediaWikiServices();
+ $oldServices = MediaWikiServices::forceGlobalInstance( $newServices );
+
+ $service1 = $this->createMock( SalvageableService::class );
+ $service1->expects( $this->never() )
+ ->method( 'salvage' );
+
+ $newServices->defineService(
+ 'Test',
+ function () use ( $service1 ) {
+ return $service1;
+ }
+ );
+
+ // force instantiation
+ $newServices->getService( 'Test' );
+
+ MediaWikiServices::resetGlobalInstance( $this->newTestConfig() );
+ $theServices = MediaWikiServices::getInstance();
+
+ $this->assertSame(
+ $service1,
+ $theServices->getService( 'Test' ),
+ 'service definition should survive reset'
+ );
+
+ $this->assertNotSame( $theServices, $newServices );
+ $this->assertNotSame( $theServices, $oldServices );
+
+ MediaWikiServices::forceGlobalInstance( $oldServices );
+ }
+
+ public function testResetGlobalInstance_quick() {
+ $newServices = $this->newMediaWikiServices();
+ $oldServices = MediaWikiServices::forceGlobalInstance( $newServices );
+
+ $service1 = $this->createMock( SalvageableService::class );
+ $service1->expects( $this->never() )
+ ->method( 'salvage' );
+
+ $service2 = $this->createMock( SalvageableService::class );
+ $service2->expects( $this->once() )
+ ->method( 'salvage' )
+ ->with( $service1 );
+
+ // sequence of values the instantiator will return
+ $instantiatorReturnValues = [
+ $service1,
+ $service2,
+ ];
+
+ $newServices->defineService(
+ 'Test',
+ function () use ( &$instantiatorReturnValues ) {
+ return array_shift( $instantiatorReturnValues );
+ }
+ );
+
+ // force instantiation
+ $newServices->getService( 'Test' );
+
+ MediaWikiServices::resetGlobalInstance( $this->newTestConfig(), 'quick' );
+ $theServices = MediaWikiServices::getInstance();
+
+ $this->assertSame( $service2, $theServices->getService( 'Test' ) );
+
+ $this->assertNotSame( $theServices, $newServices );
+ $this->assertNotSame( $theServices, $oldServices );
+
+ MediaWikiServices::forceGlobalInstance( $oldServices );
+ }
+
+ public function testDisableStorageBackend() {
+ $newServices = $this->newMediaWikiServices();
+ $oldServices = MediaWikiServices::forceGlobalInstance( $newServices );
+
+ $lbFactory = $this->getMockBuilder( \Wikimedia\Rdbms\LBFactorySimple::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $newServices->redefineService(
+ 'DBLoadBalancerFactory',
+ function () use ( $lbFactory ) {
+ return $lbFactory;
+ }
+ );
+
+ // force the service to become active, so we can check that it does get destroyed
+ $newServices->getService( 'DBLoadBalancerFactory' );
+
+ MediaWikiServices::disableStorageBackend(); // should destroy DBLoadBalancerFactory
+
+ try {
+ MediaWikiServices::getInstance()->getService( 'DBLoadBalancerFactory' );
+ $this->fail( 'DBLoadBalancerFactory should have been disabled' );
+ }
+ catch ( ServiceDisabledException $ex ) {
+ // ok, as expected
+ } catch ( Throwable $ex ) {
+ $this->fail( 'ServiceDisabledException expected, caught ' . get_class( $ex ) );
+ }
+
+ MediaWikiServices::forceGlobalInstance( $oldServices );
+ $newServices->destroy();
+
+ // No exception was thrown, avoid being risky
+ $this->assertTrue( true );
+ }
+
+ public function testResetChildProcessServices() {
+ $newServices = $this->newMediaWikiServices();
+ $oldServices = MediaWikiServices::forceGlobalInstance( $newServices );
+
+ $service1 = $this->createMock( DestructibleService::class );
+ $service1->expects( $this->once() )
+ ->method( 'destroy' );
+
+ $service2 = $this->createMock( DestructibleService::class );
+ $service2->expects( $this->never() )
+ ->method( 'destroy' );
+
+ // sequence of values the instantiator will return
+ $instantiatorReturnValues = [
+ $service1,
+ $service2,
+ ];
+
+ $newServices->defineService(
+ 'Test',
+ function () use ( &$instantiatorReturnValues ) {
+ return array_shift( $instantiatorReturnValues );
+ }
+ );
+
+ // force the service to become active, so we can check that it does get destroyed
+ $oldTestService = $newServices->getService( 'Test' );
+
+ MediaWikiServices::resetChildProcessServices();
+ $finalServices = MediaWikiServices::getInstance();
+
+ $newTestService = $finalServices->getService( 'Test' );
+ $this->assertNotSame( $oldTestService, $newTestService );
+
+ MediaWikiServices::forceGlobalInstance( $oldServices );
+ }
+
+ public function testResetServiceForTesting() {
+ $services = $this->newMediaWikiServices();
+ $serviceCounter = 0;
+
+ $services->defineService(
+ 'Test',
+ function () use ( &$serviceCounter ) {
+ $serviceCounter++;
+ $service = $this->createMock( MediaWiki\Services\DestructibleService::class );
+ $service->expects( $this->once() )->method( 'destroy' );
+ return $service;
+ }
+ );
+
+ // This should do nothing. In particular, it should not create a service instance.
+ $services->resetServiceForTesting( 'Test' );
+ $this->assertEquals( 0, $serviceCounter, 'No service instance should be created yet.' );
+
+ $oldInstance = $services->getService( 'Test' );
+ $this->assertEquals( 1, $serviceCounter, 'A service instance should exit now.' );
+
+ // The old instance should be detached, and destroy() called.
+ $services->resetServiceForTesting( 'Test' );
+ $newInstance = $services->getService( 'Test' );
+
+ $this->assertNotSame( $oldInstance, $newInstance );
+
+ // Satisfy the expectation that destroy() is called also for the second service instance.
+ $newInstance->destroy();
+ }
+
+ public function testResetServiceForTesting_noDestroy() {
+ $services = $this->newMediaWikiServices();
+
+ $services->defineService(
+ 'Test',
+ function () {
+ $service = $this->createMock( MediaWiki\Services\DestructibleService::class );
+ $service->expects( $this->never() )->method( 'destroy' );
+ return $service;
+ }
+ );
+
+ $oldInstance = $services->getService( 'Test' );
+
+ // The old instance should be detached, but destroy() not called.
+ $services->resetServiceForTesting( 'Test', false );
+ $newInstance = $services->getService( 'Test' );
+
+ $this->assertNotSame( $oldInstance, $newInstance );
+ }
+
+ public function provideGetters() {
+ $getServiceCases = $this->provideGetService();
+ $getterCases = [];
+
+ // All getters should be named just like the service, with "get" added.
+ foreach ( $getServiceCases as $name => $case ) {
+ if ( $name[0] === '_' ) {
+ // Internal service, no getter
+ continue;
+ }
+ list( $service, $class ) = $case;
+ $getterCases[$name] = [
+ 'get' . $service,
+ $class,
+ ];
+ }
+
+ return $getterCases;
+ }
+
+ /**
+ * @dataProvider provideGetters
+ */
+ public function testGetters( $getter, $type ) {
+ // Test against the default instance, since the dummy will not know the default services.
+ $services = MediaWikiServices::getInstance();
+ $service = $services->$getter();
+ $this->assertInstanceOf( $type, $service );
+ }
+
+ public function provideGetService() {
+ // NOTE: This should list all service getters defined in ServiceWiring.php.
+ // NOTE: For every test case defined here there should be a corresponding
+ // test case defined in provideGetters().
+ return [
+ 'BootstrapConfig' => [ 'BootstrapConfig', Config::class ],
+ 'ConfigFactory' => [ 'ConfigFactory', ConfigFactory::class ],
+ 'MainConfig' => [ 'MainConfig', Config::class ],
+ 'SiteStore' => [ 'SiteStore', SiteStore::class ],
+ 'SiteLookup' => [ 'SiteLookup', SiteLookup::class ],
+ 'StatsdDataFactory' => [ 'StatsdDataFactory', IBufferingStatsdDataFactory::class ],
+ 'InterwikiLookup' => [ 'InterwikiLookup', InterwikiLookup::class ],
+ 'EventRelayerGroup' => [ 'EventRelayerGroup', EventRelayerGroup::class ],
+ 'SearchEngineFactory' => [ 'SearchEngineFactory', SearchEngineFactory::class ],
+ 'SearchEngineConfig' => [ 'SearchEngineConfig', SearchEngineConfig::class ],
+ 'SkinFactory' => [ 'SkinFactory', SkinFactory::class ],
+ 'DBLoadBalancerFactory' => [ 'DBLoadBalancerFactory', Wikimedia\Rdbms\LBFactory::class ],
+ 'DBLoadBalancer' => [ 'DBLoadBalancer', Wikimedia\Rdbms\LoadBalancer::class ],
+ 'WatchedItemStore' => [ 'WatchedItemStore', WatchedItemStore::class ],
+ 'WatchedItemQueryService' => [ 'WatchedItemQueryService', WatchedItemQueryService::class ],
+ 'CryptRand' => [ 'CryptRand', CryptRand::class ],
+ 'CryptHKDF' => [ 'CryptHKDF', CryptHKDF::class ],
+ 'MediaHandlerFactory' => [ 'MediaHandlerFactory', MediaHandlerFactory::class ],
+ 'Parser' => [ 'Parser', Parser::class ],
+ 'ParserCache' => [ 'ParserCache', ParserCache::class ],
+ 'GenderCache' => [ 'GenderCache', GenderCache::class ],
+ 'LinkCache' => [ 'LinkCache', LinkCache::class ],
+ 'LinkRenderer' => [ 'LinkRenderer', LinkRenderer::class ],
+ 'LinkRendererFactory' => [ 'LinkRendererFactory', LinkRendererFactory::class ],
+ '_MediaWikiTitleCodec' => [ '_MediaWikiTitleCodec', MediaWikiTitleCodec::class ],
+ 'MimeAnalyzer' => [ 'MimeAnalyzer', MimeAnalyzer::class ],
+ 'TitleFormatter' => [ 'TitleFormatter', TitleFormatter::class ],
+ 'TitleParser' => [ 'TitleParser', TitleParser::class ],
+ 'ProxyLookup' => [ 'ProxyLookup', ProxyLookup::class ],
+ 'MainObjectStash' => [ 'MainObjectStash', BagOStuff::class ],
+ 'MainWANObjectCache' => [ 'MainWANObjectCache', WANObjectCache::class ],
+ 'LocalServerObjectCache' => [ 'LocalServerObjectCache', BagOStuff::class ],
+ 'VirtualRESTServiceClient' => [ 'VirtualRESTServiceClient', VirtualRESTServiceClient::class ],
+ 'ShellCommandFactory' => [ 'ShellCommandFactory', CommandFactory::class ],
+ 'BlobStoreFactory' => [ 'BlobStoreFactory', BlobStoreFactory::class ],
+ 'BlobStore' => [ 'BlobStore', BlobStore::class ],
+ '_SqlBlobStore' => [ '_SqlBlobStore', SqlBlobStore::class ],
+ 'RevisionStore' => [ 'RevisionStore', RevisionStore::class ],
+ 'RevisionLookup' => [ 'RevisionLookup', RevisionLookup::class ],
+ 'HttpRequestFactory' => [ 'HttpRequestFactory', HttpRequestFactory::class ],
+ 'CommentStore' => [ 'CommentStore', CommentStore::class ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetService
+ */
+ public function testGetService( $name, $type ) {
+ // Test against the default instance, since the dummy will not know the default services.
+ $services = MediaWikiServices::getInstance();
+
+ $service = $services->getService( $name );
+ $this->assertInstanceOf( $type, $service );
+ }
+
+ public function testDefaultServiceInstantiation() {
+ // Check all services in the default instance, not a dummy instance!
+ // Note that we instantiate all services here, including any that
+ // were registered by extensions.
+ $services = MediaWikiServices::getInstance();
+ $names = $services->getServiceNames();
+
+ foreach ( $names as $name ) {
+ $this->assertTrue( $services->hasService( $name ) );
+ $service = $services->getService( $name );
+ $this->assertInternalType( 'object', $service );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/MediaWikiTest.php b/www/wiki/tests/phpunit/includes/MediaWikiTest.php
new file mode 100644
index 00000000..a8d1e339
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/MediaWikiTest.php
@@ -0,0 +1,157 @@
+<?php
+
+class MediaWikiTest extends MediaWikiTestCase {
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgServer' => 'http://example.org',
+ 'wgScriptPath' => '/w',
+ 'wgScript' => '/w/index.php',
+ 'wgArticlePath' => '/wiki/$1',
+ 'wgActionPaths' => [],
+ ] );
+ }
+
+ public static function provideTryNormaliseRedirect() {
+ return [
+ [
+ // View: Canonical
+ 'url' => 'http://example.org/wiki/Foo_Bar',
+ 'query' => [],
+ 'title' => 'Foo_Bar',
+ 'redirect' => false,
+ ],
+ [
+ // View: Escaped title
+ 'url' => 'http://example.org/wiki/Foo%20Bar',
+ 'query' => [],
+ 'title' => 'Foo_Bar',
+ 'redirect' => 'http://example.org/wiki/Foo_Bar',
+ ],
+ [
+ // View: Script path
+ 'url' => 'http://example.org/w/index.php?title=Foo_Bar',
+ 'query' => [ 'title' => 'Foo_Bar' ],
+ 'title' => 'Foo_Bar',
+ 'redirect' => false,
+ ],
+ [
+ // View: Script path with implicit title from page id
+ 'url' => 'http://example.org/w/index.php?curid=123',
+ 'query' => [ 'curid' => '123' ],
+ 'title' => 'Foo_Bar',
+ 'redirect' => false,
+ ],
+ [
+ // View: Script path with implicit title from revision id
+ 'url' => 'http://example.org/w/index.php?oldid=123',
+ 'query' => [ 'oldid' => '123' ],
+ 'title' => 'Foo_Bar',
+ 'redirect' => false,
+ ],
+ [
+ // View: Script path without title
+ 'url' => 'http://example.org/w/index.php',
+ 'query' => [],
+ 'title' => 'Main_Page',
+ 'redirect' => 'http://example.org/wiki/Main_Page',
+ ],
+ [
+ // View: Script path with empty title
+ 'url' => 'http://example.org/w/index.php?title=',
+ 'query' => [ 'title' => '' ],
+ 'title' => 'Main_Page',
+ 'redirect' => 'http://example.org/wiki/Main_Page',
+ ],
+ [
+ // View: Index with escaped title
+ 'url' => 'http://example.org/w/index.php?title=Foo%20Bar',
+ 'query' => [ 'title' => 'Foo Bar' ],
+ 'title' => 'Foo_Bar',
+ 'redirect' => 'http://example.org/wiki/Foo_Bar',
+ ],
+ [
+ // View: Script path with escaped title
+ 'url' => 'http://example.org/w/?title=Foo_Bar',
+ 'query' => [ 'title' => 'Foo_Bar' ],
+ 'title' => 'Foo_Bar',
+ 'redirect' => false,
+ ],
+ [
+ // View: Root path with escaped title
+ 'url' => 'http://example.org/?title=Foo_Bar',
+ 'query' => [ 'title' => 'Foo_Bar' ],
+ 'title' => 'Foo_Bar',
+ 'redirect' => false,
+ ],
+ [
+ // View: Canonical with redundant query
+ 'url' => 'http://example.org/wiki/Foo_Bar?action=view',
+ 'query' => [ 'action' => 'view' ],
+ 'title' => 'Foo_Bar',
+ 'redirect' => false,
+ ],
+ [
+ // Edit: Canonical view url with action query
+ 'url' => 'http://example.org/wiki/Foo_Bar?action=edit',
+ 'query' => [ 'action' => 'edit' ],
+ 'title' => 'Foo_Bar',
+ 'redirect' => false,
+ ],
+ [
+ // View: Index with action query
+ 'url' => 'http://example.org/w/index.php?title=Foo_Bar&action=view',
+ 'query' => [ 'title' => 'Foo_Bar', 'action' => 'view' ],
+ 'title' => 'Foo_Bar',
+ 'redirect' => false,
+ ],
+ [
+ // Edit: Index with action query
+ 'url' => 'http://example.org/w/index.php?title=Foo_Bar&action=edit',
+ 'query' => [ 'title' => 'Foo_Bar', 'action' => 'edit' ],
+ 'title' => 'Foo_Bar',
+ 'redirect' => false,
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideTryNormaliseRedirect
+ * @covers MediaWiki::tryNormaliseRedirect
+ */
+ public function testTryNormaliseRedirect( $url, $query, $title, $expectedRedirect = false ) {
+ // Set SERVER because interpolateTitle() doesn't use getRequestURL(),
+ // whereas tryNormaliseRedirect does().
+ $_SERVER['REQUEST_URI'] = $url;
+
+ $req = new FauxRequest( $query );
+ $req->setRequestURL( $url );
+ // This adds a virtual 'title' query parameter. Normally called from Setup.php
+ $req->interpolateTitle();
+
+ $titleObj = Title::newFromText( $title );
+
+ // Set global context since some involved code paths don't yet have context
+ $context = RequestContext::getMain();
+ $context->setRequest( $req );
+ $context->setTitle( $titleObj );
+
+ $mw = new MediaWiki( $context );
+
+ $method = new ReflectionMethod( $mw, 'tryNormaliseRedirect' );
+ $method->setAccessible( true );
+ $ret = $method->invoke( $mw, $titleObj );
+
+ $this->assertEquals(
+ $expectedRedirect !== false,
+ $ret,
+ 'Return true only when redirecting'
+ );
+
+ $this->assertEquals(
+ $expectedRedirect ?: '',
+ $context->getOutput()->getRedirect()
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/MediaWikiVersionFetcherTest.php b/www/wiki/tests/phpunit/includes/MediaWikiVersionFetcherTest.php
new file mode 100644
index 00000000..87a7dffd
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/MediaWikiVersionFetcherTest.php
@@ -0,0 +1,22 @@
+<?php
+
+/**
+ * Note: this is not a unit test, as it touches the file system and reads an actual file.
+ * If unit tests are added for MediaWikiVersionFetcher, this should be done in a distinct test case.
+ *
+ * @covers MediaWikiVersionFetcher
+ *
+ * @group ComposerHooks
+ *
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class MediaWikiVersionFetcherTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function testReturnsResult() {
+ $versionFetcher = new MediaWikiVersionFetcher();
+ $this->assertInternalType( 'string', $versionFetcher->fetchVersion() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/MergeHistoryTest.php b/www/wiki/tests/phpunit/includes/MergeHistoryTest.php
new file mode 100644
index 00000000..54db581c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/MergeHistoryTest.php
@@ -0,0 +1,124 @@
+<?php
+
+/**
+ * @group Database
+ */
+class MergeHistoryTest extends MediaWikiTestCase {
+
+ /**
+ * Make some pages to work with
+ */
+ public function addDBDataOnce() {
+ // Pages that won't actually be merged
+ $this->insertPage( 'Test' );
+ $this->insertPage( 'Test2' );
+
+ // Pages that will be merged
+ $this->insertPage( 'Merge1' );
+ $this->insertPage( 'Merge2' );
+ }
+
+ /**
+ * @dataProvider provideIsValidMerge
+ * @covers MergeHistory::isValidMerge
+ * @param string $source Source page
+ * @param string $dest Destination page
+ * @param string|bool $timestamp Timestamp up to which revisions are merged (or false for all)
+ * @param string|bool $error Expected error for test (or true for no error)
+ */
+ public function testIsValidMerge( $source, $dest, $timestamp, $error ) {
+ $this->setMwGlobals( 'wgContentHandlerUseDB', false );
+ $mh = new MergeHistory(
+ Title::newFromText( $source ),
+ Title::newFromText( $dest ),
+ $timestamp
+ );
+ $status = $mh->isValidMerge();
+ if ( $error === true ) {
+ $this->assertTrue( $status->isGood() );
+ } else {
+ $this->assertTrue( $status->hasMessage( $error ) );
+ }
+ }
+
+ public static function provideIsValidMerge() {
+ return [
+ // for MergeHistory::isValidMerge
+ [ 'Test', 'Test2', false, true ],
+ // Although this timestamp is after the latest timestamp of both pages,
+ // MergeHistory should select the latest source timestamp up to this which should
+ // still work for the merge.
+ [ 'Test', 'Test2', strtotime( 'tomorrow' ), true ],
+ [ 'Test', 'Test', false, 'mergehistory-fail-self-merge' ],
+ [ 'Nonexistant', 'Test2', false, 'mergehistory-fail-invalid-source' ],
+ [ 'Test', 'Nonexistant', false, 'mergehistory-fail-invalid-dest' ],
+ [
+ 'Test',
+ 'Test2',
+ 'This is obviously an invalid timestamp',
+ 'mergehistory-fail-bad-timestamp'
+ ],
+ ];
+ }
+
+ /**
+ * Test merge revision limit checking
+ * @covers MergeHistory::isValidMerge
+ */
+ public function testIsValidMergeRevisionLimit() {
+ $limit = MergeHistory::REVISION_LIMIT;
+
+ $mh = $this->getMockBuilder( MergeHistory::class )
+ ->setMethods( [ 'getRevisionCount' ] )
+ ->setConstructorArgs( [
+ Title::newFromText( 'Test' ),
+ Title::newFromText( 'Test2' ),
+ ] )
+ ->getMock();
+ $mh->expects( $this->once() )
+ ->method( 'getRevisionCount' )
+ ->will( $this->returnValue( $limit + 1 ) );
+
+ $status = $mh->isValidMerge();
+ $this->assertTrue( $status->hasMessage( 'mergehistory-fail-toobig' ) );
+ $errors = $status->getErrorsByType( 'error' );
+ $params = $errors[0]['params'];
+ $this->assertEquals( $params[0], Message::numParam( $limit ) );
+ }
+
+ /**
+ * Test user permission checking
+ * @covers MergeHistory::checkPermissions
+ */
+ public function testCheckPermissions() {
+ $mh = new MergeHistory(
+ Title::newFromText( 'Test' ),
+ Title::newFromText( 'Test2' )
+ );
+
+ // Sysop with mergehistory permission
+ $sysop = static::getTestSysop()->getUser();
+ $status = $mh->checkPermissions( $sysop, '' );
+ $this->assertTrue( $status->isOK() );
+
+ // Normal user
+ $notSysop = static::getTestUser()->getUser();
+ $status = $mh->checkPermissions( $notSysop, '' );
+ $this->assertTrue( $status->hasMessage( 'mergehistory-fail-permission' ) );
+ }
+
+ /**
+ * Test merged revision count
+ * @covers MergeHistory::getMergedRevisionCount
+ */
+ public function testGetMergedRevisionCount() {
+ $mh = new MergeHistory(
+ Title::newFromText( 'Merge1' ),
+ Title::newFromText( 'Merge2' )
+ );
+
+ $sysop = static::getTestSysop()->getUser();
+ $mh->merge( $sysop );
+ $this->assertEquals( $mh->getMergedRevisionCount(), 1 );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/MessageTest.php b/www/wiki/tests/phpunit/includes/MessageTest.php
new file mode 100644
index 00000000..70f4af9e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/MessageTest.php
@@ -0,0 +1,855 @@
+<?php
+
+use Wikimedia\ObjectFactory;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Database
+ */
+class MessageTest extends MediaWikiLangTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgForceUIMsgAsContentMsg' => [],
+ ] );
+ $this->setUserLang( 'en' );
+ }
+
+ /**
+ * @covers Message::__construct
+ * @dataProvider provideConstructor
+ */
+ public function testConstructor( $expectedLang, $key, $params, $language ) {
+ $message = new Message( $key, $params, $language );
+
+ $this->assertSame( $key, $message->getKey() );
+ $this->assertSame( $params, $message->getParams() );
+ $this->assertEquals( $expectedLang, $message->getLanguage() );
+
+ $messageSpecifier = $this->getMockForAbstractClass( MessageSpecifier::class );
+ $messageSpecifier->expects( $this->any() )
+ ->method( 'getKey' )->will( $this->returnValue( $key ) );
+ $messageSpecifier->expects( $this->any() )
+ ->method( 'getParams' )->will( $this->returnValue( $params ) );
+ $message = new Message( $messageSpecifier, [], $language );
+
+ $this->assertSame( $key, $message->getKey() );
+ $this->assertSame( $params, $message->getParams() );
+ $this->assertEquals( $expectedLang, $message->getLanguage() );
+ }
+
+ public static function provideConstructor() {
+ $langDe = Language::factory( 'de' );
+ $langEn = Language::factory( 'en' );
+
+ return [
+ [ $langDe, 'foo', [], $langDe ],
+ [ $langDe, 'foo', [ 'bar' ], $langDe ],
+ [ $langEn, 'foo', [ 'bar' ], null ]
+ ];
+ }
+
+ public static function provideConstructorParams() {
+ return [
+ [
+ [],
+ [],
+ ],
+ [
+ [],
+ [ [] ],
+ ],
+ [
+ [ 'foo' ],
+ [ 'foo' ],
+ ],
+ [
+ [ 'foo', 'bar' ],
+ [ 'foo', 'bar' ],
+ ],
+ [
+ [ 'baz' ],
+ [ [ 'baz' ] ],
+ ],
+ [
+ [ 'baz', 'foo' ],
+ [ [ 'baz', 'foo' ] ],
+ ],
+ [
+ [ Message::rawParam( 'baz' ) ],
+ [ Message::rawParam( 'baz' ) ],
+ ],
+ [
+ [ Message::rawParam( 'baz' ), 'foo' ],
+ [ Message::rawParam( 'baz' ), 'foo' ],
+ ],
+ [
+ [ Message::rawParam( 'baz' ) ],
+ [ [ Message::rawParam( 'baz' ) ] ],
+ ],
+ [
+ [ Message::rawParam( 'baz' ), 'foo' ],
+ [ [ Message::rawParam( 'baz' ), 'foo' ] ],
+ ],
+
+ // Test handling of erroneous input, to detect if it changes
+ [
+ [ [ 'baz', 'foo' ], 'hhh' ],
+ [ [ 'baz', 'foo' ], 'hhh' ],
+ ],
+ [
+ [ [ 'baz', 'foo' ], 'hhh', [ 'ahahahahha' ] ],
+ [ [ 'baz', 'foo' ], 'hhh', [ 'ahahahahha' ] ],
+ ],
+ [
+ [ [ 'baz', 'foo' ], [ 'ahahahahha' ] ],
+ [ [ 'baz', 'foo' ], [ 'ahahahahha' ] ],
+ ],
+ [
+ [ [ 'baz' ], [ 'ahahahahha' ] ],
+ [ [ 'baz' ], [ 'ahahahahha' ] ],
+ ],
+ ];
+ }
+
+ /**
+ * @covers Message::__construct
+ * @covers Message::getParams
+ * @dataProvider provideConstructorParams
+ */
+ public function testConstructorParams( $expected, $args ) {
+ $msg = new Message( 'imasomething' );
+
+ $returned = call_user_func_array( [ $msg, 'params' ], $args );
+
+ $this->assertSame( $msg, $returned );
+ $this->assertSame( $expected, $msg->getParams() );
+ }
+
+ public static function provideConstructorLanguage() {
+ return [
+ [ 'foo', [ 'bar' ], 'en' ],
+ [ 'foo', [ 'bar' ], 'de' ]
+ ];
+ }
+
+ /**
+ * @covers Message::__construct
+ * @covers Message::getLanguage
+ * @dataProvider provideConstructorLanguage
+ */
+ public function testConstructorLanguage( $key, $params, $languageCode ) {
+ $language = Language::factory( $languageCode );
+ $message = new Message( $key, $params, $language );
+
+ $this->assertEquals( $language, $message->getLanguage() );
+ }
+
+ public static function provideKeys() {
+ return [
+ 'string' => [
+ 'key' => 'mainpage',
+ 'expected' => [ 'mainpage' ],
+ ],
+ 'single' => [
+ 'key' => [ 'mainpage' ],
+ 'expected' => [ 'mainpage' ],
+ ],
+ 'multi' => [
+ 'key' => [ 'mainpage-foo', 'mainpage-bar', 'mainpage' ],
+ 'expected' => [ 'mainpage-foo', 'mainpage-bar', 'mainpage' ],
+ ],
+ 'empty' => [
+ 'key' => [],
+ 'expected' => null,
+ 'exception' => 'InvalidArgumentException',
+ ],
+ 'null' => [
+ 'key' => null,
+ 'expected' => null,
+ 'exception' => 'InvalidArgumentException',
+ ],
+ 'bad type' => [
+ 'key' => 123,
+ 'expected' => null,
+ 'exception' => 'InvalidArgumentException',
+ ],
+ ];
+ }
+
+ /**
+ * @covers Message::__construct
+ * @covers Message::getKey
+ * @covers Message::isMultiKey
+ * @covers Message::getKeysToTry
+ * @dataProvider provideKeys
+ */
+ public function testKeys( $key, $expected, $exception = null ) {
+ if ( $exception ) {
+ $this->setExpectedException( $exception );
+ }
+
+ $msg = new Message( $key );
+ $this->assertContains( $msg->getKey(), $expected );
+ $this->assertSame( $expected, $msg->getKeysToTry() );
+ $this->assertSame( count( $expected ) > 1, $msg->isMultiKey() );
+ }
+
+ /**
+ * @covers ::wfMessage
+ */
+ public function testWfMessage() {
+ $this->assertInstanceOf( Message::class, wfMessage( 'mainpage' ) );
+ $this->assertInstanceOf( Message::class, wfMessage( 'i-dont-exist-evar' ) );
+ }
+
+ /**
+ * @covers Message::newFromKey
+ */
+ public function testNewFromKey() {
+ $this->assertInstanceOf( Message::class, Message::newFromKey( 'mainpage' ) );
+ $this->assertInstanceOf( Message::class, Message::newFromKey( 'i-dont-exist-evar' ) );
+ }
+
+ /**
+ * @covers ::wfMessage
+ * @covers Message::__construct
+ */
+ public function testWfMessageParams() {
+ $this->assertSame( 'Return to $1.', wfMessage( 'returnto' )->text() );
+ $this->assertSame( 'Return to $1.', wfMessage( 'returnto', [] )->text() );
+ $this->assertSame(
+ 'Return to 1,024.',
+ wfMessage( 'returnto', Message::numParam( 1024 ) )->text()
+ );
+ $this->assertSame(
+ 'Return to 1,024.',
+ wfMessage( 'returnto', [ Message::numParam( 1024 ) ] )->text()
+ );
+ $this->assertSame(
+ 'You have foo (bar).',
+ wfMessage( 'youhavenewmessages', 'foo', 'bar' )->text()
+ );
+ $this->assertSame(
+ 'You have foo (bar).',
+ wfMessage( 'youhavenewmessages', [ 'foo', 'bar' ] )->text()
+ );
+ $this->assertSame(
+ 'You have 1,024 (bar).',
+ wfMessage(
+ 'youhavenewmessages',
+ Message::numParam( 1024 ), 'bar'
+ )->text()
+ );
+ $this->assertSame(
+ 'You have foo (2,048).',
+ wfMessage(
+ 'youhavenewmessages',
+ 'foo', Message::numParam( 2048 )
+ )->text()
+ );
+ $this->assertSame(
+ 'You have 1,024 (2,048).',
+ wfMessage(
+ 'youhavenewmessages',
+ [ Message::numParam( 1024 ), Message::numParam( 2048 ) ]
+ )->text()
+ );
+ }
+
+ /**
+ * @covers Message::exists
+ */
+ public function testExists() {
+ $this->assertTrue( wfMessage( 'mainpage' )->exists() );
+ $this->assertTrue( wfMessage( 'mainpage' )->params( [] )->exists() );
+ $this->assertTrue( wfMessage( 'mainpage' )->rawParams( 'foo', 123 )->exists() );
+ $this->assertFalse( wfMessage( 'i-dont-exist-evar' )->exists() );
+ $this->assertFalse( wfMessage( 'i-dont-exist-evar' )->params( [] )->exists() );
+ $this->assertFalse( wfMessage( 'i-dont-exist-evar' )->rawParams( 'foo', 123 )->exists() );
+ }
+
+ /**
+ * @covers Message::__construct
+ * @covers Message::text
+ * @covers Message::plain
+ * @covers Message::escaped
+ * @covers Message::toString
+ */
+ public function testToStringKey() {
+ $this->assertSame( 'Main Page', wfMessage( 'mainpage' )->text() );
+ $this->assertSame( '⧼i-dont-exist-evar⧽', wfMessage( 'i-dont-exist-evar' )->text() );
+ $this->assertSame( '⧼i&lt;dont&gt;exist-evar⧽', wfMessage( 'i<dont>exist-evar' )->text() );
+ $this->assertSame( '⧼i-dont-exist-evar⧽', wfMessage( 'i-dont-exist-evar' )->plain() );
+ $this->assertSame( '⧼i&lt;dont&gt;exist-evar⧽', wfMessage( 'i<dont>exist-evar' )->plain() );
+ $this->assertSame( '⧼i-dont-exist-evar⧽', wfMessage( 'i-dont-exist-evar' )->escaped() );
+ $this->assertSame(
+ '⧼i&lt;dont&gt;exist-evar⧽',
+ wfMessage( 'i<dont>exist-evar' )->escaped()
+ );
+ }
+
+ public static function provideToString() {
+ return [
+ // key, transformation, transformed, transformed implicitly
+ [ 'mainpage', 'plain', 'Main Page', 'Main Page' ],
+ [ 'i-dont-exist-evar', 'plain', '⧼i-dont-exist-evar⧽', '⧼i-dont-exist-evar⧽' ],
+ [ 'i-dont-exist-evar', 'escaped', '⧼i-dont-exist-evar⧽', '⧼i-dont-exist-evar⧽' ],
+ [ 'script>alert(1)</script', 'escaped', '⧼script&gt;alert(1)&lt;/script⧽',
+ '⧼script&gt;alert(1)&lt;/script⧽' ],
+ [ 'script>alert(1)</script', 'plain', '⧼script&gt;alert(1)&lt;/script⧽',
+ '⧼script&gt;alert(1)&lt;/script⧽' ],
+ ];
+ }
+
+ /**
+ * @covers Message::toString
+ * @covers Message::__toString
+ * @dataProvider provideToString
+ */
+ public function testToString( $key, $format, $expect, $expectImplicit ) {
+ $msg = new Message( $key );
+ $this->assertSame( $expect, $msg->$format() );
+ $this->assertSame( $expect, $msg->toString(), 'toString is unaffected by previous call' );
+ $this->assertSame( $expectImplicit, $msg->__toString() );
+ $this->assertSame( $expect, $msg->toString(), 'toString is unaffected by __toString' );
+ }
+
+ public static function provideToString_raw() {
+ return [
+ [ '<span>foo</span>', 'parse', '<span>foo</span>', '<span>foo</span>' ],
+ [ '<span>foo</span>', 'escaped', '&lt;span&gt;foo&lt;/span&gt;',
+ '<span>foo</span>' ],
+ [ '<span>foo</span>', 'plain', '<span>foo</span>', '<span>foo</span>' ],
+ [ '<script>alert(1)</script>', 'parse', '&lt;script&gt;alert(1)&lt;/script&gt;',
+ '&lt;script&gt;alert(1)&lt;/script&gt;' ],
+ [ '<script>alert(1)</script>', 'escaped', '&lt;script&gt;alert(1)&lt;/script&gt;',
+ '&lt;script&gt;alert(1)&lt;/script&gt;' ],
+ [ '<script>alert(1)</script>', 'plain', '<script>alert(1)</script>',
+ '&lt;script&gt;alert(1)&lt;/script&gt;' ],
+ ];
+ }
+
+ /**
+ * @covers Message::toString
+ * @covers Message::__toString
+ * @dataProvider provideToString_raw
+ */
+ public function testToString_raw( $message, $format, $expect, $expectImplicit ) {
+ // make the message behave like RawMessage and use the key as-is
+ $msg = $this->getMockBuilder( Message::class )->setMethods( [ 'fetchMessage' ] )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $msg->expects( $this->any() )->method( 'fetchMessage' )->willReturn( $message );
+ /** @var Message $msg */
+ $this->assertSame( $expect, $msg->$format() );
+ $this->assertSame( $expect, $msg->toString(), 'toString is unaffected by previous call' );
+ $this->assertSame( $expectImplicit, $msg->__toString() );
+ $this->assertSame( $expect, $msg->toString(), 'toString is unaffected by __toString' );
+ }
+
+ /**
+ * @covers Message::inLanguage
+ */
+ public function testInLanguage() {
+ $this->assertSame( 'Main Page', wfMessage( 'mainpage' )->inLanguage( 'en' )->text() );
+ $this->assertSame( 'Заглавная страница',
+ wfMessage( 'mainpage' )->inLanguage( 'ru' )->text() );
+
+ // NOTE: make sure internal caching of the message text is reset appropriately
+ $msg = wfMessage( 'mainpage' );
+ $this->assertSame( 'Main Page', $msg->inLanguage( Language::factory( 'en' ) )->text() );
+ $this->assertSame(
+ 'Заглавная страница',
+ $msg->inLanguage( Language::factory( 'ru' ) )->text()
+ );
+ }
+
+ /**
+ * @covers Message::rawParam
+ * @covers Message::rawParams
+ */
+ public function testRawParams() {
+ $this->assertSame(
+ '(Заглавная страница)',
+ wfMessage( 'parentheses', 'Заглавная страница' )->plain()
+ );
+ $this->assertSame(
+ '(Заглавная страница $1)',
+ wfMessage( 'parentheses', 'Заглавная страница $1' )->plain()
+ );
+ $this->assertSame(
+ '(Заглавная страница)',
+ wfMessage( 'parentheses' )->rawParams( 'Заглавная страница' )->plain()
+ );
+ $this->assertSame(
+ '(Заглавная страница $1)',
+ wfMessage( 'parentheses' )->rawParams( 'Заглавная страница $1' )->plain()
+ );
+ }
+
+ /**
+ * @covers RawMessage::__construct
+ * @covers RawMessage::fetchMessage
+ */
+ public function testRawMessage() {
+ $msg = new RawMessage( 'example &' );
+ $this->assertSame( 'example &', $msg->plain() );
+ $this->assertSame( 'example &amp;', $msg->escaped() );
+ }
+
+ public function testRawHtmlInMsg() {
+ global $wgParserConf;
+ $this->setMwGlobals( 'wgRawHtml', true );
+ // We have to reset the core hook registration.
+ // to register the html hook
+ MessageCache::destroyInstance();
+ $this->setMwGlobals( 'wgParser',
+ ObjectFactory::constructClassInstance( $wgParserConf['class'], [ $wgParserConf ] )
+ );
+
+ $msg = new RawMessage( '<html><script>alert("xss")</script></html>' );
+ $txt = '<span class="error">&lt;html&gt; tags cannot be' .
+ ' used outside of normal pages.</span>';
+ $this->assertSame( $txt, $msg->parse() );
+ }
+
+ /**
+ * @covers Message::params
+ * @covers Message::toString
+ * @covers Message::replaceParameters
+ */
+ public function testReplaceManyParams() {
+ $msg = new RawMessage( '$1$2$3$4$5$6$7$8$9$10$11$12' );
+ // One less than above has placeholders
+ $params = [ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k' ];
+ $this->assertSame(
+ 'abcdefghijka2',
+ $msg->params( $params )->plain(),
+ 'Params > 9 are replaced correctly'
+ );
+
+ $msg = new RawMessage( 'Params$*' );
+ $params = [ 'ab', 'bc', 'cd' ];
+ $this->assertSame(
+ 'Params: ab, bc, cd',
+ $msg->params( $params )->text()
+ );
+ }
+
+ /**
+ * @covers Message::numParam
+ * @covers Message::numParams
+ */
+ public function testNumParams() {
+ $lang = Language::factory( 'en' );
+ $msg = new RawMessage( '$1' );
+
+ $this->assertSame(
+ $lang->formatNum( 123456.789 ),
+ $msg->inLanguage( $lang )->numParams( 123456.789 )->plain(),
+ 'numParams is handled correctly'
+ );
+ }
+
+ /**
+ * @covers Message::durationParam
+ * @covers Message::durationParams
+ */
+ public function testDurationParams() {
+ $lang = Language::factory( 'en' );
+ $msg = new RawMessage( '$1' );
+
+ $this->assertSame(
+ $lang->formatDuration( 1234 ),
+ $msg->inLanguage( $lang )->durationParams( 1234 )->plain(),
+ 'durationParams is handled correctly'
+ );
+ }
+
+ /**
+ * FIXME: This should not need database, but Language#formatExpiry does (T57912)
+ * @covers Message::expiryParam
+ * @covers Message::expiryParams
+ */
+ public function testExpiryParams() {
+ $lang = Language::factory( 'en' );
+ $msg = new RawMessage( '$1' );
+
+ $this->assertSame(
+ $lang->formatExpiry( wfTimestampNow() ),
+ $msg->inLanguage( $lang )->expiryParams( wfTimestampNow() )->plain(),
+ 'expiryParams is handled correctly'
+ );
+ }
+
+ /**
+ * @covers Message::timeperiodParam
+ * @covers Message::timeperiodParams
+ */
+ public function testTimeperiodParams() {
+ $lang = Language::factory( 'en' );
+ $msg = new RawMessage( '$1' );
+
+ $this->assertSame(
+ $lang->formatTimePeriod( 1234 ),
+ $msg->inLanguage( $lang )->timeperiodParams( 1234 )->plain(),
+ 'timeperiodParams is handled correctly'
+ );
+ }
+
+ /**
+ * @covers Message::sizeParam
+ * @covers Message::sizeParams
+ */
+ public function testSizeParams() {
+ $lang = Language::factory( 'en' );
+ $msg = new RawMessage( '$1' );
+
+ $this->assertSame(
+ $lang->formatSize( 123456 ),
+ $msg->inLanguage( $lang )->sizeParams( 123456 )->plain(),
+ 'sizeParams is handled correctly'
+ );
+ }
+
+ /**
+ * @covers Message::bitrateParam
+ * @covers Message::bitrateParams
+ */
+ public function testBitrateParams() {
+ $lang = Language::factory( 'en' );
+ $msg = new RawMessage( '$1' );
+
+ $this->assertSame(
+ $lang->formatBitrate( 123456 ),
+ $msg->inLanguage( $lang )->bitrateParams( 123456 )->plain(),
+ 'bitrateParams is handled correctly'
+ );
+ }
+
+ public static function providePlaintextParams() {
+ return [
+ [
+ 'one $2 <div>foo</div> [[Bar]] {{Baz}} &lt;',
+ 'plain',
+ ],
+
+ [
+ // expect
+ 'one $2 <div>foo</div> [[Bar]] {{Baz}} &lt;',
+ // format
+ 'text',
+ ],
+ [
+ 'one $2 &lt;div&gt;foo&lt;/div&gt; [[Bar]] {{Baz}} &amp;lt;',
+ 'escaped',
+ ],
+
+ [
+ 'one $2 &lt;div&gt;foo&lt;/div&gt; [[Bar]] {{Baz}} &amp;lt;',
+ 'parse',
+ ],
+
+ [
+ "<p>one $2 &lt;div&gt;foo&lt;/div&gt; [[Bar]] {{Baz}} &amp;lt;\n</p>",
+ 'parseAsBlock',
+ ],
+ ];
+ }
+
+ /**
+ * @covers Message::plaintextParam
+ * @covers Message::plaintextParams
+ * @covers Message::formatPlaintext
+ * @covers Message::toString
+ * @covers Message::parse
+ * @covers Message::parseAsBlock
+ * @dataProvider providePlaintextParams
+ */
+ public function testPlaintextParams( $expect, $format ) {
+ $lang = Language::factory( 'en' );
+
+ $msg = new RawMessage( '$1 $2' );
+ $params = [
+ 'one $2',
+ '<div>foo</div> [[Bar]] {{Baz}} &lt;',
+ ];
+ $this->assertSame(
+ $expect,
+ $msg->inLanguage( $lang )->plaintextParams( $params )->$format(),
+ "Fail formatting for $format"
+ );
+ }
+
+ public static function provideListParam() {
+ $lang = Language::factory( 'de' );
+ $msg1 = new Message( 'mainpage', [], $lang );
+ $msg2 = new RawMessage( "''link''", [], $lang );
+
+ return [
+ 'Simple comma list' => [
+ [ 'a', 'b', 'c' ],
+ 'comma',
+ 'text',
+ 'a, b, c'
+ ],
+
+ 'Simple semicolon list' => [
+ [ 'a', 'b', 'c' ],
+ 'semicolon',
+ 'text',
+ 'a; b; c'
+ ],
+
+ 'Simple pipe list' => [
+ [ 'a', 'b', 'c' ],
+ 'pipe',
+ 'text',
+ 'a | b | c'
+ ],
+
+ 'Simple text list' => [
+ [ 'a', 'b', 'c' ],
+ 'text',
+ 'text',
+ 'a, b and c'
+ ],
+
+ 'Empty list' => [
+ [],
+ 'comma',
+ 'text',
+ ''
+ ],
+
+ 'List with all "before" params, ->text()' => [
+ [ "''link''", Message::numParam( 12345678 ) ],
+ 'semicolon',
+ 'text',
+ '\'\'link\'\'; 12,345,678'
+ ],
+
+ 'List with all "before" params, ->parse()' => [
+ [ "''link''", Message::numParam( 12345678 ) ],
+ 'semicolon',
+ 'parse',
+ '<i>link</i>; 12,345,678'
+ ],
+
+ 'List with all "after" params, ->text()' => [
+ [ $msg1, $msg2, Message::rawParam( '[[foo]]' ) ],
+ 'semicolon',
+ 'text',
+ 'Main Page; \'\'link\'\'; [[foo]]'
+ ],
+
+ 'List with all "after" params, ->parse()' => [
+ [ $msg1, $msg2, Message::rawParam( '[[foo]]' ) ],
+ 'semicolon',
+ 'parse',
+ 'Main Page; <i>link</i>; [[foo]]'
+ ],
+
+ 'List with both "before" and "after" params, ->text()' => [
+ [ $msg1, $msg2, Message::rawParam( '[[foo]]' ), "''link''", Message::numParam( 12345678 ) ],
+ 'semicolon',
+ 'text',
+ 'Main Page; \'\'link\'\'; [[foo]]; \'\'link\'\'; 12,345,678'
+ ],
+
+ 'List with both "before" and "after" params, ->parse()' => [
+ [ $msg1, $msg2, Message::rawParam( '[[foo]]' ), "''link''", Message::numParam( 12345678 ) ],
+ 'semicolon',
+ 'parse',
+ 'Main Page; <i>link</i>; [[foo]]; <i>link</i>; 12,345,678'
+ ],
+ ];
+ }
+
+ /**
+ * @covers Message::listParam
+ * @covers Message::extractParam
+ * @covers Message::formatListParam
+ * @dataProvider provideListParam
+ */
+ public function testListParam( $list, $type, $format, $expect ) {
+ $lang = Language::factory( 'en' );
+
+ $msg = new RawMessage( '$1' );
+ $msg->params( [ Message::listParam( $list, $type ) ] );
+ $this->assertEquals(
+ $expect,
+ $msg->inLanguage( $lang )->$format()
+ );
+ }
+
+ /**
+ * @covers Message::extractParam
+ */
+ public function testMessageAsParam() {
+ $this->setMwGlobals( [
+ 'wgScript' => '/wiki/index.php',
+ 'wgArticlePath' => '/wiki/$1',
+ ] );
+
+ $msg = new Message( 'returnto', [
+ new Message( 'apihelp-link', [
+ 'foo', new Message( 'mainpage', [], Language::factory( 'en' ) )
+ ], Language::factory( 'de' ) )
+ ], Language::factory( 'es' ) );
+
+ $this->assertEquals(
+ 'Volver a [[Special:ApiHelp/foo|Página principal]].',
+ $msg->text(),
+ 'Process with ->text()'
+ );
+ $this->assertEquals(
+ '<p>Volver a <a href="/wiki/Special:ApiHelp/foo" title="Special:ApiHelp/foo">Página '
+ . "principal</a>.\n</p>",
+ $msg->parseAsBlock(),
+ 'Process with ->parseAsBlock()'
+ );
+ }
+
+ public static function provideParser() {
+ return [
+ [
+ "''&'' <x><!-- x -->",
+ 'plain',
+ ],
+
+ [
+ "''&'' <x><!-- x -->",
+ 'text',
+ ],
+ [
+ '<i>&amp;</i> &lt;x&gt;',
+ 'parse',
+ ],
+
+ [
+ "<p><i>&amp;</i> &lt;x&gt;\n</p>",
+ 'parseAsBlock',
+ ],
+ ];
+ }
+
+ /**
+ * @covers Message::text
+ * @covers Message::parse
+ * @covers Message::parseAsBlock
+ * @covers Message::toString
+ * @covers Message::transformText
+ * @covers Message::parseText
+ * @dataProvider provideParser
+ */
+ public function testParser( $expect, $format ) {
+ $msg = new RawMessage( "''&'' <x><!-- x -->" );
+ $this->assertSame(
+ $expect,
+ $msg->inLanguage( 'en' )->$format()
+ );
+ }
+
+ /**
+ * @covers Message::inContentLanguage
+ */
+ public function testInContentLanguage() {
+ $this->setUserLang( 'fr' );
+
+ // NOTE: make sure internal caching of the message text is reset appropriately
+ $msg = wfMessage( 'mainpage' );
+ $this->assertSame( 'Hauptseite', $msg->inLanguage( 'de' )->plain(), "inLanguage( 'de' )" );
+ $this->assertSame( 'Main Page', $msg->inContentLanguage()->plain(), "inContentLanguage()" );
+ $this->assertSame( 'Accueil', $msg->inLanguage( 'fr' )->plain(), "inLanguage( 'fr' )" );
+ }
+
+ /**
+ * @covers Message::inContentLanguage
+ */
+ public function testInContentLanguageOverride() {
+ $this->setMwGlobals( [
+ 'wgForceUIMsgAsContentMsg' => [ 'mainpage' ],
+ ] );
+ $this->setUserLang( 'fr' );
+
+ // NOTE: make sure internal caching of the message text is reset appropriately.
+ // NOTE: wgForceUIMsgAsContentMsg forces the messages *current* language to be used.
+ $msg = wfMessage( 'mainpage' );
+ $this->assertSame(
+ 'Accueil',
+ $msg->inContentLanguage()->plain(),
+ 'inContentLanguage() with ForceUIMsg override enabled'
+ );
+ $this->assertSame( 'Main Page', $msg->inLanguage( 'en' )->plain(), "inLanguage( 'en' )" );
+ $this->assertSame(
+ 'Main Page',
+ $msg->inContentLanguage()->plain(),
+ 'inContentLanguage() with ForceUIMsg override enabled'
+ );
+ $this->assertSame( 'Hauptseite', $msg->inLanguage( 'de' )->plain(), "inLanguage( 'de' )" );
+ }
+
+ /**
+ * @expectedException MWException
+ * @covers Message::inLanguage
+ */
+ public function testInLanguageThrows() {
+ wfMessage( 'foo' )->inLanguage( 123 );
+ }
+
+ /**
+ * @covers Message::serialize
+ * @covers Message::unserialize
+ */
+ public function testSerialization() {
+ $msg = new Message( 'parentheses' );
+ $msg->rawParams( '<a>foo</a>' );
+ $msg->title( Title::newFromText( 'Testing' ) );
+ $this->assertSame( '(<a>foo</a>)', $msg->parse(), 'Sanity check' );
+ $msg = unserialize( serialize( $msg ) );
+ $this->assertSame( '(<a>foo</a>)', $msg->parse() );
+ $title = TestingAccessWrapper::newFromObject( $msg )->title;
+ $this->assertInstanceOf( Title::class, $title );
+ $this->assertSame( 'Testing', $title->getFullText() );
+
+ $msg = new Message( 'mainpage' );
+ $msg->inLanguage( 'de' );
+ $this->assertSame( 'Hauptseite', $msg->plain(), 'Sanity check' );
+ $msg = unserialize( serialize( $msg ) );
+ $this->assertSame( 'Hauptseite', $msg->plain() );
+ }
+
+ /**
+ * @covers Message::newFromSpecifier
+ * @dataProvider provideNewFromSpecifier
+ */
+ public function testNewFromSpecifier( $value, $expectedText ) {
+ $message = Message::newFromSpecifier( $value );
+ $this->assertInstanceOf( Message::class, $message );
+ if ( $value instanceof Message ) {
+ $this->assertInstanceOf( get_class( $value ), $message );
+ $this->assertEquals( $value, $message );
+ }
+ $this->assertSame( $expectedText, $message->text() );
+ }
+
+ public function provideNewFromSpecifier() {
+ $messageSpecifier = $this->getMockForAbstractClass( MessageSpecifier::class );
+ $messageSpecifier->expects( $this->any() )->method( 'getKey' )->willReturn( 'mainpage' );
+ $messageSpecifier->expects( $this->any() )->method( 'getParams' )->willReturn( [] );
+
+ return [
+ 'string' => [ 'mainpage', 'Main Page' ],
+ 'array' => [ [ 'youhavenewmessages', 'foo', 'bar' ], 'You have foo (bar).' ],
+ 'Message' => [ new Message( 'youhavenewmessages', [ 'foo', 'bar' ] ), 'You have foo (bar).' ],
+ 'RawMessage' => [ new RawMessage( 'foo ($1)', [ 'bar' ] ), 'foo (bar)' ],
+ 'ApiMessage' => [ new ApiMessage( [ 'mainpage' ], 'code', [ 'data' ] ), 'Main Page' ],
+ 'MessageSpecifier' => [ $messageSpecifier, 'Main Page' ],
+ 'nested RawMessage' => [ [ new RawMessage( 'foo ($1)', [ 'bar' ] ) ], 'foo (bar)' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/MovePageTest.php b/www/wiki/tests/phpunit/includes/MovePageTest.php
new file mode 100644
index 00000000..2bde97b6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/MovePageTest.php
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * @group Database
+ */
+class MovePageTest extends MediaWikiTestCase {
+
+ /**
+ * @dataProvider provideIsValidMove
+ * @covers MovePage::isValidMove
+ * @covers MovePage::isValidFileMove
+ */
+ public function testIsValidMove( $old, $new, $error ) {
+ $this->setMwGlobals( 'wgContentHandlerUseDB', false );
+ $mp = new MovePage(
+ Title::newFromText( $old ),
+ Title::newFromText( $new )
+ );
+ $status = $mp->isValidMove();
+ if ( $error === true ) {
+ $this->assertTrue( $status->isGood() );
+ } else {
+ $this->assertTrue( $status->hasMessage( $error ) );
+ }
+ }
+
+ /**
+ * This should be kept in sync with TitleTest::provideTestIsValidMoveOperation
+ */
+ public static function provideIsValidMove() {
+ return [
+ // for MovePage::isValidMove
+ [ 'Test', 'Test', 'selfmove' ],
+ [ 'Special:FooBar', 'Test', 'immobile-source-namespace' ],
+ [ 'Test', 'Special:FooBar', 'immobile-target-namespace' ],
+ [ 'MediaWiki:Common.js', 'Help:Some wikitext page', 'bad-target-model' ],
+ [ 'Page', 'File:Test.jpg', 'nonfile-cannot-move-to-file' ],
+ // for MovePage::isValidFileMove
+ [ 'File:Test.jpg', 'Page', 'imagenocrossnamespace' ],
+ ];
+ }
+
+ /**
+ * Integration test to catch regressions like T74870. Taken and modified
+ * from SemanticMediaWiki
+ */
+ public function testTitleMoveCompleteIntegrationTest() {
+ $oldTitle = Title::newFromText( 'Help:Some title' );
+ WikiPage::factory( $oldTitle )->doEditContent( new WikitextContent( 'foo' ), 'bar' );
+ $newTitle = Title::newFromText( 'Help:Some other title' );
+ $this->assertNull(
+ WikiPage::factory( $newTitle )->getRevision()
+ );
+
+ $this->assertTrue( $oldTitle->moveTo( $newTitle, false, 'test1', true ) );
+ $this->assertNotNull(
+ WikiPage::factory( $oldTitle )->getRevision()
+ );
+ $this->assertNotNull(
+ WikiPage::factory( $newTitle )->getRevision()
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/OutputPageTest.php b/www/wiki/tests/phpunit/includes/OutputPageTest.php
new file mode 100644
index 00000000..88c585fe
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/OutputPageTest.php
@@ -0,0 +1,707 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ *
+ * @author Matthew Flaschen
+ *
+ * @group Database
+ * @group Output
+ *
+ * @todo factor tests in this class into providers and test methods
+ */
+class OutputPageTest extends MediaWikiTestCase {
+ const SCREEN_MEDIA_QUERY = 'screen and (min-width: 982px)';
+ const SCREEN_ONLY_MEDIA_QUERY = 'only screen and (min-width: 982px)';
+
+ /**
+ * @covers OutputPage::addMeta
+ * @covers OutputPage::getMetaTags
+ * @covers OutputPage::getHeadLinksArray
+ */
+ public function testMetaTags() {
+ $outputPage = $this->newInstance();
+ $outputPage->addMeta( 'http:expires', '0' );
+ $outputPage->addMeta( 'keywords', 'first' );
+ $outputPage->addMeta( 'keywords', 'second' );
+ $outputPage->addMeta( 'og:title', 'Ta-duh' );
+
+ $expected = [
+ [ 'http:expires', '0' ],
+ [ 'keywords', 'first' ],
+ [ 'keywords', 'second' ],
+ [ 'og:title', 'Ta-duh' ],
+ ];
+ $this->assertSame( $expected, $outputPage->getMetaTags() );
+
+ $links = $outputPage->getHeadLinksArray();
+ $this->assertContains( '<meta http-equiv="expires" content="0"/>', $links );
+ $this->assertContains( '<meta name="keywords" content="first"/>', $links );
+ $this->assertContains( '<meta name="keywords" content="second"/>', $links );
+ $this->assertContains( '<meta property="og:title" content="Ta-duh"/>', $links );
+ $this->assertArrayNotHasKey( 'meta-robots', $links );
+ }
+
+ /**
+ * @covers OutputPage::setIndexPolicy
+ * @covers OutputPage::setFollowPolicy
+ * @covers OutputPage::getHeadLinksArray
+ */
+ public function testRobotsPolicies() {
+ $outputPage = $this->newInstance();
+ $outputPage->setIndexPolicy( 'noindex' );
+ $outputPage->setFollowPolicy( 'nofollow' );
+
+ $links = $outputPage->getHeadLinksArray();
+ $this->assertContains( '<meta name="robots" content="noindex,nofollow"/>', $links );
+ }
+
+ /**
+ * Tests a particular case of transformCssMedia, using the given input, globals,
+ * expected return, and message
+ *
+ * Asserts that $expectedReturn is returned.
+ *
+ * options['printableQuery'] - value of query string for printable, or omitted for none
+ * options['handheldQuery'] - value of query string for handheld, or omitted for none
+ * options['media'] - passed into the method under the same name
+ * options['expectedReturn'] - expected return value
+ * options['message'] - PHPUnit message for assertion
+ *
+ * @param array $args Key-value array of arguments as shown above
+ */
+ protected function assertTransformCssMediaCase( $args ) {
+ $queryData = [];
+ if ( isset( $args['printableQuery'] ) ) {
+ $queryData['printable'] = $args['printableQuery'];
+ }
+
+ if ( isset( $args['handheldQuery'] ) ) {
+ $queryData['handheld'] = $args['handheldQuery'];
+ }
+
+ $fauxRequest = new FauxRequest( $queryData, false );
+ $this->setMwGlobals( [
+ 'wgRequest' => $fauxRequest,
+ ] );
+
+ $actualReturn = OutputPage::transformCssMedia( $args['media'] );
+ $this->assertSame( $args['expectedReturn'], $actualReturn, $args['message'] );
+ }
+
+ /**
+ * Tests print requests
+ * @covers OutputPage::transformCssMedia
+ */
+ public function testPrintRequests() {
+ $this->assertTransformCssMediaCase( [
+ 'printableQuery' => '1',
+ 'media' => 'screen',
+ 'expectedReturn' => null,
+ 'message' => 'On printable request, screen returns null'
+ ] );
+
+ $this->assertTransformCssMediaCase( [
+ 'printableQuery' => '1',
+ 'media' => self::SCREEN_MEDIA_QUERY,
+ 'expectedReturn' => null,
+ 'message' => 'On printable request, screen media query returns null'
+ ] );
+
+ $this->assertTransformCssMediaCase( [
+ 'printableQuery' => '1',
+ 'media' => self::SCREEN_ONLY_MEDIA_QUERY,
+ 'expectedReturn' => null,
+ 'message' => 'On printable request, screen media query with only returns null'
+ ] );
+
+ $this->assertTransformCssMediaCase( [
+ 'printableQuery' => '1',
+ 'media' => 'print',
+ 'expectedReturn' => '',
+ 'message' => 'On printable request, media print returns empty string'
+ ] );
+ }
+
+ /**
+ * Tests screen requests, without either query parameter set
+ * @covers OutputPage::transformCssMedia
+ */
+ public function testScreenRequests() {
+ $this->assertTransformCssMediaCase( [
+ 'media' => 'screen',
+ 'expectedReturn' => 'screen',
+ 'message' => 'On screen request, screen media type is preserved'
+ ] );
+
+ $this->assertTransformCssMediaCase( [
+ 'media' => 'handheld',
+ 'expectedReturn' => 'handheld',
+ 'message' => 'On screen request, handheld media type is preserved'
+ ] );
+
+ $this->assertTransformCssMediaCase( [
+ 'media' => self::SCREEN_MEDIA_QUERY,
+ 'expectedReturn' => self::SCREEN_MEDIA_QUERY,
+ 'message' => 'On screen request, screen media query is preserved.'
+ ] );
+
+ $this->assertTransformCssMediaCase( [
+ 'media' => self::SCREEN_ONLY_MEDIA_QUERY,
+ 'expectedReturn' => self::SCREEN_ONLY_MEDIA_QUERY,
+ 'message' => 'On screen request, screen media query with only is preserved.'
+ ] );
+
+ $this->assertTransformCssMediaCase( [
+ 'media' => 'print',
+ 'expectedReturn' => 'print',
+ 'message' => 'On screen request, print media type is preserved'
+ ] );
+ }
+
+ /**
+ * Tests handheld behavior
+ * @covers OutputPage::transformCssMedia
+ */
+ public function testHandheld() {
+ $this->assertTransformCssMediaCase( [
+ 'handheldQuery' => '1',
+ 'media' => 'handheld',
+ 'expectedReturn' => '',
+ 'message' => 'On request with handheld querystring and media is handheld, returns empty string'
+ ] );
+
+ $this->assertTransformCssMediaCase( [
+ 'handheldQuery' => '1',
+ 'media' => 'screen',
+ 'expectedReturn' => null,
+ 'message' => 'On request with handheld querystring and media is screen, returns null'
+ ] );
+ }
+
+ public static function provideTransformFilePath() {
+ $baseDir = dirname( __DIR__ ) . '/data/media';
+ return [
+ // File that matches basePath, and exists. Hash found and appended.
+ [
+ 'baseDir' => $baseDir, 'basePath' => '/w',
+ '/w/test.jpg',
+ '/w/test.jpg?edcf2'
+ ],
+ // File that matches basePath, but not found on disk. Empty query.
+ [
+ 'baseDir' => $baseDir, 'basePath' => '/w',
+ '/w/unknown.png',
+ '/w/unknown.png?'
+ ],
+ // File not matching basePath. Ignored.
+ [
+ 'baseDir' => $baseDir, 'basePath' => '/w',
+ '/files/test.jpg'
+ ],
+ // Empty string. Ignored.
+ [
+ 'baseDir' => $baseDir, 'basePath' => '/w',
+ '',
+ ''
+ ],
+ // Similar path, but with domain component. Ignored.
+ [
+ 'baseDir' => $baseDir, 'basePath' => '/w',
+ '//example.org/w/test.jpg'
+ ],
+ [
+ 'baseDir' => $baseDir, 'basePath' => '/w',
+ 'https://example.org/w/test.jpg'
+ ],
+ // Unrelated path with domain component. Ignored.
+ [
+ 'baseDir' => $baseDir, 'basePath' => '/w',
+ 'https://example.org/files/test.jpg'
+ ],
+ [
+ 'baseDir' => $baseDir, 'basePath' => '/w',
+ '//example.org/files/test.jpg'
+ ],
+ // Unrelated path with domain, and empty base path (root mw install). Ignored.
+ [
+ 'baseDir' => $baseDir, 'basePath' => '',
+ 'https://example.org/files/test.jpg'
+ ],
+ [
+ 'baseDir' => $baseDir, 'basePath' => '',
+ // T155310
+ '//example.org/files/test.jpg'
+ ],
+ // Check UploadPath before ResourceBasePath (T155146)
+ [
+ 'baseDir' => dirname( $baseDir ), 'basePath' => '',
+ 'uploadDir' => $baseDir, 'uploadPath' => '/images',
+ '/images/test.jpg',
+ '/images/test.jpg?edcf2'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideTransformFilePath
+ * @covers OutputPage::transformFilePath
+ * @covers OutputPage::transformResourcePath
+ */
+ public function testTransformResourcePath( $baseDir, $basePath, $uploadDir = null,
+ $uploadPath = null, $path = null, $expected = null
+ ) {
+ if ( $path === null ) {
+ // Skip optional $uploadDir and $uploadPath
+ $path = $uploadDir;
+ $expected = $uploadPath;
+ $uploadDir = "$baseDir/images";
+ $uploadPath = "$basePath/images";
+ }
+ $this->setMwGlobals( 'IP', $baseDir );
+ $conf = new HashConfig( [
+ 'ResourceBasePath' => $basePath,
+ 'UploadDirectory' => $uploadDir,
+ 'UploadPath' => $uploadPath,
+ ] );
+
+ Wikimedia\suppressWarnings();
+ $actual = OutputPage::transformResourcePath( $conf, $path );
+ Wikimedia\restoreWarnings();
+
+ $this->assertEquals( $expected ?: $path, $actual );
+ }
+
+ public static function provideMakeResourceLoaderLink() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ // Single only=scripts load
+ [
+ [ 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS ],
+ "<script>(window.RLQ=window.RLQ||[]).push(function(){"
+ . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.foo\u0026only=scripts\u0026skin=fallback");'
+ . "});</script>"
+ ],
+ // Multiple only=styles load
+ [
+ [ [ 'test.baz', 'test.foo', 'test.bar' ], ResourceLoaderModule::TYPE_STYLES ],
+
+ '<link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?debug=false&amp;lang=en&amp;modules=test.bar%2Cbaz%2Cfoo&amp;only=styles&amp;skin=fallback"/>'
+ ],
+ // Private embed (only=scripts)
+ [
+ [ 'test.quux', ResourceLoaderModule::TYPE_SCRIPTS ],
+ "<script>(window.RLQ=window.RLQ||[]).push(function(){"
+ . "mw.test.baz({token:123});\nmw.loader.state({\"test.quux\":\"ready\"});"
+ . "});</script>"
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * See ResourceLoaderClientHtmlTest for full coverage.
+ *
+ * @dataProvider provideMakeResourceLoaderLink
+ * @covers OutputPage::makeResourceLoaderLink
+ */
+ public function testMakeResourceLoaderLink( $args, $expectedHtml ) {
+ $this->setMwGlobals( [
+ 'wgResourceLoaderDebug' => false,
+ 'wgLoadScript' => 'http://127.0.0.1:8080/w/load.php',
+ ] );
+ $class = new ReflectionClass( OutputPage::class );
+ $method = $class->getMethod( 'makeResourceLoaderLink' );
+ $method->setAccessible( true );
+ $ctx = new RequestContext();
+ $ctx->setSkin( SkinFactory::getDefaultInstance()->makeSkin( 'fallback' ) );
+ $ctx->setLanguage( 'en' );
+ $out = new OutputPage( $ctx );
+ $rl = $out->getResourceLoader();
+ $rl->setMessageBlobStore( new NullMessageBlobStore() );
+ $rl->register( [
+ 'test.foo' => new ResourceLoaderTestModule( [
+ 'script' => 'mw.test.foo( { a: true } );',
+ 'styles' => '.mw-test-foo { content: "style"; }',
+ ] ),
+ 'test.bar' => new ResourceLoaderTestModule( [
+ 'script' => 'mw.test.bar( { a: true } );',
+ 'styles' => '.mw-test-bar { content: "style"; }',
+ ] ),
+ 'test.baz' => new ResourceLoaderTestModule( [
+ 'script' => 'mw.test.baz( { a: true } );',
+ 'styles' => '.mw-test-baz { content: "style"; }',
+ ] ),
+ 'test.quux' => new ResourceLoaderTestModule( [
+ 'script' => 'mw.test.baz( { token: 123 } );',
+ 'styles' => '/* pref-animate=off */ .mw-icon { transition: none; }',
+ 'group' => 'private',
+ ] ),
+ ] );
+ $links = $method->invokeArgs( $out, $args );
+ $actualHtml = strval( $links );
+ $this->assertEquals( $expectedHtml, $actualHtml );
+ }
+
+ public static function provideBuildExemptModules() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ 'empty' => [
+ 'exemptStyleModules' => [],
+ '<meta name="ResourceLoaderDynamicStyles" content=""/>',
+ ],
+ 'empty sets' => [
+ 'exemptStyleModules' => [ 'site' => [], 'noscript' => [], 'private' => [], 'user' => [] ],
+ '<meta name="ResourceLoaderDynamicStyles" content=""/>',
+ ],
+ 'default logged-out' => [
+ 'exemptStyleModules' => [ 'site' => [ 'site.styles' ] ],
+ '<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" .
+ '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=site.styles&amp;only=styles&amp;skin=fallback"/>',
+ ],
+ 'default logged-in' => [
+ 'exemptStyleModules' => [ 'site' => [ 'site.styles' ], 'user' => [ 'user.styles' ] ],
+ '<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" .
+ '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=site.styles&amp;only=styles&amp;skin=fallback"/>' . "\n" .
+ '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=user.styles&amp;only=styles&amp;skin=fallback&amp;version=1e9z0ox"/>',
+ ],
+ 'custom modules' => [
+ 'exemptStyleModules' => [
+ 'site' => [ 'site.styles', 'example.site.a', 'example.site.b' ],
+ 'user' => [ 'user.styles', 'example.user' ],
+ ],
+ '<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" .
+ '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=example.site.a%2Cb&amp;only=styles&amp;skin=fallback"/>' . "\n" .
+ '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=site.styles&amp;only=styles&amp;skin=fallback"/>' . "\n" .
+ '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=example.user&amp;only=styles&amp;skin=fallback&amp;version=0a56zyi"/>' . "\n" .
+ '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=en&amp;modules=user.styles&amp;only=styles&amp;skin=fallback&amp;version=1e9z0ox"/>',
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @dataProvider provideBuildExemptModules
+ * @covers OutputPage::buildExemptModules
+ */
+ public function testBuildExemptModules( array $exemptStyleModules, $expect ) {
+ $this->setMwGlobals( [
+ 'wgResourceLoaderDebug' => false,
+ 'wgLoadScript' => '/w/load.php',
+ // Stub wgCacheEpoch as it influences getVersionHash used for the
+ // urls in the expected HTML
+ 'wgCacheEpoch' => '20140101000000',
+ ] );
+
+ // Set up stubs
+ $ctx = new RequestContext();
+ $ctx->setSkin( SkinFactory::getDefaultInstance()->makeSkin( 'fallback' ) );
+ $ctx->setLanguage( 'en' );
+ $outputPage = $this->getMockBuilder( OutputPage::class )
+ ->setConstructorArgs( [ $ctx ] )
+ ->setMethods( [ 'isUserCssPreview', 'buildCssLinksArray' ] )
+ ->getMock();
+ $outputPage->expects( $this->any() )
+ ->method( 'isUserCssPreview' )
+ ->willReturn( false );
+ $outputPage->expects( $this->any() )
+ ->method( 'buildCssLinksArray' )
+ ->willReturn( [] );
+ $rl = $outputPage->getResourceLoader();
+ $rl->setMessageBlobStore( new NullMessageBlobStore() );
+
+ // Register custom modules
+ $rl->register( [
+ 'example.site.a' => new ResourceLoaderTestModule( [ 'group' => 'site' ] ),
+ 'example.site.b' => new ResourceLoaderTestModule( [ 'group' => 'site' ] ),
+ 'example.user' => new ResourceLoaderTestModule( [ 'group' => 'user' ] ),
+ ] );
+
+ $outputPage = TestingAccessWrapper::newFromObject( $outputPage );
+ $outputPage->rlExemptStyleModules = $exemptStyleModules;
+ $this->assertEquals(
+ $expect,
+ strval( $outputPage->buildExemptModules() )
+ );
+ }
+
+ /**
+ * @dataProvider provideVaryHeaders
+ * @covers OutputPage::addVaryHeader
+ * @covers OutputPage::getVaryHeader
+ * @covers OutputPage::getKeyHeader
+ */
+ public function testVaryHeaders( $calls, $vary, $key ) {
+ // get rid of default Vary fields
+ $outputPage = $this->getMockBuilder( OutputPage::class )
+ ->setConstructorArgs( [ new RequestContext() ] )
+ ->setMethods( [ 'getCacheVaryCookies' ] )
+ ->getMock();
+ $outputPage->expects( $this->any() )
+ ->method( 'getCacheVaryCookies' )
+ ->will( $this->returnValue( [] ) );
+ TestingAccessWrapper::newFromObject( $outputPage )->mVaryHeader = [];
+
+ foreach ( $calls as $call ) {
+ call_user_func_array( [ $outputPage, 'addVaryHeader' ], $call );
+ }
+ $this->assertEquals( $vary, $outputPage->getVaryHeader(), 'Vary:' );
+ $this->assertEquals( $key, $outputPage->getKeyHeader(), 'Key:' );
+ }
+
+ public function provideVaryHeaders() {
+ // note: getKeyHeader() automatically adds Vary: Cookie
+ return [
+ [ // single header
+ [
+ [ 'Cookie' ],
+ ],
+ 'Vary: Cookie',
+ 'Key: Cookie',
+ ],
+ [ // non-unique headers
+ [
+ [ 'Cookie' ],
+ [ 'Accept-Language' ],
+ [ 'Cookie' ],
+ ],
+ 'Vary: Cookie, Accept-Language',
+ 'Key: Cookie,Accept-Language',
+ ],
+ [ // two headers with single options
+ [
+ [ 'Cookie', [ 'param=phpsessid' ] ],
+ [ 'Accept-Language', [ 'substr=en' ] ],
+ ],
+ 'Vary: Cookie, Accept-Language',
+ 'Key: Cookie;param=phpsessid,Accept-Language;substr=en',
+ ],
+ [ // one header with multiple options
+ [
+ [ 'Cookie', [ 'param=phpsessid', 'param=userId' ] ],
+ ],
+ 'Vary: Cookie',
+ 'Key: Cookie;param=phpsessid;param=userId',
+ ],
+ [ // Duplicate option
+ [
+ [ 'Cookie', [ 'param=phpsessid' ] ],
+ [ 'Cookie', [ 'param=phpsessid' ] ],
+ [ 'Accept-Language', [ 'substr=en', 'substr=en' ] ],
+ ],
+ 'Vary: Cookie, Accept-Language',
+ 'Key: Cookie;param=phpsessid,Accept-Language;substr=en',
+ ],
+ [ // Same header, different options
+ [
+ [ 'Cookie', [ 'param=phpsessid' ] ],
+ [ 'Cookie', [ 'param=userId' ] ],
+ ],
+ 'Vary: Cookie',
+ 'Key: Cookie;param=phpsessid;param=userId',
+ ],
+ ];
+ }
+
+ /**
+ * @covers OutputPage::haveCacheVaryCookies
+ */
+ public function testHaveCacheVaryCookies() {
+ $request = new FauxRequest();
+ $context = new RequestContext();
+ $context->setRequest( $request );
+ $outputPage = new OutputPage( $context );
+
+ // No cookies are set.
+ $this->assertFalse( $outputPage->haveCacheVaryCookies() );
+
+ // 'Token' is present but empty, so it shouldn't count.
+ $request->setCookie( 'Token', '' );
+ $this->assertFalse( $outputPage->haveCacheVaryCookies() );
+
+ // 'Token' present and nonempty.
+ $request->setCookie( 'Token', '123' );
+ $this->assertTrue( $outputPage->haveCacheVaryCookies() );
+ }
+
+ /**
+ * @covers OutputPage::addCategoryLinks
+ * @covers OutputPage::getCategories
+ */
+ public function testGetCategories() {
+ $fakeResultWrapper = new FakeResultWrapper( [
+ (object)[
+ 'pp_value' => 1,
+ 'page_title' => 'Test'
+ ],
+ (object)[
+ 'page_title' => 'Test2'
+ ]
+ ] );
+ $outputPage = $this->getMockBuilder( OutputPage::class )
+ ->setConstructorArgs( [ new RequestContext() ] )
+ ->setMethods( [ 'addCategoryLinksToLBAndGetResult' ] )
+ ->getMock();
+ $outputPage->expects( $this->any() )
+ ->method( 'addCategoryLinksToLBAndGetResult' )
+ ->will( $this->returnValue( $fakeResultWrapper ) );
+
+ $outputPage->addCategoryLinks( [
+ 'Test' => 'Test',
+ 'Test2' => 'Test2',
+ ] );
+ $this->assertEquals( [ 0 => 'Test', '1' => 'Test2' ], $outputPage->getCategories() );
+ $this->assertEquals( [ 0 => 'Test2' ], $outputPage->getCategories( 'normal' ) );
+ $this->assertEquals( [ 0 => 'Test' ], $outputPage->getCategories( 'hidden' ) );
+ }
+
+ /**
+ * @dataProvider provideLinkHeaders
+ * @covers OutputPage::addLinkHeader
+ * @covers OutputPage::getLinkHeader
+ */
+ public function testLinkHeaders( $headers, $result ) {
+ $outputPage = $this->newInstance();
+
+ foreach ( $headers as $header ) {
+ $outputPage->addLinkHeader( $header );
+ }
+
+ $this->assertEquals( $result, $outputPage->getLinkHeader() );
+ }
+
+ public function provideLinkHeaders() {
+ return [
+ [
+ [],
+ false
+ ],
+ [
+ [ '<https://foo/bar.jpg>;rel=preload;as=image' ],
+ 'Link: <https://foo/bar.jpg>;rel=preload;as=image',
+ ],
+ [
+ [ '<https://foo/bar.jpg>;rel=preload;as=image','<https://foo/baz.jpg>;rel=preload;as=image' ],
+ 'Link: <https://foo/bar.jpg>;rel=preload;as=image,<https://foo/baz.jpg>;rel=preload;as=image',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePreloadLinkHeaders
+ * @covers OutputPage::addLogoPreloadLinkHeaders
+ * @covers ResourceLoaderSkinModule::getLogo
+ */
+ public function testPreloadLinkHeaders( $config, $result, $baseDir = null ) {
+ if ( $baseDir ) {
+ $this->setMwGlobals( 'IP', $baseDir );
+ }
+ $out = TestingAccessWrapper::newFromObject( $this->newInstance( $config ) );
+ $out->addLogoPreloadLinkHeaders();
+
+ $this->assertEquals( $result, $out->getLinkHeader() );
+ }
+
+ public function providePreloadLinkHeaders() {
+ return [
+ [
+ [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => [
+ '1.5x' => '/img/one-point-five.png',
+ '2x' => '/img/two-x.png',
+ ],
+ ],
+ 'Link: </img/default.png>;rel=preload;as=image;media=' .
+ 'not all and (min-resolution: 1.5dppx),' .
+ '</img/one-point-five.png>;rel=preload;as=image;media=' .
+ '(min-resolution: 1.5dppx) and (max-resolution: 1.999999dppx),' .
+ '</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
+ ],
+ [
+ [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => false,
+ ],
+ 'Link: </img/default.png>;rel=preload;as=image'
+ ],
+ [
+ [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => [
+ '2x' => '/img/two-x.png',
+ ],
+ ],
+ 'Link: </img/default.png>;rel=preload;as=image;media=' .
+ 'not all and (min-resolution: 2dppx),' .
+ '</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
+ ],
+ [
+ [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => [
+ 'svg' => '/img/vector.svg',
+ ],
+ ],
+ 'Link: </img/vector.svg>;rel=preload;as=image'
+
+ ],
+ [
+ [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/w/test.jpg',
+ 'LogoHD' => false,
+ 'UploadPath' => '/w/images',
+ ],
+ 'Link: </w/test.jpg?edcf2>;rel=preload;as=image',
+ 'baseDir' => dirname( __DIR__ ) . '/data/media',
+ ],
+ ];
+ }
+
+ /**
+ * @return OutputPage
+ */
+ private function newInstance( $config = [] ) {
+ $context = new RequestContext();
+
+ $context->setConfig( new HashConfig( $config + [
+ 'AppleTouchIcon' => false,
+ 'DisableLangConversion' => true,
+ 'EnableAPI' => false,
+ 'EnableCanonicalServerLink' => false,
+ 'Favicon' => false,
+ 'Feed' => false,
+ 'LanguageCode' => false,
+ 'ReferrerPolicy' => false,
+ 'RightsPage' => false,
+ 'RightsUrl' => false,
+ 'UniversalEditButton' => false,
+ ] ) );
+
+ return new OutputPage( $context );
+ }
+}
+
+/**
+ * MessageBlobStore that doesn't do anything
+ */
+class NullMessageBlobStore extends MessageBlobStore {
+ public function get( ResourceLoader $resourceLoader, $modules, $lang ) {
+ return [];
+ }
+
+ public function updateModule( $name, ResourceLoaderModule $module, $lang ) {
+ }
+
+ public function updateMessage( $key ) {
+ }
+
+ public function clear() {
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/PageArchiveTest.php b/www/wiki/tests/phpunit/includes/PageArchiveTest.php
new file mode 100644
index 00000000..623d4a65
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/PageArchiveTest.php
@@ -0,0 +1,265 @@
+<?php
+
+/**
+ * Test class for page archiving.
+ *
+ * @group ContentHandler
+ * @group Database
+ * ^--- important, causes temporary tables to be used instead of the real database
+ *
+ * @group medium
+ * ^--- important, causes tests not to fail with timeout
+ */
+class PageArchiveTest extends MediaWikiTestCase {
+
+ /**
+ * @var PageArchive $archivedPage
+ */
+ private $archivedPage;
+
+ /**
+ * A logged out user who edited the page before it was archived.
+ * @var string $ipEditor
+ */
+ private $ipEditor;
+
+ /**
+ * Revision ID of the IP edit
+ * @var int $ipRevId
+ */
+ private $ipRevId;
+
+ function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+
+ $this->tablesUsed = array_merge(
+ $this->tablesUsed,
+ [
+ 'page',
+ 'revision',
+ 'ip_changes',
+ 'text',
+ 'archive',
+ 'recentchanges',
+ 'logging',
+ 'page_props',
+ ]
+ );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->overrideMwServices();
+
+ // First create our dummy page
+ $page = Title::newFromText( 'PageArchiveTest_thePage' );
+ $page = new WikiPage( $page );
+ $content = ContentHandler::makeContent(
+ 'testing',
+ $page->getTitle(),
+ CONTENT_MODEL_WIKITEXT
+ );
+ $page->doEditContent( $content, 'testing', EDIT_NEW );
+
+ // Insert IP revision
+ $this->ipEditor = '2600:387:ed7:947e:8c16:a1ad:dd34:1dd7';
+ $rev = new Revision( [
+ 'text' => 'Lorem Ipsum',
+ 'comment' => 'just a test',
+ 'page' => $page->getId(),
+ 'user_text' => $this->ipEditor,
+ ] );
+ $dbw = wfGetDB( DB_MASTER );
+ $this->ipRevId = $rev->insertOn( $dbw );
+
+ // Delete the page
+ $page->doDeleteArticleReal( 'Just a test deletion' );
+
+ $this->archivedPage = new PageArchive( $page->getTitle() );
+ }
+
+ /**
+ * @covers PageArchive::undelete
+ * @covers PageArchive::undeleteRevisions
+ */
+ public function testUndeleteRevisions() {
+ // First make sure old revisions are archived
+ $dbr = wfGetDB( DB_REPLICA );
+ $arQuery = Revision::getArchiveQueryInfo();
+ $res = $dbr->select(
+ $arQuery['tables'],
+ $arQuery['fields'],
+ [ 'ar_rev_id' => $this->ipRevId ],
+ __METHOD__,
+ [],
+ $arQuery['joins']
+ );
+ $row = $res->fetchObject();
+ $this->assertEquals( $this->ipEditor, $row->ar_user_text );
+
+ // Should not be in revision
+ $res = $dbr->select( 'revision', '1', [ 'rev_id' => $this->ipRevId ] );
+ $this->assertFalse( $res->fetchObject() );
+
+ // Should not be in ip_changes
+ $res = $dbr->select( 'ip_changes', '1', [ 'ipc_rev_id' => $this->ipRevId ] );
+ $this->assertFalse( $res->fetchObject() );
+
+ // Restore the page
+ $this->archivedPage->undelete( [] );
+
+ // Should be back in revision
+ $revQuery = Revision::getQueryInfo();
+ $res = $dbr->select(
+ $revQuery['tables'],
+ $revQuery['fields'],
+ [ 'rev_id' => $this->ipRevId ],
+ __METHOD__,
+ [],
+ $revQuery['joins']
+ );
+ $row = $res->fetchObject();
+ $this->assertEquals( $this->ipEditor, $row->rev_user_text );
+
+ // Should be back in ip_changes
+ $res = $dbr->select( 'ip_changes', [ 'ipc_hex' ], [ 'ipc_rev_id' => $this->ipRevId ] );
+ $row = $res->fetchObject();
+ $this->assertEquals( IP::toHex( $this->ipEditor ), $row->ipc_hex );
+ }
+
+ /**
+ * @covers PageArchive::listRevisions
+ */
+ public function testListRevisions() {
+ $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->overrideMwServices();
+
+ $revisions = $this->archivedPage->listRevisions();
+ $this->assertEquals( 2, $revisions->numRows() );
+
+ // Get the rows as arrays
+ $row1 = (array)$revisions->current();
+ $row2 = (array)$revisions->next();
+ // Unset the timestamps (we assume they will be right...
+ $this->assertInternalType( 'string', $row1['ar_timestamp'] );
+ $this->assertInternalType( 'string', $row2['ar_timestamp'] );
+ unset( $row1['ar_timestamp'] );
+ unset( $row2['ar_timestamp'] );
+
+ $this->assertEquals(
+ [
+ 'ar_minor_edit' => '0',
+ 'ar_user' => '0',
+ 'ar_user_text' => '2600:387:ed7:947e:8c16:a1ad:dd34:1dd7',
+ 'ar_actor' => null,
+ 'ar_len' => '11',
+ 'ar_deleted' => '0',
+ 'ar_rev_id' => '3',
+ 'ar_sha1' => '0qdrpxl537ivfnx4gcpnzz0285yxryy',
+ 'ar_page_id' => '2',
+ 'ar_comment_text' => 'just a test',
+ 'ar_comment_data' => null,
+ 'ar_comment_cid' => null,
+ 'ar_content_format' => null,
+ 'ar_content_model' => null,
+ 'ts_tags' => null,
+ 'ar_id' => '2',
+ 'ar_namespace' => '0',
+ 'ar_title' => 'PageArchiveTest_thePage',
+ 'ar_text_id' => '3',
+ 'ar_parent_id' => '2',
+ ],
+ $row1
+ );
+ $this->assertEquals(
+ [
+ 'ar_minor_edit' => '0',
+ 'ar_user' => '0',
+ 'ar_user_text' => '127.0.0.1',
+ 'ar_actor' => null,
+ 'ar_len' => '7',
+ 'ar_deleted' => '0',
+ 'ar_rev_id' => '2',
+ 'ar_sha1' => 'pr0s8e18148pxhgjfa0gjrvpy8fiyxc',
+ 'ar_page_id' => '2',
+ 'ar_comment_text' => 'testing',
+ 'ar_comment_data' => null,
+ 'ar_comment_cid' => null,
+ 'ar_content_format' => null,
+ 'ar_content_model' => null,
+ 'ts_tags' => null,
+ 'ar_id' => '1',
+ 'ar_namespace' => '0',
+ 'ar_title' => 'PageArchiveTest_thePage',
+ 'ar_text_id' => '2',
+ 'ar_parent_id' => '0',
+ ],
+ $row2
+ );
+ }
+
+ /**
+ * @covers PageArchive::listPagesBySearch
+ */
+ public function testListPagesBySearch() {
+ $pages = PageArchive::listPagesBySearch( 'PageArchiveTest_thePage' );
+ $this->assertSame( 1, $pages->numRows() );
+
+ $page = (array)$pages->current();
+
+ $this->assertSame(
+ [
+ 'ar_namespace' => '0',
+ 'ar_title' => 'PageArchiveTest_thePage',
+ 'count' => '2',
+ ],
+ $page
+ );
+ }
+
+ /**
+ * @covers PageArchive::listPagesBySearch
+ */
+ public function testListPagesByPrefix() {
+ $pages = PageArchive::listPagesByPrefix( 'PageArchiveTest' );
+ $this->assertSame( 1, $pages->numRows() );
+
+ $page = (array)$pages->current();
+
+ $this->assertSame(
+ [
+ 'ar_namespace' => '0',
+ 'ar_title' => 'PageArchiveTest_thePage',
+ 'count' => '2',
+ ],
+ $page
+ );
+ }
+
+ /**
+ * @covers PageArchive::getTextFromRow
+ */
+ public function testGetTextFromRow() {
+ $row = (object)[ 'ar_text_id' => 2 ];
+ $text = $this->archivedPage->getTextFromRow( $row );
+ $this->assertSame( 'testing', $text );
+ }
+
+ /**
+ * @covers PageArchive::getLastRevisionText
+ */
+ public function testGetLastRevisionText() {
+ $text = $this->archivedPage->getLastRevisionText();
+ $this->assertSame( 'Lorem Ipsum', $text );
+ }
+
+ /**
+ * @covers PageArchive::isDeleted
+ */
+ public function testIsDeleted() {
+ $this->assertTrue( $this->archivedPage->isDeleted() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/PagePropsTest.php b/www/wiki/tests/phpunit/includes/PagePropsTest.php
new file mode 100644
index 00000000..f602cdab
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/PagePropsTest.php
@@ -0,0 +1,303 @@
+<?php
+
+/**
+ * @covers PageProps
+ *
+ * @group Database
+ * ^--- tell jenkins this test needs the database
+ *
+ * @group medium
+ * ^--- tell phpunit that these test cases may take longer than 2 seconds.
+ */
+class PagePropsTest extends MediaWikiLangTestCase {
+
+ /**
+ * @var Title $title1
+ */
+ private $title1;
+
+ /**
+ * @var Title $title2
+ */
+ private $title2;
+
+ /**
+ * @var array $the_properties
+ */
+ private $the_properties;
+
+ protected function setUp() {
+ global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang;
+
+ parent::setUp();
+
+ $wgExtraNamespaces[12312] = 'Dummy';
+ $wgExtraNamespaces[12313] = 'Dummy_talk';
+
+ $wgNamespaceContentModels[12312] = 'DUMMY';
+ $wgContentHandlers['DUMMY'] = 'DummyContentHandlerForTesting';
+
+ MWNamespace::clearCaches();
+ $wgContLang->resetNamespaces(); # reset namespace cache
+
+ if ( !$this->the_properties ) {
+ $this->the_properties = [
+ "property1" => "value1",
+ "property2" => "value2",
+ "property3" => "value3",
+ "property4" => "value4"
+ ];
+ }
+
+ if ( !$this->title1 ) {
+ $page = $this->createPage(
+ 'PagePropsTest_page_1',
+ "just a dummy page",
+ CONTENT_MODEL_WIKITEXT
+ );
+ $this->title1 = $page->getTitle();
+ $page1ID = $this->title1->getArticleID();
+ $this->setProperties( $page1ID, $this->the_properties );
+ }
+
+ if ( !$this->title2 ) {
+ $page = $this->createPage(
+ 'PagePropsTest_page_2',
+ "just a dummy page",
+ CONTENT_MODEL_WIKITEXT
+ );
+ $this->title2 = $page->getTitle();
+ $page2ID = $this->title2->getArticleID();
+ $this->setProperties( $page2ID, $this->the_properties );
+ }
+ }
+
+ protected function tearDown() {
+ global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang;
+
+ parent::tearDown();
+
+ unset( $wgExtraNamespaces[12312] );
+ unset( $wgExtraNamespaces[12313] );
+
+ unset( $wgNamespaceContentModels[12312] );
+ unset( $wgContentHandlers['DUMMY'] );
+
+ MWNamespace::clearCaches();
+ $wgContLang->resetNamespaces(); # reset namespace cache
+ }
+
+ /**
+ * Test getting a single property from a single page. The property was
+ * set in setUp().
+ */
+ public function testGetSingleProperty() {
+ $pageProps = PageProps::getInstance();
+ $page1ID = $this->title1->getArticleID();
+ $result = $pageProps->getProperties( $this->title1, "property1" );
+ $this->assertArrayHasKey( $page1ID, $result, "Found property" );
+ $this->assertEquals( $result[$page1ID], "value1", "Get property" );
+ }
+
+ /**
+ * Test getting a single property from multiple pages. The property was
+ * set in setUp().
+ */
+ public function testGetSinglePropertyMultiplePages() {
+ $pageProps = PageProps::getInstance();
+ $page1ID = $this->title1->getArticleID();
+ $page2ID = $this->title2->getArticleID();
+ $titles = [
+ $this->title1,
+ $this->title2
+ ];
+ $result = $pageProps->getProperties( $titles, "property1" );
+ $this->assertArrayHasKey( $page1ID, $result, "Found page 1 property" );
+ $this->assertArrayHasKey( $page2ID, $result, "Found page 2 property" );
+ $this->assertEquals( $result[$page1ID], "value1", "Get property page 1" );
+ $this->assertEquals( $result[$page2ID], "value1", "Get property page 2" );
+ }
+
+ /**
+ * Test getting multiple properties from multiple pages. The properties
+ * were set in setUp().
+ */
+ public function testGetMultiplePropertiesMultiplePages() {
+ $pageProps = PageProps::getInstance();
+ $page1ID = $this->title1->getArticleID();
+ $page2ID = $this->title2->getArticleID();
+ $titles = [
+ $this->title1,
+ $this->title2
+ ];
+ $properties = [
+ "property1",
+ "property2"
+ ];
+ $result = $pageProps->getProperties( $titles, $properties );
+ $this->assertArrayHasKey( $page1ID, $result, "Found page 1 property" );
+ $this->assertArrayHasKey( "property1", $result[$page1ID], "Found page 1 property 1" );
+ $this->assertArrayHasKey( "property2", $result[$page1ID], "Found page 1 property 2" );
+ $this->assertArrayHasKey( $page2ID, $result, "Found page 2 property" );
+ $this->assertArrayHasKey( "property1", $result[$page2ID], "Found page 2 property 1" );
+ $this->assertArrayHasKey( "property2", $result[$page2ID], "Found page 2 property 2" );
+ $this->assertEquals( $result[$page1ID]["property1"], "value1", "Get page 1 property 1" );
+ $this->assertEquals( $result[$page1ID]["property2"], "value2", "Get page 1 property 2" );
+ $this->assertEquals( $result[$page2ID]["property1"], "value1", "Get page 2 property 1" );
+ $this->assertEquals( $result[$page2ID]["property2"], "value2", "Get page 2 property 2" );
+ }
+
+ /**
+ * Test getting all properties from a single page. The properties were
+ * set in setUp(). The properties retrieved from the page may include
+ * additional properties not set in the test case that are added by
+ * other extensions. Therefore, rather than checking to see if the
+ * properties that were set in the test case exactly match the
+ * retrieved properties, we need to check to see if they are a
+ * subset of the retrieved properties. Since this version of PHPUnit
+ * does not yet include assertArraySubset(), we needed to code the
+ * equivalent functionality.
+ */
+ public function testGetAllProperties() {
+ $pageProps = PageProps::getInstance();
+ $page1ID = $this->title1->getArticleID();
+ $result = $pageProps->getAllProperties( $this->title1 );
+ $this->assertArrayHasKey( $page1ID, $result, "Found properties" );
+ $properties = $result[$page1ID];
+ $patched = array_replace_recursive( $properties, $this->the_properties );
+ $this->assertEquals( $patched, $properties, "Get all properties" );
+ }
+
+ /**
+ * Test getting all properties from multiple pages. The properties were
+ * set in setUp(). See getAllProperties() above for more information.
+ */
+ public function testGetAllPropertiesMultiplePages() {
+ $pageProps = PageProps::getInstance();
+ $page1ID = $this->title1->getArticleID();
+ $page2ID = $this->title2->getArticleID();
+ $titles = [
+ $this->title1,
+ $this->title2
+ ];
+ $result = $pageProps->getAllProperties( $titles );
+ $this->assertArrayHasKey( $page1ID, $result, "Found page 1 properties" );
+ $this->assertArrayHasKey( $page2ID, $result, "Found page 2 properties" );
+ $properties1 = $result[$page1ID];
+ $patched = array_replace_recursive( $properties1, $this->the_properties );
+ $this->assertEquals( $patched, $properties1, "Get all properties page 1" );
+ $properties2 = $result[$page2ID];
+ $patched = array_replace_recursive( $properties2, $this->the_properties );
+ $this->assertEquals( $patched, $properties2, "Get all properties page 2" );
+ }
+
+ /**
+ * Test caching when retrieving single properties by getting a property,
+ * saving a new value for the property, then getting the property
+ * again. The cached value for the property rather than the new value
+ * of the property should be returned.
+ */
+ public function testSingleCache() {
+ $pageProps = PageProps::getInstance();
+ $page1ID = $this->title1->getArticleID();
+ $value1 = $pageProps->getProperties( $this->title1, "property1" );
+ $this->setProperty( $page1ID, "property1", "another value" );
+ $value2 = $pageProps->getProperties( $this->title1, "property1" );
+ $this->assertEquals( $value1, $value2, "Single cache" );
+ }
+
+ /**
+ * Test caching when retrieving all properties by getting all
+ * properties, saving a new value for a property, then getting all
+ * properties again. The cached value for the properties rather than the
+ * new value of the properties should be returned.
+ */
+ public function testMultiCache() {
+ $pageProps = PageProps::getInstance();
+ $page1ID = $this->title1->getArticleID();
+ $properties1 = $pageProps->getAllProperties( $this->title1 );
+ $this->setProperty( $page1ID, "property1", "another value" );
+ $properties2 = $pageProps->getAllProperties( $this->title1 );
+ $this->assertEquals( $properties1, $properties2, "Multi Cache" );
+ }
+
+ /**
+ * Test that getting all properties clears the single properties
+ * that have been cached by getting a property, saving a new value for
+ * the property, getting all properties (which clears the cached single
+ * properties), then getting the property again. The new value for the
+ * property rather than the cached value of the property should be
+ * returned.
+ */
+ public function testClearCache() {
+ $pageProps = PageProps::getInstance();
+ $page1ID = $this->title1->getArticleID();
+ $pageProps->getProperties( $this->title1, "property1" );
+ $new_value = "another value";
+ $this->setProperty( $page1ID, "property1", $new_value );
+ $pageProps->getAllProperties( $this->title1 );
+ $result = $pageProps->getProperties( $this->title1, "property1" );
+ $this->assertArrayHasKey( $page1ID, $result, "Found property" );
+ $this->assertEquals( $result[$page1ID], "another value", "Clear cache" );
+ }
+
+ protected function createPage( $page, $text, $model = null ) {
+ if ( is_string( $page ) ) {
+ if ( !preg_match( '/:/', $page ) &&
+ ( $model === null || $model === CONTENT_MODEL_WIKITEXT )
+ ) {
+ $ns = $this->getDefaultWikitextNS();
+ $page = MWNamespace::getCanonicalName( $ns ) . ':' . $page;
+ }
+
+ $page = Title::newFromText( $page );
+ }
+
+ if ( $page instanceof Title ) {
+ $page = new WikiPage( $page );
+ }
+
+ if ( $page->exists() ) {
+ $page->doDeleteArticle( "done" );
+ }
+
+ $content = ContentHandler::makeContent( $text, $page->getTitle(), $model );
+ $page->doEditContent( $content, "testing", EDIT_NEW );
+
+ return $page;
+ }
+
+ protected function setProperties( $pageID, $properties ) {
+ $rows = [];
+
+ foreach ( $properties as $propertyName => $propertyValue ) {
+ $row = [
+ 'pp_page' => $pageID,
+ 'pp_propname' => $propertyName,
+ 'pp_value' => $propertyValue
+ ];
+
+ $rows[] = $row;
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->replace(
+ 'page_props',
+ [
+ [
+ 'pp_page',
+ 'pp_propname'
+ ]
+ ],
+ $rows,
+ __METHOD__
+ );
+ }
+
+ protected function setProperty( $pageID, $propertyName, $propertyValue ) {
+ $properties = [];
+ $properties[$propertyName] = $propertyValue;
+
+ $this->setProperties( $pageID, $properties );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/PathRouterTest.php b/www/wiki/tests/phpunit/includes/PathRouterTest.php
new file mode 100644
index 00000000..fc6a70b1
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/PathRouterTest.php
@@ -0,0 +1,269 @@
+<?php
+
+/**
+ * Tests for the PathRouter parsing.
+ *
+ * @covers PathRouter
+ */
+class PathRouterTest extends MediaWikiTestCase {
+
+ /**
+ * @var PathRouter
+ */
+ protected $basicRouter;
+
+ protected function setUp() {
+ parent::setUp();
+ $router = new PathRouter;
+ $router->add( "/wiki/$1" );
+ $this->basicRouter = $router;
+ }
+
+ /**
+ * Test basic path parsing
+ */
+ public function testBasic() {
+ $matches = $this->basicRouter->parse( "/wiki/Foo" );
+ $this->assertEquals( $matches, [ 'title' => "Foo" ] );
+ }
+
+ /**
+ * Test loose path auto-$1
+ */
+ public function testLoose() {
+ $router = new PathRouter;
+ $router->add( "/" ); # Should be the same as "/$1"
+ $matches = $router->parse( "/Foo" );
+ $this->assertEquals( $matches, [ 'title' => "Foo" ] );
+
+ $router = new PathRouter;
+ $router->add( "/wiki" ); # Should be the same as /wiki/$1
+ $matches = $router->parse( "/wiki/Foo" );
+ $this->assertEquals( $matches, [ 'title' => "Foo" ] );
+
+ $router = new PathRouter;
+ $router->add( "/wiki/" ); # Should be the same as /wiki/$1
+ $matches = $router->parse( "/wiki/Foo" );
+ $this->assertEquals( $matches, [ 'title' => "Foo" ] );
+ }
+
+ /**
+ * Test to ensure that path is based on specifity, not order
+ */
+ public function testOrder() {
+ $router = new PathRouter;
+ $router->add( "/$1" );
+ $router->add( "/a/$1" );
+ $router->add( "/b/$1" );
+ $matches = $router->parse( "/a/Foo" );
+ $this->assertEquals( $matches, [ 'title' => "Foo" ] );
+
+ $router = new PathRouter;
+ $router->add( "/b/$1" );
+ $router->add( "/a/$1" );
+ $router->add( "/$1" );
+ $matches = $router->parse( "/a/Foo" );
+ $this->assertEquals( $matches, [ 'title' => "Foo" ] );
+ }
+
+ /**
+ * Test the handling of key based arrays with a url parameter
+ */
+ public function testKeyParameter() {
+ $router = new PathRouter;
+ $router->add( [ 'edit' => "/edit/$1" ], [ 'action' => '$key' ] );
+ $matches = $router->parse( "/edit/Foo" );
+ $this->assertEquals( $matches, [ 'title' => "Foo", 'action' => 'edit' ] );
+ }
+
+ /**
+ * Test the handling of $2 inside paths
+ */
+ public function testAdditionalParameter() {
+ // Basic $2
+ $router = new PathRouter;
+ $router->add( '/$2/$1', [ 'test' => '$2' ] );
+ $matches = $router->parse( "/asdf/Foo" );
+ $this->assertEquals( $matches, [ 'title' => "Foo", 'test' => 'asdf' ] );
+ }
+
+ /**
+ * Test additional restricted value parameter
+ */
+ public function testRestrictedValue() {
+ $router = new PathRouter;
+ $router->add( '/$2/$1',
+ [ 'test' => '$2' ],
+ [ '$2' => [ 'a', 'b' ] ]
+ );
+ $router->add( '/$2/$1',
+ [ 'test2' => '$2' ],
+ [ '$2' => 'c' ]
+ );
+ $router->add( '/$1' );
+
+ $matches = $router->parse( "/asdf/Foo" );
+ $this->assertEquals( $matches, [ 'title' => "asdf/Foo" ] );
+
+ $matches = $router->parse( "/a/Foo" );
+ $this->assertEquals( $matches, [ 'title' => "Foo", 'test' => 'a' ] );
+
+ $matches = $router->parse( "/c/Foo" );
+ $this->assertEquals( $matches, [ 'title' => "Foo", 'test2' => 'c' ] );
+ }
+
+ public function callbackForTest( &$matches, $data ) {
+ $matches['x'] = $data['$1'];
+ $matches['foo'] = $data['foo'];
+ }
+
+ public function testCallback() {
+ $router = new PathRouter;
+ $router->add( "/$1",
+ [ 'a' => 'b', 'data:foo' => 'bar' ],
+ [ 'callback' => [ $this, 'callbackForTest' ] ]
+ );
+ $matches = $router->parse( '/Foo' );
+ $this->assertEquals( $matches, [
+ 'title' => "Foo",
+ 'x' => 'Foo',
+ 'a' => 'b',
+ 'foo' => 'bar'
+ ] );
+ }
+
+ /**
+ * Test to ensure that matches are not made if a parameter expects nonexistent input
+ */
+ public function testFail() {
+ $router = new PathRouter;
+ $router->add( "/wiki/$1", [ 'title' => "$1$2" ] );
+ $matches = $router->parse( "/wiki/A" );
+ $this->assertEquals( [], $matches );
+ }
+
+ /**
+ * Test to ensure weight of paths is handled correctly
+ */
+ public function testWeight() {
+ $router = new PathRouter;
+ $router->addStrict( "/Bar", [ 'ping' => 'pong' ] );
+ $router->add( "/asdf-$1", [ 'title' => 'qwerty-$1' ] );
+ $router->add( "/$1" );
+ $router->add( "/qwerty-$1", [ 'title' => 'asdf-$1' ] );
+ $router->addStrict( "/Baz", [ 'marco' => 'polo' ] );
+ $router->add( "/a/$1" );
+ $router->add( "/asdf/$1" );
+ $router->add( "/$2/$1", [ 'unrestricted' => '$2' ] );
+ $router->add( [ 'qwerty' => "/qwerty/$1" ], [ 'qwerty' => '$key' ] );
+ $router->add( "/$2/$1", [ 'restricted-to-y' => '$2' ], [ '$2' => 'y' ] );
+
+ foreach (
+ [
+ '/Foo' => [ 'title' => 'Foo' ],
+ '/Bar' => [ 'ping' => 'pong' ],
+ '/Baz' => [ 'marco' => 'polo' ],
+ '/asdf-foo' => [ 'title' => 'qwerty-foo' ],
+ '/qwerty-bar' => [ 'title' => 'asdf-bar' ],
+ '/a/Foo' => [ 'title' => 'Foo' ],
+ '/asdf/Foo' => [ 'title' => 'Foo' ],
+ '/qwerty/Foo' => [ 'title' => 'Foo', 'qwerty' => 'qwerty' ],
+ '/baz/Foo' => [ 'title' => 'Foo', 'unrestricted' => 'baz' ],
+ '/y/Foo' => [ 'title' => 'Foo', 'restricted-to-y' => 'y' ],
+ ] as $path => $result
+ ) {
+ $this->assertEquals( $router->parse( $path ), $result );
+ }
+ }
+
+ /**
+ * Make sure the router handles titles like Special:Recentchanges correctly
+ */
+ public function testSpecial() {
+ $matches = $this->basicRouter->parse( "/wiki/Special:Recentchanges" );
+ $this->assertEquals( $matches, [ 'title' => "Special:Recentchanges" ] );
+ }
+
+ /**
+ * Make sure the router decodes urlencoding properly
+ */
+ public function testUrlencoding() {
+ $matches = $this->basicRouter->parse( "/wiki/Title_With%20Space" );
+ $this->assertEquals( $matches, [ 'title' => "Title_With Space" ] );
+ }
+
+ public static function provideRegexpChars() {
+ return [
+ [ "$" ],
+ [ "$1" ],
+ [ "\\" ],
+ [ "\\$1" ],
+ ];
+ }
+
+ /**
+ * Make sure the router doesn't break on special characters like $ used in regexp replacements
+ * @dataProvider provideRegexpChars
+ */
+ public function testRegexpChars( $char ) {
+ $matches = $this->basicRouter->parse( "/wiki/$char" );
+ $this->assertEquals( $matches, [ 'title' => "$char" ] );
+ }
+
+ /**
+ * Make sure the router handles characters like +&() properly
+ */
+ public function testCharacters() {
+ $matches = $this->basicRouter->parse( "/wiki/Plus+And&Dollar\\Stuff();[]{}*" );
+ $this->assertEquals( $matches, [ 'title' => "Plus+And&Dollar\\Stuff();[]{}*" ] );
+ }
+
+ /**
+ * Make sure the router handles unicode characters correctly
+ * @depends testSpecial
+ * @depends testUrlencoding
+ * @depends testCharacters
+ */
+ public function testUnicode() {
+ $matches = $this->basicRouter->parse( "/wiki/Spécial:Modifications_récentes" );
+ $this->assertEquals( $matches, [ 'title' => "Spécial:Modifications_récentes" ] );
+
+ $matches = $this->basicRouter->parse( "/wiki/Sp%C3%A9cial:Modifications_r%C3%A9centes" );
+ $this->assertEquals( $matches, [ 'title' => "Spécial:Modifications_récentes" ] );
+ }
+
+ /**
+ * Ensure the router doesn't choke on long paths.
+ */
+ public function testLength() {
+ // phpcs:disable Generic.Files.LineLength
+ $matches = $this->basicRouter->parse(
+ "/wiki/Lorem_ipsum_dolor_sit_amet,_consectetur_adipisicing_elit,_sed_do_eiusmod_tempor_incididunt_ut_labore_et_dolore_magna_aliqua._Ut_enim_ad_minim_veniam,_quis_nostrud_exercitation_ullamco_laboris_nisi_ut_aliquip_ex_ea_commodo_consequat._Duis_aute_irure_dolor_in_reprehenderit_in_voluptate_velit_esse_cillum_dolore_eu_fugiat_nulla_pariatur._Excepteur_sint_occaecat_cupidatat_non_proident,_sunt_in_culpa_qui_officia_deserunt_mollit_anim_id_est_laborum."
+ );
+ $this->assertEquals(
+ $matches,
+ [ 'title' => "Lorem_ipsum_dolor_sit_amet,_consectetur_adipisicing_elit,_sed_do_eiusmod_tempor_incididunt_ut_labore_et_dolore_magna_aliqua._Ut_enim_ad_minim_veniam,_quis_nostrud_exercitation_ullamco_laboris_nisi_ut_aliquip_ex_ea_commodo_consequat._Duis_aute_irure_dolor_in_reprehenderit_in_voluptate_velit_esse_cillum_dolore_eu_fugiat_nulla_pariatur._Excepteur_sint_occaecat_cupidatat_non_proident,_sunt_in_culpa_qui_officia_deserunt_mollit_anim_id_est_laborum." ]
+ );
+ // phpcs:enable
+ }
+
+ /**
+ * Ensure that the php passed site of parameter values are not urldecoded
+ */
+ public function testPatternUrlencoding() {
+ $router = new PathRouter;
+ $router->add( "/wiki/$1", [ 'title' => '%20:$1' ] );
+ $matches = $router->parse( "/wiki/Foo" );
+ $this->assertEquals( $matches, [ 'title' => '%20:Foo' ] );
+ }
+
+ /**
+ * Ensure that raw parameter values do not have any variable replacements or urldecoding
+ */
+ public function testRawParamValue() {
+ $router = new PathRouter;
+ $router->add( "/wiki/$1", [ 'title' => [ 'value' => 'bar%20$1' ] ] );
+ $matches = $router->parse( "/wiki/Foo" );
+ $this->assertEquals( $matches, [ 'title' => 'bar%20$1' ] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/PreferencesTest.php b/www/wiki/tests/phpunit/includes/PreferencesTest.php
new file mode 100644
index 00000000..4d3b195c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/PreferencesTest.php
@@ -0,0 +1,90 @@
+<?php
+
+/**
+ * @group Database
+ */
+class PreferencesTest extends MediaWikiTestCase {
+ /**
+ * @var User[]
+ */
+ private $prefUsers;
+ /**
+ * @var RequestContext
+ */
+ private $context;
+
+ public function __construct() {
+ parent::__construct();
+
+ $this->prefUsers['noemail'] = new User;
+
+ $this->prefUsers['notauth'] = new User;
+ $this->prefUsers['notauth']
+ ->setEmail( 'noauth@example.org' );
+
+ $this->prefUsers['auth'] = new User;
+ $this->prefUsers['auth']
+ ->setEmail( 'noauth@example.org' );
+ $this->prefUsers['auth']
+ ->setEmailAuthenticationTimestamp( 1330946623 );
+
+ $this->context = new RequestContext;
+ $this->context->setTitle( Title::newFromText( 'PreferencesTest' ) );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgEnableEmail' => true,
+ 'wgEmailAuthentication' => true,
+ ] );
+ }
+
+ /**
+ * Placeholder to verify T36302
+ * @covers Preferences::profilePreferences
+ * @deprecated replaced by DefaultPreferencesFactoryTest::testEmailAuthentication()
+ */
+ public function testEmailAuthenticationFieldWhenUserHasNoEmail() {
+ $prefs = $this->prefsFor( 'noemail' );
+ $this->assertArrayHasKey( 'cssclass',
+ $prefs['emailauthentication']
+ );
+ $this->assertEquals( 'mw-email-none', $prefs['emailauthentication']['cssclass'] );
+ }
+
+ /**
+ * Placeholder to verify T36302
+ * @covers Preferences::profilePreferences
+ * @deprecated replaced by DefaultPreferencesFactoryTest::testEmailAuthentication()
+ */
+ public function testEmailAuthenticationFieldWhenUserEmailNotAuthenticated() {
+ $prefs = $this->prefsFor( 'notauth' );
+ $this->assertArrayHasKey( 'cssclass',
+ $prefs['emailauthentication']
+ );
+ $this->assertEquals( 'mw-email-not-authenticated', $prefs['emailauthentication']['cssclass'] );
+ }
+
+ /**
+ * Placeholder to verify T36302
+ * @covers Preferences::profilePreferences
+ * @deprecated replaced by DefaultPreferencesFactoryTest::testEmailAuthentication()
+ */
+ public function testEmailAuthenticationFieldWhenUserEmailIsAuthenticated() {
+ $prefs = $this->prefsFor( 'auth' );
+ $this->assertArrayHasKey( 'cssclass',
+ $prefs['emailauthentication']
+ );
+ $this->assertEquals( 'mw-email-authenticated', $prefs['emailauthentication']['cssclass'] );
+ }
+
+ /** Helper */
+ protected function prefsFor( $user_key ) {
+ return Preferences::getPreferences(
+ $this->prefUsers[$user_key],
+ $this->context
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/PrefixSearchTest.php b/www/wiki/tests/phpunit/includes/PrefixSearchTest.php
new file mode 100644
index 00000000..ed34a8ab
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/PrefixSearchTest.php
@@ -0,0 +1,386 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Search
+ * @group Database
+ * @covers PrefixSearch
+ */
+class PrefixSearchTest extends MediaWikiLangTestCase {
+ const NS_NONCAP = 12346;
+
+ private $originalHandlers;
+
+ public function addDBDataOnce() {
+ if ( !$this->isWikitextNS( NS_MAIN ) ) {
+ // tests are skipped if NS_MAIN is not wikitext
+ return;
+ }
+
+ $this->insertPage( 'Sandbox' );
+ $this->insertPage( 'Bar' );
+ $this->insertPage( 'Example' );
+ $this->insertPage( 'Example Bar' );
+ $this->insertPage( 'Example Foo' );
+ $this->insertPage( 'Example Foo/Bar' );
+ $this->insertPage( 'Example/Baz' );
+ $this->insertPage( 'Redirect test', '#REDIRECT [[Redirect Test]]' );
+ $this->insertPage( 'Redirect Test' );
+ $this->insertPage( 'Redirect Test Worse Result' );
+ $this->insertPage( 'Redirect test2', '#REDIRECT [[Redirect Test2]]' );
+ $this->insertPage( 'Redirect TEST2', '#REDIRECT [[Redirect Test2]]' );
+ $this->insertPage( 'Redirect Test2' );
+ $this->insertPage( 'Redirect Test2 Worse Result' );
+
+ $this->insertPage( 'Talk:Sandbox' );
+ $this->insertPage( 'Talk:Example' );
+
+ $this->insertPage( 'User:Example' );
+
+ $this->insertPage( Title::makeTitle( self::NS_NONCAP, 'Bar' ) );
+ $this->insertPage( Title::makeTitle( self::NS_NONCAP, 'Upper' ) );
+ $this->insertPage( Title::makeTitle( self::NS_NONCAP, 'sandbox' ) );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ if ( !$this->isWikitextNS( NS_MAIN ) ) {
+ $this->markTestSkipped( 'Main namespace does not support wikitext.' );
+ }
+
+ // Avoid special pages from extensions interferring with the tests
+ $this->setMwGlobals( [
+ 'wgSpecialPages' => [],
+ 'wgHooks' => [],
+ 'wgExtraNamespaces' => [ self::NS_NONCAP => 'NonCap' ],
+ 'wgCapitalLinkOverrides' => [ self::NS_NONCAP => false ],
+ ] );
+
+ $this->originalHandlers = TestingAccessWrapper::newFromClass( Hooks::class )->handlers;
+ TestingAccessWrapper::newFromClass( Hooks::class )->handlers = [];
+
+ // Clear caches so that our new namespace appears
+ MWNamespace::clearCaches();
+ Language::factory( 'en' )->resetNamespaces();
+
+ SpecialPageFactory::resetList();
+ }
+
+ public function tearDown() {
+ MWNamespace::clearCaches();
+ Language::factory( 'en' )->resetNamespaces();
+
+ parent::tearDown();
+
+ TestingAccessWrapper::newFromClass( Hooks::class )->handlers = $this->originalHandlers;
+
+ SpecialPageFactory::resetList();
+ }
+
+ protected function searchProvision( array $results = null ) {
+ if ( $results === null ) {
+ $this->setMwGlobals( 'wgHooks', [] );
+ } else {
+ $this->setMwGlobals( 'wgHooks', [
+ 'PrefixSearchBackend' => [
+ function ( $namespaces, $search, $limit, &$srchres ) use ( $results ) {
+ $srchres = $results;
+ return false;
+ }
+ ],
+ ] );
+ }
+ }
+
+ public static function provideSearch() {
+ return [
+ [ [
+ 'Empty string',
+ 'query' => '',
+ 'results' => [],
+ ] ],
+ [ [
+ 'Main namespace with title prefix',
+ 'query' => 'Ex',
+ 'results' => [
+ 'Example',
+ 'Example/Baz',
+ 'Example Bar',
+ ],
+ // Third result when testing offset
+ 'offsetresult' => [
+ 'Example Foo',
+ ],
+ ] ],
+ [ [
+ 'Talk namespace prefix',
+ 'query' => 'Talk:',
+ 'results' => [
+ 'Talk:Example',
+ 'Talk:Sandbox',
+ ],
+ ] ],
+ [ [
+ 'User namespace prefix',
+ 'query' => 'User:',
+ 'results' => [
+ 'User:Example',
+ ],
+ ] ],
+ [ [
+ 'Special namespace prefix',
+ 'query' => 'Special:',
+ 'results' => [
+ 'Special:ActiveUsers',
+ 'Special:AllMessages',
+ 'Special:AllMyUploads',
+ ],
+ // Third result when testing offset
+ 'offsetresult' => [
+ 'Special:AllPages',
+ ],
+ ] ],
+ [ [
+ 'Special namespace with prefix',
+ 'query' => 'Special:Un',
+ 'results' => [
+ 'Special:Unblock',
+ 'Special:UncategorizedCategories',
+ 'Special:UncategorizedFiles',
+ ],
+ // Third result when testing offset
+ 'offsetresult' => [
+ 'Special:UncategorizedPages',
+ ],
+ ] ],
+ [ [
+ 'Special page name',
+ 'query' => 'Special:EditWatchlist',
+ 'results' => [
+ 'Special:EditWatchlist',
+ ],
+ ] ],
+ [ [
+ 'Special page subpages',
+ 'query' => 'Special:EditWatchlist/',
+ 'results' => [
+ 'Special:EditWatchlist/clear',
+ 'Special:EditWatchlist/raw',
+ ],
+ ] ],
+ [ [
+ 'Special page subpages with prefix',
+ 'query' => 'Special:EditWatchlist/cl',
+ 'results' => [
+ 'Special:EditWatchlist/clear',
+ ],
+ ] ],
+ [ [
+ 'Namespace with case sensitive first letter',
+ 'query' => 'NonCap:upper',
+ 'results' => []
+ ] ],
+ [ [
+ 'Multinamespace search',
+ 'query' => 'B',
+ 'results' => [
+ 'Bar',
+ 'NonCap:Bar',
+ ],
+ 'namespaces' => [ NS_MAIN, self::NS_NONCAP ],
+ ] ],
+ [ [
+ 'Multinamespace search with lowercase first letter',
+ 'query' => 'sand',
+ 'results' => [
+ 'Sandbox',
+ 'NonCap:sandbox',
+ ],
+ 'namespaces' => [ NS_MAIN, self::NS_NONCAP ],
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideSearch
+ * @covers PrefixSearch::search
+ * @covers PrefixSearch::searchBackend
+ */
+ public function testSearch( array $case ) {
+ $this->searchProvision( null );
+
+ $namespaces = isset( $case['namespaces'] ) ? $case['namespaces'] : [];
+
+ if ( wfGetDB( DB_REPLICA )->getType() === 'postgres' ) {
+ // Postgres will sort lexicographically on utf8 code units (" " before "/")
+ sort( $case['results'], SORT_STRING );
+ }
+
+ $searcher = new StringPrefixSearch;
+ $results = $searcher->search( $case['query'], 3, $namespaces );
+ $this->assertEquals(
+ $case['results'],
+ $results,
+ $case[0]
+ );
+ }
+
+ /**
+ * @dataProvider provideSearch
+ * @covers PrefixSearch::search
+ * @covers PrefixSearch::searchBackend
+ */
+ public function testSearchWithOffset( array $case ) {
+ $this->searchProvision( null );
+
+ $namespaces = isset( $case['namespaces'] ) ? $case['namespaces'] : [];
+
+ $searcher = new StringPrefixSearch;
+ $results = $searcher->search( $case['query'], 3, $namespaces, 1 );
+
+ if ( wfGetDB( DB_REPLICA )->getType() === 'postgres' ) {
+ // Postgres will sort lexicographically on utf8 code units (" " before "/")
+ sort( $case['results'], SORT_STRING );
+ }
+
+ // We don't expect the first result when offsetting
+ array_shift( $case['results'] );
+ // And sometimes we expect a different last result
+ $expected = isset( $case['offsetresult'] ) ?
+ array_merge( $case['results'], $case['offsetresult'] ) :
+ $case['results'];
+
+ $this->assertEquals(
+ $expected,
+ $results,
+ $case[0]
+ );
+ }
+
+ public static function provideSearchBackend() {
+ return [
+ [ [
+ 'Simple case',
+ 'provision' => [
+ 'Bar',
+ 'Barcelona',
+ 'Barbara',
+ ],
+ 'query' => 'Bar',
+ 'results' => [
+ 'Bar',
+ 'Barcelona',
+ 'Barbara',
+ ],
+ ] ],
+ [ [
+ 'Exact match not on top (T72958)',
+ 'provision' => [
+ 'Barcelona',
+ 'Bar',
+ 'Barbara',
+ ],
+ 'query' => 'Bar',
+ 'results' => [
+ 'Bar',
+ 'Barcelona',
+ 'Barbara',
+ ],
+ ] ],
+ [ [
+ 'Exact match missing (T72958)',
+ 'provision' => [
+ 'Barcelona',
+ 'Barbara',
+ 'Bart',
+ ],
+ 'query' => 'Bar',
+ 'results' => [
+ 'Bar',
+ 'Barcelona',
+ 'Barbara',
+ ],
+ ] ],
+ [ [
+ 'Exact match missing and not existing',
+ 'provision' => [
+ 'Exile',
+ 'Exist',
+ 'External',
+ ],
+ 'query' => 'Ex',
+ 'results' => [
+ 'Exile',
+ 'Exist',
+ 'External',
+ ],
+ ] ],
+ [ [
+ "Exact match shouldn't override already found match if " .
+ "exact is redirect and found isn't",
+ 'provision' => [
+ // Target of the exact match is low in the list
+ 'Redirect Test Worse Result',
+ 'Redirect Test',
+ ],
+ 'query' => 'redirect test',
+ 'results' => [
+ // Redirect target is pulled up and exact match isn't added
+ 'Redirect Test',
+ 'Redirect Test Worse Result',
+ ],
+ ] ],
+ [ [
+ "Exact match shouldn't override already found match if " .
+ "both exact match and found match are redirect",
+ 'provision' => [
+ // Another redirect to the same target as the exact match
+ // is low in the list
+ 'Redirect Test2 Worse Result',
+ 'Redirect test2',
+ ],
+ 'query' => 'redirect TEST2',
+ 'results' => [
+ // Found redirect is pulled to the top and exact match isn't
+ // added
+ 'Redirect test2',
+ 'Redirect Test2 Worse Result',
+ ],
+ ] ],
+ [ [
+ "Exact match should override any already found matches that " .
+ "are redirects to it",
+ 'provision' => [
+ // Another redirect to the same target as the exact match
+ // is low in the list
+ 'Redirect Test Worse Result',
+ 'Redirect test',
+ ],
+ 'query' => 'Redirect Test',
+ 'results' => [
+ // Found redirect is pulled to the top and exact match isn't
+ // added
+ 'Redirect Test',
+ 'Redirect Test Worse Result',
+ ],
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideSearchBackend
+ * @covers PrefixSearch::searchBackend
+ */
+ public function testSearchBackend( array $case ) {
+ $this->searchProvision( $case['provision'] );
+ $searcher = new StringPrefixSearch;
+ $results = $searcher->search( $case['query'], 3 );
+ $this->assertEquals(
+ $case['results'],
+ $results,
+ $case[0]
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/ReadOnlyModeTest.php b/www/wiki/tests/phpunit/includes/ReadOnlyModeTest.php
new file mode 100644
index 00000000..b14424fb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/ReadOnlyModeTest.php
@@ -0,0 +1,194 @@
+<?php
+
+/**
+ * @group Database
+ *
+ * @covers ReadOnlyMode
+ * @covers ConfiguredReadOnlyMode
+ */
+class ReadOnlyModeTest extends MediaWikiTestCase {
+ public function provider() {
+ $rawTests = [
+ 'None of anything' => [
+ 'confMessage' => null,
+ 'hasFileName' => false,
+ 'fileContents' => false,
+ 'lbMessage' => false,
+ 'expectedState' => false,
+ 'expectedMessage' => false,
+ 'expectedConfState' => false,
+ 'expectedConfMessage' => false
+ ],
+ 'File missing' => [
+ 'confMessage' => null,
+ 'hasFileName' => true,
+ 'fileContents' => false,
+ 'lbMessage' => false,
+ 'expectedState' => false,
+ 'expectedMessage' => false,
+ 'expectedConfState' => false,
+ 'expectedConfMessage' => false
+ ],
+ 'File empty' => [
+ 'confMessage' => null,
+ 'hasFileName' => true,
+ 'fileContents' => '',
+ 'lbMessage' => false,
+ 'expectedState' => false,
+ 'expectedMessage' => false,
+ 'expectedConfState' => false,
+ 'expectedConfMessage' => false
+ ],
+ 'File has message' => [
+ 'confMessage' => null,
+ 'hasFileName' => true,
+ 'fileContents' => 'Message',
+ 'lbMessage' => false,
+ 'expectedState' => true,
+ 'expectedMessage' => 'Message',
+ 'expectedConfState' => true,
+ 'expectedConfMessage' => 'Message',
+ ],
+ 'Conf has message' => [
+ 'confMessage' => 'Message',
+ 'hasFileName' => false,
+ 'fileContents' => false,
+ 'lbMessage' => false,
+ 'expectedState' => true,
+ 'expectedMessage' => 'Message',
+ 'expectedConfState' => true,
+ 'expectedConfMessage' => 'Message'
+ ],
+ "Conf=false means don't check the file" => [
+ 'confMessage' => false,
+ 'hasFileName' => true,
+ 'fileContents' => 'Message',
+ 'lbMessage' => false,
+ 'expectedState' => false,
+ 'expectedMessage' => false,
+ 'expectedConfState' => false,
+ 'expectedConfMessage' => false,
+ ],
+ 'LB has message' => [
+ 'confMessage' => null,
+ 'hasFileName' => false,
+ 'fileContents' => false,
+ 'lbMessage' => 'Message',
+ 'expectedState' => true,
+ 'expectedMessage' => 'Message',
+ 'expectedConfState' => false,
+ 'expectedConfMessage' => false
+ ],
+ 'All three have a message: conf wins' => [
+ 'confMessage' => 'conf',
+ 'hasFileName' => true,
+ 'fileContents' => 'file',
+ 'lbMessage' => 'lb',
+ 'expectedState' => true,
+ 'expectedMessage' => 'conf',
+ 'expectedConfState' => true,
+ 'expectedConfMessage' => 'conf'
+ ]
+ ];
+ $cookedTests = [];
+ foreach ( $rawTests as $desc => $test ) {
+ $cookedTests[$desc] = [ $test ];
+ }
+ return $cookedTests;
+ }
+
+ private function createMode( $params, $makeLB ) {
+ $config = new HashConfig( [
+ 'ReadOnly' => $params['confMessage'],
+ 'ReadOnlyFile' => $this->createFile( $params ),
+ ] );
+
+ $rom = new ConfiguredReadOnlyMode( $config );
+
+ if ( $makeLB ) {
+ $lb = $this->createLB( $params );
+ $rom = new ReadOnlyMode( $rom, $lb );
+ }
+
+ return $rom;
+ }
+
+ private function createLB( $params ) {
+ $lb = $this->getMockBuilder( \Wikimedia\Rdbms\LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $lb->expects( $this->any() )->method( 'getReadOnlyReason' )
+ ->willReturn( $params['lbMessage'] );
+ return $lb;
+ }
+
+ private function createFile( $params ) {
+ if ( $params['hasFileName'] ) {
+ $fileName = $this->getNewTempFile();
+
+ if ( $params['fileContents'] === false ) {
+ unlink( $fileName );
+ } else {
+ file_put_contents( $fileName, $params['fileContents'] );
+ }
+ } else {
+ $fileName = null;
+ }
+ return $fileName;
+ }
+
+ /**
+ * @dataProvider provider
+ */
+ public function testWithLB( $params ) {
+ $rom = $this->createMode( $params, true );
+ $this->assertSame( $params['expectedMessage'], $rom->getReason() );
+ $this->assertSame( $params['expectedState'], $rom->isReadOnly() );
+ }
+
+ /**
+ * @dataProvider provider
+ */
+ public function testWithoutLB( $params ) {
+ $cro = $this->createMode( $params, false );
+ $this->assertSame( $params['expectedConfMessage'], $cro->getReason() );
+ $this->assertSame( $params['expectedConfState'], $cro->isReadOnly() );
+ }
+
+ public function testSetReadOnlyReason() {
+ $rom = $this->createMode(
+ [
+ 'confMessage' => 'conf',
+ 'hasFileName' => false,
+ 'fileContents' => false,
+ 'lbMessage' => 'lb'
+ ],
+ true );
+ $rom->setReason( 'override' );
+ $this->assertSame( 'override', $rom->getReason() );
+ }
+
+ /**
+ * @covers ReadOnlyMode::clearCache
+ * @covers ConfiguredReadOnlyMode::clearCache
+ */
+ public function testClearCache() {
+ $fileName = $this->getNewTempFile();
+ unlink( $fileName );
+ $config = new HashConfig( [
+ 'ReadOnly' => null,
+ 'ReadOnlyFile' => $fileName,
+ ] );
+ $cro = new ConfiguredReadOnlyMode( $config );
+ $lb = $this->createLB( [ 'lbMessage' => false ] );
+ $rom = new ReadOnlyMode( $cro, $lb );
+
+ $this->assertSame( false, $rom->getReason(), 'initial' );
+
+ file_put_contents( $fileName, 'file' );
+ $this->assertSame( false, $rom->getReason(), 'stale' );
+
+ $rom->clearCache();
+ $this->assertSame( 'file', $rom->getReason(), 'fresh' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/RevisionContentHandlerDbTest.php b/www/wiki/tests/phpunit/includes/RevisionContentHandlerDbTest.php
new file mode 100644
index 00000000..fa0153d3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/RevisionContentHandlerDbTest.php
@@ -0,0 +1,14 @@
+<?php
+
+/**
+ * @group Database
+ * @group medium
+ * @group ContentHandler
+ */
+class RevisionContentHandlerDbTest extends RevisionDbTestBase {
+
+ protected function getContentHandlerUseDB() {
+ return true;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/RevisionDbTestBase.php b/www/wiki/tests/phpunit/includes/RevisionDbTestBase.php
new file mode 100644
index 00000000..5de34d1b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/RevisionDbTestBase.php
@@ -0,0 +1,1505 @@
+<?php
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\RevisionStore;
+use MediaWiki\Storage\IncompleteRevisionException;
+use MediaWiki\Storage\RevisionRecord;
+
+/**
+ * RevisionDbTestBase contains test cases for the Revision class that have Database interactions.
+ *
+ * @group Database
+ * @group medium
+ */
+abstract class RevisionDbTestBase extends MediaWikiTestCase {
+
+ /**
+ * @var WikiPage $testPage
+ */
+ private $testPage;
+
+ public function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+
+ $this->tablesUsed = array_merge( $this->tablesUsed,
+ [
+ 'page',
+ 'revision',
+ 'ip_changes',
+ 'text',
+ 'archive',
+
+ 'recentchanges',
+ 'logging',
+
+ 'page_props',
+ 'pagelinks',
+ 'categorylinks',
+ 'langlinks',
+ 'externallinks',
+ 'imagelinks',
+ 'templatelinks',
+ 'iwlinks'
+ ]
+ );
+ }
+
+ protected function setUp() {
+ global $wgContLang;
+
+ parent::setUp();
+
+ $this->mergeMwGlobalArrayValue(
+ 'wgExtraNamespaces',
+ [
+ 12312 => 'Dummy',
+ 12313 => 'Dummy_talk',
+ ]
+ );
+
+ $this->mergeMwGlobalArrayValue(
+ 'wgNamespaceContentModels',
+ [
+ 12312 => DummyContentForTesting::MODEL_ID,
+ ]
+ );
+
+ $this->mergeMwGlobalArrayValue(
+ 'wgContentHandlers',
+ [
+ DummyContentForTesting::MODEL_ID => 'DummyContentHandlerForTesting',
+ RevisionTestModifyableContent::MODEL_ID => 'RevisionTestModifyableContentHandler',
+ ]
+ );
+
+ $this->setMwGlobals( 'wgContentHandlerUseDB', $this->getContentHandlerUseDB() );
+
+ MWNamespace::clearCaches();
+ // Reset namespace cache
+ $wgContLang->resetNamespaces();
+
+ if ( !$this->testPage ) {
+ /**
+ * We have to create a new page for each subclass as the page creation may result
+ * in different DB fields being filled based on configuration.
+ */
+ $this->testPage = $this->createPage( __CLASS__, __CLASS__ );
+ }
+ }
+
+ protected function tearDown() {
+ global $wgContLang;
+
+ parent::tearDown();
+
+ MWNamespace::clearCaches();
+ // Reset namespace cache
+ $wgContLang->resetNamespaces();
+ }
+
+ abstract protected function getContentHandlerUseDB();
+
+ private function makeRevisionWithProps( $props = null ) {
+ if ( $props === null ) {
+ $props = [];
+ }
+
+ if ( !isset( $props['content'] ) && !isset( $props['text'] ) ) {
+ $props['text'] = 'Lorem Ipsum';
+ }
+
+ if ( !isset( $props['user_text'] ) ) {
+ $user = $this->getTestUser()->getUser();
+ $props['user_text'] = $user->getName();
+ $props['user'] = $user->getId();
+ }
+
+ if ( !isset( $props['user'] ) ) {
+ $props['user'] = 0;
+ }
+
+ if ( !isset( $props['comment'] ) ) {
+ $props['comment'] = 'just a test';
+ }
+
+ if ( !isset( $props['page'] ) ) {
+ $props['page'] = $this->testPage->getId();
+ }
+
+ if ( !isset( $props['content_model'] ) ) {
+ $props['content_model'] = CONTENT_MODEL_WIKITEXT;
+ }
+
+ $rev = new Revision( $props );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $rev->insertOn( $dbw );
+
+ return $rev;
+ }
+
+ /**
+ * @param string $titleString
+ * @param string $text
+ * @param string|null $model
+ *
+ * @return WikiPage
+ */
+ private function createPage( $titleString, $text, $model = null ) {
+ if ( !preg_match( '/:/', $titleString ) &&
+ ( $model === null || $model === CONTENT_MODEL_WIKITEXT )
+ ) {
+ $ns = $this->getDefaultWikitextNS();
+ $titleString = MWNamespace::getCanonicalName( $ns ) . ':' . $titleString;
+ }
+
+ $title = Title::newFromText( $titleString );
+ $wikipage = new WikiPage( $title );
+
+ // Delete the article if it already exists
+ if ( $wikipage->exists() ) {
+ $wikipage->doDeleteArticle( "done" );
+ }
+
+ $content = ContentHandler::makeContent( $text, $title, $model );
+ $wikipage->doEditContent( $content, __METHOD__, EDIT_NEW );
+
+ return $wikipage;
+ }
+
+ private function assertRevEquals( Revision $orig, Revision $rev = null ) {
+ $this->assertNotNull( $rev, 'missing revision' );
+
+ $this->assertEquals( $orig->getId(), $rev->getId() );
+ $this->assertEquals( $orig->getPage(), $rev->getPage() );
+ $this->assertEquals( $orig->getTimestamp(), $rev->getTimestamp() );
+ $this->assertEquals( $orig->getUser(), $rev->getUser() );
+ $this->assertEquals( $orig->getContentModel(), $rev->getContentModel() );
+ $this->assertEquals( $orig->getContentFormat(), $rev->getContentFormat() );
+ $this->assertEquals( $orig->getSha1(), $rev->getSha1() );
+ }
+
+ /**
+ * @covers Revision::getRecentChange
+ */
+ public function testGetRecentChange() {
+ $rev = $this->testPage->getRevision();
+ $recentChange = $rev->getRecentChange();
+
+ // Make sure various attributes look right / the correct entry has been retrieved.
+ $this->assertEquals( $rev->getTimestamp(), $recentChange->getAttribute( 'rc_timestamp' ) );
+ $this->assertEquals(
+ $rev->getTitle()->getNamespace(),
+ $recentChange->getAttribute( 'rc_namespace' )
+ );
+ $this->assertEquals(
+ $rev->getTitle()->getDBkey(),
+ $recentChange->getAttribute( 'rc_title' )
+ );
+ $this->assertEquals( $rev->getUser(), $recentChange->getAttribute( 'rc_user' ) );
+ $this->assertEquals( $rev->getUserText(), $recentChange->getAttribute( 'rc_user_text' ) );
+ $this->assertEquals( $rev->getComment(), $recentChange->getAttribute( 'rc_comment' ) );
+ $this->assertEquals( $rev->getPage(), $recentChange->getAttribute( 'rc_cur_id' ) );
+ $this->assertEquals( $rev->getId(), $recentChange->getAttribute( 'rc_this_oldid' ) );
+ }
+
+ /**
+ * @covers Revision::insertOn
+ */
+ public function testInsertOn_success() {
+ $parentId = $this->testPage->getLatest();
+
+ // If an ExternalStore is set don't use it.
+ $this->setMwGlobals( 'wgDefaultExternalStore', false );
+
+ $rev = new Revision( [
+ 'page' => $this->testPage->getId(),
+ 'title' => $this->testPage->getTitle(),
+ 'text' => 'Revision Text',
+ 'comment' => 'Revision comment',
+ ] );
+
+ $revId = $rev->insertOn( wfGetDB( DB_MASTER ) );
+
+ $this->assertInternalType( 'integer', $revId );
+ $this->assertSame( $revId, $rev->getId() );
+
+ // getTextId() must be an int!
+ $this->assertInternalType( 'integer', $rev->getTextId() );
+
+ $mainSlot = $rev->getRevisionRecord()->getSlot( 'main', RevisionRecord::RAW );
+
+ // we currently only support storage in the text table
+ $textId = MediaWikiServices::getInstance()
+ ->getBlobStore()
+ ->getTextIdFromAddress( $mainSlot->getAddress() );
+
+ $this->assertSelect(
+ 'text',
+ [ 'old_id', 'old_text' ],
+ "old_id = $textId",
+ [ [ strval( $textId ), 'Revision Text' ] ]
+ );
+ $this->assertSelect(
+ 'revision',
+ [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ ],
+ "rev_id = {$rev->getId()}",
+ [ [
+ strval( $rev->getId() ),
+ strval( $this->testPage->getId() ),
+ strval( $textId ),
+ '0',
+ '0',
+ '13',
+ strval( $parentId ),
+ 's0ngbdoxagreuf2vjtuxzwdz64n29xm',
+ ] ]
+ );
+ }
+
+ /**
+ * @covers Revision::insertOn
+ */
+ public function testInsertOn_exceptionOnNoPage() {
+ // If an ExternalStore is set don't use it.
+ $this->setMwGlobals( 'wgDefaultExternalStore', false );
+ $this->setExpectedException(
+ IncompleteRevisionException::class,
+ "rev_page field must not be 0!"
+ );
+
+ $title = Title::newFromText( 'Nonexistant-' . __METHOD__ );
+ $rev = new Revision( [], 0, $title );
+
+ $rev->insertOn( wfGetDB( DB_MASTER ) );
+ }
+
+ /**
+ * @covers Revision::newFromTitle
+ */
+ public function testNewFromTitle_withoutId() {
+ $latestRevId = $this->testPage->getLatest();
+
+ $rev = Revision::newFromTitle( $this->testPage->getTitle() );
+
+ $this->assertTrue( $this->testPage->getTitle()->equals( $rev->getTitle() ) );
+ $this->assertEquals( $latestRevId, $rev->getId() );
+ }
+
+ /**
+ * @covers Revision::newFromTitle
+ */
+ public function testNewFromTitle_withId() {
+ $latestRevId = $this->testPage->getLatest();
+
+ $rev = Revision::newFromTitle( $this->testPage->getTitle(), $latestRevId );
+
+ $this->assertTrue( $this->testPage->getTitle()->equals( $rev->getTitle() ) );
+ $this->assertEquals( $latestRevId, $rev->getId() );
+ }
+
+ /**
+ * @covers Revision::newFromTitle
+ */
+ public function testNewFromTitle_withBadId() {
+ $latestRevId = $this->testPage->getLatest();
+
+ $rev = Revision::newFromTitle( $this->testPage->getTitle(), $latestRevId + 1 );
+
+ $this->assertNull( $rev );
+ }
+
+ /**
+ * @covers Revision::newFromRow
+ */
+ public function testNewFromRow() {
+ $orig = $this->makeRevisionWithProps();
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $revQuery = Revision::getQueryInfo();
+ $res = $dbr->select( $revQuery['tables'], $revQuery['fields'], [ 'rev_id' => $orig->getId() ],
+ __METHOD__, [], $revQuery['joins'] );
+ $this->assertTrue( is_object( $res ), 'query failed' );
+
+ $row = $res->fetchObject();
+ $res->free();
+
+ $rev = Revision::newFromRow( $row );
+
+ $this->assertRevEquals( $orig, $rev );
+ }
+
+ public function provideNewFromArchiveRow() {
+ yield [
+ function ( $f ) {
+ return $f;
+ },
+ ];
+ yield [
+ function ( $f ) {
+ return $f + [ 'ar_namespace', 'ar_title' ];
+ },
+ ];
+ yield [
+ function ( $f ) {
+ unset( $f['ar_text_id'] );
+ return $f;
+ },
+ ];
+ yield [
+ function ( $f ) {
+ unset( $f['ar_page_id'] );
+ return $f;
+ },
+ ];
+ yield [
+ function ( $f ) {
+ unset( $f['ar_parent_id'] );
+ return $f;
+ },
+ ];
+ yield [
+ function ( $f ) {
+ unset( $f['ar_rev_id'] );
+ return $f;
+ },
+ ];
+ yield [
+ function ( $f ) {
+ unset( $f['ar_sha1'] );
+ return $f;
+ },
+ ];
+ }
+
+ /**
+ * @dataProvider provideNewFromArchiveRow
+ * @covers Revision::newFromArchiveRow
+ */
+ public function testNewFromArchiveRow( $selectModifier ) {
+ $services = MediaWikiServices::getInstance();
+
+ $store = new RevisionStore(
+ $services->getDBLoadBalancer(),
+ $services->getService( '_SqlBlobStore' ),
+ $services->getMainWANObjectCache(),
+ $services->getCommentStore(),
+ $services->getActorMigration()
+ );
+
+ $store->setContentHandlerUseDB( $this->getContentHandlerUseDB() );
+ $this->setService( 'RevisionStore', $store );
+
+ $page = $this->createPage(
+ 'RevisionStorageTest_testNewFromArchiveRow',
+ 'Lorem Ipsum',
+ CONTENT_MODEL_WIKITEXT
+ );
+ $orig = $page->getRevision();
+ $page->doDeleteArticle( 'test Revision::newFromArchiveRow' );
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $arQuery = Revision::getArchiveQueryInfo();
+ $arQuery['fields'] = $selectModifier( $arQuery['fields'] );
+ $res = $dbr->select(
+ $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ],
+ __METHOD__, [], $arQuery['joins']
+ );
+ $this->assertTrue( is_object( $res ), 'query failed' );
+
+ $row = $res->fetchObject();
+ $res->free();
+
+ // MCR migration note: $row is now required to contain ar_title and ar_namespace.
+ // Alternatively, a Title object can be passed to RevisionStore::newRevisionFromArchiveRow
+ $rev = Revision::newFromArchiveRow( $row );
+
+ $this->assertRevEquals( $orig, $rev );
+ }
+
+ /**
+ * @covers Revision::newFromArchiveRow
+ */
+ public function testNewFromArchiveRowOverrides() {
+ $page = $this->createPage(
+ 'RevisionStorageTest_testNewFromArchiveRow',
+ 'Lorem Ipsum',
+ CONTENT_MODEL_WIKITEXT
+ );
+ $orig = $page->getRevision();
+ $page->doDeleteArticle( 'test Revision::newFromArchiveRow' );
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $arQuery = Revision::getArchiveQueryInfo();
+ $res = $dbr->select(
+ $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ],
+ __METHOD__, [], $arQuery['joins']
+ );
+ $this->assertTrue( is_object( $res ), 'query failed' );
+
+ $row = $res->fetchObject();
+ $res->free();
+
+ $rev = Revision::newFromArchiveRow( $row, [ 'comment_text' => 'SOMEOVERRIDE' ] );
+
+ $this->assertNotEquals( $orig->getComment(), $rev->getComment() );
+ $this->assertEquals( 'SOMEOVERRIDE', $rev->getComment() );
+ }
+
+ /**
+ * @covers Revision::newFromId
+ */
+ public function testNewFromId() {
+ $orig = $this->testPage->getRevision();
+ $rev = Revision::newFromId( $orig->getId() );
+ $this->assertRevEquals( $orig, $rev );
+ }
+
+ /**
+ * @covers Revision::newFromPageId
+ */
+ public function testNewFromPageId() {
+ $rev = Revision::newFromPageId( $this->testPage->getId() );
+ $this->assertRevEquals(
+ $this->testPage->getRevision(),
+ $rev
+ );
+ }
+
+ /**
+ * @covers Revision::newFromPageId
+ */
+ public function testNewFromPageIdWithLatestId() {
+ $rev = Revision::newFromPageId(
+ $this->testPage->getId(),
+ $this->testPage->getLatest()
+ );
+ $this->assertRevEquals(
+ $this->testPage->getRevision(),
+ $rev
+ );
+ }
+
+ /**
+ * @covers Revision::newFromPageId
+ */
+ public function testNewFromPageIdWithNotLatestId() {
+ $content = new WikitextContent( __METHOD__ );
+ $this->testPage->doEditContent( $content, __METHOD__ );
+ $rev = Revision::newFromPageId(
+ $this->testPage->getId(),
+ $this->testPage->getRevision()->getPrevious()->getId()
+ );
+ $this->assertRevEquals(
+ $this->testPage->getRevision()->getPrevious(),
+ $rev
+ );
+ }
+
+ /**
+ * @covers Revision::fetchRevision
+ */
+ public function testFetchRevision() {
+ // Hidden process cache assertion below
+ $this->testPage->getRevision()->getId();
+
+ $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+ $id = $this->testPage->getRevision()->getId();
+
+ $this->hideDeprecated( 'Revision::fetchRevision' );
+ $res = Revision::fetchRevision( $this->testPage->getTitle() );
+
+ # note: order is unspecified
+ $rows = [];
+ while ( ( $row = $res->fetchObject() ) ) {
+ $rows[$row->rev_id] = $row;
+ }
+
+ $this->assertEmpty( $rows, 'expected empty set' );
+ }
+
+ /**
+ * @covers Revision::getPage
+ */
+ public function testGetPage() {
+ $page = $this->testPage;
+
+ $orig = $this->makeRevisionWithProps( [ 'page' => $page->getId() ] );
+ $rev = Revision::newFromId( $orig->getId() );
+
+ $this->assertEquals( $page->getId(), $rev->getPage() );
+ }
+
+ /**
+ * @covers Revision::isCurrent
+ */
+ public function testIsCurrent() {
+ $rev1 = $this->testPage->getRevision();
+
+ # @todo find out if this should be true
+ # $this->assertTrue( $rev1->isCurrent() );
+
+ $rev1x = Revision::newFromId( $rev1->getId() );
+ $this->assertTrue( $rev1x->isCurrent() );
+
+ $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+ $rev2 = $this->testPage->getRevision();
+
+ # @todo find out if this should be true
+ # $this->assertTrue( $rev2->isCurrent() );
+
+ $rev1x = Revision::newFromId( $rev1->getId() );
+ $this->assertFalse( $rev1x->isCurrent() );
+
+ $rev2x = Revision::newFromId( $rev2->getId() );
+ $this->assertTrue( $rev2x->isCurrent() );
+ }
+
+ /**
+ * @covers Revision::getPrevious
+ */
+ public function testGetPrevious() {
+ $oldestRevision = $this->testPage->getOldestRevision();
+ $latestRevision = $this->testPage->getLatest();
+
+ $this->assertNull( $oldestRevision->getPrevious() );
+
+ $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+ $newRevision = $this->testPage->getRevision();
+
+ $this->assertNotNull( $newRevision->getPrevious() );
+ $this->assertEquals( $latestRevision, $newRevision->getPrevious()->getId() );
+ }
+
+ /**
+ * @covers Revision::getNext
+ */
+ public function testGetNext() {
+ $rev1 = $this->testPage->getRevision();
+
+ $this->assertNull( $rev1->getNext() );
+
+ $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+ $rev2 = $this->testPage->getRevision();
+
+ $this->assertNotNull( $rev1->getNext() );
+ $this->assertEquals( $rev2->getId(), $rev1->getNext()->getId() );
+ }
+
+ /**
+ * @covers Revision::newNullRevision
+ */
+ public function testNewNullRevision() {
+ $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+ $orig = $this->testPage->getRevision();
+
+ $dbw = wfGetDB( DB_MASTER );
+ $rev = Revision::newNullRevision( $dbw, $this->testPage->getId(), 'a null revision', false );
+
+ $this->assertNotEquals( $orig->getId(), $rev->getId(),
+ 'new null revision should have a different id from the original revision' );
+ $this->assertEquals( $orig->getTextId(), $rev->getTextId(),
+ 'new null revision should have the same text id as the original revision' );
+ $this->assertEquals( $orig->getSha1(), $rev->getSha1(),
+ 'new null revision should have the same SHA1 as the original revision' );
+ $this->assertTrue( $orig->getRevisionRecord()->hasSameContent( $rev->getRevisionRecord() ),
+ 'new null revision should have the same content as the original revision' );
+ $this->assertEquals( __METHOD__, $rev->getContent()->getNativeData() );
+ }
+
+ /**
+ * @covers Revision::newNullRevision
+ */
+ public function testNewNullRevision_badPage() {
+ $dbw = wfGetDB( DB_MASTER );
+ $rev = Revision::newNullRevision( $dbw, -1, 'a null revision', false );
+
+ $this->assertNull( $rev );
+ }
+
+ /**
+ * @covers Revision::insertOn
+ */
+ public function testInsertOn() {
+ $ip = '2600:387:ed7:947e:8c16:a1ad:dd34:1dd7';
+
+ $orig = $this->makeRevisionWithProps( [
+ 'user_text' => $ip
+ ] );
+
+ // Make sure the revision was copied to ip_changes
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select( 'ip_changes', '*', [ 'ipc_rev_id' => $orig->getId() ] );
+ $row = $res->fetchObject();
+
+ $this->assertEquals( IP::toHex( $ip ), $row->ipc_hex );
+ $this->assertEquals(
+ $orig->getTimestamp(),
+ wfTimestamp( TS_MW, $row->ipc_rev_timestamp )
+ );
+ }
+
+ public static function provideUserWasLastToEdit() {
+ yield 'actually the last edit' => [ 3, true ];
+ yield 'not the current edit, but still by this user' => [ 2, true ];
+ yield 'edit by another user' => [ 1, false ];
+ yield 'first edit, by this user, but another user edited in the mean time' => [ 0, false ];
+ }
+
+ /**
+ * @covers Revision::userWasLastToEdit
+ * @dataProvider provideUserWasLastToEdit
+ */
+ public function testUserWasLastToEdit( $sinceIdx, $expectedLast ) {
+ $userA = User::newFromName( "RevisionStorageTest_userA" );
+ $userB = User::newFromName( "RevisionStorageTest_userB" );
+
+ if ( $userA->getId() === 0 ) {
+ $userA = User::createNew( $userA->getName() );
+ }
+
+ if ( $userB->getId() === 0 ) {
+ $userB = User::createNew( $userB->getName() );
+ }
+
+ $ns = $this->getDefaultWikitextNS();
+
+ $dbw = wfGetDB( DB_MASTER );
+ $revisions = [];
+
+ // create revisions -----------------------------
+ $page = WikiPage::factory( Title::newFromText(
+ 'RevisionStorageTest_testUserWasLastToEdit', $ns ) );
+ $page->insertOn( $dbw );
+
+ $revisions[0] = new Revision( [
+ 'page' => $page->getId(),
+ // we need the title to determine the page's default content model
+ 'title' => $page->getTitle(),
+ 'timestamp' => '20120101000000',
+ 'user' => $userA->getId(),
+ 'text' => 'zero',
+ 'content_model' => CONTENT_MODEL_WIKITEXT,
+ 'comment' => 'edit zero'
+ ] );
+ $revisions[0]->insertOn( $dbw );
+
+ $revisions[1] = new Revision( [
+ 'page' => $page->getId(),
+ // still need the title, because $page->getId() is 0 (there's no entry in the page table)
+ 'title' => $page->getTitle(),
+ 'timestamp' => '20120101000100',
+ 'user' => $userA->getId(),
+ 'text' => 'one',
+ 'content_model' => CONTENT_MODEL_WIKITEXT,
+ 'comment' => 'edit one'
+ ] );
+ $revisions[1]->insertOn( $dbw );
+
+ $revisions[2] = new Revision( [
+ 'page' => $page->getId(),
+ 'title' => $page->getTitle(),
+ 'timestamp' => '20120101000200',
+ 'user' => $userB->getId(),
+ 'text' => 'two',
+ 'content_model' => CONTENT_MODEL_WIKITEXT,
+ 'comment' => 'edit two'
+ ] );
+ $revisions[2]->insertOn( $dbw );
+
+ $revisions[3] = new Revision( [
+ 'page' => $page->getId(),
+ 'title' => $page->getTitle(),
+ 'timestamp' => '20120101000300',
+ 'user' => $userA->getId(),
+ 'text' => 'three',
+ 'content_model' => CONTENT_MODEL_WIKITEXT,
+ 'comment' => 'edit three'
+ ] );
+ $revisions[3]->insertOn( $dbw );
+
+ $revisions[4] = new Revision( [
+ 'page' => $page->getId(),
+ 'title' => $page->getTitle(),
+ 'timestamp' => '20120101000200',
+ 'user' => $userA->getId(),
+ 'text' => 'zero',
+ 'content_model' => CONTENT_MODEL_WIKITEXT,
+ 'comment' => 'edit four'
+ ] );
+ $revisions[4]->insertOn( $dbw );
+
+ // test it ---------------------------------
+ $since = $revisions[$sinceIdx]->getTimestamp();
+
+ $revQuery = Revision::getQueryInfo();
+ $allRows = iterator_to_array( $dbw->select(
+ $revQuery['tables'],
+ [ 'rev_id', 'rev_timestamp', 'rev_user' => $revQuery['fields']['rev_user'] ],
+ [
+ 'rev_page' => $page->getId(),
+ //'rev_timestamp > ' . $dbw->addQuotes( $dbw->timestamp( $since ) )
+ ],
+ __METHOD__,
+ [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ],
+ $revQuery['joins']
+ ) );
+
+ $wasLast = Revision::userWasLastToEdit( $dbw, $page->getId(), $userA->getId(), $since );
+
+ $this->assertEquals( $expectedLast, $wasLast );
+ }
+
+ /**
+ * @param string $text
+ * @param string $title
+ * @param string $model
+ * @param string $format
+ *
+ * @return Revision
+ */
+ private function newTestRevision( $text, $title = "Test",
+ $model = CONTENT_MODEL_WIKITEXT, $format = null
+ ) {
+ if ( is_string( $title ) ) {
+ $title = Title::newFromText( $title );
+ }
+
+ $content = ContentHandler::makeContent( $text, $title, $model, $format );
+
+ $rev = new Revision(
+ [
+ 'id' => 42,
+ 'page' => 23,
+ 'title' => $title,
+
+ 'content' => $content,
+ 'length' => $content->getSize(),
+ 'comment' => "testing",
+ 'minor_edit' => false,
+
+ 'content_format' => $format,
+ ]
+ );
+
+ return $rev;
+ }
+
+ public function provideGetContentModel() {
+ // NOTE: we expect the help namespace to always contain wikitext
+ return [
+ [ 'hello world', 'Help:Hello', null, null, CONTENT_MODEL_WIKITEXT ],
+ [ 'hello world', 'User:hello/there.css', null, null, CONTENT_MODEL_CSS ],
+ [ serialize( 'hello world' ), 'Dummy:Hello', null, null, DummyContentForTesting::MODEL_ID ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetContentModel
+ * @covers Revision::getContentModel
+ */
+ public function testGetContentModel( $text, $title, $model, $format, $expectedModel ) {
+ $rev = $this->newTestRevision( $text, $title, $model, $format );
+
+ $this->assertEquals( $expectedModel, $rev->getContentModel() );
+ }
+
+ public function provideGetContentFormat() {
+ // NOTE: we expect the help namespace to always contain wikitext
+ return [
+ [ 'hello world', 'Help:Hello', null, null, CONTENT_FORMAT_WIKITEXT ],
+ [ 'hello world', 'Help:Hello', CONTENT_MODEL_CSS, null, CONTENT_FORMAT_CSS ],
+ [ 'hello world', 'User:hello/there.css', null, null, CONTENT_FORMAT_CSS ],
+ [ serialize( 'hello world' ), 'Dummy:Hello', null, null, DummyContentForTesting::MODEL_ID ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetContentFormat
+ * @covers Revision::getContentFormat
+ */
+ public function testGetContentFormat( $text, $title, $model, $format, $expectedFormat ) {
+ $rev = $this->newTestRevision( $text, $title, $model, $format );
+
+ $this->assertEquals( $expectedFormat, $rev->getContentFormat() );
+ }
+
+ public function provideGetContentHandler() {
+ // NOTE: we expect the help namespace to always contain wikitext
+ return [
+ [ 'hello world', 'Help:Hello', null, null, WikitextContentHandler::class ],
+ [ 'hello world', 'User:hello/there.css', null, null, CssContentHandler::class ],
+ [ serialize( 'hello world' ), 'Dummy:Hello', null, null, DummyContentHandlerForTesting::class ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetContentHandler
+ * @covers Revision::getContentHandler
+ */
+ public function testGetContentHandler( $text, $title, $model, $format, $expectedClass ) {
+ $rev = $this->newTestRevision( $text, $title, $model, $format );
+
+ $this->assertEquals( $expectedClass, get_class( $rev->getContentHandler() ) );
+ }
+
+ public function provideGetContent() {
+ // NOTE: we expect the help namespace to always contain wikitext
+ return [
+ [ 'hello world', 'Help:Hello', null, null, Revision::FOR_PUBLIC, 'hello world' ],
+ [
+ serialize( 'hello world' ),
+ 'Hello',
+ DummyContentForTesting::MODEL_ID,
+ null,
+ Revision::FOR_PUBLIC,
+ serialize( 'hello world' )
+ ],
+ [
+ serialize( 'hello world' ),
+ 'Dummy:Hello',
+ null,
+ null,
+ Revision::FOR_PUBLIC,
+ serialize( 'hello world' )
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetContent
+ * @covers Revision::getContent
+ */
+ public function testGetContent( $text, $title, $model, $format,
+ $audience, $expectedSerialization
+ ) {
+ $rev = $this->newTestRevision( $text, $title, $model, $format );
+ $content = $rev->getContent( $audience );
+
+ $this->assertEquals(
+ $expectedSerialization,
+ is_null( $content ) ? null : $content->serialize( $format )
+ );
+ }
+
+ /**
+ * @covers Revision::getContent
+ */
+ public function testGetContent_failure() {
+ $rev = new Revision( [
+ 'page' => $this->testPage->getId(),
+ 'content_model' => $this->testPage->getContentModel(),
+ 'text_id' => 123456789, // not in the test DB
+ ] );
+
+ Wikimedia\suppressWarnings(); // bad text_id will trigger a warning.
+
+ $this->assertNull( $rev->getContent(),
+ "getContent() should return null if the revision's text blob could not be loaded." );
+
+ // NOTE: check this twice, once for lazy initialization, and once with the cached value.
+ $this->assertNull( $rev->getContent(),
+ "getContent() should return null if the revision's text blob could not be loaded." );
+
+ Wikimedia\restoreWarnings();
+ }
+
+ public function provideGetSize() {
+ return [
+ [ "hello world.", CONTENT_MODEL_WIKITEXT, 12 ],
+ [ serialize( "hello world." ), DummyContentForTesting::MODEL_ID, 12 ],
+ ];
+ }
+
+ /**
+ * @covers Revision::getSize
+ * @dataProvider provideGetSize
+ */
+ public function testGetSize( $text, $model, $expected_size ) {
+ $rev = $this->newTestRevision( $text, 'RevisionTest_testGetSize', $model );
+ $this->assertEquals( $expected_size, $rev->getSize() );
+ }
+
+ public function provideGetSha1() {
+ return [
+ [ "hello world.", CONTENT_MODEL_WIKITEXT, Revision::base36Sha1( "hello world." ) ],
+ [
+ serialize( "hello world." ),
+ DummyContentForTesting::MODEL_ID,
+ Revision::base36Sha1( serialize( "hello world." ) )
+ ],
+ ];
+ }
+
+ /**
+ * @covers Revision::getSha1
+ * @dataProvider provideGetSha1
+ */
+ public function testGetSha1( $text, $model, $expected_hash ) {
+ $rev = $this->newTestRevision( $text, 'RevisionTest_testGetSha1', $model );
+ $this->assertEquals( $expected_hash, $rev->getSha1() );
+ }
+
+ /**
+ * Tests whether $rev->getContent() returns a clone when needed.
+ *
+ * @covers Revision::getContent
+ */
+ public function testGetContentClone() {
+ $content = new RevisionTestModifyableContent( "foo" );
+
+ $rev = new Revision(
+ [
+ 'id' => 42,
+ 'page' => 23,
+ 'title' => Title::newFromText( "testGetContentClone_dummy" ),
+
+ 'content' => $content,
+ 'length' => $content->getSize(),
+ 'comment' => "testing",
+ 'minor_edit' => false,
+ ]
+ );
+
+ /** @var RevisionTestModifyableContent $content */
+ $content = $rev->getContent( Revision::RAW );
+ $content->setText( "bar" );
+
+ /** @var RevisionTestModifyableContent $content2 */
+ $content2 = $rev->getContent( Revision::RAW );
+ // content is mutable, expect clone
+ $this->assertNotSame( $content, $content2, "expected a clone" );
+ // clone should contain the original text
+ $this->assertEquals( "foo", $content2->getText() );
+
+ $content2->setText( "bla bla" );
+ // clones should be independent
+ $this->assertEquals( "bar", $content->getText() );
+ }
+
+ /**
+ * Tests whether $rev->getContent() returns the same object repeatedly if appropriate.
+ * @covers Revision::getContent
+ */
+ public function testGetContentUncloned() {
+ $rev = $this->newTestRevision( "hello", "testGetContentUncloned_dummy", CONTENT_MODEL_WIKITEXT );
+ $content = $rev->getContent( Revision::RAW );
+ $content2 = $rev->getContent( Revision::RAW );
+
+ // for immutable content like wikitext, this should be the same object
+ $this->assertSame( $content, $content2 );
+ }
+
+ /**
+ * @covers Revision::loadFromId
+ */
+ public function testLoadFromId() {
+ $rev = $this->testPage->getRevision();
+ $this->hideDeprecated( 'Revision::loadFromId' );
+ $this->assertRevEquals(
+ $rev,
+ Revision::loadFromId( wfGetDB( DB_MASTER ), $rev->getId() )
+ );
+ }
+
+ /**
+ * @covers Revision::loadFromPageId
+ */
+ public function testLoadFromPageId() {
+ $this->assertRevEquals(
+ $this->testPage->getRevision(),
+ Revision::loadFromPageId( wfGetDB( DB_MASTER ), $this->testPage->getId() )
+ );
+ }
+
+ /**
+ * @covers Revision::loadFromPageId
+ */
+ public function testLoadFromPageIdWithLatestRevId() {
+ $this->assertRevEquals(
+ $this->testPage->getRevision(),
+ Revision::loadFromPageId(
+ wfGetDB( DB_MASTER ),
+ $this->testPage->getId(),
+ $this->testPage->getLatest()
+ )
+ );
+ }
+
+ /**
+ * @covers Revision::loadFromPageId
+ */
+ public function testLoadFromPageIdWithNotLatestRevId() {
+ $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+ $this->assertRevEquals(
+ $this->testPage->getRevision()->getPrevious(),
+ Revision::loadFromPageId(
+ wfGetDB( DB_MASTER ),
+ $this->testPage->getId(),
+ $this->testPage->getRevision()->getPrevious()->getId()
+ )
+ );
+ }
+
+ /**
+ * @covers Revision::loadFromTitle
+ */
+ public function testLoadFromTitle() {
+ $this->assertRevEquals(
+ $this->testPage->getRevision(),
+ Revision::loadFromTitle( wfGetDB( DB_MASTER ), $this->testPage->getTitle() )
+ );
+ }
+
+ /**
+ * @covers Revision::loadFromTitle
+ */
+ public function testLoadFromTitleWithLatestRevId() {
+ $this->assertRevEquals(
+ $this->testPage->getRevision(),
+ Revision::loadFromTitle(
+ wfGetDB( DB_MASTER ),
+ $this->testPage->getTitle(),
+ $this->testPage->getLatest()
+ )
+ );
+ }
+
+ /**
+ * @covers Revision::loadFromTitle
+ */
+ public function testLoadFromTitleWithNotLatestRevId() {
+ $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+ $this->assertRevEquals(
+ $this->testPage->getRevision()->getPrevious(),
+ Revision::loadFromTitle(
+ wfGetDB( DB_MASTER ),
+ $this->testPage->getTitle(),
+ $this->testPage->getRevision()->getPrevious()->getId()
+ )
+ );
+ }
+
+ /**
+ * @covers Revision::loadFromTimestamp()
+ */
+ public function testLoadFromTimestamp() {
+ $this->assertRevEquals(
+ $this->testPage->getRevision(),
+ Revision::loadFromTimestamp(
+ wfGetDB( DB_MASTER ),
+ $this->testPage->getTitle(),
+ $this->testPage->getRevision()->getTimestamp()
+ )
+ );
+ }
+
+ /**
+ * @covers Revision::getParentLengths
+ */
+ public function testGetParentLengths_noRevIds() {
+ $this->assertSame(
+ [],
+ Revision::getParentLengths(
+ wfGetDB( DB_MASTER ),
+ []
+ )
+ );
+ }
+
+ /**
+ * @covers Revision::getParentLengths
+ */
+ public function testGetParentLengths_oneRevId() {
+ $text = '831jr091jr0921kr21kr0921kjr0921j09rj1';
+ $textLength = strlen( $text );
+
+ $this->testPage->doEditContent( new WikitextContent( $text ), __METHOD__ );
+ $rev[1] = $this->testPage->getLatest();
+
+ $this->assertSame(
+ [ $rev[1] => $textLength ],
+ Revision::getParentLengths(
+ wfGetDB( DB_MASTER ),
+ [ $rev[1] ]
+ )
+ );
+ }
+
+ /**
+ * @covers Revision::getParentLengths
+ */
+ public function testGetParentLengths_multipleRevIds() {
+ $textOne = '831jr091jr0921kr21kr0921kjr0921j09rj1';
+ $textOneLength = strlen( $textOne );
+ $textTwo = '831jr091jr092121j09rj1';
+ $textTwoLength = strlen( $textTwo );
+
+ $this->testPage->doEditContent( new WikitextContent( $textOne ), __METHOD__ );
+ $rev[1] = $this->testPage->getLatest();
+ $this->testPage->doEditContent( new WikitextContent( $textTwo ), __METHOD__ );
+ $rev[2] = $this->testPage->getLatest();
+
+ $this->assertSame(
+ [ $rev[1] => $textOneLength, $rev[2] => $textTwoLength ],
+ Revision::getParentLengths(
+ wfGetDB( DB_MASTER ),
+ [ $rev[1], $rev[2] ]
+ )
+ );
+ }
+
+ /**
+ * @covers Revision::getTitle
+ */
+ public function testGetTitle_fromExistingRevision() {
+ $this->assertTrue(
+ $this->testPage->getTitle()->equals(
+ $this->testPage->getRevision()->getTitle()
+ )
+ );
+ }
+
+ /**
+ * @covers Revision::getTitle
+ */
+ public function testGetTitle_fromRevisionWhichWillLoadTheTitle() {
+ $rev = new Revision( [ 'id' => $this->testPage->getLatest() ] );
+ $this->assertTrue(
+ $this->testPage->getTitle()->equals(
+ $rev->getTitle()
+ )
+ );
+ }
+
+ /**
+ * @covers Revision::isMinor
+ */
+ public function testIsMinor_true() {
+ // Use a sysop to ensure we can mark edits as minor
+ $sysop = $this->getTestSysop()->getUser();
+
+ $this->testPage->doEditContent(
+ new WikitextContent( __METHOD__ ),
+ __METHOD__,
+ EDIT_MINOR,
+ false,
+ $sysop
+ );
+ $rev = $this->testPage->getRevision();
+
+ $this->assertSame( true, $rev->isMinor() );
+ }
+
+ /**
+ * @covers Revision::isMinor
+ */
+ public function testIsMinor_false() {
+ $this->testPage->doEditContent(
+ new WikitextContent( __METHOD__ ),
+ __METHOD__,
+ 0
+ );
+ $rev = $this->testPage->getRevision();
+
+ $this->assertSame( false, $rev->isMinor() );
+ }
+
+ /**
+ * @covers Revision::getTimestamp
+ */
+ public function testGetTimestamp() {
+ $testTimestamp = wfTimestampNow();
+
+ $this->testPage->doEditContent(
+ new WikitextContent( __METHOD__ ),
+ __METHOD__
+ );
+ $rev = $this->testPage->getRevision();
+
+ $this->assertInternalType( 'string', $rev->getTimestamp() );
+ $this->assertTrue( strlen( $rev->getTimestamp() ) == strlen( 'YYYYMMDDHHMMSS' ) );
+ $this->assertContains( substr( $testTimestamp, 0, 10 ), $rev->getTimestamp() );
+ }
+
+ /**
+ * @covers Revision::getUser
+ * @covers Revision::getUserText
+ */
+ public function testGetUserAndText() {
+ $sysop = $this->getTestSysop()->getUser();
+
+ $this->testPage->doEditContent(
+ new WikitextContent( __METHOD__ ),
+ __METHOD__,
+ 0,
+ false,
+ $sysop
+ );
+ $rev = $this->testPage->getRevision();
+
+ $this->assertSame( $sysop->getId(), $rev->getUser() );
+ $this->assertSame( $sysop->getName(), $rev->getUserText() );
+ }
+
+ /**
+ * @covers Revision::isDeleted
+ */
+ public function testIsDeleted_nothingDeleted() {
+ $rev = $this->testPage->getRevision();
+
+ $this->assertSame( false, $rev->isDeleted( Revision::DELETED_TEXT ) );
+ $this->assertSame( false, $rev->isDeleted( Revision::DELETED_COMMENT ) );
+ $this->assertSame( false, $rev->isDeleted( Revision::DELETED_RESTRICTED ) );
+ $this->assertSame( false, $rev->isDeleted( Revision::DELETED_USER ) );
+ }
+
+ /**
+ * @covers Revision::getVisibility
+ */
+ public function testGetVisibility_nothingDeleted() {
+ $rev = $this->testPage->getRevision();
+
+ $this->assertSame( 0, $rev->getVisibility() );
+ }
+
+ /**
+ * @covers Revision::getComment
+ */
+ public function testGetComment_notDeleted() {
+ $expectedSummary = 'goatlicious summary';
+
+ $this->testPage->doEditContent(
+ new WikitextContent( __METHOD__ ),
+ $expectedSummary
+ );
+ $rev = $this->testPage->getRevision();
+
+ $this->assertSame( $expectedSummary, $rev->getComment() );
+ }
+
+ /**
+ * @covers Revision::isUnpatrolled
+ */
+ public function testIsUnpatrolled_returnsRecentChangesId() {
+ $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+ $rev = $this->testPage->getRevision();
+
+ $this->assertGreaterThan( 0, $rev->isUnpatrolled() );
+ $this->assertSame( $rev->getRecentChange()->getAttribute( 'rc_id' ), $rev->isUnpatrolled() );
+ }
+
+ /**
+ * @covers Revision::isUnpatrolled
+ */
+ public function testIsUnpatrolled_returnsZeroIfPatrolled() {
+ // This assumes that sysops are auto patrolled
+ $sysop = $this->getTestSysop()->getUser();
+ $this->testPage->doEditContent(
+ new WikitextContent( __METHOD__ ),
+ __METHOD__,
+ 0,
+ false,
+ $sysop
+ );
+ $rev = $this->testPage->getRevision();
+
+ $this->assertSame( 0, $rev->isUnpatrolled() );
+ }
+
+ /**
+ * This is a simple blanket test for all simple content getters and is methods to provide some
+ * coverage before the split of Revision into multiple classes for MCR work.
+ * @covers Revision::getContent
+ * @covers Revision::getSerializedData
+ * @covers Revision::getContentModel
+ * @covers Revision::getContentFormat
+ * @covers Revision::getContentHandler
+ */
+ public function testSimpleContentGetters() {
+ $expectedText = 'testSimpleContentGetters in Revision. Goats love MCR...';
+ $expectedSummary = 'goatlicious testSimpleContentGetters summary';
+
+ $this->testPage->doEditContent(
+ new WikitextContent( $expectedText ),
+ $expectedSummary
+ );
+ $rev = $this->testPage->getRevision();
+
+ $this->assertSame( $expectedText, $rev->getContent()->getNativeData() );
+ $this->assertSame( $expectedText, $rev->getSerializedData() );
+ $this->assertSame( $this->testPage->getContentModel(), $rev->getContentModel() );
+ $this->assertSame( $this->testPage->getContent()->getDefaultFormat(), $rev->getContentFormat() );
+ $this->assertSame( $this->testPage->getContentHandler(), $rev->getContentHandler() );
+ }
+
+ /**
+ * @covers Revision::newKnownCurrent
+ */
+ public function testNewKnownCurrent() {
+ // Setup the services
+ $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
+ $this->setService( 'MainWANObjectCache', $cache );
+ $db = wfGetDB( DB_MASTER );
+
+ // Get a fresh revision to use during testing
+ $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+ $rev = $this->testPage->getRevision();
+
+ // Clear any previous cache for the revision during creation
+ $key = $cache->makeGlobalKey( 'revision-row-1.29',
+ $db->getDomainID(),
+ $rev->getPage(),
+ $rev->getId()
+ );
+ $cache->delete( $key, WANObjectCache::HOLDOFF_NONE );
+ $this->assertFalse( $cache->get( $key ) );
+
+ // Get the new revision and make sure it is in the cache and correct
+ $newRev = Revision::newKnownCurrent( $db, $rev->getPage(), $rev->getId() );
+ $this->assertRevEquals( $rev, $newRev );
+
+ $cachedRow = $cache->get( $key );
+ $this->assertNotFalse( $cachedRow );
+ $this->assertEquals( $rev->getId(), $cachedRow->rev_id );
+ }
+
+ public function testNewKnownCurrent_withPageId() {
+ $db = wfGetDB( DB_MASTER );
+
+ $this->testPage->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+ $rev = $this->testPage->getRevision();
+
+ $pageId = $this->testPage->getId();
+
+ $newRev = Revision::newKnownCurrent( $db, $pageId, $rev->getId() );
+ $this->assertRevEquals( $rev, $newRev );
+ }
+
+ public function testNewKnownCurrent_returnsFalseWhenTitleDoesntExist() {
+ $db = wfGetDB( DB_MASTER );
+
+ $this->assertFalse( Revision::newKnownCurrent( $db, 0 ) );
+ }
+
+ public function provideUserCanBitfield() {
+ yield [ 0, 0, [], null, true ];
+ // Bitfields match, user has no permissions
+ yield [ Revision::DELETED_TEXT, Revision::DELETED_TEXT, [], null, false ];
+ yield [ Revision::DELETED_COMMENT, Revision::DELETED_COMMENT, [], null, false ];
+ yield [ Revision::DELETED_USER, Revision::DELETED_USER, [], null, false ];
+ yield [ Revision::DELETED_RESTRICTED, Revision::DELETED_RESTRICTED, [], null, false ];
+ // Bitfields match, user (admin) does have permissions
+ yield [ Revision::DELETED_TEXT, Revision::DELETED_TEXT, [ 'sysop' ], null, true ];
+ yield [ Revision::DELETED_COMMENT, Revision::DELETED_COMMENT, [ 'sysop' ], null, true ];
+ yield [ Revision::DELETED_USER, Revision::DELETED_USER, [ 'sysop' ], null, true ];
+ // Bitfields match, user (admin) does not have permissions
+ yield [ Revision::DELETED_RESTRICTED, Revision::DELETED_RESTRICTED, [ 'sysop' ], null, false ];
+ // Bitfields match, user (oversight) does have permissions
+ yield [ Revision::DELETED_RESTRICTED, Revision::DELETED_RESTRICTED, [ 'oversight' ], null, true ];
+ // Check permissions using the title
+ yield [
+ Revision::DELETED_TEXT,
+ Revision::DELETED_TEXT,
+ [ 'sysop' ],
+ Title::newFromText( __METHOD__ ),
+ true,
+ ];
+ yield [
+ Revision::DELETED_TEXT,
+ Revision::DELETED_TEXT,
+ [],
+ Title::newFromText( __METHOD__ ),
+ false,
+ ];
+ }
+
+ /**
+ * @dataProvider provideUserCanBitfield
+ * @covers Revision::userCanBitfield
+ */
+ public function testUserCanBitfield( $bitField, $field, $userGroups, $title, $expected ) {
+ $this->setMwGlobals(
+ 'wgGroupPermissions',
+ [
+ 'sysop' => [
+ 'deletedtext' => true,
+ 'deletedhistory' => true,
+ ],
+ 'oversight' => [
+ 'viewsuppressed' => true,
+ 'suppressrevision' => true,
+ ],
+ ]
+ );
+ $user = $this->getTestUser( $userGroups )->getUser();
+
+ $this->assertSame(
+ $expected,
+ Revision::userCanBitfield( $bitField, $field, $user, $title )
+ );
+
+ // Fallback to $wgUser
+ $this->setMwGlobals(
+ 'wgUser',
+ $user
+ );
+ $this->assertSame(
+ $expected,
+ Revision::userCanBitfield( $bitField, $field, null, $title )
+ );
+ }
+
+ public function provideUserCan() {
+ yield [ 0, 0, [], true ];
+ // Bitfields match, user has no permissions
+ yield [ Revision::DELETED_TEXT, Revision::DELETED_TEXT, [], false ];
+ yield [ Revision::DELETED_COMMENT, Revision::DELETED_COMMENT, [], false ];
+ yield [ Revision::DELETED_USER, Revision::DELETED_USER, [], false ];
+ yield [ Revision::DELETED_RESTRICTED, Revision::DELETED_RESTRICTED, [], false ];
+ // Bitfields match, user (admin) does have permissions
+ yield [ Revision::DELETED_TEXT, Revision::DELETED_TEXT, [ 'sysop' ], true ];
+ yield [ Revision::DELETED_COMMENT, Revision::DELETED_COMMENT, [ 'sysop' ], true ];
+ yield [ Revision::DELETED_USER, Revision::DELETED_USER, [ 'sysop' ], true ];
+ // Bitfields match, user (admin) does not have permissions
+ yield [ Revision::DELETED_RESTRICTED, Revision::DELETED_RESTRICTED, [ 'sysop' ], false ];
+ // Bitfields match, user (oversight) does have permissions
+ yield [ Revision::DELETED_RESTRICTED, Revision::DELETED_RESTRICTED, [ 'oversight' ], true ];
+ }
+
+ /**
+ * @dataProvider provideUserCan
+ * @covers Revision::userCan
+ */
+ public function testUserCan( $bitField, $field, $userGroups, $expected ) {
+ $this->setMwGlobals(
+ 'wgGroupPermissions',
+ [
+ 'sysop' => [
+ 'deletedtext' => true,
+ 'deletedhistory' => true,
+ ],
+ 'oversight' => [
+ 'viewsuppressed' => true,
+ 'suppressrevision' => true,
+ ],
+ ]
+ );
+ $user = $this->getTestUser( $userGroups )->getUser();
+ $revision = new Revision( [ 'deleted' => $bitField ], 0, $this->testPage->getTitle() );
+
+ $this->assertSame(
+ $expected,
+ $revision->userCan( $field, $user )
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/RevisionNoContentHandlerDbTest.php b/www/wiki/tests/phpunit/includes/RevisionNoContentHandlerDbTest.php
new file mode 100644
index 00000000..c980a487
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/RevisionNoContentHandlerDbTest.php
@@ -0,0 +1,14 @@
+<?php
+
+/**
+ * @group Database
+ * @group medium
+ * @group ContentHandler
+ */
+class RevisionNoContentHandlerDbTest extends RevisionDbTestBase {
+
+ protected function getContentHandlerUseDB() {
+ return false;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/RevisionTest.php b/www/wiki/tests/phpunit/includes/RevisionTest.php
new file mode 100644
index 00000000..ab067a47
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/RevisionTest.php
@@ -0,0 +1,1498 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\BlobStoreFactory;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionAccessException;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionStore;
+use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Storage\SqlBlobStore;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * Test cases in RevisionTest should not interact with the Database.
+ * For test cases that need Database interaction see RevisionDbTestBase.
+ */
+class RevisionTest extends MediaWikiTestCase {
+
+ public function provideConstructFromArray() {
+ yield 'with text' => [
+ [
+ 'text' => 'hello world.',
+ 'content_model' => CONTENT_MODEL_JAVASCRIPT
+ ],
+ ];
+ yield 'with content' => [
+ [
+ 'content' => new JavaScriptContent( 'hellow world.' )
+ ],
+ ];
+ // FIXME: test with and without user ID, and with a user object.
+ // We can't prepare that here though, since we don't yet have a dummy DB
+ }
+
+ /**
+ * @param string $model
+ * @return Title
+ */
+ public function getMockTitle( $model = CONTENT_MODEL_WIKITEXT ) {
+ $mock = $this->getMockBuilder( Title::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'getNamespace' )
+ ->will( $this->returnValue( $this->getDefaultWikitextNS() ) );
+ $mock->expects( $this->any() )
+ ->method( 'getPrefixedText' )
+ ->will( $this->returnValue( 'RevisionTest' ) );
+ $mock->expects( $this->any() )
+ ->method( 'getDBkey' )
+ ->will( $this->returnValue( 'RevisionTest' ) );
+ $mock->expects( $this->any() )
+ ->method( 'getArticleID' )
+ ->will( $this->returnValue( 23 ) );
+ $mock->expects( $this->any() )
+ ->method( 'getContentModel' )
+ ->will( $this->returnValue( $model ) );
+
+ return $mock;
+ }
+
+ /**
+ * @dataProvider provideConstructFromArray
+ * @covers Revision::__construct
+ * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ */
+ public function testConstructFromArray( $rowArray ) {
+ $rev = new Revision( $rowArray, 0, $this->getMockTitle() );
+ $this->assertNotNull( $rev->getContent(), 'no content object available' );
+ $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContent()->getModel() );
+ $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContentModel() );
+ }
+
+ /**
+ * @covers Revision::__construct
+ * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ */
+ public function testConstructFromEmptyArray() {
+ $rev = new Revision( [], 0, $this->getMockTitle() );
+ $this->assertNull( $rev->getContent(), 'no content object should be available' );
+ }
+
+ /**
+ * @covers Revision::__construct
+ * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ */
+ public function testConstructFromArrayWithBadPageId() {
+ Wikimedia\suppressWarnings();
+ $rev = new Revision( [ 'page' => 77777777 ] );
+ $this->assertSame( 77777777, $rev->getPage() );
+ Wikimedia\restoreWarnings();
+ }
+
+ public function provideConstructFromArray_userSetAsExpected() {
+ yield 'no user defaults to wgUser' => [
+ [
+ 'content' => new JavaScriptContent( 'hello world.' ),
+ ],
+ null,
+ null,
+ ];
+ yield 'user text and id' => [
+ [
+ 'content' => new JavaScriptContent( 'hello world.' ),
+ 'user_text' => 'SomeTextUserName',
+ 'user' => 99,
+
+ ],
+ 99,
+ 'SomeTextUserName',
+ ];
+ yield 'user text only' => [
+ [
+ 'content' => new JavaScriptContent( 'hello world.' ),
+ 'user_text' => '111.111.111.111',
+ ],
+ 0,
+ '111.111.111.111',
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructFromArray_userSetAsExpected
+ * @covers Revision::__construct
+ * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ *
+ * @param array $rowArray
+ * @param mixed $expectedUserId null to expect the current wgUser ID
+ * @param mixed $expectedUserName null to expect the current wgUser name
+ */
+ public function testConstructFromArray_userSetAsExpected(
+ array $rowArray,
+ $expectedUserId,
+ $expectedUserName
+ ) {
+ $testUser = $this->getTestUser()->getUser();
+ $this->setMwGlobals( 'wgUser', $testUser );
+ if ( $expectedUserId === null ) {
+ $expectedUserId = $testUser->getId();
+ }
+ if ( $expectedUserName === null ) {
+ $expectedUserName = $testUser->getName();
+ }
+
+ $rev = new Revision( $rowArray, 0, $this->getMockTitle() );
+ $this->assertEquals( $expectedUserId, $rev->getUser() );
+ $this->assertEquals( $expectedUserName, $rev->getUserText() );
+ }
+
+ public function provideConstructFromArrayThrowsExceptions() {
+ yield 'content and text_id both not empty' => [
+ [
+ 'content' => new WikitextContent( 'GOAT' ),
+ 'text_id' => 'someid',
+ ],
+ new MWException( "Text already stored in external store (id someid), " .
+ "can't serialize content object" )
+ ];
+ yield 'with bad content object (class)' => [
+ [ 'content' => new stdClass() ],
+ new MWException( 'content field must contain a Content object.' )
+ ];
+ yield 'with bad content object (string)' => [
+ [ 'content' => 'ImAGoat' ],
+ new MWException( 'content field must contain a Content object.' )
+ ];
+ yield 'bad row format' => [
+ 'imastring, not a row',
+ new InvalidArgumentException(
+ '$row must be a row object, an associative array, or a RevisionRecord'
+ )
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructFromArrayThrowsExceptions
+ * @covers Revision::__construct
+ * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ */
+ public function testConstructFromArrayThrowsExceptions( $rowArray, Exception $expectedException ) {
+ $this->setExpectedException(
+ get_class( $expectedException ),
+ $expectedException->getMessage(),
+ $expectedException->getCode()
+ );
+ new Revision( $rowArray, 0, $this->getMockTitle() );
+ }
+
+ /**
+ * @covers Revision::__construct
+ * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ */
+ public function testConstructFromNothing() {
+ $this->setExpectedException(
+ InvalidArgumentException::class
+ );
+ new Revision( [] );
+ }
+
+ public function provideConstructFromRow() {
+ yield 'Full construction' => [
+ [
+ 'rev_id' => '42',
+ 'rev_page' => '23',
+ 'rev_text_id' => '2',
+ 'rev_timestamp' => '20171017114835',
+ 'rev_user_text' => '127.0.0.1',
+ 'rev_user' => '0',
+ 'rev_minor_edit' => '0',
+ 'rev_deleted' => '0',
+ 'rev_len' => '46',
+ 'rev_parent_id' => '1',
+ 'rev_sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'rev_comment_text' => 'Goat Comment!',
+ 'rev_comment_data' => null,
+ 'rev_comment_cid' => null,
+ 'rev_content_format' => 'GOATFORMAT',
+ 'rev_content_model' => 'GOATMODEL',
+ ],
+ function ( RevisionTest $testCase, Revision $rev ) {
+ $testCase->assertSame( 42, $rev->getId() );
+ $testCase->assertSame( 23, $rev->getPage() );
+ $testCase->assertSame( 2, $rev->getTextId() );
+ $testCase->assertSame( '20171017114835', $rev->getTimestamp() );
+ $testCase->assertSame( '127.0.0.1', $rev->getUserText() );
+ $testCase->assertSame( 0, $rev->getUser() );
+ $testCase->assertSame( false, $rev->isMinor() );
+ $testCase->assertSame( false, $rev->isDeleted( Revision::DELETED_TEXT ) );
+ $testCase->assertSame( 46, $rev->getSize() );
+ $testCase->assertSame( 1, $rev->getParentId() );
+ $testCase->assertSame( 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z', $rev->getSha1() );
+ $testCase->assertSame( 'Goat Comment!', $rev->getComment() );
+ $testCase->assertSame( 'GOATFORMAT', $rev->getContentFormat() );
+ $testCase->assertSame( 'GOATMODEL', $rev->getContentModel() );
+ }
+ ];
+ yield 'default field values' => [
+ [
+ 'rev_id' => '42',
+ 'rev_page' => '23',
+ 'rev_text_id' => '2',
+ 'rev_timestamp' => '20171017114835',
+ 'rev_user_text' => '127.0.0.1',
+ 'rev_user' => '0',
+ 'rev_minor_edit' => '0',
+ 'rev_deleted' => '0',
+ 'rev_comment_text' => 'Goat Comment!',
+ 'rev_comment_data' => null,
+ 'rev_comment_cid' => null,
+ ],
+ function ( RevisionTest $testCase, Revision $rev ) {
+ // parent ID may be null
+ $testCase->assertSame( null, $rev->getParentId(), 'revision id' );
+
+ // given fields
+ $testCase->assertSame( $rev->getTimestamp(), '20171017114835', 'timestamp' );
+ $testCase->assertSame( $rev->getUserText(), '127.0.0.1', 'user name' );
+ $testCase->assertSame( $rev->getUser(), 0, 'user id' );
+ $testCase->assertSame( $rev->getComment(), 'Goat Comment!' );
+ $testCase->assertSame( false, $rev->isMinor(), 'minor edit' );
+ $testCase->assertSame( 0, $rev->getVisibility(), 'visibility flags' );
+
+ // computed fields
+ $testCase->assertNotNull( $rev->getSize(), 'size' );
+ $testCase->assertNotNull( $rev->getSha1(), 'hash' );
+
+ // NOTE: model and format will be detected based on the namespace of the (mock) title
+ $testCase->assertSame( 'text/x-wiki', $rev->getContentFormat(), 'format' );
+ $testCase->assertSame( 'wikitext', $rev->getContentModel(), 'model' );
+ }
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructFromRow
+ * @covers Revision::__construct
+ * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ */
+ public function testConstructFromRow( array $arrayData, $assertions ) {
+ $data = 'Hello goat.'; // needs to match model and format
+
+ $blobStore = $this->getMockBuilder( SqlBlobStore::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $blobStore->method( 'getBlob' )
+ ->will( $this->returnValue( $data ) );
+
+ $blobStore->method( 'getTextIdFromAddress' )
+ ->will( $this->returnCallback(
+ function ( $address ) {
+ // Turn "tt:1234" into 12345.
+ // Note that this must be functional so we can test getTextId().
+ // Ideally, we'd un-mock getTextIdFromAddress and use its actual implementation.
+ $parts = explode( ':', $address );
+ return (int)array_pop( $parts );
+ }
+ ) );
+
+ // Note override internal service, so RevisionStore uses it as well.
+ $this->setService( 'BlobStoreFactory', $this->mockBlobStoreFactory( $blobStore ) );
+
+ $row = (object)$arrayData;
+ $rev = new Revision( $row, 0, $this->getMockTitle() );
+ $assertions( $this, $rev );
+ }
+
+ /**
+ * @covers Revision::__construct
+ * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ */
+ public function testConstructFromRowWithBadPageId() {
+ $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->overrideMwServices();
+ Wikimedia\suppressWarnings();
+ $rev = new Revision( (object)[ 'rev_page' => 77777777 ] );
+ $this->assertSame( 77777777, $rev->getPage() );
+ Wikimedia\restoreWarnings();
+ }
+
+ public function provideGetRevisionText() {
+ yield 'Generic test' => [
+ 'This is a goat of revision text.',
+ [
+ 'old_flags' => '',
+ 'old_text' => 'This is a goat of revision text.',
+ ],
+ ];
+ }
+
+ public function provideGetId() {
+ yield [
+ [],
+ null
+ ];
+ yield [
+ [ 'id' => 998 ],
+ 998
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetId
+ * @covers Revision::getId
+ */
+ public function testGetId( $rowArray, $expectedId ) {
+ $rev = new Revision( $rowArray, 0, $this->getMockTitle() );
+ $this->assertEquals( $expectedId, $rev->getId() );
+ }
+
+ public function provideSetId() {
+ yield [ '123', 123 ];
+ yield [ 456, 456 ];
+ }
+
+ /**
+ * @dataProvider provideSetId
+ * @covers Revision::setId
+ */
+ public function testSetId( $input, $expected ) {
+ $rev = new Revision( [], 0, $this->getMockTitle() );
+ $rev->setId( $input );
+ $this->assertSame( $expected, $rev->getId() );
+ }
+
+ public function provideSetUserIdAndName() {
+ yield [ '123', 123, 'GOaT' ];
+ yield [ 456, 456, 'GOaT' ];
+ }
+
+ /**
+ * @dataProvider provideSetUserIdAndName
+ * @covers Revision::setUserIdAndName
+ */
+ public function testSetUserIdAndName( $inputId, $expectedId, $name ) {
+ $rev = new Revision( [], 0, $this->getMockTitle() );
+ $rev->setUserIdAndName( $inputId, $name );
+ $this->assertSame( $expectedId, $rev->getUser( Revision::RAW ) );
+ $this->assertEquals( $name, $rev->getUserText( Revision::RAW ) );
+ }
+
+ public function provideGetTextId() {
+ yield [ [], null ];
+ yield [ [ 'text_id' => '123' ], 123 ];
+ yield [ [ 'text_id' => 456 ], 456 ];
+ }
+
+ /**
+ * @dataProvider provideGetTextId
+ * @covers Revision::getTextId()
+ */
+ public function testGetTextId( $rowArray, $expected ) {
+ $rev = new Revision( $rowArray, 0, $this->getMockTitle() );
+ $this->assertSame( $expected, $rev->getTextId() );
+ }
+
+ public function provideGetParentId() {
+ yield [ [], null ];
+ yield [ [ 'parent_id' => '123' ], 123 ];
+ yield [ [ 'parent_id' => 456 ], 456 ];
+ }
+
+ /**
+ * @dataProvider provideGetParentId
+ * @covers Revision::getParentId()
+ */
+ public function testGetParentId( $rowArray, $expected ) {
+ $rev = new Revision( $rowArray, 0, $this->getMockTitle() );
+ $this->assertSame( $expected, $rev->getParentId() );
+ }
+
+ /**
+ * @covers Revision::getRevisionText
+ * @dataProvider provideGetRevisionText
+ */
+ public function testGetRevisionText( $expected, $rowData, $prefix = 'old_', $wiki = false ) {
+ $this->assertEquals(
+ $expected,
+ Revision::getRevisionText( (object)$rowData, $prefix, $wiki ) );
+ }
+
+ public function provideGetRevisionTextWithZlibExtension() {
+ yield 'Generic gzip test' => [
+ 'This is a small goat of revision text.',
+ [
+ 'old_flags' => 'gzip',
+ 'old_text' => gzdeflate( 'This is a small goat of revision text.' ),
+ ],
+ ];
+ }
+
+ /**
+ * @covers Revision::getRevisionText
+ * @dataProvider provideGetRevisionTextWithZlibExtension
+ */
+ public function testGetRevisionWithZlibExtension( $expected, $rowData ) {
+ $this->checkPHPExtension( 'zlib' );
+ $this->testGetRevisionText( $expected, $rowData );
+ }
+
+ private function getWANObjectCache() {
+ return new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
+ }
+
+ /**
+ * @return SqlBlobStore
+ */
+ private function getBlobStore() {
+ /** @var LoadBalancer $lb */
+ $lb = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $cache = $this->getWANObjectCache();
+
+ $blobStore = new SqlBlobStore( $lb, $cache );
+ return $blobStore;
+ }
+
+ private function mockBlobStoreFactory( $blobStore ) {
+ /** @var LoadBalancer $lb */
+ $factory = $this->getMockBuilder( BlobStoreFactory::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $factory->expects( $this->any() )
+ ->method( 'newBlobStore' )
+ ->willReturn( $blobStore );
+ $factory->expects( $this->any() )
+ ->method( 'newSqlBlobStore' )
+ ->willReturn( $blobStore );
+ return $factory;
+ }
+
+ /**
+ * @return RevisionStore
+ */
+ private function getRevisionStore() {
+ /** @var LoadBalancer $lb */
+ $lb = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $cache = $this->getWANObjectCache();
+
+ $blobStore = new RevisionStore(
+ $lb,
+ $this->getBlobStore(),
+ $cache,
+ MediaWikiServices::getInstance()->getCommentStore(),
+ MediaWikiServices::getInstance()->getActorMigration()
+ );
+ return $blobStore;
+ }
+
+ public function provideGetRevisionTextWithLegacyEncoding() {
+ yield 'Utf8Native' => [
+ "Wiki est l'\xc3\xa9cole superieur !",
+ 'fr',
+ 'iso-8859-1',
+ [
+ 'old_flags' => 'utf-8',
+ 'old_text' => "Wiki est l'\xc3\xa9cole superieur !",
+ ]
+ ];
+ yield 'Utf8Legacy' => [
+ "Wiki est l'\xc3\xa9cole superieur !",
+ 'fr',
+ 'iso-8859-1',
+ [
+ 'old_flags' => '',
+ 'old_text' => "Wiki est l'\xe9cole superieur !",
+ ]
+ ];
+ }
+
+ /**
+ * @covers Revision::getRevisionText
+ * @dataProvider provideGetRevisionTextWithLegacyEncoding
+ */
+ public function testGetRevisionWithLegacyEncoding( $expected, $lang, $encoding, $rowData ) {
+ $blobStore = $this->getBlobStore();
+ $blobStore->setLegacyEncoding( $encoding, Language::factory( $lang ) );
+ $this->setService( 'BlobStoreFactory', $this->mockBlobStoreFactory( $blobStore ) );
+
+ $this->testGetRevisionText( $expected, $rowData );
+ }
+
+ public function provideGetRevisionTextWithGzipAndLegacyEncoding() {
+ /**
+ * WARNING!
+ * Do not set the external flag!
+ * Otherwise, getRevisionText will hit the live database (if ExternalStore is enabled)!
+ */
+ yield 'Utf8NativeGzip' => [
+ "Wiki est l'\xc3\xa9cole superieur !",
+ 'fr',
+ 'iso-8859-1',
+ [
+ 'old_flags' => 'gzip,utf-8',
+ 'old_text' => gzdeflate( "Wiki est l'\xc3\xa9cole superieur !" ),
+ ]
+ ];
+ yield 'Utf8LegacyGzip' => [
+ "Wiki est l'\xc3\xa9cole superieur !",
+ 'fr',
+ 'iso-8859-1',
+ [
+ 'old_flags' => 'gzip',
+ 'old_text' => gzdeflate( "Wiki est l'\xe9cole superieur !" ),
+ ]
+ ];
+ }
+
+ /**
+ * @covers Revision::getRevisionText
+ * @dataProvider provideGetRevisionTextWithGzipAndLegacyEncoding
+ */
+ public function testGetRevisionWithGzipAndLegacyEncoding( $expected, $lang, $encoding, $rowData ) {
+ $this->checkPHPExtension( 'zlib' );
+
+ $blobStore = $this->getBlobStore();
+ $blobStore->setLegacyEncoding( $encoding, Language::factory( $lang ) );
+ $this->setService( 'BlobStoreFactory', $this->mockBlobStoreFactory( $blobStore ) );
+
+ $this->testGetRevisionText( $expected, $rowData );
+ }
+
+ /**
+ * @covers Revision::compressRevisionText
+ */
+ public function testCompressRevisionTextUtf8() {
+ $row = new stdClass;
+ $row->old_text = "Wiki est l'\xc3\xa9cole superieur !";
+ $row->old_flags = Revision::compressRevisionText( $row->old_text );
+ $this->assertTrue( false !== strpos( $row->old_flags, 'utf-8' ),
+ "Flags should contain 'utf-8'" );
+ $this->assertFalse( false !== strpos( $row->old_flags, 'gzip' ),
+ "Flags should not contain 'gzip'" );
+ $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !",
+ $row->old_text, "Direct check" );
+ $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !",
+ Revision::getRevisionText( $row ), "getRevisionText" );
+ }
+
+ /**
+ * @covers Revision::compressRevisionText
+ */
+ public function testCompressRevisionTextUtf8Gzip() {
+ $this->checkPHPExtension( 'zlib' );
+
+ $blobStore = $this->getBlobStore();
+ $blobStore->setCompressBlobs( true );
+ $this->setService( 'BlobStoreFactory', $this->mockBlobStoreFactory( $blobStore ) );
+
+ $row = new stdClass;
+ $row->old_text = "Wiki est l'\xc3\xa9cole superieur !";
+ $row->old_flags = Revision::compressRevisionText( $row->old_text );
+ $this->assertTrue( false !== strpos( $row->old_flags, 'utf-8' ),
+ "Flags should contain 'utf-8'" );
+ $this->assertTrue( false !== strpos( $row->old_flags, 'gzip' ),
+ "Flags should contain 'gzip'" );
+ $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !",
+ gzinflate( $row->old_text ), "Direct check" );
+ $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !",
+ Revision::getRevisionText( $row ), "getRevisionText" );
+ }
+
+ /**
+ * @covers Revision::loadFromTitle
+ */
+ public function testLoadFromTitle() {
+ $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->overrideMwServices();
+ $title = $this->getMockTitle();
+
+ $conditions = [
+ 'rev_id=page_latest',
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey()
+ ];
+
+ $row = (object)[
+ 'rev_id' => '42',
+ 'rev_page' => $title->getArticleID(),
+ 'rev_text_id' => '2',
+ 'rev_timestamp' => '20171017114835',
+ 'rev_user_text' => '127.0.0.1',
+ 'rev_user' => '0',
+ 'rev_minor_edit' => '0',
+ 'rev_deleted' => '0',
+ 'rev_len' => '46',
+ 'rev_parent_id' => '1',
+ 'rev_sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'rev_comment_text' => 'Goat Comment!',
+ 'rev_comment_data' => null,
+ 'rev_comment_cid' => null,
+ 'rev_content_format' => 'GOATFORMAT',
+ 'rev_content_model' => 'GOATMODEL',
+ ];
+
+ $db = $this->getMock( IDatabase::class );
+ $db->expects( $this->any() )
+ ->method( 'getDomainId' )
+ ->will( $this->returnValue( wfWikiID() ) );
+ $db->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ $this->equalTo( [ 'revision', 'page', 'user' ] ),
+ // We don't really care about the fields are they come from the selectField methods
+ $this->isType( 'array' ),
+ $this->equalTo( $conditions ),
+ // Method name
+ $this->stringContains( 'fetchRevisionRowFromConds' ),
+ // We don't really care about the options here
+ $this->isType( 'array' ),
+ // We don't really care about the join conds are they come from the joinCond methods
+ $this->isType( 'array' )
+ )
+ ->willReturn( $row );
+
+ $revision = Revision::loadFromTitle( $db, $title );
+
+ $this->assertEquals( $title->getArticleID(), $revision->getTitle()->getArticleID() );
+ $this->assertEquals( $row->rev_id, $revision->getId() );
+ $this->assertEquals( $row->rev_len, $revision->getSize() );
+ $this->assertEquals( $row->rev_sha1, $revision->getSha1() );
+ $this->assertEquals( $row->rev_parent_id, $revision->getParentId() );
+ $this->assertEquals( $row->rev_timestamp, $revision->getTimestamp() );
+ $this->assertEquals( $row->rev_comment_text, $revision->getComment() );
+ $this->assertEquals( $row->rev_user_text, $revision->getUserText() );
+ }
+
+ public function provideDecompressRevisionText() {
+ yield '(no legacy encoding), false in false out' => [ false, false, [], false ];
+ yield '(no legacy encoding), empty in empty out' => [ false, '', [], '' ];
+ yield '(no legacy encoding), empty in empty out' => [ false, 'A', [], 'A' ];
+ yield '(no legacy encoding), string in with gzip flag returns string' => [
+ // gzip string below generated with gzdeflate( 'AAAABBAAA' )
+ false, "sttttr\002\022\000", [ 'gzip' ], 'AAAABBAAA',
+ ];
+ yield '(no legacy encoding), string in with object flag returns false' => [
+ // gzip string below generated with serialize( 'JOJO' )
+ false, "s:4:\"JOJO\";", [ 'object' ], false,
+ ];
+ yield '(no legacy encoding), serialized object in with object flag returns string' => [
+ false,
+ // Using a TitleValue object as it has a getText method (which is needed)
+ serialize( new TitleValue( 0, 'HHJJDDFF' ) ),
+ [ 'object' ],
+ 'HHJJDDFF',
+ ];
+ yield '(no legacy encoding), serialized object in with object & gzip flag returns string' => [
+ false,
+ // Using a TitleValue object as it has a getText method (which is needed)
+ gzdeflate( serialize( new TitleValue( 0, '8219JJJ840' ) ) ),
+ [ 'object', 'gzip' ],
+ '8219JJJ840',
+ ];
+ yield '(ISO-8859-1 encoding), string in string out' => [
+ 'ISO-8859-1',
+ iconv( 'utf-8', 'ISO-8859-1', "1®Àþ1" ),
+ [],
+ '1®Àþ1',
+ ];
+ yield '(ISO-8859-1 encoding), serialized object in with gzip flags returns string' => [
+ 'ISO-8859-1',
+ gzdeflate( iconv( 'utf-8', 'ISO-8859-1', "4®Àþ4" ) ),
+ [ 'gzip' ],
+ '4®Àþ4',
+ ];
+ yield '(ISO-8859-1 encoding), serialized object in with object flags returns string' => [
+ 'ISO-8859-1',
+ serialize( new TitleValue( 0, iconv( 'utf-8', 'ISO-8859-1', "3®Àþ3" ) ) ),
+ [ 'object' ],
+ '3®Àþ3',
+ ];
+ yield '(ISO-8859-1 encoding), serialized object in with object & gzip flags returns string' => [
+ 'ISO-8859-1',
+ gzdeflate( serialize( new TitleValue( 0, iconv( 'utf-8', 'ISO-8859-1', "2®Àþ2" ) ) ) ),
+ [ 'gzip', 'object' ],
+ '2®Àþ2',
+ ];
+ }
+
+ /**
+ * @dataProvider provideDecompressRevisionText
+ * @covers Revision::decompressRevisionText
+ *
+ * @param bool $legacyEncoding
+ * @param mixed $text
+ * @param array $flags
+ * @param mixed $expected
+ */
+ public function testDecompressRevisionText( $legacyEncoding, $text, $flags, $expected ) {
+ $blobStore = $this->getBlobStore();
+ if ( $legacyEncoding ) {
+ $blobStore->setLegacyEncoding( $legacyEncoding, Language::factory( 'en' ) );
+ }
+
+ $this->setService( 'BlobStoreFactory', $this->mockBlobStoreFactory( $blobStore ) );
+ $this->assertSame(
+ $expected,
+ Revision::decompressRevisionText( $text, $flags )
+ );
+ }
+
+ /**
+ * @covers Revision::getRevisionText
+ */
+ public function testGetRevisionText_returnsFalseWhenNoTextField() {
+ $this->assertFalse( Revision::getRevisionText( new stdClass() ) );
+ }
+
+ public function provideTestGetRevisionText_returnsDecompressedTextFieldWhenNotExternal() {
+ yield 'Just text' => [
+ (object)[ 'old_text' => 'SomeText' ],
+ 'old_',
+ 'SomeText'
+ ];
+ // gzip string below generated with gzdeflate( 'AAAABBAAA' )
+ yield 'gzip text' => [
+ (object)[
+ 'old_text' => "sttttr\002\022\000",
+ 'old_flags' => 'gzip'
+ ],
+ 'old_',
+ 'AAAABBAAA'
+ ];
+ yield 'gzip text and different prefix' => [
+ (object)[
+ 'jojo_text' => "sttttr\002\022\000",
+ 'jojo_flags' => 'gzip'
+ ],
+ 'jojo_',
+ 'AAAABBAAA'
+ ];
+ }
+
+ /**
+ * @dataProvider provideTestGetRevisionText_returnsDecompressedTextFieldWhenNotExternal
+ * @covers Revision::getRevisionText
+ */
+ public function testGetRevisionText_returnsDecompressedTextFieldWhenNotExternal(
+ $row,
+ $prefix,
+ $expected
+ ) {
+ $this->assertSame( $expected, Revision::getRevisionText( $row, $prefix ) );
+ }
+
+ public function provideTestGetRevisionText_external_returnsFalseWhenNotEnoughUrlParts() {
+ yield 'Just some text' => [ 'someNonUrlText' ];
+ yield 'No second URL part' => [ 'someProtocol://' ];
+ }
+
+ /**
+ * @dataProvider provideTestGetRevisionText_external_returnsFalseWhenNotEnoughUrlParts
+ * @covers Revision::getRevisionText
+ */
+ public function testGetRevisionText_external_returnsFalseWhenNotEnoughUrlParts(
+ $text
+ ) {
+ $this->assertFalse(
+ Revision::getRevisionText(
+ (object)[
+ 'old_text' => $text,
+ 'old_flags' => 'external',
+ ]
+ )
+ );
+ }
+
+ /**
+ * @covers Revision::getRevisionText
+ */
+ public function testGetRevisionText_external_noOldId() {
+ $this->setService(
+ 'ExternalStoreFactory',
+ new ExternalStoreFactory( [ 'ForTesting' ] )
+ );
+ $this->assertSame(
+ 'AAAABBAAA',
+ Revision::getRevisionText(
+ (object)[
+ 'old_text' => 'ForTesting://cluster1/12345',
+ 'old_flags' => 'external,gzip',
+ ]
+ )
+ );
+ }
+
+ /**
+ * @covers Revision::getRevisionText
+ */
+ public function testGetRevisionText_external_oldId() {
+ $cache = $this->getWANObjectCache();
+ $this->setService( 'MainWANObjectCache', $cache );
+
+ $this->setService(
+ 'ExternalStoreFactory',
+ new ExternalStoreFactory( [ 'ForTesting' ] )
+ );
+
+ $lb = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $blobStore = new SqlBlobStore( $lb, $cache );
+ $this->setService( 'BlobStoreFactory', $this->mockBlobStoreFactory( $blobStore ) );
+
+ $this->assertSame(
+ 'AAAABBAAA',
+ Revision::getRevisionText(
+ (object)[
+ 'old_text' => 'ForTesting://cluster1/12345',
+ 'old_flags' => 'external,gzip',
+ 'old_id' => '7777',
+ ]
+ )
+ );
+
+ $cacheKey = $cache->makeKey( 'revisiontext', 'textid', 'tt:7777' );
+ $this->assertSame( 'AAAABBAAA', $cache->get( $cacheKey ) );
+ }
+
+ /**
+ * @covers Revision::userJoinCond
+ */
+ public function testUserJoinCond() {
+ $this->hideDeprecated( 'Revision::userJoinCond' );
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->overrideMwServices();
+ $this->assertEquals(
+ [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
+ Revision::userJoinCond()
+ );
+ }
+
+ /**
+ * @covers Revision::pageJoinCond
+ */
+ public function testPageJoinCond() {
+ $this->hideDeprecated( 'Revision::pageJoinCond' );
+ $this->assertEquals(
+ [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+ Revision::pageJoinCond()
+ );
+ }
+
+ private function overrideCommentStoreAndActorMigration() {
+ $mockStore = $this->getMockBuilder( CommentStore::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mockStore->expects( $this->any() )
+ ->method( 'getFields' )
+ ->willReturn( [ 'commentstore' => 'fields' ] );
+ $mockStore->expects( $this->any() )
+ ->method( 'getJoin' )
+ ->willReturn( [
+ 'tables' => [ 'commentstore' => 'table' ],
+ 'fields' => [ 'commentstore' => 'field' ],
+ 'joins' => [ 'commentstore' => 'join' ],
+ ] );
+ $this->setService( 'CommentStore', $mockStore );
+
+ $mockStore = $this->getMockBuilder( ActorMigration::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mockStore->expects( $this->any() )
+ ->method( 'getJoin' )
+ ->willReturnCallback( function ( $key ) {
+ $p = strtok( $key, '_' );
+ return [
+ 'tables' => [ 'actormigration' => 'table' ],
+ 'fields' => [
+ $p . '_user' => 'actormigration_user',
+ $p . '_user_text' => 'actormigration_user_text',
+ $p . '_actor' => 'actormigration_actor',
+ ],
+ 'joins' => [ 'actormigration' => 'join' ],
+ ];
+ } );
+ $this->setService( 'ActorMigration', $mockStore );
+ }
+
+ public function provideSelectFields() {
+ yield [
+ true,
+ [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_timestamp',
+ 'rev_user_text',
+ 'rev_user',
+ 'rev_actor' => 'NULL',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ 'commentstore' => 'fields',
+ 'rev_content_format',
+ 'rev_content_model',
+ ]
+ ];
+ yield [
+ false,
+ [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_timestamp',
+ 'rev_user_text',
+ 'rev_user',
+ 'rev_actor' => 'NULL',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ 'commentstore' => 'fields',
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideSelectFields
+ * @covers Revision::selectFields
+ */
+ public function testSelectFields( $contentHandlerUseDB, $expected ) {
+ $this->hideDeprecated( 'Revision::selectFields' );
+ $this->setMwGlobals( 'wgContentHandlerUseDB', $contentHandlerUseDB );
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->overrideCommentStoreAndActorMigration();
+ $this->assertEquals( $expected, Revision::selectFields() );
+ }
+
+ public function provideSelectArchiveFields() {
+ yield [
+ true,
+ [
+ 'ar_id',
+ 'ar_page_id',
+ 'ar_rev_id',
+ 'ar_text_id',
+ 'ar_timestamp',
+ 'ar_user_text',
+ 'ar_user',
+ 'ar_actor' => 'NULL',
+ 'ar_minor_edit',
+ 'ar_deleted',
+ 'ar_len',
+ 'ar_parent_id',
+ 'ar_sha1',
+ 'commentstore' => 'fields',
+ 'ar_content_format',
+ 'ar_content_model',
+ ]
+ ];
+ yield [
+ false,
+ [
+ 'ar_id',
+ 'ar_page_id',
+ 'ar_rev_id',
+ 'ar_text_id',
+ 'ar_timestamp',
+ 'ar_user_text',
+ 'ar_user',
+ 'ar_actor' => 'NULL',
+ 'ar_minor_edit',
+ 'ar_deleted',
+ 'ar_len',
+ 'ar_parent_id',
+ 'ar_sha1',
+ 'commentstore' => 'fields',
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideSelectArchiveFields
+ * @covers Revision::selectArchiveFields
+ */
+ public function testSelectArchiveFields( $contentHandlerUseDB, $expected ) {
+ $this->hideDeprecated( 'Revision::selectArchiveFields' );
+ $this->setMwGlobals( 'wgContentHandlerUseDB', $contentHandlerUseDB );
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->overrideCommentStoreAndActorMigration();
+ $this->assertEquals( $expected, Revision::selectArchiveFields() );
+ }
+
+ /**
+ * @covers Revision::selectTextFields
+ */
+ public function testSelectTextFields() {
+ $this->hideDeprecated( 'Revision::selectTextFields' );
+ $this->assertEquals(
+ [
+ 'old_text',
+ 'old_flags',
+ ],
+ Revision::selectTextFields()
+ );
+ }
+
+ /**
+ * @covers Revision::selectPageFields
+ */
+ public function testSelectPageFields() {
+ $this->hideDeprecated( 'Revision::selectPageFields' );
+ $this->assertEquals(
+ [
+ 'page_namespace',
+ 'page_title',
+ 'page_id',
+ 'page_latest',
+ 'page_is_redirect',
+ 'page_len',
+ ],
+ Revision::selectPageFields()
+ );
+ }
+
+ /**
+ * @covers Revision::selectUserFields
+ */
+ public function testSelectUserFields() {
+ $this->hideDeprecated( 'Revision::selectUserFields' );
+ $this->assertEquals(
+ [
+ 'user_name',
+ ],
+ Revision::selectUserFields()
+ );
+ }
+
+ public function provideGetArchiveQueryInfo() {
+ yield 'wgContentHandlerUseDB false' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ ],
+ [
+ 'tables' => [
+ 'archive',
+ 'commentstore' => 'table',
+ 'actormigration' => 'table',
+ ],
+ 'fields' => [
+ 'ar_id',
+ 'ar_page_id',
+ 'ar_namespace',
+ 'ar_title',
+ 'ar_rev_id',
+ 'ar_text_id',
+ 'ar_timestamp',
+ 'ar_minor_edit',
+ 'ar_deleted',
+ 'ar_len',
+ 'ar_parent_id',
+ 'ar_sha1',
+ 'commentstore' => 'field',
+ 'ar_user' => 'actormigration_user',
+ 'ar_user_text' => 'actormigration_user_text',
+ 'ar_actor' => 'actormigration_actor',
+ ],
+ 'joins' => [ 'commentstore' => 'join', 'actormigration' => 'join' ],
+ ]
+ ];
+ yield 'wgContentHandlerUseDB true' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ ],
+ [
+ 'tables' => [
+ 'archive',
+ 'commentstore' => 'table',
+ 'actormigration' => 'table',
+ ],
+ 'fields' => [
+ 'ar_id',
+ 'ar_page_id',
+ 'ar_namespace',
+ 'ar_title',
+ 'ar_rev_id',
+ 'ar_text_id',
+ 'ar_timestamp',
+ 'ar_minor_edit',
+ 'ar_deleted',
+ 'ar_len',
+ 'ar_parent_id',
+ 'ar_sha1',
+ 'commentstore' => 'field',
+ 'ar_user' => 'actormigration_user',
+ 'ar_user_text' => 'actormigration_user_text',
+ 'ar_actor' => 'actormigration_actor',
+ 'ar_content_format',
+ 'ar_content_model',
+ ],
+ 'joins' => [ 'commentstore' => 'join', 'actormigration' => 'join' ],
+ ]
+ ];
+ }
+
+ /**
+ * @covers Revision::getArchiveQueryInfo
+ * @dataProvider provideGetArchiveQueryInfo
+ */
+ public function testGetArchiveQueryInfo( $globals, $expected ) {
+ $this->setMwGlobals( $globals );
+ $this->overrideCommentStoreAndActorMigration();
+
+ $revisionStore = $this->getRevisionStore();
+ $revisionStore->setContentHandlerUseDB( $globals['wgContentHandlerUseDB'] );
+ $this->setService( 'RevisionStore', $revisionStore );
+ $this->assertEquals(
+ $expected,
+ Revision::getArchiveQueryInfo()
+ );
+ }
+
+ public function provideGetQueryInfo() {
+ yield 'wgContentHandlerUseDB false, opts none' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ ],
+ [],
+ [
+ 'tables' => [ 'revision', 'commentstore' => 'table', 'actormigration' => 'table' ],
+ 'fields' => [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_timestamp',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ 'commentstore' => 'field',
+ 'rev_user' => 'actormigration_user',
+ 'rev_user_text' => 'actormigration_user_text',
+ 'rev_actor' => 'actormigration_actor',
+ ],
+ 'joins' => [ 'commentstore' => 'join', 'actormigration' => 'join' ],
+ ],
+ ];
+ yield 'wgContentHandlerUseDB false, opts page' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ ],
+ [ 'page' ],
+ [
+ 'tables' => [ 'revision', 'commentstore' => 'table', 'actormigration' => 'table', 'page' ],
+ 'fields' => [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_timestamp',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ 'commentstore' => 'field',
+ 'rev_user' => 'actormigration_user',
+ 'rev_user_text' => 'actormigration_user_text',
+ 'rev_actor' => 'actormigration_actor',
+ 'page_namespace',
+ 'page_title',
+ 'page_id',
+ 'page_latest',
+ 'page_is_redirect',
+ 'page_len',
+ ],
+ 'joins' => [
+ 'page' => [
+ 'INNER JOIN',
+ [ 'page_id = rev_page' ],
+ ],
+ 'commentstore' => 'join',
+ 'actormigration' => 'join',
+ ],
+ ],
+ ];
+ yield 'wgContentHandlerUseDB false, opts user' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ ],
+ [ 'user' ],
+ [
+ 'tables' => [ 'revision', 'commentstore' => 'table', 'actormigration' => 'table', 'user' ],
+ 'fields' => [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_timestamp',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ 'commentstore' => 'field',
+ 'rev_user' => 'actormigration_user',
+ 'rev_user_text' => 'actormigration_user_text',
+ 'rev_actor' => 'actormigration_actor',
+ 'user_name',
+ ],
+ 'joins' => [
+ 'user' => [
+ 'LEFT JOIN',
+ [
+ 'actormigration_user != 0',
+ 'user_id = actormigration_user',
+ ],
+ ],
+ 'commentstore' => 'join',
+ 'actormigration' => 'join',
+ ],
+ ],
+ ];
+ yield 'wgContentHandlerUseDB false, opts text' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ ],
+ [ 'text' ],
+ [
+ 'tables' => [ 'revision', 'commentstore' => 'table', 'actormigration' => 'table', 'text' ],
+ 'fields' => [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_timestamp',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ 'commentstore' => 'field',
+ 'rev_user' => 'actormigration_user',
+ 'rev_user_text' => 'actormigration_user_text',
+ 'rev_actor' => 'actormigration_actor',
+ 'old_text',
+ 'old_flags',
+ ],
+ 'joins' => [
+ 'text' => [
+ 'INNER JOIN',
+ [ 'rev_text_id=old_id' ],
+ ],
+ 'commentstore' => 'join',
+ 'actormigration' => 'join',
+ ],
+ ],
+ ];
+ yield 'wgContentHandlerUseDB false, opts 3' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ ],
+ [ 'text', 'page', 'user' ],
+ [
+ 'tables' => [
+ 'revision', 'commentstore' => 'table', 'actormigration' => 'table', 'page', 'user', 'text'
+ ],
+ 'fields' => [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_timestamp',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ 'commentstore' => 'field',
+ 'rev_user' => 'actormigration_user',
+ 'rev_user_text' => 'actormigration_user_text',
+ 'rev_actor' => 'actormigration_actor',
+ 'page_namespace',
+ 'page_title',
+ 'page_id',
+ 'page_latest',
+ 'page_is_redirect',
+ 'page_len',
+ 'user_name',
+ 'old_text',
+ 'old_flags',
+ ],
+ 'joins' => [
+ 'page' => [
+ 'INNER JOIN',
+ [ 'page_id = rev_page' ],
+ ],
+ 'user' => [
+ 'LEFT JOIN',
+ [
+ 'actormigration_user != 0',
+ 'user_id = actormigration_user',
+ ],
+ ],
+ 'text' => [
+ 'INNER JOIN',
+ [ 'rev_text_id=old_id' ],
+ ],
+ 'commentstore' => 'join',
+ 'actormigration' => 'join',
+ ],
+ ],
+ ];
+ yield 'wgContentHandlerUseDB true, opts none' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ ],
+ [],
+ [
+ 'tables' => [ 'revision', 'commentstore' => 'table', 'actormigration' => 'table' ],
+ 'fields' => [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_timestamp',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ 'commentstore' => 'field',
+ 'rev_user' => 'actormigration_user',
+ 'rev_user_text' => 'actormigration_user_text',
+ 'rev_actor' => 'actormigration_actor',
+ 'rev_content_format',
+ 'rev_content_model',
+ ],
+ 'joins' => [ 'commentstore' => 'join', 'actormigration' => 'join' ],
+ ],
+ ];
+ }
+
+ /**
+ * @covers Revision::getQueryInfo
+ * @dataProvider provideGetQueryInfo
+ */
+ public function testGetQueryInfo( $globals, $options, $expected ) {
+ $this->setMwGlobals( $globals );
+ $this->overrideCommentStoreAndActorMigration();
+
+ $revisionStore = $this->getRevisionStore();
+ $revisionStore->setContentHandlerUseDB( $globals['wgContentHandlerUseDB'] );
+ $this->setService( 'RevisionStore', $revisionStore );
+
+ $this->assertEquals(
+ $expected,
+ Revision::getQueryInfo( $options )
+ );
+ }
+
+ /**
+ * @covers Revision::getSize
+ */
+ public function testGetSize() {
+ $title = $this->getMockTitle();
+
+ $rec = new MutableRevisionRecord( $title );
+ $rev = new Revision( $rec, 0, $title );
+
+ $this->assertSame( 0, $rev->getSize(), 'Size of no slots is 0' );
+
+ $rec->setSize( 13 );
+ $this->assertSame( 13, $rev->getSize() );
+ }
+
+ /**
+ * @covers Revision::getSize
+ */
+ public function testGetSize_failure() {
+ $title = $this->getMockTitle();
+
+ $rec = $this->getMockBuilder( RevisionRecord::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $rec->method( 'getSize' )
+ ->willThrowException( new RevisionAccessException( 'Oops!' ) );
+
+ $rev = new Revision( $rec, 0, $title );
+ $this->assertNull( $rev->getSize() );
+ }
+
+ /**
+ * @covers Revision::getSha1
+ */
+ public function testGetSha1() {
+ $title = $this->getMockTitle();
+
+ $rec = new MutableRevisionRecord( $title );
+ $rev = new Revision( $rec, 0, $title );
+
+ $emptyHash = SlotRecord::base36Sha1( '' );
+ $this->assertSame( $emptyHash, $rev->getSha1(), 'Sha1 of no slots is hash of empty string' );
+
+ $rec->setSha1( 'deadbeef' );
+ $this->assertSame( 'deadbeef', $rev->getSha1() );
+ }
+
+ /**
+ * @covers Revision::getSha1
+ */
+ public function testGetSha1_failure() {
+ $title = $this->getMockTitle();
+
+ $rec = $this->getMockBuilder( RevisionRecord::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $rec->method( 'getSha1' )
+ ->willThrowException( new RevisionAccessException( 'Oops!' ) );
+
+ $rev = new Revision( $rec, 0, $title );
+ $this->assertNull( $rev->getSha1() );
+ }
+
+ /**
+ * @covers Revision::getContent
+ */
+ public function testGetContent() {
+ $title = $this->getMockTitle();
+
+ $rec = new MutableRevisionRecord( $title );
+ $rev = new Revision( $rec, 0, $title );
+
+ $this->assertNull( $rev->getContent(), 'Content of no slots is null' );
+
+ $content = new TextContent( 'Hello Kittens!' );
+ $rec->setContent( 'main', $content );
+ $this->assertSame( $content, $rev->getContent() );
+ }
+
+ /**
+ * @covers Revision::getContent
+ */
+ public function testGetContent_failure() {
+ $title = $this->getMockTitle();
+
+ $rec = $this->getMockBuilder( RevisionRecord::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $rec->method( 'getContent' )
+ ->willThrowException( new RevisionAccessException( 'Oops!' ) );
+
+ $rev = new Revision( $rec, 0, $title );
+ $this->assertNull( $rev->getContent() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/RevisionTestModifyableContent.php b/www/wiki/tests/phpunit/includes/RevisionTestModifyableContent.php
new file mode 100644
index 00000000..6dcba53c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/RevisionTestModifyableContent.php
@@ -0,0 +1,23 @@
+<?php
+
+class RevisionTestModifyableContent extends TextContent {
+
+ const MODEL_ID = "RevisionTestModifyableContent";
+
+ public function __construct( $text ) {
+ parent::__construct( $text, self::MODEL_ID );
+ }
+
+ public function copy() {
+ return new RevisionTestModifyableContent( $this->mText );
+ }
+
+ public function getText() {
+ return $this->mText;
+ }
+
+ public function setText( $text ) {
+ $this->mText = $text;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/RevisionTestModifyableContentHandler.php b/www/wiki/tests/phpunit/includes/RevisionTestModifyableContentHandler.php
new file mode 100644
index 00000000..bc4e40a4
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/RevisionTestModifyableContentHandler.php
@@ -0,0 +1,19 @@
+<?php
+
+class RevisionTestModifyableContentHandler extends TextContentHandler {
+
+ public function __construct() {
+ parent::__construct( RevisionTestModifyableContent::MODEL_ID, [ CONTENT_FORMAT_TEXT ] );
+ }
+
+ public function unserializeContent( $text, $format = null ) {
+ $this->checkFormat( $format );
+
+ return new RevisionTestModifyableContent( $text );
+ }
+
+ public function makeEmptyContent() {
+ return new RevisionTestModifyableContent( '' );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/SampleTest.php b/www/wiki/tests/phpunit/includes/SampleTest.php
new file mode 100644
index 00000000..3d74ae3e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/SampleTest.php
@@ -0,0 +1,106 @@
+<?php
+
+class SampleTest extends MediaWikiLangTestCase {
+
+ /**
+ * Anything that needs to happen before your tests should go here.
+ */
+ protected function setUp() {
+ // Be sure to do call the parent setup and teardown functions.
+ // This makes sure that all the various cleanup and restorations
+ // happen as they should (including the restoration for setMwGlobals).
+ parent::setUp();
+
+ // This sets the globals and will restore them automatically
+ // after each test.
+ $this->setMwGlobals( [
+ 'wgContLang' => Language::factory( 'en' ),
+ 'wgLanguageCode' => 'en',
+ 'wgCapitalLinks' => true,
+ ] );
+ }
+
+ /**
+ * Anything cleanup you need to do should go here.
+ */
+ protected function tearDown() {
+ parent::tearDown();
+ }
+
+ /**
+ * Name tests so that PHPUnit can turn them into sentences when
+ * they run. While MediaWiki isn't strictly an Agile Programming
+ * project, you are encouraged to use the naming described under
+ * "Agile Documentation" at
+ * https://www.phpunit.de/manual/3.4/en/other-uses-for-tests.html
+ */
+ public function testTitleObjectStringConversion() {
+ $title = Title::newFromText( "text" );
+ $this->assertInstanceOf( Title::class, $title, "Title creation" );
+ $this->assertEquals( "Text", $title, "Automatic string conversion" );
+
+ $title = Title::newFromText( "text", NS_MEDIA );
+ $this->assertEquals( "Media:Text", $title, "Title creation with namespace" );
+ }
+
+ /**
+ * If you want to run a the same test with a variety of data, use a data provider.
+ * see: https://www.phpunit.de/manual/3.4/en/writing-tests-for-phpunit.html
+ */
+ public static function provideTitles() {
+ return [
+ [ 'Text', NS_MEDIA, 'Media:Text' ],
+ [ 'Text', null, 'Text' ],
+ [ 'text', null, 'Text' ],
+ [ 'Text', NS_USER, 'User:Text' ],
+ [ 'Photo.jpg', NS_FILE, 'File:Photo.jpg' ]
+ ];
+ }
+
+ /**
+ * phpcs:disable Generic.Files.LineLength
+ * @dataProvider provideTitles
+ * See https://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.dataProvider
+ * phpcs:enable
+ */
+ public function testCreateBasicListOfTitles( $titleName, $ns, $text ) {
+ $title = Title::newFromText( $titleName, $ns );
+ $this->assertEquals( $text, "$title", "see if '$titleName' matches '$text'" );
+ }
+
+ public function testSetUpMainPageTitleForNextTest() {
+ $title = Title::newMainPage();
+ $this->assertEquals( "Main Page", "$title", "Test initial creation of a title" );
+
+ return $title;
+ }
+
+ /**
+ * Instead of putting a bunch of tests in a single test method,
+ * you should put only one or two tests in each test method. This
+ * way, the test method names can remain descriptive.
+ *
+ * If you want to make tests depend on data created in another
+ * method, you can create dependencies feed whatever you return
+ * from the dependant method (e.g. testInitialCreation in this
+ * example) as arguments to the next method (e.g. $title in
+ * testTitleDepends is whatever testInitialCreatiion returned.)
+ */
+
+ /**
+ * @depends testSetUpMainPageTitleForNextTest
+ * See https://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.depends
+ */
+ public function testCheckMainPageTitleIsConsideredLocal( $title ) {
+ $this->assertTrue( $title->isLocal() );
+ }
+
+ /**
+ * @expectedException InvalidArgumentException
+ * See https://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.expectedException
+ */
+ public function testTitleObjectFromObject() {
+ $title = Title::newFromText( Title::newFromText( "test" ) );
+ $this->assertEquals( "Test", $title->isLocal() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/SanitizerValidateEmailTest.php b/www/wiki/tests/phpunit/includes/SanitizerValidateEmailTest.php
new file mode 100644
index 00000000..c4e43084
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/SanitizerValidateEmailTest.php
@@ -0,0 +1,105 @@
+<?php
+
+/**
+ * @covers Sanitizer::validateEmail
+ * @todo all test methods in this class should be refactored and...
+ * use a single test method and a single data provider...
+ */
+class SanitizerValidateEmailTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ private function checkEmail( $addr, $expected = true, $msg = '' ) {
+ if ( $msg == '' ) {
+ $msg = "Testing $addr";
+ }
+
+ $this->assertEquals(
+ $expected,
+ Sanitizer::validateEmail( $addr ),
+ $msg
+ );
+ }
+
+ private function valid( $addr, $msg = '' ) {
+ $this->checkEmail( $addr, true, $msg );
+ }
+
+ private function invalid( $addr, $msg = '' ) {
+ $this->checkEmail( $addr, false, $msg );
+ }
+
+ public function testEmailWellKnownUserAtHostDotTldAreValid() {
+ $this->valid( 'user@example.com' );
+ $this->valid( 'user@example.museum' );
+ }
+
+ public function testEmailWithUpperCaseCharactersAreValid() {
+ $this->valid( 'USER@example.com' );
+ $this->valid( 'user@EXAMPLE.COM' );
+ $this->valid( 'user@Example.com' );
+ $this->valid( 'USER@eXAMPLE.com' );
+ }
+
+ public function testEmailWithAPlusInUserName() {
+ $this->valid( 'user+sub@example.com' );
+ $this->valid( 'user+@example.com' );
+ }
+
+ public function testEmailDoesNotNeedATopLevelDomain() {
+ $this->valid( "user@localhost" );
+ $this->valid( "FooBar@localdomain" );
+ $this->valid( "nobody@mycompany" );
+ }
+
+ public function testEmailWithWhiteSpacesBeforeOrAfterAreInvalids() {
+ $this->invalid( " user@host.com" );
+ $this->invalid( "user@host.com " );
+ $this->invalid( "\tuser@host.com" );
+ $this->invalid( "user@host.com\t" );
+ }
+
+ public function testEmailWithWhiteSpacesAreInvalids() {
+ $this->invalid( "User user@host" );
+ $this->invalid( "first last@mycompany" );
+ $this->invalid( "firstlast@my company" );
+ }
+
+ /**
+ * T28948 : comma were matched by an incorrect regexp range
+ */
+ public function testEmailWithCommasAreInvalids() {
+ $this->invalid( "user,foo@example.org" );
+ $this->invalid( "userfoo@ex,ample.org" );
+ }
+
+ public function testEmailWithHyphens() {
+ $this->valid( "user-foo@example.org" );
+ $this->valid( "userfoo@ex-ample.org" );
+ }
+
+ public function testEmailDomainCanNotBeginWithDot() {
+ $this->invalid( "user@." );
+ $this->invalid( "user@.localdomain" );
+ $this->invalid( "user@localdomain." );
+ $this->valid( "user.@localdomain" );
+ $this->valid( ".@localdomain" );
+ $this->invalid( ".@a............" );
+ }
+
+ public function testEmailWithFunnyCharacters() {
+ $this->valid( "\$user!ex{this}@123.com" );
+ }
+
+ public function testEmailTopLevelDomainCanBeNumerical() {
+ $this->valid( "user@example.1234" );
+ }
+
+ public function testEmailWithoutAtSignIsInvalid() {
+ $this->invalid( 'useràexample.com' );
+ }
+
+ public function testEmailWithOneCharacterDomainIsValid() {
+ $this->valid( 'user@a' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/SiteConfigurationTest.php b/www/wiki/tests/phpunit/includes/SiteConfigurationTest.php
new file mode 100644
index 00000000..bdd4b1e5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/SiteConfigurationTest.php
@@ -0,0 +1,363 @@
+<?php
+
+class SiteConfigurationTest extends MediaWikiTestCase {
+
+ /**
+ * @var SiteConfiguration
+ */
+ protected $mConf;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->mConf = new SiteConfiguration;
+
+ $this->mConf->suffixes = [ 'wikipedia' => 'wiki' ];
+ $this->mConf->wikis = [ 'enwiki', 'dewiki', 'frwiki' ];
+ $this->mConf->settings = [
+ 'simple' => [
+ 'wiki' => 'wiki',
+ 'tag' => 'tag',
+ 'enwiki' => 'enwiki',
+ 'dewiki' => 'dewiki',
+ 'frwiki' => 'frwiki',
+ ],
+
+ 'fallback' => [
+ 'default' => 'default',
+ 'wiki' => 'wiki',
+ 'tag' => 'tag',
+ ],
+
+ 'params' => [
+ 'default' => '$lang $site $wiki',
+ ],
+
+ '+global' => [
+ 'wiki' => [
+ 'wiki' => 'wiki',
+ ],
+ 'tag' => [
+ 'tag' => 'tag',
+ ],
+ 'enwiki' => [
+ 'enwiki' => 'enwiki',
+ ],
+ 'dewiki' => [
+ 'dewiki' => 'dewiki',
+ ],
+ 'frwiki' => [
+ 'frwiki' => 'frwiki',
+ ],
+ ],
+
+ 'merge' => [
+ '+wiki' => [
+ 'wiki' => 'wiki',
+ ],
+ '+tag' => [
+ 'tag' => 'tag',
+ ],
+ 'default' => [
+ 'default' => 'default',
+ ],
+ '+enwiki' => [
+ 'enwiki' => 'enwiki',
+ ],
+ '+dewiki' => [
+ 'dewiki' => 'dewiki',
+ ],
+ '+frwiki' => [
+ 'frwiki' => 'frwiki',
+ ],
+ ],
+ ];
+
+ $GLOBALS['global'] = [ 'global' => 'global' ];
+ }
+
+ /**
+ * This function is used as a callback within the tests below
+ */
+ public static function getSiteParamsCallback( $conf, $wiki ) {
+ $site = null;
+ $lang = null;
+ foreach ( $conf->suffixes as $suffix ) {
+ if ( substr( $wiki, -strlen( $suffix ) ) == $suffix ) {
+ $site = $suffix;
+ $lang = substr( $wiki, 0, -strlen( $suffix ) );
+ break;
+ }
+ }
+
+ return [
+ 'suffix' => $site,
+ 'lang' => $lang,
+ 'params' => [
+ 'lang' => $lang,
+ 'site' => $site,
+ 'wiki' => $wiki,
+ ],
+ 'tags' => [ 'tag' ],
+ ];
+ }
+
+ /**
+ * @covers SiteConfiguration::siteFromDB
+ */
+ public function testSiteFromDb() {
+ $this->assertEquals(
+ [ 'wikipedia', 'en' ],
+ $this->mConf->siteFromDB( 'enwiki' ),
+ 'siteFromDB()'
+ );
+ $this->assertEquals(
+ [ 'wikipedia', '' ],
+ $this->mConf->siteFromDB( 'wiki' ),
+ 'siteFromDB() on a suffix'
+ );
+ $this->assertEquals(
+ [ null, null ],
+ $this->mConf->siteFromDB( 'wikien' ),
+ 'siteFromDB() on a non-existing wiki'
+ );
+
+ $this->mConf->suffixes = [ 'wiki', '' ];
+ $this->assertEquals(
+ [ '', 'wikien' ],
+ $this->mConf->siteFromDB( 'wikien' ),
+ 'siteFromDB() on a non-existing wiki (2)'
+ );
+ }
+
+ /**
+ * @covers SiteConfiguration::getLocalDatabases
+ */
+ public function testGetLocalDatabases() {
+ $this->assertEquals(
+ [ 'enwiki', 'dewiki', 'frwiki' ],
+ $this->mConf->getLocalDatabases(),
+ 'getLocalDatabases()'
+ );
+ }
+
+ /**
+ * @covers SiteConfiguration::get
+ */
+ public function testGetConfVariables() {
+ $this->assertEquals(
+ 'enwiki',
+ $this->mConf->get( 'simple', 'enwiki', 'wiki' ),
+ 'get(): simple setting on an existing wiki'
+ );
+ $this->assertEquals(
+ 'dewiki',
+ $this->mConf->get( 'simple', 'dewiki', 'wiki' ),
+ 'get(): simple setting on an existing wiki (2)'
+ );
+ $this->assertEquals(
+ 'frwiki',
+ $this->mConf->get( 'simple', 'frwiki', 'wiki' ),
+ 'get(): simple setting on an existing wiki (3)'
+ );
+ $this->assertEquals(
+ 'wiki',
+ $this->mConf->get( 'simple', 'wiki', 'wiki' ),
+ 'get(): simple setting on an suffix'
+ );
+ $this->assertEquals(
+ 'wiki',
+ $this->mConf->get( 'simple', 'eswiki', 'wiki' ),
+ 'get(): simple setting on an non-existing wiki'
+ );
+
+ $this->assertEquals(
+ 'wiki',
+ $this->mConf->get( 'fallback', 'enwiki', 'wiki' ),
+ 'get(): fallback setting on an existing wiki'
+ );
+ $this->assertEquals(
+ 'tag',
+ $this->mConf->get( 'fallback', 'dewiki', 'wiki', [], [ 'tag' ] ),
+ 'get(): fallback setting on an existing wiki (with wiki tag)'
+ );
+ $this->assertEquals(
+ 'wiki',
+ $this->mConf->get( 'fallback', 'wiki', 'wiki' ),
+ 'get(): fallback setting on an suffix'
+ );
+ $this->assertEquals(
+ 'wiki',
+ $this->mConf->get( 'fallback', 'wiki', 'wiki', [], [ 'tag' ] ),
+ 'get(): fallback setting on an suffix (with wiki tag)'
+ );
+ $this->assertEquals(
+ 'wiki',
+ $this->mConf->get( 'fallback', 'eswiki', 'wiki' ),
+ 'get(): fallback setting on an non-existing wiki'
+ );
+ $this->assertEquals(
+ 'tag',
+ $this->mConf->get( 'fallback', 'eswiki', 'wiki', [], [ 'tag' ] ),
+ 'get(): fallback setting on an non-existing wiki (with wiki tag)'
+ );
+
+ $common = [ 'wiki' => 'wiki', 'default' => 'default' ];
+ $commonTag = [ 'tag' => 'tag', 'wiki' => 'wiki', 'default' => 'default' ];
+ $this->assertEquals(
+ [ 'enwiki' => 'enwiki' ] + $common,
+ $this->mConf->get( 'merge', 'enwiki', 'wiki' ),
+ 'get(): merging setting on an existing wiki'
+ );
+ $this->assertEquals(
+ [ 'enwiki' => 'enwiki' ] + $commonTag,
+ $this->mConf->get( 'merge', 'enwiki', 'wiki', [], [ 'tag' ] ),
+ 'get(): merging setting on an existing wiki (with tag)'
+ );
+ $this->assertEquals(
+ [ 'dewiki' => 'dewiki' ] + $common,
+ $this->mConf->get( 'merge', 'dewiki', 'wiki' ),
+ 'get(): merging setting on an existing wiki (2)'
+ );
+ $this->assertEquals(
+ [ 'dewiki' => 'dewiki' ] + $commonTag,
+ $this->mConf->get( 'merge', 'dewiki', 'wiki', [], [ 'tag' ] ),
+ 'get(): merging setting on an existing wiki (2) (with tag)'
+ );
+ $this->assertEquals(
+ [ 'frwiki' => 'frwiki' ] + $common,
+ $this->mConf->get( 'merge', 'frwiki', 'wiki' ),
+ 'get(): merging setting on an existing wiki (3)'
+ );
+ $this->assertEquals(
+ [ 'frwiki' => 'frwiki' ] + $commonTag,
+ $this->mConf->get( 'merge', 'frwiki', 'wiki', [], [ 'tag' ] ),
+ 'get(): merging setting on an existing wiki (3) (with tag)'
+ );
+ $this->assertEquals(
+ [ 'wiki' => 'wiki' ] + $common,
+ $this->mConf->get( 'merge', 'wiki', 'wiki' ),
+ 'get(): merging setting on an suffix'
+ );
+ $this->assertEquals(
+ [ 'wiki' => 'wiki' ] + $commonTag,
+ $this->mConf->get( 'merge', 'wiki', 'wiki', [], [ 'tag' ] ),
+ 'get(): merging setting on an suffix (with tag)'
+ );
+ $this->assertEquals(
+ $common,
+ $this->mConf->get( 'merge', 'eswiki', 'wiki' ),
+ 'get(): merging setting on an non-existing wiki'
+ );
+ $this->assertEquals(
+ $commonTag,
+ $this->mConf->get( 'merge', 'eswiki', 'wiki', [], [ 'tag' ] ),
+ 'get(): merging setting on an non-existing wiki (with tag)'
+ );
+ }
+
+ /**
+ * @covers SiteConfiguration::siteFromDB
+ */
+ public function testSiteFromDbWithCallback() {
+ $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback';
+
+ $this->assertEquals(
+ [ 'wiki', 'en' ],
+ $this->mConf->siteFromDB( 'enwiki' ),
+ 'siteFromDB() with callback'
+ );
+ $this->assertEquals(
+ [ 'wiki', '' ],
+ $this->mConf->siteFromDB( 'wiki' ),
+ 'siteFromDB() with callback on a suffix'
+ );
+ $this->assertEquals(
+ [ null, null ],
+ $this->mConf->siteFromDB( 'wikien' ),
+ 'siteFromDB() with callback on a non-existing wiki'
+ );
+ }
+
+ /**
+ * @covers SiteConfiguration::get
+ */
+ public function testParameterReplacement() {
+ $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback';
+
+ $this->assertEquals(
+ 'en wiki enwiki',
+ $this->mConf->get( 'params', 'enwiki', 'wiki' ),
+ 'get(): parameter replacement on an existing wiki'
+ );
+ $this->assertEquals(
+ 'de wiki dewiki',
+ $this->mConf->get( 'params', 'dewiki', 'wiki' ),
+ 'get(): parameter replacement on an existing wiki (2)'
+ );
+ $this->assertEquals(
+ 'fr wiki frwiki',
+ $this->mConf->get( 'params', 'frwiki', 'wiki' ),
+ 'get(): parameter replacement on an existing wiki (3)'
+ );
+ $this->assertEquals(
+ ' wiki wiki',
+ $this->mConf->get( 'params', 'wiki', 'wiki' ),
+ 'get(): parameter replacement on an suffix'
+ );
+ $this->assertEquals(
+ 'es wiki eswiki',
+ $this->mConf->get( 'params', 'eswiki', 'wiki' ),
+ 'get(): parameter replacement on an non-existing wiki'
+ );
+ }
+
+ /**
+ * @covers SiteConfiguration::getAll
+ */
+ public function testGetAllGlobals() {
+ $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback';
+
+ $getall = [
+ 'simple' => 'enwiki',
+ 'fallback' => 'tag',
+ 'params' => 'en wiki enwiki',
+ 'global' => [ 'enwiki' => 'enwiki' ] + $GLOBALS['global'],
+ 'merge' => [
+ 'enwiki' => 'enwiki',
+ 'tag' => 'tag',
+ 'wiki' => 'wiki',
+ 'default' => 'default'
+ ],
+ ];
+ $this->assertEquals( $getall, $this->mConf->getAll( 'enwiki' ), 'getAll()' );
+
+ $this->mConf->extractAllGlobals( 'enwiki', 'wiki' );
+
+ $this->assertEquals(
+ $getall['simple'],
+ $GLOBALS['simple'],
+ 'extractAllGlobals(): simple setting'
+ );
+ $this->assertEquals(
+ $getall['fallback'],
+ $GLOBALS['fallback'],
+ 'extractAllGlobals(): fallback setting'
+ );
+ $this->assertEquals(
+ $getall['params'],
+ $GLOBALS['params'],
+ 'extractAllGlobals(): parameter replacement'
+ );
+ $this->assertEquals(
+ $getall['global'],
+ $GLOBALS['global'],
+ 'extractAllGlobals(): merging with global'
+ );
+ $this->assertEquals(
+ $getall['merge'],
+ $GLOBALS['merge'],
+ 'extractAllGlobals(): merging setting'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/SiteStatsTest.php b/www/wiki/tests/phpunit/includes/SiteStatsTest.php
new file mode 100644
index 00000000..56bde5da
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/SiteStatsTest.php
@@ -0,0 +1,42 @@
+<?php
+
+class SiteStatsTest extends MediaWikiTestCase {
+
+ /**
+ * @covers SiteStats::jobs
+ */
+ function testJobsCountGetCached() {
+ $this->setService( 'MainWANObjectCache',
+ new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ) );
+ $cache = \MediaWiki\MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $jobq = JobQueueGroup::singleton();
+
+ // Delete jobs that might have been left behind by other tests
+ $jobq->get( 'htmlCacheUpdate' )->delete();
+ $jobq->get( 'recentChangesUpdate' )->delete();
+ $jobq->get( 'userGroupExpiry' )->delete();
+ $cache->delete( $cache->makeKey( 'SiteStats', 'jobscount' ) );
+
+ $jobq->push( new NullJob( Title::newMainPage(), [] ) );
+ $this->assertEquals( 1, SiteStats::jobs(),
+ 'A single job enqueued bumps jobscount stat to 1' );
+
+ $jobq->push( new NullJob( Title::newMainPage(), [] ) );
+ $this->assertEquals( 1, SiteStats::jobs(),
+ 'SiteStats::jobs() count does not reflect addition ' .
+ 'of a second job (cached)'
+ );
+
+ $jobq->get( 'null' )->delete(); // clear jobqueue
+ $this->assertEquals( 0, $jobq->get( 'null' )->getSize(),
+ 'Job queue for NullJob has been cleaned' );
+
+ $cache->delete( $cache->makeKey( 'SiteStats', 'jobscount' ) );
+ $this->assertEquals( 1, SiteStats::jobs(),
+ 'jobs count is kept in process cache' );
+
+ $cache->clearProcessCache();
+ $this->assertEquals( 0, SiteStats::jobs() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/StatusTest.php b/www/wiki/tests/phpunit/includes/StatusTest.php
new file mode 100644
index 00000000..6e62afdd
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/StatusTest.php
@@ -0,0 +1,722 @@
+<?php
+
+/**
+ * @author Addshore
+ */
+class StatusTest extends MediaWikiLangTestCase {
+
+ /**
+ * @dataProvider provideValues
+ * @covers Status::newGood
+ */
+ public function testNewGood( $value = null ) {
+ $status = Status::newGood( $value );
+ $this->assertTrue( $status->isGood() );
+ $this->assertTrue( $status->isOK() );
+ $this->assertEquals( $value, $status->getValue() );
+ }
+
+ public static function provideValues() {
+ return [
+ [],
+ [ 'foo' ],
+ [ [ 'foo' => 'bar' ] ],
+ [ new Exception() ],
+ [ 1234 ],
+ ];
+ }
+
+ /**
+ * @covers Status::newFatal
+ */
+ public function testNewFatalWithMessage() {
+ $message = $this->getMockBuilder( Message::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $status = Status::newFatal( $message );
+ $this->assertFalse( $status->isGood() );
+ $this->assertFalse( $status->isOK() );
+ $this->assertEquals( $message, $status->getMessage() );
+ }
+
+ /**
+ * @covers Status::newFatal
+ */
+ public function testNewFatalWithString() {
+ $message = 'foo';
+ $status = Status::newFatal( $message );
+ $this->assertFalse( $status->isGood() );
+ $this->assertFalse( $status->isOK() );
+ $this->assertEquals( $message, $status->getMessage()->getKey() );
+ }
+
+ /**
+ * Test 'ok' and 'errors' getters.
+ *
+ * @covers Status::__get
+ */
+ public function testOkAndErrorsGetters() {
+ $status = Status::newGood( 'foo' );
+ $this->assertTrue( $status->ok );
+ $status = Status::newFatal( 'foo', 1, 2 );
+ $this->assertFalse( $status->ok );
+ $this->assertArrayEquals(
+ [
+ [
+ 'type' => 'error',
+ 'message' => 'foo',
+ 'params' => [ 1, 2 ]
+ ]
+ ],
+ $status->errors
+ );
+ }
+
+ /**
+ * Test 'ok' setter.
+ *
+ * @covers Status::__set
+ */
+ public function testOkSetter() {
+ $status = new Status();
+ $status->ok = false;
+ $this->assertFalse( $status->isOK() );
+ $status->ok = true;
+ $this->assertTrue( $status->isOK() );
+ }
+
+ /**
+ * @dataProvider provideSetResult
+ * @covers Status::setResult
+ */
+ public function testSetResult( $ok, $value = null ) {
+ $status = new Status();
+ $status->setResult( $ok, $value );
+ $this->assertEquals( $ok, $status->isOK() );
+ $this->assertEquals( $value, $status->getValue() );
+ }
+
+ public static function provideSetResult() {
+ return [
+ [ true ],
+ [ false ],
+ [ true, 'value' ],
+ [ false, 'value' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsOk
+ * @covers Status::setOK
+ * @covers Status::isOK
+ */
+ public function testIsOk( $ok ) {
+ $status = new Status();
+ $status->setOK( $ok );
+ $this->assertEquals( $ok, $status->isOK() );
+ }
+
+ public static function provideIsOk() {
+ return [
+ [ true ],
+ [ false ],
+ ];
+ }
+
+ /**
+ * @covers Status::getValue
+ */
+ public function testGetValue() {
+ $status = new Status();
+ $status->value = 'foobar';
+ $this->assertEquals( 'foobar', $status->getValue() );
+ }
+
+ /**
+ * @dataProvider provideIsGood
+ * @covers Status::isGood
+ */
+ public function testIsGood( $ok, $errors, $expected ) {
+ $status = new Status();
+ $status->setOK( $ok );
+ foreach ( $errors as $error ) {
+ $status->warning( $error );
+ }
+ $this->assertEquals( $expected, $status->isGood() );
+ }
+
+ public static function provideIsGood() {
+ return [
+ [ true, [], true ],
+ [ true, [ 'foo' ], false ],
+ [ false, [], false ],
+ [ false, [ 'foo' ], false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideMockMessageDetails
+ * @covers Status::warning
+ * @covers Status::getWarningsArray
+ * @covers Status::getStatusArray
+ */
+ public function testWarningWithMessage( $mockDetails ) {
+ $status = new Status();
+ $messages = $this->getMockMessages( $mockDetails );
+
+ foreach ( $messages as $message ) {
+ $status->warning( $message );
+ }
+ $warnings = $status->getWarningsArray();
+
+ $this->assertEquals( count( $messages ), count( $warnings ) );
+ foreach ( $messages as $key => $message ) {
+ $expectedArray = array_merge( [ $message->getKey() ], $message->getParams() );
+ $this->assertEquals( $warnings[$key], $expectedArray );
+ }
+ }
+
+ /**
+ * @dataProvider provideMockMessageDetails
+ * @covers Status::error
+ * @covers Status::getErrorsArray
+ * @covers Status::getStatusArray
+ * @covers Status::getErrors
+ */
+ public function testErrorWithMessage( $mockDetails ) {
+ $status = new Status();
+ $messages = $this->getMockMessages( $mockDetails );
+
+ foreach ( $messages as $message ) {
+ $status->error( $message );
+ }
+ $errors = $status->getErrorsArray();
+
+ $this->assertEquals( count( $messages ), count( $errors ) );
+ foreach ( $messages as $key => $message ) {
+ $expectedArray = array_merge( [ $message->getKey() ], $message->getParams() );
+ $this->assertEquals( $errors[$key], $expectedArray );
+ }
+ }
+
+ /**
+ * @dataProvider provideMockMessageDetails
+ * @covers Status::fatal
+ * @covers Status::getErrorsArray
+ * @covers Status::getStatusArray
+ */
+ public function testFatalWithMessage( $mockDetails ) {
+ $status = new Status();
+ $messages = $this->getMockMessages( $mockDetails );
+
+ foreach ( $messages as $message ) {
+ $status->fatal( $message );
+ }
+ $errors = $status->getErrorsArray();
+
+ $this->assertEquals( count( $messages ), count( $errors ) );
+ foreach ( $messages as $key => $message ) {
+ $expectedArray = array_merge( [ $message->getKey() ], $message->getParams() );
+ $this->assertEquals( $errors[$key], $expectedArray );
+ }
+ $this->assertFalse( $status->isOK() );
+ }
+
+ protected function getMockMessage( $key = 'key', $params = [] ) {
+ $message = $this->getMockBuilder( Message::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $message->expects( $this->atLeastOnce() )
+ ->method( 'getKey' )
+ ->will( $this->returnValue( $key ) );
+ $message->expects( $this->atLeastOnce() )
+ ->method( 'getParams' )
+ ->will( $this->returnValue( $params ) );
+ return $message;
+ }
+
+ /**
+ * @param array $messageDetails E.g. array( 'KEY' => array(/PARAMS/) )
+ * @return Message[]
+ */
+ protected function getMockMessages( $messageDetails ) {
+ $messages = [];
+ foreach ( $messageDetails as $key => $paramsArray ) {
+ $messages[] = $this->getMockMessage( $key, $paramsArray );
+ }
+ return $messages;
+ }
+
+ public static function provideMockMessageDetails() {
+ return [
+ [ [ 'key1' => [ 'foo' => 'bar' ] ] ],
+ [ [ 'key1' => [ 'foo' => 'bar' ], 'key2' => [ 'foo2' => 'bar2' ] ] ],
+ ];
+ }
+
+ /**
+ * @covers Status::merge
+ */
+ public function testMerge() {
+ $status1 = new Status();
+ $status2 = new Status();
+ $message1 = $this->getMockMessage( 'warn1' );
+ $message2 = $this->getMockMessage( 'error2' );
+ $status1->warning( $message1 );
+ $status2->error( $message2 );
+
+ $status1->merge( $status2 );
+ $this->assertEquals(
+ 2,
+ count( $status1->getWarningsArray() ) + count( $status1->getErrorsArray() )
+ );
+ }
+
+ /**
+ * @covers Status::merge
+ */
+ public function testMergeWithOverwriteValue() {
+ $status1 = new Status();
+ $status2 = new Status();
+ $message1 = $this->getMockMessage( 'warn1' );
+ $message2 = $this->getMockMessage( 'error2' );
+ $status1->warning( $message1 );
+ $status2->error( $message2 );
+ $status2->value = 'FooValue';
+
+ $status1->merge( $status2, true );
+ $this->assertEquals(
+ 2,
+ count( $status1->getWarningsArray() ) + count( $status1->getErrorsArray() )
+ );
+ $this->assertEquals( 'FooValue', $status1->getValue() );
+ }
+
+ /**
+ * @covers Status::hasMessage
+ */
+ public function testHasMessage() {
+ $status = new Status();
+ $status->fatal( 'bad' );
+ $status->fatal( wfMessage( 'bad-msg' ) );
+ $this->assertTrue( $status->hasMessage( 'bad' ) );
+ $this->assertTrue( $status->hasMessage( 'bad-msg' ) );
+ $this->assertTrue( $status->hasMessage( wfMessage( 'bad-msg' ) ) );
+ $this->assertFalse( $status->hasMessage( 'good' ) );
+ }
+
+ /**
+ * @dataProvider provideCleanParams
+ * @covers Status::cleanParams
+ */
+ public function testCleanParams( $cleanCallback, $params, $expected ) {
+ $method = new ReflectionMethod( Status::class, 'cleanParams' );
+ $method->setAccessible( true );
+ $status = new Status();
+ $status->cleanCallback = $cleanCallback;
+
+ $this->assertEquals( $expected, $method->invoke( $status, $params ) );
+ }
+
+ public static function provideCleanParams() {
+ $cleanCallback = function ( $value ) {
+ return '-' . $value . '-';
+ };
+
+ return [
+ [ false, [ 'foo' => 'bar' ], [ 'foo' => 'bar' ] ],
+ [ $cleanCallback, [ 'foo' => 'bar' ], [ 'foo' => '-bar-' ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetWikiTextAndHtml
+ * @covers Status::getWikiText
+ */
+ public function testGetWikiText(
+ Status $status, $wikitext, $wrappedWikitext, $html, $wrappedHtml
+ ) {
+ $this->assertEquals( $wikitext, $status->getWikiText() );
+
+ $this->assertEquals( $wrappedWikitext, $status->getWikiText( 'wrap-short', 'wrap-long', 'qqx' ) );
+ }
+
+ /**
+ * @dataProvider provideGetWikiTextAndHtml
+ * @covers Status::getHtml
+ */
+ public function testGetHtml(
+ Status $status, $wikitext, $wrappedWikitext, $html, $wrappedHtml
+ ) {
+ $this->assertEquals( $html, $status->getHTML() );
+
+ $this->assertEquals( $wrappedHtml, $status->getHTML( 'wrap-short', 'wrap-long', 'qqx' ) );
+ }
+
+ /**
+ * @return array Array of arrays with values;
+ * 0 => status object
+ * 1 => expected string (with no context)
+ */
+ public static function provideGetWikiTextAndHtml() {
+ $testCases = [];
+
+ $testCases['GoodStatus'] = [
+ new Status(),
+ "Internal error: Status::getWikiText called for a good result, this is incorrect\n",
+ "(wrap-short: (internalerror_info: Status::getWikiText called for a good result, " .
+ "this is incorrect\n))",
+ "<p>Internal error: Status::getWikiText called for a good result, this is incorrect\n</p>",
+ "<p>(wrap-short: (internalerror_info: Status::getWikiText called for a good result, " .
+ "this is incorrect\n))\n</p>",
+ ];
+
+ $status = new Status();
+ $status->setOK( false );
+ $testCases['GoodButNoError'] = [
+ $status,
+ "Internal error: Status::getWikiText: Invalid result object: no error text but not OK\n",
+ "(wrap-short: (internalerror_info: Status::getWikiText: Invalid result object: " .
+ "no error text but not OK\n))",
+ "<p>Internal error: Status::getWikiText: Invalid result object: no error text but not OK\n</p>",
+ "<p>(wrap-short: (internalerror_info: Status::getWikiText: Invalid result object: " .
+ "no error text but not OK\n))\n</p>",
+ ];
+
+ $status = new Status();
+ $status->warning( 'fooBar!' );
+ $testCases['1StringWarning'] = [
+ $status,
+ "⧼fooBar!⧽",
+ "(wrap-short: (fooBar!))",
+ "<p>⧼fooBar!⧽\n</p>",
+ "<p>(wrap-short: (fooBar!))\n</p>",
+ ];
+
+ $status = new Status();
+ $status->warning( 'fooBar!' );
+ $status->warning( 'fooBar2!' );
+ $testCases['2StringWarnings'] = [
+ $status,
+ "* ⧼fooBar!⧽\n* ⧼fooBar2!⧽\n",
+ "(wrap-long: * (fooBar!)\n* (fooBar2!)\n)",
+ "<ul><li>⧼fooBar!⧽</li>\n<li>⧼fooBar2!⧽</li></ul>\n",
+ "<p>(wrap-long: * (fooBar!)\n</p>\n<ul><li>(fooBar2!)</li></ul>\n<p>)\n</p>",
+ ];
+
+ $status = new Status();
+ $status->warning( new Message( 'fooBar!', [ 'foo', 'bar' ] ) );
+ $testCases['1MessageWarning'] = [
+ $status,
+ "⧼fooBar!⧽",
+ "(wrap-short: (fooBar!: foo, bar))",
+ "<p>⧼fooBar!⧽\n</p>",
+ "<p>(wrap-short: (fooBar!: foo, bar))\n</p>",
+ ];
+
+ $status = new Status();
+ $status->warning( new Message( 'fooBar!', [ 'foo', 'bar' ] ) );
+ $status->warning( new Message( 'fooBar2!' ) );
+ $testCases['2MessageWarnings'] = [
+ $status,
+ "* ⧼fooBar!⧽\n* ⧼fooBar2!⧽\n",
+ "(wrap-long: * (fooBar!: foo, bar)\n* (fooBar2!)\n)",
+ "<ul><li>⧼fooBar!⧽</li>\n<li>⧼fooBar2!⧽</li></ul>\n",
+ "<p>(wrap-long: * (fooBar!: foo, bar)\n</p>\n<ul><li>(fooBar2!)</li></ul>\n<p>)\n</p>",
+ ];
+
+ return $testCases;
+ }
+
+ private static function sanitizedMessageParams( Message $message ) {
+ return array_map( function ( $p ) {
+ return $p instanceof Message
+ ? [
+ 'key' => $p->getKey(),
+ 'params' => self::sanitizedMessageParams( $p ),
+ 'lang' => $p->getLanguage()->getCode(),
+ ]
+ : $p;
+ }, $message->getParams() );
+ }
+
+ /**
+ * @dataProvider provideGetMessage
+ * @covers Status::getMessage
+ */
+ public function testGetMessage(
+ Status $status, $expectedParams = [], $expectedKey, $expectedWrapper
+ ) {
+ $message = $status->getMessage( null, null, 'qqx' );
+ $this->assertInstanceOf( Message::class, $message );
+ $this->assertEquals( $expectedParams, self::sanitizedMessageParams( $message ),
+ 'Message::getParams' );
+ $this->assertEquals( $expectedKey, $message->getKey(), 'Message::getKey' );
+
+ $message = $status->getMessage( 'wrapper-short', 'wrapper-long' );
+ $this->assertInstanceOf( Message::class, $message );
+ $this->assertEquals( $expectedWrapper, $message->getKey(), 'Message::getKey with wrappers' );
+ $this->assertCount( 1, $message->getParams(), 'Message::getParams with wrappers' );
+
+ $message = $status->getMessage( 'wrapper' );
+ $this->assertInstanceOf( Message::class, $message );
+ $this->assertEquals( 'wrapper', $message->getKey(), 'Message::getKey with wrappers' );
+ $this->assertCount( 1, $message->getParams(), 'Message::getParams with wrappers' );
+
+ $message = $status->getMessage( false, 'wrapper' );
+ $this->assertInstanceOf( Message::class, $message );
+ $this->assertEquals( 'wrapper', $message->getKey(), 'Message::getKey with wrappers' );
+ $this->assertCount( 1, $message->getParams(), 'Message::getParams with wrappers' );
+ }
+
+ /**
+ * @return array Array of arrays with values;
+ * 0 => status object
+ * 1 => expected Message parameters (with no context)
+ * 2 => expected Message key
+ */
+ public static function provideGetMessage() {
+ $testCases = [];
+
+ $testCases['GoodStatus'] = [
+ new Status(),
+ [ "Status::getMessage called for a good result, this is incorrect\n" ],
+ 'internalerror_info',
+ 'wrapper-short'
+ ];
+
+ $status = new Status();
+ $status->setOK( false );
+ $testCases['GoodButNoError'] = [
+ $status,
+ [ "Status::getMessage: Invalid result object: no error text but not OK\n" ],
+ 'internalerror_info',
+ 'wrapper-short'
+ ];
+
+ $status = new Status();
+ $status->warning( 'fooBar!' );
+ $testCases['1StringWarning'] = [
+ $status,
+ [],
+ 'fooBar!',
+ 'wrapper-short'
+ ];
+
+ $status = new Status();
+ $status->warning( 'fooBar!' );
+ $status->warning( 'fooBar2!' );
+ $testCases[ '2StringWarnings' ] = [
+ $status,
+ [
+ [ 'key' => 'fooBar!', 'params' => [], 'lang' => 'qqx' ],
+ [ 'key' => 'fooBar2!', 'params' => [], 'lang' => 'qqx' ]
+ ],
+ "* \$1\n* \$2",
+ 'wrapper-long'
+ ];
+
+ $status = new Status();
+ $status->warning( new Message( 'fooBar!', [ 'foo', 'bar' ] ) );
+ $testCases['1MessageWarning'] = [
+ $status,
+ [ 'foo', 'bar' ],
+ 'fooBar!',
+ 'wrapper-short'
+ ];
+
+ $status = new Status();
+ $status->warning( new Message( 'fooBar!', [ 'foo', 'bar' ] ) );
+ $status->warning( new Message( 'fooBar2!' ) );
+ $testCases['2MessageWarnings'] = [
+ $status,
+ [
+ [ 'key' => 'fooBar!', 'params' => [ 'foo', 'bar' ], 'lang' => 'qqx' ],
+ [ 'key' => 'fooBar2!', 'params' => [], 'lang' => 'qqx' ]
+ ],
+ "* \$1\n* \$2",
+ 'wrapper-long'
+ ];
+
+ return $testCases;
+ }
+
+ /**
+ * @covers Status::replaceMessage
+ */
+ public function testReplaceMessage() {
+ $status = new Status();
+ $message = new Message( 'key1', [ 'foo1', 'bar1' ] );
+ $status->error( $message );
+ $newMessage = new Message( 'key2', [ 'foo2', 'bar2' ] );
+
+ $status->replaceMessage( $message, $newMessage );
+
+ $this->assertEquals( $newMessage, $status->errors[0]['message'] );
+ }
+
+ /**
+ * @covers Status::getErrorMessage
+ */
+ public function testGetErrorMessage() {
+ $method = new ReflectionMethod( Status::class, 'getErrorMessage' );
+ $method->setAccessible( true );
+ $status = new Status();
+ $key = 'foo';
+ $params = [ 'bar' ];
+
+ /** @var Message $message */
+ $message = $method->invoke( $status, array_merge( [ $key ], $params ) );
+ $this->assertInstanceOf( Message::class, $message );
+ $this->assertEquals( $key, $message->getKey() );
+ $this->assertEquals( $params, $message->getParams() );
+ }
+
+ /**
+ * @covers Status::getErrorMessageArray
+ */
+ public function testGetErrorMessageArray() {
+ $method = new ReflectionMethod( Status::class, 'getErrorMessageArray' );
+ $method->setAccessible( true );
+ $status = new Status();
+ $key = 'foo';
+ $params = [ 'bar' ];
+
+ /** @var Message[] $messageArray */
+ $messageArray = $method->invoke(
+ $status,
+ [
+ array_merge( [ $key ], $params ),
+ array_merge( [ $key ], $params )
+ ]
+ );
+
+ $this->assertInternalType( 'array', $messageArray );
+ $this->assertCount( 2, $messageArray );
+ foreach ( $messageArray as $message ) {
+ $this->assertInstanceOf( Message::class, $message );
+ $this->assertEquals( $key, $message->getKey() );
+ $this->assertEquals( $params, $message->getParams() );
+ }
+ }
+
+ /**
+ * @covers Status::getErrorsByType
+ */
+ public function testGetErrorsByType() {
+ $status = new Status();
+ $warning = new Message( 'warning111' );
+ $error = new Message( 'error111' );
+ $status->warning( $warning );
+ $status->error( $error );
+
+ $warnings = $status->getErrorsByType( 'warning' );
+ $errors = $status->getErrorsByType( 'error' );
+
+ $this->assertCount( 1, $warnings );
+ $this->assertCount( 1, $errors );
+ $this->assertEquals( $warning, $warnings[0]['message'] );
+ $this->assertEquals( $error, $errors[0]['message'] );
+ }
+
+ /**
+ * @covers Status::__wakeup
+ */
+ public function testWakeUpSanitizesCallback() {
+ $status = new Status();
+ $status->cleanCallback = function ( $value ) {
+ return '-' . $value . '-';
+ };
+ $status->__wakeup();
+ $this->assertEquals( false, $status->cleanCallback );
+ }
+
+ /**
+ * @dataProvider provideNonObjectMessages
+ * @covers Status::getStatusArray
+ */
+ public function testGetStatusArrayWithNonObjectMessages( $nonObjMsg ) {
+ $status = new Status();
+ if ( !array_key_exists( 1, $nonObjMsg ) ) {
+ $status->warning( $nonObjMsg[0] );
+ } else {
+ $status->warning( $nonObjMsg[0], $nonObjMsg[1] );
+ }
+
+ $array = $status->getWarningsArray(); // We use getWarningsArray to access getStatusArray
+
+ $this->assertEquals( 1, count( $array ) );
+ $this->assertEquals( $nonObjMsg, $array[0] );
+ }
+
+ public static function provideNonObjectMessages() {
+ return [
+ [ [ 'ImaString', [ 'param1' => 'value1' ] ] ],
+ [ [ 'ImaString' ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideErrorsWarningsOnly
+ * @covers Status::splitByErrorType
+ * @covers StatusValue::splitByErrorType
+ */
+ public function testGetErrorsWarningsOnlyStatus( $errorText, $warningText, $type, $errorResult,
+ $warningResult
+ ) {
+ $status = Status::newGood();
+ if ( $errorText ) {
+ $status->fatal( $errorText );
+ }
+ if ( $warningText ) {
+ $status->warning( $warningText );
+ }
+ $testStatus = $status->splitByErrorType()[$type];
+ $this->assertEquals( $errorResult, $testStatus->getErrorsByType( 'error' ) );
+ $this->assertEquals( $warningResult, $testStatus->getErrorsByType( 'warning' ) );
+ }
+
+ public static function provideErrorsWarningsOnly() {
+ return [
+ [
+ 'Just an error',
+ 'Just a warning',
+ 0,
+ [
+ 0 => [
+ 'type' => 'error',
+ 'message' => 'Just an error',
+ 'params' => []
+ ],
+ ],
+ [],
+ ], [
+ 'Just an error',
+ 'Just a warning',
+ 1,
+ [],
+ [
+ 0 => [
+ 'type' => 'warning',
+ 'message' => 'Just a warning',
+ 'params' => []
+ ],
+ ],
+ ], [
+ null,
+ null,
+ 1,
+ [],
+ [],
+ ], [
+ null,
+ null,
+ 0,
+ [],
+ [],
+ ]
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php b/www/wiki/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php
new file mode 100644
index 00000000..252c6578
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\BlobStore;
+use MediaWiki\Storage\SqlBlobStore;
+use MediaWikiTestCase;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers \MediaWiki\Storage\BlobStoreFactory
+ */
+class BlobStoreFactoryTest extends MediaWikiTestCase {
+
+ public function provideWikiIds() {
+ yield [ false ];
+ yield [ 'someWiki' ];
+ }
+
+ /**
+ * @dataProvider provideWikiIds
+ */
+ public function testNewBlobStore( $wikiId ) {
+ $factory = MediaWikiServices::getInstance()->getBlobStoreFactory();
+ $store = $factory->newBlobStore( $wikiId );
+ $this->assertInstanceOf( BlobStore::class, $store );
+
+ // This only works as we currently know this is a SqlBlobStore object
+ $wrapper = TestingAccessWrapper::newFromObject( $store );
+ $this->assertEquals( $wikiId, $wrapper->wikiId );
+ }
+
+ /**
+ * @dataProvider provideWikiIds
+ */
+ public function testNewSqlBlobStore( $wikiId ) {
+ $factory = MediaWikiServices::getInstance()->getBlobStoreFactory();
+ $store = $factory->newSqlBlobStore( $wikiId );
+ $this->assertInstanceOf( SqlBlobStore::class, $store );
+
+ $wrapper = TestingAccessWrapper::newFromObject( $store );
+ $this->assertEquals( $wikiId, $wrapper->wikiId );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php b/www/wiki/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php
new file mode 100644
index 00000000..dd2c4b68
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php
@@ -0,0 +1,212 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use InvalidArgumentException;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionAccessException;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SlotRecord;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use TextContent;
+use Title;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Storage\MutableRevisionRecord
+ * @covers \MediaWiki\Storage\RevisionRecord
+ */
+class MutableRevisionRecordTest extends MediaWikiTestCase {
+
+ use RevisionRecordTests;
+
+ /**
+ * @param array $rowOverrides
+ *
+ * @return MutableRevisionRecord
+ */
+ protected function newRevision( array $rowOverrides = [] ) {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $record = new MutableRevisionRecord( $title );
+
+ if ( isset( $rowOverrides['rev_deleted'] ) ) {
+ $record->setVisibility( $rowOverrides['rev_deleted'] );
+ }
+
+ if ( isset( $rowOverrides['rev_id'] ) ) {
+ $record->setId( $rowOverrides['rev_id'] );
+ }
+
+ if ( isset( $rowOverrides['rev_page'] ) ) {
+ $record->setPageId( $rowOverrides['rev_page'] );
+ }
+
+ $record->setContent( 'main', new TextContent( 'Lorem Ipsum' ) );
+ $record->setComment( $comment );
+ $record->setUser( $user );
+
+ return $record;
+ }
+
+ public function provideConstructor() {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ yield [
+ $title,
+ 'acmewiki'
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructor
+ *
+ * @param Title $title
+ * @param bool $wikiId
+ */
+ public function testConstructorAndGetters(
+ Title $title,
+ $wikiId = false
+ ) {
+ $rec = new MutableRevisionRecord( $title, $wikiId );
+
+ $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' );
+ $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );
+ }
+
+ public function provideConstructorFailure() {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ yield 'not a wiki id' => [
+ $title,
+ null
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructorFailure
+ *
+ * @param Title $title
+ * @param bool $wikiId
+ */
+ public function testConstructorFailure(
+ Title $title,
+ $wikiId = false
+ ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ new MutableRevisionRecord( $title, $wikiId );
+ }
+
+ public function testSetGetId() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertNull( $record->getId() );
+ $record->setId( 888 );
+ $this->assertSame( 888, $record->getId() );
+ }
+
+ public function testSetGetUser() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $user = $this->getTestSysop()->getUser();
+ $this->assertNull( $record->getUser() );
+ $record->setUser( $user );
+ $this->assertSame( $user, $record->getUser() );
+ }
+
+ public function testSetGetPageId() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertSame( 0, $record->getPageId() );
+ $record->setPageId( 999 );
+ $this->assertSame( 999, $record->getPageId() );
+ }
+
+ public function testSetGetParentId() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertNull( $record->getParentId() );
+ $record->setParentId( 100 );
+ $this->assertSame( 100, $record->getParentId() );
+ }
+
+ public function testGetMainContentWhenEmpty() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->setExpectedException( RevisionAccessException::class );
+ $this->assertNull( $record->getContent( 'main' ) );
+ }
+
+ public function testSetGetMainContent() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $content = new WikitextContent( 'Badger' );
+ $record->setContent( 'main', $content );
+ $this->assertSame( $content, $record->getContent( 'main' ) );
+ }
+
+ public function testGetSlotWhenEmpty() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertFalse( $record->hasSlot( 'main' ) );
+
+ $this->setExpectedException( RevisionAccessException::class );
+ $record->getSlot( 'main' );
+ }
+
+ public function testSetGetSlot() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $slot = SlotRecord::newUnsaved(
+ 'main',
+ new WikitextContent( 'x' )
+ );
+ $record->setSlot( $slot );
+ $this->assertTrue( $record->hasSlot( 'main' ) );
+ $this->assertSame( $slot, $record->getSlot( 'main' ) );
+ }
+
+ public function testSetGetMinor() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertFalse( $record->isMinor() );
+ $record->setMinorEdit( true );
+ $this->assertSame( true, $record->isMinor() );
+ }
+
+ public function testSetGetTimestamp() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertNull( $record->getTimestamp() );
+ $record->setTimestamp( '20180101010101' );
+ $this->assertSame( '20180101010101', $record->getTimestamp() );
+ }
+
+ public function testSetGetVisibility() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertSame( 0, $record->getVisibility() );
+ $record->setVisibility( RevisionRecord::DELETED_USER );
+ $this->assertSame( RevisionRecord::DELETED_USER, $record->getVisibility() );
+ }
+
+ public function testSetGetSha1() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertSame( 'phoiac9h4m842xq45sp7s6u21eteeq1', $record->getSha1() );
+ $record->setSha1( 'someHash' );
+ $this->assertSame( 'someHash', $record->getSha1() );
+ }
+
+ public function testSetGetSize() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertSame( 0, $record->getSize() );
+ $record->setSize( 775 );
+ $this->assertSame( 775, $record->getSize() );
+ }
+
+ public function testSetGetComment() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $comment = new CommentStoreComment( 1, 'foo' );
+ $this->assertNull( $record->getComment() );
+ $record->setComment( $comment );
+ $this->assertSame( $comment, $record->getComment() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php b/www/wiki/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php
new file mode 100644
index 00000000..0416bcfa
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use MediaWiki\Storage\MutableRevisionSlots;
+use MediaWiki\Storage\RevisionAccessException;
+use MediaWiki\Storage\SlotRecord;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Storage\MutableRevisionSlots
+ */
+class MutableRevisionSlotsTest extends RevisionSlotsTest {
+
+ public function testSetMultipleSlots() {
+ $slots = new MutableRevisionSlots();
+
+ $this->assertSame( [], $slots->getSlots() );
+
+ $slotA = SlotRecord::newUnsaved( 'some', new WikitextContent( 'A' ) );
+ $slots->setSlot( $slotA );
+ $this->assertTrue( $slots->hasSlot( 'some' ) );
+ $this->assertSame( $slotA, $slots->getSlot( 'some' ) );
+ $this->assertSame( [ 'some' => $slotA ], $slots->getSlots() );
+
+ $slotB = SlotRecord::newUnsaved( 'other', new WikitextContent( 'B' ) );
+ $slots->setSlot( $slotB );
+ $this->assertTrue( $slots->hasSlot( 'other' ) );
+ $this->assertSame( $slotB, $slots->getSlot( 'other' ) );
+ $this->assertSame( [ 'some' => $slotA, 'other' => $slotB ], $slots->getSlots() );
+ }
+
+ public function testSetExistingSlotOverwritesSlot() {
+ $slots = new MutableRevisionSlots();
+
+ $this->assertSame( [], $slots->getSlots() );
+
+ $slotA = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $slots->setSlot( $slotA );
+ $this->assertSame( $slotA, $slots->getSlot( 'main' ) );
+ $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() );
+
+ $slotB = SlotRecord::newUnsaved( 'main', new WikitextContent( 'B' ) );
+ $slots->setSlot( $slotB );
+ $this->assertSame( $slotB, $slots->getSlot( 'main' ) );
+ $this->assertSame( [ 'main' => $slotB ], $slots->getSlots() );
+ }
+
+ public function testSetContentOfExistingSlotOverwritesContent() {
+ $slots = new MutableRevisionSlots();
+
+ $this->assertSame( [], $slots->getSlots() );
+
+ $slotA = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $slots->setSlot( $slotA );
+ $this->assertSame( $slotA, $slots->getSlot( 'main' ) );
+ $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() );
+
+ $newContent = new WikitextContent( 'B' );
+ $slots->setContent( 'main', $newContent );
+ $this->assertSame( $newContent, $slots->getContent( 'main' ) );
+ }
+
+ public function testRemoveExistingSlot() {
+ $slotA = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $slots = new MutableRevisionSlots( [ $slotA ] );
+
+ $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() );
+
+ $slots->removeSlot( 'main' );
+ $this->assertSame( [], $slots->getSlots() );
+ $this->setExpectedException( RevisionAccessException::class );
+ $slots->getSlot( 'main' );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/Storage/NameTableStoreTest.php b/www/wiki/tests/phpunit/includes/Storage/NameTableStoreTest.php
new file mode 100644
index 00000000..0cd164b7
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/Storage/NameTableStoreTest.php
@@ -0,0 +1,298 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use BagOStuff;
+use EmptyBagOStuff;
+use HashBagOStuff;
+use MediaWiki\Storage\NameTableAccessException;
+use MediaWiki\Storage\NameTableStore;
+use MediaWikiTestCase;
+use Psr\Log\NullLogger;
+use WANObjectCache;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @author Addshore
+ * @group Database
+ * @covers \MediaWiki\Storage\NameTableStore
+ */
+class NameTableStoreTest extends MediaWikiTestCase {
+
+ public function setUp() {
+ $this->tablesUsed[] = 'slot_roles';
+ parent::setUp();
+ }
+
+ private function populateTable( $values ) {
+ $insertValues = [];
+ foreach ( $values as $name ) {
+ $insertValues[] = [ 'role_name' => $name ];
+ }
+ $this->db->insert( 'slot_roles', $insertValues );
+ }
+
+ private function getHashWANObjectCache( $cacheBag ) {
+ return new WANObjectCache( [ 'cache' => $cacheBag ] );
+ }
+
+ /**
+ * @param $db
+ * @return \PHPUnit_Framework_MockObject_MockObject|LoadBalancer
+ */
+ private function getMockLoadBalancer( $db ) {
+ $mock = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'getConnection' )
+ ->willReturn( $db );
+ return $mock;
+ }
+
+ private function getCallCheckingDb( $insertCalls, $selectCalls ) {
+ $mock = $this->getMockBuilder( Database::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->exactly( $insertCalls ) )
+ ->method( 'insert' )
+ ->willReturnCallback( function () {
+ return call_user_func_array( [ $this->db, 'insert' ], func_get_args() );
+ } );
+ $mock->expects( $this->exactly( $selectCalls ) )
+ ->method( 'select' )
+ ->willReturnCallback( function () {
+ return call_user_func_array( [ $this->db, 'select' ], func_get_args() );
+ } );
+ $mock->expects( $this->exactly( $insertCalls ) )
+ ->method( 'affectedRows' )
+ ->willReturnCallback( function () {
+ return call_user_func_array( [ $this->db, 'affectedRows' ], func_get_args() );
+ } );
+ $mock->expects( $this->any() )
+ ->method( 'insertId' )
+ ->willReturnCallback( function () {
+ return call_user_func_array( [ $this->db, 'insertId' ], func_get_args() );
+ } );
+ return $mock;
+ }
+
+ private function getNameTableSqlStore(
+ BagOStuff $cacheBag,
+ $insertCalls,
+ $selectCalls,
+ $normalizationCallback = null
+ ) {
+ return new NameTableStore(
+ $this->getMockLoadBalancer( $this->getCallCheckingDb( $insertCalls, $selectCalls ) ),
+ $this->getHashWANObjectCache( $cacheBag ),
+ new NullLogger(),
+ 'slot_roles', 'role_id', 'role_name',
+ $normalizationCallback
+ );
+ }
+
+ public function provideGetAndAcquireId() {
+ return [
+ 'no wancache, empty table' =>
+ [ new EmptyBagOStuff(), true, 1, [], 'foo', 1 ],
+ 'no wancache, one matching value' =>
+ [ new EmptyBagOStuff(), false, 1, [ 'foo' ], 'foo', 1 ],
+ 'no wancache, one not matching value' =>
+ [ new EmptyBagOStuff(), true, 1, [ 'bar' ], 'foo', 2 ],
+ 'no wancache, multiple, one matching value' =>
+ [ new EmptyBagOStuff(), false, 1, [ 'foo', 'bar' ], 'bar', 2 ],
+ 'no wancache, multiple, no matching value' =>
+ [ new EmptyBagOStuff(), true, 1, [ 'foo', 'bar' ], 'baz', 3 ],
+ 'wancache, empty table' =>
+ [ new HashBagOStuff(), true, 1, [], 'foo', 1 ],
+ 'wancache, one matching value' =>
+ [ new HashBagOStuff(), false, 1, [ 'foo' ], 'foo', 1 ],
+ 'wancache, one not matching value' =>
+ [ new HashBagOStuff(), true, 1, [ 'bar' ], 'foo', 2 ],
+ 'wancache, multiple, one matching value' =>
+ [ new HashBagOStuff(), false, 1, [ 'foo', 'bar' ], 'bar', 2 ],
+ 'wancache, multiple, no matching value' =>
+ [ new HashBagOStuff(), true, 1, [ 'foo', 'bar' ], 'baz', 3 ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetAndAcquireId
+ * @param BagOStuff $cacheBag to use in the WANObjectCache service
+ * @param bool $needsInsert Does the value we are testing need to be inserted?
+ * @param int $selectCalls Number of times the select DB method will be called
+ * @param string[] $existingValues to be added to the db table
+ * @param string $name name to acquire
+ * @param int $expectedId the id we expect the name to have
+ */
+ public function testGetAndAcquireId(
+ $cacheBag,
+ $needsInsert,
+ $selectCalls,
+ $existingValues,
+ $name,
+ $expectedId
+ ) {
+ $this->populateTable( $existingValues );
+ $store = $this->getNameTableSqlStore( $cacheBag, (int)$needsInsert, $selectCalls );
+
+ // Some names will not initially exist
+ try {
+ $result = $store->getId( $name );
+ $this->assertSame( $expectedId, $result );
+ } catch ( NameTableAccessException $e ) {
+ if ( $needsInsert ) {
+ $this->assertTrue( true ); // Expected exception
+ } else {
+ $this->fail( 'Did not expect an exception, but got one: ' . $e->getMessage() );
+ }
+ }
+
+ // All names should return their id here
+ $this->assertSame( $expectedId, $store->acquireId( $name ) );
+
+ // acquireId inserted these names, so now everything should exist with getId
+ $this->assertSame( $expectedId, $store->getId( $name ) );
+
+ // calling getId again will also still work, and not result in more selects
+ $this->assertSame( $expectedId, $store->getId( $name ) );
+ }
+
+ public function provideTestGetAndAcquireIdNameNormalization() {
+ yield [ 'A', 'a', 'strtolower' ];
+ yield [ 'b', 'B', 'strtoupper' ];
+ yield [
+ 'X',
+ 'X',
+ function ( $name ) {
+ return $name;
+ }
+ ];
+ yield [ 'ZZ', 'ZZ-a', __CLASS__ . '::appendDashAToString' ];
+ }
+
+ public static function appendDashAToString( $string ) {
+ return $string . '-a';
+ }
+
+ /**
+ * @dataProvider provideTestGetAndAcquireIdNameNormalization
+ */
+ public function testGetAndAcquireIdNameNormalization(
+ $nameIn,
+ $nameOut,
+ $normalizationCallback
+ ) {
+ $store = $this->getNameTableSqlStore(
+ new EmptyBagOStuff(),
+ 1,
+ 1,
+ $normalizationCallback
+ );
+ $acquiredId = $store->acquireId( $nameIn );
+ $this->assertSame( $nameOut, $store->getName( $acquiredId ) );
+ }
+
+ public function provideGetName() {
+ return [
+ [ new HashBagOStuff(), 3, 3 ],
+ [ new EmptyBagOStuff(), 3, 3 ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetName
+ */
+ public function testGetName( $cacheBag, $insertCalls, $selectCalls ) {
+ $store = $this->getNameTableSqlStore( $cacheBag, $insertCalls, $selectCalls );
+
+ // Get 1 ID and make sure getName returns correctly
+ $fooId = $store->acquireId( 'foo' );
+ $this->assertSame( 'foo', $store->getName( $fooId ) );
+
+ // Get another ID and make sure getName returns correctly
+ $barId = $store->acquireId( 'bar' );
+ $this->assertSame( 'bar', $store->getName( $barId ) );
+
+ // Blitz the cache and make sure it still returns
+ TestingAccessWrapper::newFromObject( $store )->tableCache = null;
+ $this->assertSame( 'foo', $store->getName( $fooId ) );
+ $this->assertSame( 'bar', $store->getName( $barId ) );
+
+ // Blitz the cache again and get another ID and make sure getName returns correctly
+ TestingAccessWrapper::newFromObject( $store )->tableCache = null;
+ $bazId = $store->acquireId( 'baz' );
+ $this->assertSame( 'baz', $store->getName( $bazId ) );
+ $this->assertSame( 'baz', $store->getName( $bazId ) );
+ }
+
+ public function testGetName_masterFallback() {
+ $store = $this->getNameTableSqlStore( new EmptyBagOStuff(), 1, 2 );
+
+ // Insert a new name
+ $fooId = $store->acquireId( 'foo' );
+
+ // Empty the process cache, getCachedTable() will now return this empty array
+ TestingAccessWrapper::newFromObject( $store )->tableCache = [];
+
+ // getName should fallback to master, which is why we assert 2 selectCalls above
+ $this->assertSame( 'foo', $store->getName( $fooId ) );
+ }
+
+ public function testGetMap_empty() {
+ $this->populateTable( [] );
+ $store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 1 );
+ $table = $store->getMap();
+ $this->assertSame( [], $table );
+ }
+
+ public function testGetMap_twoValues() {
+ $this->populateTable( [ 'foo', 'bar' ] );
+ $store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 1 );
+
+ // We are using a cache, so 2 calls should only result in 1 select on the db
+ $store->getMap();
+ $table = $store->getMap();
+
+ $expected = [ 1 => 'foo', 2 => 'bar' ];
+ $this->assertSame( $expected, $table );
+ // Make sure the table returned is the same as the cached table
+ $this->assertSame( $expected, TestingAccessWrapper::newFromObject( $store )->tableCache );
+ }
+
+ public function testCacheRaceCondition() {
+ $wanHashBag = new HashBagOStuff();
+ $store1 = $this->getNameTableSqlStore( $wanHashBag, 1, 1 );
+ $store2 = $this->getNameTableSqlStore( $wanHashBag, 1, 0 );
+ $store3 = $this->getNameTableSqlStore( $wanHashBag, 1, 1 );
+
+ // Cache the current table in the instances we will use
+ // This simulates multiple requests running simultaneously
+ $store1->getMap();
+ $store2->getMap();
+ $store3->getMap();
+
+ // Store 2 separate names using different instances
+ $fooId = $store1->acquireId( 'foo' );
+ $barId = $store2->acquireId( 'bar' );
+
+ // Each of these instances should be aware of what they have inserted
+ $this->assertSame( $fooId, $store1->acquireId( 'foo' ) );
+ $this->assertSame( $barId, $store2->acquireId( 'bar' ) );
+
+ // A new store should be able to get both of these new Ids
+ // Note: before there was a race condition here where acquireId( 'bar' ) would update the
+ // cache with data missing the 'foo' key that it was not aware of
+ $store4 = $this->getNameTableSqlStore( $wanHashBag, 0, 1 );
+ $this->assertSame( $fooId, $store4->getId( 'foo' ) );
+ $this->assertSame( $barId, $store4->getId( 'bar' ) );
+
+ // If a store with old cached data tries to acquire these we will get the same ids.
+ $this->assertSame( $fooId, $store3->acquireId( 'foo' ) );
+ $this->assertSame( $barId, $store3->acquireId( 'bar' ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/Storage/RevisionArchiveRecordTest.php b/www/wiki/tests/phpunit/includes/Storage/RevisionArchiveRecordTest.php
new file mode 100644
index 00000000..f959d680
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/Storage/RevisionArchiveRecordTest.php
@@ -0,0 +1,272 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use InvalidArgumentException;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionSlots;
+use MediaWiki\Storage\RevisionArchiveRecord;
+use MediaWiki\Storage\SlotRecord;
+use MediaWiki\User\UserIdentity;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use TextContent;
+use Title;
+
+/**
+ * @covers \MediaWiki\Storage\RevisionArchiveRecord
+ * @covers \MediaWiki\Storage\RevisionRecord
+ */
+class RevisionArchiveRecordTest extends MediaWikiTestCase {
+
+ use RevisionRecordTests;
+
+ /**
+ * @param array $rowOverrides
+ *
+ * @return RevisionArchiveRecord
+ */
+ protected function newRevision( array $rowOverrides = [] ) {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $main = SlotRecord::newUnsaved( 'main', new TextContent( 'Lorem Ipsum' ) );
+ $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+ $slots = new RevisionSlots( [ $main, $aux ] );
+
+ $row = [
+ 'ar_id' => '5',
+ 'ar_rev_id' => '7',
+ 'ar_page_id' => strval( $title->getArticleID() ),
+ 'ar_timestamp' => '20200101000000',
+ 'ar_deleted' => 0,
+ 'ar_minor_edit' => 0,
+ 'ar_parent_id' => '5',
+ 'ar_len' => $slots->computeSize(),
+ 'ar_sha1' => $slots->computeSha1(),
+ ];
+
+ foreach ( $rowOverrides as $field => $value ) {
+ $field = preg_replace( '/^rev_/', 'ar_', $field );
+ $row[$field] = $value;
+ }
+
+ return new RevisionArchiveRecord( $title, $user, $comment, (object)$row, $slots );
+ }
+
+ public function provideConstructor() {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $main = SlotRecord::newUnsaved( 'main', new TextContent( 'Lorem Ipsum' ) );
+ $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+ $slots = new RevisionSlots( [ $main, $aux ] );
+
+ $protoRow = [
+ 'ar_id' => '5',
+ 'ar_rev_id' => '7',
+ 'ar_page_id' => strval( $title->getArticleID() ),
+ 'ar_timestamp' => '20200101000000',
+ 'ar_deleted' => 0,
+ 'ar_minor_edit' => 0,
+ 'ar_parent_id' => '5',
+ 'ar_len' => $slots->computeSize(),
+ 'ar_sha1' => $slots->computeSha1(),
+ ];
+
+ $row = $protoRow;
+ yield 'all info' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots,
+ 'acmewiki'
+ ];
+
+ $row = $protoRow;
+ $row['ar_minor_edit'] = '1';
+ $row['ar_deleted'] = strval( RevisionRecord::DELETED_USER );
+
+ yield 'minor deleted' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ unset( $row['ar_parent'] );
+
+ yield 'no parent' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ $row['ar_len'] = null;
+ $row['ar_sha1'] = '';
+
+ yield 'ar_len is null, ar_sha1 is ""' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ yield 'no length, no hash' => [
+ Title::newFromText( 'DummyDoesNotExist' ),
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructor
+ *
+ * @param Title $title
+ * @param UserIdentity $user
+ * @param CommentStoreComment $comment
+ * @param object $row
+ * @param RevisionSlots $slots
+ * @param bool $wikiId
+ */
+ public function testConstructorAndGetters(
+ Title $title,
+ UserIdentity $user,
+ CommentStoreComment $comment,
+ $row,
+ RevisionSlots $slots,
+ $wikiId = false
+ ) {
+ $rec = new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $wikiId );
+
+ $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' );
+ $this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' );
+ $this->assertSame( $comment, $rec->getComment(), 'getComment' );
+
+ $this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' );
+ $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );
+
+ $this->assertSame( (int)$row->ar_id, $rec->getArchiveId(), 'getArchiveId' );
+ $this->assertSame( (int)$row->ar_rev_id, $rec->getId(), 'getId' );
+ $this->assertSame( (int)$row->ar_page_id, $rec->getPageId(), 'getId' );
+ $this->assertSame( $row->ar_timestamp, $rec->getTimestamp(), 'getTimestamp' );
+ $this->assertSame( (int)$row->ar_deleted, $rec->getVisibility(), 'getVisibility' );
+ $this->assertSame( (bool)$row->ar_minor_edit, $rec->isMinor(), 'getIsMinor' );
+
+ if ( isset( $row->ar_parent_id ) ) {
+ $this->assertSame( (int)$row->ar_parent_id, $rec->getParentId(), 'getParentId' );
+ } else {
+ $this->assertSame( 0, $rec->getParentId(), 'getParentId' );
+ }
+
+ if ( isset( $row->ar_len ) ) {
+ $this->assertSame( (int)$row->ar_len, $rec->getSize(), 'getSize' );
+ } else {
+ $this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' );
+ }
+
+ if ( !empty( $row->ar_sha1 ) ) {
+ $this->assertSame( $row->ar_sha1, $rec->getSha1(), 'getSha1' );
+ } else {
+ $this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' );
+ }
+ }
+
+ public function provideConstructorFailure() {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $main = SlotRecord::newUnsaved( 'main', new TextContent( 'Lorem Ipsum' ) );
+ $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+ $slots = new RevisionSlots( [ $main, $aux ] );
+
+ $protoRow = [
+ 'ar_id' => '5',
+ 'ar_rev_id' => '7',
+ 'ar_page_id' => strval( $title->getArticleID() ),
+ 'ar_timestamp' => '20200101000000',
+ 'ar_deleted' => 0,
+ 'ar_minor_edit' => 0,
+ 'ar_parent_id' => '5',
+ 'ar_len' => $slots->computeSize(),
+ 'ar_sha1' => $slots->computeSha1(),
+ ];
+
+ yield 'not a row' => [
+ $title,
+ $user,
+ $comment,
+ 'not a row',
+ $slots,
+ 'acmewiki'
+ ];
+
+ $row = $protoRow;
+ $row['ar_timestamp'] = 'kittens';
+
+ yield 'bad timestamp' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+
+ yield 'bad wiki' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots,
+ 12345
+ ];
+
+ // NOTE: $title->getArticleID does *not* have to match ar_page_id in all cases!
+ }
+
+ /**
+ * @dataProvider provideConstructorFailure
+ *
+ * @param Title $title
+ * @param UserIdentity $user
+ * @param CommentStoreComment $comment
+ * @param object $row
+ * @param RevisionSlots $slots
+ * @param bool $wikiId
+ */
+ public function testConstructorFailure(
+ Title $title,
+ UserIdentity $user,
+ CommentStoreComment $comment,
+ $row,
+ RevisionSlots $slots,
+ $wikiId = false
+ ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $wikiId );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/Storage/RevisionRecordTests.php b/www/wiki/tests/phpunit/includes/Storage/RevisionRecordTests.php
new file mode 100644
index 00000000..607f7829
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/Storage/RevisionRecordTests.php
@@ -0,0 +1,512 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use LogicException;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionSlots;
+use MediaWiki\Storage\RevisionStoreRecord;
+use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Storage\SuppressedDataException;
+use MediaWiki\User\UserIdentityValue;
+use TextContent;
+use Title;
+
+// PHPCS should not complain about @covers and @dataProvider being used in traits, see T192384
+// phpcs:disable MediaWiki.Commenting.PhpunitAnnotations.NotTestClass
+
+/**
+ * @covers \MediaWiki\Storage\RevisionRecord
+ *
+ * @note Expects to be used in classes that extend MediaWikiTestCase.
+ */
+trait RevisionRecordTests {
+
+ /**
+ * @param array $rowOverrides
+ *
+ * @return RevisionRecord
+ */
+ protected abstract function newRevision( array $rowOverrides = [] );
+
+ private function provideAudienceCheckData( $field ) {
+ yield 'field accessible for oversighter (ALL)' => [
+ RevisionRecord::SUPPRESSED_ALL,
+ [ 'oversight' ],
+ true,
+ false
+ ];
+
+ yield 'field accessible for oversighter' => [
+ RevisionRecord::DELETED_RESTRICTED | $field,
+ [ 'oversight' ],
+ true,
+ false
+ ];
+
+ yield 'field not accessible for sysops (ALL)' => [
+ RevisionRecord::SUPPRESSED_ALL,
+ [ 'sysop' ],
+ false,
+ false
+ ];
+
+ yield 'field not accessible for sysops' => [
+ RevisionRecord::DELETED_RESTRICTED | $field,
+ [ 'sysop' ],
+ false,
+ false
+ ];
+
+ yield 'field accessible for sysops' => [
+ $field,
+ [ 'sysop' ],
+ true,
+ false
+ ];
+
+ yield 'field suppressed for logged in users' => [
+ $field,
+ [ 'user' ],
+ false,
+ false
+ ];
+
+ yield 'unrelated field suppressed' => [
+ $field === RevisionRecord::DELETED_COMMENT
+ ? RevisionRecord::DELETED_USER
+ : RevisionRecord::DELETED_COMMENT,
+ [ 'user' ],
+ true,
+ true
+ ];
+
+ yield 'nothing suppressed' => [
+ 0,
+ [ 'user' ],
+ true,
+ true
+ ];
+ }
+
+ public function testSerialization_fails() {
+ $this->setExpectedException( LogicException::class );
+ $rev = $this->newRevision();
+ serialize( $rev );
+ }
+
+ public function provideGetComment_audience() {
+ return $this->provideAudienceCheckData( RevisionRecord::DELETED_COMMENT );
+ }
+
+ private function forceStandardPermissions() {
+ $this->setMwGlobals(
+ 'wgGroupPermissions',
+ [
+ 'user' => [
+ 'viewsuppressed' => false,
+ 'suppressrevision' => false,
+ 'deletedtext' => false,
+ 'deletedhistory' => false,
+ ],
+ 'sysop' => [
+ 'viewsuppressed' => false,
+ 'suppressrevision' => false,
+ 'deletedtext' => true,
+ 'deletedhistory' => true,
+ ],
+ 'oversight' => [
+ 'deletedtext' => true,
+ 'deletedhistory' => true,
+ 'viewsuppressed' => true,
+ 'suppressrevision' => true,
+ ],
+ ]
+ );
+ }
+
+ /**
+ * @dataProvider provideGetComment_audience
+ */
+ public function testGetComment_audience( $visibility, $groups, $userCan, $publicCan ) {
+ $this->forceStandardPermissions();
+
+ $user = $this->getTestUser( $groups )->getUser();
+ $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
+
+ $this->assertNotNull( $rev->getComment( RevisionRecord::RAW ), 'raw can' );
+
+ $this->assertSame(
+ $publicCan,
+ $rev->getComment( RevisionRecord::FOR_PUBLIC ) !== null,
+ 'public can'
+ );
+ $this->assertSame(
+ $userCan,
+ $rev->getComment( RevisionRecord::FOR_THIS_USER, $user ) !== null,
+ 'user can'
+ );
+ }
+
+ public function provideGetUser_audience() {
+ return $this->provideAudienceCheckData( RevisionRecord::DELETED_USER );
+ }
+
+ /**
+ * @dataProvider provideGetUser_audience
+ */
+ public function testGetUser_audience( $visibility, $groups, $userCan, $publicCan ) {
+ $this->forceStandardPermissions();
+
+ $user = $this->getTestUser( $groups )->getUser();
+ $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
+
+ $this->assertNotNull( $rev->getUser( RevisionRecord::RAW ), 'raw can' );
+
+ $this->assertSame(
+ $publicCan,
+ $rev->getUser( RevisionRecord::FOR_PUBLIC ) !== null,
+ 'public can'
+ );
+ $this->assertSame(
+ $userCan,
+ $rev->getUser( RevisionRecord::FOR_THIS_USER, $user ) !== null,
+ 'user can'
+ );
+ }
+
+ public function provideGetSlot_audience() {
+ return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT );
+ }
+
+ /**
+ * @dataProvider provideGetSlot_audience
+ */
+ public function testGetSlot_audience( $visibility, $groups, $userCan, $publicCan ) {
+ $this->forceStandardPermissions();
+
+ $user = $this->getTestUser( $groups )->getUser();
+ $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
+
+ // NOTE: slot meta-data is never suppressed, just the content is!
+ $this->assertTrue( $rev->hasSlot( 'main' ), 'hasSlot is never suppressed' );
+ $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::RAW ), 'raw meta' );
+ $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::FOR_PUBLIC ), 'public meta' );
+
+ $this->assertNotNull(
+ $rev->getSlot( 'main', RevisionRecord::FOR_THIS_USER, $user ),
+ 'user can'
+ );
+
+ try {
+ $rev->getSlot( 'main', RevisionRecord::FOR_PUBLIC )->getContent();
+ $exception = null;
+ } catch ( SuppressedDataException $ex ) {
+ $exception = $ex;
+ }
+
+ $this->assertSame(
+ $publicCan,
+ $exception === null,
+ 'public can'
+ );
+
+ try {
+ $rev->getSlot( 'main', RevisionRecord::FOR_THIS_USER, $user )->getContent();
+ $exception = null;
+ } catch ( SuppressedDataException $ex ) {
+ $exception = $ex;
+ }
+
+ $this->assertSame(
+ $userCan,
+ $exception === null,
+ 'user can'
+ );
+ }
+
+ /**
+ * @dataProvider provideGetSlot_audience
+ */
+ public function testGetContent_audience( $visibility, $groups, $userCan, $publicCan ) {
+ $this->forceStandardPermissions();
+
+ $user = $this->getTestUser( $groups )->getUser();
+ $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
+
+ $this->assertNotNull( $rev->getContent( 'main', RevisionRecord::RAW ), 'raw can' );
+
+ $this->assertSame(
+ $publicCan,
+ $rev->getContent( 'main', RevisionRecord::FOR_PUBLIC ) !== null,
+ 'public can'
+ );
+ $this->assertSame(
+ $userCan,
+ $rev->getContent( 'main', RevisionRecord::FOR_THIS_USER, $user ) !== null,
+ 'user can'
+ );
+ }
+
+ public function testGetSlot() {
+ $rev = $this->newRevision();
+
+ $slot = $rev->getSlot( 'main' );
+ $this->assertNotNull( $slot, 'getSlot()' );
+ $this->assertSame( 'main', $slot->getRole(), 'getRole()' );
+ }
+
+ public function testHasSlot() {
+ $rev = $this->newRevision();
+
+ $this->assertTrue( $rev->hasSlot( 'main' ) );
+ $this->assertFalse( $rev->hasSlot( 'xyz' ) );
+ }
+
+ public function testGetContent() {
+ $rev = $this->newRevision();
+
+ $content = $rev->getSlot( 'main' );
+ $this->assertNotNull( $content, 'getContent()' );
+ $this->assertSame( CONTENT_MODEL_TEXT, $content->getModel(), 'getModel()' );
+ }
+
+ public function provideUserCanBitfield() {
+ yield [ 0, 0, [], null, true ];
+ // Bitfields match, user has no permissions
+ yield [
+ RevisionRecord::DELETED_TEXT,
+ RevisionRecord::DELETED_TEXT,
+ [],
+ null,
+ false
+ ];
+ yield [
+ RevisionRecord::DELETED_COMMENT,
+ RevisionRecord::DELETED_COMMENT,
+ [],
+ null,
+ false,
+ ];
+ yield [
+ RevisionRecord::DELETED_USER,
+ RevisionRecord::DELETED_USER,
+ [],
+ null,
+ false
+ ];
+ yield [
+ RevisionRecord::DELETED_RESTRICTED,
+ RevisionRecord::DELETED_RESTRICTED,
+ [],
+ null,
+ false,
+ ];
+ // Bitfields match, user (admin) does have permissions
+ yield [
+ RevisionRecord::DELETED_TEXT,
+ RevisionRecord::DELETED_TEXT,
+ [ 'sysop' ],
+ null,
+ true,
+ ];
+ yield [
+ RevisionRecord::DELETED_COMMENT,
+ RevisionRecord::DELETED_COMMENT,
+ [ 'sysop' ],
+ null,
+ true,
+ ];
+ yield [
+ RevisionRecord::DELETED_USER,
+ RevisionRecord::DELETED_USER,
+ [ 'sysop' ],
+ null,
+ true,
+ ];
+ // Bitfields match, user (admin) does not have permissions
+ yield [
+ RevisionRecord::DELETED_RESTRICTED,
+ RevisionRecord::DELETED_RESTRICTED,
+ [ 'sysop' ],
+ null,
+ false,
+ ];
+ // Bitfields match, user (oversight) does have permissions
+ yield [
+ RevisionRecord::DELETED_RESTRICTED,
+ RevisionRecord::DELETED_RESTRICTED,
+ [ 'oversight' ],
+ null,
+ true,
+ ];
+ // Check permissions using the title
+ yield [
+ RevisionRecord::DELETED_TEXT,
+ RevisionRecord::DELETED_TEXT,
+ [ 'sysop' ],
+ Title::newFromText( __METHOD__ ),
+ true,
+ ];
+ yield [
+ RevisionRecord::DELETED_TEXT,
+ RevisionRecord::DELETED_TEXT,
+ [],
+ Title::newFromText( __METHOD__ ),
+ false,
+ ];
+ }
+
+ /**
+ * @dataProvider provideUserCanBitfield
+ * @covers \MediaWiki\Storage\RevisionRecord::userCanBitfield
+ */
+ public function testUserCanBitfield( $bitField, $field, $userGroups, $title, $expected ) {
+ $this->forceStandardPermissions();
+
+ $user = $this->getTestUser( $userGroups )->getUser();
+
+ $this->assertSame(
+ $expected,
+ RevisionRecord::userCanBitfield( $bitField, $field, $user, $title )
+ );
+ }
+
+ public function provideHasSameContent() {
+ /**
+ * @param SlotRecord[] $slots
+ * @param int $revId
+ * @return RevisionStoreRecord
+ */
+ $recordCreator = function ( array $slots, $revId ) {
+ $title = Title::newFromText( 'provideHasSameContent' );
+ $title->resetArticleID( 19 );
+ $slots = new RevisionSlots( $slots );
+
+ return new RevisionStoreRecord(
+ $title,
+ new UserIdentityValue( 11, __METHOD__, 0 ),
+ CommentStoreComment::newUnsavedComment( __METHOD__ ),
+ (object)[
+ 'rev_id' => strval( $revId ),
+ 'rev_page' => strval( $title->getArticleID() ),
+ 'rev_timestamp' => '20200101000000',
+ 'rev_deleted' => 0,
+ 'rev_minor_edit' => 0,
+ 'rev_parent_id' => '5',
+ 'rev_len' => $slots->computeSize(),
+ 'rev_sha1' => $slots->computeSha1(),
+ 'page_latest' => '18',
+ ],
+ $slots
+ );
+ };
+
+ // Create some slots with content
+ $mainA = SlotRecord::newUnsaved( 'main', new TextContent( 'A' ) );
+ $mainB = SlotRecord::newUnsaved( 'main', new TextContent( 'B' ) );
+ $auxA = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) );
+ $auxB = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) );
+
+ $initialRecord = $recordCreator( [ $mainA ], 12 );
+
+ return [
+ 'same record object' => [
+ true,
+ $initialRecord,
+ $initialRecord,
+ ],
+ 'same record content, different object' => [
+ true,
+ $recordCreator( [ $mainA ], 12 ),
+ $recordCreator( [ $mainA ], 13 ),
+ ],
+ 'same record content, aux slot, different object' => [
+ true,
+ $recordCreator( [ $auxA ], 12 ),
+ $recordCreator( [ $auxB ], 13 ),
+ ],
+ 'different content' => [
+ false,
+ $recordCreator( [ $mainA ], 12 ),
+ $recordCreator( [ $mainB ], 13 ),
+ ],
+ 'different content and number of slots' => [
+ false,
+ $recordCreator( [ $mainA ], 12 ),
+ $recordCreator( [ $mainA, $mainB ], 13 ),
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideHasSameContent
+ * @covers \MediaWiki\Storage\RevisionRecord::hasSameContent
+ * @group Database
+ */
+ public function testHasSameContent(
+ $expected,
+ RevisionRecord $record1,
+ RevisionRecord $record2
+ ) {
+ $this->assertSame(
+ $expected,
+ $record1->hasSameContent( $record2 )
+ );
+ }
+
+ public function provideIsDeleted() {
+ yield 'no deletion' => [
+ 0,
+ [
+ RevisionRecord::DELETED_TEXT => false,
+ RevisionRecord::DELETED_COMMENT => false,
+ RevisionRecord::DELETED_USER => false,
+ RevisionRecord::DELETED_RESTRICTED => false,
+ ]
+ ];
+ yield 'text deleted' => [
+ RevisionRecord::DELETED_TEXT,
+ [
+ RevisionRecord::DELETED_TEXT => true,
+ RevisionRecord::DELETED_COMMENT => false,
+ RevisionRecord::DELETED_USER => false,
+ RevisionRecord::DELETED_RESTRICTED => false,
+ ]
+ ];
+ yield 'text and comment deleted' => [
+ RevisionRecord::DELETED_TEXT + RevisionRecord::DELETED_COMMENT,
+ [
+ RevisionRecord::DELETED_TEXT => true,
+ RevisionRecord::DELETED_COMMENT => true,
+ RevisionRecord::DELETED_USER => false,
+ RevisionRecord::DELETED_RESTRICTED => false,
+ ]
+ ];
+ yield 'all 4 deleted' => [
+ RevisionRecord::DELETED_TEXT +
+ RevisionRecord::DELETED_COMMENT +
+ RevisionRecord::DELETED_RESTRICTED +
+ RevisionRecord::DELETED_USER,
+ [
+ RevisionRecord::DELETED_TEXT => true,
+ RevisionRecord::DELETED_COMMENT => true,
+ RevisionRecord::DELETED_USER => true,
+ RevisionRecord::DELETED_RESTRICTED => true,
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsDeleted
+ * @covers \MediaWiki\Storage\RevisionRecord::isDeleted
+ */
+ public function testIsDeleted( $revDeleted, $assertionMap ) {
+ $rev = $this->newRevision( [ 'rev_deleted' => $revDeleted ] );
+ foreach ( $assertionMap as $deletionLevel => $expected ) {
+ $this->assertSame( $expected, $rev->isDeleted( $deletionLevel ) );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/Storage/RevisionSlotsTest.php b/www/wiki/tests/phpunit/includes/Storage/RevisionSlotsTest.php
new file mode 100644
index 00000000..b9f833ca
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/Storage/RevisionSlotsTest.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use MediaWiki\Storage\RevisionAccessException;
+use MediaWiki\Storage\RevisionSlots;
+use MediaWiki\Storage\SlotRecord;
+use MediaWikiTestCase;
+use WikitextContent;
+
+class RevisionSlotsTest extends MediaWikiTestCase {
+
+ /**
+ * @param SlotRecord[] $slots
+ * @return RevisionSlots
+ */
+ protected function newRevisionSlots( $slots = [] ) {
+ return new RevisionSlots( $slots );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionSlots::getSlot
+ */
+ public function testGetSlot() {
+ $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
+ $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] );
+
+ $this->assertSame( $mainSlot, $slots->getSlot( 'main' ) );
+ $this->assertSame( $auxSlot, $slots->getSlot( 'aux' ) );
+ $this->setExpectedException( RevisionAccessException::class );
+ $slots->getSlot( 'nothere' );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionSlots::hasSlot
+ */
+ public function testHasSlot() {
+ $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
+ $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] );
+
+ $this->assertTrue( $slots->hasSlot( 'main' ) );
+ $this->assertTrue( $slots->hasSlot( 'aux' ) );
+ $this->assertFalse( $slots->hasSlot( 'AUX' ) );
+ $this->assertFalse( $slots->hasSlot( 'xyz' ) );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionSlots::getContent
+ */
+ public function testGetContent() {
+ $mainContent = new WikitextContent( 'A' );
+ $auxContent = new WikitextContent( 'B' );
+ $mainSlot = SlotRecord::newUnsaved( 'main', $mainContent );
+ $auxSlot = SlotRecord::newUnsaved( 'aux', $auxContent );
+ $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] );
+
+ $this->assertSame( $mainContent, $slots->getContent( 'main' ) );
+ $this->assertSame( $auxContent, $slots->getContent( 'aux' ) );
+ $this->setExpectedException( RevisionAccessException::class );
+ $slots->getContent( 'nothere' );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionSlots::getSlotRoles
+ */
+ public function testGetSlotRoles_someSlots() {
+ $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
+ $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] );
+
+ $this->assertSame( [ 'main', 'aux' ], $slots->getSlotRoles() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionSlots::getSlotRoles
+ */
+ public function testGetSlotRoles_noSlots() {
+ $slots = $this->newRevisionSlots( [] );
+
+ $this->assertSame( [], $slots->getSlotRoles() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionSlots::getSlots
+ */
+ public function testGetSlots() {
+ $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
+ $slotsArray = [ $mainSlot, $auxSlot ];
+ $slots = $this->newRevisionSlots( $slotsArray );
+
+ $this->assertEquals( [ 'main' => $mainSlot, 'aux' => $auxSlot ], $slots->getSlots() );
+ }
+
+ public function provideComputeSize() {
+ yield [ 1, [ 'A' ] ];
+ yield [ 2, [ 'AA' ] ];
+ yield [ 4, [ 'AA', 'X', 'H' ] ];
+ }
+
+ /**
+ * @dataProvider provideComputeSize
+ * @covers \MediaWiki\Storage\RevisionSlots::computeSize
+ */
+ public function testComputeSize( $expected, $contentStrings ) {
+ $slotsArray = [];
+ foreach ( $contentStrings as $key => $contentString ) {
+ $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) );
+ }
+ $slots = $this->newRevisionSlots( $slotsArray );
+
+ $this->assertSame( $expected, $slots->computeSize() );
+ }
+
+ public function provideComputeSha1() {
+ yield [ 'ctqm7794fr2dp1taki8a88ovwnvmnmj', [ 'A' ] ];
+ yield [ 'eyq8wiwlcofnaiy4eid97gyfy60uw51', [ 'AA' ] ];
+ yield [ 'lavctqfpxartyjr31f853drgfl4kj1g', [ 'AA', 'X', 'H' ] ];
+ }
+
+ /**
+ * @dataProvider provideComputeSha1
+ * @covers \MediaWiki\Storage\RevisionSlots::computeSha1
+ * @note this test is a bit brittle as the hashes are hardcoded, perhaps just check that strings
+ * are returned and different Slots objects return different strings?
+ */
+ public function testComputeSha1( $expected, $contentStrings ) {
+ $slotsArray = [];
+ foreach ( $contentStrings as $key => $contentString ) {
+ $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) );
+ }
+ $slots = $this->newRevisionSlots( $slotsArray );
+
+ $this->assertSame( $expected, $slots->computeSha1() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/Storage/RevisionStoreDbTest.php b/www/wiki/tests/phpunit/includes/Storage/RevisionStoreDbTest.php
new file mode 100644
index 00000000..7d6906c1
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/Storage/RevisionStoreDbTest.php
@@ -0,0 +1,1281 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use Exception;
+use HashBagOStuff;
+use InvalidArgumentException;
+use Language;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\BlobStoreFactory;
+use MediaWiki\Storage\IncompleteRevisionException;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionStore;
+use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Storage\SqlBlobStore;
+use MediaWikiTestCase;
+use Revision;
+use TestUserRegistry;
+use Title;
+use WANObjectCache;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DatabaseSqlite;
+use Wikimedia\Rdbms\FakeResultWrapper;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\TransactionProfiler;
+use WikiPage;
+use WikitextContent;
+
+/**
+ * @group Database
+ */
+class RevisionStoreDbTest extends MediaWikiTestCase {
+
+ public function setUp() {
+ parent::setUp();
+ $this->tablesUsed[] = 'archive';
+ $this->tablesUsed[] = 'page';
+ $this->tablesUsed[] = 'revision';
+ $this->tablesUsed[] = 'comment';
+ }
+
+ /**
+ * @return LoadBalancer
+ */
+ private function getLoadBalancerMock( array $server ) {
+ $lb = $this->getMockBuilder( LoadBalancer::class )
+ ->setMethods( [ 'reallyOpenConnection' ] )
+ ->setConstructorArgs( [ [ 'servers' => [ $server ] ] ] )
+ ->getMock();
+
+ $lb->method( 'reallyOpenConnection' )->willReturnCallback(
+ function ( array $server, $dbNameOverride ) {
+ return $this->getDatabaseMock( $server );
+ }
+ );
+
+ return $lb;
+ }
+
+ /**
+ * @return Database
+ */
+ private function getDatabaseMock( array $params ) {
+ $db = $this->getMockBuilder( DatabaseSqlite::class )
+ ->setMethods( [ 'select', 'doQuery', 'open', 'closeConnection', 'isOpen' ] )
+ ->setConstructorArgs( [ $params ] )
+ ->getMock();
+
+ $db->method( 'select' )->willReturn( new FakeResultWrapper( [] ) );
+ $db->method( 'isOpen' )->willReturn( true );
+
+ return $db;
+ }
+
+ public function provideDomainCheck() {
+ yield [ false, 'test', '' ];
+ yield [ 'test', 'test', '' ];
+
+ yield [ false, 'test', 'foo_' ];
+ yield [ 'test-foo_', 'test', 'foo_' ];
+
+ yield [ false, 'dash-test', '' ];
+ yield [ 'dash-test', 'dash-test', '' ];
+
+ yield [ false, 'underscore_test', 'foo_' ];
+ yield [ 'underscore_test-foo_', 'underscore_test', 'foo_' ];
+ }
+
+ /**
+ * @dataProvider provideDomainCheck
+ * @covers \MediaWiki\Storage\RevisionStore::checkDatabaseWikiId
+ */
+ public function testDomainCheck( $wikiId, $dbName, $dbPrefix ) {
+ $this->setMwGlobals(
+ [
+ 'wgDBname' => $dbName,
+ 'wgDBprefix' => $dbPrefix,
+ ]
+ );
+
+ $loadBalancer = $this->getLoadBalancerMock(
+ [
+ 'host' => '*dummy*',
+ 'dbDirectory' => '*dummy*',
+ 'user' => 'test',
+ 'password' => 'test',
+ 'flags' => 0,
+ 'variables' => [],
+ 'schema' => '',
+ 'cliMode' => true,
+ 'agent' => '',
+ 'load' => 100,
+ 'profiler' => null,
+ 'trxProfiler' => new TransactionProfiler(),
+ 'connLogger' => new \Psr\Log\NullLogger(),
+ 'queryLogger' => new \Psr\Log\NullLogger(),
+ 'errorLogger' => function () {
+ },
+ 'deprecationLogger' => function () {
+ },
+ 'type' => 'test',
+ 'dbname' => $dbName,
+ 'tablePrefix' => $dbPrefix,
+ ]
+ );
+ $db = $loadBalancer->getConnection( DB_REPLICA );
+
+ $blobStore = $this->getMockBuilder( SqlBlobStore::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $store = new RevisionStore(
+ $loadBalancer,
+ $blobStore,
+ new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ),
+ MediaWikiServices::getInstance()->getCommentStore(),
+ MediaWikiServices::getInstance()->getActorMigration(),
+ $wikiId
+ );
+
+ $count = $store->countRevisionsByPageId( $db, 0 );
+
+ // Dummy check to make PhpUnit happy. We are really only interested in
+ // countRevisionsByPageId not failing due to the DB domain check.
+ $this->assertSame( 0, $count );
+ }
+
+ private function assertLinkTargetsEqual( LinkTarget $l1, LinkTarget $l2 ) {
+ $this->assertEquals( $l1->getDBkey(), $l2->getDBkey() );
+ $this->assertEquals( $l1->getNamespace(), $l2->getNamespace() );
+ $this->assertEquals( $l1->getFragment(), $l2->getFragment() );
+ $this->assertEquals( $l1->getInterwiki(), $l2->getInterwiki() );
+ }
+
+ private function assertRevisionRecordsEqual( RevisionRecord $r1, RevisionRecord $r2 ) {
+ $this->assertEquals( $r1->getUser()->getName(), $r2->getUser()->getName() );
+ $this->assertEquals( $r1->getUser()->getId(), $r2->getUser()->getId() );
+ $this->assertEquals( $r1->getComment(), $r2->getComment() );
+ $this->assertEquals( $r1->getPageAsLinkTarget(), $r2->getPageAsLinkTarget() );
+ $this->assertEquals( $r1->getTimestamp(), $r2->getTimestamp() );
+ $this->assertEquals( $r1->getVisibility(), $r2->getVisibility() );
+ $this->assertEquals( $r1->getSha1(), $r2->getSha1() );
+ $this->assertEquals( $r1->getParentId(), $r2->getParentId() );
+ $this->assertEquals( $r1->getSize(), $r2->getSize() );
+ $this->assertEquals( $r1->getPageId(), $r2->getPageId() );
+ $this->assertEquals( $r1->getSlotRoles(), $r2->getSlotRoles() );
+ $this->assertEquals( $r1->getWikiId(), $r2->getWikiId() );
+ $this->assertEquals( $r1->isMinor(), $r2->isMinor() );
+ foreach ( $r1->getSlotRoles() as $role ) {
+ $this->assertSlotRecordsEqual( $r1->getSlot( $role ), $r2->getSlot( $role ) );
+ $this->assertTrue( $r1->getContent( $role )->equals( $r2->getContent( $role ) ) );
+ }
+ foreach ( [
+ RevisionRecord::DELETED_TEXT,
+ RevisionRecord::DELETED_COMMENT,
+ RevisionRecord::DELETED_USER,
+ RevisionRecord::DELETED_RESTRICTED,
+ ] as $field ) {
+ $this->assertEquals( $r1->isDeleted( $field ), $r2->isDeleted( $field ) );
+ }
+ }
+
+ private function assertSlotRecordsEqual( SlotRecord $s1, SlotRecord $s2 ) {
+ $this->assertSame( $s1->getRole(), $s2->getRole() );
+ $this->assertSame( $s1->getModel(), $s2->getModel() );
+ $this->assertSame( $s1->getFormat(), $s2->getFormat() );
+ $this->assertSame( $s1->getSha1(), $s2->getSha1() );
+ $this->assertSame( $s1->getSize(), $s2->getSize() );
+ $this->assertTrue( $s1->getContent()->equals( $s2->getContent() ) );
+
+ $s1->hasRevision() ? $this->assertSame( $s1->getRevision(), $s2->getRevision() ) : null;
+ $s1->hasAddress() ? $this->assertSame( $s1->hasAddress(), $s2->hasAddress() ) : null;
+ }
+
+ private function assertRevisionCompleteness( RevisionRecord $r ) {
+ foreach ( $r->getSlotRoles() as $role ) {
+ $this->assertSlotCompleteness( $r, $r->getSlot( $role ) );
+ }
+ }
+
+ private function assertSlotCompleteness( RevisionRecord $r, SlotRecord $slot ) {
+ $this->assertTrue( $slot->hasAddress() );
+ $this->assertSame( $r->getId(), $slot->getRevision() );
+ }
+
+ /**
+ * @param mixed[] $details
+ *
+ * @return RevisionRecord
+ */
+ private function getRevisionRecordFromDetailsArray( $title, $details = [] ) {
+ // Convert some values that can't be provided by dataProviders
+ $page = WikiPage::factory( $title );
+ if ( isset( $details['user'] ) && $details['user'] === true ) {
+ $details['user'] = $this->getTestUser()->getUser();
+ }
+ if ( isset( $details['page'] ) && $details['page'] === true ) {
+ $details['page'] = $page->getId();
+ }
+ if ( isset( $details['parent'] ) && $details['parent'] === true ) {
+ $details['parent'] = $page->getLatest();
+ }
+
+ // Create the RevisionRecord with any available data
+ $rev = new MutableRevisionRecord( $title );
+ isset( $details['slot'] ) ? $rev->setSlot( $details['slot'] ) : null;
+ isset( $details['parent'] ) ? $rev->setParentId( $details['parent'] ) : null;
+ isset( $details['page'] ) ? $rev->setPageId( $details['page'] ) : null;
+ isset( $details['size'] ) ? $rev->setSize( $details['size'] ) : null;
+ isset( $details['sha1'] ) ? $rev->setSha1( $details['sha1'] ) : null;
+ isset( $details['comment'] ) ? $rev->setComment( $details['comment'] ) : null;
+ isset( $details['timestamp'] ) ? $rev->setTimestamp( $details['timestamp'] ) : null;
+ isset( $details['minor'] ) ? $rev->setMinorEdit( $details['minor'] ) : null;
+ isset( $details['user'] ) ? $rev->setUser( $details['user'] ) : null;
+ isset( $details['visibility'] ) ? $rev->setVisibility( $details['visibility'] ) : null;
+ isset( $details['id'] ) ? $rev->setId( $details['id'] ) : null;
+
+ return $rev;
+ }
+
+ private function getRandomCommentStoreComment() {
+ return CommentStoreComment::newUnsavedComment( __METHOD__ . '.' . rand( 0, 1000 ) );
+ }
+
+ public function provideInsertRevisionOn_successes() {
+ yield 'Bare minimum revision insertion' => [
+ Title::newFromText( 'UTPage' ),
+ [
+ 'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
+ 'parent' => true,
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ ];
+ yield 'Detailed revision insertion' => [
+ Title::newFromText( 'UTPage' ),
+ [
+ 'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
+ 'parent' => true,
+ 'page' => true,
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ 'minor' => true,
+ 'visibility' => RevisionRecord::DELETED_RESTRICTED,
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideInsertRevisionOn_successes
+ * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
+ */
+ public function testInsertRevisionOn_successes( Title $title, array $revDetails = [] ) {
+ $rev = $this->getRevisionRecordFromDetailsArray( $title, $revDetails );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $return = $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) );
+
+ $this->assertLinkTargetsEqual( $title, $return->getPageAsLinkTarget() );
+ $this->assertRevisionRecordsEqual( $rev, $return );
+ $this->assertRevisionCompleteness( $return );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
+ */
+ public function testInsertRevisionOn_blobAddressExists() {
+ $title = Title::newFromText( 'UTPage' );
+ $revDetails = [
+ 'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
+ 'parent' => true,
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+ // Insert the first revision
+ $revOne = $this->getRevisionRecordFromDetailsArray( $title, $revDetails );
+ $firstReturn = $store->insertRevisionOn( $revOne, wfGetDB( DB_MASTER ) );
+ $this->assertLinkTargetsEqual( $title, $firstReturn->getPageAsLinkTarget() );
+ $this->assertRevisionRecordsEqual( $revOne, $firstReturn );
+
+ // Insert a second revision inheriting the same blob address
+ $revDetails['slot'] = SlotRecord::newInherited( $firstReturn->getSlot( 'main' ) );
+ $revTwo = $this->getRevisionRecordFromDetailsArray( $title, $revDetails );
+ $secondReturn = $store->insertRevisionOn( $revTwo, wfGetDB( DB_MASTER ) );
+ $this->assertLinkTargetsEqual( $title, $secondReturn->getPageAsLinkTarget() );
+ $this->assertRevisionRecordsEqual( $revTwo, $secondReturn );
+
+ // Assert that the same blob address has been used.
+ $this->assertEquals(
+ $firstReturn->getSlot( 'main' )->getAddress(),
+ $secondReturn->getSlot( 'main' )->getAddress()
+ );
+ // And that different revisions have been created.
+ $this->assertNotSame(
+ $firstReturn->getId(),
+ $secondReturn->getId()
+ );
+ }
+
+ public function provideInsertRevisionOn_failures() {
+ yield 'no slot' => [
+ Title::newFromText( 'UTPage' ),
+ [
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ new InvalidArgumentException( 'At least one slot needs to be defined!' )
+ ];
+ yield 'slot that is not main slot' => [
+ Title::newFromText( 'UTPage' ),
+ [
+ 'slot' => SlotRecord::newUnsaved( 'lalala', new WikitextContent( 'Chicken' ) ),
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ new InvalidArgumentException( 'Only the main slot is supported for now!' )
+ ];
+ yield 'no timestamp' => [
+ Title::newFromText( 'UTPage' ),
+ [
+ 'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'user' => true,
+ ],
+ new IncompleteRevisionException( 'timestamp field must not be NULL!' )
+ ];
+ yield 'no comment' => [
+ Title::newFromText( 'UTPage' ),
+ [
+ 'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ new IncompleteRevisionException( 'comment must not be NULL!' )
+ ];
+ yield 'no user' => [
+ Title::newFromText( 'UTPage' ),
+ [
+ 'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ ],
+ new IncompleteRevisionException( 'user must not be NULL!' )
+ ];
+ }
+
+ /**
+ * @dataProvider provideInsertRevisionOn_failures
+ * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
+ */
+ public function testInsertRevisionOn_failures(
+ Title $title,
+ array $revDetails = [],
+ Exception $exception ) {
+ $rev = $this->getRevisionRecordFromDetailsArray( $title, $revDetails );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+ $this->setExpectedException(
+ get_class( $exception ),
+ $exception->getMessage(),
+ $exception->getCode()
+ );
+ $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) );
+ }
+
+ public function provideNewNullRevision() {
+ yield [
+ Title::newFromText( 'UTPage' ),
+ CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment1' ),
+ true,
+ ];
+ yield [
+ Title::newFromText( 'UTPage' ),
+ CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment2', [ 'a' => 1 ] ),
+ false,
+ ];
+ }
+
+ /**
+ * @dataProvider provideNewNullRevision
+ * @covers \MediaWiki\Storage\RevisionStore::newNullRevision
+ */
+ public function testNewNullRevision( Title $title, $comment, $minor ) {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $user = TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser();
+
+ $parent = $store->getRevisionByTitle( $title );
+ $record = $store->newNullRevision(
+ wfGetDB( DB_MASTER ),
+ $title,
+ $comment,
+ $minor,
+ $user
+ );
+
+ $this->assertEquals( $title->getNamespace(), $record->getPageAsLinkTarget()->getNamespace() );
+ $this->assertEquals( $title->getDBkey(), $record->getPageAsLinkTarget()->getDBkey() );
+ $this->assertEquals( $comment, $record->getComment() );
+ $this->assertEquals( $minor, $record->isMinor() );
+ $this->assertEquals( $user->getName(), $record->getUser()->getName() );
+ $this->assertEquals( $parent->getId(), $record->getParentId() );
+
+ $parentSlot = $parent->getSlot( 'main' );
+ $slot = $record->getSlot( 'main' );
+
+ $this->assertTrue( $slot->isInherited(), 'isInherited' );
+ $this->assertSame( $parentSlot->getOrigin(), $slot->getOrigin(), 'getOrigin' );
+ $this->assertSame( $parentSlot->getAddress(), $slot->getAddress(), 'getAddress' );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::newNullRevision
+ */
+ public function testNewNullRevision_nonExistingTitle() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $record = $store->newNullRevision(
+ wfGetDB( DB_MASTER ),
+ Title::newFromText( __METHOD__ . '.iDontExist!' ),
+ CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment' ),
+ false,
+ TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser()
+ );
+ $this->assertNull( $record );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getRcIdIfUnpatrolled
+ */
+ public function testGetRcIdIfUnpatrolled_returnsRecentChangesId() {
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $status = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revisionRecord = $store->getRevisionById( $rev->getId() );
+ $result = $store->getRcIdIfUnpatrolled( $revisionRecord );
+
+ $this->assertGreaterThan( 0, $result );
+ $this->assertSame(
+ $page->getRevision()->getRecentChange()->getAttribute( 'rc_id' ),
+ $result
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getRcIdIfUnpatrolled
+ */
+ public function testGetRcIdIfUnpatrolled_returnsZeroIfPatrolled() {
+ // This assumes that sysops are auto patrolled
+ $sysop = $this->getTestSysop()->getUser();
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $status = $page->doEditContent(
+ new WikitextContent( __METHOD__ ),
+ __METHOD__,
+ 0,
+ false,
+ $sysop
+ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revisionRecord = $store->getRevisionById( $rev->getId() );
+ $result = $store->getRcIdIfUnpatrolled( $revisionRecord );
+
+ $this->assertSame( 0, $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getRecentChange
+ */
+ public function testGetRecentChange() {
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $content = new WikitextContent( __METHOD__ );
+ $status = $page->doEditContent( $content, __METHOD__ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revRecord = $store->getRevisionById( $rev->getId() );
+ $recentChange = $store->getRecentChange( $revRecord );
+
+ $this->assertEquals( $rev->getId(), $recentChange->getAttribute( 'rc_this_oldid' ) );
+ $this->assertEquals( $rev->getRecentChange(), $recentChange );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getRevisionById
+ */
+ public function testGetRevisionById() {
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $content = new WikitextContent( __METHOD__ );
+ $status = $page->doEditContent( $content, __METHOD__ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revRecord = $store->getRevisionById( $rev->getId() );
+
+ $this->assertSame( $rev->getId(), $revRecord->getId() );
+ $this->assertTrue( $revRecord->getSlot( 'main' )->getContent()->equals( $content ) );
+ $this->assertSame( __METHOD__, $revRecord->getComment()->text );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getRevisionByTitle
+ */
+ public function testGetRevisionByTitle() {
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $content = new WikitextContent( __METHOD__ );
+ $status = $page->doEditContent( $content, __METHOD__ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revRecord = $store->getRevisionByTitle( $page->getTitle() );
+
+ $this->assertSame( $rev->getId(), $revRecord->getId() );
+ $this->assertTrue( $revRecord->getSlot( 'main' )->getContent()->equals( $content ) );
+ $this->assertSame( __METHOD__, $revRecord->getComment()->text );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getRevisionByPageId
+ */
+ public function testGetRevisionByPageId() {
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $content = new WikitextContent( __METHOD__ );
+ $status = $page->doEditContent( $content, __METHOD__ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revRecord = $store->getRevisionByPageId( $page->getId() );
+
+ $this->assertSame( $rev->getId(), $revRecord->getId() );
+ $this->assertTrue( $revRecord->getSlot( 'main' )->getContent()->equals( $content ) );
+ $this->assertSame( __METHOD__, $revRecord->getComment()->text );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getRevisionByTimestamp
+ */
+ public function testGetRevisionByTimestamp() {
+ // Make sure there is 1 second between the last revision and the rev we create...
+ // Otherwise we might not get the correct revision and the test may fail...
+ // :(
+ sleep( 1 );
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $content = new WikitextContent( __METHOD__ );
+ $status = $page->doEditContent( $content, __METHOD__ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revRecord = $store->getRevisionByTimestamp(
+ $page->getTitle(),
+ $rev->getTimestamp()
+ );
+
+ $this->assertSame( $rev->getId(), $revRecord->getId() );
+ $this->assertTrue( $revRecord->getSlot( 'main' )->getContent()->equals( $content ) );
+ $this->assertSame( __METHOD__, $revRecord->getComment()->text );
+ }
+
+ private function revisionToRow( Revision $rev ) {
+ $page = WikiPage::factory( $rev->getTitle() );
+
+ return (object)[
+ 'rev_id' => (string)$rev->getId(),
+ 'rev_page' => (string)$rev->getPage(),
+ 'rev_text_id' => (string)$rev->getTextId(),
+ 'rev_timestamp' => (string)$rev->getTimestamp(),
+ 'rev_user_text' => (string)$rev->getUserText(),
+ 'rev_user' => (string)$rev->getUser(),
+ 'rev_minor_edit' => $rev->isMinor() ? '1' : '0',
+ 'rev_deleted' => (string)$rev->getVisibility(),
+ 'rev_len' => (string)$rev->getSize(),
+ 'rev_parent_id' => (string)$rev->getParentId(),
+ 'rev_sha1' => (string)$rev->getSha1(),
+ 'rev_comment_text' => $rev->getComment(),
+ 'rev_comment_data' => null,
+ 'rev_comment_cid' => null,
+ 'rev_content_format' => $rev->getContentFormat(),
+ 'rev_content_model' => $rev->getContentModel(),
+ 'page_namespace' => (string)$page->getTitle()->getNamespace(),
+ 'page_title' => $page->getTitle()->getDBkey(),
+ 'page_id' => (string)$page->getId(),
+ 'page_latest' => (string)$page->getLatest(),
+ 'page_is_redirect' => $page->isRedirect() ? '1' : '0',
+ 'page_len' => (string)$page->getContent()->getSize(),
+ 'user_name' => (string)$rev->getUserText(),
+ ];
+ }
+
+ private function assertRevisionRecordMatchesRevision(
+ Revision $rev,
+ RevisionRecord $record
+ ) {
+ $this->assertSame( $rev->getId(), $record->getId() );
+ $this->assertSame( $rev->getPage(), $record->getPageId() );
+ $this->assertSame( $rev->getTimestamp(), $record->getTimestamp() );
+ $this->assertSame( $rev->getUserText(), $record->getUser()->getName() );
+ $this->assertSame( $rev->getUser(), $record->getUser()->getId() );
+ $this->assertSame( $rev->isMinor(), $record->isMinor() );
+ $this->assertSame( $rev->getVisibility(), $record->getVisibility() );
+ $this->assertSame( $rev->getSize(), $record->getSize() );
+ /**
+ * @note As of MW 1.31, the database schema allows the parent ID to be
+ * NULL to indicate that it is unknown.
+ */
+ $expectedParent = $rev->getParentId();
+ if ( $expectedParent === null ) {
+ $expectedParent = 0;
+ }
+ $this->assertSame( $expectedParent, $record->getParentId() );
+ $this->assertSame( $rev->getSha1(), $record->getSha1() );
+ $this->assertSame( $rev->getComment(), $record->getComment()->text );
+ $this->assertSame( $rev->getContentFormat(), $record->getContent( 'main' )->getDefaultFormat() );
+ $this->assertSame( $rev->getContentModel(), $record->getContent( 'main' )->getModel() );
+ $this->assertLinkTargetsEqual( $rev->getTitle(), $record->getPageAsLinkTarget() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
+ * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow_1_29
+ */
+ public function testNewRevisionFromRow_anonEdit() {
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->overrideMwServices();
+
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $text = __METHOD__ . 'a-ä';
+ /** @var Revision $rev */
+ $rev = $page->doEditContent(
+ new WikitextContent( $text ),
+ __METHOD__ . 'a'
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $record = $store->newRevisionFromRow(
+ $this->revisionToRow( $rev ),
+ [],
+ $page->getTitle()
+ );
+ $this->assertRevisionRecordMatchesRevision( $rev, $record );
+ $this->assertSame( $text, $rev->getContent()->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
+ * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow_1_29
+ */
+ public function testNewRevisionFromRow_anonEdit_legacyEncoding() {
+ $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' );
+ $this->overrideMwServices();
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $text = __METHOD__ . 'a-ä';
+ /** @var Revision $rev */
+ $rev = $page->doEditContent(
+ new WikitextContent( $text ),
+ __METHOD__. 'a'
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $record = $store->newRevisionFromRow(
+ $this->revisionToRow( $rev ),
+ [],
+ $page->getTitle()
+ );
+ $this->assertRevisionRecordMatchesRevision( $rev, $record );
+ $this->assertSame( $text, $rev->getContent()->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
+ * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow_1_29
+ */
+ public function testNewRevisionFromRow_userEdit() {
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->overrideMwServices();
+
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $text = __METHOD__ . 'b-ä';
+ /** @var Revision $rev */
+ $rev = $page->doEditContent(
+ new WikitextContent( $text ),
+ __METHOD__ . 'b',
+ 0,
+ false,
+ $this->getTestUser()->getUser()
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $record = $store->newRevisionFromRow(
+ $this->revisionToRow( $rev ),
+ [],
+ $page->getTitle()
+ );
+ $this->assertRevisionRecordMatchesRevision( $rev, $record );
+ $this->assertSame( $text, $rev->getContent()->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromArchiveRow
+ */
+ public function testNewRevisionFromArchiveRow() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $title = Title::newFromText( __METHOD__ );
+ $text = __METHOD__ . '-bä';
+ $page = WikiPage::factory( $title );
+ /** @var Revision $orig */
+ $orig = $page->doEditContent( new WikitextContent( $text ), __METHOD__ )
+ ->value['revision'];
+ $page->doDeleteArticle( __METHOD__ );
+
+ $db = wfGetDB( DB_MASTER );
+ $arQuery = $store->getArchiveQueryInfo();
+ $res = $db->select(
+ $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ],
+ __METHOD__, [], $arQuery['joins']
+ );
+ $this->assertTrue( is_object( $res ), 'query failed' );
+
+ $row = $res->fetchObject();
+ $res->free();
+ $record = $store->newRevisionFromArchiveRow( $row );
+
+ $this->assertRevisionRecordMatchesRevision( $orig, $record );
+ $this->assertSame( $text, $record->getContent( 'main' )->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromArchiveRow
+ */
+ public function testNewRevisionFromArchiveRow_legacyEncoding() {
+ $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' );
+ $this->overrideMwServices();
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $title = Title::newFromText( __METHOD__ );
+ $text = __METHOD__ . '-bä';
+ $page = WikiPage::factory( $title );
+ /** @var Revision $orig */
+ $orig = $page->doEditContent( new WikitextContent( $text ), __METHOD__ )
+ ->value['revision'];
+ $page->doDeleteArticle( __METHOD__ );
+
+ $db = wfGetDB( DB_MASTER );
+ $arQuery = $store->getArchiveQueryInfo();
+ $res = $db->select(
+ $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ],
+ __METHOD__, [], $arQuery['joins']
+ );
+ $this->assertTrue( is_object( $res ), 'query failed' );
+
+ $row = $res->fetchObject();
+ $res->free();
+ $record = $store->newRevisionFromArchiveRow( $row );
+
+ $this->assertRevisionRecordMatchesRevision( $orig, $record );
+ $this->assertSame( $text, $record->getContent( 'main' )->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromId
+ */
+ public function testLoadRevisionFromId() {
+ $title = Title::newFromText( __METHOD__ );
+ $page = WikiPage::factory( $title );
+ /** @var Revision $rev */
+ $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+ ->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->loadRevisionFromId( wfGetDB( DB_MASTER ), $rev->getId() );
+ $this->assertRevisionRecordMatchesRevision( $rev, $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromPageId
+ */
+ public function testLoadRevisionFromPageId() {
+ $title = Title::newFromText( __METHOD__ );
+ $page = WikiPage::factory( $title );
+ /** @var Revision $rev */
+ $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+ ->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->loadRevisionFromPageId( wfGetDB( DB_MASTER ), $page->getId() );
+ $this->assertRevisionRecordMatchesRevision( $rev, $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromTitle
+ */
+ public function testLoadRevisionFromTitle() {
+ $title = Title::newFromText( __METHOD__ );
+ $page = WikiPage::factory( $title );
+ /** @var Revision $rev */
+ $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+ ->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->loadRevisionFromTitle( wfGetDB( DB_MASTER ), $title );
+ $this->assertRevisionRecordMatchesRevision( $rev, $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromTimestamp
+ */
+ public function testLoadRevisionFromTimestamp() {
+ $title = Title::newFromText( __METHOD__ );
+ $page = WikiPage::factory( $title );
+ /** @var Revision $revOne */
+ $revOne = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+ ->value['revision'];
+ // Sleep to ensure different timestamps... )(evil)
+ sleep( 1 );
+ /** @var Revision $revTwo */
+ $revTwo = $page->doEditContent( new WikitextContent( __METHOD__ . 'a' ), '' )
+ ->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $this->assertNull(
+ $store->loadRevisionFromTimestamp( wfGetDB( DB_MASTER ), $title, '20150101010101' )
+ );
+ $this->assertSame(
+ $revOne->getId(),
+ $store->loadRevisionFromTimestamp(
+ wfGetDB( DB_MASTER ),
+ $title,
+ $revOne->getTimestamp()
+ )->getId()
+ );
+ $this->assertSame(
+ $revTwo->getId(),
+ $store->loadRevisionFromTimestamp(
+ wfGetDB( DB_MASTER ),
+ $title,
+ $revTwo->getTimestamp()
+ )->getId()
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::listRevisionSizes
+ */
+ public function testGetParentLengths() {
+ $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+ /** @var Revision $revOne */
+ $revOne = $page->doEditContent(
+ new WikitextContent( __METHOD__ ), __METHOD__
+ )->value['revision'];
+ /** @var Revision $revTwo */
+ $revTwo = $page->doEditContent(
+ new WikitextContent( __METHOD__ . '2' ), __METHOD__
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $this->assertSame(
+ [
+ $revOne->getId() => strlen( __METHOD__ ),
+ ],
+ $store->listRevisionSizes(
+ wfGetDB( DB_MASTER ),
+ [ $revOne->getId() ]
+ )
+ );
+ $this->assertSame(
+ [
+ $revOne->getId() => strlen( __METHOD__ ),
+ $revTwo->getId() => strlen( __METHOD__ ) + 1,
+ ],
+ $store->listRevisionSizes(
+ wfGetDB( DB_MASTER ),
+ [ $revOne->getId(), $revTwo->getId() ]
+ )
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getPreviousRevision
+ */
+ public function testGetPreviousRevision() {
+ $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+ /** @var Revision $revOne */
+ $revOne = $page->doEditContent(
+ new WikitextContent( __METHOD__ ), __METHOD__
+ )->value['revision'];
+ /** @var Revision $revTwo */
+ $revTwo = $page->doEditContent(
+ new WikitextContent( __METHOD__ . '2' ), __METHOD__
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $this->assertNull(
+ $store->getPreviousRevision( $store->getRevisionById( $revOne->getId() ) )
+ );
+ $this->assertSame(
+ $revOne->getId(),
+ $store->getPreviousRevision( $store->getRevisionById( $revTwo->getId() ) )->getId()
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getNextRevision
+ */
+ public function testGetNextRevision() {
+ $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+ /** @var Revision $revOne */
+ $revOne = $page->doEditContent(
+ new WikitextContent( __METHOD__ ), __METHOD__
+ )->value['revision'];
+ /** @var Revision $revTwo */
+ $revTwo = $page->doEditContent(
+ new WikitextContent( __METHOD__ . '2' ), __METHOD__
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $this->assertSame(
+ $revTwo->getId(),
+ $store->getNextRevision( $store->getRevisionById( $revOne->getId() ) )->getId()
+ );
+ $this->assertNull(
+ $store->getNextRevision( $store->getRevisionById( $revTwo->getId() ) )
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getTimestampFromId
+ */
+ public function testGetTimestampFromId_found() {
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ /** @var Revision $rev */
+ $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+ ->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->getTimestampFromId(
+ $page->getTitle(),
+ $rev->getId()
+ );
+
+ $this->assertSame( $rev->getTimestamp(), $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getTimestampFromId
+ */
+ public function testGetTimestampFromId_notFound() {
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ /** @var Revision $rev */
+ $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+ ->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->getTimestampFromId(
+ $page->getTitle(),
+ $rev->getId() + 1
+ );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::countRevisionsByPageId
+ */
+ public function testCountRevisionsByPageId() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+
+ $this->assertSame(
+ 0,
+ $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() )
+ );
+ $page->doEditContent( new WikitextContent( 'a' ), 'a' );
+ $this->assertSame(
+ 1,
+ $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() )
+ );
+ $page->doEditContent( new WikitextContent( 'b' ), 'b' );
+ $this->assertSame(
+ 2,
+ $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() )
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::countRevisionsByTitle
+ */
+ public function testCountRevisionsByTitle() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+
+ $this->assertSame(
+ 0,
+ $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() )
+ );
+ $page->doEditContent( new WikitextContent( 'a' ), 'a' );
+ $this->assertSame(
+ 1,
+ $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() )
+ );
+ $page->doEditContent( new WikitextContent( 'b' ), 'b' );
+ $this->assertSame(
+ 2,
+ $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() )
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::userWasLastToEdit
+ */
+ public function testUserWasLastToEdit_false() {
+ $sysop = $this->getTestSysop()->getUser();
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->userWasLastToEdit(
+ wfGetDB( DB_MASTER ),
+ $page->getId(),
+ $sysop->getId(),
+ '20160101010101'
+ );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::userWasLastToEdit
+ */
+ public function testUserWasLastToEdit_true() {
+ $startTime = wfTimestampNow();
+ $sysop = $this->getTestSysop()->getUser();
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page->doEditContent(
+ new WikitextContent( __METHOD__ ),
+ __METHOD__,
+ 0,
+ false,
+ $sysop
+ );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->userWasLastToEdit(
+ wfGetDB( DB_MASTER ),
+ $page->getId(),
+ $sysop->getId(),
+ $startTime
+ );
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getKnownCurrentRevision
+ */
+ public function testGetKnownCurrentRevision() {
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ /** @var Revision $rev */
+ $rev = $page->doEditContent(
+ new WikitextContent( __METHOD__ . 'b' ),
+ __METHOD__ . 'b',
+ 0,
+ false,
+ $this->getTestUser()->getUser()
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $record = $store->getKnownCurrentRevision(
+ $page->getTitle(),
+ $rev->getId()
+ );
+
+ $this->assertRevisionRecordMatchesRevision( $rev, $record );
+ }
+
+ public function provideNewMutableRevisionFromArray() {
+ yield 'Basic array, with page & id' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'text_id' => 2,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'content_format' => 'text/x-wiki',
+ 'content_model' => 'wikitext',
+ ]
+ ];
+ yield 'Basic array, content object' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'content' => new WikitextContent( 'Some Content' ),
+ ]
+ ];
+ yield 'Basic array, serialized text' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'text' => ( new WikitextContent( 'Söme Content' ) )->serialize(),
+ ]
+ ];
+ yield 'Basic array, serialized text, utf-8 flags' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'text' => ( new WikitextContent( 'Söme Content' ) )->serialize(),
+ 'flags' => 'utf-8',
+ ]
+ ];
+ yield 'Basic array, with title' => [
+ [
+ 'title' => Title::newFromText( 'SomeText' ),
+ 'text_id' => 2,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'content_format' => 'text/x-wiki',
+ 'content_model' => 'wikitext',
+ ]
+ ];
+ yield 'Basic array, no user field' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'text_id' => 2,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.3',
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'content_format' => 'text/x-wiki',
+ 'content_model' => 'wikitext',
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideNewMutableRevisionFromArray
+ * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ */
+ public function testNewMutableRevisionFromArray( array $array ) {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+ $result = $store->newMutableRevisionFromArray( $array );
+
+ if ( isset( $array['id'] ) ) {
+ $this->assertSame( $array['id'], $result->getId() );
+ }
+ if ( isset( $array['page'] ) ) {
+ $this->assertSame( $array['page'], $result->getPageId() );
+ }
+ $this->assertSame( $array['timestamp'], $result->getTimestamp() );
+ $this->assertSame( $array['user_text'], $result->getUser()->getName() );
+ if ( isset( $array['user'] ) ) {
+ $this->assertSame( $array['user'], $result->getUser()->getId() );
+ }
+ $this->assertSame( (bool)$array['minor_edit'], $result->isMinor() );
+ $this->assertSame( $array['deleted'], $result->getVisibility() );
+ $this->assertSame( $array['len'], $result->getSize() );
+ $this->assertSame( $array['parent_id'], $result->getParentId() );
+ $this->assertSame( $array['sha1'], $result->getSha1() );
+ $this->assertSame( $array['comment'], $result->getComment()->text );
+ if ( isset( $array['content'] ) ) {
+ $this->assertTrue(
+ $result->getSlot( 'main' )->getContent()->equals( $array['content'] )
+ );
+ } elseif ( isset( $array['text'] ) ) {
+ $this->assertSame( $array['text'], $result->getSlot( 'main' )->getContent()->serialize() );
+ } else {
+ $this->assertSame(
+ $array['content_format'],
+ $result->getSlot( 'main' )->getContent()->getDefaultFormat()
+ );
+ $this->assertSame( $array['content_model'], $result->getSlot( 'main' )->getModel() );
+ }
+ }
+
+ /**
+ * @dataProvider provideNewMutableRevisionFromArray
+ * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ */
+ public function testNewMutableRevisionFromArray_legacyEncoding( array $array ) {
+ $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
+ $blobStore = new SqlBlobStore( wfGetLB(), $cache );
+ $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) );
+
+ $factory = $this->getMockBuilder( BlobStoreFactory::class )
+ ->setMethods( [ 'newBlobStore', 'newSqlBlobStore' ] )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $factory->expects( $this->any() )
+ ->method( 'newBlobStore' )
+ ->willReturn( $blobStore );
+ $factory->expects( $this->any() )
+ ->method( 'newSqlBlobStore' )
+ ->willReturn( $blobStore );
+
+ $this->setService( 'BlobStoreFactory', $factory );
+
+ $this->testNewMutableRevisionFromArray( $array );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php b/www/wiki/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php
new file mode 100644
index 00000000..0295e900
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php
@@ -0,0 +1,363 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use InvalidArgumentException;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionSlots;
+use MediaWiki\Storage\RevisionStoreRecord;
+use MediaWiki\Storage\SlotRecord;
+use MediaWiki\User\UserIdentity;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use TextContent;
+use Title;
+
+/**
+ * @covers \MediaWiki\Storage\RevisionStoreRecord
+ * @covers \MediaWiki\Storage\RevisionRecord
+ */
+class RevisionStoreRecordTest extends MediaWikiTestCase {
+
+ use RevisionRecordTests;
+
+ /**
+ * @param array $rowOverrides
+ *
+ * @return RevisionStoreRecord
+ */
+ protected function newRevision( array $rowOverrides = [] ) {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $main = SlotRecord::newUnsaved( 'main', new TextContent( 'Lorem Ipsum' ) );
+ $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+ $slots = new RevisionSlots( [ $main, $aux ] );
+
+ $row = [
+ 'rev_id' => '7',
+ 'rev_page' => strval( $title->getArticleID() ),
+ 'rev_timestamp' => '20200101000000',
+ 'rev_deleted' => 0,
+ 'rev_minor_edit' => 0,
+ 'rev_parent_id' => '5',
+ 'rev_len' => $slots->computeSize(),
+ 'rev_sha1' => $slots->computeSha1(),
+ 'page_latest' => '18',
+ ];
+
+ $row = array_merge( $row, $rowOverrides );
+
+ return new RevisionStoreRecord( $title, $user, $comment, (object)$row, $slots );
+ }
+
+ public function provideConstructor() {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $main = SlotRecord::newUnsaved( 'main', new TextContent( 'Lorem Ipsum' ) );
+ $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+ $slots = new RevisionSlots( [ $main, $aux ] );
+
+ $protoRow = [
+ 'rev_id' => '7',
+ 'rev_page' => strval( $title->getArticleID() ),
+ 'rev_timestamp' => '20200101000000',
+ 'rev_deleted' => 0,
+ 'rev_minor_edit' => 0,
+ 'rev_parent_id' => '5',
+ 'rev_len' => $slots->computeSize(),
+ 'rev_sha1' => $slots->computeSha1(),
+ 'page_latest' => '18',
+ ];
+
+ $row = $protoRow;
+ yield 'all info' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots,
+ 'acmewiki'
+ ];
+
+ $row = $protoRow;
+ $row['rev_minor_edit'] = '1';
+ $row['rev_deleted'] = strval( RevisionRecord::DELETED_USER );
+
+ yield 'minor deleted' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ $row['page_latest'] = $row['rev_id'];
+
+ yield 'latest' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ unset( $row['rev_parent'] );
+
+ yield 'no parent' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ $row['rev_len'] = null;
+ $row['rev_sha1'] = '';
+
+ yield 'rev_len is null, rev_sha1 is ""' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ yield 'no length, no hash' => [
+ Title::newFromText( 'DummyDoesNotExist' ),
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructor
+ *
+ * @param Title $title
+ * @param UserIdentity $user
+ * @param CommentStoreComment $comment
+ * @param object $row
+ * @param RevisionSlots $slots
+ * @param bool $wikiId
+ */
+ public function testConstructorAndGetters(
+ Title $title,
+ UserIdentity $user,
+ CommentStoreComment $comment,
+ $row,
+ RevisionSlots $slots,
+ $wikiId = false
+ ) {
+ $rec = new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $wikiId );
+
+ $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' );
+ $this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' );
+ $this->assertSame( $comment, $rec->getComment(), 'getComment' );
+
+ $this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' );
+ $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );
+
+ $this->assertSame( (int)$row->rev_id, $rec->getId(), 'getId' );
+ $this->assertSame( (int)$row->rev_page, $rec->getPageId(), 'getId' );
+ $this->assertSame( $row->rev_timestamp, $rec->getTimestamp(), 'getTimestamp' );
+ $this->assertSame( (int)$row->rev_deleted, $rec->getVisibility(), 'getVisibility' );
+ $this->assertSame( (bool)$row->rev_minor_edit, $rec->isMinor(), 'getIsMinor' );
+
+ if ( isset( $row->rev_parent_id ) ) {
+ $this->assertSame( (int)$row->rev_parent_id, $rec->getParentId(), 'getParentId' );
+ } else {
+ $this->assertSame( 0, $rec->getParentId(), 'getParentId' );
+ }
+
+ if ( isset( $row->rev_len ) ) {
+ $this->assertSame( (int)$row->rev_len, $rec->getSize(), 'getSize' );
+ } else {
+ $this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' );
+ }
+
+ if ( !empty( $row->rev_sha1 ) ) {
+ $this->assertSame( $row->rev_sha1, $rec->getSha1(), 'getSha1' );
+ } else {
+ $this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' );
+ }
+
+ if ( isset( $row->page_latest ) ) {
+ $this->assertSame(
+ (int)$row->rev_id === (int)$row->page_latest,
+ $rec->isCurrent(),
+ 'isCurrent'
+ );
+ } else {
+ $this->assertSame(
+ false,
+ $rec->isCurrent(),
+ 'isCurrent'
+ );
+ }
+ }
+
+ public function provideConstructorFailure() {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $main = SlotRecord::newUnsaved( 'main', new TextContent( 'Lorem Ipsum' ) );
+ $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+ $slots = new RevisionSlots( [ $main, $aux ] );
+
+ $protoRow = [
+ 'rev_id' => '7',
+ 'rev_page' => strval( $title->getArticleID() ),
+ 'rev_timestamp' => '20200101000000',
+ 'rev_deleted' => 0,
+ 'rev_minor_edit' => 0,
+ 'rev_parent_id' => '5',
+ 'rev_len' => $slots->computeSize(),
+ 'rev_sha1' => $slots->computeSha1(),
+ 'page_latest' => '18',
+ ];
+
+ yield 'not a row' => [
+ $title,
+ $user,
+ $comment,
+ 'not a row',
+ $slots,
+ 'acmewiki'
+ ];
+
+ $row = $protoRow;
+ $row['rev_timestamp'] = 'kittens';
+
+ yield 'bad timestamp' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ $row['rev_page'] = 99;
+
+ yield 'page ID mismatch' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+
+ yield 'bad wiki' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots,
+ 12345
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructorFailure
+ *
+ * @param Title $title
+ * @param UserIdentity $user
+ * @param CommentStoreComment $comment
+ * @param object $row
+ * @param RevisionSlots $slots
+ * @param bool $wikiId
+ */
+ public function testConstructorFailure(
+ Title $title,
+ UserIdentity $user,
+ CommentStoreComment $comment,
+ $row,
+ RevisionSlots $slots,
+ $wikiId = false
+ ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $wikiId );
+ }
+
+ public function provideIsCurrent() {
+ yield [
+ [
+ 'rev_id' => 11,
+ 'page_latest' => 11,
+ ],
+ true,
+ ];
+ yield [
+ [
+ 'rev_id' => 11,
+ 'page_latest' => 10,
+ ],
+ false,
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsCurrent
+ */
+ public function testIsCurrent( $row, $current ) {
+ $rev = $this->newRevision( $row );
+
+ $this->assertSame( $current, $rev->isCurrent(), 'isCurrent()' );
+ }
+
+ public function provideGetSlot_audience_latest() {
+ return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT );
+ }
+
+ /**
+ * @dataProvider provideGetSlot_audience_latest
+ */
+ public function testGetSlot_audience_latest( $visibility, $groups, $userCan, $publicCan ) {
+ $this->forceStandardPermissions();
+
+ $user = $this->getTestUser( $groups )->getUser();
+ $rev = $this->newRevision(
+ [
+ 'rev_deleted' => $visibility,
+ 'rev_id' => 11,
+ 'page_latest' => 11, // revision is current
+ ]
+ );
+
+ // NOTE: slot meta-data is never suppressed, just the content is!
+ $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::RAW ), 'raw can' );
+ $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::FOR_PUBLIC ), 'public can' );
+
+ $this->assertNotNull(
+ $rev->getSlot( 'main', RevisionRecord::FOR_THIS_USER, $user ),
+ 'user can'
+ );
+
+ $rev->getSlot( 'main', RevisionRecord::RAW )->getContent();
+ // NOTE: the content of the current revision is never suppressed!
+ // Check that getContent() doesn't throw SuppressedDataException
+ $rev->getSlot( 'main', RevisionRecord::FOR_PUBLIC )->getContent();
+ $rev->getSlot( 'main', RevisionRecord::FOR_THIS_USER, $user )->getContent();
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/Storage/RevisionStoreTest.php b/www/wiki/tests/phpunit/includes/Storage/RevisionStoreTest.php
new file mode 100644
index 00000000..0bce572d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/Storage/RevisionStoreTest.php
@@ -0,0 +1,690 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use HashBagOStuff;
+use Language;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\RevisionAccessException;
+use MediaWiki\Storage\RevisionStore;
+use MediaWiki\Storage\SqlBlobStore;
+use MediaWikiTestCase;
+use Title;
+use WANObjectCache;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\LoadBalancer;
+
+class RevisionStoreTest extends MediaWikiTestCase {
+
+ /**
+ * @param LoadBalancer $loadBalancer
+ * @param SqlBlobStore $blobStore
+ * @param WANObjectCache $WANObjectCache
+ *
+ * @return RevisionStore
+ */
+ private function getRevisionStore(
+ $loadBalancer = null,
+ $blobStore = null,
+ $WANObjectCache = null
+ ) {
+ return new RevisionStore(
+ $loadBalancer ? $loadBalancer : $this->getMockLoadBalancer(),
+ $blobStore ? $blobStore : $this->getMockSqlBlobStore(),
+ $WANObjectCache ? $WANObjectCache : $this->getHashWANObjectCache(),
+ MediaWikiServices::getInstance()->getCommentStore(),
+ MediaWikiServices::getInstance()->getActorMigration()
+ );
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|LoadBalancer
+ */
+ private function getMockLoadBalancer() {
+ return $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()->getMock();
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|Database
+ */
+ private function getMockDatabase() {
+ return $this->getMockBuilder( Database::class )
+ ->disableOriginalConstructor()->getMock();
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore
+ */
+ private function getMockSqlBlobStore() {
+ return $this->getMockBuilder( SqlBlobStore::class )
+ ->disableOriginalConstructor()->getMock();
+ }
+
+ private function getHashWANObjectCache() {
+ return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getContentHandlerUseDB
+ * @covers \MediaWiki\Storage\RevisionStore::setContentHandlerUseDB
+ */
+ public function testGetSetContentHandlerDb() {
+ $store = $this->getRevisionStore();
+ $this->assertTrue( $store->getContentHandlerUseDB() );
+ $store->setContentHandlerUseDB( false );
+ $this->assertFalse( $store->getContentHandlerUseDB() );
+ $store->setContentHandlerUseDB( true );
+ $this->assertTrue( $store->getContentHandlerUseDB() );
+ }
+
+ private function getDefaultQueryFields() {
+ return [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_timestamp',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ ];
+ }
+
+ private function getCommentQueryFields() {
+ return [
+ 'rev_comment_text' => 'rev_comment',
+ 'rev_comment_data' => 'NULL',
+ 'rev_comment_cid' => 'NULL',
+ ];
+ }
+
+ private function getActorQueryFields() {
+ return [
+ 'rev_user' => 'rev_user',
+ 'rev_user_text' => 'rev_user_text',
+ 'rev_actor' => 'NULL',
+ ];
+ }
+
+ private function getContentHandlerQueryFields() {
+ return [
+ 'rev_content_format',
+ 'rev_content_model',
+ ];
+ }
+
+ public function provideGetQueryInfo() {
+ yield [
+ true,
+ [],
+ [
+ 'tables' => [ 'revision' ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields(),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields(),
+ $this->getContentHandlerQueryFields()
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield [
+ false,
+ [],
+ [
+ 'tables' => [ 'revision' ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields(),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields()
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield [
+ false,
+ [ 'page' ],
+ [
+ 'tables' => [ 'revision', 'page' ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields(),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields(),
+ [
+ 'page_namespace',
+ 'page_title',
+ 'page_id',
+ 'page_latest',
+ 'page_is_redirect',
+ 'page_len',
+ ]
+ ),
+ 'joins' => [
+ 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+ ],
+ ]
+ ];
+ yield [
+ false,
+ [ 'user' ],
+ [
+ 'tables' => [ 'revision', 'user' ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields(),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields(),
+ [
+ 'user_name',
+ ]
+ ),
+ 'joins' => [
+ 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
+ ],
+ ]
+ ];
+ yield [
+ false,
+ [ 'text' ],
+ [
+ 'tables' => [ 'revision', 'text' ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields(),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields(),
+ [
+ 'old_text',
+ 'old_flags',
+ ]
+ ),
+ 'joins' => [
+ 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ],
+ ],
+ ]
+ ];
+ yield [
+ true,
+ [ 'page', 'user', 'text' ],
+ [
+ 'tables' => [ 'revision', 'page', 'user', 'text' ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields(),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields(),
+ $this->getContentHandlerQueryFields(),
+ [
+ 'page_namespace',
+ 'page_title',
+ 'page_id',
+ 'page_latest',
+ 'page_is_redirect',
+ 'page_len',
+ 'user_name',
+ 'old_text',
+ 'old_flags',
+ ]
+ ),
+ 'joins' => [
+ 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+ 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
+ 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ],
+ ],
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetQueryInfo
+ * @covers \MediaWiki\Storage\RevisionStore::getQueryInfo
+ */
+ public function testGetQueryInfo( $contentHandlerUseDb, $options, $expected ) {
+ $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->overrideMwServices();
+ $store = $this->getRevisionStore();
+ $store->setContentHandlerUseDB( $contentHandlerUseDb );
+ $this->assertEquals( $expected, $store->getQueryInfo( $options ) );
+ }
+
+ private function getDefaultArchiveFields() {
+ return [
+ 'ar_id',
+ 'ar_page_id',
+ 'ar_namespace',
+ 'ar_title',
+ 'ar_rev_id',
+ 'ar_text_id',
+ 'ar_timestamp',
+ 'ar_minor_edit',
+ 'ar_deleted',
+ 'ar_len',
+ 'ar_parent_id',
+ 'ar_sha1',
+ ];
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getArchiveQueryInfo
+ */
+ public function testGetArchiveQueryInfo_contentHandlerDb() {
+ $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->overrideMwServices();
+ $store = $this->getRevisionStore();
+ $store->setContentHandlerUseDB( true );
+ $this->assertEquals(
+ [
+ 'tables' => [
+ 'archive'
+ ],
+ 'fields' => array_merge(
+ $this->getDefaultArchiveFields(),
+ [
+ 'ar_comment_text' => 'ar_comment',
+ 'ar_comment_data' => 'NULL',
+ 'ar_comment_cid' => 'NULL',
+ 'ar_user_text' => 'ar_user_text',
+ 'ar_user' => 'ar_user',
+ 'ar_actor' => 'NULL',
+ 'ar_content_format',
+ 'ar_content_model',
+ ]
+ ),
+ 'joins' => [],
+ ],
+ $store->getArchiveQueryInfo()
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getArchiveQueryInfo
+ */
+ public function testGetArchiveQueryInfo_noContentHandlerDb() {
+ $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->overrideMwServices();
+ $store = $this->getRevisionStore();
+ $store->setContentHandlerUseDB( false );
+ $this->assertEquals(
+ [
+ 'tables' => [
+ 'archive'
+ ],
+ 'fields' => array_merge(
+ $this->getDefaultArchiveFields(),
+ [
+ 'ar_comment_text' => 'ar_comment',
+ 'ar_comment_data' => 'NULL',
+ 'ar_comment_cid' => 'NULL',
+ 'ar_user_text' => 'ar_user_text',
+ 'ar_user' => 'ar_user',
+ 'ar_actor' => 'NULL',
+ ]
+ ),
+ 'joins' => [],
+ ],
+ $store->getArchiveQueryInfo()
+ );
+ }
+
+ public function testGetTitle_successFromPageId() {
+ $mockLoadBalancer = $this->getMockLoadBalancer();
+ // Title calls wfGetDB() so we have to set the main service
+ $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
+
+ $db = $this->getMockDatabase();
+ // Title calls wfGetDB() which uses a regular Connection
+ $mockLoadBalancer->expects( $this->atLeastOnce() )
+ ->method( 'getConnection' )
+ ->willReturn( $db );
+
+ // First call to Title::newFromID, faking no result (db lag?)
+ $db->expects( $this->at( 0 ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( (object)[
+ 'page_namespace' => '1',
+ 'page_title' => 'Food',
+ ] );
+
+ $store = $this->getRevisionStore( $mockLoadBalancer );
+ $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
+
+ $this->assertSame( 1, $title->getNamespace() );
+ $this->assertSame( 'Food', $title->getDBkey() );
+ }
+
+ public function testGetTitle_successFromPageIdOnFallback() {
+ $mockLoadBalancer = $this->getMockLoadBalancer();
+ // Title calls wfGetDB() so we have to set the main service
+ $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
+
+ $db = $this->getMockDatabase();
+ // Title calls wfGetDB() which uses a regular Connection
+ // Assert that the first call uses a REPLICA and the second falls back to master
+ $mockLoadBalancer->expects( $this->exactly( 2 ) )
+ ->method( 'getConnection' )
+ ->willReturn( $db );
+ // RevisionStore getTitle uses a ConnectionRef
+ $mockLoadBalancer->expects( $this->atLeastOnce() )
+ ->method( 'getConnectionRef' )
+ ->willReturn( $db );
+
+ // First call to Title::newFromID, faking no result (db lag?)
+ $db->expects( $this->at( 0 ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( false );
+
+ // First select using rev_id, faking no result (db lag?)
+ $db->expects( $this->at( 1 ) )
+ ->method( 'selectRow' )
+ ->with(
+ [ 'revision', 'page' ],
+ $this->anything(),
+ [ 'rev_id' => 2 ]
+ )
+ ->willReturn( false );
+
+ // Second call to Title::newFromID, no result
+ $db->expects( $this->at( 2 ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( (object)[
+ 'page_namespace' => '2',
+ 'page_title' => 'Foodey',
+ ] );
+
+ $store = $this->getRevisionStore( $mockLoadBalancer );
+ $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
+
+ $this->assertSame( 2, $title->getNamespace() );
+ $this->assertSame( 'Foodey', $title->getDBkey() );
+ }
+
+ public function testGetTitle_successFromRevId() {
+ $mockLoadBalancer = $this->getMockLoadBalancer();
+ // Title calls wfGetDB() so we have to set the main service
+ $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
+
+ $db = $this->getMockDatabase();
+ // Title calls wfGetDB() which uses a regular Connection
+ $mockLoadBalancer->expects( $this->atLeastOnce() )
+ ->method( 'getConnection' )
+ ->willReturn( $db );
+ // RevisionStore getTitle uses a ConnectionRef
+ $mockLoadBalancer->expects( $this->atLeastOnce() )
+ ->method( 'getConnectionRef' )
+ ->willReturn( $db );
+
+ // First call to Title::newFromID, faking no result (db lag?)
+ $db->expects( $this->at( 0 ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( false );
+
+ // First select using rev_id, faking no result (db lag?)
+ $db->expects( $this->at( 1 ) )
+ ->method( 'selectRow' )
+ ->with(
+ [ 'revision', 'page' ],
+ $this->anything(),
+ [ 'rev_id' => 2 ]
+ )
+ ->willReturn( (object)[
+ 'page_namespace' => '1',
+ 'page_title' => 'Food2',
+ ] );
+
+ $store = $this->getRevisionStore( $mockLoadBalancer );
+ $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
+
+ $this->assertSame( 1, $title->getNamespace() );
+ $this->assertSame( 'Food2', $title->getDBkey() );
+ }
+
+ public function testGetTitle_successFromRevIdOnFallback() {
+ $mockLoadBalancer = $this->getMockLoadBalancer();
+ // Title calls wfGetDB() so we have to set the main service
+ $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
+
+ $db = $this->getMockDatabase();
+ // Title calls wfGetDB() which uses a regular Connection
+ // Assert that the first call uses a REPLICA and the second falls back to master
+ $mockLoadBalancer->expects( $this->exactly( 2 ) )
+ ->method( 'getConnection' )
+ ->willReturn( $db );
+ // RevisionStore getTitle uses a ConnectionRef
+ $mockLoadBalancer->expects( $this->atLeastOnce() )
+ ->method( 'getConnectionRef' )
+ ->willReturn( $db );
+
+ // First call to Title::newFromID, faking no result (db lag?)
+ $db->expects( $this->at( 0 ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( false );
+
+ // First select using rev_id, faking no result (db lag?)
+ $db->expects( $this->at( 1 ) )
+ ->method( 'selectRow' )
+ ->with(
+ [ 'revision', 'page' ],
+ $this->anything(),
+ [ 'rev_id' => 2 ]
+ )
+ ->willReturn( false );
+
+ // Second call to Title::newFromID, no result
+ $db->expects( $this->at( 2 ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( false );
+
+ // Second select using rev_id, result
+ $db->expects( $this->at( 3 ) )
+ ->method( 'selectRow' )
+ ->with(
+ [ 'revision', 'page' ],
+ $this->anything(),
+ [ 'rev_id' => 2 ]
+ )
+ ->willReturn( (object)[
+ 'page_namespace' => '2',
+ 'page_title' => 'Foodey',
+ ] );
+
+ $store = $this->getRevisionStore( $mockLoadBalancer );
+ $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
+
+ $this->assertSame( 2, $title->getNamespace() );
+ $this->assertSame( 'Foodey', $title->getDBkey() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::getTitle
+ */
+ public function testGetTitle_correctFallbackAndthrowsExceptionAfterFallbacks() {
+ $mockLoadBalancer = $this->getMockLoadBalancer();
+ // Title calls wfGetDB() so we have to set the main service
+ $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
+
+ $db = $this->getMockDatabase();
+ // Title calls wfGetDB() which uses a regular Connection
+ // Assert that the first call uses a REPLICA and the second falls back to master
+
+ // RevisionStore getTitle uses getConnectionRef
+ // Title::newFromID uses getConnection
+ foreach ( [ 'getConnection', 'getConnectionRef' ] as $method ) {
+ $mockLoadBalancer->expects( $this->exactly( 2 ) )
+ ->method( $method )
+ ->willReturnCallback( function ( $masterOrReplica ) use ( $db ) {
+ static $callCounter = 0;
+ $callCounter++;
+ // The first call should be to a REPLICA, and the second a MASTER.
+ if ( $callCounter === 1 ) {
+ $this->assertSame( DB_REPLICA, $masterOrReplica );
+ } elseif ( $callCounter === 2 ) {
+ $this->assertSame( DB_MASTER, $masterOrReplica );
+ }
+ return $db;
+ } );
+ }
+ // First and third call to Title::newFromID, faking no result
+ foreach ( [ 0, 2 ] as $counter ) {
+ $db->expects( $this->at( $counter ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( false );
+ }
+
+ foreach ( [ 1, 3 ] as $counter ) {
+ $db->expects( $this->at( $counter ) )
+ ->method( 'selectRow' )
+ ->with(
+ [ 'revision', 'page' ],
+ $this->anything(),
+ [ 'rev_id' => 2 ]
+ )
+ ->willReturn( false );
+ }
+
+ $store = $this->getRevisionStore( $mockLoadBalancer );
+
+ $this->setExpectedException( RevisionAccessException::class );
+ $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
+ }
+
+ public function provideNewRevisionFromRow_legacyEncoding_applied() {
+ yield 'windows-1252, old_flags is empty' => [
+ 'windows-1252',
+ 'en',
+ [
+ 'old_flags' => '',
+ 'old_text' => "S\xF6me Content",
+ ],
+ 'Söme Content'
+ ];
+
+ yield 'windows-1252, old_flags is null' => [
+ 'windows-1252',
+ 'en',
+ [
+ 'old_flags' => null,
+ 'old_text' => "S\xF6me Content",
+ ],
+ 'Söme Content'
+ ];
+ }
+
+ /**
+ * @dataProvider provideNewRevisionFromRow_legacyEncoding_applied
+ *
+ * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
+ * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow_1_29
+ */
+ public function testNewRevisionFromRow_legacyEncoding_applied( $encoding, $locale, $row, $text ) {
+ $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
+
+ $blobStore = new SqlBlobStore( wfGetLB(), $cache );
+ $blobStore->setLegacyEncoding( $encoding, Language::factory( $locale ) );
+
+ $store = $this->getRevisionStore( wfGetLB(), $blobStore, $cache );
+
+ $record = $store->newRevisionFromRow(
+ $this->makeRow( $row ),
+ 0,
+ Title::newFromText( __METHOD__ . '-UTPage' )
+ );
+
+ $this->assertSame( $text, $record->getContent( 'main' )->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
+ * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow_1_29
+ */
+ public function testNewRevisionFromRow_legacyEncoding_ignored() {
+ $row = [
+ 'old_flags' => 'utf-8',
+ 'old_text' => 'Söme Content',
+ ];
+
+ $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
+
+ $blobStore = new SqlBlobStore( wfGetLB(), $cache );
+ $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) );
+
+ $store = $this->getRevisionStore( wfGetLB(), $blobStore, $cache );
+
+ $record = $store->newRevisionFromRow(
+ $this->makeRow( $row ),
+ 0,
+ Title::newFromText( __METHOD__ . '-UTPage' )
+ );
+ $this->assertSame( 'Söme Content', $record->getContent( 'main' )->serialize() );
+ }
+
+ private function makeRow( array $array ) {
+ $row = $array + [
+ 'rev_id' => 7,
+ 'rev_page' => 5,
+ 'rev_text_id' => 11,
+ 'rev_timestamp' => '20110101000000',
+ 'rev_user_text' => 'Tester',
+ 'rev_user' => 17,
+ 'rev_minor_edit' => 0,
+ 'rev_deleted' => 0,
+ 'rev_len' => 100,
+ 'rev_parent_id' => 0,
+ 'rev_sha1' => 'deadbeef',
+ 'rev_comment_text' => 'Testing',
+ 'rev_comment_data' => '{}',
+ 'rev_comment_cid' => 111,
+ 'rev_content_format' => CONTENT_FORMAT_TEXT,
+ 'rev_content_model' => CONTENT_MODEL_TEXT,
+ 'page_namespace' => 0,
+ 'page_title' => 'TEST',
+ 'page_id' => 5,
+ 'page_latest' => 7,
+ 'page_is_redirect' => 0,
+ 'page_len' => 100,
+ 'user_name' => 'Tester',
+ 'old_is' => 13,
+ 'old_text' => 'Hello World',
+ 'old_flags' => 'utf-8',
+ ];
+
+ return (object)$row;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/Storage/SlotRecordTest.php b/www/wiki/tests/phpunit/includes/Storage/SlotRecordTest.php
new file mode 100644
index 00000000..8f26494d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/Storage/SlotRecordTest.php
@@ -0,0 +1,298 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use InvalidArgumentException;
+use LogicException;
+use MediaWiki\Storage\IncompleteRevisionException;
+use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Storage\SuppressedDataException;
+use MediaWikiTestCase;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Storage\SlotRecord
+ */
+class SlotRecordTest extends MediaWikiTestCase {
+
+ private function makeRow( $data = [] ) {
+ $data = $data + [
+ 'slot_id' => 1234,
+ 'slot_content_id' => 33,
+ 'content_size' => '5',
+ 'content_sha1' => 'someHash',
+ 'content_address' => 'tt:456',
+ 'model_name' => CONTENT_MODEL_WIKITEXT,
+ 'format_name' => CONTENT_FORMAT_WIKITEXT,
+ 'slot_revision_id' => '2',
+ 'slot_origin' => '1',
+ 'role_name' => 'myRole',
+ ];
+ return (object)$data;
+ }
+
+ public function testCompleteConstruction() {
+ $row = $this->makeRow();
+ $record = new SlotRecord( $row, new WikitextContent( 'A' ) );
+
+ $this->assertTrue( $record->hasAddress() );
+ $this->assertTrue( $record->hasRevision() );
+ $this->assertTrue( $record->isInherited() );
+ $this->assertSame( 'A', $record->getContent()->getNativeData() );
+ $this->assertSame( 5, $record->getSize() );
+ $this->assertSame( 'someHash', $record->getSha1() );
+ $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() );
+ $this->assertSame( 2, $record->getRevision() );
+ $this->assertSame( 1, $record->getOrigin() );
+ $this->assertSame( 'tt:456', $record->getAddress() );
+ $this->assertSame( 33, $record->getContentId() );
+ $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() );
+ $this->assertSame( 'myRole', $record->getRole() );
+ }
+
+ public function testConstructionDeferred() {
+ $row = $this->makeRow( [
+ 'content_size' => null, // to be computed
+ 'content_sha1' => null, // to be computed
+ 'format_name' => function () {
+ return CONTENT_FORMAT_WIKITEXT;
+ },
+ 'slot_revision_id' => '2',
+ 'slot_origin' => '2',
+ ] );
+
+ $content = function () {
+ return new WikitextContent( 'A' );
+ };
+
+ $record = new SlotRecord( $row, $content );
+
+ $this->assertTrue( $record->hasAddress() );
+ $this->assertTrue( $record->hasRevision() );
+ $this->assertFalse( $record->isInherited() );
+ $this->assertSame( 'A', $record->getContent()->getNativeData() );
+ $this->assertSame( 1, $record->getSize() );
+ $this->assertNotNull( $record->getSha1() );
+ $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() );
+ $this->assertSame( 2, $record->getRevision() );
+ $this->assertSame( 2, $record->getRevision() );
+ $this->assertSame( 'tt:456', $record->getAddress() );
+ $this->assertSame( 33, $record->getContentId() );
+ $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() );
+ $this->assertSame( 'myRole', $record->getRole() );
+ }
+
+ public function testNewUnsaved() {
+ $record = SlotRecord::newUnsaved( 'myRole', new WikitextContent( 'A' ) );
+
+ $this->assertFalse( $record->hasAddress() );
+ $this->assertFalse( $record->hasRevision() );
+ $this->assertFalse( $record->isInherited() );
+ $this->assertSame( 'A', $record->getContent()->getNativeData() );
+ $this->assertSame( 1, $record->getSize() );
+ $this->assertNotNull( $record->getSha1() );
+ $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() );
+ $this->assertSame( 'myRole', $record->getRole() );
+ }
+
+ public function provideInvalidConstruction() {
+ yield 'both null' => [ null, null ];
+ yield 'null row' => [ null, new WikitextContent( 'A' ) ];
+ yield 'array row' => [ [], new WikitextContent( 'A' ) ];
+ yield 'empty row' => [ (object)[], new WikitextContent( 'A' ) ];
+ yield 'null content' => [ (object)[], null ];
+ }
+
+ /**
+ * @dataProvider provideInvalidConstruction
+ */
+ public function testInvalidConstruction( $row, $content ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ new SlotRecord( $row, $content );
+ }
+
+ public function testGetContentId_fails() {
+ $record = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $this->setExpectedException( IncompleteRevisionException::class );
+
+ $record->getContentId();
+ }
+
+ public function testGetAddress_fails() {
+ $record = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $this->setExpectedException( IncompleteRevisionException::class );
+
+ $record->getAddress();
+ }
+
+ public function provideIncomplete() {
+ $unsaved = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ yield 'unsaved' => [ $unsaved ];
+
+ $parent = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) );
+ $inherited = SlotRecord::newInherited( $parent );
+ yield 'inherited' => [ $inherited ];
+ }
+
+ /**
+ * @dataProvider provideIncomplete
+ */
+ public function testGetRevision_fails( SlotRecord $record ) {
+ $record = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $this->setExpectedException( IncompleteRevisionException::class );
+
+ $record->getRevision();
+ }
+
+ /**
+ * @dataProvider provideIncomplete
+ */
+ public function testGetOrigin_fails( SlotRecord $record ) {
+ $record = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $this->setExpectedException( IncompleteRevisionException::class );
+
+ $record->getOrigin();
+ }
+
+ public function provideHashStability() {
+ yield [ '', 'phoiac9h4m842xq45sp7s6u21eteeq1' ];
+ yield [ 'Lorem ipsum', 'hcr5u40uxr81d3nx89nvwzclfz6r9c5' ];
+ }
+
+ /**
+ * @dataProvider provideHashStability
+ */
+ public function testHashStability( $text, $hash ) {
+ // Changing the output of the hash function will break things horribly!
+
+ $this->assertSame( $hash, SlotRecord::base36Sha1( $text ) );
+
+ $record = SlotRecord::newUnsaved( 'main', new WikitextContent( $text ) );
+ $this->assertSame( $hash, $record->getSha1() );
+ }
+
+ public function testNewWithSuppressedContent() {
+ $input = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) );
+ $output = SlotRecord::newWithSuppressedContent( $input );
+
+ $this->setExpectedException( SuppressedDataException::class );
+ $output->getContent();
+ }
+
+ public function testNewInherited() {
+ $row = $this->makeRow( [ 'slot_revision_id' => 7, 'slot_origin' => 7 ] );
+ $parent = new SlotRecord( $row, new WikitextContent( 'A' ) );
+
+ // This would happen while doing an edit, before saving revision meta-data.
+ $inherited = SlotRecord::newInherited( $parent );
+
+ $this->assertSame( $parent->getContentId(), $inherited->getContentId() );
+ $this->assertSame( $parent->getAddress(), $inherited->getAddress() );
+ $this->assertSame( $parent->getContent(), $inherited->getContent() );
+ $this->assertTrue( $inherited->isInherited() );
+ $this->assertFalse( $inherited->hasRevision() );
+
+ // make sure we didn't mess with the internal state of $parent
+ $this->assertFalse( $parent->isInherited() );
+ $this->assertSame( 7, $parent->getRevision() );
+
+ // This would happen while doing an edit, after saving the revision meta-data
+ // and content meta-data.
+ $saved = SlotRecord::newSaved(
+ 10,
+ $inherited->getContentId(),
+ $inherited->getAddress(),
+ $inherited
+ );
+ $this->assertSame( $parent->getContentId(), $saved->getContentId() );
+ $this->assertSame( $parent->getAddress(), $saved->getAddress() );
+ $this->assertSame( $parent->getContent(), $saved->getContent() );
+ $this->assertTrue( $saved->isInherited() );
+ $this->assertTrue( $saved->hasRevision() );
+ $this->assertSame( 10, $saved->getRevision() );
+
+ // make sure we didn't mess with the internal state of $parent or $inherited
+ $this->assertSame( 7, $parent->getRevision() );
+ $this->assertFalse( $inherited->hasRevision() );
+ }
+
+ public function testNewSaved() {
+ // This would happen while doing an edit, before saving revision meta-data.
+ $unsaved = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+
+ // This would happen while doing an edit, after saving the revision meta-data
+ // and content meta-data.
+ $saved = SlotRecord::newSaved( 10, 20, 'theNewAddress', $unsaved );
+ $this->assertFalse( $saved->isInherited() );
+ $this->assertTrue( $saved->hasRevision() );
+ $this->assertTrue( $saved->hasAddress() );
+ $this->assertSame( 'theNewAddress', $saved->getAddress() );
+ $this->assertSame( 20, $saved->getContentId() );
+ $this->assertSame( 'A', $saved->getContent()->getNativeData() );
+ $this->assertSame( 10, $saved->getRevision() );
+ $this->assertSame( 10, $saved->getOrigin() );
+
+ // make sure we didn't mess with the internal state of $unsaved
+ $this->assertFalse( $unsaved->hasAddress() );
+ $this->assertFalse( $unsaved->hasRevision() );
+ }
+
+ public function provideNewSaved_LogicException() {
+ $freshRow = $this->makeRow( [
+ 'content_id' => 10,
+ 'content_address' => 'address:1',
+ 'slot_origin' => 1,
+ 'slot_revision_id' => 1,
+ ] );
+
+ $freshSlot = new SlotRecord( $freshRow, new WikitextContent( 'A' ) );
+ yield 'mismatching address' => [ 1, 10, 'address:BAD', $freshSlot ];
+ yield 'mismatching revision' => [ 5, 10, 'address:1', $freshSlot ];
+ yield 'mismatching content ID' => [ 1, 17, 'address:1', $freshSlot ];
+
+ $inheritedRow = $this->makeRow( [
+ 'content_id' => null,
+ 'content_address' => null,
+ 'slot_origin' => 0,
+ 'slot_revision_id' => 1,
+ ] );
+
+ $inheritedSlot = new SlotRecord( $inheritedRow, new WikitextContent( 'A' ) );
+ yield 'inherited, but no address' => [ 1, 10, 'address:2', $inheritedSlot ];
+ }
+
+ /**
+ * @dataProvider provideNewSaved_LogicException
+ */
+ public function testNewSaved_LogicException(
+ $revisionId,
+ $contentId,
+ $contentAddress,
+ SlotRecord $protoSlot
+ ) {
+ $this->setExpectedException( LogicException::class );
+ SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot );
+ }
+
+ public function provideNewSaved_InvalidArgumentException() {
+ $unsaved = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+
+ yield 'bad revision id' => [ 'xyzzy', 5, 'address', $unsaved ];
+ yield 'bad content id' => [ 7, 'xyzzy', 'address', $unsaved ];
+ yield 'bad content address' => [ 7, 5, 77, $unsaved ];
+ }
+
+ /**
+ * @dataProvider provideNewSaved_InvalidArgumentException
+ */
+ public function testNewSaved_InvalidArgumentException(
+ $revisionId,
+ $contentId,
+ $contentAddress,
+ SlotRecord $protoSlot
+ ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/Storage/SqlBlobStoreTest.php b/www/wiki/tests/phpunit/includes/Storage/SqlBlobStoreTest.php
new file mode 100644
index 00000000..dbbef11e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/Storage/SqlBlobStoreTest.php
@@ -0,0 +1,241 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use Language;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\SqlBlobStore;
+use MediaWikiTestCase;
+use stdClass;
+use TitleValue;
+
+/**
+ * @covers \MediaWiki\Storage\SqlBlobStore
+ * @group Database
+ */
+class SqlBlobStoreTest extends MediaWikiTestCase {
+
+ /**
+ * @return SqlBlobStore
+ */
+ public function getBlobStore( $legacyEncoding = false, $compressRevisions = false ) {
+ $services = MediaWikiServices::getInstance();
+
+ $store = new SqlBlobStore(
+ $services->getDBLoadBalancer(),
+ $services->getMainWANObjectCache()
+ );
+
+ if ( $compressRevisions ) {
+ $store->setCompressBlobs( $compressRevisions );
+ }
+ if ( $legacyEncoding ) {
+ $store->setLegacyEncoding( $legacyEncoding, Language::factory( 'en' ) );
+ }
+
+ return $store;
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\SqlBlobStore::getCompressBlobs()
+ * @covers \MediaWiki\Storage\SqlBlobStore::setCompressBlobs()
+ */
+ public function testGetSetCompressRevisions() {
+ $store = $this->getBlobStore();
+ $this->assertFalse( $store->getCompressBlobs() );
+ $store->setCompressBlobs( true );
+ $this->assertTrue( $store->getCompressBlobs() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\SqlBlobStore::getLegacyEncoding()
+ * @covers \MediaWiki\Storage\SqlBlobStore::getLegacyEncodingConversionLang()
+ * @covers \MediaWiki\Storage\SqlBlobStore::setLegacyEncoding()
+ */
+ public function testGetSetLegacyEncoding() {
+ $store = $this->getBlobStore();
+ $this->assertFalse( $store->getLegacyEncoding() );
+ $this->assertNull( $store->getLegacyEncodingConversionLang() );
+ $en = Language::factory( 'en' );
+ $store->setLegacyEncoding( 'foo', $en );
+ $this->assertSame( 'foo', $store->getLegacyEncoding() );
+ $this->assertSame( $en, $store->getLegacyEncodingConversionLang() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\SqlBlobStore::getCacheExpiry()
+ * @covers \MediaWiki\Storage\SqlBlobStore::setCacheExpiry()
+ */
+ public function testGetSetCacheExpiry() {
+ $store = $this->getBlobStore();
+ $this->assertSame( 604800, $store->getCacheExpiry() );
+ $store->setCacheExpiry( 12 );
+ $this->assertSame( 12, $store->getCacheExpiry() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\SqlBlobStore::getUseExternalStore()
+ * @covers \MediaWiki\Storage\SqlBlobStore::setUseExternalStore()
+ */
+ public function testGetSetUseExternalStore() {
+ $store = $this->getBlobStore();
+ $this->assertFalse( $store->getUseExternalStore() );
+ $store->setUseExternalStore( true );
+ $this->assertTrue( $store->getUseExternalStore() );
+ }
+
+ public function provideDecompress() {
+ yield '(no legacy encoding), false in false out' => [ false, false, [], false ];
+ yield '(no legacy encoding), empty in empty out' => [ false, '', [], '' ];
+ yield '(no legacy encoding), empty in empty out' => [ false, 'A', [], 'A' ];
+ yield '(no legacy encoding), string in with gzip flag returns string' => [
+ // gzip string below generated with gzdeflate( 'AAAABBAAA' )
+ false, "sttttr\002\022\000", [ 'gzip' ], 'AAAABBAAA',
+ ];
+ yield '(no legacy encoding), string in with object flag returns false' => [
+ // gzip string below generated with serialize( 'JOJO' )
+ false, "s:4:\"JOJO\";", [ 'object' ], false,
+ ];
+ yield '(no legacy encoding), serialized object in with object flag returns string' => [
+ false,
+ // Using a TitleValue object as it has a getText method (which is needed)
+ serialize( new TitleValue( 0, 'HHJJDDFF' ) ),
+ [ 'object' ],
+ 'HHJJDDFF',
+ ];
+ yield '(no legacy encoding), serialized object in with object & gzip flag returns string' => [
+ false,
+ // Using a TitleValue object as it has a getText method (which is needed)
+ gzdeflate( serialize( new TitleValue( 0, '8219JJJ840' ) ) ),
+ [ 'object', 'gzip' ],
+ '8219JJJ840',
+ ];
+ yield '(ISO-8859-1 encoding), string in string out' => [
+ 'ISO-8859-1',
+ iconv( 'utf-8', 'ISO-8859-1', "1®Àþ1" ),
+ [],
+ '1®Àþ1',
+ ];
+ yield '(ISO-8859-1 encoding), serialized object in with gzip flags returns string' => [
+ 'ISO-8859-1',
+ gzdeflate( iconv( 'utf-8', 'ISO-8859-1', "4®Àþ4" ) ),
+ [ 'gzip' ],
+ '4®Àþ4',
+ ];
+ yield '(ISO-8859-1 encoding), serialized object in with object flags returns string' => [
+ 'ISO-8859-1',
+ serialize( new TitleValue( 0, iconv( 'utf-8', 'ISO-8859-1', "3®Àþ3" ) ) ),
+ [ 'object' ],
+ '3®Àþ3',
+ ];
+ yield '(ISO-8859-1 encoding), serialized object in with object & gzip flags returns string' => [
+ 'ISO-8859-1',
+ gzdeflate( serialize( new TitleValue( 0, iconv( 'utf-8', 'ISO-8859-1', "2®Àþ2" ) ) ) ),
+ [ 'gzip', 'object' ],
+ '2®Àþ2',
+ ];
+ yield 'T184749 (windows-1252 encoding), string in string out' => [
+ 'windows-1252',
+ iconv( 'utf-8', 'windows-1252', "sammansättningar" ),
+ [],
+ 'sammansättningar',
+ ];
+ yield 'T184749 (windows-1252 encoding), string in string out with gzip' => [
+ 'windows-1252',
+ gzdeflate( iconv( 'utf-8', 'windows-1252', "sammansättningar" ) ),
+ [ 'gzip' ],
+ 'sammansättningar',
+ ];
+ }
+
+ /**
+ * @dataProvider provideDecompress
+ * @covers \MediaWiki\Storage\SqlBlobStore::decompressData
+ *
+ * @param string|bool $legacyEncoding
+ * @param mixed $data
+ * @param array $flags
+ * @param mixed $expected
+ */
+ public function testDecompressData( $legacyEncoding, $data, $flags, $expected ) {
+ $store = $this->getBlobStore( $legacyEncoding );
+ $this->assertSame(
+ $expected,
+ $store->decompressData( $data, $flags )
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\SqlBlobStore::compressData
+ */
+ public function testCompressRevisionTextUtf8() {
+ $store = $this->getBlobStore();
+ $row = new stdClass;
+ $row->old_text = "Wiki est l'\xc3\xa9cole superieur !";
+ $row->old_flags = $store->compressData( $row->old_text );
+ $this->assertTrue( false !== strpos( $row->old_flags, 'utf-8' ),
+ "Flags should contain 'utf-8'" );
+ $this->assertFalse( false !== strpos( $row->old_flags, 'gzip' ),
+ "Flags should not contain 'gzip'" );
+ $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !",
+ $row->old_text, "Direct check" );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\SqlBlobStore::compressData
+ */
+ public function testCompressRevisionTextUtf8Gzip() {
+ $store = $this->getBlobStore( false, true );
+ $this->checkPHPExtension( 'zlib' );
+
+ $row = new stdClass;
+ $row->old_text = "Wiki est l'\xc3\xa9cole superieur !";
+ $row->old_flags = $store->compressData( $row->old_text );
+ $this->assertTrue( false !== strpos( $row->old_flags, 'utf-8' ),
+ "Flags should contain 'utf-8'" );
+ $this->assertTrue( false !== strpos( $row->old_flags, 'gzip' ),
+ "Flags should contain 'gzip'" );
+ $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !",
+ gzinflate( $row->old_text ), "Direct check" );
+ }
+
+ public function provideBlobs() {
+ yield [ '' ];
+ yield [ 'someText' ];
+ yield [ "sammansättningar" ];
+ }
+
+ /**
+ * @dataProvider provideBlobs
+ * @covers \MediaWiki\Storage\SqlBlobStore::storeBlob
+ * @covers \MediaWiki\Storage\SqlBlobStore::getBlob
+ */
+ public function testSimpleStoreGetBlobSimpleRoundtrip( $blob ) {
+ $store = $this->getBlobStore();
+ $address = $store->storeBlob( $blob );
+ $this->assertSame( $blob, $store->getBlob( $address ) );
+ }
+
+ /**
+ * @dataProvider provideBlobs
+ * @covers \MediaWiki\Storage\SqlBlobStore::storeBlob
+ * @covers \MediaWiki\Storage\SqlBlobStore::getBlob
+ */
+ public function testSimpleStoreGetBlobSimpleRoundtripWindowsLegacyEncoding( $blob ) {
+ $store = $this->getBlobStore( 'windows-1252' );
+ $address = $store->storeBlob( $blob );
+ $this->assertSame( $blob, $store->getBlob( $address ) );
+ }
+
+ /**
+ * @dataProvider provideBlobs
+ * @covers \MediaWiki\Storage\SqlBlobStore::storeBlob
+ * @covers \MediaWiki\Storage\SqlBlobStore::getBlob
+ */
+ public function testSimpleStoreGetBlobSimpleRoundtripWindowsLegacyEncodingGzip( $blob ) {
+ $store = $this->getBlobStore( 'windows-1252', true );
+ $address = $store->storeBlob( $blob );
+ $this->assertSame( $blob, $store->getBlob( $address ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/TemplateCategoriesTest.php b/www/wiki/tests/phpunit/includes/TemplateCategoriesTest.php
new file mode 100644
index 00000000..ebd8dbd3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/TemplateCategoriesTest.php
@@ -0,0 +1,98 @@
+<?php
+
+require __DIR__ . "/../../../maintenance/runJobs.php";
+
+/**
+ * @group Database
+ */
+class TemplateCategoriesTest extends MediaWikiLangTestCase {
+
+ /**
+ * Broken per T165099.
+ *
+ * @group Broken
+ * @covers Title::getParentCategories
+ */
+ public function testTemplateCategories() {
+ $user = new User();
+ $user->mRights = [ 'createpage', 'edit', 'purge', 'delete' ];
+
+ $title = Title::newFromText( "Categorized from template" );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ new WikitextContent( '{{Categorising template}}' ),
+ 'Create a page with a template',
+ 0,
+ false,
+ $user
+ );
+
+ $this->assertEquals(
+ [],
+ $title->getParentCategories(),
+ 'Verify that the category doesn\'t contain the page before the template is created'
+ );
+
+ // Create template
+ $template = WikiPage::factory( Title::newFromText( 'Template:Categorising template' ) );
+ $template->doEditContent(
+ new WikitextContent( '[[Category:Solved bugs]]' ),
+ 'Add a category through a template',
+ 0,
+ false,
+ $user
+ );
+
+ // Run the job queue
+ JobQueueGroup::destroySingletons();
+ $jobs = new RunJobs;
+ $jobs->loadParamsAndArgs( null, [ 'quiet' => true ], null );
+ $jobs->execute();
+
+ // Make sure page is in the category
+ $this->assertEquals(
+ [ 'Category:Solved_bugs' => $title->getPrefixedText() ],
+ $title->getParentCategories(),
+ 'Verify that the page is in the category after the template is created'
+ );
+
+ // Edit the template
+ $template->doEditContent(
+ new WikitextContent( '[[Category:Solved bugs 2]]' ),
+ 'Change the category added by the template',
+ 0,
+ false,
+ $user
+ );
+
+ // Run the job queue
+ JobQueueGroup::destroySingletons();
+ $jobs = new RunJobs;
+ $jobs->loadParamsAndArgs( null, [ 'quiet' => true ], null );
+ $jobs->execute();
+
+ // Make sure page is in the right category
+ $this->assertEquals(
+ [ 'Category:Solved_bugs_2' => $title->getPrefixedText() ],
+ $title->getParentCategories(),
+ 'Verify that the page is in the right category after the template is edited'
+ );
+
+ // Now delete the template
+ $error = '';
+ $template->doDeleteArticleReal( 'Delete the template', false, 0, true, $error, $user );
+
+ // Run the job queue
+ JobQueueGroup::destroySingletons();
+ $jobs = new RunJobs;
+ $jobs->loadParamsAndArgs( null, [ 'quiet' => true ], null );
+ $jobs->execute();
+
+ // Make sure the page is no longer in the category
+ $this->assertEquals(
+ [],
+ $title->getParentCategories(),
+ 'Verify that the page is no longer in the category after template deletion'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/TemplateParserTest.php b/www/wiki/tests/phpunit/includes/TemplateParserTest.php
new file mode 100644
index 00000000..ccccf0f9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/TemplateParserTest.php
@@ -0,0 +1,123 @@
+<?php
+
+/**
+ * @group Templates
+ * @covers TemplateParser
+ */
+class TemplateParserTest extends MediaWikiTestCase {
+
+ protected $templateDir;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgSecretKey' => 'foo',
+ ] );
+
+ $this->templateDir = dirname( __DIR__ ) . '/data/templates/';
+ }
+
+ /**
+ * @dataProvider provideProcessTemplate
+ */
+ public function testProcessTemplate( $name, $args, $result, $exception = false ) {
+ if ( $exception ) {
+ $this->setExpectedException( $exception );
+ }
+ $tp = new TemplateParser( $this->templateDir );
+ $this->assertEquals( $result, $tp->processTemplate( $name, $args ) );
+ }
+
+ public static function provideProcessTemplate() {
+ return [
+ [
+ 'foobar',
+ [],
+ "hello world!\n"
+ ],
+ [
+ 'foobar_args',
+ [
+ 'planet' => 'world',
+ ],
+ "hello world!\n",
+ ],
+ [
+ '../foobar',
+ [],
+ false,
+ 'UnexpectedValueException'
+ ],
+ [
+ "\000../foobar",
+ [],
+ false,
+ 'UnexpectedValueException'
+ ],
+ [
+ '/',
+ [],
+ false,
+ 'UnexpectedValueException'
+ ],
+ [
+ // Allegedly this can strip ext in windows.
+ 'baz<',
+ [],
+ false,
+ 'UnexpectedValueException'
+ ],
+ [
+ '\\foo',
+ [],
+ false,
+ 'UnexpectedValueException'
+ ],
+ [
+ 'C:\bar',
+ [],
+ false,
+ 'UnexpectedValueException'
+ ],
+ [
+ "foo\000bar",
+ [],
+ false,
+ 'UnexpectedValueException'
+ ],
+ [
+ 'nonexistenttemplate',
+ [],
+ false,
+ 'RuntimeException',
+ ],
+ [
+ 'has_partial',
+ [
+ 'planet' => 'world',
+ ],
+ "Partial hello world!\n in here\n",
+ ],
+ [
+ 'bad_partial',
+ [],
+ false,
+ 'Exception',
+ ],
+ ];
+ }
+
+ public function testEnableRecursivePartials() {
+ $tp = new TemplateParser( $this->templateDir );
+ $data = [ 'r' => [ 'r' => [ 'r' => [] ] ] ];
+
+ $tp->enableRecursivePartials( true );
+ $this->assertEquals( 'rrr', $tp->processTemplate( 'recurse', $data ) );
+
+ $tp->enableRecursivePartials( false );
+ $this->setExpectedException( Exception::class );
+ $tp->processTemplate( 'recurse', $data );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/TestLogger.php b/www/wiki/tests/phpunit/includes/TestLogger.php
new file mode 100644
index 00000000..21d1bf25
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/TestLogger.php
@@ -0,0 +1,127 @@
+<?php
+/**
+ * Testing logger
+ *
+ * Copyright (C) 2015 Wikimedia Foundation and contributors
+ *
+ * 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
+ */
+
+use Psr\Log\LogLevel;
+
+/**
+ * A logger that may be configured to either buffer logs or to print them to
+ * the output where PHPUnit will complain about them.
+ *
+ * @since 1.27
+ */
+class TestLogger extends \Psr\Log\AbstractLogger {
+ private $collect = false;
+ private $collectContext = false;
+ private $buffer = [];
+ private $filter = null;
+
+ /**
+ * @param bool $collect Whether to collect logs. @see setCollect()
+ * @param callable $filter Filter logs before collecting/printing. Signature is
+ * string|null function ( string $message, string $level, array $context );
+ * @param bool $collectContext Whether to keep the context passed to log.
+ * @since 1.29 @see setCollectContext()
+ */
+ public function __construct( $collect = false, $filter = null, $collectContext = false ) {
+ $this->collect = $collect;
+ $this->collectContext = $collectContext;
+ $this->filter = $filter;
+ }
+
+ /**
+ * Set the "collect" flag
+ * @param bool $collect
+ * @return TestLogger $this
+ */
+ public function setCollect( $collect ) {
+ $this->collect = $collect;
+ return $this;
+ }
+
+ /**
+ * Set the collectContext flag
+ *
+ * @param bool $collectContext
+ * @since 1.29
+ * @return TestLogger $this
+ */
+ public function setCollectContext( $collectContext ) {
+ $this->collectContext = $collectContext;
+ return $this;
+ }
+
+ /**
+ * Return the collected logs
+ * @return array Array of array( string $level, string $message ), or
+ * array( string $level, string $message, array $context ) if $collectContext was true.
+ */
+ public function getBuffer() {
+ return $this->buffer;
+ }
+
+ /**
+ * Clear the collected log buffer
+ */
+ public function clearBuffer() {
+ $this->buffer = [];
+ }
+
+ public function log( $level, $message, array $context = [] ) {
+ $message = trim( $message );
+
+ if ( $this->filter ) {
+ $message = call_user_func( $this->filter, $message, $level, $context );
+ if ( $message === null ) {
+ return;
+ }
+ }
+
+ if ( $this->collect ) {
+ if ( $this->collectContext ) {
+ $this->buffer[] = [ $level, $message, $context ];
+ } else {
+ $this->buffer[] = [ $level, $message ];
+ }
+ } else {
+ switch ( $level ) {
+ case LogLevel::DEBUG:
+ case LogLevel::INFO:
+ case LogLevel::NOTICE:
+ trigger_error( "LOG[$level]: $message", E_USER_NOTICE );
+ break;
+
+ case LogLevel::WARNING:
+ trigger_error( "LOG[$level]: $message", E_USER_WARNING );
+ break;
+
+ case LogLevel::ERROR:
+ case LogLevel::CRITICAL:
+ case LogLevel::ALERT:
+ case LogLevel::EMERGENCY:
+ trigger_error( "LOG[$level]: $message", E_USER_ERROR );
+ break;
+ }
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/TestUser.php b/www/wiki/tests/phpunit/includes/TestUser.php
new file mode 100644
index 00000000..86f4ae78
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/TestUser.php
@@ -0,0 +1,171 @@
+<?php
+
+/**
+ * Wraps the user object, so we can also retain full access to properties
+ * like password if we log in via the API.
+ */
+class TestUser {
+ /**
+ * @var string
+ */
+ private $username;
+
+ /**
+ * @var string
+ */
+ private $password;
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ private function assertNotReal() {
+ global $wgDBprefix;
+ if ( $wgDBprefix !== MediaWikiTestCase::DB_PREFIX &&
+ $wgDBprefix !== MediaWikiTestCase::ORA_DB_PREFIX
+ ) {
+ throw new MWException( "Can't create user on real database" );
+ }
+ }
+
+ public function __construct( $username, $realname = 'Real Name',
+ $email = 'sample@example.com', $groups = []
+ ) {
+ $this->assertNotReal();
+
+ $this->username = $username;
+ $this->password = 'TestUser';
+
+ $this->user = User::newFromName( $this->username );
+ $this->user->load();
+
+ // In an ideal world we'd have a new wiki (or mock data store) for every single test.
+ // But for now, we just need to create or update the user with the desired properties.
+ // we particularly need the new password, since we just generated it randomly.
+ // In core MediaWiki, there is no functionality to delete users, so this is the best we can do.
+ if ( !$this->user->isLoggedIn() ) {
+ // create the user
+ $this->user = User::createNew(
+ $this->username, [
+ "email" => $email,
+ "real_name" => $realname
+ ]
+ );
+
+ if ( !$this->user ) {
+ throw new MWException( "Error creating TestUser " . $username );
+ }
+ }
+
+ // Update the user to use the password and other details
+ $this->setPassword( $this->password );
+ $change = $this->setEmail( $email ) ||
+ $this->setRealName( $realname );
+
+ // Adjust groups by adding any missing ones and removing any extras
+ $currentGroups = $this->user->getGroups();
+ foreach ( array_diff( $groups, $currentGroups ) as $group ) {
+ $this->user->addGroup( $group );
+ }
+ foreach ( array_diff( $currentGroups, $groups ) as $group ) {
+ $this->user->removeGroup( $group );
+ }
+ if ( $change ) {
+ // Disable CAS check before saving. The User object may have been initialized from cached
+ // information that may be out of whack with the database during testing. If tests were
+ // perfectly isolated, this would not happen. But if it does happen, let's just ignore the
+ // inconsistency, and just write the data we want - during testing, we are not worried
+ // about data loss.
+ $this->user->mTouched = '';
+ $this->user->saveSettings();
+ }
+ }
+
+ /**
+ * @param string $realname
+ * @return bool
+ */
+ private function setRealName( $realname ) {
+ if ( $this->user->getRealName() !== $realname ) {
+ $this->user->setRealName( $realname );
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $email
+ * @return bool
+ */
+ private function setEmail( $email ) {
+ if ( $this->user->getEmail() !== $email ) {
+ $this->user->setEmail( $email );
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $password
+ */
+ private function setPassword( $password ) {
+ self::setPasswordForUser( $this->user, $password );
+ }
+
+ /**
+ * Set the password on a testing user
+ *
+ * This assumes we're still using the generic AuthManager config from
+ * PHPUnitMaintClass::finalSetup(), and just sets the password in the
+ * database directly.
+ * @param User $user
+ * @param string $password
+ */
+ public static function setPasswordForUser( User $user, $password ) {
+ if ( !$user->getId() ) {
+ throw new MWException( "Passed User has not been added to the database yet!" );
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $row = $dbw->selectRow(
+ 'user',
+ [ 'user_password' ],
+ [ 'user_id' => $user->getId() ],
+ __METHOD__
+ );
+ if ( !$row ) {
+ throw new MWException( "Passed User has an ID but is not in the database?" );
+ }
+
+ $passwordFactory = new PasswordFactory();
+ $passwordFactory->init( RequestContext::getMain()->getConfig() );
+ if ( !$passwordFactory->newFromCiphertext( $row->user_password )->equals( $password ) ) {
+ $passwordHash = $passwordFactory->newFromPlaintext( $password );
+ $dbw->update(
+ 'user',
+ [ 'user_password' => $passwordHash->toString() ],
+ [ 'user_id' => $user->getId() ],
+ __METHOD__
+ );
+ }
+ }
+
+ /**
+ * @since 1.25
+ * @return User
+ */
+ public function getUser() {
+ return $this->user;
+ }
+
+ /**
+ * @since 1.25
+ * @return string
+ */
+ public function getPassword() {
+ return $this->password;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/TestUserRegistry.php b/www/wiki/tests/phpunit/includes/TestUserRegistry.php
new file mode 100644
index 00000000..0c178ca1
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/TestUserRegistry.php
@@ -0,0 +1,125 @@
+<?php
+
+/**
+ * @since 1.28
+ */
+class TestUserRegistry {
+
+ /** @var TestUser[] (group key => TestUser) */
+ private static $testUsers = [];
+
+ /** @var int Count of users that have been generated */
+ private static $counter = 0;
+
+ /** @var int Random int, included in IDs */
+ private static $randInt;
+
+ public static function getNextId() {
+ if ( !self::$randInt ) {
+ self::$randInt = mt_rand( 1, 0xFFFFFF );
+ }
+ return sprintf( '%06x.%03x', self::$randInt, ++self::$counter );
+ }
+
+ /**
+ * Get a TestUser object that the caller may modify.
+ *
+ * @since 1.28
+ *
+ * @param string $testName Caller's __CLASS__. Used to generate the
+ * user's username.
+ * @param string[] $groups Groups the test user should be added to.
+ * @return TestUser
+ */
+ public static function getMutableTestUser( $testName, $groups = [] ) {
+ $id = self::getNextId();
+ $password = wfRandomString( 20 );
+ $testUser = new TestUser(
+ "TestUser $testName $id", // username
+ "Name $id", // real name
+ "$id@mediawiki.test", // e-mail
+ $groups, // groups
+ $password // password
+ );
+ $testUser->getUser()->clearInstanceCache();
+ return $testUser;
+ }
+
+ /**
+ * Get a TestUser object that the caller may not modify.
+ *
+ * Whenever possible, unit tests should use immutable users, because
+ * immutable users can be reused in multiple tests, which helps keep
+ * the unit tests fast.
+ *
+ * @since 1.28
+ *
+ * @param string[] $groups Groups the test user should be added to.
+ * @return TestUser
+ */
+ public static function getImmutableTestUser( $groups = [] ) {
+ $groups = array_unique( $groups );
+ sort( $groups );
+ $key = implode( ',', $groups );
+
+ $testUser = isset( self::$testUsers[$key] )
+ ? self::$testUsers[$key]
+ : false;
+
+ if ( !$testUser || !$testUser->getUser()->isLoggedIn() ) {
+ $id = self::getNextId();
+ // Hack! If this is the primary sysop account, make the username
+ // be 'UTSysop', for back-compat, and for the sake of PHPUnit data
+ // provider methods, which are executed before the test database
+ // is set up. See T136348.
+ if ( $groups === [ 'bureaucrat', 'sysop' ] ) {
+ $username = 'UTSysop';
+ $password = 'UTSysopPassword';
+ } else {
+ $username = "TestUser $id";
+ $password = wfRandomString( 20 );
+ }
+ self::$testUsers[$key] = $testUser = new TestUser(
+ $username, // username
+ "Name $id", // real name
+ "$id@mediawiki.test", // e-mail
+ $groups, // groups
+ $password // password
+ );
+ }
+
+ $testUser->getUser()->clearInstanceCache();
+ return self::$testUsers[$key];
+ }
+
+ /**
+ * Clear the registry.
+ *
+ * TestUsers created by this class will not be deleted, but any handles
+ * to existing immutable TestUsers will be deleted, ensuring these users
+ * are not reused. We don't reset the counter or random string by design.
+ *
+ * @since 1.28
+ *
+ * @param string[] $groups Groups the test user should be added to.
+ * @return TestUser
+ */
+ public static function clear() {
+ self::$testUsers = [];
+ }
+
+ /**
+ * @todo It would be nice if this were a non-static method of TestUser
+ * instead, but that doesn't seem possible without friends?
+ *
+ * @return bool True if it's safe to modify the user
+ */
+ public static function isMutable( User $user ) {
+ foreach ( self::$testUsers as $key => $testUser ) {
+ if ( $user === $testUser->getUser() ) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/TimeAdjustTest.php b/www/wiki/tests/phpunit/includes/TimeAdjustTest.php
new file mode 100644
index 00000000..93aef34b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/TimeAdjustTest.php
@@ -0,0 +1,39 @@
+<?php
+
+class TimeAdjustTest extends MediaWikiLangTestCase {
+ protected function setUp() {
+ parent::setUp();
+ }
+
+ /**
+ * Test offset usage for a given Language::userAdjust
+ * @dataProvider dataUserAdjust
+ * @covers Language::userAdjust
+ */
+ public function testUserAdjust( $date, $localTZoffset, $expected ) {
+ global $wgContLang;
+
+ $this->setMwGlobals( 'wgLocalTZoffset', $localTZoffset );
+
+ $this->assertEquals(
+ $expected,
+ strval( $wgContLang->userAdjust( $date, '' ) ),
+ "User adjust {$date} by {$localTZoffset} minutes should give {$expected}"
+ );
+ }
+
+ public static function dataUserAdjust() {
+ return [
+ [ '20061231235959', 0, '20061231235959' ],
+ [ '20061231235959', 5, '20070101000459' ],
+ [ '20061231235959', 15, '20070101001459' ],
+ [ '20061231235959', 60, '20070101005959' ],
+ [ '20061231235959', 90, '20070101012959' ],
+ [ '20061231235959', 120, '20070101015959' ],
+ [ '20061231235959', 540, '20070101085959' ],
+ [ '20061231235959', -5, '20061231235459' ],
+ [ '20061231235959', -30, '20061231232959' ],
+ [ '20061231235959', -60, '20061231225959' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/TitleArrayFromResultTest.php b/www/wiki/tests/phpunit/includes/TitleArrayFromResultTest.php
new file mode 100644
index 00000000..af49ecf7
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/TitleArrayFromResultTest.php
@@ -0,0 +1,121 @@
+<?php
+
+/**
+ * @author Addshore
+ * @covers TitleArrayFromResult
+ */
+class TitleArrayFromResultTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ private function getMockResultWrapper( $row = null, $numRows = 1 ) {
+ $resultWrapper = $this->getMockBuilder( Wikimedia\Rdbms\ResultWrapper::class )
+ ->disableOriginalConstructor();
+
+ $resultWrapper = $resultWrapper->getMock();
+ $resultWrapper->expects( $this->atLeastOnce() )
+ ->method( 'current' )
+ ->will( $this->returnValue( $row ) );
+ $resultWrapper->expects( $this->any() )
+ ->method( 'numRows' )
+ ->will( $this->returnValue( $numRows ) );
+
+ return $resultWrapper;
+ }
+
+ private function getRowWithTitle( $namespace = 3, $title = 'foo' ) {
+ $row = new stdClass();
+ $row->page_namespace = $namespace;
+ $row->page_title = $title;
+ return $row;
+ }
+
+ private function getTitleArrayFromResult( $resultWrapper ) {
+ return new TitleArrayFromResult( $resultWrapper );
+ }
+
+ /**
+ * @covers TitleArrayFromResult::__construct
+ */
+ public function testConstructionWithFalseRow() {
+ $row = false;
+ $resultWrapper = $this->getMockResultWrapper( $row );
+
+ $object = $this->getTitleArrayFromResult( $resultWrapper );
+
+ $this->assertEquals( $resultWrapper, $object->res );
+ $this->assertSame( 0, $object->key );
+ $this->assertEquals( $row, $object->current );
+ }
+
+ /**
+ * @covers TitleArrayFromResult::__construct
+ */
+ public function testConstructionWithRow() {
+ $namespace = 0;
+ $title = 'foo';
+ $row = $this->getRowWithTitle( $namespace, $title );
+ $resultWrapper = $this->getMockResultWrapper( $row );
+
+ $object = $this->getTitleArrayFromResult( $resultWrapper );
+
+ $this->assertEquals( $resultWrapper, $object->res );
+ $this->assertSame( 0, $object->key );
+ $this->assertInstanceOf( Title::class, $object->current );
+ $this->assertEquals( $namespace, $object->current->mNamespace );
+ $this->assertEquals( $title, $object->current->mTextform );
+ }
+
+ public static function provideNumberOfRows() {
+ return [
+ [ 0 ],
+ [ 1 ],
+ [ 122 ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideNumberOfRows
+ * @covers TitleArrayFromResult::count
+ */
+ public function testCountWithVaryingValues( $numRows ) {
+ $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper(
+ $this->getRowWithTitle(),
+ $numRows
+ ) );
+ $this->assertEquals( $numRows, $object->count() );
+ }
+
+ /**
+ * @covers TitleArrayFromResult::current
+ */
+ public function testCurrentAfterConstruction() {
+ $namespace = 0;
+ $title = 'foo';
+ $row = $this->getRowWithTitle( $namespace, $title );
+ $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( $row ) );
+ $this->assertInstanceOf( Title::class, $object->current() );
+ $this->assertEquals( $namespace, $object->current->mNamespace );
+ $this->assertEquals( $title, $object->current->mTextform );
+ }
+
+ public function provideTestValid() {
+ return [
+ [ $this->getRowWithTitle(), true ],
+ [ false, false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideTestValid
+ * @covers TitleArrayFromResult::valid
+ */
+ public function testValid( $input, $expected ) {
+ $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( $input ) );
+ $this->assertEquals( $expected, $object->valid() );
+ }
+
+ // @todo unit test for key()
+ // @todo unit test for next()
+ // @todo unit test for rewind()
+}
diff --git a/www/wiki/tests/phpunit/includes/TitleMethodsTest.php b/www/wiki/tests/phpunit/includes/TitleMethodsTest.php
new file mode 100644
index 00000000..4032b3a1
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/TitleMethodsTest.php
@@ -0,0 +1,367 @@
+<?php
+
+/**
+ * @group ContentHandler
+ * @group Database
+ *
+ * @note We don't make assumptions about the main namespace.
+ * But we do expect the Help namespace to contain Wikitext.
+ */
+class TitleMethodsTest extends MediaWikiLangTestCase {
+
+ protected function setUp() {
+ global $wgContLang;
+
+ parent::setUp();
+
+ $this->mergeMwGlobalArrayValue(
+ 'wgExtraNamespaces',
+ [
+ 12302 => 'TEST-JS',
+ 12303 => 'TEST-JS_TALK',
+ ]
+ );
+
+ $this->mergeMwGlobalArrayValue(
+ 'wgNamespaceContentModels',
+ [
+ 12302 => CONTENT_MODEL_JAVASCRIPT,
+ ]
+ );
+
+ MWNamespace::clearCaches();
+ $wgContLang->resetNamespaces(); # reset namespace cache
+ }
+
+ protected function tearDown() {
+ global $wgContLang;
+
+ parent::tearDown();
+
+ MWNamespace::clearCaches();
+ $wgContLang->resetNamespaces(); # reset namespace cache
+ }
+
+ public static function provideEquals() {
+ return [
+ [ 'Main Page', 'Main Page', true ],
+ [ 'Main Page', 'Not The Main Page', false ],
+ [ 'Main Page', 'Project:Main Page', false ],
+ [ 'File:Example.png', 'Image:Example.png', true ],
+ [ 'Special:Version', 'Special:Version', true ],
+ [ 'Special:Version', 'Special:Recentchanges', false ],
+ [ 'Special:Version', 'Main Page', false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideEquals
+ * @covers Title::equals
+ */
+ public function testEquals( $titleA, $titleB, $expectedBool ) {
+ $titleA = Title::newFromText( $titleA );
+ $titleB = Title::newFromText( $titleB );
+
+ $this->assertEquals( $expectedBool, $titleA->equals( $titleB ) );
+ $this->assertEquals( $expectedBool, $titleB->equals( $titleA ) );
+ }
+
+ public static function provideInNamespace() {
+ return [
+ [ 'Main Page', NS_MAIN, true ],
+ [ 'Main Page', NS_TALK, false ],
+ [ 'Main Page', NS_USER, false ],
+ [ 'User:Foo', NS_USER, true ],
+ [ 'User:Foo', NS_USER_TALK, false ],
+ [ 'User:Foo', NS_TEMPLATE, false ],
+ [ 'User_talk:Foo', NS_USER_TALK, true ],
+ [ 'User_talk:Foo', NS_USER, false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideInNamespace
+ * @covers Title::inNamespace
+ */
+ public function testInNamespace( $title, $ns, $expectedBool ) {
+ $title = Title::newFromText( $title );
+ $this->assertEquals( $expectedBool, $title->inNamespace( $ns ) );
+ }
+
+ /**
+ * @covers Title::inNamespaces
+ */
+ public function testInNamespaces() {
+ $mainpage = Title::newFromText( 'Main Page' );
+ $this->assertTrue( $mainpage->inNamespaces( NS_MAIN, NS_USER ) );
+ $this->assertTrue( $mainpage->inNamespaces( [ NS_MAIN, NS_USER ] ) );
+ $this->assertTrue( $mainpage->inNamespaces( [ NS_USER, NS_MAIN ] ) );
+ $this->assertFalse( $mainpage->inNamespaces( [ NS_PROJECT, NS_TEMPLATE ] ) );
+ }
+
+ public static function provideHasSubjectNamespace() {
+ return [
+ [ 'Main Page', NS_MAIN, true ],
+ [ 'Main Page', NS_TALK, true ],
+ [ 'Main Page', NS_USER, false ],
+ [ 'User:Foo', NS_USER, true ],
+ [ 'User:Foo', NS_USER_TALK, true ],
+ [ 'User:Foo', NS_TEMPLATE, false ],
+ [ 'User_talk:Foo', NS_USER_TALK, true ],
+ [ 'User_talk:Foo', NS_USER, true ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideHasSubjectNamespace
+ * @covers Title::hasSubjectNamespace
+ */
+ public function testHasSubjectNamespace( $title, $ns, $expectedBool ) {
+ $title = Title::newFromText( $title );
+ $this->assertEquals( $expectedBool, $title->hasSubjectNamespace( $ns ) );
+ }
+
+ public function dataGetContentModel() {
+ return [
+ [ 'Help:Foo', CONTENT_MODEL_WIKITEXT ],
+ [ 'Help:Foo.js', CONTENT_MODEL_WIKITEXT ],
+ [ 'Help:Foo/bar.js', CONTENT_MODEL_WIKITEXT ],
+ [ 'User:Foo', CONTENT_MODEL_WIKITEXT ],
+ [ 'User:Foo.js', CONTENT_MODEL_WIKITEXT ],
+ [ 'User:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ],
+ [ 'User:Foo/bar.css', CONTENT_MODEL_CSS ],
+ [ 'User talk:Foo/bar.css', CONTENT_MODEL_WIKITEXT ],
+ [ 'User:Foo/bar.js.xxx', CONTENT_MODEL_WIKITEXT ],
+ [ 'User:Foo/bar.xxx', CONTENT_MODEL_WIKITEXT ],
+ [ 'MediaWiki:Foo.js', CONTENT_MODEL_JAVASCRIPT ],
+ [ 'MediaWiki:Foo.css', CONTENT_MODEL_CSS ],
+ [ 'MediaWiki:Foo/bar.css', CONTENT_MODEL_CSS ],
+ [ 'MediaWiki:Foo.JS', CONTENT_MODEL_WIKITEXT ],
+ [ 'MediaWiki:Foo.CSS', CONTENT_MODEL_WIKITEXT ],
+ [ 'MediaWiki:Foo.css.xxx', CONTENT_MODEL_WIKITEXT ],
+ [ 'TEST-JS:Foo', CONTENT_MODEL_JAVASCRIPT ],
+ [ 'TEST-JS:Foo.js', CONTENT_MODEL_JAVASCRIPT ],
+ [ 'TEST-JS:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ],
+ [ 'TEST-JS_TALK:Foo.js', CONTENT_MODEL_WIKITEXT ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetContentModel
+ * @covers Title::getContentModel
+ */
+ public function testGetContentModel( $title, $expectedModelId ) {
+ $title = Title::newFromText( $title );
+ $this->assertEquals( $expectedModelId, $title->getContentModel() );
+ }
+
+ /**
+ * @dataProvider dataGetContentModel
+ * @covers Title::hasContentModel
+ */
+ public function testHasContentModel( $title, $expectedModelId ) {
+ $title = Title::newFromText( $title );
+ $this->assertTrue( $title->hasContentModel( $expectedModelId ) );
+ }
+
+ public static function provideIsSiteConfigPage() {
+ return [
+ [ 'Help:Foo', false ],
+ [ 'Help:Foo.js', false ],
+ [ 'Help:Foo/bar.js', false ],
+ [ 'User:Foo', false ],
+ [ 'User:Foo.js', false ],
+ [ 'User:Foo/bar.js', false ],
+ [ 'User:Foo/bar.json', false ],
+ [ 'User:Foo/bar.css', false ],
+ [ 'User:Foo/bar.JS', false ],
+ [ 'User:Foo/bar.JSON', false ],
+ [ 'User:Foo/bar.CSS', false ],
+ [ 'User talk:Foo/bar.css', false ],
+ [ 'User:Foo/bar.js.xxx', false ],
+ [ 'User:Foo/bar.xxx', false ],
+ [ 'MediaWiki:Foo.js', true ],
+ [ 'MediaWiki:Foo.json', true ],
+ [ 'MediaWiki:Foo.css', true ],
+ [ 'MediaWiki:Foo.JS', false ],
+ [ 'MediaWiki:Foo.JSON', false ],
+ [ 'MediaWiki:Foo.CSS', false ],
+ [ 'MediaWiki:Foo/bar.css', true ],
+ [ 'MediaWiki:Foo.css.xxx', false ],
+ [ 'TEST-JS:Foo', false ],
+ [ 'TEST-JS:Foo.js', false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsSiteConfigPage
+ * @covers Title::isSiteConfigPage
+ */
+ public function testSiteConfigPage( $title, $expectedBool ) {
+ $title = Title::newFromText( $title );
+ $this->assertEquals( $expectedBool, $title->isSiteConfigPage() );
+ }
+
+ public static function provideIsUserConfigPage() {
+ return [
+ [ 'Help:Foo', false ],
+ [ 'Help:Foo.js', false ],
+ [ 'Help:Foo/bar.js', false ],
+ [ 'User:Foo', false ],
+ [ 'User:Foo.js', false ],
+ [ 'User:Foo/bar.js', true ],
+ [ 'User:Foo/bar.JS', false ],
+ [ 'User:Foo/bar.json', true ],
+ [ 'User:Foo/bar.JSON', false ],
+ [ 'User:Foo/bar.css', true ],
+ [ 'User:Foo/bar.CSS', false ],
+ [ 'User talk:Foo/bar.css', false ],
+ [ 'User:Foo/bar.js.xxx', false ],
+ [ 'User:Foo/bar.xxx', false ],
+ [ 'MediaWiki:Foo.js', false ],
+ [ 'MediaWiki:Foo.json', false ],
+ [ 'MediaWiki:Foo.css', false ],
+ [ 'MediaWiki:Foo.JS', false ],
+ [ 'MediaWiki:Foo.JSON', false ],
+ [ 'MediaWiki:Foo.CSS', false ],
+ [ 'MediaWiki:Foo.css.xxx', false ],
+ [ 'TEST-JS:Foo', false ],
+ [ 'TEST-JS:Foo.js', false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsUserConfigPage
+ * @covers Title::isUserConfigPage
+ */
+ public function testIsUserConfigPage( $title, $expectedBool ) {
+ $title = Title::newFromText( $title );
+ $this->assertEquals( $expectedBool, $title->isUserConfigPage() );
+ }
+
+ public static function provideIsUserCssConfigPage() {
+ return [
+ [ 'Help:Foo', false ],
+ [ 'Help:Foo.css', false ],
+ [ 'User:Foo', false ],
+ [ 'User:Foo.js', false ],
+ [ 'User:Foo.json', false ],
+ [ 'User:Foo.css', false ],
+ [ 'User:Foo/bar.js', false ],
+ [ 'User:Foo/bar.json', false ],
+ [ 'User:Foo/bar.css', true ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsUserCssConfigPage
+ * @covers Title::isUserCssConfigPage
+ */
+ public function testIsUserCssConfigPage( $title, $expectedBool ) {
+ $title = Title::newFromText( $title );
+ $this->assertEquals( $expectedBool, $title->isUserCssConfigPage() );
+ }
+
+ public static function provideIsUserJsConfigPage() {
+ return [
+ [ 'Help:Foo', false ],
+ [ 'Help:Foo.css', false ],
+ [ 'User:Foo', false ],
+ [ 'User:Foo.js', false ],
+ [ 'User:Foo.json', false ],
+ [ 'User:Foo.css', false ],
+ [ 'User:Foo/bar.js', true ],
+ [ 'User:Foo/bar.json', false ],
+ [ 'User:Foo/bar.css', false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsUserJsConfigPage
+ * @covers Title::isUserJsConfigPage
+ */
+ public function testIsUserJsConfigPage( $title, $expectedBool ) {
+ $title = Title::newFromText( $title );
+ $this->assertEquals( $expectedBool, $title->isUserJsConfigPage() );
+ }
+
+ public static function provideIsWikitextPage() {
+ return [
+ [ 'Help:Foo', true ],
+ [ 'Help:Foo.js', true ],
+ [ 'Help:Foo/bar.js', true ],
+ [ 'User:Foo', true ],
+ [ 'User:Foo.js', true ],
+ [ 'User:Foo/bar.js', false ],
+ [ 'User:Foo/bar.json', false ],
+ [ 'User:Foo/bar.css', false ],
+ [ 'User talk:Foo/bar.css', true ],
+ [ 'User:Foo/bar.js.xxx', true ],
+ [ 'User:Foo/bar.xxx', true ],
+ [ 'MediaWiki:Foo.js', false ],
+ [ 'User:Foo/bar.JS', true ],
+ [ 'User:Foo/bar.JSON', true ],
+ [ 'User:Foo/bar.CSS', true ],
+ [ 'MediaWiki:Foo.json', false ],
+ [ 'MediaWiki:Foo.css', false ],
+ [ 'MediaWiki:Foo.JS', true ],
+ [ 'MediaWiki:Foo.JSON', true ],
+ [ 'MediaWiki:Foo.CSS', true ],
+ [ 'MediaWiki:Foo.css.xxx', true ],
+ [ 'TEST-JS:Foo', false ],
+ [ 'TEST-JS:Foo.js', false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsWikitextPage
+ * @covers Title::isWikitextPage
+ */
+ public function testIsWikitextPage( $title, $expectedBool ) {
+ $title = Title::newFromText( $title );
+ $this->assertEquals( $expectedBool, $title->isWikitextPage() );
+ }
+
+ public static function provideGetOtherPage() {
+ return [
+ [ 'Main Page', 'Talk:Main Page' ],
+ [ 'Talk:Main Page', 'Main Page' ],
+ [ 'Help:Main Page', 'Help talk:Main Page' ],
+ [ 'Help talk:Main Page', 'Help:Main Page' ],
+ [ 'Special:FooBar', null ],
+ [ 'Media:File.jpg', null ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetOtherpage
+ * @covers Title::getOtherPage
+ *
+ * @param string $text
+ * @param string|null $expected
+ */
+ public function testGetOtherPage( $text, $expected ) {
+ if ( $expected === null ) {
+ $this->setExpectedException( MWException::class );
+ }
+
+ $title = Title::newFromText( $text );
+ $this->assertEquals( $expected, $title->getOtherPage()->getPrefixedText() );
+ }
+
+ /**
+ * @covers Title::clearCaches
+ */
+ public function testClearCaches() {
+ $linkCache = LinkCache::singleton();
+
+ $title1 = Title::newFromText( 'Foo' );
+ $linkCache->addGoodLinkObj( 23, $title1 );
+
+ Title::clearCaches();
+
+ $title2 = Title::newFromText( 'Foo' );
+ $this->assertNotSame( $title1, $title2, 'title cache should be empty' );
+ $this->assertEquals( 0, $linkCache->getGoodLinkID( 'Foo' ), 'link cache should be empty' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/TitlePermissionTest.php b/www/wiki/tests/phpunit/includes/TitlePermissionTest.php
new file mode 100644
index 00000000..6600aa23
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/TitlePermissionTest.php
@@ -0,0 +1,928 @@
+<?php
+
+/**
+ * @group Database
+ *
+ * @covers Title::getUserPermissionsErrors
+ * @covers Title::getUserPermissionsErrorsInternal
+ */
+class TitlePermissionTest extends MediaWikiLangTestCase {
+
+ /**
+ * @var string
+ */
+ protected $userName, $altUserName;
+
+ /**
+ * @var Title
+ */
+ protected $title;
+
+ /**
+ * @var User
+ */
+ protected $user, $anonUser, $userUser, $altUser;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $localZone = 'UTC';
+ $localOffset = date( 'Z' ) / 60;
+
+ $this->setMwGlobals( [
+ 'wgLocaltimezone' => $localZone,
+ 'wgLocalTZoffset' => $localOffset,
+ 'wgNamespaceProtection' => [
+ NS_MEDIAWIKI => 'editinterface',
+ ],
+ ] );
+ // Without this testUserBlock will use a non-English context on non-English MediaWiki
+ // installations (because of how Title::checkUserBlock is implemented) and fail.
+ RequestContext::resetMain();
+
+ $this->userName = 'Useruser';
+ $this->altUserName = 'Altuseruser';
+ date_default_timezone_set( $localZone );
+
+ $this->title = Title::makeTitle( NS_MAIN, "Main Page" );
+ if ( !isset( $this->userUser ) || !( $this->userUser instanceof User ) ) {
+ $this->userUser = User::newFromName( $this->userName );
+
+ if ( !$this->userUser->getId() ) {
+ $this->userUser = User::createNew( $this->userName, [
+ "email" => "test@example.com",
+ "real_name" => "Test User" ] );
+ $this->userUser->load();
+ }
+
+ $this->altUser = User::newFromName( $this->altUserName );
+ if ( !$this->altUser->getId() ) {
+ $this->altUser = User::createNew( $this->altUserName, [
+ "email" => "alttest@example.com",
+ "real_name" => "Test User Alt" ] );
+ $this->altUser->load();
+ }
+
+ $this->anonUser = User::newFromId( 0 );
+
+ $this->user = $this->userUser;
+ }
+ }
+
+ protected function setUserPerm( $perm ) {
+ // Setting member variables is evil!!!
+
+ if ( is_array( $perm ) ) {
+ $this->user->mRights = $perm;
+ } else {
+ $this->user->mRights = [ $perm ];
+ }
+ }
+
+ protected function setTitle( $ns, $title = "Main_Page" ) {
+ $this->title = Title::makeTitle( $ns, $title );
+ }
+
+ protected function setUser( $userName = null ) {
+ if ( $userName === 'anon' ) {
+ $this->user = $this->anonUser;
+ } elseif ( $userName === null || $userName === $this->userName ) {
+ $this->user = $this->userUser;
+ } else {
+ $this->user = $this->altUser;
+ }
+ }
+
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkQuickPermissions
+ */
+ public function testQuickPermissions() {
+ global $wgContLang;
+ $prefix = $wgContLang->getFormattedNsText( NS_PROJECT );
+
+ $this->setUser( 'anon' );
+ $this->setTitle( NS_TALK );
+ $this->setUserPerm( "createtalk" );
+ $res = $this->title->getUserPermissionsErrors( 'create', $this->user );
+ $this->assertEquals( [], $res );
+
+ $this->setTitle( NS_TALK );
+ $this->setUserPerm( "createpage" );
+ $res = $this->title->getUserPermissionsErrors( 'create', $this->user );
+ $this->assertEquals( [ [ "nocreatetext" ] ], $res );
+
+ $this->setTitle( NS_TALK );
+ $this->setUserPerm( "" );
+ $res = $this->title->getUserPermissionsErrors( 'create', $this->user );
+ $this->assertEquals( [ [ 'nocreatetext' ] ], $res );
+
+ $this->setTitle( NS_MAIN );
+ $this->setUserPerm( "createpage" );
+ $res = $this->title->getUserPermissionsErrors( 'create', $this->user );
+ $this->assertEquals( [], $res );
+
+ $this->setTitle( NS_MAIN );
+ $this->setUserPerm( "createtalk" );
+ $res = $this->title->getUserPermissionsErrors( 'create', $this->user );
+ $this->assertEquals( [ [ 'nocreatetext' ] ], $res );
+
+ $this->setUser( $this->userName );
+ $this->setTitle( NS_TALK );
+ $this->setUserPerm( "createtalk" );
+ $res = $this->title->getUserPermissionsErrors( 'create', $this->user );
+ $this->assertEquals( [], $res );
+
+ $this->setTitle( NS_TALK );
+ $this->setUserPerm( "createpage" );
+ $res = $this->title->getUserPermissionsErrors( 'create', $this->user );
+ $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res );
+
+ $this->setTitle( NS_TALK );
+ $this->setUserPerm( "" );
+ $res = $this->title->getUserPermissionsErrors( 'create', $this->user );
+ $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res );
+
+ $this->setTitle( NS_MAIN );
+ $this->setUserPerm( "createpage" );
+ $res = $this->title->getUserPermissionsErrors( 'create', $this->user );
+ $this->assertEquals( [], $res );
+
+ $this->setTitle( NS_MAIN );
+ $this->setUserPerm( "createtalk" );
+ $res = $this->title->getUserPermissionsErrors( 'create', $this->user );
+ $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res );
+
+ $this->setTitle( NS_MAIN );
+ $this->setUserPerm( "" );
+ $res = $this->title->getUserPermissionsErrors( 'create', $this->user );
+ $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res );
+
+ $this->setUser( 'anon' );
+ $this->setTitle( NS_USER, $this->userName . '' );
+ $this->setUserPerm( "" );
+ $res = $this->title->getUserPermissionsErrors( 'move', $this->user );
+ $this->assertEquals( [ [ 'cant-move-user-page' ], [ 'movenologintext' ] ], $res );
+
+ $this->setTitle( NS_USER, $this->userName . '/subpage' );
+ $this->setUserPerm( "" );
+ $res = $this->title->getUserPermissionsErrors( 'move', $this->user );
+ $this->assertEquals( [ [ 'movenologintext' ] ], $res );
+
+ $this->setTitle( NS_USER, $this->userName . '' );
+ $this->setUserPerm( "move-rootuserpages" );
+ $res = $this->title->getUserPermissionsErrors( 'move', $this->user );
+ $this->assertEquals( [ [ 'movenologintext' ] ], $res );
+
+ $this->setTitle( NS_USER, $this->userName . '/subpage' );
+ $this->setUserPerm( "move-rootuserpages" );
+ $res = $this->title->getUserPermissionsErrors( 'move', $this->user );
+ $this->assertEquals( [ [ 'movenologintext' ] ], $res );
+
+ $this->setTitle( NS_USER, $this->userName . '' );
+ $this->setUserPerm( "" );
+ $res = $this->title->getUserPermissionsErrors( 'move', $this->user );
+ $this->assertEquals( [ [ 'cant-move-user-page' ], [ 'movenologintext' ] ], $res );
+
+ $this->setTitle( NS_USER, $this->userName . '/subpage' );
+ $this->setUserPerm( "" );
+ $res = $this->title->getUserPermissionsErrors( 'move', $this->user );
+ $this->assertEquals( [ [ 'movenologintext' ] ], $res );
+
+ $this->setTitle( NS_USER, $this->userName . '' );
+ $this->setUserPerm( "move-rootuserpages" );
+ $res = $this->title->getUserPermissionsErrors( 'move', $this->user );
+ $this->assertEquals( [ [ 'movenologintext' ] ], $res );
+
+ $this->setTitle( NS_USER, $this->userName . '/subpage' );
+ $this->setUserPerm( "move-rootuserpages" );
+ $res = $this->title->getUserPermissionsErrors( 'move', $this->user );
+ $this->assertEquals( [ [ 'movenologintext' ] ], $res );
+
+ $this->setUser( $this->userName );
+ $this->setTitle( NS_FILE, "img.png" );
+ $this->setUserPerm( "" );
+ $res = $this->title->getUserPermissionsErrors( 'move', $this->user );
+ $this->assertEquals( [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ], $res );
+
+ $this->setTitle( NS_FILE, "img.png" );
+ $this->setUserPerm( "movefile" );
+ $res = $this->title->getUserPermissionsErrors( 'move', $this->user );
+ $this->assertEquals( [ [ 'movenotallowed' ] ], $res );
+
+ $this->setUser( 'anon' );
+ $this->setTitle( NS_FILE, "img.png" );
+ $this->setUserPerm( "" );
+ $res = $this->title->getUserPermissionsErrors( 'move', $this->user );
+ $this->assertEquals( [ [ 'movenotallowedfile' ], [ 'movenologintext' ] ], $res );
+
+ $this->setTitle( NS_FILE, "img.png" );
+ $this->setUserPerm( "movefile" );
+ $res = $this->title->getUserPermissionsErrors( 'move', $this->user );
+ $this->assertEquals( [ [ 'movenologintext' ] ], $res );
+
+ $this->setUser( $this->userName );
+ $this->setUserPerm( "move" );
+ $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ] ] );
+
+ $this->setUserPerm( "" );
+ $this->runGroupPermissions(
+ 'move',
+ [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ]
+ );
+
+ $this->setUser( 'anon' );
+ $this->setUserPerm( "move" );
+ $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ] ] );
+
+ $this->setUserPerm( "" );
+ $this->runGroupPermissions(
+ 'move',
+ [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ],
+ [ [ 'movenotallowedfile' ], [ 'movenologintext' ] ]
+ );
+
+ if ( $this->isWikitextNS( NS_MAIN ) ) {
+ // NOTE: some content models don't allow moving
+ // @todo find a Wikitext namespace for testing
+
+ $this->setTitle( NS_MAIN );
+ $this->setUser( 'anon' );
+ $this->setUserPerm( "move" );
+ $this->runGroupPermissions( 'move', [] );
+
+ $this->setUserPerm( "" );
+ $this->runGroupPermissions( 'move', [ [ 'movenotallowed' ] ],
+ [ [ 'movenologintext' ] ] );
+
+ $this->setUser( $this->userName );
+ $this->setUserPerm( "" );
+ $this->runGroupPermissions( 'move', [ [ 'movenotallowed' ] ] );
+
+ $this->setUserPerm( "move" );
+ $this->runGroupPermissions( 'move', [] );
+
+ $this->setUser( 'anon' );
+ $this->setUserPerm( 'move' );
+ $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user );
+ $this->assertEquals( [], $res );
+
+ $this->setUserPerm( '' );
+ $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user );
+ $this->assertEquals( [ [ 'movenotallowed' ] ], $res );
+ }
+
+ $this->setTitle( NS_USER );
+ $this->setUser( $this->userName );
+ $this->setUserPerm( [ "move", "move-rootuserpages" ] );
+ $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user );
+ $this->assertEquals( [], $res );
+
+ $this->setUserPerm( "move" );
+ $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user );
+ $this->assertEquals( [ [ 'cant-move-to-user-page' ] ], $res );
+
+ $this->setUser( 'anon' );
+ $this->setUserPerm( [ "move", "move-rootuserpages" ] );
+ $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user );
+ $this->assertEquals( [], $res );
+
+ $this->setTitle( NS_USER, "User/subpage" );
+ $this->setUserPerm( [ "move", "move-rootuserpages" ] );
+ $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user );
+ $this->assertEquals( [], $res );
+
+ $this->setUserPerm( "move" );
+ $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user );
+ $this->assertEquals( [], $res );
+
+ $this->setUser( 'anon' );
+ $check = [
+ 'edit' => [
+ [ [ 'badaccess-groups', "*, [[$prefix:Users|Users]]", 2 ] ],
+ [ [ 'badaccess-group0' ] ],
+ [],
+ true
+ ],
+ 'protect' => [
+ [ [
+ 'badaccess-groups',
+ "[[$prefix:Administrators|Administrators]]", 1 ],
+ [ 'protect-cantedit'
+ ] ],
+ [ [ 'badaccess-group0' ], [ 'protect-cantedit' ] ],
+ [ [ 'protect-cantedit' ] ],
+ false
+ ],
+ '' => [ [], [], [], true ]
+ ];
+
+ foreach ( [ "edit", "protect", "" ] as $action ) {
+ $this->setUserPerm( null );
+ $this->assertEquals( $check[$action][0],
+ $this->title->getUserPermissionsErrors( $action, $this->user, true ) );
+ $this->assertEquals( $check[$action][0],
+ $this->title->getUserPermissionsErrors( $action, $this->user, 'full' ) );
+ $this->assertEquals( $check[$action][0],
+ $this->title->getUserPermissionsErrors( $action, $this->user, 'secure' ) );
+
+ global $wgGroupPermissions;
+ $old = $wgGroupPermissions;
+ $wgGroupPermissions = [];
+
+ $this->assertEquals( $check[$action][1],
+ $this->title->getUserPermissionsErrors( $action, $this->user, true ) );
+ $this->assertEquals( $check[$action][1],
+ $this->title->getUserPermissionsErrors( $action, $this->user, 'full' ) );
+ $this->assertEquals( $check[$action][1],
+ $this->title->getUserPermissionsErrors( $action, $this->user, 'secure' ) );
+ $wgGroupPermissions = $old;
+
+ $this->setUserPerm( $action );
+ $this->assertEquals( $check[$action][2],
+ $this->title->getUserPermissionsErrors( $action, $this->user, true ) );
+ $this->assertEquals( $check[$action][2],
+ $this->title->getUserPermissionsErrors( $action, $this->user, 'full' ) );
+ $this->assertEquals( $check[$action][2],
+ $this->title->getUserPermissionsErrors( $action, $this->user, 'secure' ) );
+
+ $this->setUserPerm( $action );
+ $this->assertEquals( $check[$action][3],
+ $this->title->userCan( $action, $this->user, true ) );
+ $this->assertEquals( $check[$action][3],
+ $this->title->quickUserCan( $action, $this->user ) );
+ # count( User::getGroupsWithPermissions( $action ) ) < 1
+ }
+ }
+
+ protected function runGroupPermissions( $action, $result, $result2 = null ) {
+ global $wgGroupPermissions;
+
+ if ( $result2 === null ) {
+ $result2 = $result;
+ }
+
+ $wgGroupPermissions['autoconfirmed']['move'] = false;
+ $wgGroupPermissions['user']['move'] = false;
+ $res = $this->title->getUserPermissionsErrors( $action, $this->user );
+ $this->assertEquals( $result, $res );
+
+ $wgGroupPermissions['autoconfirmed']['move'] = true;
+ $wgGroupPermissions['user']['move'] = false;
+ $res = $this->title->getUserPermissionsErrors( $action, $this->user );
+ $this->assertEquals( $result2, $res );
+
+ $wgGroupPermissions['autoconfirmed']['move'] = true;
+ $wgGroupPermissions['user']['move'] = true;
+ $res = $this->title->getUserPermissionsErrors( $action, $this->user );
+ $this->assertEquals( $result2, $res );
+
+ $wgGroupPermissions['autoconfirmed']['move'] = false;
+ $wgGroupPermissions['user']['move'] = true;
+ $res = $this->title->getUserPermissionsErrors( $action, $this->user );
+ $this->assertEquals( $result2, $res );
+ }
+
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkSpecialsAndNSPermissions
+ */
+ public function testSpecialsAndNSPermissions() {
+ global $wgNamespaceProtection;
+ $this->setUser( $this->userName );
+
+ $this->setTitle( NS_SPECIAL );
+
+ $this->assertEquals( [ [ 'badaccess-group0' ], [ 'ns-specialprotected' ] ],
+ $this->title->getUserPermissionsErrors( 'bogus', $this->user ) );
+
+ $this->setTitle( NS_MAIN );
+ $this->setUserPerm( 'bogus' );
+ $this->assertEquals( [],
+ $this->title->getUserPermissionsErrors( 'bogus', $this->user ) );
+
+ $this->setTitle( NS_MAIN );
+ $this->setUserPerm( '' );
+ $this->assertEquals( [ [ 'badaccess-group0' ] ],
+ $this->title->getUserPermissionsErrors( 'bogus', $this->user ) );
+
+ $wgNamespaceProtection[NS_USER] = [ 'bogus' ];
+
+ $this->setTitle( NS_USER );
+ $this->setUserPerm( '' );
+ $this->assertEquals( [ [ 'badaccess-group0' ],
+ [ 'namespaceprotected', 'User', 'bogus' ] ],
+ $this->title->getUserPermissionsErrors( 'bogus', $this->user ) );
+
+ $this->setTitle( NS_MEDIAWIKI );
+ $this->setUserPerm( 'bogus' );
+ $this->assertEquals( [ [ 'protectedinterface', 'bogus' ] ],
+ $this->title->getUserPermissionsErrors( 'bogus', $this->user ) );
+
+ $this->setTitle( NS_MEDIAWIKI );
+ $this->setUserPerm( 'bogus' );
+ $this->assertEquals( [ [ 'protectedinterface', 'bogus' ] ],
+ $this->title->getUserPermissionsErrors( 'bogus', $this->user ) );
+
+ $wgNamespaceProtection = null;
+
+ $this->setUserPerm( 'bogus' );
+ $this->assertEquals( [],
+ $this->title->getUserPermissionsErrors( 'bogus', $this->user ) );
+ $this->assertEquals( true,
+ $this->title->userCan( 'bogus', $this->user ) );
+
+ $this->setUserPerm( '' );
+ $this->assertEquals( [ [ 'badaccess-group0' ] ],
+ $this->title->getUserPermissionsErrors( 'bogus', $this->user ) );
+ $this->assertEquals( false,
+ $this->title->userCan( 'bogus', $this->user ) );
+ }
+
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkUserConfigPermissions
+ */
+ public function testJsConfigEditPermissions() {
+ $this->setUser( $this->userName );
+
+ $this->setTitle( NS_USER, $this->userName . '/test.js' );
+ $this->runConfigEditPermissions(
+ [ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ] ]
+ );
+ }
+
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkUserConfigPermissions
+ */
+ public function testJsonConfigEditPermissions() {
+ $this->setUser( $this->userName );
+
+ $this->setTitle( NS_USER, $this->userName . '/test.json' );
+ $this->runConfigEditPermissions(
+ [ [ 'badaccess-group0' ], [ 'mycustomjsonprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'mycustomjsonprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomjsonprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'mycustomjsonprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomjsonprotected', 'bogus' ] ]
+ );
+ }
+
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkUserConfigPermissions
+ */
+ public function testCssConfigEditPermissions() {
+ $this->setUser( $this->userName );
+
+ $this->setTitle( NS_USER, $this->userName . '/test.css' );
+ $this->runConfigEditPermissions(
+ [ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ]
+ );
+ }
+
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkUserConfigPermissions
+ */
+ public function testOtherJsConfigEditPermissions() {
+ $this->setUser( $this->userName );
+
+ $this->setTitle( NS_USER, $this->altUserName . '/test.js' );
+ $this->runConfigEditPermissions(
+ [ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ] ]
+ );
+ }
+
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkUserConfigPermissions
+ */
+ public function testOtherJsonConfigEditPermissions() {
+ $this->setUser( $this->userName );
+
+ $this->setTitle( NS_USER, $this->altUserName . '/test.json' );
+ $this->runConfigEditPermissions(
+ [ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ]
+ );
+ }
+
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkUserConfigPermissions
+ */
+ public function testOtherCssConfigEditPermissions() {
+ $this->setUser( $this->userName );
+
+ $this->setTitle( NS_USER, $this->altUserName . '/test.css' );
+ $this->runConfigEditPermissions(
+ [ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ]
+ );
+ }
+
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkUserConfigPermissions
+ */
+ public function testOtherNonConfigEditPermissions() {
+ $this->setUser( $this->userName );
+
+ $this->setTitle( NS_USER, $this->altUserName . '/tempo' );
+ $this->runConfigEditPermissions(
+ [ [ 'badaccess-group0' ] ],
+
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ] ],
+
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ] ]
+ );
+ }
+
+ protected function runConfigEditPermissions(
+ $resultNone,
+ $resultMyCss,
+ $resultMyJson,
+ $resultMyJs,
+ $resultUserCss,
+ $resultUserJson,
+ $resultUserJs
+ ) {
+ $this->setUserPerm( '' );
+ $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
+ $this->assertEquals( $resultNone, $result );
+
+ $this->setUserPerm( 'editmyusercss' );
+ $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
+ $this->assertEquals( $resultMyCss, $result );
+
+ $this->setUserPerm( 'editmyuserjson' );
+ $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
+ $this->assertEquals( $resultMyJson, $result );
+
+ $this->setUserPerm( 'editmyuserjs' );
+ $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
+ $this->assertEquals( $resultMyJs, $result );
+
+ $this->setUserPerm( 'editusercss' );
+ $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
+ $this->assertEquals( $resultUserCss, $result );
+
+ $this->setUserPerm( 'edituserjson' );
+ $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
+ $this->assertEquals( $resultUserJson, $result );
+
+ $this->setUserPerm( 'edituserjs' );
+ $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
+ $this->assertEquals( $resultUserJs, $result );
+
+ $this->setUserPerm( [ 'edituserjs', 'edituserjson', 'editusercss' ] );
+ $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
+ $this->assertEquals( [ [ 'badaccess-group0' ] ], $result );
+ }
+
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkPageRestrictions
+ */
+ public function testPageRestrictions() {
+ global $wgContLang;
+
+ $prefix = $wgContLang->getFormattedNsText( NS_PROJECT );
+
+ $this->setTitle( NS_MAIN );
+ $this->title->mRestrictionsLoaded = true;
+ $this->setUserPerm( "edit" );
+ $this->title->mRestrictions = [ "bogus" => [ 'bogus', "sysop", "protect", "" ] ];
+
+ $this->assertEquals( [],
+ $this->title->getUserPermissionsErrors( 'edit',
+ $this->user ) );
+
+ $this->assertEquals( true,
+ $this->title->quickUserCan( 'edit', $this->user ) );
+ $this->title->mRestrictions = [ "edit" => [ 'bogus', "sysop", "protect", "" ],
+ "bogus" => [ 'bogus', "sysop", "protect", "" ] ];
+
+ $this->assertEquals( [ [ 'badaccess-group0' ],
+ [ 'protectedpagetext', 'bogus', 'bogus' ],
+ [ 'protectedpagetext', 'editprotected', 'bogus' ],
+ [ 'protectedpagetext', 'protect', 'bogus' ] ],
+ $this->title->getUserPermissionsErrors( 'bogus',
+ $this->user ) );
+ $this->assertEquals( [ [ 'protectedpagetext', 'bogus', 'edit' ],
+ [ 'protectedpagetext', 'editprotected', 'edit' ],
+ [ 'protectedpagetext', 'protect', 'edit' ] ],
+ $this->title->getUserPermissionsErrors( 'edit',
+ $this->user ) );
+ $this->setUserPerm( "" );
+ $this->assertEquals( [ [ 'badaccess-group0' ],
+ [ 'protectedpagetext', 'bogus', 'bogus' ],
+ [ 'protectedpagetext', 'editprotected', 'bogus' ],
+ [ 'protectedpagetext', 'protect', 'bogus' ] ],
+ $this->title->getUserPermissionsErrors( 'bogus',
+ $this->user ) );
+ $this->assertEquals( [ [ 'badaccess-groups', "*, [[$prefix:Users|Users]]", 2 ],
+ [ 'protectedpagetext', 'bogus', 'edit' ],
+ [ 'protectedpagetext', 'editprotected', 'edit' ],
+ [ 'protectedpagetext', 'protect', 'edit' ] ],
+ $this->title->getUserPermissionsErrors( 'edit',
+ $this->user ) );
+ $this->setUserPerm( [ "edit", "editprotected" ] );
+ $this->assertEquals( [ [ 'badaccess-group0' ],
+ [ 'protectedpagetext', 'bogus', 'bogus' ],
+ [ 'protectedpagetext', 'protect', 'bogus' ] ],
+ $this->title->getUserPermissionsErrors( 'bogus',
+ $this->user ) );
+ $this->assertEquals( [
+ [ 'protectedpagetext', 'bogus', 'edit' ],
+ [ 'protectedpagetext', 'protect', 'edit' ] ],
+ $this->title->getUserPermissionsErrors( 'edit',
+ $this->user ) );
+
+ $this->title->mCascadeRestriction = true;
+ $this->setUserPerm( "edit" );
+ $this->assertEquals( false,
+ $this->title->quickUserCan( 'bogus', $this->user ) );
+ $this->assertEquals( false,
+ $this->title->quickUserCan( 'edit', $this->user ) );
+ $this->assertEquals( [ [ 'badaccess-group0' ],
+ [ 'protectedpagetext', 'bogus', 'bogus' ],
+ [ 'protectedpagetext', 'editprotected', 'bogus' ],
+ [ 'protectedpagetext', 'protect', 'bogus' ] ],
+ $this->title->getUserPermissionsErrors( 'bogus',
+ $this->user ) );
+ $this->assertEquals( [ [ 'protectedpagetext', 'bogus', 'edit' ],
+ [ 'protectedpagetext', 'editprotected', 'edit' ],
+ [ 'protectedpagetext', 'protect', 'edit' ] ],
+ $this->title->getUserPermissionsErrors( 'edit',
+ $this->user ) );
+
+ $this->setUserPerm( [ "edit", "editprotected" ] );
+ $this->assertEquals( false,
+ $this->title->quickUserCan( 'bogus', $this->user ) );
+ $this->assertEquals( false,
+ $this->title->quickUserCan( 'edit', $this->user ) );
+ $this->assertEquals( [ [ 'badaccess-group0' ],
+ [ 'protectedpagetext', 'bogus', 'bogus' ],
+ [ 'protectedpagetext', 'protect', 'bogus' ],
+ [ 'protectedpagetext', 'protect', 'bogus' ] ],
+ $this->title->getUserPermissionsErrors( 'bogus',
+ $this->user ) );
+ $this->assertEquals( [ [ 'protectedpagetext', 'bogus', 'edit' ],
+ [ 'protectedpagetext', 'protect', 'edit' ],
+ [ 'protectedpagetext', 'protect', 'edit' ] ],
+ $this->title->getUserPermissionsErrors( 'edit',
+ $this->user ) );
+ }
+
+ /**
+ * @covers Title::checkCascadingSourcesRestrictions
+ */
+ public function testCascadingSourcesRestrictions() {
+ $this->setTitle( NS_MAIN, "test page" );
+ $this->setUserPerm( [ "edit", "bogus" ] );
+
+ $this->title->mCascadeSources = [
+ Title::makeTitle( NS_MAIN, "Bogus" ),
+ Title::makeTitle( NS_MAIN, "UnBogus" )
+ ];
+ $this->title->mCascadingRestrictions = [
+ "bogus" => [ 'bogus', "sysop", "protect", "" ]
+ ];
+
+ $this->assertEquals( false,
+ $this->title->userCan( 'bogus', $this->user ) );
+ $this->assertEquals( [
+ [ "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ],
+ [ "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ],
+ [ "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ] ],
+ $this->title->getUserPermissionsErrors( 'bogus', $this->user ) );
+
+ $this->assertEquals( true,
+ $this->title->userCan( 'edit', $this->user ) );
+ $this->assertEquals( [],
+ $this->title->getUserPermissionsErrors( 'edit', $this->user ) );
+ }
+
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkActionPermissions
+ */
+ public function testActionPermissions() {
+ $this->setUserPerm( [ "createpage" ] );
+ $this->setTitle( NS_MAIN, "test page" );
+ $this->title->mTitleProtection['permission'] = '';
+ $this->title->mTitleProtection['user'] = $this->user->getId();
+ $this->title->mTitleProtection['expiry'] = 'infinity';
+ $this->title->mTitleProtection['reason'] = 'test';
+ $this->title->mCascadeRestriction = false;
+
+ $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ],
+ $this->title->getUserPermissionsErrors( 'create', $this->user ) );
+ $this->assertEquals( false,
+ $this->title->userCan( 'create', $this->user ) );
+
+ $this->title->mTitleProtection['permission'] = 'editprotected';
+ $this->setUserPerm( [ 'createpage', 'protect' ] );
+ $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ],
+ $this->title->getUserPermissionsErrors( 'create', $this->user ) );
+ $this->assertEquals( false,
+ $this->title->userCan( 'create', $this->user ) );
+
+ $this->setUserPerm( [ 'createpage', 'editprotected' ] );
+ $this->assertEquals( [],
+ $this->title->getUserPermissionsErrors( 'create', $this->user ) );
+ $this->assertEquals( true,
+ $this->title->userCan( 'create', $this->user ) );
+
+ $this->setUserPerm( [ 'createpage' ] );
+ $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ],
+ $this->title->getUserPermissionsErrors( 'create', $this->user ) );
+ $this->assertEquals( false,
+ $this->title->userCan( 'create', $this->user ) );
+
+ $this->setTitle( NS_MEDIA, "test page" );
+ $this->setUserPerm( [ "move" ] );
+ $this->assertEquals( false,
+ $this->title->userCan( 'move', $this->user ) );
+ $this->assertEquals( [ [ 'immobile-source-namespace', 'Media' ] ],
+ $this->title->getUserPermissionsErrors( 'move', $this->user ) );
+
+ $this->setTitle( NS_HELP, "test page" );
+ $this->assertEquals( [],
+ $this->title->getUserPermissionsErrors( 'move', $this->user ) );
+ $this->assertEquals( true,
+ $this->title->userCan( 'move', $this->user ) );
+
+ $this->title->mInterwiki = "no";
+ $this->assertEquals( [ [ 'immobile-source-page' ] ],
+ $this->title->getUserPermissionsErrors( 'move', $this->user ) );
+ $this->assertEquals( false,
+ $this->title->userCan( 'move', $this->user ) );
+
+ $this->setTitle( NS_MEDIA, "test page" );
+ $this->assertEquals( false,
+ $this->title->userCan( 'move-target', $this->user ) );
+ $this->assertEquals( [ [ 'immobile-target-namespace', 'Media' ] ],
+ $this->title->getUserPermissionsErrors( 'move-target', $this->user ) );
+
+ $this->setTitle( NS_HELP, "test page" );
+ $this->assertEquals( [],
+ $this->title->getUserPermissionsErrors( 'move-target', $this->user ) );
+ $this->assertEquals( true,
+ $this->title->userCan( 'move-target', $this->user ) );
+
+ $this->title->mInterwiki = "no";
+ $this->assertEquals( [ [ 'immobile-target-page' ] ],
+ $this->title->getUserPermissionsErrors( 'move-target', $this->user ) );
+ $this->assertEquals( false,
+ $this->title->userCan( 'move-target', $this->user ) );
+ }
+
+ /**
+ * @covers Title::checkUserBlock
+ */
+ public function testUserBlock() {
+ $this->setMwGlobals( [
+ 'wgEmailConfirmToEdit' => true,
+ 'wgEmailAuthentication' => true,
+ ] );
+
+ $this->setUserPerm( [ "createpage", "move" ] );
+ $this->setTitle( NS_HELP, "test page" );
+
+ # $wgEmailConfirmToEdit only applies to 'edit' action
+ $this->assertEquals( [],
+ $this->title->getUserPermissionsErrors( 'move-target', $this->user ) );
+ $this->assertContains( [ 'confirmedittext' ],
+ $this->title->getUserPermissionsErrors( 'edit', $this->user ) );
+
+ $this->setMwGlobals( 'wgEmailConfirmToEdit', false );
+ $this->assertNotContains( [ 'confirmedittext' ],
+ $this->title->getUserPermissionsErrors( 'edit', $this->user ) );
+
+ # $wgEmailConfirmToEdit && !$user->isEmailConfirmed() && $action != 'createaccount'
+ $this->assertEquals( [],
+ $this->title->getUserPermissionsErrors( 'move-target',
+ $this->user ) );
+
+ global $wgLang;
+ $prev = time();
+ $now = time() + 120;
+ $this->user->mBlockedby = $this->user->getId();
+ $this->user->mBlock = new Block( [
+ 'address' => '127.0.8.1',
+ 'by' => $this->user->getId(),
+ 'reason' => 'no reason given',
+ 'timestamp' => $prev + 3600,
+ 'auto' => true,
+ 'expiry' => 0
+ ] );
+ $this->user->mBlock->mTimestamp = 0;
+ $this->assertEquals( [ [ 'autoblockedtext',
+ '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1',
+ 'Useruser', null, 'infinite', '127.0.8.1',
+ $wgLang->timeanddate( wfTimestamp( TS_MW, $prev ), true ) ] ],
+ $this->title->getUserPermissionsErrors( 'move-target',
+ $this->user ) );
+
+ $this->assertEquals( false, $this->title->userCan( 'move-target', $this->user ) );
+ // quickUserCan should ignore user blocks
+ $this->assertEquals( true, $this->title->quickUserCan( 'move-target', $this->user ) );
+
+ global $wgLocalTZoffset;
+ $wgLocalTZoffset = -60;
+ $this->user->mBlockedby = $this->user->getName();
+ $this->user->mBlock = new Block( [
+ 'address' => '127.0.8.1',
+ 'by' => $this->user->getId(),
+ 'reason' => 'no reason given',
+ 'timestamp' => $now,
+ 'auto' => false,
+ 'expiry' => 10,
+ ] );
+ $this->assertEquals( [ [ 'blockedtext',
+ '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1',
+ 'Useruser', null, '23:00, 31 December 1969', '127.0.8.1',
+ $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ],
+ $this->title->getUserPermissionsErrors( 'move-target', $this->user ) );
+ # $action != 'read' && $action != 'createaccount' && $user->isBlockedFrom( $this )
+ # $user->blockedFor() == ''
+ # $user->mBlock->mExpiry == 'infinity'
+
+ $this->user->mBlockedby = $this->user->getName();
+ $this->user->mBlock = new Block( [
+ 'address' => '127.0.8.1',
+ 'by' => $this->user->getId(),
+ 'reason' => 'no reason given',
+ 'timestamp' => $now,
+ 'auto' => false,
+ 'expiry' => 10,
+ 'systemBlock' => 'test',
+ ] );
+ $this->assertEquals( [ [ 'systemblockedtext',
+ '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1',
+ 'Useruser', 'test', '23:00, 31 December 1969', '127.0.8.1',
+ $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ],
+ $this->title->getUserPermissionsErrors( 'move-target', $this->user ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/TitleTest.php b/www/wiki/tests/phpunit/includes/TitleTest.php
new file mode 100644
index 00000000..c81a0787
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/TitleTest.php
@@ -0,0 +1,968 @@
+<?php
+
+/**
+ * @group Database
+ * @group Title
+ */
+class TitleTest extends MediaWikiTestCase {
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgAllowUserJs' => false,
+ 'wgDefaultLanguageVariant' => false,
+ 'wgMetaNamespace' => 'Project',
+ ] );
+ $this->setUserLang( 'en' );
+ $this->setContentLang( 'en' );
+ }
+
+ /**
+ * @covers Title::legalChars
+ */
+ public function testLegalChars() {
+ $titlechars = Title::legalChars();
+
+ foreach ( range( 1, 255 ) as $num ) {
+ $chr = chr( $num );
+ if ( strpos( "#[]{}<>|", $chr ) !== false || preg_match( "/[\\x00-\\x1f\\x7f]/", $chr ) ) {
+ $this->assertFalse(
+ (bool)preg_match( "/[$titlechars]/", $chr ),
+ "chr($num) = $chr is not a valid titlechar"
+ );
+ } else {
+ $this->assertTrue(
+ (bool)preg_match( "/[$titlechars]/", $chr ),
+ "chr($num) = $chr is a valid titlechar"
+ );
+ }
+ }
+ }
+
+ public static function provideValidSecureAndSplit() {
+ return [
+ [ 'Sandbox' ],
+ [ 'A "B"' ],
+ [ 'A \'B\'' ],
+ [ '.com' ],
+ [ '~' ],
+ [ '#' ],
+ [ '"' ],
+ [ '\'' ],
+ [ 'Talk:Sandbox' ],
+ [ 'Talk:Foo:Sandbox' ],
+ [ 'File:Example.svg' ],
+ [ 'File_talk:Example.svg' ],
+ [ 'Foo/.../Sandbox' ],
+ [ 'Sandbox/...' ],
+ [ 'A~~' ],
+ [ ':A' ],
+ // Length is 256 total, but only title part matters
+ [ 'Category:' . str_repeat( 'x', 248 ) ],
+ [ str_repeat( 'x', 252 ) ],
+ // interwiki prefix
+ [ 'localtestiw: #anchor' ],
+ [ 'localtestiw:' ],
+ [ 'localtestiw:foo' ],
+ [ 'localtestiw: foo # anchor' ],
+ [ 'localtestiw: Talk: Sandbox # anchor' ],
+ [ 'remotetestiw:' ],
+ [ 'remotetestiw: Talk: # anchor' ],
+ [ 'remotetestiw: #bar' ],
+ [ 'remotetestiw: Talk:' ],
+ [ 'remotetestiw: Talk: Foo' ],
+ [ 'localtestiw:remotetestiw:' ],
+ [ 'localtestiw:remotetestiw:foo' ]
+ ];
+ }
+
+ public static function provideInvalidSecureAndSplit() {
+ return [
+ [ '', 'title-invalid-empty' ],
+ [ ':', 'title-invalid-empty' ],
+ [ '__ __', 'title-invalid-empty' ],
+ [ ' __ ', 'title-invalid-empty' ],
+ // Bad characters forbidden regardless of wgLegalTitleChars
+ [ 'A [ B', 'title-invalid-characters' ],
+ [ 'A ] B', 'title-invalid-characters' ],
+ [ 'A { B', 'title-invalid-characters' ],
+ [ 'A } B', 'title-invalid-characters' ],
+ [ 'A < B', 'title-invalid-characters' ],
+ [ 'A > B', 'title-invalid-characters' ],
+ [ 'A | B', 'title-invalid-characters' ],
+ [ "A \t B", 'title-invalid-characters' ],
+ [ "A \n B", 'title-invalid-characters' ],
+ // URL encoding
+ [ 'A%20B', 'title-invalid-characters' ],
+ [ 'A%23B', 'title-invalid-characters' ],
+ [ 'A%2523B', 'title-invalid-characters' ],
+ // XML/HTML character entity references
+ // Note: Commented out because they are not marked invalid by the PHP test as
+ // Title::newFromText runs Sanitizer::decodeCharReferencesAndNormalize first.
+ // 'A &eacute; B',
+ // 'A &#233; B',
+ // 'A &#x00E9; B',
+ // Subject of NS_TALK does not roundtrip to NS_MAIN
+ [ 'Talk:File:Example.svg', 'title-invalid-talk-namespace' ],
+ // Directory navigation
+ [ '.', 'title-invalid-relative' ],
+ [ '..', 'title-invalid-relative' ],
+ [ './Sandbox', 'title-invalid-relative' ],
+ [ '../Sandbox', 'title-invalid-relative' ],
+ [ 'Foo/./Sandbox', 'title-invalid-relative' ],
+ [ 'Foo/../Sandbox', 'title-invalid-relative' ],
+ [ 'Sandbox/.', 'title-invalid-relative' ],
+ [ 'Sandbox/..', 'title-invalid-relative' ],
+ // Tilde
+ [ 'A ~~~ Name', 'title-invalid-magic-tilde' ],
+ [ 'A ~~~~ Signature', 'title-invalid-magic-tilde' ],
+ [ 'A ~~~~~ Timestamp', 'title-invalid-magic-tilde' ],
+ // Length
+ [ str_repeat( 'x', 256 ), 'title-invalid-too-long' ],
+ // Namespace prefix without actual title
+ [ 'Talk:', 'title-invalid-empty' ],
+ [ 'Talk:#', 'title-invalid-empty' ],
+ [ 'Category: ', 'title-invalid-empty' ],
+ [ 'Category: #bar', 'title-invalid-empty' ],
+ // interwiki prefix
+ [ 'localtestiw: Talk: # anchor', 'title-invalid-empty' ],
+ [ 'localtestiw: Talk:', 'title-invalid-empty' ]
+ ];
+ }
+
+ private function secureAndSplitGlobals() {
+ $this->setMwGlobals( [
+ 'wgLocalInterwikis' => [ 'localtestiw' ],
+ 'wgHooks' => [
+ 'InterwikiLoadPrefix' => [
+ function ( $prefix, &$data ) {
+ if ( $prefix === 'localtestiw' ) {
+ $data = [ 'iw_url' => 'localtestiw' ];
+ } elseif ( $prefix === 'remotetestiw' ) {
+ $data = [ 'iw_url' => 'remotetestiw' ];
+ }
+ return false;
+ }
+ ]
+ ]
+ ] );
+
+ // Reset TitleParser since we modified $wgLocalInterwikis
+ $this->setService( 'TitleParser', new MediaWikiTitleCodec(
+ Language::factory( 'en' ),
+ new GenderCache(),
+ [ 'localtestiw' ]
+ ) );
+ }
+
+ /**
+ * See also mediawiki.Title.test.js
+ * @covers Title::secureAndSplit
+ * @dataProvider provideValidSecureAndSplit
+ * @note This mainly tests MediaWikiTitleCodec::parseTitle().
+ */
+ public function testSecureAndSplitValid( $text ) {
+ $this->secureAndSplitGlobals();
+ $this->assertInstanceOf( Title::class, Title::newFromText( $text ), "Valid: $text" );
+ }
+
+ /**
+ * See also mediawiki.Title.test.js
+ * @covers Title::secureAndSplit
+ * @dataProvider provideInvalidSecureAndSplit
+ * @note This mainly tests MediaWikiTitleCodec::parseTitle().
+ */
+ public function testSecureAndSplitInvalid( $text, $expectedErrorMessage ) {
+ $this->secureAndSplitGlobals();
+ try {
+ Title::newFromTextThrow( $text ); // should throw
+ $this->assertTrue( false, "Invalid: $text" );
+ } catch ( MalformedTitleException $ex ) {
+ $this->assertEquals( $expectedErrorMessage, $ex->getErrorMessage(), "Invalid: $text" );
+ }
+ }
+
+ public static function provideConvertByteClassToUnicodeClass() {
+ return [
+ [
+ ' %!"$&\'()*,\\-.\\/0-9:;=?@A-Z\\\\^_`a-z~\\x80-\\xFF+',
+ ' %!"$&\'()*,\\-./0-9:;=?@A-Z\\\\\\^_`a-z~+\\u0080-\\uFFFF',
+ ],
+ [
+ 'QWERTYf-\\xFF+',
+ 'QWERTYf-\\x7F+\\u0080-\\uFFFF',
+ ],
+ [
+ 'QWERTY\\x66-\\xFD+',
+ 'QWERTYf-\\x7F+\\u0080-\\uFFFF',
+ ],
+ [
+ 'QWERTYf-y+',
+ 'QWERTYf-y+',
+ ],
+ [
+ 'QWERTYf-\\x80+',
+ 'QWERTYf-\\x7F+\\u0080-\\uFFFF',
+ ],
+ [
+ 'QWERTY\\x66-\\x80+\\x23',
+ 'QWERTYf-\\x7F+#\\u0080-\\uFFFF',
+ ],
+ [
+ 'QWERTY\\x66-\\x80+\\xD3',
+ 'QWERTYf-\\x7F+\\u0080-\\uFFFF',
+ ],
+ [
+ '\\\\\\x99',
+ '\\\\\\u0080-\\uFFFF',
+ ],
+ [
+ '-\\x99',
+ '\\-\\u0080-\\uFFFF',
+ ],
+ [
+ 'QWERTY\\-\\x99',
+ 'QWERTY\\-\\u0080-\\uFFFF',
+ ],
+ [
+ '\\\\x99',
+ '\\\\x99',
+ ],
+ [
+ 'A-\\x9F',
+ 'A-\\x7F\\u0080-\\uFFFF',
+ ],
+ [
+ '\\x66-\\x77QWERTY\\x88-\\x91FXZ',
+ 'f-wQWERTYFXZ\\u0080-\\uFFFF',
+ ],
+ [
+ '\\x66-\\x99QWERTY\\xAA-\\xEEFXZ',
+ 'f-\\x7FQWERTYFXZ\\u0080-\\uFFFF',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideConvertByteClassToUnicodeClass
+ * @covers Title::convertByteClassToUnicodeClass
+ */
+ public function testConvertByteClassToUnicodeClass( $byteClass, $unicodeClass ) {
+ $this->assertEquals( $unicodeClass, Title::convertByteClassToUnicodeClass( $byteClass ) );
+ }
+
+ /**
+ * @dataProvider provideSpecialNamesWithAndWithoutParameter
+ * @covers Title::fixSpecialName
+ */
+ public function testFixSpecialNameRetainsParameter( $text, $expectedParam ) {
+ $title = Title::newFromText( $text );
+ $fixed = $title->fixSpecialName();
+ $stuff = explode( '/', $fixed->getDBkey(), 2 );
+ if ( count( $stuff ) == 2 ) {
+ $par = $stuff[1];
+ } else {
+ $par = null;
+ }
+ $this->assertEquals(
+ $expectedParam,
+ $par,
+ "T33100 regression check: Title->fixSpecialName() should preserve parameter"
+ );
+ }
+
+ public static function provideSpecialNamesWithAndWithoutParameter() {
+ return [
+ [ 'Special:Version', null ],
+ [ 'Special:Version/', '' ],
+ [ 'Special:Version/param', 'param' ],
+ ];
+ }
+
+ /**
+ * Auth-less test of Title::isValidMoveOperation
+ *
+ * @param string $source
+ * @param string $target
+ * @param array|string|bool $expected Required error
+ * @dataProvider provideTestIsValidMoveOperation
+ * @covers Title::isValidMoveOperation
+ * @covers Title::validateFileMoveOperation
+ */
+ public function testIsValidMoveOperation( $source, $target, $expected ) {
+ $this->setMwGlobals( 'wgContentHandlerUseDB', false );
+ $title = Title::newFromText( $source );
+ $nt = Title::newFromText( $target );
+ $errors = $title->isValidMoveOperation( $nt, false );
+ if ( $expected === true ) {
+ $this->assertTrue( $errors );
+ } else {
+ $errors = $this->flattenErrorsArray( $errors );
+ foreach ( (array)$expected as $error ) {
+ $this->assertContains( $error, $errors );
+ }
+ }
+ }
+
+ public static function provideTestIsValidMoveOperation() {
+ return [
+ // for Title::isValidMoveOperation
+ [ 'Some page', '', 'badtitletext' ],
+ [ 'Test', 'Test', 'selfmove' ],
+ [ 'Special:FooBar', 'Test', 'immobile-source-namespace' ],
+ [ 'Test', 'Special:FooBar', 'immobile-target-namespace' ],
+ [ 'MediaWiki:Common.js', 'Help:Some wikitext page', 'bad-target-model' ],
+ [ 'Page', 'File:Test.jpg', 'nonfile-cannot-move-to-file' ],
+ // for Title::validateFileMoveOperation
+ [ 'File:Test.jpg', 'Page', 'imagenocrossnamespace' ],
+ ];
+ }
+
+ /**
+ * Auth-less test of Title::userCan
+ *
+ * @param array $whitelistRegexp
+ * @param string $source
+ * @param string $action
+ * @param array|string|bool $expected Required error
+ *
+ * @covers Title::checkReadPermissions
+ * @dataProvider dataWgWhitelistReadRegexp
+ */
+ public function testWgWhitelistReadRegexp( $whitelistRegexp, $source, $action, $expected ) {
+ // $wgWhitelistReadRegexp must be an array. Since the provided test cases
+ // usually have only one regex, it is more concise to write the lonely regex
+ // as a string. Thus we cast to an array() to honor $wgWhitelistReadRegexp
+ // type requisite.
+ if ( is_string( $whitelistRegexp ) ) {
+ $whitelistRegexp = [ $whitelistRegexp ];
+ }
+
+ $this->setMwGlobals( [
+ // So User::isEveryoneAllowed( 'read' ) === false
+ 'wgGroupPermissions' => [ '*' => [ 'read' => false ] ],
+ 'wgWhitelistRead' => [ 'some random non sense title' ],
+ 'wgWhitelistReadRegexp' => $whitelistRegexp,
+ ] );
+
+ $title = Title::newFromDBkey( $source );
+
+ // New anonymous user with no rights
+ $user = new User;
+ $user->mRights = [];
+ $errors = $title->userCan( $action, $user );
+
+ if ( is_bool( $expected ) ) {
+ # Forge the assertion message depending on the assertion expectation
+ $allowableness = $expected
+ ? " should be allowed"
+ : " should NOT be allowed";
+ $this->assertEquals(
+ $expected,
+ $errors,
+ "User action '$action' on [[$source]] $allowableness."
+ );
+ } else {
+ $errors = $this->flattenErrorsArray( $errors );
+ foreach ( (array)$expected as $error ) {
+ $this->assertContains( $error, $errors );
+ }
+ }
+ }
+
+ /**
+ * Provides test parameter values for testWgWhitelistReadRegexp()
+ */
+ public function dataWgWhitelistReadRegexp() {
+ $ALLOWED = true;
+ $DISALLOWED = false;
+
+ return [
+ // Everything, if this doesn't work, we're really in trouble
+ [ '/.*/', 'Main_Page', 'read', $ALLOWED ],
+ [ '/.*/', 'Main_Page', 'edit', $DISALLOWED ],
+
+ // We validate against the title name, not the db key
+ [ '/^Main_Page$/', 'Main_Page', 'read', $DISALLOWED ],
+ // Main page
+ [ '/^Main/', 'Main_Page', 'read', $ALLOWED ],
+ [ '/^Main.*/', 'Main_Page', 'read', $ALLOWED ],
+ // With spaces
+ [ '/Mic\sCheck/', 'Mic Check', 'read', $ALLOWED ],
+ // Unicode multibyte
+ // ...without unicode modifier
+ [ '/Unicode Test . Yes/', 'Unicode Test Ñ Yes', 'read', $DISALLOWED ],
+ // ...with unicode modifier
+ [ '/Unicode Test . Yes/u', 'Unicode Test Ñ Yes', 'read', $ALLOWED ],
+ // Case insensitive
+ [ '/MiC ChEcK/', 'mic check', 'read', $DISALLOWED ],
+ [ '/MiC ChEcK/i', 'mic check', 'read', $ALLOWED ],
+
+ // From DefaultSettings.php:
+ [ "@^UsEr.*@i", 'User is banned', 'read', $ALLOWED ],
+ [ "@^UsEr.*@i", 'User:John Doe', 'read', $ALLOWED ],
+
+ // With namespaces:
+ [ '/^Special:NewPages$/', 'Special:NewPages', 'read', $ALLOWED ],
+ [ null, 'Special:Newpages', 'read', $DISALLOWED ],
+
+ ];
+ }
+
+ public function flattenErrorsArray( $errors ) {
+ $result = [];
+ foreach ( $errors as $error ) {
+ $result[] = $error[0];
+ }
+
+ return $result;
+ }
+
+ /**
+ * @dataProvider provideGetPageViewLanguage
+ * @covers Title::getPageViewLanguage
+ */
+ public function testGetPageViewLanguage( $expected, $titleText, $contLang,
+ $lang, $variant, $msg = ''
+ ) {
+ // Setup environnement for this test
+ $this->setMwGlobals( [
+ 'wgDefaultLanguageVariant' => $variant,
+ 'wgAllowUserJs' => true,
+ ] );
+ $this->setUserLang( $lang );
+ $this->setContentLang( $contLang );
+
+ $title = Title::newFromText( $titleText );
+ $this->assertInstanceOf( Title::class, $title,
+ "Test must be passed a valid title text, you gave '$titleText'"
+ );
+ $this->assertEquals( $expected,
+ $title->getPageViewLanguage()->getCode(),
+ $msg
+ );
+ }
+
+ public static function provideGetPageViewLanguage() {
+ # Format:
+ # - expected
+ # - Title name
+ # - wgContLang (expected in most case)
+ # - wgLang (on some specific pages)
+ # - wgDefaultLanguageVariant
+ # - Optional message
+ return [
+ [ 'fr', 'Help:I_need_somebody', 'fr', 'fr', false ],
+ [ 'es', 'Help:I_need_somebody', 'es', 'zh-tw', false ],
+ [ 'zh', 'Help:I_need_somebody', 'zh', 'zh-tw', false ],
+
+ [ 'es', 'Help:I_need_somebody', 'es', 'zh-tw', 'zh-cn' ],
+ [ 'es', 'MediaWiki:About', 'es', 'zh-tw', 'zh-cn' ],
+ [ 'es', 'MediaWiki:About/', 'es', 'zh-tw', 'zh-cn' ],
+ [ 'de', 'MediaWiki:About/de', 'es', 'zh-tw', 'zh-cn' ],
+ [ 'en', 'MediaWiki:Common.js', 'es', 'zh-tw', 'zh-cn' ],
+ [ 'en', 'MediaWiki:Common.css', 'es', 'zh-tw', 'zh-cn' ],
+ [ 'en', 'User:JohnDoe/Common.js', 'es', 'zh-tw', 'zh-cn' ],
+ [ 'en', 'User:JohnDoe/Monobook.css', 'es', 'zh-tw', 'zh-cn' ],
+
+ [ 'zh-cn', 'Help:I_need_somebody', 'zh', 'zh-tw', 'zh-cn' ],
+ [ 'zh', 'MediaWiki:About', 'zh', 'zh-tw', 'zh-cn' ],
+ [ 'zh', 'MediaWiki:About/', 'zh', 'zh-tw', 'zh-cn' ],
+ [ 'de', 'MediaWiki:About/de', 'zh', 'zh-tw', 'zh-cn' ],
+ [ 'zh-cn', 'MediaWiki:About/zh-cn', 'zh', 'zh-tw', 'zh-cn' ],
+ [ 'zh-tw', 'MediaWiki:About/zh-tw', 'zh', 'zh-tw', 'zh-cn' ],
+ [ 'en', 'MediaWiki:Common.js', 'zh', 'zh-tw', 'zh-cn' ],
+ [ 'en', 'MediaWiki:Common.css', 'zh', 'zh-tw', 'zh-cn' ],
+ [ 'en', 'User:JohnDoe/Common.js', 'zh', 'zh-tw', 'zh-cn' ],
+ [ 'en', 'User:JohnDoe/Monobook.css', 'zh', 'zh-tw', 'zh-cn' ],
+
+ [ 'zh-tw', 'Special:NewPages', 'es', 'zh-tw', 'zh-cn' ],
+ [ 'zh-tw', 'Special:NewPages', 'zh', 'zh-tw', 'zh-cn' ],
+
+ ];
+ }
+
+ /**
+ * @dataProvider provideBaseTitleCases
+ * @covers Title::getBaseText
+ */
+ public function testGetBaseText( $title, $expected, $msg = '' ) {
+ $title = Title::newFromText( $title );
+ $this->assertEquals( $expected,
+ $title->getBaseText(),
+ $msg
+ );
+ }
+
+ public static function provideBaseTitleCases() {
+ return [
+ # Title, expected base, optional message
+ [ 'User:John_Doe/subOne/subTwo', 'John Doe/subOne' ],
+ [ 'User:Foo/Bar/Baz', 'Foo/Bar' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideRootTitleCases
+ * @covers Title::getRootText
+ */
+ public function testGetRootText( $title, $expected, $msg = '' ) {
+ $title = Title::newFromText( $title );
+ $this->assertEquals( $expected,
+ $title->getRootText(),
+ $msg
+ );
+ }
+
+ public static function provideRootTitleCases() {
+ return [
+ # Title, expected base, optional message
+ [ 'User:John_Doe/subOne/subTwo', 'John Doe' ],
+ [ 'User:Foo/Bar/Baz', 'Foo' ],
+ ];
+ }
+
+ /**
+ * @todo Handle $wgNamespacesWithSubpages cases
+ * @dataProvider provideSubpageTitleCases
+ * @covers Title::getSubpageText
+ */
+ public function testGetSubpageText( $title, $expected, $msg = '' ) {
+ $title = Title::newFromText( $title );
+ $this->assertEquals( $expected,
+ $title->getSubpageText(),
+ $msg
+ );
+ }
+
+ public static function provideSubpageTitleCases() {
+ return [
+ # Title, expected base, optional message
+ [ 'User:John_Doe/subOne/subTwo', 'subTwo' ],
+ [ 'User:John_Doe/subOne', 'subOne' ],
+ ];
+ }
+
+ public static function provideNewFromTitleValue() {
+ return [
+ [ new TitleValue( NS_MAIN, 'Foo' ) ],
+ [ new TitleValue( NS_MAIN, 'Foo', 'bar' ) ],
+ [ new TitleValue( NS_USER, 'Hansi_Maier' ) ],
+ ];
+ }
+
+ /**
+ * @covers Title::newFromTitleValue
+ * @dataProvider provideNewFromTitleValue
+ */
+ public function testNewFromTitleValue( TitleValue $value ) {
+ $title = Title::newFromTitleValue( $value );
+
+ $dbkey = str_replace( ' ', '_', $value->getText() );
+ $this->assertEquals( $dbkey, $title->getDBkey() );
+ $this->assertEquals( $value->getNamespace(), $title->getNamespace() );
+ $this->assertEquals( $value->getFragment(), $title->getFragment() );
+ }
+
+ public static function provideGetTitleValue() {
+ return [
+ [ 'Foo' ],
+ [ 'Foo#bar' ],
+ [ 'User:Hansi_Maier' ],
+ ];
+ }
+
+ /**
+ * @covers Title::getTitleValue
+ * @dataProvider provideGetTitleValue
+ */
+ public function testGetTitleValue( $text ) {
+ $title = Title::newFromText( $text );
+ $value = $title->getTitleValue();
+
+ $dbkey = str_replace( ' ', '_', $value->getText() );
+ $this->assertEquals( $title->getDBkey(), $dbkey );
+ $this->assertEquals( $title->getNamespace(), $value->getNamespace() );
+ $this->assertEquals( $title->getFragment(), $value->getFragment() );
+ }
+
+ public static function provideGetFragment() {
+ return [
+ [ 'Foo', '' ],
+ [ 'Foo#bar', 'bar' ],
+ [ 'Foo#bär', 'bär' ],
+
+ // Inner whitespace is normalized
+ [ 'Foo#bar_bar', 'bar bar' ],
+ [ 'Foo#bar bar', 'bar bar' ],
+ [ 'Foo#bar bar', 'bar bar' ],
+
+ // Leading whitespace is kept, trailing whitespace is trimmed.
+ // XXX: Is this really want we want?
+ [ 'Foo#_bar_bar_', ' bar bar' ],
+ [ 'Foo# bar bar ', ' bar bar' ],
+ ];
+ }
+
+ /**
+ * @covers Title::getFragment
+ * @dataProvider provideGetFragment
+ *
+ * @param string $full
+ * @param string $fragment
+ */
+ public function testGetFragment( $full, $fragment ) {
+ $title = Title::newFromText( $full );
+ $this->assertEquals( $fragment, $title->getFragment() );
+ }
+
+ /**
+ * @covers Title::isAlwaysKnown
+ * @dataProvider provideIsAlwaysKnown
+ * @param string $page
+ * @param bool $isKnown
+ */
+ public function testIsAlwaysKnown( $page, $isKnown ) {
+ $title = Title::newFromText( $page );
+ $this->assertEquals( $isKnown, $title->isAlwaysKnown() );
+ }
+
+ public static function provideIsAlwaysKnown() {
+ return [
+ [ 'Some nonexistent page', false ],
+ [ 'UTPage', false ],
+ [ '#test', true ],
+ [ 'Special:BlankPage', true ],
+ [ 'Special:SomeNonexistentSpecialPage', false ],
+ [ 'MediaWiki:Parentheses', true ],
+ [ 'MediaWiki:Some nonexistent message', false ],
+ ];
+ }
+
+ /**
+ * @covers Title::isValid
+ * @dataProvider provideIsValid
+ * @param Title $title
+ * @param bool $isValid
+ */
+ public function testIsValid( Title $title, $isValid ) {
+ $this->assertEquals( $isValid, $title->isValid(), $title->getPrefixedText() );
+ }
+
+ public static function provideIsValid() {
+ return [
+ [ Title::makeTitle( NS_MAIN, '' ), false ],
+ [ Title::makeTitle( NS_MAIN, '<>' ), false ],
+ [ Title::makeTitle( NS_MAIN, '|' ), false ],
+ [ Title::makeTitle( NS_MAIN, '#' ), false ],
+ [ Title::makeTitle( NS_MAIN, 'Test' ), true ],
+ [ Title::makeTitle( -33, 'Test' ), false ],
+ [ Title::makeTitle( 77663399, 'Test' ), false ],
+ ];
+ }
+
+ /**
+ * @covers Title::isAlwaysKnown
+ */
+ public function testIsAlwaysKnownOnInterwiki() {
+ $title = Title::makeTitle( NS_MAIN, 'Interwiki link', '', 'externalwiki' );
+ $this->assertTrue( $title->isAlwaysKnown() );
+ }
+
+ /**
+ * @covers Title::exists
+ */
+ public function testExists() {
+ $title = Title::makeTitle( NS_PROJECT, 'New page' );
+ $linkCache = LinkCache::singleton();
+
+ $article = new Article( $title );
+ $page = $article->getPage();
+ $page->doEditContent( new WikitextContent( 'Some [[link]]' ), 'summary' );
+
+ // Tell Title it doesn't know whether it exists
+ $title->mArticleID = -1;
+
+ // Tell the link cache it doesn't exists when it really does
+ $linkCache->clearLink( $title );
+ $linkCache->addBadLinkObj( $title );
+
+ $this->assertEquals(
+ false,
+ $title->exists(),
+ 'exists() should rely on link cache unless GAID_FOR_UPDATE is used'
+ );
+ $this->assertEquals(
+ true,
+ $title->exists( Title::GAID_FOR_UPDATE ),
+ 'exists() should re-query database when GAID_FOR_UPDATE is used'
+ );
+ }
+
+ public function provideCanHaveTalkPage() {
+ return [
+ 'User page has talk page' => [
+ Title::makeTitle( NS_USER, 'Jane' ), true
+ ],
+ 'Talke page has talk page' => [
+ Title::makeTitle( NS_TALK, 'Foo' ), true
+ ],
+ 'Special page cannot have talk page' => [
+ Title::makeTitle( NS_SPECIAL, 'Thing' ), false
+ ],
+ 'Virtual namespace cannot have talk page' => [
+ Title::makeTitle( NS_MEDIA, 'Kitten.jpg' ), false
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCanHaveTalkPage
+ * @covers Title::canHaveTalkPage
+ *
+ * @param Title $title
+ * @param bool $expected
+ */
+ public function testCanHaveTalkPage( Title $title, $expected ) {
+ $actual = $title->canHaveTalkPage();
+ $this->assertSame( $expected, $actual, $title->getPrefixedDBkey() );
+ }
+
+ /**
+ * @dataProvider provideCanHaveTalkPage
+ * @covers Title::canTalk
+ *
+ * @param Title $title
+ * @param bool $expected
+ */
+ public function testCanTalk( Title $title, $expected ) {
+ $actual = $title->canTalk();
+ $this->assertSame( $expected, $actual, $title->getPrefixedDBkey() );
+ }
+
+ public static function provideGetTalkPage_good() {
+ return [
+ [ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ],
+ [ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetTalkPage_good
+ * @covers Title::getTalkPage
+ */
+ public function testGetTalkPage_good( Title $title, Title $expected ) {
+ $talk = $title->getTalkPage();
+ $this->assertSame(
+ $expected->getPrefixedDBKey(),
+ $talk->getPrefixedDBKey(),
+ $title->getPrefixedDBKey()
+ );
+ }
+
+ /**
+ * @dataProvider provideGetTalkPage_good
+ * @covers Title::getTalkPageIfDefined
+ */
+ public function testGetTalkPageIfDefined_good( Title $title ) {
+ $talk = $title->getTalkPageIfDefined();
+ $this->assertInstanceOf(
+ Title::class,
+ $talk,
+ $title->getPrefixedDBKey()
+ );
+ }
+
+ public static function provideGetTalkPage_bad() {
+ return [
+ [ Title::makeTitle( NS_SPECIAL, 'Test' ) ],
+ [ Title::makeTitle( NS_MEDIA, 'Test' ) ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetTalkPage_bad
+ * @covers Title::getTalkPageIfDefined
+ */
+ public function testGetTalkPageIfDefined_bad( Title $title ) {
+ $talk = $title->getTalkPageIfDefined();
+ $this->assertNull(
+ $talk,
+ $title->getPrefixedDBKey()
+ );
+ }
+
+ public function provideCreateFragmentTitle() {
+ return [
+ [ Title::makeTitle( NS_MAIN, 'Test' ), 'foo' ],
+ [ Title::makeTitle( NS_TALK, 'Test', 'foo' ), '' ],
+ [ Title::makeTitle( NS_CATEGORY, 'Test', 'foo' ), 'bar' ],
+ [ Title::makeTitle( NS_MAIN, 'Test1', '', 'interwiki' ), 'baz' ]
+ ];
+ }
+
+ /**
+ * @covers Title::createFragmentTarget
+ * @dataProvider provideCreateFragmentTitle
+ */
+ public function testCreateFragmentTitle( Title $title, $fragment ) {
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'InterwikiLoadPrefix' => [
+ function ( $prefix, &$iwdata ) {
+ if ( $prefix === 'interwiki' ) {
+ $iwdata = [
+ 'iw_url' => 'http://example.com/',
+ 'iw_local' => 0,
+ 'iw_trans' => 0,
+ ];
+ return false;
+ }
+ },
+ ],
+ ] );
+
+ $fragmentTitle = $title->createFragmentTarget( $fragment );
+
+ $this->assertEquals( $title->getNamespace(), $fragmentTitle->getNamespace() );
+ $this->assertEquals( $title->getText(), $fragmentTitle->getText() );
+ $this->assertEquals( $title->getInterwiki(), $fragmentTitle->getInterwiki() );
+ $this->assertEquals( $fragment, $fragmentTitle->getFragment() );
+ }
+
+ public function provideGetPrefixedText() {
+ return [
+ // ns = 0
+ [
+ Title::makeTitle( NS_MAIN, 'Foo bar' ),
+ 'Foo bar'
+ ],
+ // ns = 2
+ [
+ Title::makeTitle( NS_USER, 'Foo bar' ),
+ 'User:Foo bar'
+ ],
+ // ns = 3
+ [
+ Title::makeTitle( NS_USER_TALK, 'Foo bar' ),
+ 'User talk:Foo bar'
+ ],
+ // fragment not included
+ [
+ Title::makeTitle( NS_MAIN, 'Foo bar', 'fragment' ),
+ 'Foo bar'
+ ],
+ // ns = -2
+ [
+ Title::makeTitle( NS_MEDIA, 'Foo bar' ),
+ 'Media:Foo bar'
+ ],
+ // non-existent namespace
+ [
+ Title::makeTitle( 100777, 'Foo bar' ),
+ 'Special:Badtitle/NS100777:Foo bar'
+ ],
+ ];
+ }
+
+ /**
+ * @covers Title::getPrefixedText
+ * @dataProvider provideGetPrefixedText
+ */
+ public function testGetPrefixedText( Title $title, $expected ) {
+ $this->assertEquals( $expected, $title->getPrefixedText() );
+ }
+
+ public function provideGetPrefixedDBKey() {
+ return [
+ // ns = 0
+ [
+ Title::makeTitle( NS_MAIN, 'Foo_bar' ),
+ 'Foo_bar'
+ ],
+ // ns = 2
+ [
+ Title::makeTitle( NS_USER, 'Foo_bar' ),
+ 'User:Foo_bar'
+ ],
+ // ns = 3
+ [
+ Title::makeTitle( NS_USER_TALK, 'Foo_bar' ),
+ 'User_talk:Foo_bar'
+ ],
+ // fragment not included
+ [
+ Title::makeTitle( NS_MAIN, 'Foo_bar', 'fragment' ),
+ 'Foo_bar'
+ ],
+ // ns = -2
+ [
+ Title::makeTitle( NS_MEDIA, 'Foo_bar' ),
+ 'Media:Foo_bar'
+ ],
+ // non-existent namespace
+ [
+ Title::makeTitle( 100777, 'Foo_bar' ),
+ 'Special:Badtitle/NS100777:Foo_bar'
+ ],
+ ];
+ }
+
+ /**
+ * @covers Title::getPrefixedDBKey
+ * @dataProvider provideGetPrefixedDBKey
+ */
+ public function testGetPrefixedDBKey( Title $title, $expected ) {
+ $this->assertEquals( $expected, $title->getPrefixedDBkey() );
+ }
+
+ /**
+ * @covers Title::getFragmentForURL
+ * @dataProvider provideGetFragmentForURL
+ *
+ * @param string $titleStr
+ * @param string $expected
+ */
+ public function testGetFragmentForURL( $titleStr, $expected ) {
+ $this->setMwGlobals( [
+ 'wgFragmentMode' => [ 'html5' ],
+ 'wgExternalInterwikiFragmentMode' => 'legacy',
+ ] );
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->insert( 'interwiki',
+ [
+ [
+ 'iw_prefix' => 'de',
+ 'iw_url' => 'http://de.wikipedia.org/wiki/',
+ 'iw_api' => 'http://de.wikipedia.org/w/api.php',
+ 'iw_wikiid' => 'dewiki',
+ 'iw_local' => 1,
+ 'iw_trans' => 0,
+ ],
+ [
+ 'iw_prefix' => 'zz',
+ 'iw_url' => 'http://zzwiki.org/wiki/',
+ 'iw_api' => 'http://zzwiki.org/w/api.php',
+ 'iw_wikiid' => 'zzwiki',
+ 'iw_local' => 0,
+ 'iw_trans' => 0,
+ ],
+ ],
+ __METHOD__,
+ [ 'IGNORE' ]
+ );
+
+ $title = Title::newFromText( $titleStr );
+ self::assertEquals( $expected, $title->getFragmentForURL() );
+
+ $dbw->delete( 'interwiki', '*', __METHOD__ );
+ }
+
+ public function provideGetFragmentForURL() {
+ return [
+ [ 'Foo', '' ],
+ [ 'Foo#ümlåût', '#ümlåût' ],
+ [ 'de:Foo#Bå®', '#Bå®' ],
+ [ 'zz:Foo#тест', '#.D1.82.D0.B5.D1.81.D1.82' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/WebRequestTest.php b/www/wiki/tests/phpunit/includes/WebRequestTest.php
new file mode 100644
index 00000000..9583921d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/WebRequestTest.php
@@ -0,0 +1,626 @@
+<?php
+
+/**
+ * @group WebRequest
+ */
+class WebRequestTest extends MediaWikiTestCase {
+ protected $oldServer;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->oldServer = $_SERVER;
+ }
+
+ protected function tearDown() {
+ $_SERVER = $this->oldServer;
+
+ parent::tearDown();
+ }
+
+ /**
+ * @dataProvider provideDetectServer
+ * @covers WebRequest::detectServer
+ * @covers WebRequest::detectProtocol
+ */
+ public function testDetectServer( $expected, $input, $description ) {
+ $this->setMwGlobals( 'wgAssumeProxiesUseDefaultProtocolPorts', true );
+
+ $this->setServerVars( $input );
+ $result = WebRequest::detectServer();
+ $this->assertEquals( $expected, $result, $description );
+ }
+
+ public static function provideDetectServer() {
+ return [
+ [
+ 'http://x',
+ [
+ 'HTTP_HOST' => 'x'
+ ],
+ 'Host header'
+ ],
+ [
+ 'https://x',
+ [
+ 'HTTP_HOST' => 'x',
+ 'HTTPS' => 'on',
+ ],
+ 'Host header with secure'
+ ],
+ [
+ 'http://x',
+ [
+ 'HTTP_HOST' => 'x',
+ 'SERVER_PORT' => 80,
+ ],
+ 'Default SERVER_PORT',
+ ],
+ [
+ 'http://x',
+ [
+ 'HTTP_HOST' => 'x',
+ 'HTTPS' => 'off',
+ ],
+ 'Secure off'
+ ],
+ [
+ 'https://x',
+ [
+ 'HTTP_HOST' => 'x',
+ 'HTTP_X_FORWARDED_PROTO' => 'https',
+ ],
+ 'Forwarded HTTPS'
+ ],
+ [
+ 'https://x',
+ [
+ 'HTTP_HOST' => 'x',
+ 'HTTPS' => 'off',
+ 'SERVER_PORT' => '81',
+ 'HTTP_X_FORWARDED_PROTO' => 'https',
+ ],
+ 'Forwarded HTTPS'
+ ],
+ [
+ 'http://y',
+ [
+ 'SERVER_NAME' => 'y',
+ ],
+ 'Server name'
+ ],
+ [
+ 'http://x',
+ [
+ 'HTTP_HOST' => 'x',
+ 'SERVER_NAME' => 'y',
+ ],
+ 'Host server name precedence'
+ ],
+ [
+ 'http://[::1]:81',
+ [
+ 'HTTP_HOST' => '[::1]',
+ 'SERVER_NAME' => '::1',
+ 'SERVER_PORT' => '81',
+ ],
+ 'Apache bug 26005'
+ ],
+ [
+ 'http://localhost',
+ [
+ 'SERVER_NAME' => '[2001'
+ ],
+ 'Kind of like lighttpd per commit message in MW r83847',
+ ],
+ [
+ 'http://[2a01:e35:2eb4:1::2]:777',
+ [
+ 'SERVER_NAME' => '[2a01:e35:2eb4:1::2]:777'
+ ],
+ 'Possible lighttpd environment per bug 14977 comment 13',
+ ],
+ ];
+ }
+
+ protected function mockWebRequest( $data = [] ) {
+ // Cannot use PHPUnit getMockBuilder() as it does not support
+ // overriding protected properties afterwards
+ $reflection = new ReflectionClass( WebRequest::class );
+ $req = $reflection->newInstanceWithoutConstructor();
+
+ $prop = $reflection->getProperty( 'data' );
+ $prop->setAccessible( true );
+ $prop->setValue( $req, $data );
+
+ $prop = $reflection->getProperty( 'requestTime' );
+ $prop->setAccessible( true );
+ $prop->setValue( $req, microtime( true ) );
+
+ return $req;
+ }
+
+ /**
+ * @covers WebRequest::getElapsedTime
+ */
+ public function testGetElapsedTime() {
+ $req = $this->mockWebRequest();
+ $this->assertGreaterThanOrEqual( 0.0, $req->getElapsedTime() );
+ $this->assertEquals( 0.0, $req->getElapsedTime(), '', /*delta*/ 0.2 );
+ }
+
+ /**
+ * @covers WebRequest::getVal
+ * @covers WebRequest::getGPCVal
+ * @covers WebRequest::normalizeUnicode
+ */
+ public function testGetValNormal() {
+ // Assert that WebRequest normalises GPC data using UtfNormal\Validator
+ $input = "a \x00 null";
+ $normal = "a \xef\xbf\xbd null";
+ $req = $this->mockWebRequest( [ 'x' => $input, 'y' => [ $input, $input ] ] );
+ $this->assertSame( $normal, $req->getVal( 'x' ) );
+ $this->assertNotSame( $input, $req->getVal( 'x' ) );
+ $this->assertSame( [ $normal, $normal ], $req->getArray( 'y' ) );
+ }
+
+ /**
+ * @covers WebRequest::getVal
+ * @covers WebRequest::getGPCVal
+ */
+ public function testGetVal() {
+ $req = $this->mockWebRequest( [ 'x' => 'Value', 'y' => [ 'a' ], 'crlf' => "A\r\nb" ] );
+ $this->assertSame( 'Value', $req->getVal( 'x' ), 'Simple value' );
+ $this->assertSame( null, $req->getVal( 'z' ), 'Not found' );
+ $this->assertSame( null, $req->getVal( 'y' ), 'Array is ignored' );
+ $this->assertSame( "A\r\nb", $req->getVal( 'crlf' ), 'CRLF' );
+ }
+
+ /**
+ * @covers WebRequest::getRawVal
+ */
+ public function testGetRawVal() {
+ $req = $this->mockWebRequest( [
+ 'x' => 'Value',
+ 'y' => [ 'a' ],
+ 'crlf' => "A\r\nb"
+ ] );
+ $this->assertSame( 'Value', $req->getRawVal( 'x' ) );
+ $this->assertSame( null, $req->getRawVal( 'z' ), 'Not found' );
+ $this->assertSame( null, $req->getRawVal( 'y' ), 'Array is ignored' );
+ $this->assertSame( "A\r\nb", $req->getRawVal( 'crlf' ), 'CRLF' );
+ }
+
+ /**
+ * @covers WebRequest::getArray
+ */
+ public function testGetArray() {
+ $req = $this->mockWebRequest( [ 'x' => 'Value', 'y' => [ 'a', 'b' ] ] );
+ $this->assertSame( [ 'Value' ], $req->getArray( 'x' ), 'Value becomes array' );
+ $this->assertSame( null, $req->getArray( 'z' ), 'Not found' );
+ $this->assertSame( [ 'a', 'b' ], $req->getArray( 'y' ) );
+ }
+
+ /**
+ * @covers WebRequest::getIntArray
+ */
+ public function testGetIntArray() {
+ $req = $this->mockWebRequest( [ 'x' => [ 'Value' ], 'y' => [ '0', '4.2', '-2' ] ] );
+ $this->assertSame( [ 0 ], $req->getIntArray( 'x' ), 'Text becomes 0' );
+ $this->assertSame( null, $req->getIntArray( 'z' ), 'Not found' );
+ $this->assertSame( [ 0, 4, -2 ], $req->getIntArray( 'y' ) );
+ }
+
+ /**
+ * @covers WebRequest::getInt
+ */
+ public function testGetInt() {
+ $req = $this->mockWebRequest( [
+ 'x' => 'Value',
+ 'y' => [ 'a' ],
+ 'zero' => '0',
+ 'answer' => '4.2',
+ 'neg' => '-2',
+ ] );
+ $this->assertSame( 0, $req->getInt( 'x' ), 'Text' );
+ $this->assertSame( 0, $req->getInt( 'y' ), 'Array' );
+ $this->assertSame( 0, $req->getInt( 'z' ), 'Not found' );
+ $this->assertSame( 0, $req->getInt( 'zero' ) );
+ $this->assertSame( 4, $req->getInt( 'answer' ) );
+ $this->assertSame( -2, $req->getInt( 'neg' ) );
+ }
+
+ /**
+ * @covers WebRequest::getIntOrNull
+ */
+ public function testGetIntOrNull() {
+ $req = $this->mockWebRequest( [
+ 'x' => 'Value',
+ 'y' => [ 'a' ],
+ 'zero' => '0',
+ 'answer' => '4.2',
+ 'neg' => '-2',
+ ] );
+ $this->assertSame( null, $req->getIntOrNull( 'x' ), 'Text' );
+ $this->assertSame( null, $req->getIntOrNull( 'y' ), 'Array' );
+ $this->assertSame( null, $req->getIntOrNull( 'z' ), 'Not found' );
+ $this->assertSame( 0, $req->getIntOrNull( 'zero' ) );
+ $this->assertSame( 4, $req->getIntOrNull( 'answer' ) );
+ $this->assertSame( -2, $req->getIntOrNull( 'neg' ) );
+ }
+
+ /**
+ * @covers WebRequest::getFloat
+ */
+ public function testGetFloat() {
+ $req = $this->mockWebRequest( [
+ 'x' => 'Value',
+ 'y' => [ 'a' ],
+ 'zero' => '0',
+ 'answer' => '4.2',
+ 'neg' => '-2',
+ ] );
+ $this->assertSame( 0.0, $req->getFloat( 'x' ), 'Text' );
+ $this->assertSame( 0.0, $req->getFloat( 'y' ), 'Array' );
+ $this->assertSame( 0.0, $req->getFloat( 'z' ), 'Not found' );
+ $this->assertSame( 0.0, $req->getFloat( 'zero' ) );
+ $this->assertSame( 4.2, $req->getFloat( 'answer' ) );
+ $this->assertSame( -2.0, $req->getFloat( 'neg' ) );
+ }
+
+ /**
+ * @covers WebRequest::getBool
+ */
+ public function testGetBool() {
+ $req = $this->mockWebRequest( [
+ 'x' => 'Value',
+ 'y' => [ 'a' ],
+ 'zero' => '0',
+ 'f' => 'false',
+ 't' => 'true',
+ ] );
+ $this->assertSame( true, $req->getBool( 'x' ), 'Text' );
+ $this->assertSame( false, $req->getBool( 'y' ), 'Array' );
+ $this->assertSame( false, $req->getBool( 'z' ), 'Not found' );
+ $this->assertSame( false, $req->getBool( 'zero' ) );
+ $this->assertSame( true, $req->getBool( 'f' ) );
+ $this->assertSame( true, $req->getBool( 't' ) );
+ }
+
+ public static function provideFuzzyBool() {
+ return [
+ [ 'Text', true ],
+ [ '', false, '(empty string)' ],
+ [ '0', false ],
+ [ '1', true ],
+ [ 'false', false ],
+ [ 'true', true ],
+ [ 'False', false ],
+ [ 'True', true ],
+ [ 'FALSE', false ],
+ [ 'TRUE', true ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideFuzzyBool
+ * @covers WebRequest::getFuzzyBool
+ */
+ public function testGetFuzzyBool( $value, $expected, $message = null ) {
+ $req = $this->mockWebRequest( [ 'x' => $value ] );
+ $this->assertSame( $expected, $req->getFuzzyBool( 'x' ), $message ?: "Value: '$value'" );
+ }
+
+ /**
+ * @covers WebRequest::getFuzzyBool
+ */
+ public function testGetFuzzyBoolDefault() {
+ $req = $this->mockWebRequest();
+ $this->assertSame( false, $req->getFuzzyBool( 'z' ), 'Not found' );
+ }
+
+ /**
+ * @covers WebRequest::getCheck
+ */
+ public function testGetCheck() {
+ $req = $this->mockWebRequest( [ 'x' => 'Value', 'zero' => '0' ] );
+ $this->assertSame( false, $req->getCheck( 'z' ), 'Not found' );
+ $this->assertSame( true, $req->getCheck( 'x' ), 'Text' );
+ $this->assertSame( true, $req->getCheck( 'zero' ) );
+ }
+
+ /**
+ * @covers WebRequest::getText
+ */
+ public function testGetText() {
+ // Avoid FauxRequest (overrides getText)
+ $req = $this->mockWebRequest( [ 'crlf' => "Va\r\nlue" ] );
+ $this->assertSame( "Va\nlue", $req->getText( 'crlf' ), 'CR stripped' );
+ }
+
+ /**
+ * @covers WebRequest::getValues
+ */
+ public function testGetValues() {
+ $values = [ 'x' => 'Value', 'y' => '' ];
+ // Avoid FauxRequest (overrides getValues)
+ $req = $this->mockWebRequest( $values );
+ $this->assertSame( $values, $req->getValues() );
+ $this->assertSame( [ 'x' => 'Value' ], $req->getValues( 'x' ), 'Specific keys' );
+ }
+
+ /**
+ * @covers WebRequest::getValueNames
+ */
+ public function testGetValueNames() {
+ $req = $this->mockWebRequest( [ 'x' => 'Value', 'y' => '' ] );
+ $this->assertSame( [ 'x', 'y' ], $req->getValueNames() );
+ $this->assertSame( [ 'x' ], $req->getValueNames( [ 'y' ] ), 'Exclude keys' );
+ }
+
+ /**
+ * @dataProvider provideGetIP
+ * @covers WebRequest::getIP
+ */
+ public function testGetIP( $expected, $input, $squid, $xffList, $private, $description ) {
+ $this->setServerVars( $input );
+ $this->setMwGlobals( [
+ 'wgUsePrivateIPs' => $private,
+ 'wgHooks' => [
+ 'IsTrustedProxy' => [
+ function ( &$ip, &$trusted ) use ( $xffList ) {
+ $trusted = $trusted || in_array( $ip, $xffList );
+ return true;
+ }
+ ]
+ ]
+ ] );
+
+ $this->setService( 'ProxyLookup', new ProxyLookup( [], $squid ) );
+
+ $request = new WebRequest();
+ $result = $request->getIP();
+ $this->assertEquals( $expected, $result, $description );
+ }
+
+ public static function provideGetIP() {
+ return [
+ [
+ '127.0.0.1',
+ [
+ 'REMOTE_ADDR' => '127.0.0.1'
+ ],
+ [],
+ [],
+ false,
+ 'Simple IPv4'
+ ],
+ [
+ '::1',
+ [
+ 'REMOTE_ADDR' => '::1'
+ ],
+ [],
+ [],
+ false,
+ 'Simple IPv6'
+ ],
+ [
+ '12.0.0.1',
+ [
+ 'REMOTE_ADDR' => 'abcd:0001:002:03:4:555:6666:7777',
+ 'HTTP_X_FORWARDED_FOR' => '12.0.0.1, abcd:0001:002:03:4:555:6666:7777',
+ ],
+ [ 'ABCD:1:2:3:4:555:6666:7777' ],
+ [],
+ false,
+ 'IPv6 normalisation'
+ ],
+ [
+ '12.0.0.3',
+ [
+ 'REMOTE_ADDR' => '12.0.0.1',
+ 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2'
+ ],
+ [ '12.0.0.1', '12.0.0.2' ],
+ [],
+ false,
+ 'With X-Forwaded-For'
+ ],
+ [
+ '12.0.0.1',
+ [
+ 'REMOTE_ADDR' => '12.0.0.1',
+ 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2'
+ ],
+ [],
+ [],
+ false,
+ 'With X-Forwaded-For and disallowed server'
+ ],
+ [
+ '12.0.0.2',
+ [
+ 'REMOTE_ADDR' => '12.0.0.1',
+ 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2'
+ ],
+ [ '12.0.0.1' ],
+ [],
+ false,
+ 'With multiple X-Forwaded-For and only one allowed server'
+ ],
+ [
+ '10.0.0.3',
+ [
+ 'REMOTE_ADDR' => '12.0.0.2',
+ 'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2'
+ ],
+ [ '12.0.0.1', '12.0.0.2' ],
+ [],
+ false,
+ 'With X-Forwaded-For and private IP (from cache proxy)'
+ ],
+ [
+ '10.0.0.4',
+ [
+ 'REMOTE_ADDR' => '12.0.0.2',
+ 'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2'
+ ],
+ [ '12.0.0.1', '12.0.0.2', '10.0.0.3' ],
+ [],
+ true,
+ 'With X-Forwaded-For and private IP (allowed)'
+ ],
+ [
+ '10.0.0.4',
+ [
+ 'REMOTE_ADDR' => '12.0.0.2',
+ 'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2'
+ ],
+ [ '12.0.0.1', '12.0.0.2' ],
+ [ '10.0.0.3' ],
+ true,
+ 'With X-Forwaded-For and private IP (allowed)'
+ ],
+ [
+ '10.0.0.3',
+ [
+ 'REMOTE_ADDR' => '12.0.0.2',
+ 'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2'
+ ],
+ [ '12.0.0.1', '12.0.0.2' ],
+ [ '10.0.0.3' ],
+ false,
+ 'With X-Forwaded-For and private IP (disallowed)'
+ ],
+ [
+ '12.0.0.3',
+ [
+ 'REMOTE_ADDR' => '12.0.0.1',
+ 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2'
+ ],
+ [],
+ [ '12.0.0.1', '12.0.0.2' ],
+ false,
+ 'With X-Forwaded-For'
+ ],
+ [
+ '12.0.0.2',
+ [
+ 'REMOTE_ADDR' => '12.0.0.1',
+ 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2'
+ ],
+ [],
+ [ '12.0.0.1' ],
+ false,
+ 'With multiple X-Forwaded-For and only one allowed server'
+ ],
+ [
+ '12.0.0.2',
+ [
+ 'REMOTE_ADDR' => '12.0.0.2',
+ 'HTTP_X_FORWARDED_FOR' => '10.0.0.3, 12.0.0.2'
+ ],
+ [],
+ [ '12.0.0.2' ],
+ false,
+ 'With X-Forwaded-For and private IP and hook (disallowed)'
+ ],
+ [
+ '12.0.0.1',
+ [
+ 'REMOTE_ADDR' => 'abcd:0001:002:03:4:555:6666:7777',
+ 'HTTP_X_FORWARDED_FOR' => '12.0.0.1, abcd:0001:002:03:4:555:6666:7777',
+ ],
+ [ 'ABCD:1:2:3::/64' ],
+ [],
+ false,
+ 'IPv6 CIDR'
+ ],
+ [
+ '12.0.0.3',
+ [
+ 'REMOTE_ADDR' => '12.0.0.1',
+ 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2'
+ ],
+ [ '12.0.0.0/24' ],
+ [],
+ false,
+ 'IPv4 CIDR'
+ ],
+ ];
+ }
+
+ /**
+ * @expectedException MWException
+ * @covers WebRequest::getIP
+ */
+ public function testGetIpLackOfRemoteAddrThrowAnException() {
+ // ensure that local install state doesn't interfere with test
+ $this->setMwGlobals( [
+ 'wgSquidServersNoPurge' => [],
+ 'wgSquidServers' => [],
+ 'wgUsePrivateIPs' => false,
+ 'wgHooks' => [],
+ ] );
+ $this->setService( 'ProxyLookup', new ProxyLookup( [], [] ) );
+
+ $request = new WebRequest();
+ # Next call throw an exception about lacking an IP
+ $request->getIP();
+ }
+
+ public static function provideLanguageData() {
+ return [
+ [ '', [], 'Empty Accept-Language header' ],
+ [ 'en', [ 'en' => 1 ], 'One language' ],
+ [ 'en, ar', [ 'en' => 1, 'ar' => 1 ], 'Two languages listed in appearance order.' ],
+ [
+ 'zh-cn,zh-tw',
+ [ 'zh-cn' => 1, 'zh-tw' => 1 ],
+ 'Two equally prefered languages, listed in appearance order per rfc3282. Checks c9119'
+ ],
+ [
+ 'es, en; q=0.5',
+ [ 'es' => 1, 'en' => '0.5' ],
+ 'Spanish as first language and English and second'
+ ],
+ [ 'en; q=0.5, es', [ 'es' => 1, 'en' => '0.5' ], 'Less prefered language first' ],
+ [ 'fr, en; q=0.5, es', [ 'fr' => 1, 'es' => 1, 'en' => '0.5' ], 'Three languages' ],
+ [ 'en; q=0.5, es', [ 'es' => 1, 'en' => '0.5' ], 'Two languages' ],
+ [ 'en, zh;q=0', [ 'en' => 1 ], "It's Chinese to me" ],
+ [
+ 'es; q=1, pt;q=0.7, it; q=0.6, de; q=0.1, ru;q=0',
+ [ 'es' => '1', 'pt' => '0.7', 'it' => '0.6', 'de' => '0.1' ],
+ 'Preference for Romance languages'
+ ],
+ [
+ 'en-gb, en-us; q=1',
+ [ 'en-gb' => 1, 'en-us' => '1' ],
+ 'Two equally prefered English variants'
+ ],
+ [ '_', [], 'Invalid input' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideLanguageData
+ * @covers WebRequest::getAcceptLang
+ */
+ public function testAcceptLang( $acceptLanguageHeader, $expectedLanguages, $description ) {
+ $this->setServerVars( [ 'HTTP_ACCEPT_LANGUAGE' => $acceptLanguageHeader ] );
+ $request = new WebRequest();
+ $this->assertSame( $request->getAcceptLang(), $expectedLanguages, $description );
+ }
+
+ protected function setServerVars( $vars ) {
+ // Don't remove vars which should be available in all SAPI.
+ if ( !isset( $vars['REQUEST_TIME_FLOAT'] ) ) {
+ $vars['REQUEST_TIME_FLOAT'] = $_SERVER['REQUEST_TIME_FLOAT'];
+ }
+ if ( !isset( $vars['REQUEST_TIME'] ) ) {
+ $vars['REQUEST_TIME'] = $_SERVER['REQUEST_TIME'];
+ }
+ $_SERVER = $vars;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/WikiMapTest.php b/www/wiki/tests/phpunit/includes/WikiMapTest.php
new file mode 100644
index 00000000..53e0b10b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/WikiMapTest.php
@@ -0,0 +1,253 @@
+<?php
+use Wikimedia\Rdbms\DatabaseDomain;
+
+/**
+ * @covers WikiMap
+ *
+ * @group Database
+ */
+class WikiMapTest extends MediaWikiLangTestCase {
+
+ public function setUp() {
+ parent::setUp();
+
+ $conf = new SiteConfiguration();
+ $conf->settings = [
+ 'wgServer' => [
+ 'enwiki' => 'http://en.example.org',
+ 'ruwiki' => '//ru.example.org',
+ 'nopathwiki' => '//nopath.example.org',
+ 'thiswiki' => '//this.wiki.org'
+ ],
+ 'wgArticlePath' => [
+ 'enwiki' => '/w/$1',
+ 'ruwiki' => '/wiki/$1',
+ ],
+ ];
+ $conf->suffixes = [ 'wiki' ];
+ $this->setMwGlobals( [
+ 'wgConf' => $conf,
+ 'wgLocalDatabases' => [ 'enwiki', 'ruwiki', 'nopathwiki' ],
+ 'wgCanonicalServer' => '//this.wiki.org',
+ 'wgDBname' => 'thiswiki',
+ 'wgDBprefix' => ''
+ ] );
+
+ TestSites::insertIntoDb();
+ }
+
+ public function provideGetWiki() {
+ // As provided by $wgConf
+ $enwiki = new WikiReference( 'http://en.example.org', '/w/$1' );
+ $ruwiki = new WikiReference( '//ru.example.org', '/wiki/$1' );
+
+ // Created from site objects
+ $nlwiki = new WikiReference( 'https://nl.wikipedia.org', '/wiki/$1' );
+ // enwiktionary doesn't have an interwiki id, thus this falls back to minor = lang code
+ $enwiktionary = new WikiReference( 'https://en.wiktionary.org', '/wiki/$1' );
+
+ return [
+ 'unknown' => [ null, 'xyzzy' ],
+ 'enwiki (wgConf)' => [ $enwiki, 'enwiki' ],
+ 'ruwiki (wgConf)' => [ $ruwiki, 'ruwiki' ],
+ 'nlwiki (sites)' => [ $nlwiki, 'nlwiki', false ],
+ 'enwiktionary (sites)' => [ $enwiktionary, 'enwiktionary', false ],
+ 'non MediaWiki site' => [ null, 'spam', false ],
+ 'boguswiki' => [ null, 'boguswiki' ],
+ 'nopathwiki' => [ null, 'nopathwiki' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetWiki
+ */
+ public function testGetWiki( $expected, $wikiId, $useWgConf = true ) {
+ if ( !$useWgConf ) {
+ $this->setMwGlobals( [
+ 'wgConf' => new SiteConfiguration(),
+ ] );
+ }
+
+ $this->assertEquals( $expected, WikiMap::getWiki( $wikiId ) );
+ }
+
+ public function provideGetWikiName() {
+ return [
+ 'unknown' => [ 'xyzzy', 'xyzzy' ],
+ 'enwiki' => [ 'en.example.org', 'enwiki' ],
+ 'ruwiki' => [ 'ru.example.org', 'ruwiki' ],
+ 'enwiktionary (sites)' => [ 'en.wiktionary.org', 'enwiktionary' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetWikiName
+ */
+ public function testGetWikiName( $expected, $wikiId ) {
+ $this->assertEquals( $expected, WikiMap::getWikiName( $wikiId ) );
+ }
+
+ public function provideMakeForeignLink() {
+ return [
+ 'unknown' => [ false, 'xyzzy', 'Foo' ],
+ 'enwiki' => [
+ '<a class="external" rel="nofollow" ' .
+ 'href="http://en.example.org/w/Foo">Foo</a>',
+ 'enwiki',
+ 'Foo'
+ ],
+ 'ruwiki' => [
+ '<a class="external" rel="nofollow" ' .
+ 'href="//ru.example.org/wiki/%D0%A4%D1%83">вар</a>',
+ 'ruwiki',
+ 'Фу',
+ 'вар'
+ ],
+ 'enwiktionary (sites)' => [
+ '<a class="external" rel="nofollow" ' .
+ 'href="https://en.wiktionary.org/wiki/Kitten">Kittens!</a>',
+ 'enwiktionary',
+ 'Kitten',
+ 'Kittens!'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideMakeForeignLink
+ */
+ public function testMakeForeignLink( $expected, $wikiId, $page, $text = null ) {
+ $this->assertEquals(
+ $expected,
+ WikiMap::makeForeignLink( $wikiId, $page, $text )
+ );
+ }
+
+ public function provideForeignUserLink() {
+ return [
+ 'unknown' => [ false, 'xyzzy', 'Foo' ],
+ 'enwiki' => [
+ '<a class="external" rel="nofollow" ' .
+ 'href="http://en.example.org/w/User:Foo">User:Foo</a>',
+ 'enwiki',
+ 'Foo'
+ ],
+ 'ruwiki' => [
+ '<a class="external" rel="nofollow" ' .
+ 'href="//ru.example.org/wiki/User:%D0%A4%D1%83">вар</a>',
+ 'ruwiki',
+ 'Фу',
+ 'вар'
+ ],
+ 'enwiktionary (sites)' => [
+ '<a class="external" rel="nofollow" ' .
+ 'href="https://en.wiktionary.org/wiki/User:Dummy">Whatever</a>',
+ 'enwiktionary',
+ 'Dummy',
+ 'Whatever'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideForeignUserLink
+ */
+ public function testForeignUserLink( $expected, $wikiId, $user, $text = null ) {
+ $this->assertEquals( $expected, WikiMap::foreignUserLink( $wikiId, $user, $text ) );
+ }
+
+ public function provideGetForeignURL() {
+ return [
+ 'unknown' => [ false, 'xyzzy', 'Foo' ],
+ 'enwiki' => [ 'http://en.example.org/w/Foo', 'enwiki', 'Foo' ],
+ 'enwiktionary (sites)' => [
+ 'https://en.wiktionary.org/wiki/Testme',
+ 'enwiktionary',
+ 'Testme'
+ ],
+ 'ruwiki with fragment' => [
+ '//ru.example.org/wiki/%D0%A4%D1%83#%D0%B2%D0%B0%D1%80',
+ 'ruwiki',
+ 'Фу',
+ 'вар'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetForeignURL
+ */
+ public function testGetForeignURL( $expected, $wikiId, $page, $fragment = null ) {
+ $this->assertEquals( $expected, WikiMap::getForeignURL( $wikiId, $page, $fragment ) );
+ }
+
+ /**
+ * @covers WikiMap::getCanonicalServerInfoForAllWikis()
+ */
+ public function testGetCanonicalServerInfoForAllWikis() {
+ $expected = [
+ 'thiswiki' => [
+ 'url' => '//this.wiki.org',
+ 'parts' => [ 'scheme' => '', 'host' => 'this.wiki.org', 'delimiter' => '//' ]
+ ],
+ 'enwiki' => [
+ 'url' => 'http://en.example.org',
+ 'parts' => [
+ 'scheme' => 'http', 'host' => 'en.example.org', 'delimiter' => '://' ]
+ ],
+ 'ruwiki' => [
+ 'url' => '//ru.example.org',
+ 'parts' => [ 'scheme' => '', 'host' => 'ru.example.org', 'delimiter' => '//' ]
+ ]
+ ];
+
+ $this->assertArrayEquals(
+ $expected,
+ WikiMap::getCanonicalServerInfoForAllWikis(),
+ true,
+ true
+ );
+ }
+
+ public function provideGetWikiFromUrl() {
+ return [
+ [ 'http://this.wiki.org', 'thiswiki' ],
+ [ 'https://this.wiki.org', 'thiswiki' ],
+ [ 'http://this.wiki.org/$1', 'thiswiki' ],
+ [ 'https://this.wiki.org/$2', 'thiswiki' ],
+ [ 'http://en.example.org', 'enwiki' ],
+ [ 'https://en.example.org', 'enwiki' ],
+ [ 'http://en.example.org/$1', 'enwiki' ],
+ [ 'https://en.example.org/$2', 'enwiki' ],
+ [ 'http://ru.example.org', 'ruwiki' ],
+ [ 'https://ru.example.org', 'ruwiki' ],
+ [ 'http://ru.example.org/$1', 'ruwiki' ],
+ [ 'https://ru.example.org/$2', 'ruwiki' ],
+ [ 'http://not.defined.org', false ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetWikiFromUrl
+ * @covers WikiMap::getWikiFromUrl()
+ */
+ public function testGetWikiFromUrl( $url, $wiki ) {
+ $this->assertEquals( $wiki, WikiMap::getWikiFromUrl( $url ) );
+ }
+
+ /**
+ * @dataProvider provideGetWikiIdFromDomain
+ * @covers WikiMap::getWikiIdFromDomain()
+ */
+ public function testGetWikiIdFromDomain( $domain, $wikiId ) {
+ $this->assertEquals( $wikiId, WikiMap::getWikiIdFromDomain( $domain ) );
+ }
+
+ public function provideGetWikiIdFromDomain() {
+ return [
+ [ 'db-prefix', 'db-prefix' ],
+ [ wfWikiID(), wfWikiID() ],
+ [ new DatabaseDomain( 'db-dash', null, 'prefix' ), 'db-dash-prefix' ]
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/WikiReferenceTest.php b/www/wiki/tests/phpunit/includes/WikiReferenceTest.php
new file mode 100644
index 00000000..e4b21ce5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/WikiReferenceTest.php
@@ -0,0 +1,166 @@
+<?php
+
+/**
+ * @covers WikiReference
+ */
+class WikiReferenceTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function provideGetDisplayName() {
+ return [
+ 'http' => [ 'foo.bar', 'http://foo.bar' ],
+ 'https' => [ 'foo.bar', 'http://foo.bar' ],
+
+ // apparently, this is the expected behavior
+ 'invalid' => [ 'purple kittens', 'purple kittens' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetDisplayName
+ */
+ public function testGetDisplayName( $expected, $canonicalServer ) {
+ $reference = new WikiReference( $canonicalServer, '/wiki/$1' );
+ $this->assertEquals( $expected, $reference->getDisplayName() );
+ }
+
+ public function testGetCanonicalServer() {
+ $reference = new WikiReference( 'https://acme.com', '/wiki/$1', '//acme.com' );
+ $this->assertEquals( 'https://acme.com', $reference->getCanonicalServer() );
+ }
+
+ public function provideGetCanonicalUrl() {
+ return [
+ 'no fragment' => [
+ 'https://acme.com/wiki/Foo',
+ 'https://acme.com',
+ '//acme.com',
+ '/wiki/$1',
+ 'Foo',
+ null
+ ],
+ 'empty fragment' => [
+ 'https://acme.com/wiki/Foo',
+ 'https://acme.com',
+ '//acme.com',
+ '/wiki/$1',
+ 'Foo',
+ ''
+ ],
+ 'fragment' => [
+ 'https://acme.com/wiki/Foo#Bar',
+ 'https://acme.com',
+ '//acme.com',
+ '/wiki/$1',
+ 'Foo',
+ 'Bar'
+ ],
+ 'double fragment' => [
+ 'https://acme.com/wiki/Foo#Bar%23Xus',
+ 'https://acme.com',
+ '//acme.com',
+ '/wiki/$1',
+ 'Foo',
+ 'Bar#Xus'
+ ],
+ 'escaped fragment' => [
+ 'https://acme.com/wiki/Foo%23Bar',
+ 'https://acme.com',
+ '//acme.com',
+ '/wiki/$1',
+ 'Foo#Bar',
+ null
+ ],
+ 'empty path' => [
+ 'https://acme.com/Foo',
+ 'https://acme.com',
+ '//acme.com',
+ '/$1',
+ 'Foo',
+ null
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetCanonicalUrl
+ */
+ public function testGetCanonicalUrl(
+ $expected, $canonicalServer, $server, $path, $page, $fragmentId
+ ) {
+ $reference = new WikiReference( $canonicalServer, $path, $server );
+ $this->assertEquals( $expected, $reference->getCanonicalUrl( $page, $fragmentId ) );
+ }
+
+ /**
+ * @dataProvider provideGetCanonicalUrl
+ * @note getUrl is an alias for getCanonicalUrl
+ */
+ public function testGetUrl( $expected, $canonicalServer, $server, $path, $page, $fragmentId ) {
+ $reference = new WikiReference( $canonicalServer, $path, $server );
+ $this->assertEquals( $expected, $reference->getUrl( $page, $fragmentId ) );
+ }
+
+ public function provideGetFullUrl() {
+ return [
+ 'no fragment' => [
+ '//acme.com/wiki/Foo',
+ 'https://acme.com',
+ '//acme.com',
+ '/wiki/$1',
+ 'Foo',
+ null
+ ],
+ 'empty fragment' => [
+ '//acme.com/wiki/Foo',
+ 'https://acme.com',
+ '//acme.com',
+ '/wiki/$1',
+ 'Foo',
+ ''
+ ],
+ 'fragment' => [
+ '//acme.com/wiki/Foo#Bar',
+ 'https://acme.com',
+ '//acme.com',
+ '/wiki/$1',
+ 'Foo',
+ 'Bar'
+ ],
+ 'double fragment' => [
+ '//acme.com/wiki/Foo#Bar%23Xus',
+ 'https://acme.com',
+ '//acme.com',
+ '/wiki/$1',
+ 'Foo',
+ 'Bar#Xus'
+ ],
+ 'escaped fragment' => [
+ '//acme.com/wiki/Foo%23Bar',
+ 'https://acme.com',
+ '//acme.com',
+ '/wiki/$1',
+ 'Foo#Bar',
+ null
+ ],
+ 'empty path' => [
+ '//acme.com/Foo',
+ 'https://acme.com',
+ '//acme.com',
+ '/$1',
+ 'Foo',
+ null
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetFullUrl
+ */
+ public function testGetFullUrl( $expected, $canonicalServer, $server, $path, $page, $fragmentId ) {
+ $reference = new WikiReference( $canonicalServer, $path, $server );
+ $this->assertEquals( $expected, $reference->getFullUrl( $page, $fragmentId ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/XmlJsTest.php b/www/wiki/tests/phpunit/includes/XmlJsTest.php
new file mode 100644
index 00000000..c7975efa
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/XmlJsTest.php
@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * @group Xml
+ */
+class XmlJsTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers XmlJsCode::__construct
+ * @dataProvider provideConstruction
+ */
+ public function testConstruction( $value ) {
+ $obj = new XmlJsCode( $value );
+ $this->assertEquals( $value, $obj->value );
+ }
+
+ public static function provideConstruction() {
+ return [
+ [ null ],
+ [ '' ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/XmlSelectTest.php b/www/wiki/tests/phpunit/includes/XmlSelectTest.php
new file mode 100644
index 00000000..52e20bdb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/XmlSelectTest.php
@@ -0,0 +1,182 @@
+<?php
+
+/**
+ * @group Xml
+ */
+class XmlSelectTest extends MediaWikiTestCase {
+
+ /**
+ * @var XmlSelect
+ */
+ protected $select;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->select = new XmlSelect();
+ }
+
+ protected function tearDown() {
+ parent::tearDown();
+ $this->select = null;
+ }
+
+ /**
+ * @covers XmlSelect::__construct
+ */
+ public function testConstructWithoutParameters() {
+ $this->assertEquals( '<select></select>', $this->select->getHTML() );
+ }
+
+ /**
+ * Parameters are $name (false), $id (false), $default (false)
+ * @dataProvider provideConstructionParameters
+ * @covers XmlSelect::__construct
+ */
+ public function testConstructParameters( $name, $id, $default, $expected ) {
+ $this->select = new XmlSelect( $name, $id, $default );
+ $this->assertEquals( $expected, $this->select->getHTML() );
+ }
+
+ /**
+ * Provide parameters for testConstructParameters() which use three
+ * parameters:
+ * - $name (default: false)
+ * - $id (default: false)
+ * - $default (default: false)
+ * Provides a fourth parameters representing the expected HTML output
+ */
+ public static function provideConstructionParameters() {
+ return [
+ /**
+ * Values are set following a 3-bit Gray code where two successive
+ * values differ by only one value.
+ * See https://en.wikipedia.org/wiki/Gray_code
+ */
+ # $name $id $default
+ [ false, false, false, '<select></select>' ],
+ [ false, false, 'foo', '<select></select>' ],
+ [ false, 'id', 'foo', '<select id="id"></select>' ],
+ [ false, 'id', false, '<select id="id"></select>' ],
+ [ 'name', 'id', false, '<select name="name" id="id"></select>' ],
+ [ 'name', 'id', 'foo', '<select name="name" id="id"></select>' ],
+ [ 'name', false, 'foo', '<select name="name"></select>' ],
+ [ 'name', false, false, '<select name="name"></select>' ],
+ ];
+ }
+
+ /**
+ * @covers XmlSelect::addOption
+ */
+ public function testAddOption() {
+ $this->select->addOption( 'foo' );
+ $this->assertEquals(
+ '<select><option value="foo">foo</option></select>',
+ $this->select->getHTML()
+ );
+ }
+
+ /**
+ * @covers XmlSelect::addOption
+ */
+ public function testAddOptionWithDefault() {
+ $this->select->addOption( 'foo', true );
+ $this->assertEquals(
+ '<select><option value="1">foo</option></select>',
+ $this->select->getHTML()
+ );
+ }
+
+ /**
+ * @covers XmlSelect::addOption
+ */
+ public function testAddOptionWithFalse() {
+ $this->select->addOption( 'foo', false );
+ $this->assertEquals(
+ '<select><option value="foo">foo</option></select>',
+ $this->select->getHTML()
+ );
+ }
+
+ /**
+ * @covers XmlSelect::addOption
+ */
+ public function testAddOptionWithValueZero() {
+ $this->select->addOption( 'foo', 0 );
+ $this->assertEquals(
+ '<select><option value="0">foo</option></select>',
+ $this->select->getHTML()
+ );
+ }
+
+ /**
+ * @covers XmlSelect::setDefault
+ */
+ public function testSetDefault() {
+ $this->select->setDefault( 'bar1' );
+ $this->select->addOption( 'foo1' );
+ $this->select->addOption( 'bar1' );
+ $this->select->addOption( 'foo2' );
+ $this->assertEquals(
+ '<select><option value="foo1">foo1</option>' . "\n" .
+ '<option value="bar1" selected="">bar1</option>' . "\n" .
+ '<option value="foo2">foo2</option></select>', $this->select->getHTML() );
+ }
+
+ /**
+ * Adding default later on should set the correct selection or
+ * raise an exception.
+ * To handle this, we need to render the options in getHtml()
+ * @covers XmlSelect::setDefault
+ */
+ public function testSetDefaultAfterAddingOptions() {
+ $this->select->addOption( 'foo1' );
+ $this->select->addOption( 'bar1' );
+ $this->select->addOption( 'foo2' );
+ $this->select->setDefault( 'bar1' ); # setting default after adding options
+ $this->assertEquals(
+ '<select><option value="foo1">foo1</option>' . "\n" .
+ '<option value="bar1" selected="">bar1</option>' . "\n" .
+ '<option value="foo2">foo2</option></select>', $this->select->getHTML() );
+ }
+
+ /**
+ * @covers XmlSelect::setAttribute
+ * @covers XmlSelect::getAttribute
+ */
+ public function testGetAttributes() {
+ # create some attributes
+ $this->select->setAttribute( 'dummy', 0x777 );
+ $this->select->setAttribute( 'string', 'euro €' );
+ $this->select->setAttribute( 1911, 'razor' );
+
+ # verify we can retrieve them
+ $this->assertEquals(
+ $this->select->getAttribute( 'dummy' ),
+ 0x777
+ );
+ $this->assertEquals(
+ $this->select->getAttribute( 'string' ),
+ 'euro €'
+ );
+ $this->assertEquals(
+ $this->select->getAttribute( 1911 ),
+ 'razor'
+ );
+
+ # inexistent keys should give us 'null'
+ $this->assertEquals(
+ $this->select->getAttribute( 'I DO NOT EXIT' ),
+ null
+ );
+
+ # verify string / integer
+ $this->assertEquals(
+ $this->select->getAttribute( '1911' ),
+ 'razor'
+ );
+ $this->assertEquals(
+ $this->select->getAttribute( 'dummy' ),
+ 0x777
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/XmlTest.php b/www/wiki/tests/phpunit/includes/XmlTest.php
new file mode 100644
index 00000000..e46fc67f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/XmlTest.php
@@ -0,0 +1,617 @@
+<?php
+
+/**
+ * @group Xml
+ */
+class XmlTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $langObj = Language::factory( 'en' );
+ $langObj->setNamespaces( [
+ -2 => 'Media',
+ -1 => 'Special',
+ 0 => '',
+ 1 => 'Talk',
+ 2 => 'User',
+ 3 => 'User_talk',
+ 4 => 'MyWiki',
+ 5 => 'MyWiki_Talk',
+ 6 => 'File',
+ 7 => 'File_talk',
+ 8 => 'MediaWiki',
+ 9 => 'MediaWiki_talk',
+ 10 => 'Template',
+ 11 => 'Template_talk',
+ 100 => 'Custom',
+ 101 => 'Custom_talk',
+ ] );
+
+ $this->setMwGlobals( [
+ 'wgLang' => $langObj,
+ 'wgUseMediaWikiUIEverywhere' => false,
+ ] );
+ }
+
+ protected function tearDown() {
+ Language::factory( 'en' )->resetNamespaces();
+ parent::tearDown();
+ }
+
+ /**
+ * @covers Xml::expandAttributes
+ */
+ public function testExpandAttributes() {
+ $this->assertNull( Xml::expandAttributes( null ),
+ 'Converting a null list of attributes'
+ );
+ $this->assertEquals( '', Xml::expandAttributes( [] ),
+ 'Converting an empty list of attributes'
+ );
+ }
+
+ /**
+ * @covers Xml::expandAttributes
+ */
+ public function testExpandAttributesException() {
+ $this->setExpectedException( MWException::class );
+ Xml::expandAttributes( 'string' );
+ }
+
+ /**
+ * @covers Xml::element
+ */
+ public function testElementOpen() {
+ $this->assertEquals(
+ '<element>',
+ Xml::element( 'element', null, null ),
+ 'Opening element with no attributes'
+ );
+ }
+
+ /**
+ * @covers Xml::element
+ */
+ public function testElementEmpty() {
+ $this->assertEquals(
+ '<element />',
+ Xml::element( 'element', null, '' ),
+ 'Terminated empty element'
+ );
+ }
+
+ /**
+ * @covers Xml::input
+ */
+ public function testElementInputCanHaveAValueOfZero() {
+ $this->assertEquals(
+ '<input name="name" value="0" />',
+ Xml::input( 'name', false, 0 ),
+ 'Input with a value of 0 (T25797)'
+ );
+ }
+
+ /**
+ * @covers Xml::element
+ */
+ public function testElementEscaping() {
+ $this->assertEquals(
+ '<element>hello &lt;there&gt; you &amp; you</element>',
+ Xml::element( 'element', null, 'hello <there> you & you' ),
+ 'Element with no attributes and content that needs escaping'
+ );
+ }
+
+ /**
+ * @covers Xml::escapeTagsOnly
+ */
+ public function testEscapeTagsOnly() {
+ $this->assertEquals( '&quot;&gt;&lt;', Xml::escapeTagsOnly( '"><' ),
+ 'replace " > and < with their HTML entitites'
+ );
+ }
+
+ /**
+ * @covers Xml::element
+ */
+ public function testElementAttributes() {
+ $this->assertEquals(
+ '<element key="value" <>="&lt;&gt;">',
+ Xml::element( 'element', [ 'key' => 'value', '<>' => '<>' ], null ),
+ 'Element attributes, keys are not escaped'
+ );
+ }
+
+ /**
+ * @covers Xml::openElement
+ */
+ public function testOpenElement() {
+ $this->assertEquals(
+ '<element k="v">',
+ Xml::openElement( 'element', [ 'k' => 'v' ] ),
+ 'openElement() shortcut'
+ );
+ }
+
+ /**
+ * @covers Xml::closeElement
+ */
+ public function testCloseElement() {
+ $this->assertEquals( '</element>', Xml::closeElement( 'element' ), 'closeElement() shortcut' );
+ }
+
+ public function provideMonthSelector() {
+ global $wgLang;
+
+ $header = '<select name="month" id="month" class="mw-month-selector">';
+ $header2 = '<select name="month" id="monthSelector" class="mw-month-selector">';
+ $monthsString = '';
+ for ( $i = 1; $i < 13; $i++ ) {
+ $monthName = $wgLang->getMonthName( $i );
+ $monthsString .= "<option value=\"{$i}\">{$monthName}</option>";
+ if ( $i !== 12 ) {
+ $monthsString .= "\n";
+ }
+ }
+ $monthsString2 = str_replace(
+ '<option value="12">December</option>',
+ '<option value="12" selected="">December</option>',
+ $monthsString
+ );
+ $end = '</select>';
+
+ $allMonths = "<option value=\"AllMonths\">all</option>\n";
+ return [
+ [ $header . $monthsString . $end, '', null, 'month' ],
+ [ $header . $monthsString2 . $end, 12, null, 'month' ],
+ [ $header2 . $monthsString . $end, '', null, 'monthSelector' ],
+ [ $header . $allMonths . $monthsString . $end, '', 'AllMonths', 'month' ],
+
+ ];
+ }
+
+ /**
+ * @covers Xml::monthSelector
+ * @dataProvider provideMonthSelector
+ */
+ public function testMonthSelector( $expected, $selected, $allmonths, $id ) {
+ $this->assertEquals(
+ $expected,
+ Xml::monthSelector( $selected, $allmonths, $id )
+ );
+ }
+
+ /**
+ * @covers Xml::span
+ */
+ public function testSpan() {
+ $this->assertEquals(
+ '<span class="foo" id="testSpan">element</span>',
+ Xml::span( 'element', 'foo', [ 'id' => 'testSpan' ] )
+ );
+ }
+
+ /**
+ * @covers Xml::dateMenu
+ */
+ public function testDateMenu() {
+ $curYear = intval( gmdate( 'Y' ) );
+ $prevYear = $curYear - 1;
+
+ $curMonth = intval( gmdate( 'n' ) );
+
+ $nextMonth = $curMonth + 1;
+ if ( $nextMonth == 13 ) {
+ $nextMonth = 1;
+ }
+
+ $this->assertEquals(
+ '<label for="year">From year (and earlier):</label> ' .
+ '<input id="year" maxlength="4" size="7" type="number" value="2011" name="year"/> ' .
+ '<label for="month">From month (and earlier):</label> ' .
+ '<select name="month" id="month" class="mw-month-selector">' .
+ '<option value="-1">all</option>' . "\n" .
+ '<option value="1">January</option>' . "\n" .
+ '<option value="2" selected="">February</option>' . "\n" .
+ '<option value="3">March</option>' . "\n" .
+ '<option value="4">April</option>' . "\n" .
+ '<option value="5">May</option>' . "\n" .
+ '<option value="6">June</option>' . "\n" .
+ '<option value="7">July</option>' . "\n" .
+ '<option value="8">August</option>' . "\n" .
+ '<option value="9">September</option>' . "\n" .
+ '<option value="10">October</option>' . "\n" .
+ '<option value="11">November</option>' . "\n" .
+ '<option value="12">December</option></select>',
+ Xml::dateMenu( 2011, 02 ),
+ "Date menu for february 2011"
+ );
+ $this->assertEquals(
+ '<label for="year">From year (and earlier):</label> ' .
+ '<input id="year" maxlength="4" size="7" type="number" value="2011" name="year"/> ' .
+ '<label for="month">From month (and earlier):</label> ' .
+ '<select name="month" id="month" class="mw-month-selector">' .
+ '<option value="-1">all</option>' . "\n" .
+ '<option value="1">January</option>' . "\n" .
+ '<option value="2">February</option>' . "\n" .
+ '<option value="3">March</option>' . "\n" .
+ '<option value="4">April</option>' . "\n" .
+ '<option value="5">May</option>' . "\n" .
+ '<option value="6">June</option>' . "\n" .
+ '<option value="7">July</option>' . "\n" .
+ '<option value="8">August</option>' . "\n" .
+ '<option value="9">September</option>' . "\n" .
+ '<option value="10">October</option>' . "\n" .
+ '<option value="11">November</option>' . "\n" .
+ '<option value="12">December</option></select>',
+ Xml::dateMenu( 2011, -1 ),
+ "Date menu with negative month for 'All'"
+ );
+ $this->assertEquals(
+ Xml::dateMenu( $curYear, $curMonth ),
+ Xml::dateMenu( '', $curMonth ),
+ "Date menu year is the current one when not specified"
+ );
+
+ $wantedYear = $nextMonth == 1 ? $curYear : $prevYear;
+ $this->assertEquals(
+ Xml::dateMenu( $wantedYear, $nextMonth ),
+ Xml::dateMenu( '', $nextMonth ),
+ "Date menu next month is 11 months ago"
+ );
+
+ $this->assertEquals(
+ '<label for="year">From year (and earlier):</label> ' .
+ '<input id="year" maxlength="4" size="7" type="number" name="year"/> ' .
+ '<label for="month">From month (and earlier):</label> ' .
+ '<select name="month" id="month" class="mw-month-selector">' .
+ '<option value="-1">all</option>' . "\n" .
+ '<option value="1">January</option>' . "\n" .
+ '<option value="2">February</option>' . "\n" .
+ '<option value="3">March</option>' . "\n" .
+ '<option value="4">April</option>' . "\n" .
+ '<option value="5">May</option>' . "\n" .
+ '<option value="6">June</option>' . "\n" .
+ '<option value="7">July</option>' . "\n" .
+ '<option value="8">August</option>' . "\n" .
+ '<option value="9">September</option>' . "\n" .
+ '<option value="10">October</option>' . "\n" .
+ '<option value="11">November</option>' . "\n" .
+ '<option value="12">December</option></select>',
+ Xml::dateMenu( '', '' ),
+ "Date menu with neither year or month"
+ );
+ }
+
+ /**
+ * @covers Xml::textarea
+ */
+ public function testTextareaNoContent() {
+ $this->assertEquals(
+ '<textarea name="name" id="name" cols="40" rows="5"></textarea>',
+ Xml::textarea( 'name', '' ),
+ 'textarea() with not content'
+ );
+ }
+
+ /**
+ * @covers Xml::textarea
+ */
+ public function testTextareaAttribs() {
+ $this->assertEquals(
+ '<textarea name="name" id="name" cols="20" rows="10">&lt;txt&gt;</textarea>',
+ Xml::textarea( 'name', '<txt>', 20, 10 ),
+ 'textarea() with custom attribs'
+ );
+ }
+
+ /**
+ * @covers Xml::label
+ */
+ public function testLabelCreation() {
+ $this->assertEquals(
+ '<label for="id">name</label>',
+ Xml::label( 'name', 'id' ),
+ 'label() with no attribs'
+ );
+ }
+
+ /**
+ * @covers Xml::label
+ */
+ public function testLabelAttributeCanOnlyBeClassOrTitle() {
+ $this->assertEquals(
+ '<label for="id">name</label>',
+ Xml::label( 'name', 'id', [ 'generated' => true ] ),
+ 'label() can not be given a generated attribute'
+ );
+ $this->assertEquals(
+ '<label for="id" class="nice">name</label>',
+ Xml::label( 'name', 'id', [ 'class' => 'nice' ] ),
+ 'label() can get a class attribute'
+ );
+ $this->assertEquals(
+ '<label for="id" title="nice tooltip">name</label>',
+ Xml::label( 'name', 'id', [ 'title' => 'nice tooltip' ] ),
+ 'label() can get a title attribute'
+ );
+ $this->assertEquals(
+ '<label for="id" class="nice" title="nice tooltip">name</label>',
+ Xml::label( 'name', 'id', [
+ 'generated' => true,
+ 'class' => 'nice',
+ 'title' => 'nice tooltip',
+ 'anotherattr' => 'value',
+ ]
+ ),
+ 'label() skip all attributes but "class" and "title"'
+ );
+ }
+
+ /**
+ * @covers Xml::languageSelector
+ */
+ public function testLanguageSelector() {
+ $select = Xml::languageSelector( 'en', true, null,
+ [ 'id' => 'testlang' ], wfMessage( 'yourlanguage' ) );
+ $this->assertEquals(
+ '<label for="testlang">Language:</label>',
+ $select[0]
+ );
+ }
+
+ /**
+ * @covers Xml::encodeJsVar
+ */
+ public function testEncodeJsVarBoolean() {
+ $this->assertEquals(
+ 'true',
+ Xml::encodeJsVar( true ),
+ 'encodeJsVar() with boolean'
+ );
+ }
+
+ /**
+ * @covers Xml::encodeJsVar
+ */
+ public function testEncodeJsVarNull() {
+ $this->assertEquals(
+ 'null',
+ Xml::encodeJsVar( null ),
+ 'encodeJsVar() with null'
+ );
+ }
+
+ /**
+ * @covers Xml::encodeJsVar
+ */
+ public function testEncodeJsVarArray() {
+ $this->assertEquals(
+ '["a",1]',
+ Xml::encodeJsVar( [ 'a', 1 ] ),
+ 'encodeJsVar() with array'
+ );
+ $this->assertEquals(
+ '{"a":"a","b":1}',
+ Xml::encodeJsVar( [ 'a' => 'a', 'b' => 1 ] ),
+ 'encodeJsVar() with associative array'
+ );
+ }
+
+ /**
+ * @covers Xml::encodeJsVar
+ */
+ public function testEncodeJsVarObject() {
+ $this->assertEquals(
+ '{"a":"a","b":1}',
+ Xml::encodeJsVar( (object)[ 'a' => 'a', 'b' => 1 ] ),
+ 'encodeJsVar() with object'
+ );
+ }
+
+ /**
+ * @covers Xml::encodeJsVar
+ */
+ public function testEncodeJsVarInt() {
+ $this->assertEquals(
+ '123456',
+ Xml::encodeJsVar( 123456 ),
+ 'encodeJsVar() with int'
+ );
+ }
+
+ /**
+ * @covers Xml::encodeJsVar
+ */
+ public function testEncodeJsVarFloat() {
+ $this->assertEquals(
+ '1.23456',
+ Xml::encodeJsVar( 1.23456 ),
+ 'encodeJsVar() with float'
+ );
+ }
+
+ /**
+ * @covers Xml::encodeJsVar
+ */
+ public function testEncodeJsVarIntString() {
+ $this->assertEquals(
+ '"123456"',
+ Xml::encodeJsVar( '123456' ),
+ 'encodeJsVar() with int-like string'
+ );
+ }
+
+ /**
+ * @covers Xml::encodeJsVar
+ */
+ public function testEncodeJsVarFloatString() {
+ $this->assertEquals(
+ '"1.23456"',
+ Xml::encodeJsVar( '1.23456' ),
+ 'encodeJsVar() with float-like string'
+ );
+ }
+
+ /**
+ * @covers Xml::listDropDown
+ */
+ public function testListDropDown() {
+ $this->assertEquals(
+ '<select name="test-name" id="test-name" class="test-css" tabindex="2">' .
+ '<option value="other">other reasons</option>' . "\n" .
+ '<optgroup label="Foo">' .
+ '<option value="Foo 1">Foo 1</option>' . "\n" .
+ '<option value="Example" selected="">Example</option>' . "\n" .
+ '</optgroup>' . "\n" .
+ '<optgroup label="Bar">' .
+ '<option value="Bar 1">Bar 1</option>' . "\n" .
+ '</optgroup>' .
+ '</select>',
+ Xml::listDropDown(
+ // name
+ 'test-name',
+ // source list
+ "* Foo\n** Foo 1\n** Example\n* Bar\n** Bar 1",
+ // other
+ 'other reasons',
+ // selected
+ 'Example',
+ // class
+ 'test-css',
+ // tabindex
+ 2
+ )
+ );
+ }
+
+ /**
+ * @covers Xml::listDropDownOptions
+ */
+ public function testListDropDownOptions() {
+ $this->assertEquals(
+ [
+ 'other reasons' => 'other',
+ 'Foo' => [
+ 'Foo 1' => 'Foo 1',
+ 'Example' => 'Example',
+ ],
+ 'Bar' => [
+ 'Bar 1' => 'Bar 1',
+ ],
+ ],
+ Xml::listDropDownOptions(
+ "* Foo\n** Foo 1\n** Example\n* Bar\n** Bar 1",
+ [ 'other' => 'other reasons' ]
+ )
+ );
+ }
+
+ /**
+ * @covers Xml::listDropDownOptionsOoui
+ */
+ public function testListDropDownOptionsOoui() {
+ $this->assertEquals(
+ [
+ [ 'data' => 'other', 'label' => 'other reasons' ],
+ [ 'optgroup' => 'Foo' ],
+ [ 'data' => 'Foo 1', 'label' => 'Foo 1' ],
+ [ 'data' => 'Example', 'label' => 'Example' ],
+ [ 'optgroup' => 'Bar' ],
+ [ 'data' => 'Bar 1', 'label' => 'Bar 1' ],
+ ],
+ Xml::listDropDownOptionsOoui( [
+ 'other reasons' => 'other',
+ 'Foo' => [
+ 'Foo 1' => 'Foo 1',
+ 'Example' => 'Example',
+ ],
+ 'Bar' => [
+ 'Bar 1' => 'Bar 1',
+ ],
+ ] )
+ );
+ }
+
+ /**
+ * @covers Xml::fieldset
+ */
+ public function testFieldset() {
+ $this->assertEquals(
+ "<fieldset>\n",
+ Xml::fieldset(),
+ 'Opening tag'
+ );
+ $this->assertEquals(
+ "<fieldset>\n",
+ Xml::fieldset( false ),
+ 'Opening tag (false means no legend)'
+ );
+ $this->assertEquals(
+ "<fieldset>\n",
+ Xml::fieldset( '' ),
+ 'Opening tag (empty string also means no legend)'
+ );
+ $this->assertEquals(
+ "<fieldset>\n<legend>Foo</legend>\n",
+ Xml::fieldset( 'Foo' ),
+ 'Opening tag with legend'
+ );
+ $this->assertEquals(
+ "<fieldset>\n<legend>Foo</legend>\nBar\n</fieldset>\n",
+ Xml::fieldset( 'Foo', 'Bar' ),
+ 'Entire element with legend'
+ );
+ $this->assertEquals(
+ "<fieldset>\n<legend>Foo</legend>\n",
+ Xml::fieldset( 'Foo', false ),
+ 'Opening tag with legend (false means no content and no closing tag)'
+ );
+ $this->assertEquals(
+ "<fieldset>\n<legend>Foo</legend>\n\n</fieldset>\n",
+ Xml::fieldset( 'Foo', '' ),
+ 'Entire element with legend but no content (empty string generates a closing tag)'
+ );
+ $this->assertEquals(
+ "<fieldset class=\"bar\">\n<legend>Foo</legend>\nBar\n</fieldset>\n",
+ Xml::fieldset( 'Foo', 'Bar', [ 'class' => 'bar' ] ),
+ 'Opening tag with legend and attributes'
+ );
+ $this->assertEquals(
+ "<fieldset class=\"bar\">\n<legend>Foo</legend>\n",
+ Xml::fieldset( 'Foo', false, [ 'class' => 'bar' ] ),
+ 'Entire element with legend and attributes'
+ );
+ }
+
+ /**
+ * @covers Xml::buildTable
+ */
+ public function testBuildTable() {
+ $firstRow = [ 'foo', 'bar' ];
+ $secondRow = [ 'Berlin', 'Tehran' ];
+ $headers = [ 'header1', 'header2' ];
+ $expected = '<table id="testTable"><thead id="testTable"><th>header1</th>' .
+ '<th>header2</th></thead><tr><td>foo</td><td>bar</td></tr><tr><td>Berlin</td>' .
+ '<td>Tehran</td></tr></table>';
+ $this->assertEquals(
+ $expected,
+ Xml::buildTable(
+ [ $firstRow, $secondRow ],
+ [ 'id' => 'testTable' ],
+ $headers
+ )
+ );
+ }
+
+ /**
+ * @covers Xml::buildTableRow
+ */
+ public function testBuildTableRow() {
+ $this->assertEquals(
+ '<tr id="testRow"><td>foo</td><td>bar</td></tr>',
+ Xml::buildTableRow( [ 'id' => 'testRow' ], [ 'foo', 'bar' ] )
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/actions/ActionTest.php b/www/wiki/tests/phpunit/includes/actions/ActionTest.php
new file mode 100644
index 00000000..b96b4914
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/actions/ActionTest.php
@@ -0,0 +1,208 @@
+<?php
+
+/**
+ * @covers Action
+ *
+ * @group Action
+ * @group Database
+ *
+ * @license GNU GPL v2+
+ * @author Thiemo Kreuz
+ */
+class ActionTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $context = $this->getContext();
+ $this->setMwGlobals( 'wgActions', [
+ 'null' => null,
+ 'disabled' => false,
+ 'view' => true,
+ 'edit' => true,
+ 'revisiondelete' => SpecialPageAction::class,
+ 'dummy' => true,
+ 'string' => 'NamedDummyAction',
+ 'declared' => 'NonExistingClassName',
+ 'callable' => [ $this, 'dummyActionCallback' ],
+ 'object' => new InstantiatedDummyAction( $context->getWikiPage(), $context ),
+ ] );
+ }
+
+ private function getPage() {
+ return WikiPage::factory( Title::makeTitle( 0, 'Title' ) );
+ }
+
+ private function getContext( $requestedAction = null ) {
+ $request = new FauxRequest( [ 'action' => $requestedAction ] );
+
+ $context = new DerivativeContext( RequestContext::getMain() );
+ $context->setRequest( $request );
+ $context->setWikiPage( $this->getPage() );
+
+ return $context;
+ }
+
+ public function actionProvider() {
+ return [
+ [ 'dummy', 'DummyAction' ],
+ [ 'string', 'NamedDummyAction' ],
+ [ 'callable', 'CalledDummyAction' ],
+ [ 'object', 'InstantiatedDummyAction' ],
+
+ // Capitalization is ignored
+ [ 'DUMMY', 'DummyAction' ],
+ [ 'STRING', 'NamedDummyAction' ],
+
+ // Null and non-existing values
+ [ 'null', null ],
+ [ 'undeclared', null ],
+ [ '', null ],
+ [ false, null ],
+ ];
+ }
+
+ /**
+ * @dataProvider actionProvider
+ * @param string $requestedAction
+ * @param string|null $expected
+ */
+ public function testActionExists( $requestedAction, $expected ) {
+ $exists = Action::exists( $requestedAction );
+
+ $this->assertSame( $expected !== null, $exists );
+ }
+
+ public function testActionExists_doesNotRequireInstantiation() {
+ // The method is not supposed to check if the action can be instantiated.
+ $exists = Action::exists( 'declared' );
+
+ $this->assertTrue( $exists );
+ }
+
+ /**
+ * @dataProvider actionProvider
+ * @param string $requestedAction
+ * @param string|null $expected
+ */
+ public function testGetActionName( $requestedAction, $expected ) {
+ $context = $this->getContext( $requestedAction );
+ $actionName = Action::getActionName( $context );
+
+ $this->assertEquals( $expected ?: 'nosuchaction', $actionName );
+ }
+
+ public function testGetActionName_editredlinkWorkaround() {
+ // See https://phabricator.wikimedia.org/T22966
+ $context = $this->getContext( 'editredlink' );
+ $actionName = Action::getActionName( $context );
+
+ $this->assertEquals( 'edit', $actionName );
+ }
+
+ public function testGetActionName_historysubmitWorkaround() {
+ // See https://phabricator.wikimedia.org/T22966
+ $context = $this->getContext( 'historysubmit' );
+ $actionName = Action::getActionName( $context );
+
+ $this->assertEquals( 'view', $actionName );
+ }
+
+ public function testGetActionName_revisiondeleteWorkaround() {
+ // See https://phabricator.wikimedia.org/T22966
+ $context = $this->getContext( 'historysubmit' );
+ $context->getRequest()->setVal( 'revisiondelete', true );
+ $actionName = Action::getActionName( $context );
+
+ $this->assertEquals( 'revisiondelete', $actionName );
+ }
+
+ public function testGetActionName_whenCanNotUseWikiPage_defaultsToView() {
+ $request = new FauxRequest( [ 'action' => 'edit' ] );
+ $context = new DerivativeContext( RequestContext::getMain() );
+ $context->setRequest( $request );
+ $actionName = Action::getActionName( $context );
+
+ $this->assertEquals( 'view', $actionName );
+ }
+
+ /**
+ * @dataProvider actionProvider
+ * @param string $requestedAction
+ * @param string|null $expected
+ */
+ public function testActionFactory( $requestedAction, $expected ) {
+ $context = $this->getContext();
+ $action = Action::factory( $requestedAction, $context->getWikiPage(), $context );
+
+ $this->assertType( $expected ?: 'null', $action );
+ }
+
+ public function testNull_doesNotExist() {
+ $exists = Action::exists( null );
+
+ $this->assertFalse( $exists );
+ }
+
+ public function testNull_defaultsToView() {
+ $context = $this->getContext( null );
+ $actionName = Action::getActionName( $context );
+
+ $this->assertEquals( 'view', $actionName );
+ }
+
+ public function testNull_canNotBeInstantiated() {
+ $page = $this->getPage();
+ $action = Action::factory( null, $page );
+
+ $this->assertNull( $action );
+ }
+
+ public function testDisabledAction_exists() {
+ $exists = Action::exists( 'disabled' );
+
+ $this->assertTrue( $exists );
+ }
+
+ public function testDisabledAction_isNotResolved() {
+ $context = $this->getContext( 'disabled' );
+ $actionName = Action::getActionName( $context );
+
+ $this->assertEquals( 'nosuchaction', $actionName );
+ }
+
+ public function testDisabledAction_factoryReturnsFalse() {
+ $page = $this->getPage();
+ $action = Action::factory( 'disabled', $page );
+
+ $this->assertFalse( $action );
+ }
+
+ public function dummyActionCallback() {
+ $context = $this->getContext();
+ return new CalledDummyAction( $context->getWikiPage(), $context );
+ }
+
+}
+
+class DummyAction extends Action {
+
+ public function getName() {
+ return static::class;
+ }
+
+ public function show() {
+ }
+
+ public function execute() {
+ }
+}
+
+class NamedDummyAction extends DummyAction {
+}
+
+class CalledDummyAction extends DummyAction {
+}
+
+class InstantiatedDummyAction extends DummyAction {
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiBaseTest.php b/www/wiki/tests/phpunit/includes/api/ApiBaseTest.php
new file mode 100644
index 00000000..4bffc742
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiBaseTest.php
@@ -0,0 +1,1275 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiBase
+ */
+class ApiBaseTest extends ApiTestCase {
+ /**
+ * This covers a variety of stub methods that return a fixed value.
+ *
+ * @param string|array $method Name of method, or [ name, params... ]
+ * @param string $value Expected value
+ *
+ * @dataProvider provideStubMethods
+ */
+ public function testStubMethods( $expected, $method, $args = [] ) {
+ // Some of these are protected
+ $mock = TestingAccessWrapper::newFromObject( new MockApi() );
+ $result = call_user_func_array( [ $mock, $method ], $args );
+ $this->assertSame( $expected, $result );
+ }
+
+ public function provideStubMethods() {
+ return [
+ [ null, 'getModuleManager' ],
+ [ null, 'getCustomPrinter' ],
+ [ [], 'getHelpUrls' ],
+ // @todo This is actually overriden by MockApi
+ // [ [], 'getAllowedParams' ],
+ [ true, 'shouldCheckMaxLag' ],
+ [ true, 'isReadMode' ],
+ [ false, 'isWriteMode' ],
+ [ false, 'mustBePosted' ],
+ [ false, 'isDeprecated' ],
+ [ false, 'isInternal' ],
+ [ false, 'needsToken' ],
+ [ null, 'getWebUITokenSalt', [ [] ] ],
+ [ null, 'getConditionalRequestData', [ 'etag' ] ],
+ [ null, 'dynamicParameterDocumentation' ],
+ ];
+ }
+
+ public function testRequireOnlyOneParameterDefault() {
+ $mock = new MockApi();
+ $mock->requireOnlyOneParameter(
+ [ "filename" => "foo.txt", "enablechunks" => false ],
+ "filename", "enablechunks"
+ );
+ $this->assertTrue( true );
+ }
+
+ /**
+ * @expectedException ApiUsageException
+ */
+ public function testRequireOnlyOneParameterZero() {
+ $mock = new MockApi();
+ $mock->requireOnlyOneParameter(
+ [ "filename" => "foo.txt", "enablechunks" => 0 ],
+ "filename", "enablechunks"
+ );
+ }
+
+ /**
+ * @expectedException ApiUsageException
+ */
+ public function testRequireOnlyOneParameterTrue() {
+ $mock = new MockApi();
+ $mock->requireOnlyOneParameter(
+ [ "filename" => "foo.txt", "enablechunks" => true ],
+ "filename", "enablechunks"
+ );
+ }
+
+ public function testRequireOnlyOneParameterMissing() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'One of the parameters "foo" and "bar" is required.' );
+ $mock = new MockApi();
+ $mock->requireOnlyOneParameter(
+ [ "filename" => "foo.txt", "enablechunks" => false ],
+ "foo", "bar" );
+ }
+
+ public function testRequireMaxOneParameterZero() {
+ $mock = new MockApi();
+ $mock->requireMaxOneParameter(
+ [ 'foo' => 'bar', 'baz' => 'quz' ],
+ 'squirrel' );
+ $this->assertTrue( true );
+ }
+
+ public function testRequireMaxOneParameterOne() {
+ $mock = new MockApi();
+ $mock->requireMaxOneParameter(
+ [ 'foo' => 'bar', 'baz' => 'quz' ],
+ 'foo', 'squirrel' );
+ $this->assertTrue( true );
+ }
+
+ public function testRequireMaxOneParameterTwo() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The parameters "foo" and "baz" can not be used together.' );
+ $mock = new MockApi();
+ $mock->requireMaxOneParameter(
+ [ 'foo' => 'bar', 'baz' => 'quz' ],
+ 'foo', 'baz' );
+ }
+
+ public function testRequireAtLeastOneParameterZero() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'At least one of the parameters "foo" and "bar" is required.' );
+ $mock = new MockApi();
+ $mock->requireAtLeastOneParameter(
+ [ 'a' => 'b', 'c' => 'd' ],
+ 'foo', 'bar' );
+ }
+
+ public function testRequireAtLeastOneParameterOne() {
+ $mock = new MockApi();
+ $mock->requireAtLeastOneParameter(
+ [ 'a' => 'b', 'c' => 'd' ],
+ 'foo', 'a' );
+ $this->assertTrue( true );
+ }
+
+ public function testRequireAtLeastOneParameterTwo() {
+ $mock = new MockApi();
+ $mock->requireAtLeastOneParameter(
+ [ 'a' => 'b', 'c' => 'd' ],
+ 'a', 'c' );
+ $this->assertTrue( true );
+ }
+
+ public function testGetTitleOrPageIdBadParams() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The parameters "title" and "pageid" can not be used together.' );
+ $mock = new MockApi();
+ $mock->getTitleOrPageId( [ 'title' => 'a', 'pageid' => 7 ] );
+ }
+
+ public function testGetTitleOrPageIdTitle() {
+ $mock = new MockApi();
+ $result = $mock->getTitleOrPageId( [ 'title' => 'Foo' ] );
+ $this->assertInstanceOf( WikiPage::class, $result );
+ $this->assertSame( 'Foo', $result->getTitle()->getPrefixedText() );
+ }
+
+ public function testGetTitleOrPageIdInvalidTitle() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Bad title "|".' );
+ $mock = new MockApi();
+ $mock->getTitleOrPageId( [ 'title' => '|' ] );
+ }
+
+ public function testGetTitleOrPageIdSpecialTitle() {
+ $this->setExpectedException( ApiUsageException::class,
+ "Namespace doesn't allow actual pages." );
+ $mock = new MockApi();
+ $mock->getTitleOrPageId( [ 'title' => 'Special:RandomPage' ] );
+ }
+
+ public function testGetTitleOrPageIdPageId() {
+ $result = ( new MockApi() )->getTitleOrPageId(
+ [ 'pageid' => Title::newFromText( 'UTPage' )->getArticleId() ] );
+ $this->assertInstanceOf( WikiPage::class, $result );
+ $this->assertSame( 'UTPage', $result->getTitle()->getPrefixedText() );
+ }
+
+ public function testGetTitleOrPageIdInvalidPageId() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no page with ID 2147483648.' );
+ $mock = new MockApi();
+ $mock->getTitleOrPageId( [ 'pageid' => 2147483648 ] );
+ }
+
+ public function testGetTitleFromTitleOrPageIdBadParams() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The parameters "title" and "pageid" can not be used together.' );
+ $mock = new MockApi();
+ $mock->getTitleFromTitleOrPageId( [ 'title' => 'a', 'pageid' => 7 ] );
+ }
+
+ public function testGetTitleFromTitleOrPageIdTitle() {
+ $mock = new MockApi();
+ $result = $mock->getTitleFromTitleOrPageId( [ 'title' => 'Foo' ] );
+ $this->assertInstanceOf( Title::class, $result );
+ $this->assertSame( 'Foo', $result->getPrefixedText() );
+ }
+
+ public function testGetTitleFromTitleOrPageIdInvalidTitle() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Bad title "|".' );
+ $mock = new MockApi();
+ $mock->getTitleFromTitleOrPageId( [ 'title' => '|' ] );
+ }
+
+ public function testGetTitleFromTitleOrPageIdPageId() {
+ $result = ( new MockApi() )->getTitleFromTitleOrPageId(
+ [ 'pageid' => Title::newFromText( 'UTPage' )->getArticleId() ] );
+ $this->assertInstanceOf( Title::class, $result );
+ $this->assertSame( 'UTPage', $result->getPrefixedText() );
+ }
+
+ public function testGetTitleFromTitleOrPageIdInvalidPageId() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no page with ID 298401643.' );
+ $mock = new MockApi();
+ $mock->getTitleFromTitleOrPageId( [ 'pageid' => 298401643 ] );
+ }
+
+ /**
+ * @dataProvider provideGetParameterFromSettings
+ * @param string|null $input
+ * @param array $paramSettings
+ * @param mixed $expected
+ * @param array $options Key-value pairs:
+ * 'parseLimits': true|false
+ * 'apihighlimits': true|false
+ * 'internalmode': true|false
+ * @param string[] $warnings
+ */
+ public function testGetParameterFromSettings(
+ $input, $paramSettings, $expected, $warnings, $options = []
+ ) {
+ $mock = new MockApi();
+ $wrapper = TestingAccessWrapper::newFromObject( $mock );
+
+ $context = new DerivativeContext( $mock );
+ $context->setRequest( new FauxRequest(
+ $input !== null ? [ 'myParam' => $input ] : [] ) );
+ $wrapper->mMainModule = new ApiMain( $context );
+
+ $parseLimits = isset( $options['parseLimits'] ) ?
+ $options['parseLimits'] : true;
+
+ if ( !empty( $options['apihighlimits'] ) ) {
+ $context->setUser( self::$users['sysop']->getUser() );
+ }
+
+ if ( isset( $options['internalmode'] ) && !$options['internalmode'] ) {
+ $mainWrapper = TestingAccessWrapper::newFromObject( $wrapper->mMainModule );
+ $mainWrapper->mInternalMode = false;
+ }
+
+ // If we're testing tags, set up some tags
+ if ( isset( $paramSettings[ApiBase::PARAM_TYPE] ) &&
+ $paramSettings[ApiBase::PARAM_TYPE] === 'tags'
+ ) {
+ ChangeTags::defineTag( 'tag1' );
+ ChangeTags::defineTag( 'tag2' );
+ }
+
+ if ( $expected instanceof Exception ) {
+ try {
+ $wrapper->getParameterFromSettings( 'myParam', $paramSettings,
+ $parseLimits );
+ $this->fail( 'No exception thrown' );
+ } catch ( Exception $ex ) {
+ $this->assertEquals( $expected, $ex );
+ }
+ } else {
+ $result = $wrapper->getParameterFromSettings( 'myParam',
+ $paramSettings, $parseLimits );
+ if ( isset( $paramSettings[ApiBase::PARAM_TYPE] ) &&
+ $paramSettings[ApiBase::PARAM_TYPE] === 'timestamp' &&
+ $expected === 'now'
+ ) {
+ // Allow one second of fuzziness. Make sure the formats are
+ // correct!
+ $this->assertRegExp( '/^\d{14}$/', $result );
+ $this->assertLessThanOrEqual( 1,
+ abs( wfTimestamp( TS_UNIX, $result ) - time() ),
+ "Result $result differs from expected $expected by " .
+ 'more than one second' );
+ } else {
+ $this->assertSame( $expected, $result );
+ }
+ $actualWarnings = array_map( function ( $warn ) {
+ return $warn instanceof Message
+ ? array_merge( [ $warn->getKey() ], $warn->getParams() )
+ : $warn;
+ }, $mock->warnings );
+ $this->assertSame( $warnings, $actualWarnings );
+ }
+
+ if ( !empty( $paramSettings[ApiBase::PARAM_SENSITIVE] ) ||
+ ( isset( $paramSettings[ApiBase::PARAM_TYPE] ) &&
+ $paramSettings[ApiBase::PARAM_TYPE] === 'password' )
+ ) {
+ $mainWrapper = TestingAccessWrapper::newFromObject( $wrapper->getMain() );
+ $this->assertSame( [ 'myParam' ],
+ $mainWrapper->getSensitiveParams() );
+ }
+ }
+
+ public static function provideGetParameterFromSettings() {
+ $warnings = [
+ [ 'apiwarn-badutf8', 'myParam' ],
+ ];
+
+ $c0 = '';
+ $enc = '';
+ for ( $i = 0; $i < 32; $i++ ) {
+ $c0 .= chr( $i );
+ $enc .= ( $i === 9 || $i === 10 || $i === 13 )
+ ? chr( $i )
+ : '�';
+ }
+
+ $returnArray = [
+ 'Basic param' => [ 'bar', null, 'bar', [] ],
+ 'Basic param, C0 controls' => [ $c0, null, $enc, $warnings ],
+ 'String param' => [ 'bar', '', 'bar', [] ],
+ 'String param, defaulted' => [ null, '', '', [] ],
+ 'String param, empty' => [ '', 'default', '', [] ],
+ 'String param, required, empty' => [
+ '',
+ [ ApiBase::PARAM_DFLT => 'default', ApiBase::PARAM_REQUIRED => true ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-missingparam', 'myParam' ] ),
+ []
+ ],
+ 'Multi-valued parameter' => [
+ 'a|b|c',
+ [ ApiBase::PARAM_ISMULTI => true ],
+ [ 'a', 'b', 'c' ],
+ []
+ ],
+ 'Multi-valued parameter, alternative separator' => [
+ "\x1fa|b\x1fc|d",
+ [ ApiBase::PARAM_ISMULTI => true ],
+ [ 'a|b', 'c|d' ],
+ []
+ ],
+ 'Multi-valued parameter, other C0 controls' => [
+ $c0,
+ [ ApiBase::PARAM_ISMULTI => true ],
+ [ $enc ],
+ $warnings
+ ],
+ 'Multi-valued parameter, other C0 controls (2)' => [
+ "\x1f" . $c0,
+ [ ApiBase::PARAM_ISMULTI => true ],
+ [ substr( $enc, 0, -3 ), '' ],
+ $warnings
+ ],
+ 'Multi-valued parameter with limits' => [
+ 'a|b|c',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ISMULTI_LIMIT1 => 3,
+ ],
+ [ 'a', 'b', 'c' ],
+ [],
+ ],
+ 'Multi-valued parameter with exceeded limits' => [
+ 'a|b|c',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ISMULTI_LIMIT1 => 2,
+ ],
+ [ 'a', 'b' ],
+ [ [ 'apiwarn-toomanyvalues', 'myParam', 2 ] ],
+ ],
+ 'Multi-valued parameter with exceeded limits for non-bot' => [
+ 'a|b|c',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ISMULTI_LIMIT1 => 2,
+ ApiBase::PARAM_ISMULTI_LIMIT2 => 3,
+ ],
+ [ 'a', 'b' ],
+ [ [ 'apiwarn-toomanyvalues', 'myParam', 2 ] ],
+ ],
+ 'Multi-valued parameter with non-exceeded limits for bot' => [
+ 'a|b|c',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ISMULTI_LIMIT1 => 2,
+ ApiBase::PARAM_ISMULTI_LIMIT2 => 3,
+ ],
+ [ 'a', 'b', 'c' ],
+ [],
+ [ 'apihighlimits' => true ],
+ ],
+ 'Multi-valued parameter with prohibited duplicates' => [
+ 'a|b|a|c',
+ [ ApiBase::PARAM_ISMULTI => true ],
+ // Note that the keys are not sequential! This matches
+ // array_unique, but might be unexpected.
+ [ 0 => 'a', 1 => 'b', 3 => 'c' ],
+ [],
+ ],
+ 'Multi-valued parameter with allowed duplicates' => [
+ 'a|a',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ALLOW_DUPLICATES => true,
+ ],
+ [ 'a', 'a' ],
+ [],
+ ],
+ 'Empty boolean param' => [
+ '',
+ [ ApiBase::PARAM_TYPE => 'boolean' ],
+ true,
+ [],
+ ],
+ 'Boolean param 0' => [
+ '0',
+ [ ApiBase::PARAM_TYPE => 'boolean' ],
+ true,
+ [],
+ ],
+ 'Boolean param false' => [
+ 'false',
+ [ ApiBase::PARAM_TYPE => 'boolean' ],
+ true,
+ [],
+ ],
+ 'Boolean multi-param' => [
+ 'true|false',
+ [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ 'Multi-values not supported for myParam'
+ ),
+ [],
+ ],
+ 'Empty boolean param with non-false default' => [
+ '',
+ [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => true,
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ "Boolean param myParam's default is set to '1'. " .
+ 'Boolean parameters must default to false.' ),
+ [],
+ ],
+ 'Deprecated parameter' => [
+ 'foo',
+ [ ApiBase::PARAM_DEPRECATED => true ],
+ 'foo',
+ [ [ 'apiwarn-deprecation-parameter', 'myParam' ] ],
+ ],
+ 'Deprecated parameter value' => [
+ 'a',
+ [ ApiBase::PARAM_DEPRECATED_VALUES => [ 'a' => true ] ],
+ 'a',
+ [ [ 'apiwarn-deprecation-parameter', 'myParam=a' ] ],
+ ],
+ 'Multiple deprecated parameter values' => [
+ 'a|b|c|d',
+ [ ApiBase::PARAM_DEPRECATED_VALUES =>
+ [ 'b' => true, 'd' => true ],
+ ApiBase::PARAM_ISMULTI => true ],
+ [ 'a', 'b', 'c', 'd' ],
+ [
+ [ 'apiwarn-deprecation-parameter', 'myParam=b' ],
+ [ 'apiwarn-deprecation-parameter', 'myParam=d' ],
+ ],
+ ],
+ 'Deprecated parameter value with custom warning' => [
+ 'a',
+ [ ApiBase::PARAM_DEPRECATED_VALUES => [ 'a' => 'my-msg' ] ],
+ 'a',
+ [ 'my-msg' ],
+ ],
+ '"*" when wildcard not allowed' => [
+ '*',
+ [ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ] ],
+ [],
+ [ [ 'apiwarn-unrecognizedvalues', 'myParam',
+ [ 'list' => [ '&#42;' ], 'type' => 'comma' ], 1 ] ],
+ ],
+ 'Wildcard "*"' => [
+ '*',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ],
+ ApiBase::PARAM_ALL => true,
+ ],
+ [ 'a', 'b', 'c' ],
+ [],
+ ],
+ 'Wildcard "*" with multiples not allowed' => [
+ '*',
+ [
+ ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ],
+ ApiBase::PARAM_ALL => true,
+ ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-unrecognizedvalue', 'myParam', '&#42;' ],
+ 'unknown_myParam' ),
+ [],
+ ],
+ 'Wildcard "*" with unrestricted type' => [
+ '*',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ALL => true,
+ ],
+ [ '*' ],
+ [],
+ ],
+ 'Wildcard "x"' => [
+ 'x',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ],
+ ApiBase::PARAM_ALL => 'x',
+ ],
+ [ 'a', 'b', 'c' ],
+ [],
+ ],
+ 'Wildcard conflicting with allowed value' => [
+ 'a',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ],
+ ApiBase::PARAM_ALL => 'a',
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ 'For param myParam, PARAM_ALL collides with a possible ' .
+ 'value' ),
+ [],
+ ],
+ 'Namespace with wildcard' => [
+ '*',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ],
+ MWNamespace::getValidNamespaces(),
+ [],
+ ],
+ // PARAM_ALL is ignored with namespace types.
+ 'Namespace with wildcard suppressed' => [
+ '*',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_ALL => false,
+ ],
+ MWNamespace::getValidNamespaces(),
+ [],
+ ],
+ 'Namespace with wildcard "x"' => [
+ 'x',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_ALL => 'x',
+ ],
+ [],
+ [ [ 'apiwarn-unrecognizedvalues', 'myParam',
+ [ 'list' => [ 'x' ], 'type' => 'comma' ], 1 ] ],
+ ],
+ 'Password' => [
+ 'dDy+G?e?txnr.1:(@[Ru',
+ [ ApiBase::PARAM_TYPE => 'password' ],
+ 'dDy+G?e?txnr.1:(@[Ru',
+ [],
+ ],
+ 'Sensitive field' => [
+ 'I am fond of pineapples',
+ [ ApiBase::PARAM_SENSITIVE => true ],
+ 'I am fond of pineapples',
+ [],
+ ],
+ 'Upload with default' => [
+ '',
+ [
+ ApiBase::PARAM_TYPE => 'upload',
+ ApiBase::PARAM_DFLT => '',
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ "File upload param myParam's default is set to ''. " .
+ 'File upload parameters may not have a default.' ),
+ [],
+ ],
+ 'Multiple upload' => [
+ '',
+ [
+ ApiBase::PARAM_TYPE => 'upload',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ 'Multi-values not supported for myParam' ),
+ [],
+ ],
+ // @todo Test actual upload
+ 'Namespace -1' => [
+ '-1',
+ [ ApiBase::PARAM_TYPE => 'namespace' ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-unrecognizedvalue', 'myParam', '-1' ],
+ 'unknown_myParam' ),
+ [],
+ ],
+ 'Extra namespace -1' => [
+ '-1',
+ [
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_EXTRA_NAMESPACES => [ '-1' ],
+ ],
+ '-1',
+ [],
+ ],
+ // @todo Test with PARAM_SUBMODULE_MAP unset, need
+ // getModuleManager() to return something real
+ 'Nonexistent module' => [
+ 'not-a-module-name',
+ [
+ ApiBase::PARAM_TYPE => 'submodule',
+ ApiBase::PARAM_SUBMODULE_MAP =>
+ [ 'foo' => 'foo', 'bar' => 'foo+bar' ],
+ ],
+ ApiUsageException::newWithMessage(
+ null,
+ [
+ 'apierror-unrecognizedvalue',
+ 'myParam',
+ 'not-a-module-name',
+ ],
+ 'unknown_myParam'
+ ),
+ [],
+ ],
+ '\\x1f with multiples not allowed' => [
+ "\x1f",
+ [],
+ ApiUsageException::newWithMessage( null,
+ 'apierror-badvalue-notmultivalue',
+ 'badvalue_notmultivalue' ),
+ [],
+ ],
+ 'Integer with unenforced min' => [
+ '-2',
+ [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => -1,
+ ],
+ -1,
+ [ [ 'apierror-integeroutofrange-belowminimum', 'myParam', -1,
+ -2 ] ],
+ ],
+ 'Integer with enforced min' => [
+ '-2',
+ [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => -1,
+ ApiBase::PARAM_RANGE_ENFORCE => true,
+ ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-integeroutofrange-belowminimum', 'myParam',
+ '-1', '-2' ], 'integeroutofrange',
+ [ 'min' => -1, 'max' => null, 'botMax' => null ] ),
+ [],
+ ],
+ 'Integer with unenforced max (internal mode)' => [
+ '8',
+ [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MAX => 7,
+ ],
+ 8,
+ [],
+ ],
+ 'Integer with enforced max (internal mode)' => [
+ '8',
+ [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MAX => 7,
+ ApiBase::PARAM_RANGE_ENFORCE => true,
+ ],
+ 8,
+ [],
+ ],
+ 'Integer with unenforced max (non-internal mode)' => [
+ '8',
+ [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MAX => 7,
+ ],
+ 7,
+ [ [ 'apierror-integeroutofrange-abovemax', 'myParam', 7, 8 ] ],
+ [ 'internalmode' => false ],
+ ],
+ 'Integer with enforced max (non-internal mode)' => [
+ '8',
+ [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MAX => 7,
+ ApiBase::PARAM_RANGE_ENFORCE => true,
+ ],
+ ApiUsageException::newWithMessage(
+ null,
+ [ 'apierror-integeroutofrange-abovemax', 'myParam', '7', '8' ],
+ 'integeroutofrange',
+ [ 'min' => null, 'max' => 7, 'botMax' => 7 ]
+ ),
+ [],
+ [ 'internalmode' => false ],
+ ],
+ 'Array of integers' => [
+ '3|12|966|-1',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ [ 3, 12, 966, -1 ],
+ [],
+ ],
+ 'Array of integers with unenforced min/max (internal mode)' => [
+ '3|12|966|-1',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 0,
+ ApiBase::PARAM_MAX => 100,
+ ],
+ [ 3, 12, 966, 0 ],
+ [ [ 'apierror-integeroutofrange-belowminimum', 'myParam', 0, -1 ] ],
+ ],
+ 'Array of integers with enforced min/max (internal mode)' => [
+ '3|12|966|-1',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 0,
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_RANGE_ENFORCE => true,
+ ],
+ ApiUsageException::newWithMessage(
+ null,
+ [ 'apierror-integeroutofrange-belowminimum', 'myParam', 0, -1 ],
+ 'integeroutofrange',
+ [ 'min' => 0, 'max' => 100, 'botMax' => 100 ]
+ ),
+ [],
+ ],
+ 'Array of integers with unenforced min/max (non-internal mode)' => [
+ '3|12|966|-1',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 0,
+ ApiBase::PARAM_MAX => 100,
+ ],
+ [ 3, 12, 100, 0 ],
+ [
+ [ 'apierror-integeroutofrange-abovemax', 'myParam', 100, 966 ],
+ [ 'apierror-integeroutofrange-belowminimum', 'myParam', 0, -1 ]
+ ],
+ [ 'internalmode' => false ],
+ ],
+ 'Array of integers with enforced min/max (non-internal mode)' => [
+ '3|12|966|-1',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 0,
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_RANGE_ENFORCE => true,
+ ],
+ ApiUsageException::newWithMessage(
+ null,
+ [ 'apierror-integeroutofrange-abovemax', 'myParam', 100, 966 ],
+ 'integeroutofrange',
+ [ 'min' => 0, 'max' => 100, 'botMax' => 100 ]
+ ),
+ [],
+ [ 'internalmode' => false ],
+ ],
+ 'Limit with parseLimits false' => [
+ '100',
+ [ ApiBase::PARAM_TYPE => 'limit' ],
+ '100',
+ [],
+ [ 'parseLimits' => false ],
+ ],
+ 'Limit with no max' => [
+ '100',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX2 => 10,
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ 'MAX1 or MAX2 are not defined for the limit myParam' ),
+ [],
+ ],
+ 'Limit with no max2' => [
+ '100',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 10,
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ 'MAX1 or MAX2 are not defined for the limit myParam' ),
+ [],
+ ],
+ 'Limit with multi-value' => [
+ '100',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 10,
+ ApiBase::PARAM_MAX2 => 10,
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ 'Multi-values not supported for myParam' ),
+ [],
+ ],
+ 'Valid limit' => [
+ '100',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 100,
+ ],
+ 100,
+ [],
+ ],
+ 'Limit max' => [
+ 'max',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 100,
+ [],
+ ],
+ 'Limit max for apihighlimits' => [
+ 'max',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 101,
+ [],
+ [ 'apihighlimits' => true ],
+ ],
+ 'Limit too large (internal mode)' => [
+ '101',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 101,
+ [],
+ ],
+ 'Limit okay for apihighlimits (internal mode)' => [
+ '101',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 101,
+ [],
+ [ 'apihighlimits' => true ],
+ ],
+ 'Limit too large for apihighlimits (internal mode)' => [
+ '102',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 102,
+ [],
+ [ 'apihighlimits' => true ],
+ ],
+ 'Limit too large (non-internal mode)' => [
+ '101',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 100,
+ [ [ 'apierror-integeroutofrange-abovemax', 'myParam', 100, 101 ] ],
+ [ 'internalmode' => false ],
+ ],
+ 'Limit okay for apihighlimits (non-internal mode)' => [
+ '101',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 101,
+ [],
+ [ 'internalmode' => false, 'apihighlimits' => true ],
+ ],
+ 'Limit too large for apihighlimits (non-internal mode)' => [
+ '102',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 101,
+ [ [ 'apierror-integeroutofrange-abovebotmax', 'myParam', 101, 102 ] ],
+ [ 'internalmode' => false, 'apihighlimits' => true ],
+ ],
+ 'Limit too small' => [
+ '-2',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => -1,
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 100,
+ ],
+ -1,
+ [ [ 'apierror-integeroutofrange-belowminimum', 'myParam', -1,
+ -2 ] ],
+ ],
+ 'Timestamp' => [
+ wfTimestamp( TS_UNIX, '20211221122112' ),
+ [ ApiBase::PARAM_TYPE => 'timestamp' ],
+ '20211221122112',
+ [],
+ ],
+ 'Timestamp 0' => [
+ '0',
+ [ ApiBase::PARAM_TYPE => 'timestamp' ],
+ // Magic keyword
+ 'now',
+ [ [ 'apiwarn-unclearnowtimestamp', 'myParam', '0' ] ],
+ ],
+ 'Timestamp empty' => [
+ '',
+ [ ApiBase::PARAM_TYPE => 'timestamp' ],
+ 'now',
+ [ [ 'apiwarn-unclearnowtimestamp', 'myParam', '' ] ],
+ ],
+ // wfTimestamp() interprets this as Unix time
+ 'Timestamp 00' => [
+ '00',
+ [ ApiBase::PARAM_TYPE => 'timestamp' ],
+ '19700101000000',
+ [],
+ ],
+ 'Timestamp now' => [
+ 'now',
+ [ ApiBase::PARAM_TYPE => 'timestamp' ],
+ 'now',
+ [],
+ ],
+ 'Invalid timestamp' => [
+ 'a potato',
+ [ ApiBase::PARAM_TYPE => 'timestamp' ],
+ ApiUsageException::newWithMessage(
+ null,
+ [ 'apierror-badtimestamp', 'myParam', 'a potato' ],
+ 'badtimestamp_myParam'
+ ),
+ [],
+ ],
+ 'Timestamp array' => [
+ '100|101',
+ [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ApiBase::PARAM_ISMULTI => 1,
+ ],
+ [ wfTimestamp( TS_MW, 100 ), wfTimestamp( TS_MW, 101 ) ],
+ [],
+ ],
+ 'User' => [
+ 'foo_bar',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ 'Foo bar',
+ [],
+ ],
+ 'Invalid username "|"' => [
+ '|',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-baduser', 'myParam', '&#124;' ],
+ 'baduser_myParam' ),
+ [],
+ ],
+ 'Invalid username "300.300.300.300"' => [
+ '300.300.300.300',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-baduser', 'myParam', '300.300.300.300' ],
+ 'baduser_myParam' ),
+ [],
+ ],
+ 'IP range as username' => [
+ '10.0.0.0/8',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ '10.0.0.0/8',
+ [],
+ ],
+ 'IPv6 as username' => [
+ '::1',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ '0:0:0:0:0:0:0:1',
+ [],
+ ],
+ 'Obsolete cloaked usemod IP address as username' => [
+ '1.2.3.xxx',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ '1.2.3.xxx',
+ [],
+ ],
+ 'Invalid username containing IP address' => [
+ 'This is [not] valid 1.2.3.xxx, ha!',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ ApiUsageException::newWithMessage(
+ null,
+ [ 'apierror-baduser', 'myParam', 'This is &#91;not&#93; valid 1.2.3.xxx, ha!' ],
+ 'baduser_myParam'
+ ),
+ [],
+ ],
+ 'External username' => [
+ 'M>Foo bar',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ 'M>Foo bar',
+ [],
+ ],
+ 'Array of usernames' => [
+ 'foo|bar',
+ [
+ ApiBase::PARAM_TYPE => 'user',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ [ 'Foo', 'Bar' ],
+ [],
+ ],
+ 'tag' => [
+ 'tag1',
+ [ ApiBase::PARAM_TYPE => 'tags' ],
+ [ 'tag1' ],
+ [],
+ ],
+ 'Array of one tag' => [
+ 'tag1',
+ [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ [ 'tag1' ],
+ [],
+ ],
+ 'Array of tags' => [
+ 'tag1|tag2',
+ [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ [ 'tag1', 'tag2' ],
+ [],
+ ],
+ 'Invalid tag' => [
+ 'invalid tag',
+ [ ApiBase::PARAM_TYPE => 'tags' ],
+ new ApiUsageException( null,
+ Status::newFatal( 'tags-apply-not-allowed-one',
+ 'invalid tag', 1 ) ),
+ [],
+ ],
+ 'Unrecognized type' => [
+ 'foo',
+ [ ApiBase::PARAM_TYPE => 'nonexistenttype' ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ "Param myParam's type is unknown - nonexistenttype" ),
+ [],
+ ],
+ 'Too many bytes' => [
+ '1',
+ [
+ ApiBase::PARAM_MAX_BYTES => 0,
+ ApiBase::PARAM_MAX_CHARS => 0,
+ ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-maxbytes', 'myParam', 0 ] ),
+ [],
+ ],
+ 'Too many chars' => [
+ '§§',
+ [
+ ApiBase::PARAM_MAX_BYTES => 4,
+ ApiBase::PARAM_MAX_CHARS => 1,
+ ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-maxchars', 'myParam', 1 ] ),
+ [],
+ ],
+ 'Omitted required param' => [
+ null,
+ [ ApiBase::PARAM_REQUIRED => true ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-missingparam', 'myParam' ] ),
+ [],
+ ],
+ 'Empty multi-value' => [
+ '',
+ [ ApiBase::PARAM_ISMULTI => true ],
+ [],
+ [],
+ ],
+ 'Multi-value \x1f' => [
+ "\x1f",
+ [ ApiBase::PARAM_ISMULTI => true ],
+ [],
+ [],
+ ],
+ 'Allowed non-multi-value with "|"' => [
+ 'a|b',
+ [ ApiBase::PARAM_TYPE => [ 'a|b' ] ],
+ 'a|b',
+ [],
+ ],
+ 'Prohibited multi-value' => [
+ 'a|b',
+ [ ApiBase::PARAM_TYPE => [ 'a', 'b' ] ],
+ ApiUsageException::newWithMessage( null,
+ [
+ 'apierror-multival-only-one-of',
+ 'myParam',
+ Message::listParam( [ '<kbd>a</kbd>', '<kbd>b</kbd>' ] ),
+ 2
+ ],
+ 'multival_myParam'
+ ),
+ [],
+ ],
+ ];
+
+ // The following really just test PHP's string-to-int conversion.
+ $integerTests = [
+ [ '+1', 1 ],
+ [ '-1', -1 ],
+ [ '1.5', 1 ],
+ [ '-1.5', -1 ],
+ [ '1abc', 1 ],
+ [ ' 1', 1 ],
+ [ "\t1", 1, '\t1' ],
+ [ "\r1", 1, '\r1' ],
+ [ "\f1", 0, '\f1', 'badutf-8' ],
+ [ "\n1", 1, '\n1' ],
+ [ "\v1", 0, '\v1', 'badutf-8' ],
+ [ "\e1", 0, '\e1', 'badutf-8' ],
+ [ "\x001", 0, '\x001', 'badutf-8' ],
+ ];
+
+ foreach ( $integerTests as $test ) {
+ $desc = isset( $test[2] ) ? $test[2] : $test[0];
+ $warnings = isset( $test[3] ) ?
+ [ [ 'apiwarn-badutf8', 'myParam' ] ] : [];
+ $returnArray["\"$desc\" as integer"] = [
+ $test[0],
+ [ ApiBase::PARAM_TYPE => 'integer' ],
+ $test[1],
+ $warnings,
+ ];
+ }
+
+ return $returnArray;
+ }
+
+ public function testErrorArrayToStatus() {
+ $mock = new MockApi();
+
+ // Sanity check empty array
+ $expect = Status::newGood();
+ $this->assertEquals( $expect, $mock->errorArrayToStatus( [] ) );
+
+ // No blocked $user, so no special block handling
+ $expect = Status::newGood();
+ $expect->fatal( 'blockedtext' );
+ $expect->fatal( 'autoblockedtext' );
+ $expect->fatal( 'systemblockedtext' );
+ $expect->fatal( 'mainpage' );
+ $expect->fatal( 'parentheses', 'foobar' );
+ $this->assertEquals( $expect, $mock->errorArrayToStatus( [
+ [ 'blockedtext' ],
+ [ 'autoblockedtext' ],
+ [ 'systemblockedtext' ],
+ 'mainpage',
+ [ 'parentheses', 'foobar' ],
+ ] ) );
+
+ // Has a blocked $user, so special block handling
+ $user = $this->getMutableTestUser()->getUser();
+ $block = new \Block( [
+ 'address' => $user->getName(),
+ 'user' => $user->getID(),
+ 'by' => $this->getTestSysop()->getUser()->getId(),
+ 'reason' => __METHOD__,
+ 'expiry' => time() + 100500,
+ ] );
+ $block->insert();
+ $blockinfo = [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $block ) ];
+
+ $expect = Status::newGood();
+ $expect->fatal( ApiMessage::create( 'apierror-blocked', 'blocked', $blockinfo ) );
+ $expect->fatal( ApiMessage::create( 'apierror-autoblocked', 'autoblocked', $blockinfo ) );
+ $expect->fatal( ApiMessage::create( 'apierror-systemblocked', 'blocked', $blockinfo ) );
+ $expect->fatal( 'mainpage' );
+ $expect->fatal( 'parentheses', 'foobar' );
+ $this->assertEquals( $expect, $mock->errorArrayToStatus( [
+ [ 'blockedtext' ],
+ [ 'autoblockedtext' ],
+ [ 'systemblockedtext' ],
+ 'mainpage',
+ [ 'parentheses', 'foobar' ],
+ ], $user ) );
+ }
+
+ public function testDieStatus() {
+ $mock = new MockApi();
+
+ $status = StatusValue::newGood();
+ $status->error( 'foo' );
+ $status->warning( 'bar' );
+ try {
+ $mock->dieStatus( $status );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'foo' ), 'Exception has "foo"' );
+ $this->assertFalse( ApiTestCase::apiExceptionHasCode( $ex, 'bar' ), 'Exception has "bar"' );
+ }
+
+ $status = StatusValue::newGood();
+ $status->warning( 'foo' );
+ $status->warning( 'bar' );
+ try {
+ $mock->dieStatus( $status );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'foo' ), 'Exception has "foo"' );
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'bar' ), 'Exception has "bar"' );
+ }
+
+ $status = StatusValue::newGood();
+ $status->setOk( false );
+ try {
+ $mock->dieStatus( $status );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'unknownerror-nocode' ),
+ 'Exception has "unknownerror-nocode"' );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiBlockTest.php b/www/wiki/tests/phpunit/includes/api/ApiBlockTest.php
new file mode 100644
index 00000000..efefc09d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiBlockTest.php
@@ -0,0 +1,252 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiBlock
+ */
+class ApiBlockTest extends ApiTestCase {
+ protected $mUser = null;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->mUser = $this->getMutableTestUser()->getUser();
+ $this->setMwGlobals( 'wgBlockCIDRLimit', [
+ 'IPv4' => 16,
+ 'IPv6' => 19,
+ ] );
+ }
+
+ protected function tearDown() {
+ $block = Block::newFromTarget( $this->mUser->getName() );
+ if ( !is_null( $block ) ) {
+ $block->delete();
+ }
+ parent::tearDown();
+ }
+
+ protected function getTokens() {
+ return $this->getTokenList( self::$users['sysop'] );
+ }
+
+ /**
+ * @param array $extraParams Extra API parameters to pass to doApiRequest
+ * @param User $blocker User to do the blocking, null to pick
+ * arbitrarily
+ */
+ private function doBlock( array $extraParams = [], User $blocker = null ) {
+ if ( $blocker === null ) {
+ $blocker = self::$users['sysop']->getUser();
+ }
+
+ $tokens = $this->getTokens();
+
+ $this->assertNotNull( $this->mUser, 'Sanity check' );
+
+ $this->assertArrayHasKey( 'blocktoken', $tokens, 'Sanity check' );
+
+ $params = [
+ 'action' => 'block',
+ 'user' => $this->mUser->getName(),
+ 'reason' => 'Some reason',
+ 'token' => $tokens['blocktoken'],
+ ];
+ if ( array_key_exists( 'userid', $extraParams ) ) {
+ // Make sure we don't have both user and userid
+ unset( $params['user'] );
+ }
+ $ret = $this->doApiRequest( array_merge( $params, $extraParams ), null,
+ false, $blocker );
+
+ $block = Block::newFromTarget( $this->mUser->getName() );
+
+ $this->assertTrue( !is_null( $block ), 'Block is valid' );
+
+ $this->assertSame( $this->mUser->getName(), (string)$block->getTarget() );
+ $this->assertSame( 'Some reason', $block->mReason );
+
+ return $ret;
+ }
+
+ /**
+ * Block by username
+ */
+ public function testNormalBlock() {
+ $this->doBlock();
+ }
+
+ /**
+ * Block by user ID
+ */
+ public function testBlockById() {
+ $this->doBlock( [ 'userid' => $this->mUser->getId() ] );
+ }
+
+ /**
+ * A blocked user can't block
+ */
+ public function testBlockByBlockedUser() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'You cannot block or unblock other users because you are yourself blocked.' );
+
+ $blocked = $this->getMutableTestUser( [ 'sysop' ] )->getUser();
+ $block = new Block( [
+ 'address' => $blocked->getName(),
+ 'by' => self::$users['sysop']->getUser()->getId(),
+ 'reason' => 'Capriciousness',
+ 'timestamp' => '19370101000000',
+ 'expiry' => 'infinity',
+ ] );
+ $block->insert();
+
+ $this->doBlock( [], $blocked );
+ }
+
+ public function testBlockOfNonexistentUser() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no user by the name "Nonexistent". Check your spelling.' );
+
+ $this->doBlock( [ 'user' => 'Nonexistent' ] );
+ }
+
+ public function testBlockOfNonexistentUserId() {
+ $id = 948206325;
+ $this->setExpectedException( ApiUsageException::class,
+ "There is no user with ID $id." );
+
+ $this->assertFalse( User::whoIs( $id ), 'Sanity check' );
+
+ $this->doBlock( [ 'userid' => $id ] );
+ }
+
+ public function testBlockWithTag() {
+ ChangeTags::defineTag( 'custom tag' );
+
+ $this->doBlock( [ 'tags' => 'custom tag' ] );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $this->assertSame( 'custom tag', $dbw->selectField(
+ [ 'change_tag', 'logging' ],
+ 'ct_tag',
+ [ 'log_type' => 'block' ],
+ __METHOD__,
+ [],
+ [ 'change_tag' => [ 'INNER JOIN', 'ct_log_id = log_id' ] ]
+ ) );
+ }
+
+ public function testBlockWithProhibitedTag() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'You do not have permission to apply change tags along with your changes.' );
+
+ ChangeTags::defineTag( 'custom tag' );
+
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'applychangetags' => true ] ] );
+
+ $this->doBlock( [ 'tags' => 'custom tag' ] );
+ }
+
+ public function testBlockWithHide() {
+ global $wgGroupPermissions;
+ $newPermissions = $wgGroupPermissions['sysop'];
+ $newPermissions['hideuser'] = true;
+ $this->mergeMwGlobalArrayValue( 'wgGroupPermissions',
+ [ 'sysop' => $newPermissions ] );
+
+ $res = $this->doBlock( [ 'hidename' => '' ] );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $this->assertSame( '1', $dbw->selectField(
+ 'ipblocks',
+ 'ipb_deleted',
+ [ 'ipb_id' => $res[0]['block']['id'] ],
+ __METHOD__
+ ) );
+ }
+
+ public function testBlockWithProhibitedHide() {
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to hide user names from the block log." );
+
+ $this->doBlock( [ 'hidename' => '' ] );
+ }
+
+ public function testBlockWithEmailBlock() {
+ $res = $this->doBlock( [ 'noemail' => '' ] );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $this->assertSame( '1', $dbw->selectField(
+ 'ipblocks',
+ 'ipb_block_email',
+ [ 'ipb_id' => $res[0]['block']['id'] ],
+ __METHOD__
+ ) );
+ }
+
+ public function testBlockWithProhibitedEmailBlock() {
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to block users from sending email through the wiki." );
+
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'sysop' => [ 'blockemail' => true ] ] );
+
+ $this->doBlock( [ 'noemail' => '' ] );
+ }
+
+ public function testBlockWithExpiry() {
+ $res = $this->doBlock( [ 'expiry' => '1 day' ] );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $expiry = $dbw->selectField(
+ 'ipblocks',
+ 'ipb_expiry',
+ [ 'ipb_id' => $res[0]['block']['id'] ],
+ __METHOD__
+ );
+
+ // Allow flakiness up to one second
+ $this->assertLessThanOrEqual( 1,
+ abs( wfTimestamp( TS_UNIX, $expiry ) - ( time() + 86400 ) ) );
+ }
+
+ public function testBlockWithInvalidExpiry() {
+ $this->setExpectedException( ApiUsageException::class, "Expiry time invalid." );
+
+ $this->doBlock( [ 'expiry' => '' ] );
+ }
+
+ /**
+ * @expectedException ApiUsageException
+ * @expectedExceptionMessage The "token" parameter must be set
+ */
+ public function testBlockingActionWithNoToken() {
+ $this->doApiRequest(
+ [
+ 'action' => 'block',
+ 'user' => $this->mUser->getName(),
+ 'reason' => 'Some reason',
+ ],
+ null,
+ false,
+ self::$users['sysop']->getUser()
+ );
+ }
+
+ public function testRangeBlock() {
+ $this->mUser = User::newFromName( '128.0.0.0/16', false );
+ $this->doBlock();
+ }
+
+ /**
+ * @expectedException ApiUsageException
+ * @expectedExceptionMessage Range blocks larger than /16 are not allowed.
+ */
+ public function testVeryLargeRangeBlock() {
+ $this->mUser = User::newFromName( '128.0.0.0/1', false );
+ $this->doBlock();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiCheckTokenTest.php b/www/wiki/tests/phpunit/includes/api/ApiCheckTokenTest.php
new file mode 100644
index 00000000..f1d95d03
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiCheckTokenTest.php
@@ -0,0 +1,95 @@
+<?php
+
+use MediaWiki\Session\Token;
+
+/**
+ * @group API
+ * @group medium
+ * @covers ApiCheckToken
+ */
+class ApiCheckTokenTest extends ApiTestCase {
+
+ /**
+ * Test result of checking previously queried token (should be valid)
+ */
+ public function testCheckTokenValid() {
+ // Query token which will be checked later
+ $tokens = $this->doApiRequest( [
+ 'action' => 'query',
+ 'meta' => 'tokens',
+ ] );
+
+ $data = $this->doApiRequest( [
+ 'action' => 'checktoken',
+ 'type' => 'csrf',
+ 'token' => $tokens[0]['query']['tokens']['csrftoken'],
+ ], $tokens[1]->getSessionArray() );
+
+ $this->assertEquals( 'valid', $data[0]['checktoken']['result'] );
+ $this->assertArrayHasKey( 'generated', $data[0]['checktoken'] );
+ }
+
+ /**
+ * Test result of checking invalid token
+ */
+ public function testCheckTokenInvalid() {
+ $session = [];
+ $data = $this->doApiRequest( [
+ 'action' => 'checktoken',
+ 'type' => 'csrf',
+ 'token' => 'invalid_token',
+ ], $session );
+
+ $this->assertEquals( 'invalid', $data[0]['checktoken']['result'] );
+ }
+
+ /**
+ * Test result of checking token with negative max age (should be expired)
+ */
+ public function testCheckTokenExpired() {
+ // Query token which will be checked later
+ $tokens = $this->doApiRequest( [
+ 'action' => 'query',
+ 'meta' => 'tokens',
+ ] );
+
+ $data = $this->doApiRequest( [
+ 'action' => 'checktoken',
+ 'type' => 'csrf',
+ 'token' => $tokens[0]['query']['tokens']['csrftoken'],
+ 'maxtokenage' => -1,
+ ], $tokens[1]->getSessionArray() );
+
+ $this->assertEquals( 'expired', $data[0]['checktoken']['result'] );
+ $this->assertArrayHasKey( 'generated', $data[0]['checktoken'] );
+ }
+
+ /**
+ * Test if using token with incorrect suffix will produce a warning
+ */
+ public function testCheckTokenSuffixWarning() {
+ // Query token which will be checked later
+ $tokens = $this->doApiRequest( [
+ 'action' => 'query',
+ 'meta' => 'tokens',
+ ] );
+
+ // Get token and change the suffix
+ $token = $tokens[0]['query']['tokens']['csrftoken'];
+ $token = substr( $token, 0, -strlen( Token::SUFFIX ) ) . urldecode( Token::SUFFIX );
+
+ $data = $this->doApiRequest( [
+ 'action' => 'checktoken',
+ 'type' => 'csrf',
+ 'token' => $token,
+ 'errorformat' => 'raw',
+ ], $tokens[1]->getSessionArray() );
+
+ $this->assertEquals( 'invalid', $data[0]['checktoken']['result'] );
+ $this->assertArrayHasKey( 'warnings', $data[0] );
+ $this->assertCount( 1, $data[0]['warnings'] );
+ $this->assertEquals( 'checktoken', $data[0]['warnings'][0]['module'] );
+ $this->assertEquals( 'checktoken-percentencoding', $data[0]['warnings'][0]['code'] );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiClearHasMsgTest.php b/www/wiki/tests/phpunit/includes/api/ApiClearHasMsgTest.php
new file mode 100644
index 00000000..5b124074
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiClearHasMsgTest.php
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * @group API
+ * @group medium
+ * @covers ApiClearHasMsg
+ */
+class ApiClearHasMsgTest extends ApiTestCase {
+
+ /**
+ * Test clearing hasmsg flag for current user
+ */
+ public function testClearFlag() {
+ $user = self::$users['sysop']->getUser();
+ $user->setNewtalk( true );
+ $this->assertTrue( $user->getNewtalk(), 'sanity check' );
+
+ $data = $this->doApiRequest( [ 'action' => 'clearhasmsg' ], [] );
+
+ $this->assertEquals( 'success', $data[0]['clearhasmsg'] );
+ $this->assertFalse( $user->getNewtalk() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiComparePagesTest.php b/www/wiki/tests/phpunit/includes/api/ApiComparePagesTest.php
new file mode 100644
index 00000000..ea13a0d3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiComparePagesTest.php
@@ -0,0 +1,653 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiComparePages
+ */
+class ApiComparePagesTest extends ApiTestCase {
+
+ protected static $repl = [];
+
+ protected function setUp() {
+ parent::setUp();
+
+ // Set $wgExternalDiffEngine to something bogus to try to force use of
+ // the PHP engine rather than wikidiff2.
+ $this->setMwGlobals( [
+ 'wgExternalDiffEngine' => '/dev/null',
+ ] );
+ }
+
+ protected function addPage( $page, $text, $model = CONTENT_MODEL_WIKITEXT ) {
+ $title = Title::newFromText( 'ApiComparePagesTest ' . $page );
+ $content = ContentHandler::makeContent( $text, $title, $model );
+
+ $page = WikiPage::factory( $title );
+ $user = static::getTestSysop()->getUser();
+ $status = $page->doEditContent(
+ $content, 'Test for ApiComparePagesTest: ' . $text, 0, false, $user
+ );
+ if ( !$status->isOK() ) {
+ $this->fail( "Failed to create $title: " . $status->getWikiText( false, false, 'en' ) );
+ }
+ return $status->value['revision']->getId();
+ }
+
+ public function addDBDataOnce() {
+ $user = static::getTestSysop()->getUser();
+ self::$repl['creator'] = $user->getName();
+ self::$repl['creatorid'] = $user->getId();
+
+ self::$repl['revA1'] = $this->addPage( 'A', 'A 1' );
+ self::$repl['revA2'] = $this->addPage( 'A', 'A 2' );
+ self::$repl['revA3'] = $this->addPage( 'A', 'A 3' );
+ self::$repl['revA4'] = $this->addPage( 'A', 'A 4' );
+ self::$repl['pageA'] = Title::newFromText( 'ApiComparePagesTest A' )->getArticleId();
+
+ self::$repl['revB1'] = $this->addPage( 'B', 'B 1' );
+ self::$repl['revB2'] = $this->addPage( 'B', 'B 2' );
+ self::$repl['revB3'] = $this->addPage( 'B', 'B 3' );
+ self::$repl['revB4'] = $this->addPage( 'B', 'B 4' );
+ self::$repl['pageB'] = Title::newFromText( 'ApiComparePagesTest B' )->getArticleId();
+
+ self::$repl['revC1'] = $this->addPage( 'C', 'C 1' );
+ self::$repl['revC2'] = $this->addPage( 'C', 'C 2' );
+ self::$repl['revC3'] = $this->addPage( 'C', 'C 3' );
+ self::$repl['pageC'] = Title::newFromText( 'ApiComparePagesTest C' )->getArticleId();
+
+ $id = $this->addPage( 'D', 'D 1' );
+ self::$repl['pageD'] = Title::newFromText( 'ApiComparePagesTest D' )->getArticleId();
+ wfGetDB( DB_MASTER )->delete( 'revision', [ 'rev_id' => $id ] );
+
+ self::$repl['revE1'] = $this->addPage( 'E', 'E 1' );
+ self::$repl['revE2'] = $this->addPage( 'E', 'E 2' );
+ self::$repl['revE3'] = $this->addPage( 'E', 'E 3' );
+ self::$repl['revE4'] = $this->addPage( 'E', 'E 4' );
+ self::$repl['pageE'] = Title::newFromText( 'ApiComparePagesTest E' )->getArticleId();
+ wfGetDB( DB_MASTER )->update(
+ 'page', [ 'page_latest' => 0 ], [ 'page_id' => self::$repl['pageE'] ]
+ );
+
+ self::$repl['revF1'] = $this->addPage( 'F', "== Section 1 ==\nF 1.1\n\n== Section 2 ==\nF 1.2" );
+ self::$repl['pageF'] = Title::newFromText( 'ApiComparePagesTest F' )->getArticleId();
+
+ WikiPage::factory( Title::newFromText( 'ApiComparePagesTest C' ) )
+ ->doDeleteArticleReal( 'Test for ApiComparePagesTest' );
+
+ RevisionDeleter::createList(
+ 'revision',
+ RequestContext::getMain(),
+ Title::newFromText( 'ApiComparePagesTest B' ),
+ [ self::$repl['revB2'] ]
+ )->setVisibility( [
+ 'value' => [
+ Revision::DELETED_TEXT => 1,
+ Revision::DELETED_USER => 1,
+ Revision::DELETED_COMMENT => 1,
+ ],
+ 'comment' => 'Test for ApiComparePages',
+ ] );
+
+ RevisionDeleter::createList(
+ 'revision',
+ RequestContext::getMain(),
+ Title::newFromText( 'ApiComparePagesTest B' ),
+ [ self::$repl['revB3'] ]
+ )->setVisibility( [
+ 'value' => [
+ Revision::DELETED_USER => 1,
+ Revision::DELETED_COMMENT => 1,
+ Revision::DELETED_RESTRICTED => 1,
+ ],
+ 'comment' => 'Test for ApiComparePages',
+ ] );
+
+ Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason
+ }
+
+ protected function doReplacements( &$value ) {
+ if ( is_string( $value ) ) {
+ if ( preg_match( '/^{{REPL:(.+?)}}$/', $value, $m ) ) {
+ $value = self::$repl[$m[1]];
+ } else {
+ $value = preg_replace_callback( '/{{REPL:(.+?)}}/', function ( $m ) {
+ return isset( self::$repl[$m[1]] ) ? self::$repl[$m[1]] : $m[0];
+ }, $value );
+ }
+ } elseif ( is_array( $value ) || is_object( $value ) ) {
+ foreach ( $value as &$v ) {
+ $this->doReplacements( $v );
+ }
+ unset( $v );
+ }
+ }
+
+ /**
+ * @dataProvider provideDiff
+ */
+ public function testDiff( $params, $expect, $exceptionCode = false, $sysop = false ) {
+ $this->doReplacements( $params );
+
+ $params += [
+ 'action' => 'compare',
+ ];
+
+ $user = $sysop
+ ? static::getTestSysop()->getUser()
+ : static::getTestUser()->getUser();
+ if ( $exceptionCode ) {
+ try {
+ $this->doApiRequest( $params, null, false, $user );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( $this->apiExceptionHasCode( $ex, $exceptionCode ),
+ "Exception with code $exceptionCode" );
+ }
+ } else {
+ $apiResult = $this->doApiRequest( $params, null, false, $user );
+ $apiResult = $apiResult[0];
+ $this->doReplacements( $expect );
+ $this->assertEquals( $expect, $apiResult );
+ }
+ }
+
+ public static function provideDiff() {
+ // phpcs:disable Generic.Files.LineLength.TooLong
+ return [
+ 'Basic diff, titles' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest A',
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageA}}',
+ 'fromrevid' => '{{REPL:revA4}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest A',
+ 'toid' => '{{REPL:pageB}}',
+ 'torevid' => '{{REPL:revB4}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest B',
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">A </del>4</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">B </ins>4</div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, page IDs' => [
+ [
+ 'fromid' => '{{REPL:pageA}}',
+ 'toid' => '{{REPL:pageB}}',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageA}}',
+ 'fromrevid' => '{{REPL:revA4}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest A',
+ 'toid' => '{{REPL:pageB}}',
+ 'torevid' => '{{REPL:revB4}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest B',
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">A </del>4</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">B </ins>4</div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, revision IDs' => [
+ [
+ 'fromrev' => '{{REPL:revA2}}',
+ 'torev' => '{{REPL:revA3}}',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageA}}',
+ 'fromrevid' => '{{REPL:revA2}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest A',
+ 'toid' => '{{REPL:pageA}}',
+ 'torevid' => '{{REPL:revA3}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest A',
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>A <del class="diffchange diffchange-inline">2</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>A <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, deleted revision ID as sysop' => [
+ [
+ 'fromrev' => '{{REPL:revA2}}',
+ 'torev' => '{{REPL:revC2}}',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageA}}',
+ 'fromrevid' => '{{REPL:revA2}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest A',
+ 'toid' => 0,
+ 'torevid' => '{{REPL:revC2}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest C',
+ 'toarchive' => true,
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">A </del>2</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">C </ins>2</div></td></tr>' . "\n",
+ ]
+ ],
+ false, true
+ ],
+ 'Basic diff, revdel as sysop' => [
+ [
+ 'fromrev' => '{{REPL:revA2}}',
+ 'torev' => '{{REPL:revB2}}',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageA}}',
+ 'fromrevid' => '{{REPL:revA2}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest A',
+ 'toid' => '{{REPL:pageB}}',
+ 'torevid' => '{{REPL:revB2}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest B',
+ 'totexthidden' => true,
+ 'touserhidden' => true,
+ 'tocommenthidden' => true,
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">A </del>2</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">B </ins>2</div></td></tr>' . "\n",
+ ]
+ ],
+ false, true
+ ],
+ 'Basic diff, text' => [
+ [
+ 'fromtext' => 'From text',
+ 'fromcontentmodel' => 'wikitext',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'tocontentmodel' => 'wikitext',
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">{{subst:PAGENAME}}</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, text 2' => [
+ [
+ 'fromtext' => 'From text',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'tocontentmodel' => 'wikitext',
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">{{subst:PAGENAME}}</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, guessed model' => [
+ [
+ 'fromtext' => 'From text',
+ 'totext' => 'To text',
+ ],
+ [
+ 'warnings' => [
+ 'compare' => [
+ 'warnings' => 'No content model could be determined, assuming wikitext.',
+ ],
+ ],
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text</div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, text with title and PST' => [
+ [
+ 'fromtext' => 'From text',
+ 'totitle' => 'Test',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'topst' => true,
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">Test</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, text with page ID and PST' => [
+ [
+ 'fromtext' => 'From text',
+ 'toid' => '{{REPL:pageB}}',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'topst' => true,
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest B</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, text with revision and PST' => [
+ [
+ 'fromtext' => 'From text',
+ 'torev' => '{{REPL:revB2}}',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'topst' => true,
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest B</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, text with deleted revision and PST' => [
+ [
+ 'fromtext' => 'From text',
+ 'torev' => '{{REPL:revC2}}',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'topst' => true,
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest C</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ false, true
+ ],
+ 'Basic diff, test with sections' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest F',
+ 'fromsection' => 1,
+ 'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?",
+ 'tosection' => 2,
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>== Section <del class="diffchange diffchange-inline">1 </del>==</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>== Section <ins class="diffchange diffchange-inline">2 </ins>==</div></td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">F 1.1</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To text?</ins></div></td></tr>' . "\n",
+ 'fromid' => '{{REPL:pageF}}',
+ 'fromrevid' => '{{REPL:revF1}}',
+ 'fromns' => '0',
+ 'fromtitle' => 'ApiComparePagesTest F',
+ ]
+ ],
+ ],
+ 'Diff with all props' => [
+ [
+ 'fromrev' => '{{REPL:revB1}}',
+ 'torev' => '{{REPL:revB3}}',
+ 'totitle' => 'ApiComparePagesTest B',
+ 'prop' => 'diff|diffsize|rel|ids|title|user|comment|parsedcomment|size'
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageB}}',
+ 'fromrevid' => '{{REPL:revB1}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest B',
+ 'fromsize' => 3,
+ 'fromuser' => '{{REPL:creator}}',
+ 'fromuserid' => '{{REPL:creatorid}}',
+ 'fromcomment' => 'Test for ApiComparePagesTest: B 1',
+ 'fromparsedcomment' => 'Test for ApiComparePagesTest: B 1',
+ 'toid' => '{{REPL:pageB}}',
+ 'torevid' => '{{REPL:revB3}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest B',
+ 'tosize' => 3,
+ 'touserhidden' => true,
+ 'tocommenthidden' => true,
+ 'tosuppressed' => true,
+ 'next' => '{{REPL:revB4}}',
+ 'diffsize' => 391,
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>B <del class="diffchange diffchange-inline">1</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>B <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Diff with all props as sysop' => [
+ [
+ 'fromrev' => '{{REPL:revB2}}',
+ 'torev' => '{{REPL:revB3}}',
+ 'totitle' => 'ApiComparePagesTest B',
+ 'prop' => 'diff|diffsize|rel|ids|title|user|comment|parsedcomment|size'
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageB}}',
+ 'fromrevid' => '{{REPL:revB2}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest B',
+ 'fromsize' => 3,
+ 'fromtexthidden' => true,
+ 'fromuserhidden' => true,
+ 'fromuser' => '{{REPL:creator}}',
+ 'fromuserid' => '{{REPL:creatorid}}',
+ 'fromcommenthidden' => true,
+ 'fromcomment' => 'Test for ApiComparePagesTest: B 2',
+ 'fromparsedcomment' => 'Test for ApiComparePagesTest: B 2',
+ 'toid' => '{{REPL:pageB}}',
+ 'torevid' => '{{REPL:revB3}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest B',
+ 'tosize' => 3,
+ 'touserhidden' => true,
+ 'tocommenthidden' => true,
+ 'tosuppressed' => true,
+ 'prev' => '{{REPL:revB1}}',
+ 'next' => '{{REPL:revB4}}',
+ 'diffsize' => 391,
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>B <del class="diffchange diffchange-inline">2</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>B <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ false, true
+ ],
+ 'Relative diff, cur' => [
+ [
+ 'fromrev' => '{{REPL:revA2}}',
+ 'torelative' => 'cur',
+ 'prop' => 'ids',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageA}}',
+ 'fromrevid' => '{{REPL:revA2}}',
+ 'toid' => '{{REPL:pageA}}',
+ 'torevid' => '{{REPL:revA4}}',
+ ]
+ ],
+ ],
+ 'Relative diff, next' => [
+ [
+ 'fromrev' => '{{REPL:revE2}}',
+ 'torelative' => 'next',
+ 'prop' => 'ids|rel',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageE}}',
+ 'fromrevid' => '{{REPL:revE2}}',
+ 'toid' => '{{REPL:pageE}}',
+ 'torevid' => '{{REPL:revE3}}',
+ 'prev' => '{{REPL:revE1}}',
+ 'next' => '{{REPL:revE4}}',
+ ]
+ ],
+ ],
+ 'Relative diff, prev' => [
+ [
+ 'fromrev' => '{{REPL:revE3}}',
+ 'torelative' => 'prev',
+ 'prop' => 'ids|rel',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageE}}',
+ 'fromrevid' => '{{REPL:revE2}}',
+ 'toid' => '{{REPL:pageE}}',
+ 'torevid' => '{{REPL:revE3}}',
+ 'prev' => '{{REPL:revE1}}',
+ 'next' => '{{REPL:revE4}}',
+ ]
+ ],
+ ],
+
+ 'Error, missing title' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest X',
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [],
+ 'missingtitle',
+ ],
+ 'Error, invalid title' => [
+ [
+ 'fromtitle' => '<bad>',
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [],
+ 'invalidtitle',
+ ],
+ 'Error, missing page ID' => [
+ [
+ 'fromid' => 8817900,
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [],
+ 'nosuchpageid',
+ ],
+ 'Error, page with missing revision' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest D',
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [],
+ 'nosuchrevid',
+ ],
+ 'Error, page with no revision' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest E',
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [],
+ 'nosuchrevid',
+ ],
+ 'Error, bad rev ID' => [
+ [
+ 'fromrev' => 8817900,
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [],
+ 'nosuchrevid',
+ ],
+ 'Error, deleted revision ID, non-sysop' => [
+ [
+ 'fromrev' => '{{REPL:revA2}}',
+ 'torev' => '{{REPL:revC2}}',
+ ],
+ [],
+ 'nosuchrevid',
+ ],
+ 'Error, revision-deleted content' => [
+ [
+ 'fromrev' => '{{REPL:revA2}}',
+ 'torev' => '{{REPL:revB2}}',
+ ],
+ [],
+ 'missingcontent',
+ ],
+ 'Error, text with no title and PST' => [
+ [
+ 'fromtext' => 'From text',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'topst' => true,
+ ],
+ [],
+ 'compare-no-title',
+ ],
+ 'Error, test with invalid from section ID' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest F',
+ 'fromsection' => 5,
+ 'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?",
+ 'tosection' => 2,
+ ],
+ [],
+ 'nosuchfromsection',
+ ],
+ 'Error, test with invalid to section ID' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest F',
+ 'fromsection' => 1,
+ 'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?",
+ 'tosection' => 5,
+ ],
+ [],
+ 'nosuchtosection',
+ ],
+ 'Error, Relative diff, no from revision' => [
+ [
+ 'fromtext' => 'Foo',
+ 'torelative' => 'cur',
+ 'prop' => 'ids',
+ ],
+ [],
+ 'compare-relative-to-nothing'
+ ],
+ 'Error, Relative diff, cur with no current revision' => [
+ [
+ 'fromrev' => '{{REPL:revE2}}',
+ 'torelative' => 'cur',
+ 'prop' => 'ids',
+ ],
+ [],
+ 'nosuchrevid'
+ ],
+ 'Error, Relative diff, next revdeleted' => [
+ [
+ 'fromrev' => '{{REPL:revB1}}',
+ 'torelative' => 'next',
+ 'prop' => 'ids',
+ ],
+ [],
+ 'missingcontent'
+ ],
+ 'Error, Relative diff, prev revdeleted' => [
+ [
+ 'fromrev' => '{{REPL:revB3}}',
+ 'torelative' => 'prev',
+ 'prop' => 'ids',
+ ],
+ [],
+ 'missingcontent'
+ ],
+ ];
+ // phpcs:enable
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiContinuationManagerTest.php b/www/wiki/tests/phpunit/includes/api/ApiContinuationManagerTest.php
new file mode 100644
index 00000000..788d120c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiContinuationManagerTest.php
@@ -0,0 +1,198 @@
+<?php
+
+/**
+ * @covers ApiContinuationManager
+ * @group API
+ */
+class ApiContinuationManagerTest extends MediaWikiTestCase {
+
+ private static function getManager( $continue, $allModules, $generatedModules ) {
+ $context = new DerivativeContext( RequestContext::getMain() );
+ $context->setRequest( new FauxRequest( [ 'continue' => $continue ] ) );
+ $main = new ApiMain( $context );
+ return new ApiContinuationManager( $main, $allModules, $generatedModules );
+ }
+
+ public function testContinuation() {
+ $allModules = [
+ new MockApiQueryBase( 'mock1' ),
+ new MockApiQueryBase( 'mock2' ),
+ new MockApiQueryBase( 'mocklist' ),
+ ];
+ $generator = new MockApiQueryBase( 'generator' );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( ApiMain::class, $manager->getSource() );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] );
+ $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 );
+ $manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 );
+ $this->assertSame( [ [
+ 'mlcontinue' => 2,
+ 'm1continue' => '1|2',
+ 'continue' => '||mock2',
+ ], false ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'mock1' => [ 'm1continue' => '1|2' ],
+ 'mocklist' => [ 'mlcontinue' => 2 ],
+ 'generator' => [ 'gcontinue' => 3 ],
+ ], $manager->getRawContinuation() );
+
+ $result = new ApiResult( 0 );
+ $manager->setContinuationIntoResult( $result );
+ $this->assertSame( [
+ 'mlcontinue' => 2,
+ 'm1continue' => '1|2',
+ 'continue' => '||mock2',
+ ], $result->getResultData( 'continue' ) );
+ $this->assertSame( null, $result->getResultData( 'batchcomplete' ) );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] );
+ $manager->addGeneratorContinueParam( $generator, 'gcontinue', [ 3, 4 ] );
+ $this->assertSame( [ [
+ 'm1continue' => '1|2',
+ 'continue' => '||mock2|mocklist',
+ ], false ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'mock1' => [ 'm1continue' => '1|2' ],
+ 'generator' => [ 'gcontinue' => '3|4' ],
+ ], $manager->getRawContinuation() );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 );
+ $manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 );
+ $this->assertSame( [ [
+ 'mlcontinue' => 2,
+ 'gcontinue' => 3,
+ 'continue' => 'gcontinue||',
+ ], true ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'mocklist' => [ 'mlcontinue' => 2 ],
+ 'generator' => [ 'gcontinue' => 3 ],
+ ], $manager->getRawContinuation() );
+
+ $result = new ApiResult( 0 );
+ $manager->setContinuationIntoResult( $result );
+ $this->assertSame( [
+ 'mlcontinue' => 2,
+ 'gcontinue' => 3,
+ 'continue' => 'gcontinue||',
+ ], $result->getResultData( 'continue' ) );
+ $this->assertSame( true, $result->getResultData( 'batchcomplete' ) );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 );
+ $this->assertSame( [ [
+ 'gcontinue' => 3,
+ 'continue' => 'gcontinue||mocklist',
+ ], true ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'generator' => [ 'gcontinue' => 3 ],
+ ], $manager->getRawContinuation() );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] );
+ $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 );
+ $this->assertSame( [ [
+ 'mlcontinue' => 2,
+ 'm1continue' => '1|2',
+ 'continue' => '||mock2',
+ ], false ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'mock1' => [ 'm1continue' => '1|2' ],
+ 'mocklist' => [ 'mlcontinue' => 2 ],
+ ], $manager->getRawContinuation() );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] );
+ $this->assertSame( [ [
+ 'm1continue' => '1|2',
+ 'continue' => '||mock2|mocklist',
+ ], false ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'mock1' => [ 'm1continue' => '1|2' ],
+ ], $manager->getRawContinuation() );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 );
+ $this->assertSame( [ [
+ 'mlcontinue' => 2,
+ 'continue' => '-||mock1|mock2',
+ ], true ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'mocklist' => [ 'mlcontinue' => 2 ],
+ ], $manager->getRawContinuation() );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $this->assertSame( [ [], true ], $manager->getContinuation() );
+ $this->assertSame( [], $manager->getRawContinuation() );
+
+ $manager = self::getManager( '||mock2', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame(
+ array_values( array_diff_key( $allModules, [ 1 => 1 ] ) ),
+ $manager->getRunModules()
+ );
+
+ $manager = self::getManager( '-||', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( true, $manager->isGeneratorDone() );
+ $this->assertSame(
+ array_values( array_diff_key( $allModules, [ 0 => 0, 1 => 1 ] ) ),
+ $manager->getRunModules()
+ );
+
+ try {
+ self::getManager( 'foo', $allModules, [ 'mock1', 'mock2' ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'badcontinue' ),
+ 'Expected exception'
+ );
+ }
+
+ $manager = self::getManager(
+ '||mock2',
+ array_slice( $allModules, 0, 2 ),
+ [ 'mock1', 'mock2' ]
+ );
+ try {
+ $manager->addContinueParam( $allModules[1], 'm2continue', 1 );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'Module \'mock2\' was not supposed to have been executed, ' .
+ 'but it was executed anyway',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $manager->addContinueParam( $allModules[2], 'mlcontinue', 1 );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'Module \'mocklist\' called ApiContinuationManager::addContinueParam ' .
+ 'but was not passed to ApiContinuationManager::__construct',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiDeleteTest.php b/www/wiki/tests/phpunit/includes/api/ApiDeleteTest.php
new file mode 100644
index 00000000..0f2bcc61
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiDeleteTest.php
@@ -0,0 +1,168 @@
+<?php
+
+/**
+ * Tests for MediaWiki api.php?action=delete.
+ *
+ * @author Yifei He
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiDelete
+ */
+class ApiDeleteTest extends ApiTestCase {
+ public function testDelete() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ // create new page
+ $this->editPage( $name, 'Some text' );
+
+ // test deletion
+ $apiResult = $this->doApiRequestWithToken( [
+ 'action' => 'delete',
+ 'title' => $name,
+ ] )[0];
+
+ $this->assertArrayHasKey( 'delete', $apiResult );
+ $this->assertArrayHasKey( 'title', $apiResult['delete'] );
+ $this->assertSame( $name, $apiResult['delete']['title'] );
+ $this->assertArrayHasKey( 'logid', $apiResult['delete'] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+
+ public function testDeleteNonexistent() {
+ $this->setExpectedException( ApiUsageException::class,
+ "The page you specified doesn't exist." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'delete',
+ 'title' => 'This page deliberately left nonexistent',
+ ] );
+ }
+
+ public function testDeletionWithoutPermission() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The action you have requested is limited to users in the group:' );
+
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ // create new page
+ $this->editPage( $name, 'Some text' );
+
+ // test deletion without permission
+ try {
+ $user = new User();
+ $apiResult = $this->doApiRequest( [
+ 'action' => 'delete',
+ 'title' => $name,
+ 'token' => $user->getEditToken(),
+ ], null, null, $user );
+ } finally {
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testDeleteWithTag() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ ChangeTags::defineTag( 'custom tag' );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'delete',
+ 'title' => $name,
+ 'tags' => 'custom tag',
+ ] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $this->assertSame( 'custom tag', $dbw->selectField(
+ [ 'change_tag', 'logging' ],
+ 'ct_tag',
+ [
+ 'log_namespace' => NS_HELP,
+ 'log_title' => ucfirst( __FUNCTION__ ),
+ ],
+ __METHOD__,
+ [],
+ [ 'change_tag' => [ 'INNER JOIN', 'ct_log_id = log_id' ] ]
+ ) );
+ }
+
+ public function testDeleteWithoutTagPermission() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'You do not have permission to apply change tags along with your changes.' );
+
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ ChangeTags::defineTag( 'custom tag' );
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'applychangetags' => true ] ] );
+
+ $this->editPage( $name, 'Some text' );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'delete',
+ 'title' => $name,
+ 'tags' => 'custom tag',
+ ] );
+ } finally {
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testDeleteAbortedByHook() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Deletion aborted by hook. It gave no explanation.' );
+
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->setTemporaryHook( 'ArticleDelete',
+ function () {
+ return false;
+ }
+ );
+
+ try {
+ $this->doApiRequestWithToken( [ 'action' => 'delete', 'title' => $name ] );
+ } finally {
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testDeleteWatch() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $user = self::$users['sysop']->getUser();
+
+ $this->editPage( $name, 'Some text' );
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ $this->assertFalse( $user->isWatched( Title::newFromText( $name ) ) );
+
+ $this->doApiRequestWithToken( [ 'action' => 'delete', 'title' => $name, 'watch' => '' ] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ $this->assertTrue( $user->isWatched( Title::newFromText( $name ) ) );
+ }
+
+ public function testDeleteUnwatch() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $user = self::$users['sysop']->getUser();
+
+ $this->editPage( $name, 'Some text' );
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ $user->addWatch( Title::newFromText( $name ) );
+ $this->assertTrue( $user->isWatched( Title::newFromText( $name ) ) );
+
+ $this->doApiRequestWithToken( [ 'action' => 'delete', 'title' => $name, 'unwatch' => '' ] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ $this->assertFalse( $user->isWatched( Title::newFromText( $name ) ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiDisabledTest.php b/www/wiki/tests/phpunit/includes/api/ApiDisabledTest.php
new file mode 100644
index 00000000..cfdd57b8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiDisabledTest.php
@@ -0,0 +1,19 @@
+<?php
+
+/**
+ * @group API
+ * @group medium
+ *
+ * @covers ApiDisabled
+ */
+class ApiDisabledTest extends ApiTestCase {
+ public function testDisabled() {
+ $this->mergeMwGlobalArrayValue( 'wgAPIModules',
+ [ 'login' => 'ApiDisabled' ] );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The "login" module has been disabled.' );
+
+ $this->doApiRequest( [ 'action' => 'login' ] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiEditPageTest.php b/www/wiki/tests/phpunit/includes/api/ApiEditPageTest.php
new file mode 100644
index 00000000..c1963389
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiEditPageTest.php
@@ -0,0 +1,1604 @@
+<?php
+
+/**
+ * Tests for MediaWiki api.php?action=edit.
+ *
+ * @author Daniel Kinzler
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiEditPage
+ */
+class ApiEditPageTest extends ApiTestCase {
+
+ protected function setUp() {
+ global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang;
+
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgExtraNamespaces' => $wgExtraNamespaces,
+ 'wgNamespaceContentModels' => $wgNamespaceContentModels,
+ 'wgContentHandlers' => $wgContentHandlers,
+ 'wgContLang' => $wgContLang,
+ ] );
+
+ $wgExtraNamespaces[12312] = 'Dummy';
+ $wgExtraNamespaces[12313] = 'Dummy_talk';
+ $wgExtraNamespaces[12314] = 'DummyNonText';
+ $wgExtraNamespaces[12315] = 'DummyNonText_talk';
+
+ $wgNamespaceContentModels[12312] = "testing";
+ $wgNamespaceContentModels[12314] = "testing-nontext";
+
+ $wgContentHandlers["testing"] = 'DummyContentHandlerForTesting';
+ $wgContentHandlers["testing-nontext"] = 'DummyNonTextContentHandler';
+ $wgContentHandlers["testing-serialize-error"] =
+ 'DummySerializeErrorContentHandler';
+
+ MWNamespace::clearCaches();
+ $wgContLang->resetNamespaces(); # reset namespace cache
+ }
+
+ protected function tearDown() {
+ global $wgContLang;
+
+ MWNamespace::clearCaches();
+ $wgContLang->resetNamespaces(); # reset namespace cache
+
+ parent::tearDown();
+ }
+
+ public function testEdit() {
+ $name = 'Help:ApiEditPageTest_testEdit'; // assume Help namespace to default to wikitext
+
+ // -- test new page --------------------------------------------
+ $apiResult = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ ] );
+ $apiResult = $apiResult[0];
+
+ // Validate API result data
+ $this->assertArrayHasKey( 'edit', $apiResult );
+ $this->assertArrayHasKey( 'result', $apiResult['edit'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
+
+ $this->assertArrayHasKey( 'new', $apiResult['edit'] );
+ $this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] );
+
+ $this->assertArrayHasKey( 'pageid', $apiResult['edit'] );
+
+ // -- test existing page, no change ----------------------------
+ $data = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ ] );
+
+ $this->assertSame( 'Success', $data[0]['edit']['result'] );
+
+ $this->assertArrayNotHasKey( 'new', $data[0]['edit'] );
+ $this->assertArrayHasKey( 'nochange', $data[0]['edit'] );
+
+ // -- test existing page, with change --------------------------
+ $data = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'different text'
+ ] );
+
+ $this->assertSame( 'Success', $data[0]['edit']['result'] );
+
+ $this->assertArrayNotHasKey( 'new', $data[0]['edit'] );
+ $this->assertArrayNotHasKey( 'nochange', $data[0]['edit'] );
+
+ $this->assertArrayHasKey( 'oldrevid', $data[0]['edit'] );
+ $this->assertArrayHasKey( 'newrevid', $data[0]['edit'] );
+ $this->assertNotEquals(
+ $data[0]['edit']['newrevid'],
+ $data[0]['edit']['oldrevid'],
+ "revision id should change after edit"
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideEditAppend() {
+ return [
+ [ # 0: append
+ 'foo', 'append', 'bar', "foobar"
+ ],
+ [ # 1: prepend
+ 'foo', 'prepend', 'bar', "barfoo"
+ ],
+ [ # 2: append to empty page
+ '', 'append', 'foo', "foo"
+ ],
+ [ # 3: prepend to empty page
+ '', 'prepend', 'foo', "foo"
+ ],
+ [ # 4: append to non-existing page
+ null, 'append', 'foo', "foo"
+ ],
+ [ # 5: prepend to non-existing page
+ null, 'prepend', 'foo', "foo"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideEditAppend
+ */
+ public function testEditAppend( $text, $op, $append, $expected ) {
+ static $count = 0;
+ $count++;
+
+ // assume NS_HELP defaults to wikitext
+ $name = "Help:ApiEditPageTest_testEditAppend_$count";
+
+ // -- create page (or not) -----------------------------------------
+ if ( $text !== null ) {
+ list( $re ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => $text, ] );
+
+ $this->assertSame( 'Success', $re['edit']['result'] ); // sanity
+ }
+
+ // -- try append/prepend --------------------------------------------
+ list( $re ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ $op . 'text' => $append, ] );
+
+ $this->assertSame( 'Success', $re['edit']['result'] );
+
+ // -- validate -----------------------------------------------------
+ $page = new WikiPage( Title::newFromText( $name ) );
+ $content = $page->getContent();
+ $this->assertNotNull( $content, 'Page should have been created' );
+
+ $text = $content->getNativeData();
+
+ $this->assertSame( $expected, $text );
+ }
+
+ /**
+ * Test editing of sections
+ */
+ public function testEditSection() {
+ $name = 'Help:ApiEditPageTest_testEditSection';
+ $page = WikiPage::factory( Title::newFromText( $name ) );
+ $text = "==section 1==\ncontent 1\n==section 2==\ncontent2";
+ // Preload the page with some text
+ $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), 'summary' );
+
+ list( $re ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'section' => '1',
+ 'text' => "==section 1==\nnew content 1",
+ ] );
+ $this->assertSame( 'Success', $re['edit']['result'] );
+ $newtext = WikiPage::factory( Title::newFromText( $name ) )
+ ->getContent( Revision::RAW )
+ ->getNativeData();
+ $this->assertSame( "==section 1==\nnew content 1\n\n==section 2==\ncontent2", $newtext );
+
+ // Test that we raise a 'nosuchsection' error
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'section' => '9999',
+ 'text' => 'text',
+ ] );
+ $this->fail( "Should have raised an ApiUsageException" );
+ } catch ( ApiUsageException $e ) {
+ $this->assertTrue( self::apiExceptionHasCode( $e, 'nosuchsection' ) );
+ }
+ }
+
+ /**
+ * Test action=edit&section=new
+ * Run it twice so we test adding a new section on a
+ * page that doesn't exist (T54830) and one that
+ * does exist
+ */
+ public function testEditNewSection() {
+ $name = 'Help:ApiEditPageTest_testEditNewSection';
+
+ // Test on a page that does not already exist
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ list( $re ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'section' => 'new',
+ 'text' => 'test',
+ 'summary' => 'header',
+ ] );
+
+ $this->assertSame( 'Success', $re['edit']['result'] );
+ // Check the page text is correct
+ $text = WikiPage::factory( Title::newFromText( $name ) )
+ ->getContent( Revision::RAW )
+ ->getNativeData();
+ $this->assertSame( "== header ==\n\ntest", $text );
+
+ // Now on one that does
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ list( $re2 ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'section' => 'new',
+ 'text' => 'test',
+ 'summary' => 'header',
+ ] );
+
+ $this->assertSame( 'Success', $re2['edit']['result'] );
+ $text = WikiPage::factory( Title::newFromText( $name ) )
+ ->getContent( Revision::RAW )
+ ->getNativeData();
+ $this->assertSame( "== header ==\n\ntest\n\n== header ==\n\ntest", $text );
+ }
+
+ /**
+ * Ensure we can edit through a redirect, if adding a section
+ */
+ public function testEdit_redirect() {
+ static $count = 0;
+ $count++;
+
+ // assume NS_HELP defaults to wikitext
+ $name = "Help:ApiEditPageTest_testEdit_redirect_$count";
+ $title = Title::newFromText( $name );
+ $page = WikiPage::factory( $title );
+
+ $rname = "Help:ApiEditPageTest_testEdit_redirect_r$count";
+ $rtitle = Title::newFromText( $rname );
+ $rpage = WikiPage::factory( $rtitle );
+
+ // base edit for content
+ $page->doEditContent( new WikitextContent( "Foo" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $page, '20120101000000' );
+ $baseTime = $page->getRevision()->getTimestamp();
+
+ // base edit for redirect
+ $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $rpage, '20120101000000' );
+
+ // conflicting edit to redirect
+ $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]\n\n[[Category:Test]]" ),
+ "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() );
+ $this->forceRevisionDate( $rpage, '20120101020202' );
+
+ // try to save edit, following the redirect
+ list( $re, , ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $rname,
+ 'text' => 'nix bar!',
+ 'basetimestamp' => $baseTime,
+ 'section' => 'new',
+ 'redirect' => true,
+ ] );
+
+ $this->assertSame( 'Success', $re['edit']['result'],
+ "no problems expected when following redirect" );
+ }
+
+ /**
+ * Ensure we cannot edit through a redirect, if attempting to overwrite content
+ */
+ public function testEdit_redirectText() {
+ static $count = 0;
+ $count++;
+
+ // assume NS_HELP defaults to wikitext
+ $name = "Help:ApiEditPageTest_testEdit_redirectText_$count";
+ $title = Title::newFromText( $name );
+ $page = WikiPage::factory( $title );
+
+ $rname = "Help:ApiEditPageTest_testEdit_redirectText_r$count";
+ $rtitle = Title::newFromText( $rname );
+ $rpage = WikiPage::factory( $rtitle );
+
+ // base edit for content
+ $page->doEditContent( new WikitextContent( "Foo" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $page, '20120101000000' );
+ $baseTime = $page->getRevision()->getTimestamp();
+
+ // base edit for redirect
+ $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $rpage, '20120101000000' );
+
+ // conflicting edit to redirect
+ $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]\n\n[[Category:Test]]" ),
+ "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() );
+ $this->forceRevisionDate( $rpage, '20120101020202' );
+
+ // try to save edit, following the redirect but without creating a section
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $rname,
+ 'text' => 'nix bar!',
+ 'basetimestamp' => $baseTime,
+ 'redirect' => true,
+ ] );
+
+ $this->fail( 'redirect-appendonly error expected' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( self::apiExceptionHasCode( $ex, 'redirect-appendonly' ) );
+ }
+ }
+
+ public function testEditConflict() {
+ static $count = 0;
+ $count++;
+
+ // assume NS_HELP defaults to wikitext
+ $name = "Help:ApiEditPageTest_testEditConflict_$count";
+ $title = Title::newFromText( $name );
+
+ $page = WikiPage::factory( $title );
+
+ // base edit
+ $page->doEditContent( new WikitextContent( "Foo" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $page, '20120101000000' );
+ $baseTime = $page->getRevision()->getTimestamp();
+
+ // conflicting edit
+ $page->doEditContent( new WikitextContent( "Foo bar" ),
+ "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() );
+ $this->forceRevisionDate( $page, '20120101020202' );
+
+ // try to save edit, expect conflict
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'nix bar!',
+ 'basetimestamp' => $baseTime,
+ ] );
+
+ $this->fail( 'edit conflict expected' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( self::apiExceptionHasCode( $ex, 'editconflict' ) );
+ }
+ }
+
+ /**
+ * Ensure that editing using section=new will prevent simple conflicts
+ */
+ public function testEditConflict_newSection() {
+ static $count = 0;
+ $count++;
+
+ // assume NS_HELP defaults to wikitext
+ $name = "Help:ApiEditPageTest_testEditConflict_newSection_$count";
+ $title = Title::newFromText( $name );
+
+ $page = WikiPage::factory( $title );
+
+ // base edit
+ $page->doEditContent( new WikitextContent( "Foo" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $page, '20120101000000' );
+ $baseTime = $page->getRevision()->getTimestamp();
+
+ // conflicting edit
+ $page->doEditContent( new WikitextContent( "Foo bar" ),
+ "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() );
+ $this->forceRevisionDate( $page, '20120101020202' );
+
+ // try to save edit, expect no conflict
+ list( $re, , ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'nix bar!',
+ 'basetimestamp' => $baseTime,
+ 'section' => 'new',
+ ] );
+
+ $this->assertSame( 'Success', $re['edit']['result'],
+ "no edit conflict expected here" );
+ }
+
+ public function testEditConflict_bug41990() {
+ static $count = 0;
+ $count++;
+
+ /*
+ * T43990: if the target page has a newer revision than the redirect, then editing the
+ * redirect while specifying 'redirect' and *not* specifying 'basetimestamp' erroneously
+ * caused an edit conflict to be detected.
+ */
+
+ // assume NS_HELP defaults to wikitext
+ $name = "Help:ApiEditPageTest_testEditConflict_redirect_bug41990_$count";
+ $title = Title::newFromText( $name );
+ $page = WikiPage::factory( $title );
+
+ $rname = "Help:ApiEditPageTest_testEditConflict_redirect_bug41990_r$count";
+ $rtitle = Title::newFromText( $rname );
+ $rpage = WikiPage::factory( $rtitle );
+
+ // base edit for content
+ $page->doEditContent( new WikitextContent( "Foo" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $page, '20120101000000' );
+
+ // base edit for redirect
+ $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $rpage, '20120101000000' );
+
+ // new edit to content
+ $page->doEditContent( new WikitextContent( "Foo bar" ),
+ "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() );
+ $this->forceRevisionDate( $rpage, '20120101020202' );
+
+ // try to save edit; should work, following the redirect.
+ list( $re, , ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $rname,
+ 'text' => 'nix bar!',
+ 'section' => 'new',
+ 'redirect' => true,
+ ] );
+
+ $this->assertSame( 'Success', $re['edit']['result'],
+ "no edit conflict expected here" );
+ }
+
+ /**
+ * @param WikiPage $page
+ * @param string|int $timestamp
+ */
+ protected function forceRevisionDate( WikiPage $page, $timestamp ) {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->update( 'revision',
+ [ 'rev_timestamp' => $dbw->timestamp( $timestamp ) ],
+ [ 'rev_id' => $page->getLatest() ] );
+
+ $page->clear();
+ }
+
+ public function testCheckDirectApiEditingDisallowed_forNonTextContent() {
+ $this->setExpectedException(
+ ApiUsageException::class,
+ 'Direct editing via API is not supported for content model ' .
+ 'testing used by Dummy:ApiEditPageTest_nonTextPageEdit'
+ );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => 'Dummy:ApiEditPageTest_nonTextPageEdit',
+ 'text' => '{"animals":["kittens!"]}'
+ ] );
+ }
+
+ public function testSupportsDirectApiEditing_withContentHandlerOverride() {
+ $name = 'DummyNonText:ApiEditPageTest_testNonTextEdit';
+ $data = serialize( 'some bla bla text' );
+
+ $result = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => $data,
+ ] );
+
+ $apiResult = $result[0];
+
+ // Validate API result data
+ $this->assertArrayHasKey( 'edit', $apiResult );
+ $this->assertArrayHasKey( 'result', $apiResult['edit'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
+
+ $this->assertArrayHasKey( 'new', $apiResult['edit'] );
+ $this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] );
+
+ $this->assertArrayHasKey( 'pageid', $apiResult['edit'] );
+
+ // validate resulting revision
+ $page = WikiPage::factory( Title::newFromText( $name ) );
+ $this->assertSame( "testing-nontext", $page->getContentModel() );
+ $this->assertSame( $data, $page->getContent()->serialize() );
+ }
+
+ /**
+ * This test verifies that after changing the content model
+ * of a page, undoing that edit via the API will also
+ * undo the content model change.
+ */
+ public function testUndoAfterContentModelChange() {
+ $name = 'Help:' . __FUNCTION__;
+ $uploader = self::$users['uploader']->getUser();
+ $sysop = self::$users['sysop']->getUser();
+
+ $apiResult = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ ], null, $sysop )[0];
+
+ // Check success
+ $this->assertArrayHasKey( 'edit', $apiResult );
+ $this->assertArrayHasKey( 'result', $apiResult['edit'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
+ $this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] );
+ // Content model is wikitext
+ $this->assertSame( 'wikitext', $apiResult['edit']['contentmodel'] );
+
+ // Convert the page to JSON
+ $apiResult = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => '{}',
+ 'contentmodel' => 'json',
+ ], null, $uploader )[0];
+
+ // Check success
+ $this->assertArrayHasKey( 'edit', $apiResult );
+ $this->assertArrayHasKey( 'result', $apiResult['edit'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
+ $this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] );
+ $this->assertSame( 'json', $apiResult['edit']['contentmodel'] );
+
+ $apiResult = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $apiResult['edit']['newrevid']
+ ], null, $sysop )[0];
+
+ // Check success
+ $this->assertArrayHasKey( 'edit', $apiResult );
+ $this->assertArrayHasKey( 'result', $apiResult['edit'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
+ $this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] );
+ // Check that the contentmodel is back to wikitext now.
+ $this->assertSame( 'wikitext', $apiResult['edit']['contentmodel'] );
+ }
+
+ // The tests below are mostly not commented because they do exactly what
+ // you'd expect from the name.
+
+ public function testCorrectContentFormat() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ 'contentmodel' => 'wikitext',
+ 'contentformat' => 'text/x-wiki',
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+
+ public function testUnsupportedContentFormat() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'Unrecognized value for parameter "contentformat": nonexistent format.' );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ 'contentformat' => 'nonexistent format',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testMismatchedContentFormat() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The requested format text/plain is not supported for content ' .
+ "model wikitext used by $name." );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ 'contentmodel' => 'wikitext',
+ 'contentformat' => 'text/plain',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testUndoToInvalidRev() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $revId = $this->editPage( $name, 'Some text' )->value['revision']
+ ->getId();
+ $revId++;
+
+ $this->setExpectedException( ApiUsageException::class,
+ "There is no revision with ID $revId." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId,
+ ] );
+ }
+
+ /**
+ * Tests what happens if the undo parameter is a valid revision, but
+ * the undoafter parameter doesn't refer to a revision that exists in the
+ * database.
+ */
+ public function testUndoAfterToInvalidRev() {
+ // We can't just pick a large number for undoafter (as in
+ // testUndoToInvalidRev above), because then MediaWiki will helpfully
+ // assume we switched around undo and undoafter and we'll test the code
+ // path for undo being invalid, not undoafter. So instead we delete
+ // the revision from the database. In real life this case could come
+ // up if a revision number was skipped, e.g., if two transactions try
+ // to insert new revision rows at once and the first one to succeed
+ // gets rolled back.
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $titleObj = Title::newFromText( $name );
+
+ $revId1 = $this->editPage( $name, '1' )->value['revision']->getId();
+ $revId2 = $this->editPage( $name, '2' )->value['revision']->getId();
+ $revId3 = $this->editPage( $name, '3' )->value['revision']->getId();
+
+ // Make the middle revision disappear
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete( 'revision', [ 'rev_id' => $revId2 ], __METHOD__ );
+ $dbw->update( 'revision', [ 'rev_parent_id' => $revId1 ],
+ [ 'rev_id' => $revId3 ], __METHOD__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "There is no revision with ID $revId2." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId3,
+ 'undoafter' => $revId2,
+ ] );
+ }
+
+ /**
+ * Tests what happens if the undo parameter is a valid revision, but
+ * undoafter is hidden (rev_deleted).
+ */
+ public function testUndoAfterToHiddenRev() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $titleObj = Title::newFromText( $name );
+
+ $this->editPage( $name, '0' );
+
+ $revId1 = $this->editPage( $name, '1' )->value['revision']->getId();
+
+ $revId2 = $this->editPage( $name, '2' )->value['revision']->getId();
+
+ // Hide the middle revision
+ $list = RevisionDeleter::createList( 'revision',
+ RequestContext::getMain(), $titleObj, [ $revId1 ] );
+ $list->setVisibility( [
+ 'value' => [ Revision::DELETED_TEXT => 1 ],
+ 'comment' => 'Bye-bye',
+ ] );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "There is no revision with ID $revId1." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId2,
+ 'undoafter' => $revId1,
+ ] );
+ }
+
+ /**
+ * Test undo when a revision with a higher id has an earlier timestamp.
+ * This can happen if importing an old revision.
+ */
+ public function testUndoWithSwappedRevisions() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $titleObj = Title::newFromText( $name );
+
+ $this->editPage( $name, '0' );
+
+ $revId2 = $this->editPage( $name, '2' )->value['revision']->getId();
+
+ $revId1 = $this->editPage( $name, '1' )->value['revision']->getId();
+
+ // Now monkey with the timestamp
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update(
+ 'revision',
+ [ 'rev_timestamp' => wfTimestamp( TS_MW, time() - 86400 ) ],
+ [ 'rev_id' => $revId1 ],
+ __METHOD__
+ );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId2,
+ 'undoafter' => $revId1,
+ ] );
+
+ $text = ( new WikiPage( $titleObj ) )->getContent()->getNativeData();
+
+ // This is wrong! It should be 1. But let's test for our incorrect
+ // behavior for now, so if someone fixes it they'll fix the test as
+ // well to expect 1. If we disabled the test, it might stay disabled
+ // even once the bug is fixed, which would be a shame.
+ $this->assertSame( '2', $text );
+ }
+
+ public function testUndoWithConflicts() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The edit could not be undone due to conflicting intermediate edits.' );
+
+ $this->editPage( $name, '1' );
+
+ $revId = $this->editPage( $name, '2' )->value['revision']->getId();
+
+ $this->editPage( $name, '3' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId,
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )->getContent()
+ ->getNativeData();
+ $this->assertSame( '3', $text );
+ }
+
+ /**
+ * undoafter is supposed to be less than undo. If not, we reverse their
+ * meaning, so that the two are effectively interchangeable.
+ */
+ public function testReversedUndoAfter() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, '0' );
+ $revId1 = $this->editPage( $name, '1' )->value['revision']->getId();
+ $revId2 = $this->editPage( $name, '2' )->value['revision']->getId();
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId1,
+ 'undoafter' => $revId2,
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )->getContent()
+ ->getNativeData();
+ $this->assertSame( '1', $text );
+ }
+
+ public function testUndoToRevFromDifferentPage() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( "$name-1", 'Some text' );
+ $revId = $this->editPage( "$name-1", 'Some more text' )
+ ->value['revision']->getId();
+
+ $this->editPage( "$name-2", 'Some text' );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "r$revId is not a revision of $name-2." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => "$name-2",
+ 'undo' => $revId,
+ ] );
+ }
+
+ public function testUndoAfterToRevFromDifferentPage() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $revId1 = $this->editPage( "$name-1", 'Some text' )
+ ->value['revision']->getId();
+
+ $revId2 = $this->editPage( "$name-2", 'Some text' )
+ ->value['revision']->getId();
+
+ $this->setExpectedException( ApiUsageException::class,
+ "r$revId1 is not a revision of $name-2." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => "$name-2",
+ 'undo' => $revId2,
+ 'undoafter' => $revId1,
+ ] );
+ }
+
+ public function testMd5Text() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'md5' => md5( 'Some text' ),
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+
+ public function testMd5PrependText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'prependtext' => 'Alert: ',
+ 'md5' => md5( 'Alert: ' ),
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+ $this->assertSame( 'Alert: Some text', $text );
+ }
+
+ public function testMd5AppendText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => ' is nice',
+ 'md5' => md5( ' is nice' ),
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+ $this->assertSame( 'Some text is nice', $text );
+ }
+
+ public function testMd5PrependAndAppendText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'prependtext' => 'Alert: ',
+ 'appendtext' => ' is nice',
+ 'md5' => md5( 'Alert: is nice' ),
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+ $this->assertSame( 'Alert: Some text is nice', $text );
+ }
+
+ public function testIncorrectMd5Text() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The supplied MD5 hash was incorrect.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'md5' => md5( '' ),
+ ] );
+ }
+
+ public function testIncorrectMd5PrependText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The supplied MD5 hash was incorrect.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'prependtext' => 'Some ',
+ 'appendtext' => 'text',
+ 'md5' => md5( 'Some ' ),
+ ] );
+ }
+
+ public function testIncorrectMd5AppendText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The supplied MD5 hash was incorrect.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'prependtext' => 'Some ',
+ 'appendtext' => 'text',
+ 'md5' => md5( 'text' ),
+ ] );
+ }
+
+ public function testCreateOnly() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The article you tried to create has been created already.' );
+
+ $this->editPage( $name, 'Some text' );
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some more text',
+ 'createonly' => '',
+ ] );
+ } finally {
+ // Validate that content was not changed
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( 'Some text', $text );
+ }
+ }
+
+ public function testNoCreate() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "The page you specified doesn't exist." );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'nocreate' => '',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ /**
+ * Appending/prepending is currently only supported for TextContent. We
+ * test this right now, and when support is added this test should be
+ * replaced by tests that the support is correct.
+ */
+ public function testAppendWithNonTextContentHandler() {
+ $name = 'MediaWiki:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "Can't append to pages using content model testing-nontext." );
+
+ $this->setTemporaryHook( 'ContentHandlerDefaultModelFor',
+ function ( Title $title, &$model ) use ( $name ) {
+ if ( $title->getPrefixedText() === $name ) {
+ $model = 'testing-nontext';
+ }
+ return true;
+ }
+ );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => 'Some text',
+ ] );
+ }
+
+ public function testAppendInMediaWikiNamespace() {
+ $name = 'MediaWiki:' . ucfirst( __FUNCTION__ );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => 'Some text',
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+
+ public function testAppendInMediaWikiNamespaceWithSerializationError() {
+ $name = 'MediaWiki:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'Content serialization failed: Could not unserialize content' );
+
+ $this->setTemporaryHook( 'ContentHandlerDefaultModelFor',
+ function ( Title $title, &$model ) use ( $name ) {
+ if ( $title->getPrefixedText() === $name ) {
+ $model = 'testing-serialize-error';
+ }
+ return true;
+ }
+ );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => 'Some text',
+ ] );
+ }
+
+ public function testAppendNewSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => '== New section ==',
+ 'section' => 'new',
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( "Initial content\n\n== New section ==", $text );
+ }
+
+ public function testAppendNewSectionWithInvalidContentModel() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'Sections are not supported for content model text.' );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => '== New section ==',
+ 'section' => 'new',
+ 'contentmodel' => 'text',
+ ] );
+ }
+
+ public function testAppendNewSectionWithTitle() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'sectiontitle' => 'My section',
+ 'appendtext' => 'More content',
+ 'section' => 'new',
+ ] );
+
+ $page = new WikiPage( Title::newFromText( $name ) );
+
+ $this->assertSame( "Initial content\n\n== My section ==\n\nMore content",
+ $page->getContent()->getNativeData() );
+ $this->assertSame( '/* My section */ new section',
+ $page->getRevision()->getComment() );
+ }
+
+ public function testAppendNewSectionWithSummary() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => 'More content',
+ 'section' => 'new',
+ 'summary' => 'Add new section',
+ ] );
+
+ $page = new WikiPage( Title::newFromText( $name ) );
+
+ $this->assertSame( "Initial content\n\n== Add new section ==\n\nMore content",
+ $page->getContent()->getNativeData() );
+ // EditPage actually assumes the summary is the section name here
+ $this->assertSame( '/* Add new section */ new section',
+ $page->getRevision()->getComment() );
+ }
+
+ public function testAppendNewSectionWithTitleAndSummary() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'sectiontitle' => 'My section',
+ 'appendtext' => 'More content',
+ 'section' => 'new',
+ 'summary' => 'Add new section',
+ ] );
+
+ $page = new WikiPage( Title::newFromText( $name ) );
+
+ $this->assertSame( "Initial content\n\n== My section ==\n\nMore content",
+ $page->getContent()->getNativeData() );
+ $this->assertSame( 'Add new section',
+ $page->getRevision()->getComment() );
+ }
+
+ public function testAppendToSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "== Section 1 ==\n\nContent\n\n" .
+ "== Section 2 ==\n\nFascinating!" );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => ' and more content',
+ 'section' => '1',
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( "== Section 1 ==\n\nContent and more content\n\n" .
+ "== Section 2 ==\n\nFascinating!", $text );
+ }
+
+ public function testAppendToFirstSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "Content\n\n== Section 1 ==\n\nFascinating!" );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => ' and more content',
+ 'section' => '0',
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( "Content and more content\n\n== Section 1 ==\n\n" .
+ "Fascinating!", $text );
+ }
+
+ public function testAppendToNonexistentSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class, 'There is no section 1.' );
+
+ $this->editPage( $name, 'Content' );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => ' and more content',
+ 'section' => '1',
+ ] );
+ } finally {
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( 'Content', $text );
+ }
+ }
+
+ public function testEditMalformedSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The "section" parameter must be a valid section ID or "new".' );
+ $this->editPage( $name, 'Content' );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Different content',
+ 'section' => 'It is unlikely that this is valid',
+ ] );
+ } finally {
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( 'Content', $text );
+ }
+ }
+
+ public function testEditWithStartTimestamp() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $this->setExpectedException( ApiUsageException::class,
+ 'The page has been deleted since you fetched its timestamp.' );
+
+ $startTime = MWTimestamp::convert( TS_MW, time() - 1 );
+
+ $this->editPage( $name, 'Some text' );
+
+ $pageObj = new WikiPage( Title::newFromText( $name ) );
+ $pageObj->doDeleteArticle( 'Bye-bye' );
+
+ $this->assertFalse( $pageObj->exists() );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Different text',
+ 'starttimestamp' => $startTime,
+ ] );
+ } finally {
+ $this->assertFalse( $pageObj->exists() );
+ }
+ }
+
+ public function testEditMinor() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Different text',
+ 'minor' => '',
+ ] );
+
+ $revisionStore = \MediaWiki\MediaWikiServices::getInstance()->getRevisionStore();
+ $revision = $revisionStore->getRevisionByTitle( Title::newFromText( $name ) );
+ $this->assertTrue( $revision->isMinor() );
+ }
+
+ public function testEditRecreate() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $startTime = MWTimestamp::convert( TS_MW, time() - 1 );
+
+ $this->editPage( $name, 'Some text' );
+
+ $pageObj = new WikiPage( Title::newFromText( $name ) );
+ $pageObj->doDeleteArticle( 'Bye-bye' );
+
+ $this->assertFalse( $pageObj->exists() );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Different text',
+ 'starttimestamp' => $startTime,
+ 'recreate' => '',
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+
+ public function testEditWatch() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $user = self::$users['sysop']->getUser();
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'watch' => '',
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ $this->assertTrue( $user->isWatched( Title::newFromText( $name ) ) );
+ }
+
+ public function testEditUnwatch() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $user = self::$users['sysop']->getUser();
+ $titleObj = Title::newFromText( $name );
+
+ $user->addWatch( $titleObj );
+
+ $this->assertFalse( $titleObj->exists() );
+ $this->assertTrue( $user->isWatched( $titleObj ) );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'unwatch' => '',
+ ] );
+
+ $this->assertTrue( $titleObj->exists() );
+ $this->assertFalse( $user->isWatched( $titleObj ) );
+ }
+
+ public function testEditWithTag() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ ChangeTags::defineTag( 'custom tag' );
+
+ $revId = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'tags' => 'custom tag',
+ ] )[0]['edit']['newrevid'];
+
+ $dbw = wfGetDB( DB_MASTER );
+ $this->assertSame( 'custom tag', $dbw->selectField(
+ 'change_tag', 'ct_tag', [ 'ct_rev_id' => $revId ], __METHOD__ ) );
+ }
+
+ public function testEditWithoutTagPermission() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'You do not have permission to apply change tags along with your changes.' );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ ChangeTags::defineTag( 'custom tag' );
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'applychangetags' => true ] ] );
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'tags' => 'custom tag',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testEditAbortedByHook() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The modification you tried to make was aborted by an extension.' );
+
+ $this->hideDeprecated( 'APIEditBeforeSave hook (used in ' .
+ 'hook-APIEditBeforeSave-closure)' );
+
+ $this->setTemporaryHook( 'APIEditBeforeSave',
+ function () {
+ return false;
+ }
+ );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testEditAbortedByHookWithCustomOutput() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->hideDeprecated( 'APIEditBeforeSave hook (used in ' .
+ 'hook-APIEditBeforeSave-closure)' );
+
+ $this->setTemporaryHook( 'APIEditBeforeSave',
+ function ( $unused1, $unused2, &$r ) {
+ $r['msg'] = 'Some message';
+ return false;
+ } );
+
+ $result = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ Wikimedia\restoreWarnings();
+
+ $this->assertSame( [ 'msg' => 'Some message', 'result' => 'Failure' ],
+ $result[0]['edit'] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+
+ public function testEditAbortedByEditPageHookWithResult() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setTemporaryHook( 'EditFilterMergedContent',
+ function ( $unused1, $unused2, Status $status ) {
+ $status->apiHookResult = [ 'msg' => 'A message for you!' ];
+ return false;
+ } );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ $this->assertSame( [ 'edit' => [ 'msg' => 'A message for you!',
+ 'result' => 'Failure' ] ], $res[0] );
+ }
+
+ public function testEditAbortedByEditPageHookWithNoResult() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The modification you tried to make was aborted by an extension.' );
+
+ $this->setTemporaryHook( 'EditFilterMergedContent',
+ function () {
+ return false;
+ }
+ );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testEditWhileBlocked() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'You have been blocked from editing.' );
+
+ $block = new Block( [
+ 'address' => self::$users['sysop']->getUser()->getName(),
+ 'by' => self::$users['sysop']->getUser()->getId(),
+ 'reason' => 'Capriciousness',
+ 'timestamp' => '19370101000000',
+ 'expiry' => 'infinity',
+ ] );
+ $block->insert();
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ } finally {
+ $block->delete();
+ self::$users['sysop']->getUser()->clearInstanceCache();
+ }
+ }
+
+ public function testEditWhileReadOnly() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The wiki is currently in read-only mode.' );
+
+ $svc = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode();
+ $svc->setReason( "Read-only for testing" );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ } finally {
+ $svc->setReason( false );
+ }
+ }
+
+ public function testCreateImageRedirectAnon() {
+ $name = 'File:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "Anonymous users can't create image redirects." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => '#REDIRECT [[File:Other file.png]]',
+ ], null, new User() );
+ }
+
+ public function testCreateImageRedirectLoggedIn() {
+ $name = 'File:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to create image redirects." );
+
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'upload' => true ] ] );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => '#REDIRECT [[File:Other file.png]]',
+ ] );
+ }
+
+ public function testTooBigEdit() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The content you supplied exceeds the article size limit of 1 kilobyte.' );
+
+ $this->setMwGlobals( 'wgMaxArticleSize', 1 );
+
+ $text = str_repeat( '!', 1025 );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => $text,
+ ] );
+ }
+
+ public function testProhibitedAnonymousEdit() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The action you have requested is limited to users in the group: ' );
+
+ $this->setMwGlobals( 'wgRevokePermissions', [ '*' => [ 'edit' => true ] ] );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ], null, new User() );
+ }
+
+ public function testProhibitedChangeContentModel() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to change the content model of a page." );
+
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'editcontentmodel' => true ] ] );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'contentmodel' => 'json',
+ ] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiErrorFormatterTest.php b/www/wiki/tests/phpunit/includes/api/ApiErrorFormatterTest.php
new file mode 100644
index 00000000..aa579ab0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiErrorFormatterTest.php
@@ -0,0 +1,642 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group API
+ */
+class ApiErrorFormatterTest extends MediaWikiLangTestCase {
+
+ /**
+ * @covers ApiErrorFormatter
+ */
+ public function testErrorFormatterBasics() {
+ $result = new ApiResult( 8388608 );
+ $formatter = new ApiErrorFormatter( $result, Language::factory( 'de' ), 'wikitext', false );
+ $this->assertSame( 'de', $formatter->getLanguage()->getCode() );
+
+ $formatter->addMessagesFromStatus( null, Status::newGood() );
+ $this->assertSame(
+ [ ApiResult::META_TYPE => 'assoc' ],
+ $result->getResultData()
+ );
+
+ $this->assertSame( [], $formatter->arrayFromStatus( Status::newGood() ) );
+
+ $wrappedFormatter = TestingAccessWrapper::newFromObject( $formatter );
+ $this->assertSame(
+ 'Blah "kbd" <X> 😊',
+ $wrappedFormatter->stripMarkup( 'Blah <kbd>kbd</kbd> <b>&lt;X&gt;</b> &#x1f60a;' ),
+ 'stripMarkup'
+ );
+ }
+
+ /**
+ * @covers ApiErrorFormatter
+ * @dataProvider provideErrorFormatter
+ */
+ public function testErrorFormatter( $format, $lang, $useDB,
+ $expect1, $expect2, $expect3
+ ) {
+ $result = new ApiResult( 8388608 );
+ $formatter = new ApiErrorFormatter( $result, Language::factory( $lang ), $format, $useDB );
+
+ // Add default type
+ $expect1[ApiResult::META_TYPE] = 'assoc';
+ $expect2[ApiResult::META_TYPE] = 'assoc';
+ $expect3[ApiResult::META_TYPE] = 'assoc';
+
+ $formatter->addWarning( 'string', 'mainpage' );
+ $formatter->addError( 'err', 'mainpage' );
+ $this->assertEquals( $expect1, $result->getResultData(), 'Simple test' );
+
+ $result->reset();
+ $formatter->addWarning( 'foo', 'mainpage' );
+ $formatter->addWarning( 'foo', 'mainpage' );
+ $formatter->addWarning( 'foo', [ 'parentheses', 'foobar' ] );
+ $msg1 = wfMessage( 'mainpage' );
+ $formatter->addWarning( 'message', $msg1 );
+ $msg2 = new ApiMessage( 'mainpage', 'overriddenCode', [ 'overriddenData' => true ] );
+ $formatter->addWarning( 'messageWithData', $msg2 );
+ $formatter->addError( 'errWithData', $msg2 );
+ $this->assertSame( $expect2, $result->getResultData(), 'Complex test' );
+
+ $this->assertEquals(
+ $this->removeModuleTag( $expect2['warnings'][2] ),
+ $formatter->formatMessage( $msg1 ),
+ 'formatMessage test 1'
+ );
+ $this->assertEquals(
+ $this->removeModuleTag( $expect2['warnings'][3] ),
+ $formatter->formatMessage( $msg2 ),
+ 'formatMessage test 2'
+ );
+
+ $result->reset();
+ $status = Status::newGood();
+ $status->warning( 'mainpage' );
+ $status->warning( 'parentheses', 'foobar' );
+ $status->warning( $msg1 );
+ $status->warning( $msg2 );
+ $status->error( 'mainpage' );
+ $status->error( 'parentheses', 'foobar' );
+ $formatter->addMessagesFromStatus( 'status', $status );
+ $this->assertSame( $expect3, $result->getResultData(), 'Status test' );
+
+ $this->assertSame(
+ array_map( [ $this, 'removeModuleTag' ], $expect3['errors'] ),
+ $formatter->arrayFromStatus( $status, 'error' ),
+ 'arrayFromStatus test for error'
+ );
+ $this->assertSame(
+ array_map( [ $this, 'removeModuleTag' ], $expect3['warnings'] ),
+ $formatter->arrayFromStatus( $status, 'warning' ),
+ 'arrayFromStatus test for warning'
+ );
+ }
+
+ private function removeModuleTag( $s ) {
+ if ( is_array( $s ) ) {
+ unset( $s['module'] );
+ }
+ return $s;
+ }
+
+ public static function provideErrorFormatter() {
+ $mainpageText = wfMessage( 'mainpage' )->inLanguage( 'de' )->useDatabase( false )->text();
+ $parensText = wfMessage( 'parentheses', 'foobar' )->inLanguage( 'de' )
+ ->useDatabase( false )->text();
+ $mainpageHTML = wfMessage( 'mainpage' )->inLanguage( 'en' )->parse();
+ $parensHTML = wfMessage( 'parentheses', 'foobar' )->inLanguage( 'en' )->parse();
+ $C = ApiResult::META_CONTENT;
+ $I = ApiResult::META_INDEXED_TAG_NAME;
+ $overriddenData = [ 'overriddenData' => true, ApiResult::META_TYPE => 'assoc' ];
+
+ return [
+ $tmp = [ 'wikitext', 'de', false,
+ [
+ 'errors' => [
+ [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'err', $C => 'text' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'string', $C => 'text' ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [ 'code' => 'overriddenCode', 'text' => $mainpageText,
+ 'data' => $overriddenData, 'module' => 'errWithData', $C => 'text' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'foo', $C => 'text' ],
+ [ 'code' => 'parentheses', 'text' => $parensText, 'module' => 'foo', $C => 'text' ],
+ [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'message', $C => 'text' ],
+ [ 'code' => 'overriddenCode', 'text' => $mainpageText,
+ 'data' => $overriddenData, 'module' => 'messageWithData', $C => 'text' ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'status', $C => 'text' ],
+ [ 'code' => 'parentheses', 'text' => $parensText, 'module' => 'status', $C => 'text' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'status', $C => 'text' ],
+ [ 'code' => 'parentheses', 'text' => $parensText, 'module' => 'status', $C => 'text' ],
+ [ 'code' => 'overriddenCode', 'text' => $mainpageText,
+ 'data' => $overriddenData, 'module' => 'status', $C => 'text' ],
+ $I => 'warning',
+ ],
+ ],
+ ],
+ [ 'plaintext' ] + $tmp, // For these messages, plaintext and wikitext are the same
+ [ 'html', 'en', true,
+ [
+ 'errors' => [
+ [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'err', $C => 'html' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'string', $C => 'html' ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [ 'code' => 'overriddenCode', 'html' => $mainpageHTML,
+ 'data' => $overriddenData, 'module' => 'errWithData', $C => 'html' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'foo', $C => 'html' ],
+ [ 'code' => 'parentheses', 'html' => $parensHTML, 'module' => 'foo', $C => 'html' ],
+ [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'message', $C => 'html' ],
+ [ 'code' => 'overriddenCode', 'html' => $mainpageHTML,
+ 'data' => $overriddenData, 'module' => 'messageWithData', $C => 'html' ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'status', $C => 'html' ],
+ [ 'code' => 'parentheses', 'html' => $parensHTML, 'module' => 'status', $C => 'html' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'status', $C => 'html' ],
+ [ 'code' => 'parentheses', 'html' => $parensHTML, 'module' => 'status', $C => 'html' ],
+ [ 'code' => 'overriddenCode', 'html' => $mainpageHTML,
+ 'data' => $overriddenData, 'module' => 'status', $C => 'html' ],
+ $I => 'warning',
+ ],
+ ],
+ ],
+ [ 'raw', 'fr', true,
+ [
+ 'errors' => [
+ [
+ 'code' => 'mainpage',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'module' => 'err',
+ ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [
+ 'code' => 'mainpage',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'module' => 'string',
+ ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [
+ 'code' => 'overriddenCode',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'data' => $overriddenData,
+ 'module' => 'errWithData',
+ ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [
+ 'code' => 'mainpage',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'module' => 'foo',
+ ],
+ [
+ 'code' => 'parentheses',
+ 'key' => 'parentheses',
+ 'params' => [ 'foobar', $I => 'param' ],
+ 'module' => 'foo',
+ ],
+ [
+ 'code' => 'mainpage',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'module' => 'message',
+ ],
+ [
+ 'code' => 'overriddenCode',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'data' => $overriddenData,
+ 'module' => 'messageWithData',
+ ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [
+ 'code' => 'mainpage',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'module' => 'status',
+ ],
+ [
+ 'code' => 'parentheses',
+ 'key' => 'parentheses',
+ 'params' => [ 'foobar', $I => 'param' ],
+ 'module' => 'status',
+ ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [
+ 'code' => 'mainpage',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'module' => 'status',
+ ],
+ [
+ 'code' => 'parentheses',
+ 'key' => 'parentheses',
+ 'params' => [ 'foobar', $I => 'param' ],
+ 'module' => 'status',
+ ],
+ [
+ 'code' => 'overriddenCode',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'data' => $overriddenData,
+ 'module' => 'status',
+ ],
+ $I => 'warning',
+ ],
+ ],
+ ],
+ [ 'none', 'fr', true,
+ [
+ 'errors' => [
+ [ 'code' => 'mainpage', 'module' => 'err' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'module' => 'string' ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [ 'code' => 'overriddenCode', 'data' => $overriddenData,
+ 'module' => 'errWithData' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'module' => 'foo' ],
+ [ 'code' => 'parentheses', 'module' => 'foo' ],
+ [ 'code' => 'mainpage', 'module' => 'message' ],
+ [ 'code' => 'overriddenCode', 'data' => $overriddenData,
+ 'module' => 'messageWithData' ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [ 'code' => 'mainpage', 'module' => 'status' ],
+ [ 'code' => 'parentheses', 'module' => 'status' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'module' => 'status' ],
+ [ 'code' => 'parentheses', 'module' => 'status' ],
+ [ 'code' => 'overriddenCode', 'data' => $overriddenData, 'module' => 'status' ],
+ $I => 'warning',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @covers ApiErrorFormatter_BackCompat
+ */
+ public function testErrorFormatterBC() {
+ $mainpagePlain = wfMessage( 'mainpage' )->useDatabase( false )->plain();
+ $parensPlain = wfMessage( 'parentheses', 'foobar' )->useDatabase( false )->plain();
+
+ $result = new ApiResult( 8388608 );
+ $formatter = new ApiErrorFormatter_BackCompat( $result );
+
+ $this->assertSame( 'en', $formatter->getLanguage()->getCode() );
+
+ $this->assertSame( [], $formatter->arrayFromStatus( Status::newGood() ) );
+
+ $formatter->addWarning( 'string', 'mainpage' );
+ $formatter->addWarning( 'raw',
+ new RawMessage( 'Blah <kbd>kbd</kbd> <b>&lt;X&gt;</b> &#x1f61e;' )
+ );
+ $formatter->addError( 'err', 'mainpage' );
+ $this->assertSame( [
+ 'error' => [
+ 'code' => 'mainpage',
+ 'info' => $mainpagePlain,
+ ],
+ 'warnings' => [
+ 'raw' => [
+ 'warnings' => 'Blah "kbd" <X> 😞',
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ 'string' => [
+ 'warnings' => $mainpagePlain,
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData(), 'Simple test' );
+
+ $result->reset();
+ $formatter->addWarning( 'foo', 'mainpage' );
+ $formatter->addWarning( 'foo', 'mainpage' );
+ $formatter->addWarning( 'xxx+foo', [ 'parentheses', 'foobar' ] );
+ $msg1 = wfMessage( 'mainpage' );
+ $formatter->addWarning( 'message', $msg1 );
+ $msg2 = new ApiMessage( 'mainpage', 'overriddenCode', [ 'overriddenData' => true ] );
+ $formatter->addWarning( 'messageWithData', $msg2 );
+ $formatter->addError( 'errWithData', $msg2 );
+ $formatter->addWarning( null, 'mainpage' );
+ $this->assertSame( [
+ 'error' => [
+ 'code' => 'overriddenCode',
+ 'info' => $mainpagePlain,
+ 'overriddenData' => true,
+ ],
+ 'warnings' => [
+ 'unknown' => [
+ 'warnings' => $mainpagePlain,
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ 'messageWithData' => [
+ 'warnings' => $mainpagePlain,
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ 'message' => [
+ 'warnings' => $mainpagePlain,
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ 'foo' => [
+ 'warnings' => "$mainpagePlain\n$parensPlain",
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData(), 'Complex test' );
+
+ $this->assertSame(
+ [
+ 'code' => 'mainpage',
+ 'info' => 'Main Page',
+ ],
+ $formatter->formatMessage( $msg1 )
+ );
+ $this->assertSame(
+ [
+ 'code' => 'overriddenCode',
+ 'info' => 'Main Page',
+ 'overriddenData' => true,
+ ],
+ $formatter->formatMessage( $msg2 )
+ );
+
+ $result->reset();
+ $status = Status::newGood();
+ $status->warning( 'mainpage' );
+ $status->warning( 'parentheses', 'foobar' );
+ $status->warning( $msg1 );
+ $status->warning( $msg2 );
+ $status->error( 'mainpage' );
+ $status->error( 'parentheses', 'foobar' );
+ $formatter->addMessagesFromStatus( 'status', $status );
+ $this->assertSame( [
+ 'error' => [
+ 'code' => 'mainpage',
+ 'info' => $mainpagePlain,
+ ],
+ 'warnings' => [
+ 'status' => [
+ 'warnings' => "$mainpagePlain\n$parensPlain",
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData(), 'Status test' );
+
+ $I = ApiResult::META_INDEXED_TAG_NAME;
+ $this->assertSame(
+ [
+ [
+ 'message' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'code' => 'mainpage',
+ 'type' => 'error',
+ ],
+ [
+ 'message' => 'parentheses',
+ 'params' => [ 'foobar', $I => 'param' ],
+ 'code' => 'parentheses',
+ 'type' => 'error',
+ ],
+ $I => 'error',
+ ],
+ $formatter->arrayFromStatus( $status, 'error' ),
+ 'arrayFromStatus test for error'
+ );
+ $this->assertSame(
+ [
+ [
+ 'message' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'code' => 'mainpage',
+ 'type' => 'warning',
+ ],
+ [
+ 'message' => 'parentheses',
+ 'params' => [ 'foobar', $I => 'param' ],
+ 'code' => 'parentheses',
+ 'type' => 'warning',
+ ],
+ [
+ 'message' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'code' => 'mainpage',
+ 'type' => 'warning',
+ ],
+ [
+ 'message' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'code' => 'overriddenCode',
+ 'type' => 'warning',
+ ],
+ $I => 'warning',
+ ],
+ $formatter->arrayFromStatus( $status, 'warning' ),
+ 'arrayFromStatus test for warning'
+ );
+
+ $result->reset();
+ $result->addValue( null, 'error', [ 'bogus' ] );
+ $formatter->addError( 'err', 'mainpage' );
+ $this->assertSame( [
+ 'error' => [
+ 'code' => 'mainpage',
+ 'info' => $mainpagePlain,
+ ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData(), 'Overwrites bogus "error" value with real error' );
+ }
+
+ /**
+ * @dataProvider provideGetMessageFromException
+ * @covers ApiErrorFormatter::getMessageFromException
+ * @covers ApiErrorFormatter::formatException
+ * @param Exception $exception
+ * @param array $options
+ * @param array $expect
+ */
+ public function testGetMessageFromException( $exception, $options, $expect ) {
+ if ( $exception instanceof UsageException ) {
+ $this->hideDeprecated( 'UsageException::getMessageArray' );
+ }
+
+ $result = new ApiResult( 8388608 );
+ $formatter = new ApiErrorFormatter( $result, Language::factory( 'en' ), 'html', false );
+
+ $msg = $formatter->getMessageFromException( $exception, $options );
+ $this->assertInstanceOf( Message::class, $msg );
+ $this->assertInstanceOf( IApiMessage::class, $msg );
+ $this->assertSame( $expect, [
+ 'text' => $msg->parse(),
+ 'code' => $msg->getApiCode(),
+ 'data' => $msg->getApiData(),
+ ] );
+
+ $expectFormatted = $formatter->formatMessage( $msg );
+ $formatted = $formatter->formatException( $exception, $options );
+ $this->assertSame( $expectFormatted, $formatted );
+ }
+
+ /**
+ * @dataProvider provideGetMessageFromException
+ * @covers ApiErrorFormatter_BackCompat::formatException
+ * @param Exception $exception
+ * @param array $options
+ * @param array $expect
+ */
+ public function testGetMessageFromException_BC( $exception, $options, $expect ) {
+ $result = new ApiResult( 8388608 );
+ $formatter = new ApiErrorFormatter_BackCompat( $result );
+
+ $msg = $formatter->getMessageFromException( $exception, $options );
+ $this->assertInstanceOf( Message::class, $msg );
+ $this->assertInstanceOf( IApiMessage::class, $msg );
+ $this->assertSame( $expect, [
+ 'text' => $msg->parse(),
+ 'code' => $msg->getApiCode(),
+ 'data' => $msg->getApiData(),
+ ] );
+
+ $expectFormatted = $formatter->formatMessage( $msg );
+ $formatted = $formatter->formatException( $exception, $options );
+ $this->assertSame( $expectFormatted, $formatted );
+ $formatted = $formatter->formatException( $exception, $options + [ 'bc' => true ] );
+ $this->assertSame( $expectFormatted['info'], $formatted );
+ }
+
+ public static function provideGetMessageFromException() {
+ Wikimedia\suppressWarnings();
+ $usageException = new UsageException(
+ '<b>Something broke!</b>', 'ue-code', 0, [ 'xxx' => 'yyy', 'baz' => 23 ]
+ );
+ Wikimedia\restoreWarnings();
+
+ return [
+ 'Normal exception' => [
+ new RuntimeException( '<b>Something broke!</b>' ),
+ [],
+ [
+ 'text' => '&#60;b&#62;Something broke!&#60;/b&#62;',
+ 'code' => 'internal_api_error_RuntimeException',
+ 'data' => [],
+ ]
+ ],
+ 'Normal exception, wrapped' => [
+ new RuntimeException( '<b>Something broke!</b>' ),
+ [ 'wrap' => 'parentheses', 'code' => 'some-code', 'data' => [ 'foo' => 'bar', 'baz' => 42 ] ],
+ [
+ 'text' => '(&#60;b&#62;Something broke!&#60;/b&#62;)',
+ 'code' => 'some-code',
+ 'data' => [ 'foo' => 'bar', 'baz' => 42 ],
+ ]
+ ],
+ 'UsageException' => [
+ $usageException,
+ [],
+ [
+ 'text' => '&#60;b&#62;Something broke!&#60;/b&#62;',
+ 'code' => 'ue-code',
+ 'data' => [ 'xxx' => 'yyy', 'baz' => 23 ],
+ ]
+ ],
+ 'UsageException, wrapped' => [
+ $usageException,
+ [ 'wrap' => 'parentheses', 'code' => 'some-code', 'data' => [ 'foo' => 'bar', 'baz' => 42 ] ],
+ [
+ 'text' => '(&#60;b&#62;Something broke!&#60;/b&#62;)',
+ 'code' => 'some-code',
+ 'data' => [ 'xxx' => 'yyy', 'baz' => 42, 'foo' => 'bar' ],
+ ]
+ ],
+ 'LocalizedException' => [
+ new LocalizedException( [ 'returnto', '<b>FooBar</b>' ] ),
+ [],
+ [
+ 'text' => 'Return to <b>FooBar</b>.',
+ 'code' => 'returnto',
+ 'data' => [],
+ ]
+ ],
+ 'LocalizedException, wrapped' => [
+ new LocalizedException( [ 'returnto', '<b>FooBar</b>' ] ),
+ [ 'wrap' => 'parentheses', 'code' => 'some-code', 'data' => [ 'foo' => 'bar', 'baz' => 42 ] ],
+ [
+ 'text' => 'Return to <b>FooBar</b>.',
+ 'code' => 'some-code',
+ 'data' => [ 'foo' => 'bar', 'baz' => 42 ],
+ ]
+ ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiLoginTest.php b/www/wiki/tests/phpunit/includes/api/ApiLoginTest.php
new file mode 100644
index 00000000..d382c83c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiLoginTest.php
@@ -0,0 +1,301 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiLogin
+ */
+class ApiLoginTest extends ApiTestCase {
+
+ /**
+ * Test result of attempted login with an empty username
+ */
+ public function testApiLoginNoName() {
+ $session = [
+ 'wsTokenSecrets' => [ 'login' => 'foobar' ],
+ ];
+ $data = $this->doApiRequest( [ 'action' => 'login',
+ 'lgname' => '', 'lgpassword' => self::$users['sysop']->getPassword(),
+ 'lgtoken' => (string)( new MediaWiki\Session\Token( 'foobar', '' ) )
+ ], $session );
+ $this->assertEquals( 'Failed', $data[0]['login']['result'] );
+ }
+
+ public function testApiLoginBadPass() {
+ global $wgServer;
+
+ $user = self::$users['sysop'];
+ $userName = $user->getUser()->getName();
+ $user->getUser()->logout();
+
+ if ( !isset( $wgServer ) ) {
+ $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+ }
+ $ret = $this->doApiRequest( [
+ "action" => "login",
+ "lgname" => $userName,
+ "lgpassword" => "bad",
+ ] );
+
+ $result = $ret[0];
+
+ $this->assertNotInternalType( "bool", $result );
+ $a = $result["login"]["result"];
+ $this->assertEquals( "NeedToken", $a );
+
+ $token = $result["login"]["token"];
+
+ $ret = $this->doApiRequest(
+ [
+ "action" => "login",
+ "lgtoken" => $token,
+ "lgname" => $userName,
+ "lgpassword" => "badnowayinhell",
+ ],
+ $ret[2]
+ );
+
+ $result = $ret[0];
+
+ $this->assertNotInternalType( "bool", $result );
+ $a = $result["login"]["result"];
+
+ $this->assertEquals( 'Failed', $a );
+ }
+
+ public function testApiLoginGoodPass() {
+ global $wgServer;
+
+ if ( !isset( $wgServer ) ) {
+ $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+ }
+
+ $user = self::$users['sysop'];
+ $userName = $user->getUser()->getName();
+ $password = $user->getPassword();
+ $user->getUser()->logout();
+
+ $ret = $this->doApiRequest( [
+ "action" => "login",
+ "lgname" => $userName,
+ "lgpassword" => $password,
+ ]
+ );
+
+ $result = $ret[0];
+ $this->assertNotInternalType( "bool", $result );
+ $this->assertNotInternalType( "null", $result["login"] );
+
+ $a = $result["login"]["result"];
+ $this->assertEquals( "NeedToken", $a );
+ $token = $result["login"]["token"];
+
+ $ret = $this->doApiRequest(
+ [
+ "action" => "login",
+ "lgtoken" => $token,
+ "lgname" => $userName,
+ "lgpassword" => $password,
+ ],
+ $ret[2]
+ );
+
+ $result = $ret[0];
+
+ $this->assertNotInternalType( "bool", $result );
+ $a = $result["login"]["result"];
+
+ $this->assertEquals( "Success", $a );
+ }
+
+ /**
+ * @group Broken
+ */
+ public function testApiLoginGotCookie() {
+ $this->markTestIncomplete( "The server can't do external HTTP requests, "
+ . "and the internal one won't give cookies" );
+
+ global $wgServer, $wgScriptPath;
+
+ if ( !isset( $wgServer ) ) {
+ $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+ }
+ $user = self::$users['sysop'];
+ $userName = $user->getUser()->getName();
+ $password = $user->getPassword();
+
+ $req = MWHttpRequest::factory( self::$apiUrl . "?action=login&format=xml",
+ [ "method" => "POST",
+ "postData" => [
+ "lgname" => $userName,
+ "lgpassword" => $password
+ ]
+ ],
+ __METHOD__
+ );
+ $req->execute();
+
+ libxml_use_internal_errors( true );
+ $sxe = simplexml_load_string( $req->getContent() );
+ $this->assertNotInternalType( "bool", $sxe );
+ $this->assertThat( $sxe, $this->isInstanceOf( SimpleXMLElement::class ) );
+ $this->assertNotInternalType( "null", $sxe->login[0] );
+
+ $a = $sxe->login[0]->attributes()->result[0];
+ $this->assertEquals( ' result="NeedToken"', $a->asXML() );
+ $token = (string)$sxe->login[0]->attributes()->token;
+
+ $req->setData( [
+ "lgtoken" => $token,
+ "lgname" => $userName,
+ "lgpassword" => $password ] );
+ $req->execute();
+
+ $cj = $req->getCookieJar();
+ $serverName = parse_url( $wgServer, PHP_URL_HOST );
+ $this->assertNotEquals( false, $serverName );
+ $serializedCookie = $cj->serializeToHttpRequest( $wgScriptPath, $serverName );
+ $this->assertNotEquals( '', $serializedCookie );
+ $this->assertRegExp(
+ '/_session=[^;]*; .*UserID=[0-9]*; .*UserName=' . $user->userName . '; .*Token=/',
+ $serializedCookie
+ );
+ }
+
+ public function testRunLogin() {
+ $user = self::$users['sysop'];
+ $userName = $user->getUser()->getName();
+ $password = $user->getPassword();
+
+ $data = $this->doApiRequest( [
+ 'action' => 'login',
+ 'lgname' => $userName,
+ 'lgpassword' => $password ] );
+
+ $this->assertArrayHasKey( "login", $data[0] );
+ $this->assertArrayHasKey( "result", $data[0]['login'] );
+ $this->assertEquals( "NeedToken", $data[0]['login']['result'] );
+ $token = $data[0]['login']['token'];
+
+ $data = $this->doApiRequest( [
+ 'action' => 'login',
+ "lgtoken" => $token,
+ "lgname" => $userName,
+ "lgpassword" => $password ], $data[2] );
+
+ $this->assertArrayHasKey( "login", $data[0] );
+ $this->assertArrayHasKey( "result", $data[0]['login'] );
+ $this->assertEquals( "Success", $data[0]['login']['result'] );
+ }
+
+ public function testBotPassword() {
+ global $wgServer, $wgSessionProviders;
+
+ if ( !isset( $wgServer ) ) {
+ $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+ }
+
+ $this->setMwGlobals( [
+ 'wgSessionProviders' => array_merge( $wgSessionProviders, [
+ [
+ 'class' => MediaWiki\Session\BotPasswordSessionProvider::class,
+ 'args' => [ [ 'priority' => 40 ] ],
+ ]
+ ] ),
+ 'wgEnableBotPasswords' => true,
+ 'wgBotPasswordsDatabase' => false,
+ 'wgCentralIdLookupProvider' => 'local',
+ 'wgGrantPermissions' => [
+ 'test' => [ 'read' => true ],
+ ],
+ ] );
+
+ // Make sure our session provider is present
+ $manager = TestingAccessWrapper::newFromObject( MediaWiki\Session\SessionManager::singleton() );
+ if ( !isset( $manager->sessionProviders[MediaWiki\Session\BotPasswordSessionProvider::class] ) ) {
+ $tmp = $manager->sessionProviders;
+ $manager->sessionProviders = null;
+ $manager->sessionProviders = $tmp + $manager->getProviders();
+ }
+ $this->assertNotNull(
+ MediaWiki\Session\SessionManager::singleton()->getProvider(
+ MediaWiki\Session\BotPasswordSessionProvider::class
+ ),
+ 'sanity check'
+ );
+
+ $user = self::$users['sysop'];
+ $centralId = CentralIdLookup::factory()->centralIdFromLocalUser( $user->getUser() );
+ $this->assertNotEquals( 0, $centralId, 'sanity check' );
+
+ $password = 'ngfhmjm64hv0854493hsj5nncjud2clk';
+ $passwordFactory = new PasswordFactory();
+ $passwordFactory->init( RequestContext::getMain()->getConfig() );
+ // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+ $passwordHash = $passwordFactory->newFromPlaintext( $password );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->insert(
+ 'bot_passwords',
+ [
+ 'bp_user' => $centralId,
+ 'bp_app_id' => 'foo',
+ 'bp_password' => $passwordHash->toString(),
+ 'bp_token' => '',
+ 'bp_restrictions' => MWRestrictions::newDefault()->toJson(),
+ 'bp_grants' => '["test"]',
+ ],
+ __METHOD__
+ );
+
+ $lgName = $user->getUser()->getName() . BotPassword::getSeparator() . 'foo';
+
+ $ret = $this->doApiRequest( [
+ 'action' => 'login',
+ 'lgname' => $lgName,
+ 'lgpassword' => $password,
+ ] );
+
+ $result = $ret[0];
+ $this->assertNotInternalType( 'bool', $result );
+ $this->assertNotInternalType( 'null', $result['login'] );
+
+ $a = $result['login']['result'];
+ $this->assertEquals( 'NeedToken', $a );
+ $token = $result['login']['token'];
+
+ $ret = $this->doApiRequest( [
+ 'action' => 'login',
+ 'lgtoken' => $token,
+ 'lgname' => $lgName,
+ 'lgpassword' => $password,
+ ], $ret[2] );
+
+ $result = $ret[0];
+ $this->assertNotInternalType( 'bool', $result );
+ $a = $result['login']['result'];
+
+ $this->assertEquals( 'Success', $a );
+ }
+
+ public function testLoginWithNoSameOriginSecurity() {
+ $this->setTemporaryHook( 'RequestHasSameOriginSecurity',
+ function () {
+ return false;
+ }
+ );
+
+ $result = $this->doApiRequest( [
+ 'action' => 'login',
+ ] )[0]['login'];
+
+ $this->assertSame( [
+ 'result' => 'Aborted',
+ 'reason' => 'Cannot log in when the same-origin policy is not applied.',
+ ], $result );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiLogoutTest.php b/www/wiki/tests/phpunit/includes/api/ApiLogoutTest.php
new file mode 100644
index 00000000..8254fdba
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiLogoutTest.php
@@ -0,0 +1,75 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiLogout
+ */
+class ApiLogoutTest extends ApiTestCase {
+
+ protected function setUp() {
+ global $wgRequest, $wgUser;
+
+ parent::setUp();
+
+ // Link the user to the Session properly so User::doLogout() doesn't complain.
+ $wgRequest->getSession()->setUser( $wgUser );
+ $wgUser = User::newFromSession( $wgRequest );
+ $this->apiContext->setUser( $wgUser );
+ }
+
+ public function testUserLogoutBadToken() {
+ global $wgUser;
+
+ $this->setExpectedApiException( 'apierror-badtoken' );
+
+ try {
+ $token = 'invalid token';
+ $this->doUserLogout( $token );
+ } finally {
+ $this->assertTrue( $wgUser->isLoggedIn(), 'not logged out' );
+ }
+ }
+
+ public function testUserLogout() {
+ global $wgUser;
+
+ $this->assertTrue( $wgUser->isLoggedIn(), 'sanity check' );
+ $token = $this->getUserCsrfTokenFromApi();
+ $this->doUserLogout( $token );
+ $this->assertFalse( $wgUser->isLoggedIn() );
+ }
+
+ public function testUserLogoutWithWebToken() {
+ global $wgUser, $wgRequest;
+
+ $this->assertTrue( $wgUser->isLoggedIn(), 'sanity check' );
+
+ // Logic copied from SkinTemplate.
+ $token = $wgUser->getEditToken( 'logoutToken', $wgRequest );
+
+ $this->doUserLogout( $token );
+ $this->assertFalse( $wgUser->isLoggedIn() );
+ }
+
+ private function getUserCsrfTokenFromApi() {
+ $retToken = $this->doApiRequest( [
+ 'action' => 'query',
+ 'meta' => 'tokens',
+ 'type' => 'csrf'
+ ] );
+
+ $this->assertArrayNotHasKey( 'warnings', $retToken );
+
+ return $retToken[0]['query']['tokens']['csrftoken'];
+ }
+
+ private function doUserLogout( $logoutToken ) {
+ return $this->doApiRequest( [
+ 'action' => 'logout',
+ 'token' => $logoutToken
+ ] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiMainTest.php b/www/wiki/tests/phpunit/includes/api/ApiMainTest.php
new file mode 100644
index 00000000..d17334bb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiMainTest.php
@@ -0,0 +1,1072 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiMain
+ */
+class ApiMainTest extends ApiTestCase {
+
+ /**
+ * Test that the API will accept a FauxRequest and execute.
+ */
+ public function testApi() {
+ $api = new ApiMain(
+ new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
+ );
+ $api->execute();
+ $data = $api->getResult()->getResultData();
+ $this->assertInternalType( 'array', $data );
+ $this->assertArrayHasKey( 'query', $data );
+ }
+
+ public function testApiNoParam() {
+ $api = new ApiMain();
+ $api->execute();
+ $data = $api->getResult()->getResultData();
+ $this->assertInternalType( 'array', $data );
+ }
+
+ /**
+ * ApiMain behaves differently if passed a FauxRequest (mInternalMode set
+ * to true) or a proper WebRequest (mInternalMode false). For most tests
+ * we can just set mInternalMode to false using TestingAccessWrapper, but
+ * this doesn't work for the constructor. This method returns an ApiMain
+ * that's been set up in non-internal mode.
+ *
+ * Note that calling execute() will print to the console. Wrap it in
+ * ob_start()/ob_end_clean() to prevent this.
+ *
+ * @param array $requestData Query parameters for the WebRequest
+ * @param array $headers Headers for the WebRequest
+ */
+ private function getNonInternalApiMain( array $requestData, array $headers = [] ) {
+ $req = $this->getMockBuilder( WebRequest::class )
+ ->setMethods( [ 'response', 'getRawIP' ] )
+ ->getMock();
+ $response = new FauxResponse();
+ $req->method( 'response' )->willReturn( $response );
+ $req->method( 'getRawIP' )->willReturn( '127.0.0.1' );
+
+ $wrapper = TestingAccessWrapper::newFromObject( $req );
+ $wrapper->data = $requestData;
+ if ( $headers ) {
+ $wrapper->headers = $headers;
+ }
+
+ return new ApiMain( $req );
+ }
+
+ public function testUselang() {
+ global $wgLang;
+
+ $api = $this->getNonInternalApiMain( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'uselang' => 'fr',
+ ] );
+
+ ob_start();
+ $api->execute();
+ ob_end_clean();
+
+ $this->assertSame( 'fr', $wgLang->getCode() );
+ }
+
+ public function testNonWhitelistedCorsWithCookies() {
+ $logFile = $this->getNewTempFile();
+
+ $this->mergeMwGlobalArrayValue( '_COOKIE', [ 'forceHTTPS' => '1' ] );
+ $logger = new TestLogger( true );
+ $this->setLogger( 'cors', $logger );
+
+ $api = $this->getNonInternalApiMain( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ // For some reason multiple origins (which are not allowed in the
+ // WHATWG Fetch spec that supersedes the RFC) are always considered to
+ // be problematic.
+ ], [ 'ORIGIN' => 'https://www.example.com https://www.com.example' ] );
+
+ $this->assertSame(
+ [ [ Psr\Log\LogLevel::WARNING, 'Non-whitelisted CORS request with session cookies' ] ],
+ $logger->getBuffer()
+ );
+ }
+
+ public function testSuppressedLogin() {
+ global $wgUser;
+ $origUser = $wgUser;
+
+ $api = $this->getNonInternalApiMain( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'origin' => '*',
+ ] );
+
+ ob_start();
+ $api->execute();
+ ob_end_clean();
+
+ $this->assertNotSame( $origUser, $wgUser );
+ $this->assertSame( 'true', $api->getContext()->getRequest()->response()
+ ->getHeader( 'MediaWiki-Login-Suppressed' ) );
+ }
+
+ public function testSetContinuationManager() {
+ $api = new ApiMain();
+ $manager = $this->createMock( ApiContinuationManager::class );
+ $api->setContinuationManager( $manager );
+ $this->assertTrue( true, 'No exception' );
+ return [ $api, $manager ];
+ }
+
+ /**
+ * @depends testSetContinuationManager
+ */
+ public function testSetContinuationManagerTwice( $args ) {
+ $this->setExpectedException( UnexpectedValueException::class,
+ 'ApiMain::setContinuationManager: tried to set manager from ' .
+ 'when a manager is already set from ' );
+
+ list( $api, $manager ) = $args;
+ $api->setContinuationManager( $manager );
+ }
+
+ public function testSetCacheModeUnrecognized() {
+ $api = new ApiMain();
+ $api->setCacheMode( 'unrecognized' );
+ $this->assertSame(
+ 'private',
+ TestingAccessWrapper::newFromObject( $api )->mCacheMode,
+ 'Unrecognized params must be silently ignored'
+ );
+ }
+
+ public function testSetCacheModePrivateWiki() {
+ $this->setGroupPermissions( '*', 'read', false );
+
+ $wrappedApi = TestingAccessWrapper::newFromObject( new ApiMain() );
+ $wrappedApi->setCacheMode( 'public' );
+ $this->assertSame( 'private', $wrappedApi->mCacheMode );
+ $wrappedApi->setCacheMode( 'anon-public-user-private' );
+ $this->assertSame( 'private', $wrappedApi->mCacheMode );
+ }
+
+ public function testAddRequestedFieldsRequestId() {
+ $req = new FauxRequest( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'requestid' => '123456',
+ ] );
+ $api = new ApiMain( $req );
+ $api->execute();
+ $this->assertSame( '123456', $api->getResult()->getResultData()['requestid'] );
+ }
+
+ public function testAddRequestedFieldsCurTimestamp() {
+ $req = new FauxRequest( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'curtimestamp' => '',
+ ] );
+ $api = new ApiMain( $req );
+ $api->execute();
+ $timestamp = $api->getResult()->getResultData()['curtimestamp'];
+ $this->assertLessThanOrEqual( 1, abs( strtotime( $timestamp ) - time() ) );
+ }
+
+ public function testAddRequestedFieldsResponseLangInfo() {
+ $req = new FauxRequest( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ // errorlang is ignored if errorformat is not specified
+ 'errorformat' => 'plaintext',
+ 'uselang' => 'FR',
+ 'errorlang' => 'ja',
+ 'responselanginfo' => '',
+ ] );
+ $api = new ApiMain( $req );
+ $api->execute();
+ $data = $api->getResult()->getResultData();
+ $this->assertSame( 'fr', $data['uselang'] );
+ $this->assertSame( 'ja', $data['errorlang'] );
+ }
+
+ public function testSetupModuleUnknown() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Unrecognized value for parameter "action": unknownaction.' );
+
+ $req = new FauxRequest( [ 'action' => 'unknownaction' ] );
+ $api = new ApiMain( $req );
+ $api->execute();
+ }
+
+ public function testSetupModuleNoTokenProvided() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The "token" parameter must be set.' );
+
+ $req = new FauxRequest( [
+ 'action' => 'edit',
+ 'title' => 'New page',
+ 'text' => 'Some text',
+ ] );
+ $api = new ApiMain( $req );
+ $api->execute();
+ }
+
+ public function testSetupModuleInvalidTokenProvided() {
+ $this->setExpectedException( ApiUsageException::class, 'Invalid CSRF token.' );
+
+ $req = new FauxRequest( [
+ 'action' => 'edit',
+ 'title' => 'New page',
+ 'text' => 'Some text',
+ 'token' => "This isn't a real token!",
+ ] );
+ $api = new ApiMain( $req );
+ $api->execute();
+ }
+
+ public function testSetupModuleNeedsTokenTrue() {
+ $this->setExpectedException( MWException::class,
+ "Module 'testmodule' must be updated for the new token handling. " .
+ "See documentation for ApiBase::needsToken for details." );
+
+ $mock = $this->createMock( ApiBase::class );
+ $mock->method( 'getModuleName' )->willReturn( 'testmodule' );
+ $mock->method( 'needsToken' )->willReturn( true );
+
+ $api = new ApiMain( new FauxRequest( [ 'action' => 'testmodule' ] ) );
+ $api->getModuleManager()->addModule( 'testmodule', 'action', get_class( $mock ),
+ function () use ( $mock ) {
+ return $mock;
+ }
+ );
+ $api->execute();
+ }
+
+ public function testSetupModuleNeedsTokenNeedntBePosted() {
+ $this->setExpectedException( MWException::class,
+ "Module 'testmodule' must require POST to use tokens." );
+
+ $mock = $this->createMock( ApiBase::class );
+ $mock->method( 'getModuleName' )->willReturn( 'testmodule' );
+ $mock->method( 'needsToken' )->willReturn( 'csrf' );
+ $mock->method( 'mustBePosted' )->willReturn( false );
+
+ $api = new ApiMain( new FauxRequest( [ 'action' => 'testmodule' ] ) );
+ $api->getModuleManager()->addModule( 'testmodule', 'action', get_class( $mock ),
+ function () use ( $mock ) {
+ return $mock;
+ }
+ );
+ $api->execute();
+ }
+
+ public function testCheckMaxLagFailed() {
+ // It's hard to mock the LoadBalancer properly, so instead we'll mock
+ // checkMaxLag (which is tested directly in other tests below).
+ $req = new FauxRequest( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ ] );
+
+ $mock = $this->getMockBuilder( ApiMain::class )
+ ->setConstructorArgs( [ $req ] )
+ ->setMethods( [ 'checkMaxLag' ] )
+ ->getMock();
+ $mock->method( 'checkMaxLag' )->willReturn( false );
+
+ $mock->execute();
+
+ $this->assertArrayNotHasKey( 'query', $mock->getResult()->getResultData() );
+ }
+
+ public function testCheckConditionalRequestHeadersFailed() {
+ // The detailed checking of all cases of checkConditionalRequestHeaders
+ // is below in testCheckConditionalRequestHeaders(), which calls the
+ // method directly. Here we just check that it will stop execution if
+ // it does fail.
+ $now = time();
+
+ $this->setMwGlobals( 'wgCacheEpoch', '20030516000000' );
+
+ $mock = $this->createMock( ApiBase::class );
+ $mock->method( 'getModuleName' )->willReturn( 'testmodule' );
+ $mock->method( 'getConditionalRequestData' )
+ ->willReturn( wfTimestamp( TS_MW, $now - 3600 ) );
+ $mock->expects( $this->exactly( 0 ) )->method( 'execute' );
+
+ $req = new FauxRequest( [
+ 'action' => 'testmodule',
+ ] );
+ $req->setHeader( 'If-Modified-Since', wfTimestamp( TS_RFC2822, $now - 3600 ) );
+ $req->setRequestURL( "http://localhost" );
+
+ $api = new ApiMain( $req );
+ $api->getModuleManager()->addModule( 'testmodule', 'action', get_class( $mock ),
+ function () use ( $mock ) {
+ return $mock;
+ }
+ );
+
+ $wrapper = TestingAccessWrapper::newFromObject( $api );
+ $wrapper->mInternalMode = false;
+
+ ob_start();
+ $api->execute();
+ ob_end_clean();
+ }
+
+ private function doTestCheckMaxLag( $lag ) {
+ $mockLB = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'getMaxLag', '__destruct' ] )
+ ->getMock();
+ $mockLB->method( 'getMaxLag' )->willReturn( [ 'somehost', $lag ] );
+ $this->setService( 'DBLoadBalancer', $mockLB );
+
+ $req = new FauxRequest();
+
+ $api = new ApiMain( $req );
+ $wrapper = TestingAccessWrapper::newFromObject( $api );
+
+ $mockModule = $this->createMock( ApiBase::class );
+ $mockModule->method( 'shouldCheckMaxLag' )->willReturn( true );
+
+ try {
+ $wrapper->checkMaxLag( $mockModule, [ 'maxlag' => 3 ] );
+ } finally {
+ if ( $lag > 3 ) {
+ $this->assertSame( '5', $req->response()->getHeader( 'Retry-After' ) );
+ $this->assertSame( (string)$lag, $req->response()->getHeader( 'X-Database-Lag' ) );
+ }
+ }
+ }
+
+ public function testCheckMaxLagOkay() {
+ $this->doTestCheckMaxLag( 3 );
+
+ // No exception, we're happy
+ $this->assertTrue( true );
+ }
+
+ public function testCheckMaxLagExceeded() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Waiting for a database server: 4 seconds lagged.' );
+
+ $this->setMwGlobals( 'wgShowHostnames', false );
+
+ $this->doTestCheckMaxLag( 4 );
+ }
+
+ public function testCheckMaxLagExceededWithHostNames() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Waiting for somehost: 4 seconds lagged.' );
+
+ $this->setMwGlobals( 'wgShowHostnames', true );
+
+ $this->doTestCheckMaxLag( 4 );
+ }
+
+ public static function provideAssert() {
+ return [
+ [ false, [], 'user', 'assertuserfailed' ],
+ [ true, [], 'user', false ],
+ [ true, [], 'bot', 'assertbotfailed' ],
+ [ true, [ 'bot' ], 'user', false ],
+ [ true, [ 'bot' ], 'bot', false ],
+ ];
+ }
+
+ /**
+ * Tests the assert={user|bot} functionality
+ *
+ * @dataProvider provideAssert
+ * @param bool $registered
+ * @param array $rights
+ * @param string $assert
+ * @param string|bool $error False if no error expected
+ */
+ public function testAssert( $registered, $rights, $assert, $error ) {
+ if ( $registered ) {
+ $user = $this->getMutableTestUser()->getUser();
+ $user->load(); // load before setting mRights
+ } else {
+ $user = new User();
+ }
+ $user->mRights = $rights;
+ try {
+ $this->doApiRequest( [
+ 'action' => 'query',
+ 'assert' => $assert,
+ ], null, null, $user );
+ $this->assertFalse( $error ); // That no error was expected
+ } catch ( ApiUsageException $e ) {
+ $this->assertTrue( self::apiExceptionHasCode( $e, $error ),
+ "Error '{$e->getMessage()}' matched expected '$error'" );
+ }
+ }
+
+ /**
+ * Tests the assertuser= functionality
+ */
+ public function testAssertUser() {
+ $user = $this->getTestUser()->getUser();
+ $this->doApiRequest( [
+ 'action' => 'query',
+ 'assertuser' => $user->getName(),
+ ], null, null, $user );
+
+ try {
+ $this->doApiRequest( [
+ 'action' => 'query',
+ 'assertuser' => $user->getName() . 'X',
+ ], null, null, $user );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $e ) {
+ $this->assertTrue( self::apiExceptionHasCode( $e, 'assertnameduserfailed' ) );
+ }
+ }
+
+ /**
+ * Test if all classes in the main module manager exists
+ */
+ public function testClassNamesInModuleManager() {
+ $api = new ApiMain(
+ new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
+ );
+ $modules = $api->getModuleManager()->getNamesWithClasses();
+
+ foreach ( $modules as $name => $class ) {
+ $this->assertTrue(
+ class_exists( $class ),
+ 'Class ' . $class . ' for api module ' . $name . ' does not exist (with exact case)'
+ );
+ }
+ }
+
+ /**
+ * Test HTTP precondition headers
+ *
+ * @dataProvider provideCheckConditionalRequestHeaders
+ * @param array $headers HTTP headers
+ * @param array $conditions Return data for ApiBase::getConditionalRequestData
+ * @param int $status Expected response status
+ * @param array $options Array of options:
+ * post => true Request is a POST
+ * cdn => true CDN is enabled ($wgUseSquid)
+ */
+ public function testCheckConditionalRequestHeaders(
+ $headers, $conditions, $status, $options = []
+ ) {
+ $request = new FauxRequest(
+ [ 'action' => 'query', 'meta' => 'siteinfo' ],
+ !empty( $options['post'] )
+ );
+ $request->setHeaders( $headers );
+ $request->response()->statusHeader( 200 ); // Why doesn't it default?
+
+ $context = $this->apiContext->newTestContext( $request, null );
+ $api = new ApiMain( $context );
+ $priv = TestingAccessWrapper::newFromObject( $api );
+ $priv->mInternalMode = false;
+
+ if ( !empty( $options['cdn'] ) ) {
+ $this->setMwGlobals( 'wgUseSquid', true );
+ }
+
+ // Can't do this in TestSetup.php because Setup.php will override it
+ $this->setMwGlobals( 'wgCacheEpoch', '20030516000000' );
+
+ $module = $this->getMockBuilder( ApiBase::class )
+ ->setConstructorArgs( [ $api, 'mock' ] )
+ ->setMethods( [ 'getConditionalRequestData' ] )
+ ->getMockForAbstractClass();
+ $module->expects( $this->any() )
+ ->method( 'getConditionalRequestData' )
+ ->will( $this->returnCallback( function ( $condition ) use ( $conditions ) {
+ return isset( $conditions[$condition] ) ? $conditions[$condition] : null;
+ } ) );
+
+ $ret = $priv->checkConditionalRequestHeaders( $module );
+
+ $this->assertSame( $status, $request->response()->getStatusCode() );
+ $this->assertSame( $status === 200, $ret );
+ }
+
+ public static function provideCheckConditionalRequestHeaders() {
+ global $wgSquidMaxage;
+ $now = time();
+
+ return [
+ // Non-existing from module is ignored
+ 'If-None-Match' => [ [ 'If-None-Match' => '"foo", "bar"' ], [], 200 ],
+ 'If-Modified-Since' =>
+ [ [ 'If-Modified-Since' => 'Tue, 18 Aug 2015 00:00:00 GMT' ], [], 200 ],
+
+ // No headers
+ 'No headers' => [ [], [ 'etag' => '""', 'last-modified' => '20150815000000', ], 200 ],
+
+ // Basic If-None-Match
+ 'If-None-Match with matching etag' =>
+ [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"bar"' ], 304 ],
+ 'If-None-Match with non-matching etag' =>
+ [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"baz"' ], 200 ],
+ 'Strong If-None-Match with weak matching etag' =>
+ [ [ 'If-None-Match' => '"foo"' ], [ 'etag' => 'W/"foo"' ], 304 ],
+ 'Weak If-None-Match with strong matching etag' =>
+ [ [ 'If-None-Match' => 'W/"foo"' ], [ 'etag' => '"foo"' ], 304 ],
+ 'Weak If-None-Match with weak matching etag' =>
+ [ [ 'If-None-Match' => 'W/"foo"' ], [ 'etag' => 'W/"foo"' ], 304 ],
+
+ // Pointless for GET, but supported
+ 'If-None-Match: *' => [ [ 'If-None-Match' => '*' ], [], 304 ],
+
+ // Basic If-Modified-Since
+ 'If-Modified-Since, modified one second earlier' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
+ 'If-Modified-Since, modified now' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now ) ], 304 ],
+ 'If-Modified-Since, modified one second later' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now + 1 ) ], 200 ],
+
+ // If-Modified-Since ignored when If-None-Match is given too
+ 'Non-matching If-None-Match and matching If-Modified-Since' =>
+ [ [ 'If-None-Match' => '""',
+ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
+ [ 'etag' => '"x"', 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
+ 'Non-matching If-None-Match and matching If-Modified-Since with no ETag' =>
+ [
+ [
+ 'If-None-Match' => '""',
+ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now )
+ ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ],
+ 304
+ ],
+
+ // Ignored for POST
+ 'Matching If-None-Match with POST' =>
+ [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"bar"' ], 200,
+ [ 'post' => true ] ],
+ 'Matching If-Modified-Since with POST' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200,
+ [ 'post' => true ] ],
+
+ // Other date formats allowed by the RFC
+ 'If-Modified-Since with alternate date format 1' =>
+ [ [ 'If-Modified-Since' => gmdate( 'l, d-M-y H:i:s', $now ) . ' GMT' ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
+ 'If-Modified-Since with alternate date format 2' =>
+ [ [ 'If-Modified-Since' => gmdate( 'D M j H:i:s Y', $now ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
+
+ // Old browser extension to HTTP/1.0
+ 'If-Modified-Since with length' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) . '; length=123' ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
+
+ // Invalid date formats should be ignored
+ 'If-Modified-Since with invalid date format' =>
+ [ [ 'If-Modified-Since' => gmdate( 'Y-m-d H:i:s', $now ) . ' GMT' ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
+ 'If-Modified-Since with entirely unparseable date' =>
+ [ [ 'If-Modified-Since' => 'a potato' ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
+
+ // Anything before $wgSquidMaxage seconds ago should be considered
+ // expired.
+ 'If-Modified-Since with CDN post-expiry' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgSquidMaxage * 2 ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - $wgSquidMaxage * 3 ) ],
+ 200, [ 'cdn' => true ] ],
+ 'If-Modified-Since with CDN pre-expiry' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgSquidMaxage / 2 ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - $wgSquidMaxage * 3 ) ],
+ 304, [ 'cdn' => true ] ],
+ ];
+ }
+
+ /**
+ * Test conditional headers output
+ * @dataProvider provideConditionalRequestHeadersOutput
+ * @param array $conditions Return data for ApiBase::getConditionalRequestData
+ * @param array $headers Expected output headers
+ * @param bool $isError $isError flag
+ * @param bool $post Request is a POST
+ */
+ public function testConditionalRequestHeadersOutput(
+ $conditions, $headers, $isError = false, $post = false
+ ) {
+ $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ], $post );
+ $response = $request->response();
+
+ $api = new ApiMain( $request );
+ $priv = TestingAccessWrapper::newFromObject( $api );
+ $priv->mInternalMode = false;
+
+ $module = $this->getMockBuilder( ApiBase::class )
+ ->setConstructorArgs( [ $api, 'mock' ] )
+ ->setMethods( [ 'getConditionalRequestData' ] )
+ ->getMockForAbstractClass();
+ $module->expects( $this->any() )
+ ->method( 'getConditionalRequestData' )
+ ->will( $this->returnCallback( function ( $condition ) use ( $conditions ) {
+ return isset( $conditions[$condition] ) ? $conditions[$condition] : null;
+ } ) );
+ $priv->mModule = $module;
+
+ $priv->sendCacheHeaders( $isError );
+
+ foreach ( [ 'Last-Modified', 'ETag' ] as $header ) {
+ $this->assertEquals(
+ isset( $headers[$header] ) ? $headers[$header] : null,
+ $response->getHeader( $header ),
+ $header
+ );
+ }
+ }
+
+ public static function provideConditionalRequestHeadersOutput() {
+ return [
+ [
+ [],
+ []
+ ],
+ [
+ [ 'etag' => '"foo"' ],
+ [ 'ETag' => '"foo"' ]
+ ],
+ [
+ [ 'last-modified' => '20150818000102' ],
+ [ 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ]
+ ],
+ [
+ [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
+ [ 'ETag' => '"foo"', 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ]
+ ],
+ [
+ [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
+ [],
+ true,
+ ],
+ [
+ [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
+ [],
+ false,
+ true,
+ ],
+ ];
+ }
+
+ public function testCheckExecutePermissionsReadProhibited() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'You need read permission to use this module.' );
+
+ $this->setGroupPermissions( '*', 'read', false );
+
+ $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
+ $main->execute();
+ }
+
+ public function testCheckExecutePermissionWriteDisabled() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Editing of this wiki through the API is disabled. Make sure the ' .
+ '"$wgEnableWriteAPI=true;" statement is included in the wiki\'s ' .
+ '"LocalSettings.php" file.' );
+ $main = new ApiMain( new FauxRequest( [
+ 'action' => 'edit',
+ 'title' => 'Some page',
+ 'text' => 'Some text',
+ 'token' => '+\\',
+ ] ) );
+ $main->execute();
+ }
+
+ public function testCheckExecutePermissionWriteApiProhibited() {
+ $this->setExpectedException( ApiUsageException::class,
+ "You're not allowed to edit this wiki through the API." );
+ $this->setGroupPermissions( '*', 'writeapi', false );
+
+ $main = new ApiMain( new FauxRequest( [
+ 'action' => 'edit',
+ 'title' => 'Some page',
+ 'text' => 'Some text',
+ 'token' => '+\\',
+ ] ), /* enableWrite = */ true );
+ $main->execute();
+ }
+
+ public function testCheckExecutePermissionPromiseNonWrite() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The "Promise-Non-Write-API-Action" HTTP header cannot be sent ' .
+ 'to write-mode API modules.' );
+
+ $req = new FauxRequest( [
+ 'action' => 'edit',
+ 'title' => 'Some page',
+ 'text' => 'Some text',
+ 'token' => '+\\',
+ ] );
+ $req->setHeaders( [ 'Promise-Non-Write-API-Action' => '1' ] );
+ $main = new ApiMain( $req, /* enableWrite = */ true );
+ $main->execute();
+ }
+
+ public function testCheckExecutePermissionHookAbort() {
+ $this->setExpectedException( ApiUsageException::class, 'Main Page' );
+
+ $this->setTemporaryHook( 'ApiCheckCanExecute', function ( $unused1, $unused2, &$message ) {
+ $message = 'mainpage';
+ return false;
+ } );
+
+ $main = new ApiMain( new FauxRequest( [
+ 'action' => 'edit',
+ 'title' => 'Some page',
+ 'text' => 'Some text',
+ 'token' => '+\\',
+ ] ), /* enableWrite = */ true );
+ $main->execute();
+ }
+
+ public function testGetValUnsupportedArray() {
+ $main = new ApiMain( new FauxRequest( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'siprop' => [ 'general', 'namespaces' ],
+ ] ) );
+ $this->assertSame( 'myDefault', $main->getVal( 'siprop', 'myDefault' ) );
+ $main->execute();
+ $this->assertSame( 'Parameter "siprop" uses unsupported PHP array syntax.',
+ $main->getResult()->getResultData()['warnings']['main']['warnings'] );
+ }
+
+ public function testReportUnusedParams() {
+ $main = new ApiMain( new FauxRequest( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'unusedparam' => 'unusedval',
+ 'anotherunusedparam' => 'anotherval',
+ ] ) );
+ $main->execute();
+ $this->assertSame( 'Unrecognized parameters: unusedparam, anotherunusedparam.',
+ $main->getResult()->getResultData()['warnings']['main']['warnings'] );
+ }
+
+ public function testLacksSameOriginSecurity() {
+ // Basic test
+ $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
+ $this->assertFalse( $main->lacksSameOriginSecurity(), 'Basic test, should have security' );
+
+ // JSONp
+ $main = new ApiMain(
+ new FauxRequest( [ 'action' => 'query', 'format' => 'xml', 'callback' => 'foo' ] )
+ );
+ $this->assertTrue( $main->lacksSameOriginSecurity(), 'JSONp, should lack security' );
+
+ // Header
+ $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] );
+ $request->setHeader( 'TrEaT-As-UnTrUsTeD', '' ); // With falsey value!
+ $main = new ApiMain( $request );
+ $this->assertTrue( $main->lacksSameOriginSecurity(), 'Header supplied, should lack security' );
+
+ // Hook
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'RequestHasSameOriginSecurity' => [ function () {
+ return false;
+ } ]
+ ] );
+ $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
+ $this->assertTrue( $main->lacksSameOriginSecurity(), 'Hook, should lack security' );
+ }
+
+ /**
+ * Test proper creation of the ApiErrorFormatter
+ *
+ * @dataProvider provideApiErrorFormatterCreation
+ * @param array $request Request parameters
+ * @param array $expect Expected data
+ * - uselang: ApiMain language
+ * - class: ApiErrorFormatter class
+ * - lang: ApiErrorFormatter language
+ * - format: ApiErrorFormatter format
+ * - usedb: ApiErrorFormatter use-database flag
+ */
+ public function testApiErrorFormatterCreation( array $request, array $expect ) {
+ $context = new RequestContext();
+ $context->setRequest( new FauxRequest( $request ) );
+ $context->setLanguage( 'ru' );
+
+ $main = new ApiMain( $context );
+ $formatter = $main->getErrorFormatter();
+ $wrappedFormatter = TestingAccessWrapper::newFromObject( $formatter );
+
+ $this->assertSame( $expect['uselang'], $main->getLanguage()->getCode() );
+ $this->assertInstanceOf( $expect['class'], $formatter );
+ $this->assertSame( $expect['lang'], $formatter->getLanguage()->getCode() );
+ $this->assertSame( $expect['format'], $wrappedFormatter->format );
+ $this->assertSame( $expect['usedb'], $wrappedFormatter->useDB );
+ }
+
+ public static function provideApiErrorFormatterCreation() {
+ return [
+ 'Default (BC)' => [ [], [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter_BackCompat::class,
+ 'lang' => 'en',
+ 'format' => 'none',
+ 'usedb' => false,
+ ] ],
+ 'BC ignores fields' => [ [ 'errorlang' => 'de', 'errorsuselocal' => 1 ], [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter_BackCompat::class,
+ 'lang' => 'en',
+ 'format' => 'none',
+ 'usedb' => false,
+ ] ],
+ 'Explicit BC' => [ [ 'errorformat' => 'bc' ], [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter_BackCompat::class,
+ 'lang' => 'en',
+ 'format' => 'none',
+ 'usedb' => false,
+ ] ],
+ 'Basic' => [ [ 'errorformat' => 'wikitext' ], [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'ru',
+ 'format' => 'wikitext',
+ 'usedb' => false,
+ ] ],
+ 'Follows uselang' => [ [ 'uselang' => 'fr', 'errorformat' => 'plaintext' ], [
+ 'uselang' => 'fr',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'fr',
+ 'format' => 'plaintext',
+ 'usedb' => false,
+ ] ],
+ 'Explicitly follows uselang' => [
+ [ 'uselang' => 'fr', 'errorlang' => 'uselang', 'errorformat' => 'plaintext' ],
+ [
+ 'uselang' => 'fr',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'fr',
+ 'format' => 'plaintext',
+ 'usedb' => false,
+ ]
+ ],
+ 'uselang=content' => [
+ [ 'uselang' => 'content', 'errorformat' => 'plaintext' ],
+ [
+ 'uselang' => 'en',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'en',
+ 'format' => 'plaintext',
+ 'usedb' => false,
+ ]
+ ],
+ 'errorlang=content' => [
+ [ 'errorlang' => 'content', 'errorformat' => 'plaintext' ],
+ [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'en',
+ 'format' => 'plaintext',
+ 'usedb' => false,
+ ]
+ ],
+ 'Explicit parameters' => [
+ [ 'errorlang' => 'de', 'errorformat' => 'html', 'errorsuselocal' => 1 ],
+ [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'de',
+ 'format' => 'html',
+ 'usedb' => true,
+ ]
+ ],
+ 'Explicit parameters override uselang' => [
+ [ 'errorlang' => 'de', 'uselang' => 'fr', 'errorformat' => 'raw' ],
+ [
+ 'uselang' => 'fr',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'de',
+ 'format' => 'raw',
+ 'usedb' => false,
+ ]
+ ],
+ 'Bogus language doesn\'t explode' => [
+ [ 'errorlang' => '<bogus1>', 'uselang' => '<bogus2>', 'errorformat' => 'none' ],
+ [
+ 'uselang' => 'en',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'en',
+ 'format' => 'none',
+ 'usedb' => false,
+ ]
+ ],
+ 'Bogus format doesn\'t explode' => [ [ 'errorformat' => 'bogus' ], [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter_BackCompat::class,
+ 'lang' => 'en',
+ 'format' => 'none',
+ 'usedb' => false,
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideExceptionErrors
+ * @param Exception $exception
+ * @param array $expectReturn
+ * @param array $expectResult
+ */
+ public function testExceptionErrors( $error, $expectReturn, $expectResult ) {
+ $context = new RequestContext();
+ $context->setRequest( new FauxRequest( [ 'errorformat' => 'plaintext' ] ) );
+ $context->setLanguage( 'en' );
+ $context->setConfig( new MultiConfig( [
+ new HashConfig( [
+ 'ShowHostnames' => true, 'ShowSQLErrors' => false,
+ 'ShowExceptionDetails' => true, 'ShowDBErrorBacktrace' => true,
+ ] ),
+ $context->getConfig()
+ ] ) );
+
+ $main = new ApiMain( $context );
+ $main->addWarning( new RawMessage( 'existing warning' ), 'existing-warning' );
+ $main->addError( new RawMessage( 'existing error' ), 'existing-error' );
+
+ $ret = TestingAccessWrapper::newFromObject( $main )->substituteResultWithError( $error );
+ $this->assertSame( $expectReturn, $ret );
+
+ // PHPUnit sometimes adds some SplObjectStorage garbage to the arrays,
+ // so let's try ->assertEquals().
+ $this->assertEquals(
+ $expectResult,
+ $main->getResult()->getResultData( [], [ 'Strip' => 'all' ] )
+ );
+ }
+
+ // Not static so $this can be used
+ public function provideExceptionErrors() {
+ $reqId = WebRequest::getRequestId();
+ $doclink = wfExpandUrl( wfScript( 'api' ) );
+
+ $ex = new InvalidArgumentException( 'Random exception' );
+ $trace = wfMessage( 'api-exception-trace',
+ get_class( $ex ),
+ $ex->getFile(),
+ $ex->getLine(),
+ MWExceptionHandler::getRedactedTraceAsString( $ex )
+ )->inLanguage( 'en' )->useDatabase( false )->text();
+
+ $dbex = new DBQueryError(
+ $this->createMock( \Wikimedia\Rdbms\IDatabase::class ),
+ 'error', 1234, 'SELECT 1', __METHOD__ );
+ $dbtrace = wfMessage( 'api-exception-trace',
+ get_class( $dbex ),
+ $dbex->getFile(),
+ $dbex->getLine(),
+ MWExceptionHandler::getRedactedTraceAsString( $dbex )
+ )->inLanguage( 'en' )->useDatabase( false )->text();
+
+ Wikimedia\suppressWarnings();
+ $usageEx = new UsageException( 'Usage exception!', 'ue', 0, [ 'foo' => 'bar' ] );
+ Wikimedia\restoreWarnings();
+
+ $apiEx1 = new ApiUsageException( null,
+ StatusValue::newFatal( new ApiRawMessage( 'An error', 'sv-error1' ) ) );
+ TestingAccessWrapper::newFromObject( $apiEx1 )->modulePath = 'foo+bar';
+ $apiEx1->getStatusValue()->warning( new ApiRawMessage( 'A warning', 'sv-warn1' ) );
+ $apiEx1->getStatusValue()->warning( new ApiRawMessage( 'Another warning', 'sv-warn2' ) );
+ $apiEx1->getStatusValue()->fatal( new ApiRawMessage( 'Another error', 'sv-error2' ) );
+
+ return [
+ [
+ $ex,
+ [ 'existing-error', 'internal_api_error_InvalidArgumentException' ],
+ [
+ 'warnings' => [
+ [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
+ ],
+ 'errors' => [
+ [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
+ [
+ 'code' => 'internal_api_error_InvalidArgumentException',
+ 'text' => "[$reqId] Exception caught: Random exception",
+ ]
+ ],
+ 'trace' => $trace,
+ 'servedby' => wfHostname(),
+ ]
+ ],
+ [
+ $dbex,
+ [ 'existing-error', 'internal_api_error_DBQueryError' ],
+ [
+ 'warnings' => [
+ [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
+ ],
+ 'errors' => [
+ [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
+ [
+ 'code' => 'internal_api_error_DBQueryError',
+ 'text' => "[$reqId] Database query error.",
+ ]
+ ],
+ 'trace' => $dbtrace,
+ 'servedby' => wfHostname(),
+ ]
+ ],
+ [
+ $usageEx,
+ [ 'existing-error', 'ue' ],
+ [
+ 'warnings' => [
+ [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
+ ],
+ 'errors' => [
+ [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
+ [ 'code' => 'ue', 'text' => "Usage exception!", 'data' => [ 'foo' => 'bar' ] ]
+ ],
+ 'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " .
+ "list at &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; " .
+ "for notice of API deprecations and breaking changes.",
+ 'servedby' => wfHostname(),
+ ]
+ ],
+ [
+ $apiEx1,
+ [ 'existing-error', 'sv-error1', 'sv-error2' ],
+ [
+ 'warnings' => [
+ [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
+ [ 'code' => 'sv-warn1', 'text' => 'A warning', 'module' => 'foo+bar' ],
+ [ 'code' => 'sv-warn2', 'text' => 'Another warning', 'module' => 'foo+bar' ],
+ ],
+ 'errors' => [
+ [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
+ [ 'code' => 'sv-error1', 'text' => 'An error', 'module' => 'foo+bar' ],
+ [ 'code' => 'sv-error2', 'text' => 'Another error', 'module' => 'foo+bar' ],
+ ],
+ 'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " .
+ "list at &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; " .
+ "for notice of API deprecations and breaking changes.",
+ 'servedby' => wfHostname(),
+ ]
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiMessageTest.php b/www/wiki/tests/phpunit/includes/api/ApiMessageTest.php
new file mode 100644
index 00000000..c6f5a8e7
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiMessageTest.php
@@ -0,0 +1,189 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group API
+ */
+class ApiMessageTest extends MediaWikiTestCase {
+
+ private function compareMessages( Message $msg, Message $msg2 ) {
+ $this->assertSame( $msg->getKey(), $msg2->getKey(), 'getKey' );
+ $this->assertSame( $msg->getKeysToTry(), $msg2->getKeysToTry(), 'getKeysToTry' );
+ $this->assertSame( $msg->getParams(), $msg2->getParams(), 'getParams' );
+ $this->assertSame( $msg->getLanguage(), $msg2->getLanguage(), 'getLanguage' );
+
+ $msg = TestingAccessWrapper::newFromObject( $msg );
+ $msg2 = TestingAccessWrapper::newFromObject( $msg2 );
+ $this->assertSame( $msg->interface, $msg2->interface, 'interface' );
+ $this->assertSame( $msg->useDatabase, $msg2->useDatabase, 'useDatabase' );
+ $this->assertSame( $msg->format, $msg2->format, 'format' );
+ $this->assertSame(
+ $msg->title ? $msg->title->getFullText() : null,
+ $msg2->title ? $msg2->title->getFullText() : null,
+ 'title'
+ );
+ }
+
+ /**
+ * @covers ApiMessageTrait
+ */
+ public function testCodeDefaults() {
+ $msg = new ApiMessage( 'foo' );
+ $this->assertSame( 'foo', $msg->getApiCode() );
+
+ $msg = new ApiMessage( 'apierror-bar' );
+ $this->assertSame( 'bar', $msg->getApiCode() );
+
+ $msg = new ApiMessage( 'apiwarn-baz' );
+ $this->assertSame( 'baz', $msg->getApiCode() );
+
+ // BC case
+ $msg = new ApiMessage( 'actionthrottledtext' );
+ $this->assertSame( 'ratelimited', $msg->getApiCode() );
+
+ $msg = new ApiMessage( [ 'apierror-missingparam', 'param' ] );
+ $this->assertSame( 'noparam', $msg->getApiCode() );
+ }
+
+ /**
+ * @covers ApiMessageTrait
+ * @dataProvider provideInvalidCode
+ * @param mixed $code
+ */
+ public function testInvalidCode( $code ) {
+ $msg = new ApiMessage( 'foo' );
+ try {
+ $msg->setApiCode( $code );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertTrue( true );
+ }
+
+ try {
+ new ApiMessage( 'foo', $code );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertTrue( true );
+ }
+ }
+
+ public static function provideInvalidCode() {
+ return [
+ [ '' ],
+ [ 42 ],
+ ];
+ }
+
+ /**
+ * @covers ApiMessage
+ * @covers ApiMessageTrait
+ */
+ public function testApiMessage() {
+ $msg = new Message( [ 'foo', 'bar' ], [ 'baz' ] );
+ $msg->inLanguage( 'de' )->title( Title::newMainPage() );
+ $msg2 = new ApiMessage( $msg, 'code', [ 'data' ] );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg2 = unserialize( serialize( $msg2 ) );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg = new Message( [ 'foo', 'bar' ], [ 'baz' ] );
+ $msg2 = new ApiMessage( [ [ 'foo', 'bar' ], 'baz' ], 'code', [ 'data' ] );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg = new Message( 'foo' );
+ $msg2 = new ApiMessage( 'foo' );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'foo', $msg2->getApiCode() );
+ $this->assertEquals( [], $msg2->getApiData() );
+
+ $msg2->setApiCode( 'code', [ 'data' ] );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+ $msg2->setApiCode( null );
+ $this->assertEquals( 'foo', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+ $msg2->setApiData( [ 'data2' ] );
+ $this->assertEquals( [ 'data2' ], $msg2->getApiData() );
+ }
+
+ /**
+ * @covers ApiRawMessage
+ * @covers ApiMessageTrait
+ */
+ public function testApiRawMessage() {
+ $msg = new RawMessage( 'foo', [ 'baz' ] );
+ $msg->inLanguage( 'de' )->title( Title::newMainPage() );
+ $msg2 = new ApiRawMessage( $msg, 'code', [ 'data' ] );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg2 = unserialize( serialize( $msg2 ) );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg = new RawMessage( 'foo', [ 'baz' ] );
+ $msg2 = new ApiRawMessage( [ 'foo', 'baz' ], 'code', [ 'data' ] );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg = new RawMessage( 'foo' );
+ $msg2 = new ApiRawMessage( 'foo', 'code', [ 'data' ] );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg2->setApiCode( 'code', [ 'data' ] );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+ $msg2->setApiCode( null );
+ $this->assertEquals( 'foo', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+ $msg2->setApiData( [ 'data2' ] );
+ $this->assertEquals( [ 'data2' ], $msg2->getApiData() );
+ }
+
+ /**
+ * @covers ApiMessage::create
+ */
+ public function testApiMessageCreate() {
+ $this->assertInstanceOf( ApiMessage::class, ApiMessage::create( new Message( 'mainpage' ) ) );
+ $this->assertInstanceOf(
+ ApiRawMessage::class, ApiMessage::create( new RawMessage( 'mainpage' ) )
+ );
+ $this->assertInstanceOf( ApiMessage::class, ApiMessage::create( 'mainpage' ) );
+
+ $msg = new ApiMessage( [ 'parentheses', 'foobar' ] );
+ $msg2 = new Message( 'parentheses', [ 'foobar' ] );
+
+ $this->assertSame( $msg, ApiMessage::create( $msg ) );
+ $this->assertEquals( $msg, ApiMessage::create( $msg2 ) );
+ $this->assertEquals( $msg, ApiMessage::create( [ 'parentheses', 'foobar' ] ) );
+ $this->assertEquals( $msg,
+ ApiMessage::create( [ 'message' => 'parentheses', 'params' => [ 'foobar' ] ] )
+ );
+ $this->assertSame( $msg,
+ ApiMessage::create( [ 'message' => $msg, 'params' => [ 'xxx' ] ] )
+ );
+ $this->assertEquals( $msg,
+ ApiMessage::create( [ 'message' => $msg2, 'params' => [ 'xxx' ] ] )
+ );
+ $this->assertSame( $msg,
+ ApiMessage::create( [ 'message' => $msg ] )
+ );
+
+ $msg = new ApiRawMessage( [ 'parentheses', 'foobar' ] );
+ $this->assertSame( $msg, ApiMessage::create( $msg ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiModuleManagerTest.php b/www/wiki/tests/phpunit/includes/api/ApiModuleManagerTest.php
new file mode 100644
index 00000000..b01b90e8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiModuleManagerTest.php
@@ -0,0 +1,330 @@
+<?php
+
+/**
+ * @covers ApiModuleManager
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ */
+class ApiModuleManagerTest extends MediaWikiTestCase {
+
+ private function getModuleManager() {
+ $request = new FauxRequest();
+ $main = new ApiMain( $request );
+ return new ApiModuleManager( $main );
+ }
+
+ public function newApiLogin( $main, $action ) {
+ return new ApiLogin( $main, $action );
+ }
+
+ public function addModuleProvider() {
+ return [
+ 'plain class' => [
+ 'login',
+ 'action',
+ ApiLogin::class,
+ null,
+ ],
+
+ 'with factory' => [
+ 'login',
+ 'action',
+ ApiLogin::class,
+ [ $this, 'newApiLogin' ],
+ ],
+
+ 'with closure' => [
+ 'logout',
+ 'action',
+ ApiLogout::class,
+ function ( ApiMain $main, $action ) {
+ return new ApiLogout( $main, $action );
+ },
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider addModuleProvider
+ */
+ public function testAddModule( $name, $group, $class, $factory = null ) {
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModule( $name, $group, $class, $factory );
+
+ $this->assertTrue( $moduleManager->isDefined( $name, $group ), 'isDefined' );
+ $this->assertNotNull( $moduleManager->getModule( $name, $group, true ), 'getModule' );
+ }
+
+ public function addModulesProvider() {
+ return [
+ 'empty' => [
+ [],
+ 'action',
+ ],
+
+ 'simple' => [
+ [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ],
+ 'action',
+ ],
+
+ 'with factories' => [
+ [
+ 'login' => [
+ 'class' => ApiLogin::class,
+ 'factory' => [ $this, 'newApiLogin' ],
+ ],
+ 'logout' => [
+ 'class' => ApiLogout::class,
+ 'factory' => function ( ApiMain $main, $action ) {
+ return new ApiLogout( $main, $action );
+ },
+ ],
+ ],
+ 'action',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider addModulesProvider
+ */
+ public function testAddModules( array $modules, $group ) {
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $modules, $group );
+
+ foreach ( array_keys( $modules ) as $name ) {
+ $this->assertTrue( $moduleManager->isDefined( $name, $group ), 'isDefined' );
+ $this->assertNotNull( $moduleManager->getModule( $name, $group, true ), 'getModule' );
+ }
+
+ $this->assertTrue( true ); // Don't mark the test as risky if $modules is empty
+ }
+
+ public function getModuleProvider() {
+ $modules = [
+ 'feedrecentchanges' => ApiFeedRecentChanges::class,
+ 'feedcontributions' => [ 'class' => ApiFeedContributions::class ],
+ 'login' => [
+ 'class' => ApiLogin::class,
+ 'factory' => [ $this, 'newApiLogin' ],
+ ],
+ 'logout' => [
+ 'class' => ApiLogout::class,
+ 'factory' => function ( ApiMain $main, $action ) {
+ return new ApiLogout( $main, $action );
+ },
+ ],
+ ];
+
+ return [
+ 'legacy entry' => [
+ $modules,
+ 'feedrecentchanges',
+ ApiFeedRecentChanges::class,
+ ],
+
+ 'just a class' => [
+ $modules,
+ 'feedcontributions',
+ ApiFeedContributions::class,
+ ],
+
+ 'with factory' => [
+ $modules,
+ 'login',
+ ApiLogin::class,
+ ],
+
+ 'with closure' => [
+ $modules,
+ 'logout',
+ ApiLogout::class,
+ ],
+ ];
+ }
+
+ /**
+ * @covers ApiModuleManager::getModule
+ * @dataProvider getModuleProvider
+ */
+ public function testGetModule( $modules, $name, $expectedClass ) {
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $modules, 'test' );
+
+ // should return the right module
+ $module1 = $moduleManager->getModule( $name, null, false );
+ $this->assertInstanceOf( $expectedClass, $module1 );
+
+ // should pass group check (with caching disabled)
+ $module2 = $moduleManager->getModule( $name, 'test', true );
+ $this->assertNotNull( $module2 );
+
+ // should use cached instance
+ $module3 = $moduleManager->getModule( $name, null, false );
+ $this->assertSame( $module1, $module3 );
+
+ // should not use cached instance if caching is disabled
+ $module4 = $moduleManager->getModule( $name, null, true );
+ $this->assertNotSame( $module1, $module4 );
+ }
+
+ /**
+ * @covers ApiModuleManager::getModule
+ */
+ public function testGetModule_null() {
+ $modules = [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ];
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $modules, 'test' );
+
+ $this->assertNull( $moduleManager->getModule( 'quux' ), 'unknown name' );
+ $this->assertNull( $moduleManager->getModule( 'login', 'bla' ), 'wrong group' );
+ }
+
+ /**
+ * @covers ApiModuleManager::getNames
+ */
+ public function testGetNames() {
+ $fooModules = [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ];
+
+ $barModules = [
+ 'feedcontributions' => [ 'class' => ApiFeedContributions::class ],
+ 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ],
+ ];
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $fooModules, 'foo' );
+ $moduleManager->addModules( $barModules, 'bar' );
+
+ $fooNames = $moduleManager->getNames( 'foo' );
+ $this->assertArrayEquals( array_keys( $fooModules ), $fooNames );
+
+ $allNames = $moduleManager->getNames();
+ $allModules = array_merge( $fooModules, $barModules );
+ $this->assertArrayEquals( array_keys( $allModules ), $allNames );
+ }
+
+ /**
+ * @covers ApiModuleManager::getNamesWithClasses
+ */
+ public function testGetNamesWithClasses() {
+ $fooModules = [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ];
+
+ $barModules = [
+ 'feedcontributions' => [ 'class' => ApiFeedContributions::class ],
+ 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ],
+ ];
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $fooModules, 'foo' );
+ $moduleManager->addModules( $barModules, 'bar' );
+
+ $fooNamesWithClasses = $moduleManager->getNamesWithClasses( 'foo' );
+ $this->assertArrayEquals( $fooModules, $fooNamesWithClasses );
+
+ $allNamesWithClasses = $moduleManager->getNamesWithClasses();
+ $allModules = array_merge( $fooModules, [
+ 'feedcontributions' => ApiFeedContributions::class,
+ 'feedrecentchanges' => ApiFeedRecentChanges::class,
+ ] );
+ $this->assertArrayEquals( $allModules, $allNamesWithClasses );
+ }
+
+ /**
+ * @covers ApiModuleManager::getModuleGroup
+ */
+ public function testGetModuleGroup() {
+ $fooModules = [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ];
+
+ $barModules = [
+ 'feedcontributions' => [ 'class' => ApiFeedContributions::class ],
+ 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ],
+ ];
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $fooModules, 'foo' );
+ $moduleManager->addModules( $barModules, 'bar' );
+
+ $this->assertEquals( 'foo', $moduleManager->getModuleGroup( 'login' ) );
+ $this->assertEquals( 'bar', $moduleManager->getModuleGroup( 'feedrecentchanges' ) );
+ $this->assertNull( $moduleManager->getModuleGroup( 'quux' ) );
+ }
+
+ /**
+ * @covers ApiModuleManager::getGroups
+ */
+ public function testGetGroups() {
+ $fooModules = [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ];
+
+ $barModules = [
+ 'feedcontributions' => [ 'class' => ApiFeedContributions::class ],
+ 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ],
+ ];
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $fooModules, 'foo' );
+ $moduleManager->addModules( $barModules, 'bar' );
+
+ $groups = $moduleManager->getGroups();
+ $this->assertArrayEquals( [ 'foo', 'bar' ], $groups );
+ }
+
+ /**
+ * @covers ApiModuleManager::getClassName
+ */
+ public function testGetClassName() {
+ $fooModules = [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ];
+
+ $barModules = [
+ 'feedcontributions' => [ 'class' => ApiFeedContributions::class ],
+ 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ],
+ ];
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $fooModules, 'foo' );
+ $moduleManager->addModules( $barModules, 'bar' );
+
+ $this->assertEquals(
+ ApiLogin::class,
+ $moduleManager->getClassName( 'login' )
+ );
+ $this->assertEquals(
+ ApiLogout::class,
+ $moduleManager->getClassName( 'logout' )
+ );
+ $this->assertEquals(
+ ApiFeedContributions::class,
+ $moduleManager->getClassName( 'feedcontributions' )
+ );
+ $this->assertEquals(
+ ApiFeedRecentChanges::class,
+ $moduleManager->getClassName( 'feedrecentchanges' )
+ );
+ $this->assertFalse(
+ $moduleManager->getClassName( 'nonexistentmodule' )
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiMoveTest.php b/www/wiki/tests/phpunit/includes/api/ApiMoveTest.php
new file mode 100644
index 00000000..fb697ffd
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiMoveTest.php
@@ -0,0 +1,393 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiMove
+ */
+class ApiMoveTest extends ApiTestCase {
+ /**
+ * @param string $from Prefixed name of source
+ * @param string $to Prefixed name of destination
+ * @param string $id Page id of the page to move
+ * @param array|string|null $opts Options: 'noredirect' to expect no redirect
+ */
+ protected function assertMoved( $from, $to, $id, $opts = null ) {
+ $opts = (array)$opts;
+
+ $fromTitle = Title::newFromText( $from );
+ $toTitle = Title::newFromText( $to );
+
+ $this->assertTrue( $toTitle->exists(),
+ "Destination {$toTitle->getPrefixedText()} does not exist" );
+
+ if ( in_array( 'noredirect', $opts ) ) {
+ $this->assertFalse( $fromTitle->exists(),
+ "Source {$fromTitle->getPrefixedText()} exists" );
+ } else {
+ $this->assertTrue( $fromTitle->exists(),
+ "Source {$fromTitle->getPrefixedText()} does not exist" );
+ $this->assertTrue( $fromTitle->isRedirect(),
+ "Source {$fromTitle->getPrefixedText()} is not a redirect" );
+
+ $target = Revision::newFromTitle( $fromTitle )->getContent()->getRedirectTarget();
+ $this->assertSame( $toTitle->getPrefixedText(), $target->getPrefixedText() );
+ }
+
+ $this->assertSame( $id, $toTitle->getArticleId() );
+ }
+
+ /**
+ * Shortcut function to create a page and return its id.
+ *
+ * @param string $name Page to create
+ * @return int ID of created page
+ */
+ protected function createPage( $name ) {
+ return $this->editPage( $name, 'Content' )->value['revision']->getPage();
+ }
+
+ public function testFromWithFromid() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The parameters "from" and "fromid" can not be used together.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => 'Some page',
+ 'fromid' => 123,
+ 'to' => 'Some other page',
+ ] );
+ }
+
+ public function testMove() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->createPage( $name );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testMoveById() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->createPage( $name );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'fromid' => $id,
+ 'to' => "$name 2",
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testMoveNonexistent() {
+ $this->setExpectedException( ApiUsageException::class,
+ "The page you specified doesn't exist." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => 'Nonexistent page',
+ 'to' => 'Different page'
+ ] );
+ }
+
+ public function testMoveNonexistentId() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no page with ID 2147483647.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'fromid' => pow( 2, 31 ) - 1,
+ 'to' => 'Different page',
+ ] );
+ }
+
+ public function testMoveToInvalidPageName() {
+ $this->setExpectedException( ApiUsageException::class, 'Bad title "[".' );
+
+ $name = ucfirst( __FUNCTION__ );
+ $id = $this->createPage( $name );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => '[',
+ ] );
+ } finally {
+ $this->assertSame( $id, Title::newFromText( $name )->getArticleId() );
+ }
+ }
+
+ // @todo File moving
+
+ public function testPingLimiter() {
+ global $wgRateLimits;
+
+ $this->setExpectedException( ApiUsageException::class,
+ "You've exceeded your rate limit. Please wait some time and try again." );
+
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->setMwGlobals( 'wgMainCacheType', 'hash' );
+
+ $this->stashMwGlobals( 'wgRateLimits' );
+ $wgRateLimits['move'] = [ '&can-bypass' => false, 'user' => [ 1, 60 ] ];
+
+ $id = $this->createPage( $name );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => "$name 2",
+ 'to' => "$name 3",
+ ] );
+ } finally {
+ $this->assertSame( $id, Title::newFromText( "$name 2" )->getArticleId() );
+ $this->assertFalse( Title::newFromText( "$name 3" )->exists(),
+ "\"$name 3\" should not exist" );
+ }
+ }
+
+ public function testTagsNoPermission() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'You do not have permission to apply change tags along with your changes.' );
+
+ $name = ucfirst( __FUNCTION__ );
+
+ ChangeTags::defineTag( 'custom tag' );
+
+ $this->setGroupPermissions( 'user', 'applychangetags', false );
+
+ $id = $this->createPage( $name );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ 'tags' => 'custom tag',
+ ] );
+ } finally {
+ $this->assertSame( $id, Title::newFromText( $name )->getArticleId() );
+ $this->assertFalse( Title::newFromText( "$name 2" )->exists(),
+ "\"$name 2\" should not exist" );
+ }
+ }
+
+ public function testSelfMove() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The title is the same; cannot move a page over itself.' );
+
+ $name = ucfirst( __FUNCTION__ );
+ $this->createPage( $name );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => $name,
+ ] );
+ }
+
+ public function testMoveTalk() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->createPage( $name );
+ $talkId = $this->createPage( "Talk:$name" );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ 'movetalk' => '',
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id );
+ $this->assertMoved( "Talk:$name", "Talk:$name 2", $talkId );
+
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testMoveTalkFailed() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->createPage( $name );
+ $talkId = $this->createPage( "Talk:$name" );
+ $talkDestinationId = $this->createPage( "Talk:$name 2" );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ 'movetalk' => '',
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id );
+ $this->assertSame( $talkId, Title::newFromText( "Talk:$name" )->getArticleId() );
+ $this->assertSame( $talkDestinationId,
+ Title::newFromText( "Talk:$name 2" )->getArticleId() );
+ $this->assertSame( [ [
+ 'message' => 'articleexists',
+ 'params' => [],
+ 'code' => 'articleexists',
+ 'type' => 'error',
+ ] ], $res[0]['move']['talkmove-errors'] );
+
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testMoveSubpages() {
+ global $wgNamespacesWithSubpages;
+
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->stashMwGlobals( 'wgNamespacesWithSubpages' );
+ $wgNamespacesWithSubpages[NS_MAIN] = true;
+
+ $pages = [ $name, "$name/1", "$name/2", "Talk:$name", "Talk:$name/1", "Talk:$name/3" ];
+ $ids = [];
+ foreach ( array_merge( $pages, [ "$name/error", "$name 2/error" ] ) as $page ) {
+ $ids[$page] = $this->createPage( $page );
+ }
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ 'movetalk' => '',
+ 'movesubpages' => '',
+ ] );
+
+ foreach ( $pages as $page ) {
+ $this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] );
+ }
+
+ $this->assertSame( $ids["$name/error"],
+ Title::newFromText( "$name/error" )->getArticleId() );
+ $this->assertSame( $ids["$name 2/error"],
+ Title::newFromText( "$name 2/error" )->getArticleId() );
+
+ $results = array_merge( $res[0]['move']['subpages'], $res[0]['move']['subpages-talk'] );
+ foreach ( $results as $arr ) {
+ if ( $arr['from'] === "$name/error" ) {
+ $this->assertSame( [ [
+ 'message' => 'articleexists',
+ 'params' => [],
+ 'code' => 'articleexists',
+ 'type' => 'error'
+ ] ], $arr['errors'] );
+ } else {
+ $this->assertSame( str_replace( $name, "$name 2", $arr['from'] ), $arr['to'] );
+ }
+ $this->assertCount( 2, $arr );
+ }
+
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testMoveNoPermission() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'You must be a registered user and [[Special:UserLogin|logged in]] to move a page.' );
+
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->createPage( $name );
+
+ $user = new User();
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ ], null, $user );
+ } finally {
+ $this->assertSame( $id, Title::newFromText( "$name" )->getArticleId() );
+ $this->assertFalse( Title::newFromText( "$name 2" )->exists(),
+ "\"$name 2\" should not exist" );
+ }
+ }
+
+ public function testSuppressRedirect() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->createPage( $name );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ 'noredirect' => '',
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id, 'noredirect' );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testSuppressRedirectNoPermission() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->setGroupPermissions( 'sysop', 'suppressredirect', false );
+
+ $id = $this->createPage( $name );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ 'noredirect' => '',
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testMoveSubpagesError() {
+ $name = ucfirst( __FUNCTION__ );
+
+ // Subpages are allowed in talk but not main
+ $idBase = $this->createPage( "Talk:$name" );
+ $idSub = $this->createPage( "Talk:$name/1" );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => "Talk:$name",
+ 'to' => $name,
+ 'movesubpages' => '',
+ ] );
+
+ $this->assertMoved( "Talk:$name", $name, $idBase );
+ $this->assertSame( $idSub, Title::newFromText( "Talk:$name/1" )->getArticleId() );
+ $this->assertFalse( Title::newFromText( "$name/1" )->exists(),
+ "\"$name/1\" should not exist" );
+
+ $this->assertSame( [ 'errors' => [ [
+ 'message' => 'namespace-nosubpages',
+ 'params' => [ '' ],
+ 'code' => 'namespace-nosubpages',
+ 'type' => 'error',
+ ] ] ], $res[0]['move']['subpages'] );
+
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiOpenSearchTest.php b/www/wiki/tests/phpunit/includes/api/ApiOpenSearchTest.php
new file mode 100644
index 00000000..209ca07b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiOpenSearchTest.php
@@ -0,0 +1,67 @@
+<?php
+
+/**
+ * @covers ApiOpenSearch
+ */
+class ApiOpenSearchTest extends MediaWikiTestCase {
+ public function testGetAllowedParams() {
+ $config = $this->replaceSearchEngineConfig();
+ $config->expects( $this->any() )
+ ->method( 'getSearchTypes' )
+ ->will( $this->returnValue( [ 'the one ring' ] ) );
+
+ $api = $this->createApi();
+ $engine = $this->replaceSearchEngine();
+ $engine->expects( $this->any() )
+ ->method( 'getProfiles' )
+ ->will( $this->returnValueMap( [
+ [ SearchEngine::COMPLETION_PROFILE_TYPE, $api->getUser(), [
+ [
+ 'name' => 'normal',
+ 'desc-message' => 'normal-message',
+ 'default' => true,
+ ],
+ [
+ 'name' => 'strict',
+ 'desc-message' => 'strict-message',
+ ],
+ ] ],
+ ] ) );
+
+ $params = $api->getAllowedParams();
+
+ $this->assertArrayNotHasKey( 'offset', $params );
+ $this->assertArrayHasKey( 'profile', $params, print_r( $params, true ) );
+ $this->assertEquals( 'normal', $params['profile'][ApiBase::PARAM_DFLT] );
+ }
+
+ private function replaceSearchEngineConfig() {
+ $config = $this->getMockBuilder( SearchEngineConfig::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->setService( 'SearchEngineConfig', $config );
+
+ return $config;
+ }
+
+ private function replaceSearchEngine() {
+ $engine = $this->getMockBuilder( SearchEngine::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $engineFactory = $this->getMockBuilder( SearchEngineFactory::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $engineFactory->expects( $this->any() )
+ ->method( 'create' )
+ ->will( $this->returnValue( $engine ) );
+ $this->setService( 'SearchEngineFactory', $engineFactory );
+
+ return $engine;
+ }
+
+ private function createApi() {
+ $ctx = new RequestContext();
+ $apiMain = new ApiMain( $ctx );
+ return new ApiOpenSearch( $apiMain, 'opensearch', '' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiOptionsTest.php b/www/wiki/tests/phpunit/includes/api/ApiOptionsTest.php
new file mode 100644
index 00000000..c0fecf06
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiOptionsTest.php
@@ -0,0 +1,418 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiOptions
+ */
+class ApiOptionsTest extends MediaWikiLangTestCase {
+
+ /** @var PHPUnit_Framework_MockObject_MockObject */
+ private $mUserMock;
+ /** @var ApiOptions */
+ private $mTested;
+ private $mSession;
+ /** @var DerivativeContext */
+ private $mContext;
+
+ private static $Success = [ 'options' => 'success' ];
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->mUserMock = $this->getMockBuilder( User::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ // Set up groups and rights
+ $this->mUserMock->expects( $this->any() )
+ ->method( 'getEffectiveGroups' )->will( $this->returnValue( [ '*', 'user' ] ) );
+ $this->mUserMock->expects( $this->any() )
+ ->method( 'isAllowedAny' )->will( $this->returnValue( true ) );
+
+ // Set up callback for User::getOptionKinds
+ $this->mUserMock->expects( $this->any() )
+ ->method( 'getOptionKinds' )->will( $this->returnCallback( [ $this, 'getOptionKinds' ] ) );
+
+ // No actual DB data
+ $this->mUserMock->expects( $this->any() )
+ ->method( 'getInstanceForUpdate' )->will( $this->returnValue( $this->mUserMock ) );
+
+ // Create a new context
+ $this->mContext = new DerivativeContext( new RequestContext() );
+ $this->mContext->getContext()->setTitle( Title::newFromText( 'Test' ) );
+ $this->mContext->setUser( $this->mUserMock );
+
+ $main = new ApiMain( $this->mContext );
+
+ // Empty session
+ $this->mSession = [];
+
+ $this->mTested = new ApiOptions( $main, 'options' );
+
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'GetPreferences' => [
+ [ $this, 'hookGetPreferences' ]
+ ]
+ ] );
+ }
+
+ public function hookGetPreferences( $user, &$preferences ) {
+ $preferences = [];
+
+ foreach ( [ 'name', 'willBeNull', 'willBeEmpty', 'willBeHappy' ] as $k ) {
+ $preferences[$k] = [
+ 'type' => 'text',
+ 'section' => 'test',
+ 'label' => '&#160;',
+ ];
+ }
+
+ $preferences['testmultiselect'] = [
+ 'type' => 'multiselect',
+ 'options' => [
+ 'Test' => [
+ '<span dir="auto">Some HTML here for option 1</span>' => 'opt1',
+ '<span dir="auto">Some HTML here for option 2</span>' => 'opt2',
+ '<span dir="auto">Some HTML here for option 3</span>' => 'opt3',
+ '<span dir="auto">Some HTML here for option 4</span>' => 'opt4',
+ ],
+ ],
+ 'section' => 'test',
+ 'label' => '&#160;',
+ 'prefix' => 'testmultiselect-',
+ 'default' => [],
+ ];
+
+ return true;
+ }
+
+ /**
+ * @param IContextSource $context
+ * @param array|null $options
+ *
+ * @return array
+ */
+ public function getOptionKinds( IContextSource $context, $options = null ) {
+ // Match with above.
+ $kinds = [
+ 'name' => 'registered',
+ 'willBeNull' => 'registered',
+ 'willBeEmpty' => 'registered',
+ 'willBeHappy' => 'registered',
+ 'testmultiselect-opt1' => 'registered-multiselect',
+ 'testmultiselect-opt2' => 'registered-multiselect',
+ 'testmultiselect-opt3' => 'registered-multiselect',
+ 'testmultiselect-opt4' => 'registered-multiselect',
+ 'special' => 'special',
+ ];
+
+ if ( $options === null ) {
+ return $kinds;
+ }
+
+ $mapping = [];
+ foreach ( $options as $key => $value ) {
+ if ( isset( $kinds[$key] ) ) {
+ $mapping[$key] = $kinds[$key];
+ } elseif ( substr( $key, 0, 7 ) === 'userjs-' ) {
+ $mapping[$key] = 'userjs';
+ } else {
+ $mapping[$key] = 'unused';
+ }
+ }
+
+ return $mapping;
+ }
+
+ private function getSampleRequest( $custom = [] ) {
+ $request = [
+ 'token' => '123ABC',
+ 'change' => null,
+ 'optionname' => null,
+ 'optionvalue' => null,
+ ];
+
+ return array_merge( $request, $custom );
+ }
+
+ private function executeQuery( $request ) {
+ $this->mContext->setRequest( new FauxRequest( $request, true, $this->mSession ) );
+ $this->mTested->execute();
+
+ return $this->mTested->getResult()->getResultData( null, [ 'Strip' => 'all' ] );
+ }
+
+ /**
+ * @expectedException ApiUsageException
+ */
+ public function testNoToken() {
+ $request = $this->getSampleRequest( [ 'token' => null ] );
+
+ $this->executeQuery( $request );
+ }
+
+ public function testAnon() {
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'isAnon' )
+ ->will( $this->returnValue( true ) );
+
+ try {
+ $request = $this->getSampleRequest();
+
+ $this->executeQuery( $request );
+ } catch ( ApiUsageException $e ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $e, 'notloggedin' ) );
+ return;
+ }
+ $this->fail( "ApiUsageException was not thrown" );
+ }
+
+ public function testNoOptionname() {
+ try {
+ $request = $this->getSampleRequest( [ 'optionvalue' => '1' ] );
+
+ $this->executeQuery( $request );
+ } catch ( ApiUsageException $e ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $e, 'nooptionname' ) );
+ return;
+ }
+ $this->fail( "ApiUsageException was not thrown" );
+ }
+
+ public function testNoChanges() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'setOption' );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'saveSettings' );
+
+ try {
+ $request = $this->getSampleRequest();
+
+ $this->executeQuery( $request );
+ } catch ( ApiUsageException $e ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $e, 'nochanges' ) );
+ return;
+ }
+ $this->fail( "ApiUsageException was not thrown" );
+ }
+
+ public function testReset() {
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'resetOptions' )
+ ->with( $this->equalTo( [ 'all' ] ) );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'setOption' );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [ 'reset' => '' ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testResetKinds() {
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'resetOptions' )
+ ->with( $this->equalTo( [ 'registered' ] ) );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'setOption' );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [ 'reset' => '', 'resetkinds' => 'registered' ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testOptionWithValue() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'name' ), $this->equalTo( 'value' ) );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [ 'optionname' => 'name', 'optionvalue' => 'value' ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testOptionResetValue() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'name' ), $this->identicalTo( null ) );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [ 'optionname' => 'name' ] );
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testChange() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->exactly( 3 ) )
+ ->method( 'setOption' )
+ ->withConsecutive(
+ [ $this->equalTo( 'willBeNull' ), $this->identicalTo( null ) ],
+ [ $this->equalTo( 'willBeEmpty' ), $this->equalTo( '' ) ],
+ [ $this->equalTo( 'willBeHappy' ), $this->equalTo( 'Happy' ) ]
+ );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [
+ 'change' => 'willBeNull|willBeEmpty=|willBeHappy=Happy'
+ ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testResetChangeOption() {
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->exactly( 2 ) )
+ ->method( 'setOption' )
+ ->withConsecutive(
+ [ $this->equalTo( 'willBeHappy' ), $this->equalTo( 'Happy' ) ],
+ [ $this->equalTo( 'name' ), $this->equalTo( 'value' ) ]
+ );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $args = [
+ 'reset' => '',
+ 'change' => 'willBeHappy=Happy',
+ 'optionname' => 'name',
+ 'optionvalue' => 'value'
+ ];
+
+ $response = $this->executeQuery( $this->getSampleRequest( $args ) );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testMultiSelect() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->exactly( 4 ) )
+ ->method( 'setOption' )
+ ->withConsecutive(
+ [ $this->equalTo( 'testmultiselect-opt1' ), $this->identicalTo( true ) ],
+ [ $this->equalTo( 'testmultiselect-opt2' ), $this->identicalTo( null ) ],
+ [ $this->equalTo( 'testmultiselect-opt3' ), $this->identicalTo( false ) ],
+ [ $this->equalTo( 'testmultiselect-opt4' ), $this->identicalTo( false ) ]
+ );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [
+ 'change' => 'testmultiselect-opt1=1|testmultiselect-opt2|'
+ . 'testmultiselect-opt3=|testmultiselect-opt4=0'
+ ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testSpecialOption() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [
+ 'change' => 'special=1'
+ ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( [
+ 'options' => 'success',
+ 'warnings' => [
+ 'options' => [
+ 'warnings' => "Validation error for \"special\": cannot be set by this module."
+ ]
+ ]
+ ], $response );
+ }
+
+ public function testUnknownOption() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [
+ 'change' => 'unknownOption=1'
+ ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( [
+ 'options' => 'success',
+ 'warnings' => [
+ 'options' => [
+ 'warnings' => "Validation error for \"unknownOption\": not a valid preference."
+ ]
+ ]
+ ], $response );
+ }
+
+ public function testUserjsOption() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'userjs-option' ), $this->equalTo( '1' ) );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [
+ 'change' => 'userjs-option=1'
+ ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiPageSetTest.php b/www/wiki/tests/phpunit/includes/api/ApiPageSetTest.php
new file mode 100644
index 00000000..b9e4645d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiPageSetTest.php
@@ -0,0 +1,179 @@
+<?php
+
+/**
+ * @group API
+ * @group medium
+ * @group Database
+ * @covers ApiPageSet
+ */
+class ApiPageSetTest extends ApiTestCase {
+ public static function provideRedirectMergePolicy() {
+ return [
+ 'By default nothing is merged' => [
+ null,
+ []
+ ],
+
+ 'A simple merge policy adds the redirect data in' => [
+ function ( $current, $new ) {
+ if ( !isset( $current['index'] ) || $new['index'] < $current['index'] ) {
+ $current['index'] = $new['index'];
+ }
+ return $current;
+ },
+ [ 'index' => 1 ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideRedirectMergePolicy
+ */
+ public function testRedirectMergePolicyWithArrayResult( $mergePolicy, $expect ) {
+ list( $target, $pageSet ) = $this->createPageSetWithRedirect();
+ $pageSet->setRedirectMergePolicy( $mergePolicy );
+ $result = [
+ $target->getArticleID() => []
+ ];
+ $pageSet->populateGeneratorData( $result );
+ $this->assertEquals( $expect, $result[$target->getArticleID()] );
+ }
+
+ /**
+ * @dataProvider provideRedirectMergePolicy
+ */
+ public function testRedirectMergePolicyWithApiResult( $mergePolicy, $expect ) {
+ list( $target, $pageSet ) = $this->createPageSetWithRedirect();
+ $pageSet->setRedirectMergePolicy( $mergePolicy );
+ $result = new ApiResult( false );
+ $result->addValue( null, 'pages', [
+ $target->getArticleID() => []
+ ] );
+ $pageSet->populateGeneratorData( $result, [ 'pages' ] );
+ $this->assertEquals(
+ $expect,
+ $result->getResultData( [ 'pages', $target->getArticleID() ] )
+ );
+ }
+
+ protected function createPageSetWithRedirect() {
+ $target = Title::makeTitle( NS_MAIN, 'UTRedirectTarget' );
+ $sourceA = Title::makeTitle( NS_MAIN, 'UTRedirectSourceA' );
+ $sourceB = Title::makeTitle( NS_MAIN, 'UTRedirectSourceB' );
+ self::editPage( 'UTRedirectTarget', 'api page set test' );
+ self::editPage( 'UTRedirectSourceA', '#REDIRECT [[UTRedirectTarget]]' );
+ self::editPage( 'UTRedirectSourceB', '#REDIRECT [[UTRedirectTarget]]' );
+
+ $request = new FauxRequest( [ 'redirects' => 1 ] );
+ $context = new RequestContext();
+ $context->setRequest( $request );
+ $main = new ApiMain( $context );
+ $pageSet = new ApiPageSet( $main );
+
+ $pageSet->setGeneratorData( $sourceA, [ 'index' => 1 ] );
+ $pageSet->setGeneratorData( $sourceB, [ 'index' => 3 ] );
+ $pageSet->populateFromTitles( [ $sourceA, $sourceB ] );
+
+ return [ $target, $pageSet ];
+ }
+
+ public function testHandleNormalization() {
+ $context = new RequestContext();
+ $context->setRequest( new FauxRequest( [ 'titles' => "a|B|a\xcc\x8a" ] ) );
+ $main = new ApiMain( $context );
+ $pageSet = new ApiPageSet( $main );
+ $pageSet->execute();
+
+ $this->assertSame(
+ [ 0 => [ 'A' => -1, 'B' => -2, 'Å' => -3 ] ],
+ $pageSet->getAllTitlesByNamespace()
+ );
+ $this->assertSame(
+ [
+ [ 'fromencoded' => true, 'from' => 'a%CC%8A', 'to' => 'å' ],
+ [ 'fromencoded' => false, 'from' => 'a', 'to' => 'A' ],
+ [ 'fromencoded' => false, 'from' => 'å', 'to' => 'Å' ],
+ ],
+ $pageSet->getNormalizedTitlesAsResult()
+ );
+ }
+
+ public function testSpecialRedirects() {
+ $id1 = self::editPage( 'UTApiPageSet', 'UTApiPageSet in the default language' )
+ ->value['revision']->getTitle()->getArticleID();
+ $id2 = self::editPage( 'UTApiPageSet/de', 'UTApiPageSet in German' )
+ ->value['revision']->getTitle()->getArticleID();
+
+ $user = $this->getTestUser()->getUser();
+ $userName = $user->getName();
+ $userDbkey = str_replace( ' ', '_', $userName );
+ $request = new FauxRequest( [
+ 'titles' => implode( '|', [
+ 'Special:MyContributions',
+ 'Special:MyPage',
+ 'Special:MyTalk/subpage',
+ 'Special:MyLanguage/UTApiPageSet',
+ ] ),
+ ] );
+ $context = new RequestContext();
+ $context->setRequest( $request );
+ $context->setUser( $user );
+
+ $main = new ApiMain( $context );
+ $pageSet = new ApiPageSet( $main );
+ $pageSet->execute();
+
+ $this->assertEquals( [
+ ], $pageSet->getRedirectTitlesAsResult() );
+ $this->assertEquals( [
+ [ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ],
+ [ 'ns' => -1, 'title' => 'Special:MyPage', 'special' => true ],
+ [ 'ns' => -1, 'title' => 'Special:MyTalk/subpage', 'special' => true ],
+ [ 'ns' => -1, 'title' => 'Special:MyLanguage/UTApiPageSet', 'special' => true ],
+ ], $pageSet->getInvalidTitlesAndRevisions() );
+ $this->assertEquals( [
+ ], $pageSet->getAllTitlesByNamespace() );
+
+ $request->setVal( 'redirects', 1 );
+ $main = new ApiMain( $context );
+ $pageSet = new ApiPageSet( $main );
+ $pageSet->execute();
+
+ $this->assertEquals( [
+ [ 'from' => 'Special:MyPage', 'to' => "User:$userName" ],
+ [ 'from' => 'Special:MyTalk/subpage', 'to' => "User talk:$userName/subpage" ],
+ [ 'from' => 'Special:MyLanguage/UTApiPageSet', 'to' => 'UTApiPageSet' ],
+ ], $pageSet->getRedirectTitlesAsResult() );
+ $this->assertEquals( [
+ [ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ],
+ [ 'ns' => 2, 'title' => "User:$userName", 'missing' => true ],
+ [ 'ns' => 3, 'title' => "User talk:$userName/subpage", 'missing' => true ],
+ ], $pageSet->getInvalidTitlesAndRevisions() );
+ $this->assertEquals( [
+ 0 => [ 'UTApiPageSet' => $id1 ],
+ 2 => [ $userDbkey => -2 ],
+ 3 => [ "$userDbkey/subpage" => -3 ],
+ ], $pageSet->getAllTitlesByNamespace() );
+
+ $context->setLanguage( 'de' );
+ $main = new ApiMain( $context );
+ $pageSet = new ApiPageSet( $main );
+ $pageSet->execute();
+
+ $this->assertEquals( [
+ [ 'from' => 'Special:MyPage', 'to' => "User:$userName" ],
+ [ 'from' => 'Special:MyTalk/subpage', 'to' => "User talk:$userName/subpage" ],
+ [ 'from' => 'Special:MyLanguage/UTApiPageSet', 'to' => 'UTApiPageSet/de' ],
+ ], $pageSet->getRedirectTitlesAsResult() );
+ $this->assertEquals( [
+ [ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ],
+ [ 'ns' => 2, 'title' => "User:$userName", 'missing' => true ],
+ [ 'ns' => 3, 'title' => "User talk:$userName/subpage", 'missing' => true ],
+ ], $pageSet->getInvalidTitlesAndRevisions() );
+ $this->assertEquals( [
+ 0 => [ 'UTApiPageSet/de' => $id2 ],
+ 2 => [ $userDbkey => -2 ],
+ 3 => [ "$userDbkey/subpage" => -3 ],
+ ], $pageSet->getAllTitlesByNamespace() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiParseTest.php b/www/wiki/tests/phpunit/includes/api/ApiParseTest.php
new file mode 100644
index 00000000..a04271f6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiParseTest.php
@@ -0,0 +1,849 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiParse
+ */
+class ApiParseTest extends ApiTestCase {
+
+ protected static $pageId;
+ protected static $revIds = [];
+
+ public function addDBDataOnce() {
+ $title = Title::newFromText( __CLASS__ );
+
+ $status = $this->editPage( __CLASS__, 'Test for revdel' );
+ self::$pageId = $status->value['revision']->getPage();
+ self::$revIds['revdel'] = $status->value['revision']->getId();
+
+ $status = $this->editPage( __CLASS__, 'Test for suppressed' );
+ self::$revIds['suppressed'] = $status->value['revision']->getId();
+
+ $status = $this->editPage( __CLASS__, 'Test for oldid' );
+ self::$revIds['oldid'] = $status->value['revision']->getId();
+
+ $status = $this->editPage( __CLASS__, 'Test for latest' );
+ self::$revIds['latest'] = $status->value['revision']->getId();
+
+ $this->revisionDelete( self::$revIds['revdel'] );
+ $this->revisionDelete(
+ self::$revIds['suppressed'],
+ [ Revision::DELETED_TEXT => 1, Revision::DELETED_RESTRICTED => 1 ]
+ );
+
+ Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason
+ }
+
+ /**
+ * Assert that the given result of calling $this->doApiRequest() with
+ * action=parse resulted in $html, accounting for the boilerplate that the
+ * parser adds around the parsed page. Also asserts that warnings match
+ * the provided $warning.
+ *
+ * @param string $html Expected HTML
+ * @param array $res Returned from doApiRequest()
+ * @param string|null $warnings Exact value of expected warnings, null for
+ * no warnings
+ */
+ protected function assertParsedTo( $expected, array $res, $warnings = null ) {
+ $this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertSame' ] );
+ }
+
+ /**
+ * Same as above, but asserts that the HTML matches a regexp instead of a
+ * literal string match.
+ *
+ * @param string $html Expected HTML
+ * @param array $res Returned from doApiRequest()
+ * @param string|null $warnings Exact value of expected warnings, null for
+ * no warnings
+ */
+ protected function assertParsedToRegExp( $expected, array $res, $warnings = null ) {
+ $this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertRegExp' ] );
+ }
+
+ private function doAssertParsedTo( $expected, array $res, $warnings, callable $callback ) {
+ $html = $res[0]['parse']['text'];
+
+ $expectedStart = '<div class="mw-parser-output">';
+ $this->assertSame( $expectedStart, substr( $html, 0, strlen( $expectedStart ) ) );
+
+ $html = substr( $html, strlen( $expectedStart ) );
+
+ if ( $res[1]->getBool( 'disablelimitreport' ) ) {
+ $expectedEnd = "</div>";
+ $this->assertSame( $expectedEnd, substr( $html, -strlen( $expectedEnd ) ) );
+
+ $html = substr( $html, 0, strlen( $html ) - strlen( $expectedEnd ) );
+ } else {
+ $expectedEnd = '#\n<!-- \nNewPP limit report\n(?>.+?\n-->)\n' .
+ '<!--\nTransclusion expansion time report \(%,ms,calls,template\)\n(?>.*?\n-->)\n' .
+ '</div>(\n<!-- Saved in parser cache (?>.*?\n -->)\n)?$#s';
+ $this->assertRegExp( $expectedEnd, $html );
+
+ $html = preg_replace( $expectedEnd, '', $html );
+ }
+
+ call_user_func( $callback, $expected, $html );
+
+ if ( $warnings === null ) {
+ $this->assertCount( 1, $res[0] );
+ } else {
+ $this->assertCount( 2, $res[0] );
+ // This deliberately fails if there are extra warnings
+ $this->assertSame( [ 'parse' => [ 'warnings' => $warnings ] ], $res[0]['warnings'] );
+ }
+ }
+
+ /**
+ * Set up an interwiki entry for testing.
+ */
+ protected function setupInterwiki() {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->insert(
+ 'interwiki',
+ [
+ 'iw_prefix' => 'madeuplanguage',
+ 'iw_url' => "https://example.com/wiki/$1",
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => false,
+ ],
+ __METHOD__,
+ 'IGNORE'
+ );
+
+ $this->setMwGlobals( 'wgExtraInterlanguageLinkPrefixes', [ 'madeuplanguage' ] );
+ $this->tablesUsed[] = 'interwiki';
+ }
+
+ /**
+ * Set up a skin for testing.
+ *
+ * @todo Should this code be in MediaWikiTestCase or something?
+ */
+ protected function setupSkin() {
+ $factory = new SkinFactory();
+ $factory->register( 'testing', 'Testing', function () {
+ $skin = $this->getMockBuilder( SkinFallback::class )
+ ->setMethods( [ 'getDefaultModules', 'setupSkinUserCss' ] )
+ ->getMock();
+ $skin->expects( $this->once() )->method( 'getDefaultModules' )
+ ->willReturn( [
+ 'core' => [ 'foo', 'bar' ],
+ 'content' => [ 'baz' ]
+ ] );
+ $skin->expects( $this->once() )->method( 'setupSkinUserCss' )
+ ->will( $this->returnCallback( function ( OutputPage $out ) {
+ $out->addModuleStyles( 'foo.styles' );
+ } ) );
+ return $skin;
+ } );
+ $this->setService( 'SkinFactory', $factory );
+ }
+
+ public function testParseByName() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => __CLASS__,
+ ] );
+ $this->assertParsedTo( "<p>Test for latest\n</p>", $res );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => __CLASS__,
+ 'disablelimitreport' => 1,
+ ] );
+ $this->assertParsedTo( "<p>Test for latest\n</p>", $res );
+ }
+
+ public function testParseById() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pageid' => self::$pageId,
+ ] );
+ $this->assertParsedTo( "<p>Test for latest\n</p>", $res );
+ }
+
+ public function testParseByOldId() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => self::$revIds['oldid'],
+ ] );
+ $this->assertParsedTo( "<p>Test for oldid\n</p>", $res );
+ $this->assertArrayNotHasKey( 'textdeleted', $res[0]['parse'] );
+ $this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] );
+ }
+
+ public function testRevDel() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => self::$revIds['revdel'],
+ ] );
+
+ $this->assertParsedTo( "<p>Test for revdel\n</p>", $res );
+ $this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] );
+ $this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] );
+ }
+
+ public function testRevDelNoPermission() {
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to view deleted revision text." );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => self::$revIds['revdel'],
+ ], null, null, static::getTestUser()->getUser() );
+ }
+
+ public function testSuppressed() {
+ $this->setGroupPermissions( 'sysop', 'viewsuppressed', true );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => self::$revIds['suppressed']
+ ] );
+
+ $this->assertParsedTo( "<p>Test for suppressed\n</p>", $res );
+ $this->assertArrayHasKey( 'textsuppressed', $res[0]['parse'] );
+ $this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] );
+ }
+
+ public function testNonexistentPage() {
+ try {
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => 'DoesNotExist',
+ ] );
+
+ $this->fail( "API did not return an error when parsing a nonexistent page" );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'missingtitle' ),
+ "Parse request for nonexistent page must give 'missingtitle' error: "
+ . var_export( self::getErrorFormatter()->arrayFromStatus( $ex->getStatusValue() ), true )
+ );
+ }
+ }
+
+ public function testTitleProvided() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => 'Some interesting page',
+ 'text' => '{{PAGENAME}} has attracted my attention',
+ ] );
+
+ $this->assertParsedTo( "<p>Some interesting page has attracted my attention\n</p>", $res );
+ }
+
+ public function testSection() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name,
+ "Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ 'section' => 1,
+ ] );
+
+ $this->assertParsedToRegExp( '!<h2>.*Section 1.*</h2>\n<p>Content 1\n</p>!', $res );
+ }
+
+ public function testInvalidSection() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The "section" parameter must be a valid section ID or "new".' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'section' => 'T-new',
+ ] );
+ }
+
+ public function testSectionNoContent() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $status = $this->editPage( $name,
+ "Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "Missing content for page ID {$status->value['revision']->getPage()}." );
+
+ $this->db->delete( 'revision', [ 'rev_id' => $status->value['revision']->getId() ] );
+
+ // Suppress warning in WikiPage::getContentModel
+ Wikimedia\suppressWarnings();
+ try {
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ 'section' => 1,
+ ] );
+ } finally {
+ Wikimedia\restoreWarnings();
+ }
+ }
+
+ public function testNewSectionWithPage() {
+ $this->setExpectedException( ApiUsageException::class,
+ '"section=new" cannot be combined with the "oldid", "pageid" or "page" ' .
+ 'parameters. Please use "title" and "text".' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => __CLASS__,
+ 'section' => 'new',
+ ] );
+ }
+
+ public function testNonexistentOldId() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no revision with ID 2147483647.' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => pow( 2, 31 ) - 1,
+ ] );
+ }
+
+ public function testUnfollowedRedirect() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "#REDIRECT [[$name 2]]" );
+ $this->editPage( "$name 2", "Some ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ ] );
+
+ // Can't use assertParsedTo because the parser output is different for
+ // redirects
+ $this->assertRegExp( "/Redirect to:.*$name 2/", $res[0]['parse']['text'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testFollowedRedirect() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "#REDIRECT [[$name 2]]" );
+ $this->editPage( "$name 2", "Some ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ 'redirects' => true,
+ ] );
+
+ $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res );
+ }
+
+ public function testFollowedRedirectById() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->editPage( $name, "#REDIRECT [[$name 2]]" )->value['revision']->getPage();
+ $this->editPage( "$name 2", "Some ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pageid' => $id,
+ 'redirects' => true,
+ ] );
+
+ $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res );
+ }
+
+ public function testInvalidTitle() {
+ $this->setExpectedException( ApiUsageException::class, 'Bad title "|".' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => '|',
+ ] );
+ }
+
+ public function testTitleWithNonexistentRevId() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no revision with ID 2147483647.' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'revid' => pow( 2, 31 ) - 1,
+ ] );
+ }
+
+ public function testTitleWithNonMatchingRevId() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => $name,
+ 'revid' => self::$revIds['latest'],
+ 'text' => 'Some text',
+ ] );
+
+ $this->assertParsedTo( "<p>Some text\n</p>", $res,
+ 'r' . self::$revIds['latest'] . " is not a revision of $name." );
+ }
+
+ public function testRevId() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'revid' => self::$revIds['latest'],
+ 'text' => 'My revid is {{REVISIONID}}!',
+ ] );
+
+ $this->assertParsedTo( "<p>My revid is " . self::$revIds['latest'] . "!\n</p>", $res );
+ }
+
+ public function testTitleNoText() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => 'Special:AllPages',
+ ] );
+
+ $this->assertParsedTo( '', $res,
+ '"title" used without "text", and parsed page properties were requested. ' .
+ 'Did you mean to use "page" instead of "title"?' );
+ }
+
+ public function testRevidNoText() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'revid' => self::$revIds['latest'],
+ ] );
+
+ $this->assertParsedTo( '', $res,
+ '"revid" used without "text", and parsed page properties were requested. ' .
+ 'Did you mean to use "oldid" instead of "revid"?' );
+ }
+
+ public function testTextNoContentModel() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "Some ''text''",
+ ] );
+
+ $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res,
+ 'No "title" or "contentmodel" was given, assuming wikitext.' );
+ }
+
+ public function testSerializationError() {
+ $this->setExpectedException( APIUsageException::class,
+ 'Content serialization failed: Could not unserialize content' );
+
+ $this->mergeMwGlobalArrayValue( 'wgContentHandlers',
+ [ 'testing-serialize-error' => 'DummySerializeErrorContentHandler' ] );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "Some ''text''",
+ 'contentmodel' => 'testing-serialize-error',
+ ] );
+ }
+
+ public function testNewSection() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'section' => 'new',
+ 'sectiontitle' => 'Title',
+ 'text' => 'Content',
+ ] );
+
+ $this->assertParsedToRegExp( '!<h2>.*Title.*</h2>\n<p>Content\n</p>!', $res );
+ }
+
+ public function testExistingSection() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'section' => 1,
+ 'text' => "Intro\n\n== Section 1 ==\n\nContent\n\n== Section 2 ==\n\nMore content",
+ ] );
+
+ $this->assertParsedToRegExp( '!<h2>.*Section 1.*</h2>\n<p>Content\n</p>!', $res );
+ }
+
+ public function testNoPst() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( "Template:$name", "Template ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "{{subst:$name}}",
+ 'contentmodel' => 'wikitext',
+ ] );
+
+ $this->assertParsedTo( "<p>{{subst:$name}}\n</p>", $res );
+ }
+
+ public function testPst() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( "Template:$name", "Template ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pst' => '',
+ 'text' => "{{subst:$name}}",
+ 'contentmodel' => 'wikitext',
+ 'prop' => 'text|wikitext',
+ ] );
+
+ $this->assertParsedTo( "<p>Template <i>text</i>\n</p>", $res );
+ $this->assertSame( "{{subst:$name}}", $res[0]['parse']['wikitext'] );
+ }
+
+ public function testOnlyPst() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( "Template:$name", "Template ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'onlypst' => '',
+ 'text' => "{{subst:$name}}",
+ 'contentmodel' => 'wikitext',
+ 'prop' => 'text|wikitext',
+ 'summary' => 'Summary',
+ ] );
+
+ $this->assertSame(
+ [ 'parse' => [
+ 'text' => "Template ''text''",
+ 'wikitext' => "{{subst:$name}}",
+ 'parsedsummary' => 'Summary',
+ ] ],
+ $res[0]
+ );
+ }
+
+ public function testHeadHtml() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => __CLASS__,
+ 'prop' => 'headhtml',
+ ] );
+
+ // Just do a rough sanity check
+ $this->assertRegExp( '#<!DOCTYPE.*<html.*<head.*</head>.*<body#s',
+ $res[0]['parse']['headhtml'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testCategoriesHtml() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "[[Category:$name]]" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ 'prop' => 'categorieshtml',
+ ] );
+
+ $this->assertRegExp( "#Category.*Category:$name.*$name#",
+ $res[0]['parse']['categorieshtml'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testEffectiveLangLinks() {
+ $hookRan = false;
+ $this->setTemporaryHook( 'LanguageLinks',
+ function () use ( &$hookRan ) {
+ $hookRan = true;
+ }
+ );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => '[[zh:' . __CLASS__ . ']]',
+ 'effectivelanglinks' => '',
+ ] );
+
+ $this->assertTrue( $hookRan );
+ $this->assertSame( 'The parameter "effectivelanglinks" has been deprecated.',
+ $res[0]['warnings']['parse']['warnings'] );
+ }
+
+ /**
+ * @param array $arr Extra params to add to API request
+ */
+ private function doTestLangLinks( array $arr = [] ) {
+ $this->setupInterwiki();
+
+ $res = $this->doApiRequest( array_merge( [
+ 'action' => 'parse',
+ 'title' => 'Omelette',
+ 'text' => '[[madeuplanguage:Omelette]]',
+ 'prop' => 'langlinks',
+ ], $arr ) );
+
+ $langLinks = $res[0]['parse']['langlinks'];
+
+ $this->assertCount( 1, $langLinks );
+ $this->assertSame( 'madeuplanguage', $langLinks[0]['lang'] );
+ $this->assertSame( 'Omelette', $langLinks[0]['title'] );
+ $this->assertSame( 'https://example.com/wiki/Omelette', $langLinks[0]['url'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testLangLinks() {
+ $this->doTestLangLinks();
+ }
+
+ public function testLangLinksWithSkin() {
+ $this->setupSkin();
+ $this->doTestLangLinks( [ 'useskin' => 'testing' ] );
+ }
+
+ public function testHeadItems() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => '',
+ 'prop' => 'headitems',
+ ] );
+
+ $this->assertSame( [], $res[0]['parse']['headitems'] );
+ $this->assertSame(
+ '"prop=headitems" is deprecated since MediaWiki 1.28. ' .
+ 'Use "prop=headhtml" when creating new HTML documents, ' .
+ 'or "prop=modules|jsconfigvars" when updating a document client-side.',
+ $res[0]['warnings']['parse']['warnings']
+ );
+ }
+
+ public function testHeadItemsWithSkin() {
+ $this->setupSkin();
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => '',
+ 'prop' => 'headitems',
+ 'useskin' => 'testing',
+ ] );
+
+ $this->assertSame( [], $res[0]['parse']['headitems'] );
+ $this->assertSame(
+ '"prop=headitems" is deprecated since MediaWiki 1.28. ' .
+ 'Use "prop=headhtml" when creating new HTML documents, ' .
+ 'or "prop=modules|jsconfigvars" when updating a document client-side.',
+ $res[0]['warnings']['parse']['warnings']
+ );
+ }
+
+ public function testModules() {
+ $this->setTemporaryHook( 'ParserAfterParse',
+ function ( $parser ) {
+ $output = $parser->getOutput();
+ $output->addModules( [ 'foo', 'bar' ] );
+ $output->addModuleScripts( [ 'baz', 'quuz' ] );
+ $output->addModuleStyles( [ 'aaa', 'zzz' ] );
+ $output->addJsConfigVars( [ 'x' => 'y', 'z' => -3 ] );
+ }
+ );
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => 'Content',
+ 'prop' => 'modules|jsconfigvars|encodedjsconfigvars',
+ ] );
+
+ $this->assertSame( [ 'foo', 'bar' ], $res[0]['parse']['modules'] );
+ $this->assertSame( [ 'baz', 'quuz' ], $res[0]['parse']['modulescripts'] );
+ $this->assertSame( [ 'aaa', 'zzz' ], $res[0]['parse']['modulestyles'] );
+ $this->assertSame( [ 'x' => 'y', 'z' => -3 ], $res[0]['parse']['jsconfigvars'] );
+ $this->assertSame( '{"x":"y","z":-3}', $res[0]['parse']['encodedjsconfigvars'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testModulesWithSkin() {
+ $this->setupSkin();
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pageid' => self::$pageId,
+ 'useskin' => 'testing',
+ 'prop' => 'modules',
+ ] );
+ $this->assertSame(
+ [ 'foo', 'bar', 'baz' ],
+ $res[0]['parse']['modules'],
+ 'resp.parse.modules'
+ );
+ $this->assertSame(
+ [],
+ $res[0]['parse']['modulescripts'],
+ 'resp.parse.modulescripts'
+ );
+ $this->assertSame(
+ [ 'foo.styles' ],
+ $res[0]['parse']['modulestyles'],
+ 'resp.parse.modulestyles'
+ );
+ $this->assertSame(
+ [ 'parse' =>
+ [ 'warnings' =>
+ 'Property "modules" was set but not "jsconfigvars" or ' .
+ '"encodedjsconfigvars". Configuration variables are necessary for ' .
+ 'proper module usage.'
+ ]
+ ],
+ $res[0]['warnings']
+ );
+ }
+
+ public function testIndicators() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' =>
+ '<indicator name="b">BBB!</indicator>Some text<indicator name="a">aaa</indicator>',
+ 'prop' => 'indicators',
+ ] );
+
+ $this->assertSame(
+ // It seems we return in markup order and not display order
+ [ 'b' => 'BBB!', 'a' => 'aaa' ],
+ $res[0]['parse']['indicators']
+ );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testIndicatorsWithSkin() {
+ $this->setupSkin();
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' =>
+ '<indicator name="b">BBB!</indicator>Some text<indicator name="a">aaa</indicator>',
+ 'prop' => 'indicators',
+ 'useskin' => 'testing',
+ ] );
+
+ $this->assertSame(
+ // Now we return in display order rather than markup order
+ [ 'a' => 'aaa', 'b' => 'BBB!' ],
+ $res[0]['parse']['indicators']
+ );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testIwlinks() {
+ $this->setupInterwiki();
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => 'Omelette',
+ 'text' => '[[:madeuplanguage:Omelette]][[madeuplanguage:Spaghetti]]',
+ 'prop' => 'iwlinks',
+ ] );
+
+ $iwlinks = $res[0]['parse']['iwlinks'];
+
+ $this->assertCount( 1, $iwlinks );
+ $this->assertSame( 'madeuplanguage', $iwlinks[0]['prefix'] );
+ $this->assertSame( 'https://example.com/wiki/Omelette', $iwlinks[0]['url'] );
+ $this->assertSame( 'madeuplanguage:Omelette', $iwlinks[0]['title'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testLimitReports() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pageid' => self::$pageId,
+ 'prop' => 'limitreportdata|limitreporthtml',
+ ] );
+
+ // We don't bother testing the actual values here
+ $this->assertInternalType( 'array', $res[0]['parse']['limitreportdata'] );
+ $this->assertInternalType( 'string', $res[0]['parse']['limitreporthtml'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testParseTreeNonWikitext() {
+ $this->setExpectedException( ApiUsageException::class,
+ '"prop=parsetree" is only supported for wikitext content.' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => '',
+ 'contentmodel' => 'json',
+ 'prop' => 'parsetree',
+ ] );
+ }
+
+ public function testParseTree() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "Some ''text'' is {{nice|to have|i=think}}",
+ 'contentmodel' => 'wikitext',
+ 'prop' => 'parsetree',
+ ] );
+
+ // Preprocessor_DOM and Preprocessor_Hash give different results here,
+ // so we'll accept either
+ $this->assertRegExp(
+ '#^<root>Some \'\'text\'\' is <template><title>nice</title>' .
+ '<part><name index="1"/><value>to have</value></part>' .
+ '<part><name>i</name>(?:<equals>)?=(?:</equals>)?<value>think</value></part>' .
+ '</template></root>$#',
+ $res[0]['parse']['parsetree']
+ );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testDisableTidy() {
+ $this->setMwGlobals( 'wgTidyConfig', [ 'driver' => 'RemexHtml' ] );
+
+ // Check that disabletidy doesn't have an effect just because tidying
+ // doesn't work for some other reason
+ $res1 = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "<b>Mixed <i>up</b></i>",
+ 'contentmodel' => 'wikitext',
+ ] );
+ $this->assertParsedTo( "<p><b>Mixed <i>up</i></b>\n</p>", $res1 );
+
+ $res2 = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "<b>Mixed <i>up</b></i>",
+ 'contentmodel' => 'wikitext',
+ 'disabletidy' => '',
+ ] );
+
+ $this->assertParsedTo( "<p><b>Mixed <i>up</b></i>\n</p>", $res2 );
+ }
+
+ public function testFormatCategories() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( "Category:$name", 'Content' );
+ $this->editPage( 'Category:Hidden', '__HIDDENCAT__' );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => "[[Category:$name]][[Category:Foo|Sort me]][[Category:Hidden]]",
+ 'prop' => 'categories',
+ ] );
+
+ $this->assertSame(
+ [ [ 'sortkey' => '', 'category' => $name ],
+ [ 'sortkey' => 'Sort me', 'category' => 'Foo', 'missing' => true ],
+ [ 'sortkey' => '', 'category' => 'Hidden', 'hidden' => true ] ],
+ $res[0]['parse']['categories']
+ );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiPurgeTest.php b/www/wiki/tests/phpunit/includes/api/ApiPurgeTest.php
new file mode 100644
index 00000000..96d9a384
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiPurgeTest.php
@@ -0,0 +1,40 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiPurge
+ */
+class ApiPurgeTest extends ApiTestCase {
+
+ /**
+ * @group Broken
+ */
+ public function testPurgeMainPage() {
+ if ( !Title::newFromText( 'UTPage' )->exists() ) {
+ $this->markTestIncomplete( "The article [[UTPage]] does not exist" );
+ }
+
+ $somePage = mt_rand();
+
+ $data = $this->doApiRequest( [
+ 'action' => 'purge',
+ 'titles' => 'UTPage|' . $somePage . '|%5D' ] );
+
+ $this->assertArrayHasKey( 'purge', $data[0],
+ "Must receive a 'purge' result from API" );
+
+ $this->assertEquals(
+ 3,
+ count( $data[0]['purge'] ),
+ "Purge request for three articles should give back three results received: "
+ . var_export( $data[0]['purge'], true ) );
+
+ $pages = [ 'UTPage' => 'purged', $somePage => 'missing', '%5D' => 'invalid' ];
+ foreach ( $data[0]['purge'] as $v ) {
+ $this->assertArrayHasKey( $pages[$v['title']], $v );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiQueryAllPagesTest.php b/www/wiki/tests/phpunit/includes/api/ApiQueryAllPagesTest.php
new file mode 100644
index 00000000..2d89aa54
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiQueryAllPagesTest.php
@@ -0,0 +1,36 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiQueryAllPages
+ */
+class ApiQueryAllPagesTest extends ApiTestCase {
+ /**
+ * Test T27702
+ * Prefixes of API search requests are not handled with case sensitivity and may result
+ * in wrong search results
+ */
+ public function testPrefixNormalizationSearchBug() {
+ $title = Title::newFromText( 'Category:Template:xyz' );
+ $page = WikiPage::factory( $title );
+
+ $page->doEditContent(
+ ContentHandler::makeContent( 'Some text', $page->getTitle() ),
+ 'inserting content'
+ );
+
+ $result = $this->doApiRequest( [
+ 'action' => 'query',
+ 'list' => 'allpages',
+ 'apnamespace' => NS_CATEGORY,
+ 'apprefix' => 'Template:x' ] );
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'allpages', $result[0]['query'] );
+ $this->assertNotEquals( 0, count( $result[0]['query']['allpages'] ),
+ 'allpages list does not contain page Category:Template:xyz' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php b/www/wiki/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php
new file mode 100644
index 00000000..5b43dd1b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php
@@ -0,0 +1,976 @@
+<?php
+
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiQueryRecentChanges
+ */
+class ApiQueryRecentChangesIntegrationTest extends ApiTestCase {
+
+ public function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+
+ $this->tablesUsed[] = 'recentchanges';
+ $this->tablesUsed[] = 'page';
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ self::$users['ApiQueryRecentChangesIntegrationTestUser'] = $this->getMutableTestUser();
+ wfGetDB( DB_MASTER )->delete( 'recentchanges', '*', __METHOD__ );
+ }
+
+ private function getLoggedInTestUser() {
+ return self::$users['ApiQueryRecentChangesIntegrationTestUser']->getUser();
+ }
+
+ private function doPageEdit( User $user, LinkTarget $target, $summary ) {
+ static $i = 0;
+
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( __CLASS__ . $i++, $title ),
+ $summary,
+ 0,
+ false,
+ $user
+ );
+ }
+
+ private function doMinorPageEdit( User $user, LinkTarget $target, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( __CLASS__, $title ),
+ $summary,
+ EDIT_MINOR,
+ false,
+ $user
+ );
+ }
+
+ private function doBotPageEdit( User $user, LinkTarget $target, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( __CLASS__, $title ),
+ $summary,
+ EDIT_FORCE_BOT,
+ false,
+ $user
+ );
+ }
+
+ private function doAnonPageEdit( LinkTarget $target, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( __CLASS__, $title ),
+ $summary,
+ 0,
+ false,
+ User::newFromId( 0 )
+ );
+ }
+
+ private function deletePage( LinkTarget $target, $reason ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doDeleteArticleReal( $reason );
+ }
+
+ /**
+ * Performs a batch of page edits as a specified user
+ * @param User $user
+ * @param array $editData associative array, keys:
+ * - target => LinkTarget page to edit
+ * - summary => string edit summary
+ * - minorEdit => bool mark as minor edit if true (defaults to false)
+ * - botEdit => bool mark as bot edit if true (defaults to false)
+ */
+ private function doPageEdits( User $user, array $editData ) {
+ foreach ( $editData as $singleEditData ) {
+ if ( array_key_exists( 'minorEdit', $singleEditData ) && $singleEditData['minorEdit'] ) {
+ $this->doMinorPageEdit(
+ $user,
+ $singleEditData['target'],
+ $singleEditData['summary']
+ );
+ continue;
+ }
+ if ( array_key_exists( 'botEdit', $singleEditData ) && $singleEditData['botEdit'] ) {
+ $this->doBotPageEdit(
+ $user,
+ $singleEditData['target'],
+ $singleEditData['summary']
+ );
+ continue;
+ }
+ $this->doPageEdit(
+ $user,
+ $singleEditData['target'],
+ $singleEditData['summary']
+ );
+ }
+ }
+
+ private function doListRecentChangesRequest( array $params = [] ) {
+ return $this->doApiRequest(
+ array_merge(
+ [ 'action' => 'query', 'list' => 'recentchanges' ],
+ $params
+ ),
+ null,
+ false,
+ $this->getLoggedInTestUser()
+ );
+ }
+
+ private function doGeneratorRecentChangesRequest( array $params = [] ) {
+ return $this->doApiRequest(
+ array_merge(
+ [ 'action' => 'query', 'generator' => 'recentchanges' ],
+ $params
+ ),
+ null,
+ false,
+ $this->getLoggedInTestUser()
+ );
+ }
+
+ private function getItemsFromApiResponse( array $response ) {
+ return $response[0]['query']['recentchanges'];
+ }
+
+ private function getTitleFormatter() {
+ return new MediaWikiTitleCodec(
+ Language::factory( 'en' ),
+ MediaWikiServices::getInstance()->getGenderCache()
+ );
+ }
+
+ private function getPrefixedText( LinkTarget $target ) {
+ $formatter = $this->getTitleFormatter();
+ return $formatter->getPrefixedText( $target );
+ }
+
+ public function testListRecentChanges_returnsRCInfo() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest();
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'recentchanges', $result[0]['query'] );
+
+ $items = $this->getItemsFromApiResponse( $result );
+ $this->assertCount( 1, $items );
+ $item = $items[0];
+ $this->assertArraySubset(
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ $item
+ );
+ $this->assertArrayNotHasKey( 'bot', $item );
+ $this->assertArrayNotHasKey( 'new', $item );
+ $this->assertArrayNotHasKey( 'minor', $item );
+ $this->assertArrayHasKey( 'pageid', $item );
+ $this->assertArrayHasKey( 'revid', $item );
+ $this->assertArrayHasKey( 'old_revid', $item );
+ }
+
+ public function testIdsPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'ids', ] );
+ $items = $this->getItemsFromApiResponse( $result );
+
+ $this->assertCount( 1, $items );
+ $this->assertArrayHasKey( 'pageid', $items[0] );
+ $this->assertArrayHasKey( 'revid', $items[0] );
+ $this->assertArrayHasKey( 'old_revid', $items[0] );
+ $this->assertEquals( 'new', $items[0]['type'] );
+ }
+
+ public function testTitlePropParameter() {
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $subjectTarget,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'summary' => 'Create Talk page',
+ ],
+ ]
+ );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testFlagsPropParameter() {
+ $normalEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $minorEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPageM' );
+ $botEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPageB' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $normalEditTarget,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $minorEditTarget,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $minorEditTarget,
+ 'summary' => 'Change content',
+ 'minorEdit' => true,
+ ],
+ [
+ 'target' => $botEditTarget,
+ 'summary' => 'Create the page with a bot',
+ 'botEdit' => true,
+ ],
+ ]
+ );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'flags', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'new' => true,
+ 'minor' => false,
+ 'bot' => true,
+ ],
+ [
+ 'type' => 'edit',
+ 'new' => false,
+ 'minor' => true,
+ 'bot' => false,
+ ],
+ [
+ 'type' => 'new',
+ 'new' => true,
+ 'minor' => false,
+ 'bot' => false,
+ ],
+ [
+ 'type' => 'new',
+ 'new' => true,
+ 'minor' => false,
+ 'bot' => false,
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testUserPropParameter() {
+ $userEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $anonEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPageA' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $userEditTarget, 'Create the page' );
+ $this->doAnonPageEdit( $anonEditTarget, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'user', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'anon' => true,
+ 'user' => User::newFromId( 0 )->getName(),
+ ],
+ [
+ 'type' => 'new',
+ 'user' => $this->getLoggedInTestUser()->getName(),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testUserIdPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $userEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $anonEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPageA' );
+ $this->doPageEdit( $user, $userEditTarget, 'Create the page' );
+ $this->doAnonPageEdit( $anonEditTarget, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'userid', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'anon' => true,
+ 'userid' => 0,
+ ],
+ [
+ 'type' => 'new',
+ 'userid' => $user->getId(),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testCommentPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the <b>page</b>' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'comment', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'comment' => 'Create the <b>page</b>',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testParsedCommentPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the <b>page</b>' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'parsedcomment', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'parsedcomment' => 'Create the &lt;b&gt;page&lt;/b&gt;',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testTimestampPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'timestamp', ] );
+ $items = $this->getItemsFromApiResponse( $result );
+
+ $this->assertCount( 1, $items );
+ $this->assertArrayHasKey( 'timestamp', $items[0] );
+ $this->assertInternalType( 'string', $items[0]['timestamp'] );
+ }
+
+ public function testSizesPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'sizes', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'oldlen' => 0,
+ 'newlen' => 38,
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ private function createPageAndDeleteIt( LinkTarget $target ) {
+ $this->doPageEdit( $this->getLoggedInTestUser(),
+ $target,
+ 'Create the page that will be deleted'
+ );
+ $this->deletePage( $target, 'Important Reason' );
+ }
+
+ public function testLoginfoPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->createPageAndDeleteIt( $target );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'loginfo', ] );
+
+ $items = $this->getItemsFromApiResponse( $result );
+ $this->assertCount( 1, $items );
+ $this->assertArraySubset(
+ [
+ 'type' => 'log',
+ 'logtype' => 'delete',
+ 'logaction' => 'delete',
+ 'logparams' => [],
+ ],
+ $items[0]
+ );
+ $this->assertArrayHasKey( 'logid', $items[0] );
+ }
+
+ public function testEmptyPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $user, $target, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => '', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ ]
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testNamespaceParam() {
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $subjectTarget,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'summary' => 'Create the talk page',
+ ],
+ ]
+ );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcnamespace' => '0', ] );
+
+ $items = $this->getItemsFromApiResponse( $result );
+ $this->assertCount( 1, $items );
+ $this->assertArraySubset(
+ [
+ 'ns' => 0,
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ $items[0]
+ );
+ }
+
+ public function testShowAnonParams() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doAnonPageEdit( $target, 'Create the page' );
+
+ $resultAnon = $this->doListRecentChangesRequest( [
+ 'rcprop' => 'user',
+ 'rcshow' => WatchedItemQueryService::FILTER_ANON
+ ] );
+ $resultNotAnon = $this->doListRecentChangesRequest( [
+ 'rcprop' => 'user',
+ 'rcshow' => WatchedItemQueryService::FILTER_NOT_ANON
+ ] );
+
+ $items = $this->getItemsFromApiResponse( $resultAnon );
+ $this->assertCount( 1, $items );
+ $this->assertArraySubset( [ 'anon' => true ], $items[0] );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotAnon ) );
+ }
+
+ public function testNewAndEditTypeParameters() {
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $subjectTarget,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $subjectTarget,
+ 'summary' => 'Change the content',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'summary' => 'Create Talk page',
+ ],
+ ]
+ );
+
+ $resultNew = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'new' ] );
+ $resultEdit = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'edit' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultNew )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'edit',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultEdit )
+ );
+ }
+
+ public function testLogTypeParameters() {
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->createPageAndDeleteIt( $subjectTarget );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $talkTarget, 'Create Talk page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'log' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'log',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ private function getExternalRC( LinkTarget $target ) {
+ $title = Title::newFromLinkTarget( $target );
+
+ $rc = new RecentChange;
+ $rc->mTitle = $title;
+ $rc->mAttribs = [
+ 'rc_timestamp' => wfTimestamp( TS_MW ),
+ 'rc_namespace' => $title->getNamespace(),
+ 'rc_title' => $title->getDBkey(),
+ 'rc_type' => RC_EXTERNAL,
+ 'rc_source' => 'foo',
+ 'rc_minor' => 0,
+ 'rc_cur_id' => $title->getArticleID(),
+ 'rc_user' => 0,
+ 'rc_user_text' => 'm>External User',
+ 'rc_comment' => '',
+ 'rc_comment_text' => '',
+ 'rc_comment_data' => null,
+ 'rc_this_oldid' => $title->getLatestRevID(),
+ 'rc_last_oldid' => $title->getLatestRevID(),
+ 'rc_bot' => 0,
+ 'rc_ip' => '',
+ 'rc_patrolled' => 0,
+ 'rc_new' => 0,
+ 'rc_old_len' => $title->getLength(),
+ 'rc_new_len' => $title->getLength(),
+ 'rc_deleted' => 0,
+ 'rc_logid' => 0,
+ 'rc_log_type' => null,
+ 'rc_log_action' => '',
+ 'rc_params' => '',
+ ];
+ $rc->mExtra = [
+ 'prefixedDBkey' => $title->getPrefixedDBkey(),
+ 'lastTimestamp' => 0,
+ 'oldSize' => $title->getLength(),
+ 'newSize' => $title->getLength(),
+ 'pageStatus' => 'changed'
+ ];
+
+ return $rc;
+ }
+
+ public function testExternalTypeParameters() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $user, $subjectTarget, 'Create the page' );
+ $this->doPageEdit( $user, $talkTarget, 'Create Talk page' );
+
+ $rc = $this->getExternalRC( $subjectTarget );
+ $rc->save();
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'external' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'external',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testCategorizeTypeParameter() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $categoryTarget = new TitleValue( NS_CATEGORY, 'ApiQueryRecentChangesIntegrationTestCategory' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $categoryTarget,
+ 'summary' => 'Create the category',
+ ],
+ [
+ 'target' => $subjectTarget,
+ 'summary' => 'Create the page and add it to the category',
+ ],
+ ]
+ );
+ $title = Title::newFromLinkTarget( $subjectTarget );
+ $revision = Revision::newFromTitle( $title );
+
+ $rc = RecentChange::newForCategorization(
+ $revision->getTimestamp(),
+ Title::newFromLinkTarget( $categoryTarget ),
+ $user,
+ $revision->getComment(),
+ $title,
+ 0,
+ $revision->getId(),
+ null,
+ false
+ );
+ $rc->save();
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'categorize' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'categorize',
+ 'ns' => $categoryTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $categoryTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testLimitParam() {
+ $target1 = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $target2 = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $target3 = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage2' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $target1,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target2,
+ 'summary' => 'Create Talk page',
+ ],
+ [
+ 'target' => $target3,
+ 'summary' => 'Create the page',
+ ],
+ ]
+ );
+
+ $resultWithoutLimit = $this->doListRecentChangesRequest( [ 'rcprop' => 'title' ] );
+ $resultWithLimit = $this->doListRecentChangesRequest( [ 'rclimit' => 2, 'rcprop' => 'title' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target3->getNamespace(),
+ 'title' => $this->getPrefixedText( $target3 )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target2->getNamespace(),
+ 'title' => $this->getPrefixedText( $target2 )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target1->getNamespace(),
+ 'title' => $this->getPrefixedText( $target1 )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultWithoutLimit )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target3->getNamespace(),
+ 'title' => $this->getPrefixedText( $target3 )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target2->getNamespace(),
+ 'title' => $this->getPrefixedText( $target2 )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultWithLimit )
+ );
+ $this->assertArrayHasKey( 'continue', $resultWithLimit[0] );
+ $this->assertArrayHasKey( 'rccontinue', $resultWithLimit[0]['continue'] );
+ }
+
+ public function testAllRevParam() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $target,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target,
+ 'summary' => 'Change the content',
+ ],
+ ]
+ );
+
+ $resultAllRev = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rcallrev' => '', ] );
+ $resultNoAllRev = $this->doListRecentChangesRequest( [ 'rcprop' => 'title' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'edit',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultNoAllRev )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'edit',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultAllRev )
+ );
+ }
+
+ public function testDirParams() {
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $subjectTarget,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'summary' => 'Create Talk page',
+ ],
+ ]
+ );
+
+ $resultDirOlder = $this->doListRecentChangesRequest(
+ [ 'rcdir' => 'older', 'rcprop' => 'title' ]
+ );
+ $resultDirNewer = $this->doListRecentChangesRequest(
+ [ 'rcdir' => 'newer', 'rcprop' => 'title' ]
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultDirOlder )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultDirNewer )
+ );
+ }
+
+ public function testStartEndParams() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
+
+ $resultStart = $this->doListRecentChangesRequest( [
+ 'rcstart' => '20010115000000',
+ 'rcdir' => 'newer',
+ 'rcprop' => 'title',
+ ] );
+ $resultEnd = $this->doListRecentChangesRequest( [
+ 'rcend' => '20010115000000',
+ 'rcdir' => 'newer',
+ 'rcprop' => 'title',
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ]
+ ],
+ $this->getItemsFromApiResponse( $resultStart )
+ );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultEnd ) );
+ }
+
+ public function testContinueParam() {
+ $target1 = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $target2 = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $target3 = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage2' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $target1,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target2,
+ 'summary' => 'Create Talk page',
+ ],
+ [
+ 'target' => $target3,
+ 'summary' => 'Create the page',
+ ],
+ ]
+ );
+
+ $firstResult = $this->doListRecentChangesRequest( [ 'rclimit' => 2, 'rcprop' => 'title' ] );
+ $this->assertArrayHasKey( 'continue', $firstResult[0] );
+ $this->assertArrayHasKey( 'rccontinue', $firstResult[0]['continue'] );
+
+ $continuationParam = $firstResult[0]['continue']['rccontinue'];
+
+ $continuedResult = $this->doListRecentChangesRequest(
+ [ 'rccontinue' => $continuationParam, 'rcprop' => 'title' ]
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target3->getNamespace(),
+ 'title' => $this->getPrefixedText( $target3 ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target2->getNamespace(),
+ 'title' => $this->getPrefixedText( $target2 ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $firstResult )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target1->getNamespace(),
+ 'title' => $this->getPrefixedText( $target1 )
+ ]
+ ],
+ $this->getItemsFromApiResponse( $continuedResult )
+ );
+ }
+
+ public function testGeneratorRecentChangesPropInfo_returnsRCPages() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
+
+ $result = $this->doGeneratorRecentChangesRequest( [ 'prop' => 'info' ] );
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'pages', $result[0]['query'] );
+
+ // $result[0]['query']['pages'] uses page ids as keys. Page ids don't matter here, so drop them
+ $pages = array_values( $result[0]['query']['pages'] );
+
+ $this->assertCount( 1, $pages );
+ $this->assertArraySubset(
+ [
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ 'new' => true,
+ ],
+ $pages[0]
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php b/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php
new file mode 100644
index 00000000..5f59d6fb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php
@@ -0,0 +1,1608 @@
+<?php
+
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group medium
+ * @group API
+ * @group Database
+ *
+ * @covers ApiQueryWatchlist
+ */
+class ApiQueryWatchlistIntegrationTest extends ApiTestCase {
+
+ public function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+ $this->tablesUsed = array_unique(
+ array_merge( $this->tablesUsed, [ 'watchlist', 'recentchanges', 'page' ] )
+ );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+ self::$users['ApiQueryWatchlistIntegrationTestUser'] = $this->getMutableTestUser();
+ self::$users['ApiQueryWatchlistIntegrationTestUser2'] = $this->getMutableTestUser();
+ }
+
+ private function getLoggedInTestUser() {
+ return self::$users['ApiQueryWatchlistIntegrationTestUser']->getUser();
+ }
+
+ private function getNonLoggedInTestUser() {
+ return self::$users['ApiQueryWatchlistIntegrationTestUser2']->getUser();
+ }
+
+ private function doPageEdit( User $user, LinkTarget $target, $content, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( $content, $title ),
+ $summary,
+ 0,
+ false,
+ $user
+ );
+ }
+
+ private function doMinorPageEdit( User $user, LinkTarget $target, $content, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( $content, $title ),
+ $summary,
+ EDIT_MINOR,
+ false,
+ $user
+ );
+ }
+
+ private function doBotPageEdit( User $user, LinkTarget $target, $content, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( $content, $title ),
+ $summary,
+ EDIT_FORCE_BOT,
+ false,
+ $user
+ );
+ }
+
+ private function doAnonPageEdit( LinkTarget $target, $content, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( $content, $title ),
+ $summary,
+ 0,
+ false,
+ User::newFromId( 0 )
+ );
+ }
+
+ private function doPatrolledPageEdit(
+ User $user,
+ LinkTarget $target,
+ $content,
+ $summary,
+ User $patrollingUser
+ ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $status = $page->doEditContent(
+ ContentHandler::makeContent( $content, $title ),
+ $summary,
+ 0,
+ false,
+ $user
+ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+ $rc = $rev->getRecentChange();
+ $rc->doMarkPatrolled( $patrollingUser, false, [] );
+ }
+
+ private function deletePage( LinkTarget $target, $reason ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doDeleteArticleReal( $reason );
+ }
+
+ /**
+ * Performs a batch of page edits as a specified user
+ * @param User $user
+ * @param array $editData associative array, keys:
+ * - target => LinkTarget page to edit
+ * - content => string new content
+ * - summary => string edit summary
+ * - minorEdit => bool mark as minor edit if true (defaults to false)
+ * - botEdit => bool mark as bot edit if true (defaults to false)
+ */
+ private function doPageEdits( User $user, array $editData ) {
+ foreach ( $editData as $singleEditData ) {
+ if ( array_key_exists( 'minorEdit', $singleEditData ) && $singleEditData['minorEdit'] ) {
+ $this->doMinorPageEdit(
+ $user,
+ $singleEditData['target'],
+ $singleEditData['content'],
+ $singleEditData['summary']
+ );
+ continue;
+ }
+ if ( array_key_exists( 'botEdit', $singleEditData ) && $singleEditData['botEdit'] ) {
+ $this->doBotPageEdit(
+ $user,
+ $singleEditData['target'],
+ $singleEditData['content'],
+ $singleEditData['summary']
+ );
+ continue;
+ }
+ $this->doPageEdit(
+ $user,
+ $singleEditData['target'],
+ $singleEditData['content'],
+ $singleEditData['summary']
+ );
+ }
+ }
+
+ private function getWatchedItemStore() {
+ return MediaWikiServices::getInstance()->getWatchedItemStore();
+ }
+
+ /**
+ * @param User $user
+ * @param LinkTarget[] $targets
+ */
+ private function watchPages( User $user, array $targets ) {
+ $store = $this->getWatchedItemStore();
+ $store->addWatchBatchForUser( $user, $targets );
+ }
+
+ private function doListWatchlistRequest( array $params = [], $user = null ) {
+ if ( $user === null ) {
+ $user = $this->getLoggedInTestUser();
+ }
+ return $this->doApiRequest(
+ array_merge(
+ [ 'action' => 'query', 'list' => 'watchlist' ],
+ $params
+ ), null, false, $user
+ );
+ }
+
+ private function doGeneratorWatchlistRequest( array $params = [] ) {
+ return $this->doApiRequest(
+ array_merge(
+ [ 'action' => 'query', 'generator' => 'watchlist' ],
+ $params
+ ), null, false, $this->getLoggedInTestUser()
+ );
+ }
+
+ private function getItemsFromApiResponse( array $response ) {
+ return $response[0]['query']['watchlist'];
+ }
+
+ /**
+ * Convenience method to assert that actual items array fetched from API is equal to the expected
+ * array, Unlike assertEquals this only checks if values of specified keys are equal in both
+ * arrays. This could be used e.g. not to compare IDs that could change between test run
+ * but only stable keys.
+ * Optionally this also checks that specified keys are present in the actual item without
+ * performing any checks on the related values.
+ *
+ * @param array $actualItems array of actual items (associative arrays)
+ * @param array $expectedItems array of expected items (associative arrays),
+ * those items have less keys than actual items
+ * @param array $keysUsedInValueComparison list of keys of the actual item that will be used
+ * in the comparison of values
+ * @param array $requiredKeys optional, list of keys that must be present in the
+ * actual items. Values of those keys are not checked.
+ */
+ private function assertArraySubsetsEqual(
+ array $actualItems,
+ array $expectedItems,
+ array $keysUsedInValueComparison,
+ array $requiredKeys = []
+ ) {
+ $this->assertCount( count( $expectedItems ), $actualItems );
+
+ // not checking values of all keys of the actual item, so removing unwanted keys from comparison
+ $actualItemsOnlyComparedValues = array_map(
+ function ( array $item ) use ( $keysUsedInValueComparison ) {
+ return array_intersect_key( $item, array_flip( $keysUsedInValueComparison ) );
+ },
+ $actualItems
+ );
+
+ $this->assertEquals(
+ $expectedItems,
+ $actualItemsOnlyComparedValues
+ );
+
+ // Check that each item in $actualItems contains all of keys specified in $requiredKeys
+ $actualItemsKeysOnly = array_map( 'array_keys', $actualItems );
+ foreach ( $actualItemsKeysOnly as $keysOfTheItem ) {
+ $this->assertEmpty( array_diff( $requiredKeys, $keysOfTheItem ) );
+ }
+ }
+
+ private function getTitleFormatter() {
+ return new MediaWikiTitleCodec(
+ Language::factory( 'en' ),
+ MediaWikiServices::getInstance()->getGenderCache()
+ );
+ }
+
+ private function getPrefixedText( LinkTarget $target ) {
+ $formatter = $this->getTitleFormatter();
+ return $formatter->getPrefixedText( $target );
+ }
+
+ private function cleanTestUsersWatchlist() {
+ $user = $this->getLoggedInTestUser();
+ $store = $this->getWatchedItemStore();
+ $items = $store->getWatchedItemsForUser( $user );
+ foreach ( $items as $item ) {
+ $store->removeWatch( $user, $item->getLinkTarget() );
+ }
+ }
+
+ public function testListWatchlist_returnsWatchedItemsWithRCInfo() {
+ // Clean up after previous tests that might have added something to the watchlist of
+ // the user with the same user ID as user used here as the test user
+ $this->cleanTestUsersWatchlist();
+
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest();
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'watchlist', $result[0]['query'] );
+
+ $this->assertArraySubsetsEqual(
+ $this->getItemsFromApiResponse( $result ),
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ 'bot' => false,
+ 'new' => true,
+ 'minor' => false,
+ ]
+ ],
+ [ 'type', 'ns', 'title', 'bot', 'new', 'minor' ],
+ [ 'pageid', 'revid', 'old_revid' ]
+ );
+ }
+
+ public function testIdsPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'ids', ] );
+ $items = $this->getItemsFromApiResponse( $result );
+
+ $this->assertCount( 1, $items );
+ $this->assertArrayHasKey( 'pageid', $items[0] );
+ $this->assertArrayHasKey( 'revid', $items[0] );
+ $this->assertArrayHasKey( 'old_revid', $items[0] );
+ $this->assertEquals( 'new', $items[0]['type'] );
+ }
+
+ public function testTitlePropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $subjectTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'content' => 'Some Talk Page Content',
+ 'summary' => 'Create Talk page',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testFlagsPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $normalEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $minorEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageM' );
+ $botEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageB' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $normalEditTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $minorEditTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $minorEditTarget,
+ 'content' => 'Slightly Better Content',
+ 'summary' => 'Change content',
+ 'minorEdit' => true,
+ ],
+ [
+ 'target' => $botEditTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page with a bot',
+ 'botEdit' => true,
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $normalEditTarget, $minorEditTarget, $botEditTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'flags', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'new' => true,
+ 'minor' => false,
+ 'bot' => true,
+ ],
+ [
+ 'type' => 'edit',
+ 'new' => false,
+ 'minor' => true,
+ 'bot' => false,
+ ],
+ [
+ 'type' => 'new',
+ 'new' => true,
+ 'minor' => false,
+ 'bot' => false,
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testUserPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $userEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $anonEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageA' );
+ $this->doPageEdit(
+ $user,
+ $userEditTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->doAnonPageEdit(
+ $anonEditTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $userEditTarget, $anonEditTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'user', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'anon' => true,
+ 'user' => User::newFromId( 0 )->getName(),
+ ],
+ [
+ 'type' => 'new',
+ 'user' => $user->getName(),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testUserIdPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $userEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $anonEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageA' );
+ $this->doPageEdit(
+ $user,
+ $userEditTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->doAnonPageEdit(
+ $anonEditTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $userEditTarget, $anonEditTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'userid', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'anon' => true,
+ 'user' => 0,
+ 'userid' => 0,
+ ],
+ [
+ 'type' => 'new',
+ 'user' => $user->getId(),
+ 'userid' => $user->getId(),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testCommentPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the <b>page</b>'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'comment', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'comment' => 'Create the <b>page</b>',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testParsedCommentPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the <b>page</b>'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'parsedcomment', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'parsedcomment' => 'Create the &lt;b&gt;page&lt;/b&gt;',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testTimestampPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'timestamp', ] );
+ $items = $this->getItemsFromApiResponse( $result );
+
+ $this->assertCount( 1, $items );
+ $this->assertArrayHasKey( 'timestamp', $items[0] );
+ $this->assertInternalType( 'string', $items[0]['timestamp'] );
+ }
+
+ public function testSizesPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'sizes', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'oldlen' => 0,
+ 'newlen' => 12,
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testNotificationTimestampPropParameter() {
+ $otherUser = $this->getNonLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $otherUser,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $store = $this->getWatchedItemStore();
+ $store->addWatch( $this->getLoggedInTestUser(), $target );
+ $store->updateNotificationTimestamp(
+ $otherUser,
+ $target,
+ '20151212010101'
+ );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'notificationtimestamp', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'notificationtimestamp' => '2015-12-12T01:01:01Z',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ private function setupPatrolledSpecificFixtures( User $user ) {
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+
+ $this->doPatrolledPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page (this gets patrolled)',
+ $user
+ );
+
+ $this->watchPages( $user, [ $target ] );
+ }
+
+ public function testPatrolPropParameter() {
+ $testUser = static::getTestSysop();
+ $user = $testUser->getUser();
+ $this->setupPatrolledSpecificFixtures( $user );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'patrol', ], $user );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'patrolled' => true,
+ 'unpatrolled' => false,
+ 'autopatrolled' => false,
+ ]
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ private function createPageAndDeleteIt( LinkTarget $target ) {
+ $this->doPageEdit(
+ $this->getLoggedInTestUser(),
+ $target,
+ 'Some Content',
+ 'Create the page that will be deleted'
+ );
+ $this->deletePage( $target, 'Important Reason' );
+ }
+
+ public function testLoginfoPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->createPageAndDeleteIt( $target );
+
+ $this->watchPages( $this->getLoggedInTestUser(), [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'loginfo', ] );
+
+ $this->assertArraySubsetsEqual(
+ $this->getItemsFromApiResponse( $result ),
+ [
+ [
+ 'type' => 'log',
+ 'logtype' => 'delete',
+ 'logaction' => 'delete',
+ 'logparams' => [],
+ ],
+ ],
+ [ 'type', 'logtype', 'logaction', 'logparams' ],
+ [ 'logid' ]
+ );
+ }
+
+ public function testEmptyPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => '', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ ]
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testNamespaceParam() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $subjectTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the talk page',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlnamespace' => '0', ] );
+
+ $this->assertArraySubsetsEqual(
+ $this->getItemsFromApiResponse( $result ),
+ [
+ [
+ 'ns' => 0,
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ [ 'ns', 'title' ]
+ );
+ }
+
+ public function testUserParam() {
+ $user = $this->getLoggedInTestUser();
+ $otherUser = $this->getNonLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $subjectTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->doPageEdit(
+ $otherUser,
+ $talkTarget,
+ 'What is this page about?',
+ 'Create the talk page'
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $result = $this->doListWatchlistRequest( [
+ 'wlprop' => 'user|title',
+ 'wluser' => $otherUser->getName(),
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget ),
+ 'user' => $otherUser->getName(),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testExcludeUserParam() {
+ $user = $this->getLoggedInTestUser();
+ $otherUser = $this->getNonLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $subjectTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->doPageEdit(
+ $otherUser,
+ $talkTarget,
+ 'What is this page about?',
+ 'Create the talk page'
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $result = $this->doListWatchlistRequest( [
+ 'wlprop' => 'user|title',
+ 'wlexcludeuser' => $otherUser->getName(),
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ 'user' => $user->getName(),
+ ]
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testShowMinorParams() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $target,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target,
+ 'content' => 'Slightly Better Content',
+ 'summary' => 'Change content',
+ 'minorEdit' => true,
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $resultMinor = $this->doListWatchlistRequest( [
+ 'wlshow' => WatchedItemQueryService::FILTER_MINOR,
+ 'wlprop' => 'flags'
+ ] );
+ $resultNotMinor = $this->doListWatchlistRequest( [
+ 'wlshow' => WatchedItemQueryService::FILTER_NOT_MINOR, 'wlprop' => 'flags'
+ ] );
+
+ $this->assertArraySubsetsEqual(
+ $this->getItemsFromApiResponse( $resultMinor ),
+ [
+ [ 'minor' => true, ]
+ ],
+ [ 'minor' ]
+ );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotMinor ) );
+ }
+
+ public function testShowBotParams() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doBotPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $resultBot = $this->doListWatchlistRequest( [
+ 'wlshow' => WatchedItemQueryService::FILTER_BOT
+ ] );
+ $resultNotBot = $this->doListWatchlistRequest( [
+ 'wlshow' => WatchedItemQueryService::FILTER_NOT_BOT
+ ] );
+
+ $this->assertArraySubsetsEqual(
+ $this->getItemsFromApiResponse( $resultBot ),
+ [
+ [ 'bot' => true ],
+ ],
+ [ 'bot' ]
+ );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotBot ) );
+ }
+
+ public function testShowAnonParams() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doAnonPageEdit(
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $resultAnon = $this->doListWatchlistRequest( [
+ 'wlprop' => 'user',
+ 'wlshow' => WatchedItemQueryService::FILTER_ANON
+ ] );
+ $resultNotAnon = $this->doListWatchlistRequest( [
+ 'wlprop' => 'user',
+ 'wlshow' => WatchedItemQueryService::FILTER_NOT_ANON
+ ] );
+
+ $this->assertArraySubsetsEqual(
+ $this->getItemsFromApiResponse( $resultAnon ),
+ [
+ [ 'anon' => true ],
+ ],
+ [ 'anon' ]
+ );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotAnon ) );
+ }
+
+ public function testShowUnreadParams() {
+ $user = $this->getLoggedInTestUser();
+ $otherUser = $this->getNonLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $subjectTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->doPageEdit(
+ $otherUser,
+ $talkTarget,
+ 'Some Content',
+ 'Create the talk page'
+ );
+ $store = $this->getWatchedItemStore();
+ $store->addWatchBatchForUser( $user, [ $subjectTarget, $talkTarget ] );
+ $store->updateNotificationTimestamp(
+ $otherUser,
+ $talkTarget,
+ '20151212010101'
+ );
+
+ $resultUnread = $this->doListWatchlistRequest( [
+ 'wlprop' => 'notificationtimestamp|title',
+ 'wlshow' => WatchedItemQueryService::FILTER_UNREAD
+ ] );
+ $resultNotUnread = $this->doListWatchlistRequest( [
+ 'wlprop' => 'notificationtimestamp|title',
+ 'wlshow' => WatchedItemQueryService::FILTER_NOT_UNREAD
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'notificationtimestamp' => '2015-12-12T01:01:01Z',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget )
+ ]
+ ],
+ $this->getItemsFromApiResponse( $resultUnread )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'notificationtimestamp' => '',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget )
+ ]
+ ],
+ $this->getItemsFromApiResponse( $resultNotUnread )
+ );
+ }
+
+ public function testShowPatrolledParams() {
+ $user = static::getTestSysop()->getUser();
+ $this->setupPatrolledSpecificFixtures( $user );
+
+ $resultPatrolled = $this->doListWatchlistRequest( [
+ 'wlprop' => 'patrol',
+ 'wlshow' => WatchedItemQueryService::FILTER_PATROLLED
+ ], $user );
+ $resultNotPatrolled = $this->doListWatchlistRequest( [
+ 'wlprop' => 'patrol',
+ 'wlshow' => WatchedItemQueryService::FILTER_NOT_PATROLLED
+ ], $user );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'patrolled' => true,
+ 'unpatrolled' => false,
+ 'autopatrolled' => false,
+ ]
+ ],
+ $this->getItemsFromApiResponse( $resultPatrolled )
+ );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotPatrolled ) );
+ }
+
+ public function testNewAndEditTypeParameters() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $subjectTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $subjectTarget,
+ 'content' => 'Some Other Content',
+ 'summary' => 'Change the content',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'content' => 'Some Talk Page Content',
+ 'summary' => 'Create Talk page',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $resultNew = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'new' ] );
+ $resultEdit = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'edit' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultNew )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'edit',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultEdit )
+ );
+ }
+
+ public function testLogTypeParameters() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->createPageAndDeleteIt( $subjectTarget );
+ $this->doPageEdit(
+ $user,
+ $talkTarget,
+ 'Some Talk Page Content',
+ 'Create Talk page'
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'log' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'log',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ private function getExternalRC( LinkTarget $target ) {
+ $title = Title::newFromLinkTarget( $target );
+
+ $rc = new RecentChange;
+ $rc->mTitle = $title;
+ $rc->mAttribs = [
+ 'rc_timestamp' => wfTimestamp( TS_MW ),
+ 'rc_namespace' => $title->getNamespace(),
+ 'rc_title' => $title->getDBkey(),
+ 'rc_type' => RC_EXTERNAL,
+ 'rc_source' => 'foo',
+ 'rc_minor' => 0,
+ 'rc_cur_id' => $title->getArticleID(),
+ 'rc_user' => 0,
+ 'rc_user_text' => 'ext>External User',
+ 'rc_comment' => '',
+ 'rc_comment_text' => '',
+ 'rc_comment_data' => null,
+ 'rc_this_oldid' => $title->getLatestRevID(),
+ 'rc_last_oldid' => $title->getLatestRevID(),
+ 'rc_bot' => 0,
+ 'rc_ip' => '',
+ 'rc_patrolled' => 0,
+ 'rc_new' => 0,
+ 'rc_old_len' => $title->getLength(),
+ 'rc_new_len' => $title->getLength(),
+ 'rc_deleted' => 0,
+ 'rc_logid' => 0,
+ 'rc_log_type' => null,
+ 'rc_log_action' => '',
+ 'rc_params' => '',
+ ];
+ $rc->mExtra = [
+ 'prefixedDBkey' => $title->getPrefixedDBkey(),
+ 'lastTimestamp' => 0,
+ 'oldSize' => $title->getLength(),
+ 'newSize' => $title->getLength(),
+ 'pageStatus' => 'changed'
+ ];
+
+ return $rc;
+ }
+
+ public function testExternalTypeParameters() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $subjectTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->doPageEdit(
+ $user,
+ $talkTarget,
+ 'Some Talk Page Content',
+ 'Create Talk page'
+ );
+
+ $rc = $this->getExternalRC( $subjectTarget );
+ $rc->save();
+
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'external' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'external',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testCategorizeTypeParameter() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $categoryTarget = new TitleValue( NS_CATEGORY, 'ApiQueryWatchlistIntegrationTestCategory' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $categoryTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the category',
+ ],
+ [
+ 'target' => $subjectTarget,
+ 'content' => 'Some Content [[Category:ApiQueryWatchlistIntegrationTestCategory]]t',
+ 'summary' => 'Create the page and add it to the category',
+ ],
+ ]
+ );
+ $title = Title::newFromLinkTarget( $subjectTarget );
+ $revision = Revision::newFromTitle( $title );
+
+ $rc = RecentChange::newForCategorization(
+ $revision->getTimestamp(),
+ Title::newFromLinkTarget( $categoryTarget ),
+ $user,
+ $revision->getComment(),
+ $title,
+ 0,
+ $revision->getId(),
+ null,
+ false
+ );
+ $rc->save();
+
+ $this->watchPages( $user, [ $subjectTarget, $categoryTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'categorize' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'categorize',
+ 'ns' => $categoryTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $categoryTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testLimitParam() {
+ $user = $this->getLoggedInTestUser();
+ $target1 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $target2 = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $target3 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage2' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $target1,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target2,
+ 'content' => 'Some Talk Page Content',
+ 'summary' => 'Create Talk page',
+ ],
+ [
+ 'target' => $target3,
+ 'content' => 'Some Other Content',
+ 'summary' => 'Create the page',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $target1, $target2, $target3 ] );
+
+ $resultWithoutLimit = $this->doListWatchlistRequest( [ 'wlprop' => 'title' ] );
+ $resultWithLimit = $this->doListWatchlistRequest( [ 'wllimit' => 2, 'wlprop' => 'title' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target3->getNamespace(),
+ 'title' => $this->getPrefixedText( $target3 )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target2->getNamespace(),
+ 'title' => $this->getPrefixedText( $target2 )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target1->getNamespace(),
+ 'title' => $this->getPrefixedText( $target1 )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultWithoutLimit )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target3->getNamespace(),
+ 'title' => $this->getPrefixedText( $target3 )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target2->getNamespace(),
+ 'title' => $this->getPrefixedText( $target2 )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultWithLimit )
+ );
+ $this->assertArrayHasKey( 'continue', $resultWithLimit[0] );
+ $this->assertArrayHasKey( 'wlcontinue', $resultWithLimit[0]['continue'] );
+ }
+
+ public function testAllRevParam() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $target,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target,
+ 'content' => 'Some Other Content',
+ 'summary' => 'Change the content',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $resultAllRev = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wlallrev' => '', ] );
+ $resultNoAllRev = $this->doListWatchlistRequest( [ 'wlprop' => 'title' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'edit',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultNoAllRev )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'edit',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultAllRev )
+ );
+ }
+
+ public function testDirParams() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $subjectTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'content' => 'Some Talk Page Content',
+ 'summary' => 'Create Talk page',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $resultDirOlder = $this->doListWatchlistRequest( [ 'wldir' => 'older', 'wlprop' => 'title' ] );
+ $resultDirNewer = $this->doListWatchlistRequest( [ 'wldir' => 'newer', 'wlprop' => 'title' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultDirOlder )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultDirNewer )
+ );
+ }
+
+ public function testStartEndParams() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $resultStart = $this->doListWatchlistRequest( [
+ 'wlstart' => '20010115000000',
+ 'wldir' => 'newer',
+ 'wlprop' => 'title',
+ ] );
+ $resultEnd = $this->doListWatchlistRequest( [
+ 'wlend' => '20010115000000',
+ 'wldir' => 'newer',
+ 'wlprop' => 'title',
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ]
+ ],
+ $this->getItemsFromApiResponse( $resultStart )
+ );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultEnd ) );
+ }
+
+ public function testContinueParam() {
+ $user = $this->getLoggedInTestUser();
+ $target1 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $target2 = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $target3 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage2' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $target1,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target2,
+ 'content' => 'Some Talk Page Content',
+ 'summary' => 'Create Talk page',
+ ],
+ [
+ 'target' => $target3,
+ 'content' => 'Some Other Content',
+ 'summary' => 'Create the page',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $target1, $target2, $target3 ] );
+
+ $firstResult = $this->doListWatchlistRequest( [ 'wllimit' => 2, 'wlprop' => 'title' ] );
+ $this->assertArrayHasKey( 'continue', $firstResult[0] );
+ $this->assertArrayHasKey( 'wlcontinue', $firstResult[0]['continue'] );
+
+ $continuationParam = $firstResult[0]['continue']['wlcontinue'];
+
+ $continuedResult = $this->doListWatchlistRequest(
+ [ 'wlcontinue' => $continuationParam, 'wlprop' => 'title' ]
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target3->getNamespace(),
+ 'title' => $this->getPrefixedText( $target3 ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target2->getNamespace(),
+ 'title' => $this->getPrefixedText( $target2 ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $firstResult )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target1->getNamespace(),
+ 'title' => $this->getPrefixedText( $target1 )
+ ]
+ ],
+ $this->getItemsFromApiResponse( $continuedResult )
+ );
+ }
+
+ public function testOwnerAndTokenParams() {
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $this->getLoggedInTestUser(),
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+
+ $otherUser = $this->getNonLoggedInTestUser();
+ $otherUser->setOption( 'watchlisttoken', '1234567890' );
+ $otherUser->saveSettings();
+
+ $this->watchPages( $otherUser, [ $target ] );
+
+ $reloadedUser = User::newFromName( $otherUser->getName() );
+ $this->assertEquals( '1234567890', $reloadedUser->getOption( 'watchlisttoken' ) );
+
+ $result = $this->doListWatchlistRequest( [
+ 'wlowner' => $otherUser->getName(),
+ 'wltoken' => '1234567890',
+ 'wlprop' => 'title',
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target )
+ ]
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testOwnerAndTokenParams_wrongToken() {
+ $otherUser = $this->getNonLoggedInTestUser();
+ $otherUser->setOption( 'watchlisttoken', '1234567890' );
+ $otherUser->saveSettings();
+
+ $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' );
+
+ $this->doListWatchlistRequest( [
+ 'wlowner' => $otherUser->getName(),
+ 'wltoken' => 'wrong-token',
+ ] );
+ }
+
+ public function testOwnerAndTokenParams_noWatchlistTokenSet() {
+ $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' );
+
+ $this->doListWatchlistRequest( [
+ 'wlowner' => $this->getNonLoggedInTestUser()->getName(),
+ 'wltoken' => 'some-token',
+ ] );
+ }
+
+ public function testGeneratorWatchlistPropInfo_returnsWatchedPages() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doGeneratorWatchlistRequest( [ 'prop' => 'info' ] );
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'pages', $result[0]['query'] );
+
+ // $result[0]['query']['pages'] uses page ids as keys. Page ids don't matter here, so drop them
+ $pages = array_values( $result[0]['query']['pages'] );
+
+ $this->assertArraySubsetsEqual(
+ $pages,
+ [
+ [
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ 'new' => true,
+ ]
+ ],
+ [ 'ns', 'title', 'new' ]
+ );
+ }
+
+ public function testGeneratorWatchlistPropRevisions_returnsWatchedItemsRevisions() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $target,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target,
+ 'content' => 'Some Other Content',
+ 'summary' => 'Change the content',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doGeneratorWatchlistRequest( [ 'prop' => 'revisions', 'gwlallrev' => '' ] );
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'pages', $result[0]['query'] );
+
+ // $result[0]['query']['pages'] uses page ids as keys. Page ids don't matter here, so drop them
+ $pages = array_values( $result[0]['query']['pages'] );
+
+ $this->assertCount( 1, $pages );
+ $this->assertEquals( 0, $pages[0]['ns'] );
+ $this->assertEquals( $this->getPrefixedText( $target ), $pages[0]['title'] );
+ $this->assertArraySubsetsEqual(
+ $pages[0]['revisions'],
+ [
+ [
+ 'comment' => 'Create the page',
+ 'user' => $user->getName(),
+ 'minor' => false,
+ ],
+ [
+ 'comment' => 'Change the content',
+ 'user' => $user->getName(),
+ 'minor' => false,
+ ],
+ ],
+ [ 'comment', 'user', 'minor' ]
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php b/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php
new file mode 100644
index 00000000..2af63c49
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php
@@ -0,0 +1,542 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiQueryWatchlistRaw
+ */
+class ApiQueryWatchlistRawIntegrationTest extends ApiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ self::$users['ApiQueryWatchlistRawIntegrationTestUser']
+ = $this->getMutableTestUser();
+ self::$users['ApiQueryWatchlistRawIntegrationTestUser2']
+ = $this->getMutableTestUser();
+ }
+
+ private function getLoggedInTestUser() {
+ return self::$users['ApiQueryWatchlistRawIntegrationTestUser']->getUser();
+ }
+
+ private function getNotLoggedInTestUser() {
+ return self::$users['ApiQueryWatchlistRawIntegrationTestUser2']->getUser();
+ }
+
+ private function getWatchedItemStore() {
+ return MediaWikiServices::getInstance()->getWatchedItemStore();
+ }
+
+ private function doListWatchlistRawRequest( array $params = [] ) {
+ return $this->doApiRequest( array_merge(
+ [ 'action' => 'query', 'list' => 'watchlistraw' ],
+ $params
+ ), null, false, $this->getLoggedInTestUser() );
+ }
+
+ private function doGeneratorWatchlistRawRequest( array $params = [] ) {
+ return $this->doApiRequest( array_merge(
+ [ 'action' => 'query', 'generator' => 'watchlistraw' ],
+ $params
+ ), null, false, $this->getLoggedInTestUser() );
+ }
+
+ private function getItemsFromApiResponse( array $response ) {
+ return $response[0]['watchlistraw'];
+ }
+
+ public function testListWatchlistRaw_returnsWatchedItems() {
+ $store = $this->getWatchedItemStore();
+ $store->addWatch(
+ $this->getLoggedInTestUser(),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' )
+ );
+
+ $result = $this->doListWatchlistRawRequest();
+
+ $this->assertArrayHasKey( 'watchlistraw', $result[0] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testPropChanged_addsNotificationTimestamp() {
+ $target = new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' );
+ $otherUser = $this->getNotLoggedInTestUser();
+
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatch( $this->getLoggedInTestUser(), $target );
+ $store->updateNotificationTimestamp(
+ $otherUser,
+ $target,
+ '20151212010101'
+ );
+
+ $result = $this->doListWatchlistRawRequest( [ 'wrprop' => 'changed' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage',
+ 'changed' => '2015-12-12T01:01:01Z',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testNamespaceParam() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' ),
+ new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage' ),
+ ] );
+
+ $result = $this->doListWatchlistRawRequest( [ 'wrnamespace' => '0' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testShowChangedParams() {
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage' );
+ $otherUser = $this->getNotLoggedInTestUser();
+
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ $subjectTarget,
+ $talkTarget,
+ ] );
+ $store->updateNotificationTimestamp(
+ $otherUser,
+ $subjectTarget,
+ '20151212010101'
+ );
+
+ $resultChanged = $this->doListWatchlistRawRequest(
+ [ 'wrprop' => 'changed', 'wrshow' => WatchedItemQueryService::FILTER_CHANGED ]
+ );
+ $resultNotChanged = $this->doListWatchlistRawRequest(
+ [ 'wrprop' => 'changed', 'wrshow' => WatchedItemQueryService::FILTER_NOT_CHANGED ]
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage',
+ 'changed' => '2015-12-12T01:01:01Z',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultChanged )
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 1,
+ 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultNotChanged )
+ );
+ }
+
+ public function testLimitParam() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ ] );
+
+ $resultWithoutLimit = $this->doListWatchlistRawRequest();
+ $resultWithLimit = $this->doListWatchlistRawRequest( [ 'wrlimit' => 2 ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ [
+ 'ns' => 1,
+ 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultWithoutLimit )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultWithLimit )
+ );
+
+ $this->assertArrayNotHasKey( 'continue', $resultWithoutLimit[0] );
+ $this->assertArrayHasKey( 'continue', $resultWithLimit[0] );
+ $this->assertArrayHasKey( 'wrcontinue', $resultWithLimit[0]['continue'] );
+ }
+
+ public function testDirParams() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ ] );
+
+ $resultDirAsc = $this->doListWatchlistRawRequest( [ 'wrdir' => 'ascending' ] );
+ $resultDirDesc = $this->doListWatchlistRawRequest( [ 'wrdir' => 'descending' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ [
+ 'ns' => 1,
+ 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultDirAsc )
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 1,
+ 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultDirDesc )
+ );
+ }
+
+ public function testAscendingIsDefaultOrder() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ ] );
+
+ $resultNoDir = $this->doListWatchlistRawRequest();
+ $resultAscDir = $this->doListWatchlistRawRequest( [ 'wrdir' => 'ascending' ] );
+
+ $this->assertEquals(
+ $this->getItemsFromApiResponse( $resultNoDir ),
+ $this->getItemsFromApiResponse( $resultAscDir )
+ );
+ }
+
+ public function testFromTitleParam() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ),
+ ] );
+
+ $result = $this->doListWatchlistRawRequest( [
+ 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testToTitleParam() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ),
+ ] );
+
+ $result = $this->doListWatchlistRawRequest( [
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testContinueParam() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ),
+ ] );
+
+ $firstResult = $this->doListWatchlistRawRequest( [ 'wrlimit' => 2 ] );
+ $continuationParam = $firstResult[0]['continue']['wrcontinue'];
+
+ $this->assertEquals( '0|ApiQueryWatchlistRawIntegrationTestPage3', $continuationParam );
+
+ $continuedResult = $this->doListWatchlistRawRequest( [ 'wrcontinue' => $continuationParam ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3',
+ ]
+ ],
+ $this->getItemsFromApiResponse( $continuedResult )
+ );
+ }
+
+ public function fromTitleToTitleContinueComboProvider() {
+ return [
+ [
+ [
+ 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ [
+ [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1' ],
+ [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2' ],
+ ],
+ ],
+ [
+ [
+ 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage3',
+ ],
+ [
+ [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ],
+ ],
+ ],
+ [
+ [
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage3',
+ 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ [
+ [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2' ],
+ [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ],
+ ],
+ ],
+ [
+ [
+ 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage3',
+ 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage3',
+ ],
+ [
+ [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider fromTitleToTitleContinueComboProvider
+ */
+ public function testFromTitleToTitleContinueCombo( array $params, array $expectedItems ) {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ),
+ ] );
+
+ $result = $this->doListWatchlistRawRequest( $params );
+
+ $this->assertEquals( $expectedItems, $this->getItemsFromApiResponse( $result ) );
+ }
+
+ public function fromTitleToTitleContinueSelfContradictoryComboProvider() {
+ return [
+ [
+ [
+ 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ]
+ ],
+ [
+ [
+ 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ 'wrdir' => 'descending',
+ ]
+ ],
+ [
+ [
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage2',
+ ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider fromTitleToTitleContinueSelfContradictoryComboProvider
+ */
+ public function testFromTitleToTitleContinueSelfContradictoryCombo( array $params ) {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ ] );
+
+ $result = $this->doListWatchlistRawRequest( $params );
+
+ $this->assertEmpty( $this->getItemsFromApiResponse( $result ) );
+ $this->assertArrayNotHasKey( 'continue', $result[0] );
+ }
+
+ public function testOwnerAndTokenParams() {
+ $otherUser = $this->getNotLoggedInTestUser();
+ $otherUser->setOption( 'watchlisttoken', '1234567890' );
+ $otherUser->saveSettings();
+
+ $store = $this->getWatchedItemStore();
+ $store->addWatchBatchForUser( $otherUser, [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ ] );
+
+ ObjectCache::getMainWANInstance()->clearProcessCache();
+ $result = $this->doListWatchlistRawRequest( [
+ 'wrowner' => $otherUser->getName(),
+ 'wrtoken' => '1234567890',
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ [
+ 'ns' => 1,
+ 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testOwnerAndTokenParams_wrongToken() {
+ $otherUser = $this->getNotLoggedInTestUser();
+ $otherUser->setOption( 'watchlisttoken', '1234567890' );
+ $otherUser->saveSettings();
+
+ $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' );
+
+ $this->doListWatchlistRawRequest( [
+ 'wrowner' => $otherUser->getName(),
+ 'wrtoken' => 'wrong-token',
+ ] );
+ }
+
+ public function testOwnerAndTokenParams_userHasNoWatchlistToken() {
+ $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' );
+
+ $this->doListWatchlistRawRequest( [
+ 'wrowner' => $this->getNotLoggedInTestUser()->getName(),
+ 'wrtoken' => 'some-watchlist-token',
+ ] );
+ }
+
+ public function testGeneratorWatchlistRawPropInfo_returnsWatchedItems() {
+ $store = $this->getWatchedItemStore();
+ $store->addWatch(
+ $this->getLoggedInTestUser(),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' )
+ );
+
+ $result = $this->doGeneratorWatchlistRawRequest( [ 'prop' => 'info' ] );
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'pages', $result[0]['query'] );
+ $this->assertCount( 1, $result[0]['query']['pages'] );
+
+ // $result[0]['query']['pages'] uses page ids as keys
+ $item = array_values( $result[0]['query']['pages'] )[0];
+
+ $this->assertEquals( 0, $item['ns'] );
+ $this->assertEquals( 'ApiQueryWatchlistRawIntegrationTestPage', $item['title'] );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiResultTest.php b/www/wiki/tests/phpunit/includes/api/ApiResultTest.php
new file mode 100644
index 00000000..98e24fb6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiResultTest.php
@@ -0,0 +1,1410 @@
+<?php
+
+/**
+ * @covers ApiResult
+ * @group API
+ */
+class ApiResultTest extends MediaWikiTestCase {
+
+ /**
+ * @covers ApiResult
+ */
+ public function testStaticDataMethods() {
+ $arr = [];
+
+ ApiResult::setValue( $arr, 'setValue', '1' );
+
+ ApiResult::setValue( $arr, null, 'unnamed 1' );
+ ApiResult::setValue( $arr, null, 'unnamed 2' );
+
+ ApiResult::setValue( $arr, 'deleteValue', '2' );
+ ApiResult::unsetValue( $arr, 'deleteValue' );
+
+ ApiResult::setContentValue( $arr, 'setContentValue', '3' );
+
+ $this->assertSame( [
+ 'setValue' => '1',
+ 'unnamed 1',
+ 'unnamed 2',
+ ApiResult::META_CONTENT => 'setContentValue',
+ 'setContentValue' => '3',
+ ], $arr );
+
+ try {
+ ApiResult::setValue( $arr, 'setValue', '99' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame(
+ 'Attempting to add element setValue=99, existing value is 1',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ try {
+ ApiResult::setContentValue( $arr, 'setContentValue2', '99' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame(
+ 'Attempting to set content element as setContentValue2 when setContentValue ' .
+ 'is already set as the content element',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ ApiResult::setValue( $arr, 'setValue', '99', ApiResult::OVERRIDE );
+ $this->assertSame( '99', $arr['setValue'] );
+
+ ApiResult::setContentValue( $arr, 'setContentValue2', '99', ApiResult::OVERRIDE );
+ $this->assertSame( 'setContentValue2', $arr[ApiResult::META_CONTENT] );
+
+ $arr = [ 'foo' => 1, 'bar' => 1 ];
+ ApiResult::setValue( $arr, 'top', '2', ApiResult::ADD_ON_TOP );
+ ApiResult::setValue( $arr, null, '2', ApiResult::ADD_ON_TOP );
+ ApiResult::setValue( $arr, 'bottom', '2' );
+ ApiResult::setValue( $arr, 'foo', '2', ApiResult::OVERRIDE );
+ ApiResult::setValue( $arr, 'bar', '2', ApiResult::OVERRIDE | ApiResult::ADD_ON_TOP );
+ $this->assertSame( [ 0, 'top', 'foo', 'bar', 'bottom' ], array_keys( $arr ) );
+
+ $arr = [];
+ ApiResult::setValue( $arr, 'sub', [ 'foo' => 1 ] );
+ ApiResult::setValue( $arr, 'sub', [ 'bar' => 1 ] );
+ $this->assertSame( [ 'sub' => [ 'foo' => 1, 'bar' => 1 ] ], $arr );
+
+ try {
+ ApiResult::setValue( $arr, 'sub', [ 'foo' => 2, 'baz' => 2 ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame(
+ 'Conflicting keys (foo) when attempting to merge element sub',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $arr = [];
+ $title = Title::newFromText( "MediaWiki:Foobar" );
+ $obj = new stdClass;
+ $obj->foo = 1;
+ $obj->bar = 2;
+ ApiResult::setValue( $arr, 'title', $title );
+ ApiResult::setValue( $arr, 'obj', $obj );
+ $this->assertSame( [
+ 'title' => (string)$title,
+ 'obj' => [ 'foo' => 1, 'bar' => 2, ApiResult::META_TYPE => 'assoc' ],
+ ], $arr );
+
+ $fh = tmpfile();
+ try {
+ ApiResult::setValue( $arr, 'file', $fh );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ ApiResult::setValue( $arr, null, $fh );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $obj->file = $fh;
+ ApiResult::setValue( $arr, 'sub', $obj );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $obj->file = $fh;
+ ApiResult::setValue( $arr, null, $obj );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ fclose( $fh );
+
+ try {
+ ApiResult::setValue( $arr, 'inf', INF );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ ApiResult::setValue( $arr, null, INF );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ ApiResult::setValue( $arr, 'nan', NAN );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ ApiResult::setValue( $arr, null, NAN );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ ApiResult::setValue( $arr, null, NAN, ApiResult::NO_VALIDATE );
+
+ try {
+ ApiResult::setValue( $arr, null, NAN, ApiResult::NO_SIZE_CHECK );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $arr = [];
+ $result2 = new ApiResult( 8388608 );
+ $result2->addValue( null, 'foo', 'bar' );
+ ApiResult::setValue( $arr, 'baz', $result2 );
+ $this->assertSame( [
+ 'baz' => [
+ ApiResult::META_TYPE => 'assoc',
+ 'foo' => 'bar',
+ ]
+ ], $arr );
+
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', "foo\x80bar" );
+ ApiResult::setValue( $arr, 'bar', "a\xcc\x81" );
+ ApiResult::setValue( $arr, 'baz', 74 );
+ ApiResult::setValue( $arr, null, "foo\x80bar" );
+ ApiResult::setValue( $arr, null, "a\xcc\x81" );
+ $this->assertSame( [
+ 'foo' => "foo\xef\xbf\xbdbar",
+ 'bar' => "\xc3\xa1",
+ 'baz' => 74,
+ 0 => "foo\xef\xbf\xbdbar",
+ 1 => "\xc3\xa1",
+ ], $arr );
+
+ $obj = new stdClass;
+ $obj->{'1'} = 'one';
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', $obj );
+ $this->assertSame( [
+ 'foo' => [
+ 1 => 'one',
+ ApiResult::META_TYPE => 'assoc',
+ ]
+ ], $arr );
+ }
+
+ /**
+ * @covers ApiResult
+ */
+ public function testInstanceDataMethods() {
+ $result = new ApiResult( 8388608 );
+
+ $result->addValue( null, 'setValue', '1' );
+
+ $result->addValue( null, null, 'unnamed 1' );
+ $result->addValue( null, null, 'unnamed 2' );
+
+ $result->addValue( null, 'deleteValue', '2' );
+ $result->removeValue( null, 'deleteValue' );
+
+ $result->addValue( [ 'a', 'b' ], 'deleteValue', '3' );
+ $result->removeValue( [ 'a', 'b', 'deleteValue' ], null, '3' );
+
+ $result->addContentValue( null, 'setContentValue', '3' );
+
+ $this->assertSame( [
+ 'setValue' => '1',
+ 'unnamed 1',
+ 'unnamed 2',
+ 'a' => [ 'b' => [] ],
+ 'setContentValue' => '3',
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_CONTENT => 'setContentValue',
+ ], $result->getResultData() );
+ $this->assertSame( 20, $result->getSize() );
+
+ try {
+ $result->addValue( null, 'setValue', '99' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame(
+ 'Attempting to add element setValue=99, existing value is 1',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ try {
+ $result->addContentValue( null, 'setContentValue2', '99' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame(
+ 'Attempting to set content element as setContentValue2 when setContentValue ' .
+ 'is already set as the content element',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $result->addValue( null, 'setValue', '99', ApiResult::OVERRIDE );
+ $this->assertSame( '99', $result->getResultData( [ 'setValue' ] ) );
+
+ $result->addContentValue( null, 'setContentValue2', '99', ApiResult::OVERRIDE );
+ $this->assertSame( 'setContentValue2',
+ $result->getResultData( [ ApiResult::META_CONTENT ] ) );
+
+ $result->reset();
+ $this->assertSame( [
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+ $this->assertSame( 0, $result->getSize() );
+
+ $result->addValue( null, 'foo', 1 );
+ $result->addValue( null, 'bar', 1 );
+ $result->addValue( null, 'top', '2', ApiResult::ADD_ON_TOP );
+ $result->addValue( null, null, '2', ApiResult::ADD_ON_TOP );
+ $result->addValue( null, 'bottom', '2' );
+ $result->addValue( null, 'foo', '2', ApiResult::OVERRIDE );
+ $result->addValue( null, 'bar', '2', ApiResult::OVERRIDE | ApiResult::ADD_ON_TOP );
+ $this->assertSame( [ 0, 'top', 'foo', 'bar', 'bottom', ApiResult::META_TYPE ],
+ array_keys( $result->getResultData() ) );
+
+ $result->reset();
+ $result->addValue( null, 'foo', [ 'bar' => 1 ] );
+ $result->addValue( [ 'foo', 'top' ], 'x', 2, ApiResult::ADD_ON_TOP );
+ $result->addValue( [ 'foo', 'bottom' ], 'x', 2 );
+ $this->assertSame( [ 'top', 'bar', 'bottom' ],
+ array_keys( $result->getResultData( [ 'foo' ] ) ) );
+
+ $result->reset();
+ $result->addValue( null, 'sub', [ 'foo' => 1 ] );
+ $result->addValue( null, 'sub', [ 'bar' => 1 ] );
+ $this->assertSame( [
+ 'sub' => [ 'foo' => 1, 'bar' => 1 ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+
+ try {
+ $result->addValue( null, 'sub', [ 'foo' => 2, 'baz' => 2 ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame(
+ 'Conflicting keys (foo) when attempting to merge element sub',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $result->reset();
+ $title = Title::newFromText( "MediaWiki:Foobar" );
+ $obj = new stdClass;
+ $obj->foo = 1;
+ $obj->bar = 2;
+ $result->addValue( null, 'title', $title );
+ $result->addValue( null, 'obj', $obj );
+ $this->assertSame( [
+ 'title' => (string)$title,
+ 'obj' => [ 'foo' => 1, 'bar' => 2, ApiResult::META_TYPE => 'assoc' ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+
+ $fh = tmpfile();
+ try {
+ $result->addValue( null, 'file', $fh );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $result->addValue( null, null, $fh );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $obj->file = $fh;
+ $result->addValue( null, 'sub', $obj );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $obj->file = $fh;
+ $result->addValue( null, null, $obj );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ fclose( $fh );
+
+ try {
+ $result->addValue( null, 'inf', INF );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $result->addValue( null, null, INF );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $result->addValue( null, 'nan', NAN );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $result->addValue( null, null, NAN );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $result->addValue( null, null, NAN, ApiResult::NO_VALIDATE );
+
+ try {
+ $result->addValue( null, null, NAN, ApiResult::NO_SIZE_CHECK );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $result->reset();
+ $result->addParsedLimit( 'foo', 12 );
+ $this->assertSame( [
+ 'limits' => [ 'foo' => 12 ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+ $result->addParsedLimit( 'foo', 13 );
+ $this->assertSame( [
+ 'limits' => [ 'foo' => 13 ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+ $this->assertSame( null, $result->getResultData( [ 'foo', 'bar', 'baz' ] ) );
+ $this->assertSame( 13, $result->getResultData( [ 'limits', 'foo' ] ) );
+ try {
+ $result->getResultData( [ 'limits', 'foo', 'bar' ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Path limits.foo is not an array',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ // Add two values and some metadata, but ensure metadata is not counted
+ $result = new ApiResult( 100 );
+ $obj = [ 'attr' => '12345' ];
+ ApiResult::setContentValue( $obj, 'content', '1234567890' );
+ $this->assertTrue( $result->addValue( null, 'foo', $obj ) );
+ $this->assertSame( 15, $result->getSize() );
+
+ $result = new ApiResult( 10 );
+ $formatter = new ApiErrorFormatter( $result, Language::factory( 'en' ), 'none', false );
+ $result->setErrorFormatter( $formatter );
+ $this->assertFalse( $result->addValue( null, 'foo', '12345678901' ) );
+ $this->assertTrue( $result->addValue( null, 'foo', '12345678901', ApiResult::NO_SIZE_CHECK ) );
+ $this->assertSame( 0, $result->getSize() );
+ $result->reset();
+ $this->assertTrue( $result->addValue( null, 'foo', '1234567890' ) );
+ $this->assertFalse( $result->addValue( null, 'foo', '1' ) );
+ $result->removeValue( null, 'foo' );
+ $this->assertTrue( $result->addValue( null, 'foo', '1' ) );
+
+ $result = new ApiResult( 10 );
+ $obj = new ApiResultTestSerializableObject( 'ok' );
+ $obj->foobar = 'foobaz';
+ $this->assertTrue( $result->addValue( null, 'foo', $obj ) );
+ $this->assertSame( 2, $result->getSize() );
+
+ $result = new ApiResult( 8388608 );
+ $result2 = new ApiResult( 8388608 );
+ $result2->addValue( null, 'foo', 'bar' );
+ $result->addValue( null, 'baz', $result2 );
+ $this->assertSame( [
+ 'baz' => [
+ 'foo' => 'bar',
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+
+ $result = new ApiResult( 8388608 );
+ $result->addValue( null, 'foo', "foo\x80bar" );
+ $result->addValue( null, 'bar', "a\xcc\x81" );
+ $result->addValue( null, 'baz', 74 );
+ $result->addValue( null, null, "foo\x80bar" );
+ $result->addValue( null, null, "a\xcc\x81" );
+ $this->assertSame( [
+ 'foo' => "foo\xef\xbf\xbdbar",
+ 'bar' => "\xc3\xa1",
+ 'baz' => 74,
+ 0 => "foo\xef\xbf\xbdbar",
+ 1 => "\xc3\xa1",
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+
+ $result = new ApiResult( 8388608 );
+ $obj = new stdClass;
+ $obj->{'1'} = 'one';
+ $arr = [];
+ $result->addValue( $arr, 'foo', $obj );
+ $this->assertSame( [
+ 'foo' => [
+ 1 => 'one',
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+ }
+
+ /**
+ * @covers ApiResult
+ */
+ public function testMetadata() {
+ $arr = [ 'foo' => [ 'bar' => [] ] ];
+ $result = new ApiResult( 8388608 );
+ $result->addValue( null, 'foo', [ 'bar' => [] ] );
+
+ $expect = [
+ 'foo' => [
+ 'bar' => [
+ ApiResult::META_INDEXED_TAG_NAME => 'ritn',
+ ApiResult::META_TYPE => 'default',
+ ],
+ ApiResult::META_INDEXED_TAG_NAME => 'ritn',
+ ApiResult::META_TYPE => 'default',
+ ],
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar' ],
+ ApiResult::META_TYPE => 'array',
+ ];
+
+ ApiResult::setSubelementsList( $arr, 'foo' );
+ ApiResult::setSubelementsList( $arr, [ 'bar', 'baz' ] );
+ ApiResult::unsetSubelementsList( $arr, 'baz' );
+ ApiResult::setIndexedTagNameRecursive( $arr, 'ritn' );
+ ApiResult::setIndexedTagName( $arr, 'itn' );
+ ApiResult::setPreserveKeysList( $arr, 'foo' );
+ ApiResult::setPreserveKeysList( $arr, [ 'bar', 'baz' ] );
+ ApiResult::unsetPreserveKeysList( $arr, 'baz' );
+ ApiResult::setArrayTypeRecursive( $arr, 'default' );
+ ApiResult::setArrayType( $arr, 'array' );
+ $this->assertSame( $expect, $arr );
+
+ $result->addSubelementsList( null, 'foo' );
+ $result->addSubelementsList( null, [ 'bar', 'baz' ] );
+ $result->removeSubelementsList( null, 'baz' );
+ $result->addIndexedTagNameRecursive( null, 'ritn' );
+ $result->addIndexedTagName( null, 'itn' );
+ $result->addPreserveKeysList( null, 'foo' );
+ $result->addPreserveKeysList( null, [ 'bar', 'baz' ] );
+ $result->removePreserveKeysList( null, 'baz' );
+ $result->addArrayTypeRecursive( null, 'default' );
+ $result->addArrayType( null, 'array' );
+ $this->assertEquals( $expect, $result->getResultData() );
+
+ $arr = [ 'foo' => [ 'bar' => [] ] ];
+ $expect = [
+ 'foo' => [
+ 'bar' => [
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'bc',
+ ];
+ ApiResult::setArrayTypeRecursive( $arr, 'kvp', 'key' );
+ ApiResult::setArrayType( $arr, 'BCkvp', 'bc' );
+ $this->assertSame( $expect, $arr );
+ }
+
+ /**
+ * @covers ApiResult
+ */
+ public function testUtilityFunctions() {
+ $arr = [
+ 'foo' => [
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'bar2' => (object)[ '_dummy' => 'foobaz' ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ 'foo2' => (object)[
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'bar2' => (object)[ '_dummy' => 'foobaz' ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
+ ApiResult::META_TYPE => 'array',
+ '_dummy' => 'foobaz',
+ '_dummy2' => 'foobaz!',
+ ];
+ $this->assertEquals( [
+ 'foo' => [
+ 'bar' => [],
+ 'bar2' => (object)[],
+ 'x' => 'ok',
+ ],
+ 'foo2' => (object)[
+ 'bar' => [],
+ 'bar2' => (object)[],
+ 'x' => 'ok',
+ ],
+ '_dummy2' => 'foobaz!',
+ ], ApiResult::stripMetadata( $arr ), 'ApiResult::stripMetadata' );
+
+ $metadata = [];
+ $data = ApiResult::stripMetadataNonRecursive( $arr, $metadata );
+ $this->assertEquals( [
+ 'foo' => [
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'bar2' => (object)[ '_dummy' => 'foobaz' ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ 'foo2' => (object)[
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'bar2' => (object)[ '_dummy' => 'foobaz' ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ '_dummy2' => 'foobaz!',
+ ], $data, 'ApiResult::stripMetadataNonRecursive ($data)' );
+ $this->assertEquals( [
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
+ ApiResult::META_TYPE => 'array',
+ '_dummy' => 'foobaz',
+ ], $metadata, 'ApiResult::stripMetadataNonRecursive ($metadata)' );
+
+ $metadata = null;
+ $data = ApiResult::stripMetadataNonRecursive( (object)$arr, $metadata );
+ $this->assertEquals( (object)[
+ 'foo' => [
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'bar2' => (object)[ '_dummy' => 'foobaz' ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ 'foo2' => (object)[
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'bar2' => (object)[ '_dummy' => 'foobaz' ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ '_dummy2' => 'foobaz!',
+ ], $data, 'ApiResult::stripMetadataNonRecursive on object ($data)' );
+ $this->assertEquals( [
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
+ ApiResult::META_TYPE => 'array',
+ '_dummy' => 'foobaz',
+ ], $metadata, 'ApiResult::stripMetadataNonRecursive on object ($metadata)' );
+ }
+
+ /**
+ * @covers ApiResult
+ * @dataProvider provideTransformations
+ * @param string $label
+ * @param array $input
+ * @param array $transforms
+ * @param array|Exception $expect
+ */
+ public function testTransformations( $label, $input, $transforms, $expect ) {
+ $result = new ApiResult( false );
+ $result->addValue( null, 'test', $input );
+
+ if ( $expect instanceof Exception ) {
+ try {
+ $output = $result->getResultData( 'test', $transforms );
+ $this->fail( 'Expected exception not thrown', $label );
+ } catch ( Exception $ex ) {
+ $this->assertEquals( $ex, $expect, $label );
+ }
+ } else {
+ $output = $result->getResultData( 'test', $transforms );
+ $this->assertEquals( $expect, $output, $label );
+ }
+ }
+
+ public function provideTransformations() {
+ $kvp = function ( $keyKey, $key, $valKey, $value ) {
+ return [
+ $keyKey => $key,
+ $valKey => $value,
+ ApiResult::META_PRESERVE_KEYS => [ $keyKey ],
+ ApiResult::META_CONTENT => $valKey,
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ };
+ $typeArr = [
+ 'defaultArray' => [ 2 => 'a', 0 => 'b', 1 => 'c' ],
+ 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c' ],
+ 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c' ],
+ 'array' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'array' ],
+ 'BCarray' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'BCarray' ],
+ 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'BCassoc' ],
+ 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'kvp' => [ 'x' => 'a', 'y' => 'b', 'z' => [ 'c' ], ApiResult::META_TYPE => 'kvp' ],
+ 'BCkvp' => [ 'x' => 'a', 'y' => 'b',
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ 'kvpmerge' => [ 'x' => 'a', 'y' => [ 'b' ], 'z' => [ 'c' => 'd' ],
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_MERGE => true,
+ ],
+ 'emptyDefault' => [ '_dummy' => 1 ],
+ 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
+ '_dummy' => 1,
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
+ ];
+ $stripArr = [
+ 'foo' => [
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'baz' => [
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
+ ApiResult::META_TYPE => 'array',
+ ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
+ ApiResult::META_TYPE => 'array',
+ '_dummy' => 'foobaz',
+ '_dummy2' => 'foobaz!',
+ ];
+
+ return [
+ [
+ 'BC: META_BC_BOOLS',
+ [
+ 'BCtrue' => true,
+ 'BCfalse' => false,
+ 'true' => true,
+ 'false' => false,
+ ApiResult::META_BC_BOOLS => [ 0, 'true', 'false' ],
+ ],
+ [ 'BC' => [] ],
+ [
+ 'BCtrue' => '',
+ 'true' => true,
+ 'false' => false,
+ ApiResult::META_BC_BOOLS => [ 0, 'true', 'false' ],
+ ]
+ ],
+ [
+ 'BC: META_BC_SUBELEMENTS',
+ [
+ 'bc' => 'foo',
+ 'nobc' => 'bar',
+ ApiResult::META_BC_SUBELEMENTS => [ 'bc' ],
+ ],
+ [ 'BC' => [] ],
+ [
+ 'bc' => [
+ '*' => 'foo',
+ ApiResult::META_CONTENT => '*',
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ 'nobc' => 'bar',
+ ApiResult::META_BC_SUBELEMENTS => [ 'bc' ],
+ ],
+ ],
+ [
+ 'BC: META_CONTENT',
+ [
+ 'content' => '!!!',
+ ApiResult::META_CONTENT => 'content',
+ ],
+ [ 'BC' => [] ],
+ [
+ '*' => '!!!',
+ ApiResult::META_CONTENT => '*',
+ ],
+ ],
+ [
+ 'BC: BCkvp type',
+ [
+ 'foo' => 'foo value',
+ 'bar' => 'bar value',
+ '_baz' => 'baz value',
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ '_baz' ],
+ ],
+ [ 'BC' => [] ],
+ [
+ $kvp( 'key', 'foo', '*', 'foo value' ),
+ $kvp( 'key', 'bar', '*', 'bar value' ),
+ $kvp( 'key', '_baz', '*', 'baz value' ),
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ '_baz' ],
+ ],
+ ],
+ [
+ 'BC: BCarray type',
+ [
+ ApiResult::META_TYPE => 'BCarray',
+ ],
+ [ 'BC' => [] ],
+ [
+ ApiResult::META_TYPE => 'default',
+ ],
+ ],
+ [
+ 'BC: BCassoc type',
+ [
+ ApiResult::META_TYPE => 'BCassoc',
+ ],
+ [ 'BC' => [] ],
+ [
+ ApiResult::META_TYPE => 'default',
+ ],
+ ],
+ [
+ 'BC: BCkvp exception',
+ [
+ ApiResult::META_TYPE => 'BCkvp',
+ ],
+ [ 'BC' => [] ],
+ new UnexpectedValueException(
+ 'Type "BCkvp" used without setting ApiResult::META_KVP_KEY_NAME metadata item'
+ ),
+ ],
+ [
+ 'BC: nobool, no*, nosub',
+ [
+ 'true' => true,
+ 'false' => false,
+ 'content' => 'content',
+ ApiResult::META_CONTENT => 'content',
+ 'bc' => 'foo',
+ ApiResult::META_BC_SUBELEMENTS => [ 'bc' ],
+ 'BCarray' => [ ApiResult::META_TYPE => 'BCarray' ],
+ 'BCassoc' => [ ApiResult::META_TYPE => 'BCassoc' ],
+ 'BCkvp' => [
+ 'foo' => 'foo value',
+ 'bar' => 'bar value',
+ '_baz' => 'baz value',
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ '_baz' ],
+ ],
+ ],
+ [ 'BC' => [ 'nobool', 'no*', 'nosub' ] ],
+ [
+ 'true' => true,
+ 'false' => false,
+ 'content' => 'content',
+ 'bc' => 'foo',
+ 'BCarray' => [ ApiResult::META_TYPE => 'default' ],
+ 'BCassoc' => [ ApiResult::META_TYPE => 'default' ],
+ 'BCkvp' => [
+ $kvp( 'key', 'foo', '*', 'foo value' ),
+ $kvp( 'key', 'bar', '*', 'bar value' ),
+ $kvp( 'key', '_baz', '*', 'baz value' ),
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ '_baz' ],
+ ],
+ ApiResult::META_CONTENT => 'content',
+ ApiResult::META_BC_SUBELEMENTS => [ 'bc' ],
+ ],
+ ],
+
+ [
+ 'Types: Normal transform',
+ $typeArr,
+ [ 'Types' => [] ],
+ [
+ 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
+ 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'kvp' => [ 'x' => 'a', 'y' => 'b',
+ 'z' => [ 'c', ApiResult::META_TYPE => 'array' ],
+ ApiResult::META_TYPE => 'assoc'
+ ],
+ 'BCkvp' => [ 'x' => 'a', 'y' => 'b',
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ 'kvpmerge' => [
+ 'x' => 'a',
+ 'y' => [ 'b', ApiResult::META_TYPE => 'array' ],
+ 'z' => [ 'c' => 'd', ApiResult::META_TYPE => 'assoc' ],
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_KVP_MERGE => true,
+ ],
+ 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
+ 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
+ '_dummy' => 1,
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ],
+ [
+ 'Types: AssocAsObject',
+ $typeArr,
+ [ 'Types' => [ 'AssocAsObject' => true ] ],
+ (object)[
+ 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
+ 'defaultAssoc' => (object)[ 'x' => 'a',
+ 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc'
+ ],
+ 'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b',
+ 0 => 'c', ApiResult::META_TYPE => 'assoc'
+ ],
+ 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCassoc' => (object)[ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'kvp' => (object)[ 'x' => 'a', 'y' => 'b',
+ 'z' => [ 'c', ApiResult::META_TYPE => 'array' ],
+ ApiResult::META_TYPE => 'assoc'
+ ],
+ 'BCkvp' => (object)[ 'x' => 'a', 'y' => 'b',
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ 'kvpmerge' => (object)[
+ 'x' => 'a',
+ 'y' => [ 'b', ApiResult::META_TYPE => 'array' ],
+ 'z' => (object)[ 'c' => 'd', ApiResult::META_TYPE => 'assoc' ],
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_KVP_MERGE => true,
+ ],
+ 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
+ 'emptyAssoc' => (object)[ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
+ '_dummy' => 1,
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ],
+ [
+ 'Types: ArmorKVP',
+ $typeArr,
+ [ 'Types' => [ 'ArmorKVP' => 'name' ] ],
+ [
+ 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
+ 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'kvp' => [
+ $kvp( 'name', 'x', 'value', 'a' ),
+ $kvp( 'name', 'y', 'value', 'b' ),
+ $kvp( 'name', 'z', 'value', [ 'c', ApiResult::META_TYPE => 'array' ] ),
+ ApiResult::META_TYPE => 'array'
+ ],
+ 'BCkvp' => [
+ $kvp( 'key', 'x', 'value', 'a' ),
+ $kvp( 'key', 'y', 'value', 'b' ),
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ 'kvpmerge' => [
+ $kvp( 'name', 'x', 'value', 'a' ),
+ $kvp( 'name', 'y', 'value', [ 'b', ApiResult::META_TYPE => 'array' ] ),
+ [
+ 'name' => 'z',
+ 'c' => 'd',
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_PRESERVE_KEYS => [ 'name' ]
+ ],
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_MERGE => true,
+ ],
+ 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
+ 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
+ '_dummy' => 1,
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ],
+ [
+ 'Types: ArmorKVP + BC',
+ $typeArr,
+ [ 'BC' => [], 'Types' => [ 'ArmorKVP' => 'name' ] ],
+ [
+ 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
+ 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCarray' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'array' ],
+ 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'kvp' => [
+ $kvp( 'name', 'x', '*', 'a' ),
+ $kvp( 'name', 'y', '*', 'b' ),
+ $kvp( 'name', 'z', '*', [ 'c', ApiResult::META_TYPE => 'array' ] ),
+ ApiResult::META_TYPE => 'array'
+ ],
+ 'BCkvp' => [
+ $kvp( 'key', 'x', '*', 'a' ),
+ $kvp( 'key', 'y', '*', 'b' ),
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ 'kvpmerge' => [
+ $kvp( 'name', 'x', '*', 'a' ),
+ $kvp( 'name', 'y', '*', [ 'b', ApiResult::META_TYPE => 'array' ] ),
+ [
+ 'name' => 'z',
+ 'c' => 'd',
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_PRESERVE_KEYS => [ 'name' ] ],
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_MERGE => true,
+ ],
+ 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
+ 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
+ '_dummy' => 1,
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ],
+ [
+ 'Types: ArmorKVP + AssocAsObject',
+ $typeArr,
+ [ 'Types' => [ 'ArmorKVP' => 'name', 'AssocAsObject' => true ] ],
+ (object)[
+ 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
+ 'defaultAssoc' => (object)[ 'x' => 'a', 1 => 'b',
+ 0 => 'c', ApiResult::META_TYPE => 'assoc'
+ ],
+ 'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b',
+ 0 => 'c', ApiResult::META_TYPE => 'assoc'
+ ],
+ 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCassoc' => (object)[ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'kvp' => [
+ (object)$kvp( 'name', 'x', 'value', 'a' ),
+ (object)$kvp( 'name', 'y', 'value', 'b' ),
+ (object)$kvp( 'name', 'z', 'value', [ 'c', ApiResult::META_TYPE => 'array' ] ),
+ ApiResult::META_TYPE => 'array'
+ ],
+ 'BCkvp' => [
+ (object)$kvp( 'key', 'x', 'value', 'a' ),
+ (object)$kvp( 'key', 'y', 'value', 'b' ),
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ 'kvpmerge' => [
+ (object)$kvp( 'name', 'x', 'value', 'a' ),
+ (object)$kvp( 'name', 'y', 'value', [ 'b', ApiResult::META_TYPE => 'array' ] ),
+ (object)[
+ 'name' => 'z',
+ 'c' => 'd',
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_PRESERVE_KEYS => [ 'name' ]
+ ],
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_MERGE => true,
+ ],
+ 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
+ 'emptyAssoc' => (object)[ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
+ '_dummy' => 1,
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ],
+ [
+ 'Types: BCkvp exception',
+ [
+ ApiResult::META_TYPE => 'BCkvp',
+ ],
+ [ 'Types' => [] ],
+ new UnexpectedValueException(
+ 'Type "BCkvp" used without setting ApiResult::META_KVP_KEY_NAME metadata item'
+ ),
+ ],
+
+ [
+ 'Strip: With ArmorKVP + AssocAsObject transforms',
+ $typeArr,
+ [ 'Types' => [ 'ArmorKVP' => 'name', 'AssocAsObject' => true ], 'Strip' => 'all' ],
+ (object)[
+ 'defaultArray' => [ 'b', 'c', 'a' ],
+ 'defaultAssoc' => (object)[ 'x' => 'a', 1 => 'b', 0 => 'c' ],
+ 'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b', 0 => 'c' ],
+ 'array' => [ 'a', 'c', 'b' ],
+ 'BCarray' => [ 'a', 'c', 'b' ],
+ 'BCassoc' => (object)[ 'a', 'b', 'c' ],
+ 'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c' ],
+ 'kvp' => [
+ (object)[ 'name' => 'x', 'value' => 'a' ],
+ (object)[ 'name' => 'y', 'value' => 'b' ],
+ (object)[ 'name' => 'z', 'value' => [ 'c' ] ],
+ ],
+ 'BCkvp' => [
+ (object)[ 'key' => 'x', 'value' => 'a' ],
+ (object)[ 'key' => 'y', 'value' => 'b' ],
+ ],
+ 'kvpmerge' => [
+ (object)[ 'name' => 'x', 'value' => 'a' ],
+ (object)[ 'name' => 'y', 'value' => [ 'b' ] ],
+ (object)[ 'name' => 'z', 'c' => 'd' ],
+ ],
+ 'emptyDefault' => [],
+ 'emptyAssoc' => (object)[],
+ '_dummy' => 1,
+ ],
+ ],
+
+ [
+ 'Strip: all',
+ $stripArr,
+ [ 'Strip' => 'all' ],
+ [
+ 'foo' => [
+ 'bar' => [],
+ 'baz' => [],
+ 'x' => 'ok',
+ ],
+ '_dummy2' => 'foobaz!',
+ ],
+ ],
+ [
+ 'Strip: base',
+ $stripArr,
+ [ 'Strip' => 'base' ],
+ [
+ 'foo' => [
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'baz' => [
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
+ ApiResult::META_TYPE => 'array',
+ ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ '_dummy2' => 'foobaz!',
+ ],
+ ],
+ [
+ 'Strip: bc',
+ $stripArr,
+ [ 'Strip' => 'bc' ],
+ [
+ 'foo' => [
+ 'bar' => [],
+ 'baz' => [
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ],
+ 'x' => 'ok',
+ ],
+ '_dummy2' => 'foobaz!',
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ],
+ ],
+
+ [
+ 'Custom transform',
+ [
+ 'foo' => '?',
+ 'bar' => '?',
+ '_dummy' => '?',
+ '_dummy2' => '?',
+ '_dummy3' => '?',
+ ApiResult::META_CONTENT => 'foo',
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy2', '_dummy3' ],
+ ],
+ [
+ 'Custom' => [ $this, 'customTransform' ],
+ 'BC' => [],
+ 'Types' => [],
+ 'Strip' => 'all'
+ ],
+ [
+ '*' => 'FOO',
+ 'bar' => 'BAR',
+ 'baz' => [ 'a', 'b' ],
+ '_dummy2' => '_DUMMY2',
+ '_dummy3' => '_DUMMY3',
+ ApiResult::META_CONTENT => 'bar',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Custom transformer for testTransformations
+ * @param array &$data
+ * @param array &$metadata
+ */
+ public function customTransform( &$data, &$metadata ) {
+ // Prevent recursion
+ if ( isset( $metadata['_added'] ) ) {
+ $metadata[ApiResult::META_TYPE] = 'array';
+ return;
+ }
+
+ foreach ( $data as $k => $v ) {
+ $data[$k] = strtoupper( $k );
+ }
+ $data['baz'] = [ '_added' => 1, 'z' => 'b', 'y' => 'a' ];
+ $metadata[ApiResult::META_PRESERVE_KEYS][0] = '_dummy';
+ $data[ApiResult::META_CONTENT] = 'bar';
+ }
+
+ /**
+ * @covers ApiResult
+ */
+ public function testAddMetadataToResultVars() {
+ $arr = [
+ 'a' => "foo",
+ 'b' => false,
+ 'c' => 10,
+ 'sequential_numeric_keys' => [ 'a', 'b', 'c' ],
+ 'non_sequential_numeric_keys' => [ 'a', 'b', 4 => 'c' ],
+ 'string_keys' => [
+ 'one' => 1,
+ 'two' => 2
+ ],
+ 'object_sequential_keys' => (object)[ 'a', 'b', 'c' ],
+ '_type' => "should be overwritten in result",
+ ];
+ $this->assertSame( [
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [
+ 'a', 'b', 'c',
+ 'sequential_numeric_keys', 'non_sequential_numeric_keys',
+ 'string_keys', 'object_sequential_keys'
+ ],
+ ApiResult::META_BC_BOOLS => [ 'b' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'var',
+ 'a' => "foo",
+ 'b' => false,
+ 'c' => 10,
+ 'sequential_numeric_keys' => [
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_BC_BOOLS => [],
+ ApiResult::META_INDEXED_TAG_NAME => 'value',
+ 0 => 'a',
+ 1 => 'b',
+ 2 => 'c',
+ ],
+ 'non_sequential_numeric_keys' => [
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ 0, 1, 4 ],
+ ApiResult::META_BC_BOOLS => [],
+ ApiResult::META_INDEXED_TAG_NAME => 'var',
+ 0 => 'a',
+ 1 => 'b',
+ 4 => 'c',
+ ],
+ 'string_keys' => [
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ 'one', 'two' ],
+ ApiResult::META_BC_BOOLS => [],
+ ApiResult::META_INDEXED_TAG_NAME => 'var',
+ 'one' => 1,
+ 'two' => 2,
+ ],
+ 'object_sequential_keys' => [
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ 0, 1, 2 ],
+ ApiResult::META_BC_BOOLS => [],
+ ApiResult::META_INDEXED_TAG_NAME => 'var',
+ 0 => 'a',
+ 1 => 'b',
+ 2 => 'c',
+ ],
+ ], ApiResult::addMetadataToResultVars( $arr ) );
+ }
+
+ public function testObjectSerialization() {
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', (object)[ 'a' => 1, 'b' => 2 ] );
+ $this->assertSame( [
+ 'a' => 1,
+ 'b' => 2,
+ ApiResult::META_TYPE => 'assoc',
+ ], $arr['foo'] );
+
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', new ApiResultTestStringifiableObject() );
+ $this->assertSame( 'Ok', $arr['foo'] );
+
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( 'Ok' ) );
+ $this->assertSame( 'Ok', $arr['foo'] );
+
+ try {
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject(
+ new ApiResultTestStringifiableObject()
+ ) );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'ApiResultTestSerializableObject::serializeForApiResult() ' .
+ 'returned an object of class ApiResultTestStringifiableObject',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ try {
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( NAN ) );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'ApiResultTestSerializableObject::serializeForApiResult() ' .
+ 'returned an invalid value: Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject(
+ [
+ 'one' => new ApiResultTestStringifiableObject( '1' ),
+ 'two' => new ApiResultTestSerializableObject( 2 ),
+ ]
+ ) );
+ $this->assertSame( [
+ 'one' => '1',
+ 'two' => 2,
+ ], $arr['foo'] );
+ }
+}
+
+class ApiResultTestStringifiableObject {
+ private $ret;
+
+ public function __construct( $ret = 'Ok' ) {
+ $this->ret = $ret;
+ }
+
+ public function __toString() {
+ return $this->ret;
+ }
+}
+
+class ApiResultTestSerializableObject {
+ private $ret;
+
+ public function __construct( $ret ) {
+ $this->ret = $ret;
+ }
+
+ public function __toString() {
+ return "Fail";
+ }
+
+ public function serializeForApiResult() {
+ return $this->ret;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiRevisionDeleteTest.php b/www/wiki/tests/phpunit/includes/api/ApiRevisionDeleteTest.php
new file mode 100644
index 00000000..a4ca8a10
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiRevisionDeleteTest.php
@@ -0,0 +1,117 @@
+<?php
+
+/**
+ * Tests for action=revisiondelete
+ * @covers APIRevisionDelete
+ * @group API
+ * @group medium
+ * @group Database
+ */
+class ApiRevisionDeleteTest extends ApiTestCase {
+
+ public static $page = 'Help:ApiRevDel_test';
+ public $revs = [];
+
+ protected function setUp() {
+ // Needs to be before setup since this gets cached
+ $this->mergeMwGlobalArrayValue(
+ 'wgGroupPermissions',
+ [ 'sysop' => [ 'deleterevision' => true ] ]
+ );
+ parent::setUp();
+ // Make a few edits for us to play with
+ for ( $i = 1; $i <= 5; $i++ ) {
+ self::editPage( self::$page, MWCryptRand::generateHex( 10 ), 'summary' );
+ $this->revs[] = Title::newFromText( self::$page )
+ ->getLatestRevID( Title::GAID_FOR_UPDATE );
+ }
+ }
+
+ public function testHidingRevisions() {
+ $user = self::$users['sysop']->getUser();
+ $revid = array_shift( $this->revs );
+ $out = $this->doApiRequest( [
+ 'action' => 'revisiondelete',
+ 'type' => 'revision',
+ 'target' => self::$page,
+ 'ids' => $revid,
+ 'hide' => 'content|user|comment',
+ 'token' => $user->getEditToken(),
+ ] );
+ // Check the output
+ $out = $out[0]['revisiondelete'];
+ $this->assertEquals( $out['status'], 'Success' );
+ $this->assertArrayHasKey( 'items', $out );
+ $item = $out['items'][0];
+ $this->assertTrue( $item['userhidden'], 'userhidden' );
+ $this->assertTrue( $item['commenthidden'], 'commenthidden' );
+ $this->assertTrue( $item['texthidden'], 'texthidden' );
+ $this->assertEquals( $item['id'], $revid );
+
+ // Now check that that revision was actually hidden
+ $rev = Revision::newFromId( $revid );
+ $this->assertEquals( $rev->getContent( Revision::FOR_PUBLIC ), null );
+ $this->assertEquals( $rev->getComment( Revision::FOR_PUBLIC ), '' );
+ $this->assertEquals( $rev->getUser( Revision::FOR_PUBLIC ), 0 );
+
+ // Now test unhiding!
+ $out2 = $this->doApiRequest( [
+ 'action' => 'revisiondelete',
+ 'type' => 'revision',
+ 'target' => self::$page,
+ 'ids' => $revid,
+ 'show' => 'content|user|comment',
+ 'token' => $user->getEditToken(),
+ ] );
+
+ // Check the output
+ $out2 = $out2[0]['revisiondelete'];
+ $this->assertEquals( $out2['status'], 'Success' );
+ $this->assertArrayHasKey( 'items', $out2 );
+ $item = $out2['items'][0];
+
+ $this->assertFalse( $item['userhidden'], 'userhidden' );
+ $this->assertFalse( $item['commenthidden'], 'commenthidden' );
+ $this->assertFalse( $item['texthidden'], 'texthidden' );
+
+ $this->assertEquals( $item['id'], $revid );
+
+ $rev = Revision::newFromId( $revid );
+ $this->assertNotEquals( $rev->getContent( Revision::FOR_PUBLIC ), null );
+ $this->assertNotEquals( $rev->getComment( Revision::FOR_PUBLIC ), '' );
+ $this->assertNotEquals( $rev->getUser( Revision::FOR_PUBLIC ), 0 );
+ }
+
+ public function testUnhidingOutput() {
+ $user = self::$users['sysop']->getUser();
+ $revid = array_shift( $this->revs );
+ // Hide revisions
+ $this->doApiRequest( [
+ 'action' => 'revisiondelete',
+ 'type' => 'revision',
+ 'target' => self::$page,
+ 'ids' => $revid,
+ 'hide' => 'content|user|comment',
+ 'token' => $user->getEditToken(),
+ ] );
+
+ $out = $this->doApiRequest( [
+ 'action' => 'revisiondelete',
+ 'type' => 'revision',
+ 'target' => self::$page,
+ 'ids' => $revid,
+ 'show' => 'comment',
+ 'token' => $user->getEditToken(),
+ ] );
+ $out = $out[0]['revisiondelete'];
+ $this->assertEquals( $out['status'], 'Success' );
+ $this->assertArrayHasKey( 'items', $out );
+ $item = $out['items'][0];
+ // Check it has userhidden & texthidden
+ // but not commenthidden
+ $this->assertTrue( $item['userhidden'], 'userhidden' );
+ $this->assertFalse( $item['commenthidden'], 'commenthidden' );
+ $this->assertTrue( $item['texthidden'], 'texthidden' );
+ $this->assertEquals( $item['id'], $revid );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php b/www/wiki/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php
new file mode 100644
index 00000000..dacd48f6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php
@@ -0,0 +1,51 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @author Addshore
+ * @covers ApiSetNotificationTimestamp
+ * @group API
+ * @group medium
+ * @group Database
+ */
+class ApiSetNotificationTimestampIntegrationTest extends ApiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ self::$users[__CLASS__] = new TestUser( __CLASS__ );
+ }
+
+ public function testStuff() {
+ $user = self::$users[__CLASS__]->getUser();
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+
+ $user->addWatch( $page->getTitle() );
+
+ $result = $this->doApiRequestWithToken(
+ [
+ 'action' => 'setnotificationtimestamp',
+ 'timestamp' => '20160101020202',
+ 'pageids' => $page->getId(),
+ ],
+ null,
+ $user
+ );
+
+ $this->assertEquals(
+ [
+ 'batchcomplete' => true,
+ 'setnotificationtimestamp' => [
+ [ 'ns' => 0, 'title' => 'UTPage', 'notificationtimestamp' => '2016-01-01T02:02:02Z' ]
+ ],
+ ],
+ $result[0]
+ );
+
+ $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $this->assertEquals(
+ $watchedItemStore->getNotificationTimestampsBatch( $user, [ $page->getTitle() ] ),
+ [ [ 'UTPage' => '20160101020202' ] ]
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiStashEditTest.php b/www/wiki/tests/phpunit/includes/api/ApiStashEditTest.php
new file mode 100644
index 00000000..60cda090
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiStashEditTest.php
@@ -0,0 +1,27 @@
+<?php
+
+/**
+ * @covers ApiStashEdit
+ * @group API
+ * @group medium
+ * @group Database
+ */
+class ApiStashEditTest extends ApiTestCase {
+
+ public function testBasicEdit() {
+ $apiResult = $this->doApiRequestWithToken(
+ [
+ 'action' => 'stashedit',
+ 'title' => 'ApistashEdit_Page',
+ 'contentmodel' => 'wikitext',
+ 'contentformat' => 'text/x-wiki',
+ 'text' => 'Text for ' . __METHOD__ . ' page',
+ 'baserevid' => 0,
+ ]
+ );
+ $apiResult = $apiResult[0];
+ $this->assertArrayHasKey( 'stashedit', $apiResult );
+ $this->assertEquals( 'stashed', $apiResult['stashedit']['status'] );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiTestCase.php b/www/wiki/tests/phpunit/includes/api/ApiTestCase.php
new file mode 100644
index 00000000..974e9a2d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiTestCase.php
@@ -0,0 +1,260 @@
+<?php
+
+use MediaWiki\Session\SessionManager;
+
+abstract class ApiTestCase extends MediaWikiLangTestCase {
+ protected static $apiUrl;
+
+ protected static $errorFormatter = null;
+
+ /**
+ * @var ApiTestContext
+ */
+ protected $apiContext;
+
+ protected function setUp() {
+ global $wgServer;
+
+ parent::setUp();
+ self::$apiUrl = $wgServer . wfScript( 'api' );
+
+ ApiQueryInfo::resetTokenCache(); // tokens are invalid because we cleared the session
+
+ self::$users = [
+ 'sysop' => static::getTestSysop(),
+ 'uploader' => static::getTestUser(),
+ ];
+
+ $this->setMwGlobals( [
+ 'wgAuth' => new MediaWiki\Auth\AuthManagerAuthPlugin,
+ 'wgRequest' => new FauxRequest( [] ),
+ 'wgUser' => self::$users['sysop']->getUser(),
+ ] );
+
+ $this->apiContext = new ApiTestContext();
+ }
+
+ protected function tearDown() {
+ // Avoid leaking session over tests
+ MediaWiki\Session\SessionManager::getGlobalSession()->clear();
+
+ parent::tearDown();
+ }
+
+ /**
+ * 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
+ * @return array Array as returned by WikiPage::doEditContent()
+ */
+ protected function editPage( $pageName, $text, $summary = '', $defaultNs = NS_MAIN ) {
+ $title = Title::newFromText( $pageName, $defaultNs );
+ $page = WikiPage::factory( $title );
+
+ return $page->doEditContent( ContentHandler::makeContent( $text, $title ), $summary );
+ }
+
+ /**
+ * Revision-deletes a revision.
+ *
+ * @param Revision|int $rev Revision to delete
+ * @param array $value Keys are Revision::DELETED_* flags. Values are 1 to set the bit, 0 to
+ * clear, -1 to leave alone. (All other values also clear the bit.)
+ * @param string $comment Deletion comment
+ */
+ protected function revisionDelete(
+ $rev, array $value = [ Revision::DELETED_TEXT => 1 ], $comment = ''
+ ) {
+ if ( is_int( $rev ) ) {
+ $rev = Revision::newFromId( $rev );
+ }
+ RevisionDeleter::createList(
+ 'revision', RequestContext::getMain(), $rev->getTitle(), [ $rev->getId() ]
+ )->setVisibility( [
+ 'value' => $value,
+ 'comment' => $comment,
+ ] );
+ }
+
+ /**
+ * Does the API request and returns the result.
+ *
+ * The returned value is an array containing
+ * - the result data (array)
+ * - the request (WebRequest)
+ * - the session data of the request (array)
+ * - if $appendModule is true, the Api module $module
+ *
+ * @param array $params
+ * @param array|null $session
+ * @param bool $appendModule
+ * @param User|null $user
+ * @param string|null $tokenType Set to a string like 'csrf' to send an
+ * appropriate token
+ *
+ * @throws ApiUsageException
+ * @return array
+ */
+ protected function doApiRequest( array $params, array $session = null,
+ $appendModule = false, User $user = null, $tokenType = null
+ ) {
+ global $wgRequest, $wgUser;
+
+ if ( is_null( $session ) ) {
+ // re-use existing global session by default
+ $session = $wgRequest->getSessionArray();
+ }
+
+ $sessionObj = SessionManager::singleton()->getEmptySession();
+
+ if ( $session !== null ) {
+ foreach ( $session as $key => $value ) {
+ $sessionObj->set( $key, $value );
+ }
+ }
+
+ // set up global environment
+ if ( $user ) {
+ $wgUser = $user;
+ }
+
+ if ( $tokenType !== null ) {
+ if ( $tokenType === 'auto' ) {
+ $tokenType = ( new ApiMain() )->getModuleManager()
+ ->getModule( $params['action'], 'action' )->needsToken();
+ }
+ $params['token'] = ApiQueryTokens::getToken(
+ $wgUser, $sessionObj, ApiQueryTokens::getTokenTypeSalts()[$tokenType]
+ )->toString();
+ }
+
+ $wgRequest = new FauxRequest( $params, true, $sessionObj );
+ RequestContext::getMain()->setRequest( $wgRequest );
+ RequestContext::getMain()->setUser( $wgUser );
+ MediaWiki\Auth\AuthManager::resetCache();
+
+ // set up local environment
+ $context = $this->apiContext->newTestContext( $wgRequest, $wgUser );
+
+ $module = new ApiMain( $context, true );
+
+ // run it!
+ $module->execute();
+
+ // construct result
+ $results = [
+ $module->getResult()->getResultData( null, [ 'Strip' => 'all' ] ),
+ $context->getRequest(),
+ $context->getRequest()->getSessionArray()
+ ];
+
+ if ( $appendModule ) {
+ $results[] = $module;
+ }
+
+ return $results;
+ }
+
+ /**
+ * Convenience function to access the token parameter of doApiRequest()
+ * more succinctly.
+ *
+ * @param array $params Key-value API params
+ * @param array|null $session Session array
+ * @param User|null $user A User object for the context
+ * @param string $tokenType Which token type to pass
+ * @return array Result of the API call
+ */
+ protected function doApiRequestWithToken( array $params, array $session = null,
+ User $user = null, $tokenType = 'auto'
+ ) {
+ return $this->doApiRequest( $params, $session, false, $user, $tokenType );
+ }
+
+ /**
+ * Previously this would do API requests to log in, as well as setting $wgUser and the request
+ * context's user. The API requests are unnecessary, and the global-setting is unwanted, so
+ * this method should not be called. Instead, pass appropriate User values directly to
+ * functions that need them. For functions that still rely on $wgUser, set that directly. If
+ * you just want to log in the test sysop user, don't do anything -- that's the default.
+ *
+ * @param TestUser|string $testUser Object, or key to self::$users such as 'sysop' or 'uploader'
+ * @deprecated since 1.31
+ */
+ protected function doLogin( $testUser = null ) {
+ global $wgUser;
+
+ if ( $testUser === null ) {
+ $testUser = static::getTestSysop();
+ } elseif ( is_string( $testUser ) && array_key_exists( $testUser, self::$users ) ) {
+ $testUser = self::$users[$testUser];
+ } elseif ( !$testUser instanceof TestUser ) {
+ throw new MWException( "Can't log in to undefined user $testUser" );
+ }
+
+ $wgUser = $testUser->getUser();
+ RequestContext::getMain()->setUser( $wgUser );
+ }
+
+ protected function getTokenList( TestUser $user, $session = null ) {
+ $data = $this->doApiRequest( [
+ 'action' => 'tokens',
+ 'type' => 'edit|delete|protect|move|block|unblock|watch'
+ ], $session, false, $user->getUser() );
+
+ if ( !array_key_exists( 'tokens', $data[0] ) ) {
+ throw new MWException( 'Api failed to return a token list' );
+ }
+
+ return $data[0]['tokens'];
+ }
+
+ protected static function getErrorFormatter() {
+ if ( self::$errorFormatter === null ) {
+ self::$errorFormatter = new ApiErrorFormatter(
+ new ApiResult( false ),
+ Language::factory( 'en' ),
+ 'none'
+ );
+ }
+ return self::$errorFormatter;
+ }
+
+ public static function apiExceptionHasCode( ApiUsageException $ex, $code ) {
+ return (bool)array_filter(
+ self::getErrorFormatter()->arrayFromStatus( $ex->getStatusValue() ),
+ function ( $e ) use ( $code ) {
+ return is_array( $e ) && $e['code'] === $code;
+ }
+ );
+ }
+
+ /**
+ * @coversNothing
+ */
+ public function testApiTestGroup() {
+ $groups = PHPUnit_Util_Test::getGroups( static::class );
+ $constraint = PHPUnit_Framework_Assert::logicalOr(
+ $this->contains( 'medium' ),
+ $this->contains( 'large' )
+ );
+ $this->assertThat( $groups, $constraint,
+ 'ApiTestCase::setUp can be slow, tests must be "medium" or "large"'
+ );
+ }
+
+ /**
+ * Expect an ApiUsageException to be thrown with the given parameters, which are the same as
+ * ApiUsageException::newWithMessage()'s parameters. This allows checking for an exception
+ * whose text is given by a message key instead of text, so as not to hard-code the message's
+ * text into test code.
+ */
+ protected function setExpectedApiException(
+ $msg, $code = null, array $data = null, $httpCode = 0
+ ) {
+ $expected = ApiUsageException::newWithMessage( null, $msg, $code, $data, $httpCode );
+ $this->setExpectedException( ApiUsageException::class, $expected->getMessage() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiTestCaseUpload.php b/www/wiki/tests/phpunit/includes/api/ApiTestCaseUpload.php
new file mode 100644
index 00000000..3670fad8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiTestCaseUpload.php
@@ -0,0 +1,8 @@
+<?php
+
+/**
+ * For backward compatibility since 1.31
+ */
+abstract class ApiTestCaseUpload extends ApiUploadTestCase {
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiTestContext.php b/www/wiki/tests/phpunit/includes/api/ApiTestContext.php
new file mode 100644
index 00000000..17dad1fa
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiTestContext.php
@@ -0,0 +1,21 @@
+<?php
+
+class ApiTestContext extends RequestContext {
+
+ /**
+ * Returns a DerivativeContext with the request variables in place
+ *
+ * @param WebRequest $request WebRequest request object including parameters and session
+ * @param User|null $user User or null
+ * @return DerivativeContext
+ */
+ public function newTestContext( WebRequest $request, User $user = null ) {
+ $context = new DerivativeContext( $this );
+ $context->setRequest( $request );
+ if ( $user !== null ) {
+ $context->setUser( $user );
+ }
+
+ return $context;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiTokensTest.php b/www/wiki/tests/phpunit/includes/api/ApiTokensTest.php
new file mode 100644
index 00000000..1f7c00b0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiTokensTest.php
@@ -0,0 +1,40 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiTokens
+ */
+class ApiTokensTest extends ApiTestCase {
+
+ public function testGettingToken() {
+ foreach ( self::$users as $user ) {
+ $this->runTokenTest( $user );
+ }
+ }
+
+ protected function runTokenTest( TestUser $user ) {
+ $tokens = $this->getTokenList( $user );
+
+ $rights = $user->getUser()->getRights();
+
+ $this->assertArrayHasKey( 'edittoken', $tokens );
+ $this->assertArrayHasKey( 'movetoken', $tokens );
+
+ if ( isset( $rights['delete'] ) ) {
+ $this->assertArrayHasKey( 'deletetoken', $tokens );
+ }
+
+ if ( isset( $rights['block'] ) ) {
+ $this->assertArrayHasKey( 'blocktoken', $tokens );
+ $this->assertArrayHasKey( 'unblocktoken', $tokens );
+ }
+
+ if ( isset( $rights['protect'] ) ) {
+ $this->assertArrayHasKey( 'protecttoken', $tokens );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiUnblockTest.php b/www/wiki/tests/phpunit/includes/api/ApiUnblockTest.php
new file mode 100644
index 00000000..d20de0dc
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiUnblockTest.php
@@ -0,0 +1,23 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiUnblock
+ */
+class ApiUnblockTest extends ApiTestCase {
+ /**
+ * @expectedException ApiUsageException
+ */
+ public function testWithNoToken() {
+ $this->doApiRequest(
+ [
+ 'action' => 'unblock',
+ 'user' => 'UTApiBlockee',
+ 'reason' => 'Some reason',
+ ]
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiUploadTest.php b/www/wiki/tests/phpunit/includes/api/ApiUploadTest.php
new file mode 100644
index 00000000..41c9aed4
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiUploadTest.php
@@ -0,0 +1,560 @@
+<?php
+/**
+ * n.b. Ensure that you can write to the images/ directory as the
+ * user that will run tests.
+ *
+ * Note for reviewers: this intentionally duplicates functionality already in
+ * "ApiSetup" and so on. This framework works better IMO and has less
+ * strangeness (such as test cases inheriting from "ApiSetup"...) (and in the
+ * case of the other Upload tests, this flat out just actually works... )
+ *
+ * @todo Port the other Upload tests, and other API tests to this framework
+ *
+ * @todo Broken test, reports false errors from time to time.
+ * See https://phabricator.wikimedia.org/T28169
+ *
+ * @todo This is pretty sucky... needs to be prettified.
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ * @group Broken
+ *
+ * @covers ApiUpload
+ */
+class ApiUploadTest extends ApiUploadTestCase {
+ /**
+ * Testing login
+ * XXX this is a funny way of getting session context
+ */
+ public function testLogin() {
+ $user = self::$users['uploader'];
+ $userName = $user->getUser()->getName();
+ $password = $user->getPassword();
+
+ $params = [
+ 'action' => 'login',
+ 'lgname' => $userName,
+ 'lgpassword' => $password
+ ];
+ list( $result, , $session ) = $this->doApiRequest( $params );
+ $this->assertArrayHasKey( "login", $result );
+ $this->assertArrayHasKey( "result", $result['login'] );
+ $this->assertEquals( "NeedToken", $result['login']['result'] );
+ $token = $result['login']['token'];
+
+ $params = [
+ 'action' => 'login',
+ 'lgtoken' => $token,
+ 'lgname' => $userName,
+ 'lgpassword' => $password
+ ];
+ list( $result, , $session ) = $this->doApiRequest( $params, $session );
+ $this->assertArrayHasKey( "login", $result );
+ $this->assertArrayHasKey( "result", $result['login'] );
+ $this->assertEquals( "Success", $result['login']['result'] );
+
+ $this->assertNotEmpty( $session, 'API Login must return a session' );
+
+ return $session;
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadRequiresToken( $session ) {
+ $exception = false;
+ try {
+ $this->doApiRequest( [
+ 'action' => 'upload'
+ ] );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ $this->assertContains( 'The "token" parameter must be set', $e->getMessage() );
+ }
+ $this->assertTrue( $exception, "Got exception" );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadMissingParams( $session ) {
+ $exception = false;
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'upload',
+ ], $session, self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ $this->assertEquals(
+ 'One of the parameters "filekey", "file" and "url" is required.',
+ $e->getMessage()
+ );
+ }
+ $this->assertTrue( $exception, "Got exception" );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUpload( $session ) {
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ $filePaths = $randomImageGenerator->writeImages( 1, $extension, $this->getNewTempDirectory() );
+ } catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ /** @var array $filePaths */
+ $filePath = $filePaths[0];
+ $fileSize = filesize( $filePath );
+ $fileName = basename( $filePath );
+
+ $this->deleteFileByFileName( $fileName );
+ $this->deleteFileByContent( $filePath );
+
+ if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $params = [
+ 'action' => 'upload',
+ 'filename' => $fileName,
+ 'file' => 'dummy content',
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for $fileName",
+ ];
+
+ $exception = false;
+ try {
+ list( $result, , ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertEquals( $fileSize, (int)$result['upload']['imageinfo']['size'] );
+ $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] );
+ $this->assertFalse( $exception );
+
+ // clean up
+ $this->deleteFileByFileName( $fileName );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadZeroLength( $session ) {
+ $mimeType = 'image/png';
+
+ $filePath = $this->getNewTempFile();
+ $fileName = "apiTestUploadZeroLength.png";
+
+ $this->deleteFileByFileName( $fileName );
+
+ if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $params = [
+ 'action' => 'upload',
+ 'filename' => $fileName,
+ 'file' => 'dummy content',
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for $fileName",
+ ];
+
+ $exception = false;
+ try {
+ $this->doApiRequestWithToken( $params, $session, self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $this->assertContains( 'The file you submitted was empty', $e->getMessage() );
+ $exception = true;
+ }
+ $this->assertTrue( $exception );
+
+ // clean up
+ $this->deleteFileByFileName( $fileName );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadSameFileName( $session ) {
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ $filePaths = $randomImageGenerator->writeImages( 2, $extension, $this->getNewTempDirectory() );
+ } catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ // we'll reuse this filename
+ /** @var array $filePaths */
+ $fileName = basename( $filePaths[0] );
+
+ // clear any other files with the same name
+ $this->deleteFileByFileName( $fileName );
+
+ // we reuse these params
+ $params = [
+ 'action' => 'upload',
+ 'filename' => $fileName,
+ 'file' => 'dummy content',
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for $fileName",
+ ];
+
+ // first upload .... should succeed
+
+ if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[0] ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $exception = false;
+ try {
+ list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertFalse( $exception );
+
+ // second upload with the same name (but different content)
+
+ if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[1] ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $exception = false;
+ try {
+ list( $result, , ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() ); // FIXME: leaks a temporary file
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Warning', $result['upload']['result'] );
+ $this->assertTrue( isset( $result['upload']['warnings'] ) );
+ $this->assertTrue( isset( $result['upload']['warnings']['exists'] ) );
+ $this->assertFalse( $exception );
+
+ // clean up
+ $this->deleteFileByFileName( $fileName );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadSameContent( $session ) {
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ $filePaths = $randomImageGenerator->writeImages( 1, $extension, $this->getNewTempDirectory() );
+ } catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ /** @var array $filePaths */
+ $fileNames[0] = basename( $filePaths[0] );
+ $fileNames[1] = "SameContentAs" . $fileNames[0];
+
+ // clear any other files with the same name or content
+ $this->deleteFileByContent( $filePaths[0] );
+ $this->deleteFileByFileName( $fileNames[0] );
+ $this->deleteFileByFileName( $fileNames[1] );
+
+ // first upload .... should succeed
+
+ $params = [
+ 'action' => 'upload',
+ 'filename' => $fileNames[0],
+ 'file' => 'dummy content',
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for " . $fileNames[0],
+ ];
+
+ if ( !$this->fakeUploadFile( 'file', $fileNames[0], $mimeType, $filePaths[0] ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $exception = false;
+ try {
+ list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertFalse( $exception );
+
+ // second upload with the same content (but different name)
+
+ if ( !$this->fakeUploadFile( 'file', $fileNames[1], $mimeType, $filePaths[0] ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $params = [
+ 'action' => 'upload',
+ 'filename' => $fileNames[1],
+ 'file' => 'dummy content',
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for " . $fileNames[1],
+ ];
+
+ $exception = false;
+ try {
+ list( $result ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() ); // FIXME: leaks a temporary file
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Warning', $result['upload']['result'] );
+ $this->assertTrue( isset( $result['upload']['warnings'] ) );
+ $this->assertTrue( isset( $result['upload']['warnings']['duplicate'] ) );
+ $this->assertFalse( $exception );
+
+ // clean up
+ $this->deleteFileByFileName( $fileNames[0] );
+ $this->deleteFileByFileName( $fileNames[1] );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadStash( $session ) {
+ $this->setMwGlobals( [
+ 'wgUser' => self::$users['uploader']->getUser(), // @todo FIXME: still used somewhere
+ ] );
+
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ $filePaths = $randomImageGenerator->writeImages( 1, $extension, $this->getNewTempDirectory() );
+ } catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ /** @var array $filePaths */
+ $filePath = $filePaths[0];
+ $fileSize = filesize( $filePath );
+ $fileName = basename( $filePath );
+
+ $this->deleteFileByFileName( $fileName );
+ $this->deleteFileByContent( $filePath );
+
+ if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $params = [
+ 'action' => 'upload',
+ 'stash' => 1,
+ 'filename' => $fileName,
+ 'file' => 'dummy content',
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for $fileName",
+ ];
+
+ $exception = false;
+ try {
+ list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() ); // FIXME: leaks a temporary file
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertFalse( $exception );
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertEquals( $fileSize, (int)$result['upload']['imageinfo']['size'] );
+ $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] );
+ $this->assertTrue( isset( $result['upload']['filekey'] ) );
+ $this->assertEquals( $result['upload']['sessionkey'], $result['upload']['filekey'] );
+ $filekey = $result['upload']['filekey'];
+
+ // it should be visible from Special:UploadStash
+ // XXX ...but how to test this, with a fake WebRequest with the session?
+
+ // now we should try to release the file from stash
+ $params = [
+ 'action' => 'upload',
+ 'filekey' => $filekey,
+ 'filename' => $fileName,
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for $fileName, altered",
+ ];
+
+ $this->clearFakeUploads();
+ $exception = false;
+ try {
+ list( $result ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertFalse( $exception, "No ApiUsageException exception." );
+
+ // clean up
+ $this->deleteFileByFileName( $fileName );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadChunks( $session ) {
+ $this->setMwGlobals( [
+ // @todo FIXME: still used somewhere
+ 'wgUser' => self::$users['uploader']->getUser(),
+ ] );
+
+ $chunkSize = 1048576;
+ // Download a large image file
+ // (using RandomImageGenerator for large files is not stable)
+ // @todo Don't download files from wikimedia.org
+ $mimeType = 'image/jpeg';
+ $url = 'http://upload.wikimedia.org/wikipedia/commons/'
+ . 'e/ed/Oberaargletscher_from_Oberaar%2C_2010_07.JPG';
+ $filePath = $this->getNewTempDirectory() . '/Oberaargletscher_from_Oberaar.jpg';
+ try {
+ copy( $url, $filePath );
+ } catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ $fileSize = filesize( $filePath );
+ $fileName = basename( $filePath );
+
+ $this->deleteFileByFileName( $fileName );
+ $this->deleteFileByContent( $filePath );
+
+ // Base upload params:
+ $params = [
+ 'action' => 'upload',
+ 'stash' => 1,
+ 'filename' => $fileName,
+ 'filesize' => $fileSize,
+ 'offset' => 0,
+ ];
+
+ // Upload chunks
+ $chunkSessionKey = false;
+ $resultOffset = 0;
+ // Open the file:
+ Wikimedia\suppressWarnings();
+ $handle = fopen( $filePath, "r" );
+ Wikimedia\restoreWarnings();
+
+ if ( $handle === false ) {
+ $this->markTestIncomplete( "could not open file: $filePath" );
+ }
+
+ while ( !feof( $handle ) ) {
+ // Get the current chunk
+ Wikimedia\suppressWarnings();
+ $chunkData = fread( $handle, $chunkSize );
+ Wikimedia\restoreWarnings();
+
+ // Upload the current chunk into the $_FILE object:
+ $this->fakeUploadChunk( 'chunk', 'blob', $mimeType, $chunkData );
+
+ // Check for chunkSessionKey
+ if ( !$chunkSessionKey ) {
+ // Upload fist chunk ( and get the session key )
+ try {
+ list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+ // Make sure we got a valid chunk continue:
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertTrue( isset( $result['upload']['filekey'] ) );
+ // If we don't get a session key mark test incomplete.
+ if ( !isset( $result['upload']['filekey'] ) ) {
+ $this->markTestIncomplete( "no filekey provided" );
+ }
+ $chunkSessionKey = $result['upload']['filekey'];
+ $this->assertEquals( 'Continue', $result['upload']['result'] );
+ // First chunk should have chunkSize == offset
+ $this->assertEquals( $chunkSize, $result['upload']['offset'] );
+ $resultOffset = $result['upload']['offset'];
+ continue;
+ }
+ // Filekey set to chunk session
+ $params['filekey'] = $chunkSessionKey;
+ // Update the offset ( always add chunkSize for subquent chunks
+ // should be in-sync with $result['upload']['offset'] )
+ $params['offset'] += $chunkSize;
+ // Make sure param offset is insync with resultOffset:
+ $this->assertEquals( $resultOffset, $params['offset'] );
+ // Upload current chunk
+ try {
+ list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+ // Make sure we got a valid chunk continue:
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertTrue( isset( $result['upload']['filekey'] ) );
+
+ // Check if we were on the last chunk:
+ if ( $params['offset'] + $chunkSize >= $fileSize ) {
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ break;
+ } else {
+ $this->assertEquals( 'Continue', $result['upload']['result'] );
+ // update $resultOffset
+ $resultOffset = $result['upload']['offset'];
+ }
+ }
+ fclose( $handle );
+
+ // Check that we got a valid file result:
+ wfDebug( __METHOD__
+ . " hohoh filesize {$fileSize} info {$result['upload']['imageinfo']['size']}\n\n" );
+ $this->assertEquals( $fileSize, $result['upload']['imageinfo']['size'] );
+ $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] );
+ $this->assertTrue( isset( $result['upload']['filekey'] ) );
+ $filekey = $result['upload']['filekey'];
+
+ // Now we should try to release the file from stash
+ $params = [
+ 'action' => 'upload',
+ 'filekey' => $filekey,
+ 'filename' => $fileName,
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for $fileName, altered",
+ ];
+ $this->clearFakeUploads();
+ $exception = false;
+ try {
+ list( $result ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertFalse( $exception );
+
+ // clean up
+ $this->deleteFileByFileName( $fileName );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiUploadTestCase.php b/www/wiki/tests/phpunit/includes/api/ApiUploadTestCase.php
new file mode 100644
index 00000000..3c7efd57
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiUploadTestCase.php
@@ -0,0 +1,153 @@
+<?php
+
+/**
+ * Abstract class to support upload tests
+ */
+abstract class ApiUploadTestCase extends ApiTestCase {
+ /**
+ * Fixture -- run before every test
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgEnableUploads' => true,
+ 'wgEnableAPI' => true,
+ ] );
+
+ $this->clearFakeUploads();
+ }
+
+ /**
+ * Helper function -- remove files and associated articles by Title
+ *
+ * @param Title $title Title to be removed
+ *
+ * @return bool
+ */
+ public function deleteFileByTitle( $title ) {
+ if ( $title->exists() ) {
+ $file = wfFindFile( $title, [ 'ignoreRedirect' => true ] );
+ $noOldArchive = ""; // yes this really needs to be set this way
+ $comment = "removing for test";
+ $restrictDeletedVersions = false;
+ $status = FileDeleteForm::doDelete(
+ $title,
+ $file,
+ $noOldArchive,
+ $comment,
+ $restrictDeletedVersions
+ );
+
+ if ( !$status->isGood() ) {
+ return false;
+ }
+
+ $page = WikiPage::factory( $title );
+ $page->doDeleteArticle( "removing for test" );
+
+ // see if it now doesn't exist; reload
+ $title = Title::newFromText( $title->getText(), NS_FILE );
+ }
+
+ return !( $title && $title instanceof Title && $title->exists() );
+ }
+
+ /**
+ * Helper function -- remove files and associated articles with a particular filename
+ *
+ * @param string $fileName Filename to be removed
+ *
+ * @return bool
+ */
+ public function deleteFileByFileName( $fileName ) {
+ return $this->deleteFileByTitle( Title::newFromText( $fileName, NS_FILE ) );
+ }
+
+ /**
+ * Helper function -- given a file on the filesystem, find matching
+ * content in the db (and associated articles) and remove them.
+ *
+ * @param string $filePath Path to file on the filesystem
+ *
+ * @return bool
+ */
+ public function deleteFileByContent( $filePath ) {
+ $hash = FSFile::getSha1Base36FromPath( $filePath );
+ $dupes = RepoGroup::singleton()->findBySha1( $hash );
+ $success = true;
+ foreach ( $dupes as $dupe ) {
+ $success &= $this->deleteFileByTitle( $dupe->getTitle() );
+ }
+
+ return $success;
+ }
+
+ /**
+ * Fake an upload by dumping the file into temp space, and adding info to $_FILES.
+ * (This is what PHP would normally do).
+ *
+ * @param string $fieldName Name this would have in the upload form
+ * @param string $fileName Name to title this
+ * @param string $type MIME type
+ * @param string $filePath Path where to find file contents
+ *
+ * @throws Exception
+ * @return bool
+ */
+ function fakeUploadFile( $fieldName, $fileName, $type, $filePath ) {
+ $tmpName = $this->getNewTempFile();
+ if ( !file_exists( $filePath ) ) {
+ throw new Exception( "$filePath doesn't exist!" );
+ }
+
+ if ( !copy( $filePath, $tmpName ) ) {
+ throw new Exception( "couldn't copy $filePath to $tmpName" );
+ }
+
+ clearstatcache();
+ $size = filesize( $tmpName );
+ if ( $size === false ) {
+ throw new Exception( "couldn't stat $tmpName" );
+ }
+
+ $_FILES[$fieldName] = [
+ 'name' => $fileName,
+ 'type' => $type,
+ 'tmp_name' => $tmpName,
+ 'size' => $size,
+ 'error' => null
+ ];
+
+ return true;
+ }
+
+ function fakeUploadChunk( $fieldName, $fileName, $type, & $chunkData ) {
+ $tmpName = $this->getNewTempFile();
+ // copy the chunk data to temp location:
+ if ( !file_put_contents( $tmpName, $chunkData ) ) {
+ throw new Exception( "couldn't copy chunk data to $tmpName" );
+ }
+
+ clearstatcache();
+ $size = filesize( $tmpName );
+ if ( $size === false ) {
+ throw new Exception( "couldn't stat $tmpName" );
+ }
+
+ $_FILES[$fieldName] = [
+ 'name' => $fileName,
+ 'type' => $type,
+ 'tmp_name' => $tmpName,
+ 'size' => $size,
+ 'error' => null
+ ];
+ }
+
+ /**
+ * Remove traces of previous fake uploads
+ */
+ function clearFakeUploads() {
+ $_FILES = [];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiUsageExceptionTest.php b/www/wiki/tests/phpunit/includes/api/ApiUsageExceptionTest.php
new file mode 100644
index 00000000..bb720211
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiUsageExceptionTest.php
@@ -0,0 +1,44 @@
+<?php
+
+/**
+ * @covers ApiUsageException
+ */
+class ApiUsageExceptionTest extends MediaWikiTestCase {
+
+ public function testCreateWithStatusValue_CanGetAMessageObject() {
+ $messageKey = 'some-message-key';
+ $messageParameter = 'some-parameter';
+ $statusValue = new StatusValue();
+ $statusValue->fatal( $messageKey, $messageParameter );
+
+ $apiUsageException = new ApiUsageException( null, $statusValue );
+ /** @var \Message $gotMessage */
+ $gotMessage = $apiUsageException->getMessageObject();
+
+ $this->assertInstanceOf( \Message::class, $gotMessage );
+ $this->assertEquals( $messageKey, $gotMessage->getKey() );
+ $this->assertEquals( [ $messageParameter ], $gotMessage->getParams() );
+ }
+
+ public function testNewWithMessage_ThenGetMessageObject_ReturnsApiMessageWithProvidedData() {
+ $expectedMessage = new Message( 'some-message-key', [ 'some message parameter' ] );
+ $expectedCode = 'some-error-code';
+ $expectedData = [ 'some-error-data' ];
+
+ $apiUsageException = ApiUsageException::newWithMessage(
+ null,
+ $expectedMessage,
+ $expectedCode,
+ $expectedData
+ );
+ /** @var \ApiMessage $gotMessage */
+ $gotMessage = $apiUsageException->getMessageObject();
+
+ $this->assertInstanceOf( \ApiMessage::class, $gotMessage );
+ $this->assertEquals( $expectedMessage->getKey(), $gotMessage->getKey() );
+ $this->assertEquals( $expectedMessage->getParams(), $gotMessage->getParams() );
+ $this->assertEquals( $expectedCode, $gotMessage->getApiCode() );
+ $this->assertEquals( $expectedData, $gotMessage->getApiData() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiUserrightsTest.php b/www/wiki/tests/phpunit/includes/api/ApiUserrightsTest.php
new file mode 100644
index 00000000..0229e767
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiUserrightsTest.php
@@ -0,0 +1,358 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiUserrights
+ */
+class ApiUserrightsTest extends ApiTestCase {
+ /**
+ * Unsets $wgGroupPermissions['bureaucrat']['userrights'], and sets
+ * $wgAddGroups['bureaucrat'] and $wgRemoveGroups['bureaucrat'] to the
+ * specified values.
+ *
+ * @param array|bool $add Groups bureaucrats should be allowed to add, true for all
+ * @param array|bool $remove Groups bureaucrats should be allowed to remove, true for all
+ */
+ protected function setPermissions( $add = [], $remove = [] ) {
+ global $wgAddGroups, $wgRemoveGroups;
+
+ $this->setGroupPermissions( 'bureaucrat', 'userrights', false );
+
+ if ( $add ) {
+ $this->stashMwGlobals( 'wgAddGroups' );
+ $wgAddGroups['bureaucrat'] = $add;
+ }
+ if ( $remove ) {
+ $this->stashMwGlobals( 'wgRemoveGroups' );
+ $wgRemoveGroups['bureaucrat'] = $remove;
+ }
+ }
+
+ /**
+ * Perform an API userrights request that's expected to be successful.
+ *
+ * @param array|string $expectedGroups Group(s) that the user is expected
+ * to have after the API request
+ * @param array $params Array to pass to doApiRequestWithToken(). 'action'
+ * => 'userrights' is implicit. If no 'user' or 'userid' is specified,
+ * we add a 'user' parameter. If no 'add' or 'remove' is specified, we
+ * add 'add' => 'sysop'.
+ * @param User|null $user The user that we're modifying. The user must be
+ * mutable, because we're going to change its groups! null means that
+ * we'll make up our own user to modify, and doesn't make sense if 'user'
+ * or 'userid' is specified in $params.
+ */
+ protected function doSuccessfulRightsChange(
+ $expectedGroups = 'sysop', array $params = [], User $user = null
+ ) {
+ $expectedGroups = (array)$expectedGroups;
+ $params['action'] = 'userrights';
+
+ if ( !$user ) {
+ $user = $this->getMutableTestUser()->getUser();
+ }
+
+ $this->assertTrue( TestUserRegistry::isMutable( $user ),
+ 'Immutable user passed to doSuccessfulRightsChange!' );
+
+ if ( !isset( $params['user'] ) && !isset( $params['userid'] ) ) {
+ $params['user'] = $user->getName();
+ }
+ if ( !isset( $params['add'] ) && !isset( $params['remove'] ) ) {
+ $params['add'] = 'sysop';
+ }
+
+ $res = $this->doApiRequestWithToken( $params );
+
+ $user->clearInstanceCache();
+ $this->assertSame( $expectedGroups, $user->getGroups() );
+
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ /**
+ * Perform an API userrights request that's expected to fail.
+ *
+ * @param string $expectedException Expected exception text
+ * @param array $params As for doSuccessfulRightsChange()
+ * @param User|null $user As for doSuccessfulRightsChange(). If there's no
+ * user who will possibly be affected (such as if an invalid username is
+ * provided in $params), pass null.
+ */
+ protected function doFailedRightsChange(
+ $expectedException, array $params = [], User $user = null
+ ) {
+ $params['action'] = 'userrights';
+
+ $this->setExpectedException( ApiUsageException::class, $expectedException );
+
+ if ( !$user ) {
+ // If 'user' or 'userid' is specified and $user was not specified,
+ // the user we're creating now will have nothing to do with the API
+ // request, but that's okay, since we're just testing that it has
+ // no groups.
+ $user = $this->getMutableTestUser()->getUser();
+ }
+
+ $this->assertTrue( TestUserRegistry::isMutable( $user ),
+ 'Immutable user passed to doFailedRightsChange!' );
+
+ if ( !isset( $params['user'] ) && !isset( $params['userid'] ) ) {
+ $params['user'] = $user->getName();
+ }
+ if ( !isset( $params['add'] ) && !isset( $params['remove'] ) ) {
+ $params['add'] = 'sysop';
+ }
+ $expectedGroups = $user->getGroups();
+
+ try {
+ $this->doApiRequestWithToken( $params );
+ } finally {
+ $user->clearInstanceCache();
+ $this->assertSame( $expectedGroups, $user->getGroups() );
+ }
+ }
+
+ public function testAdd() {
+ $this->doSuccessfulRightsChange();
+ }
+
+ public function testBlockedWithUserrights() {
+ global $wgUser;
+
+ $block = new Block( [ 'address' => $wgUser, 'by' => $wgUser->getId(), ] );
+ $block->insert();
+
+ try {
+ $this->doSuccessfulRightsChange();
+ } finally {
+ $block->delete();
+ $wgUser->clearInstanceCache();
+ }
+ }
+
+ public function testBlockedWithoutUserrights() {
+ $user = $this->getTestSysop()->getUser();
+
+ $this->setPermissions( true, true );
+
+ $block = new Block( [ 'address' => $user, 'by' => $user->getId() ] );
+ $block->insert();
+
+ try {
+ $this->doFailedRightsChange( 'You have been blocked from editing.' );
+ } finally {
+ $block->delete();
+ $user->clearInstanceCache();
+ }
+ }
+
+ public function testAddMultiple() {
+ $this->doSuccessfulRightsChange(
+ [ 'bureaucrat', 'sysop' ],
+ [ 'add' => 'bureaucrat|sysop' ]
+ );
+ }
+
+ public function testTooFewExpiries() {
+ $this->doFailedRightsChange(
+ '2 expiry timestamps were provided where 3 were needed.',
+ [ 'add' => 'sysop|bureaucrat|bot', 'expiry' => 'infinity|tomorrow' ]
+ );
+ }
+
+ public function testTooManyExpiries() {
+ $this->doFailedRightsChange(
+ '3 expiry timestamps were provided where 2 were needed.',
+ [ 'add' => 'sysop|bureaucrat', 'expiry' => 'infinity|tomorrow|never' ]
+ );
+ }
+
+ public function testInvalidExpiry() {
+ $this->doFailedRightsChange( 'Invalid expiry time', [ 'expiry' => 'yummy lollipops!' ] );
+ }
+
+ public function testMultipleInvalidExpiries() {
+ $this->doFailedRightsChange(
+ 'Invalid expiry time "foo".',
+ [ 'add' => 'sysop|bureaucrat', 'expiry' => 'foo|bar' ]
+ );
+ }
+
+ public function testWithTag() {
+ ChangeTags::defineTag( 'custom tag' );
+
+ $user = $this->getMutableTestUser()->getUser();
+
+ $this->doSuccessfulRightsChange( 'sysop', [ 'tags' => 'custom tag' ], $user );
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $this->assertSame(
+ 'custom tag',
+ $dbr->selectField(
+ [ 'change_tag', 'logging' ],
+ 'ct_tag',
+ [
+ 'ct_log_id = log_id',
+ 'log_namespace' => NS_USER,
+ 'log_title' => strtr( $user->getName(), ' ', '_' )
+ ],
+ __METHOD__
+ )
+ );
+ }
+
+ public function testWithoutTagPermission() {
+ global $wgGroupPermissions;
+
+ ChangeTags::defineTag( 'custom tag' );
+
+ $this->stashMwGlobals( 'wgGroupPermissions' );
+ $wgGroupPermissions['user']['applychangetags'] = false;
+
+ $this->doFailedRightsChange(
+ 'You do not have permission to apply change tags along with your changes.',
+ [ 'tags' => 'custom tag' ]
+ );
+ }
+
+ public function testNonexistentUser() {
+ $this->doFailedRightsChange(
+ 'There is no user by the name "Nonexistent user". Check your spelling.',
+ [ 'user' => 'Nonexistent user' ]
+ );
+ }
+
+ public function testWebToken() {
+ $sysop = $this->getTestSysop()->getUser();
+ $user = $this->getMutableTestUser()->getUser();
+
+ $token = $sysop->getEditToken( $user->getName() );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'userrights',
+ 'user' => $user->getName(),
+ 'add' => 'sysop',
+ 'token' => $token,
+ ] );
+
+ $user->clearInstanceCache();
+ $this->assertSame( [ 'sysop' ], $user->getGroups() );
+
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ /**
+ * Helper for testCanProcessExpiries that returns a mock ApiUserrights that either can or cannot
+ * process expiries. Although the regular page can process expiries, we use a mock here to
+ * ensure that it's the result of canProcessExpiries() that makes a difference, and not some
+ * error in the way we construct the mock.
+ *
+ * @param bool $canProcessExpiries
+ */
+ private function getMockForProcessingExpiries( $canProcessExpiries ) {
+ $sysop = $this->getTestSysop()->getUser();
+ $user = $this->getMutableTestUser()->getUser();
+
+ $token = $sysop->getEditToken( 'userrights' );
+
+ $main = new ApiMain( new FauxRequest( [
+ 'action' => 'userrights',
+ 'user' => $user->getName(),
+ 'add' => 'sysop',
+ 'token' => $token,
+ ] ) );
+
+ $mockUserRightsPage = $this->getMockBuilder( UserrightsPage::class )
+ ->setMethods( [ 'canProcessExpiries' ] )
+ ->getMock();
+ $mockUserRightsPage->method( 'canProcessExpiries' )->willReturn( $canProcessExpiries );
+
+ $mockApi = $this->getMockBuilder( ApiUserrights::class )
+ ->setConstructorArgs( [ $main, 'userrights' ] )
+ ->setMethods( [ 'getUserRightsPage' ] )
+ ->getMock();
+ $mockApi->method( 'getUserRightsPage' )->willReturn( $mockUserRightsPage );
+
+ return $mockApi;
+ }
+
+ public function testCanProcessExpiries() {
+ $mock1 = $this->getMockForProcessingExpiries( true );
+ $this->assertArrayHasKey( 'expiry', $mock1->getAllowedParams() );
+
+ $mock2 = $this->getMockForProcessingExpiries( false );
+ $this->assertArrayNotHasKey( 'expiry', $mock2->getAllowedParams() );
+ }
+
+ /**
+ * Tests adding and removing various groups with various permissions.
+ *
+ * @dataProvider addAndRemoveGroupsProvider
+ * @param array|null $permissions [ [ $wgAddGroups, $wgRemoveGroups ] ] or null for 'userrights'
+ * to be set in $wgGroupPermissions
+ * @param array $groupsToChange [ [ groups to add ], [ groups to remove ] ]
+ * @param array $expectedGroups Array of expected groups
+ */
+ public function testAddAndRemoveGroups(
+ array $permissions = null, array $groupsToChange, array $expectedGroups
+ ) {
+ if ( $permissions !== null ) {
+ $this->setPermissions( $permissions[0], $permissions[1] );
+ }
+
+ $params = [
+ 'add' => implode( '|', $groupsToChange[0] ),
+ 'remove' => implode( '|', $groupsToChange[1] ),
+ ];
+
+ // We'll take a bot so we have a group to remove
+ $user = $this->getMutableTestUser( [ 'bot' ] )->getUser();
+
+ $this->doSuccessfulRightsChange( $expectedGroups, $params, $user );
+ }
+
+ public function addAndRemoveGroupsProvider() {
+ return [
+ 'Simple add' => [
+ [ [ 'sysop' ], [] ],
+ [ [ 'sysop' ], [] ],
+ [ 'bot', 'sysop' ]
+ ], 'Add with only remove permission' => [
+ [ [], [ 'sysop' ] ],
+ [ [ 'sysop' ], [] ],
+ [ 'bot' ],
+ ], 'Add with global remove permission' => [
+ [ [], true ],
+ [ [ 'sysop' ], [] ],
+ [ 'bot' ],
+ ], 'Simple remove' => [
+ [ [], [ 'bot' ] ],
+ [ [], [ 'bot' ] ],
+ [],
+ ], 'Remove with only add permission' => [
+ [ [ 'bot' ], [] ],
+ [ [], [ 'bot' ] ],
+ [ 'bot' ],
+ ], 'Remove with global add permission' => [
+ [ true, [] ],
+ [ [], [ 'bot' ] ],
+ [ 'bot' ],
+ ], 'Add and remove same new group' => [
+ null,
+ [ [ 'sysop' ], [ 'sysop' ] ],
+ // The userrights code does removals before adds, so it doesn't remove the sysop
+ // group here and only adds it.
+ [ 'bot', 'sysop' ],
+ ], 'Add and remove same existing group' => [
+ null,
+ [ [ 'bot' ], [ 'bot' ] ],
+ // But here it first removes the existing group and then re-adds it.
+ [ 'bot' ],
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiWatchTest.php b/www/wiki/tests/phpunit/includes/api/ApiWatchTest.php
new file mode 100644
index 00000000..6d64a178
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiWatchTest.php
@@ -0,0 +1,148 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @todo This test suite is severly broken and need a full review
+ *
+ * @covers ApiWatch
+ */
+class ApiWatchTest extends ApiTestCase {
+ function getTokens() {
+ return $this->getTokenList( self::$users['sysop'] );
+ }
+
+ public function testWatchEdit() {
+ $tokens = $this->getTokens();
+
+ $data = $this->doApiRequest( [
+ 'action' => 'edit',
+ 'title' => 'Help:UTPage', // Help namespace is hopefully wikitext
+ 'text' => 'new text',
+ 'token' => $tokens['edittoken'],
+ 'watchlist' => 'watch' ] );
+ $this->assertArrayHasKey( 'edit', $data[0] );
+ $this->assertArrayHasKey( 'result', $data[0]['edit'] );
+ $this->assertEquals( 'Success', $data[0]['edit']['result'] );
+
+ return $data;
+ }
+
+ /**
+ * @depends testWatchEdit
+ */
+ public function testWatchClear() {
+ $tokens = $this->getTokens();
+
+ $data = $this->doApiRequest( [
+ 'action' => 'query',
+ 'wllimit' => 'max',
+ 'list' => 'watchlist' ] );
+
+ if ( isset( $data[0]['query']['watchlist'] ) ) {
+ $wl = $data[0]['query']['watchlist'];
+
+ foreach ( $wl as $page ) {
+ $data = $this->doApiRequest( [
+ 'action' => 'watch',
+ 'title' => $page['title'],
+ 'unwatch' => true,
+ 'token' => $tokens['watchtoken'] ] );
+ }
+ }
+ $data = $this->doApiRequest( [
+ 'action' => 'query',
+ 'list' => 'watchlist' ], $data );
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'watchlist', $data[0]['query'] );
+ foreach ( $data[0]['query']['watchlist'] as $index => $item ) {
+ // Previous tests may insert an invalid title
+ // like ":ApiEditPageTest testNonTextEdit", which
+ // can't be cleared.
+ if ( strpos( $item['title'], ':' ) === 0 ) {
+ unset( $data[0]['query']['watchlist'][$index] );
+ }
+ }
+ $this->assertEquals( 0, count( $data[0]['query']['watchlist'] ) );
+
+ return $data;
+ }
+
+ public function testWatchProtect() {
+ $tokens = $this->getTokens();
+
+ $data = $this->doApiRequest( [
+ 'action' => 'protect',
+ 'token' => $tokens['protecttoken'],
+ 'title' => 'Help:UTPage',
+ 'protections' => 'edit=sysop',
+ 'watchlist' => 'unwatch' ] );
+
+ $this->assertArrayHasKey( 'protect', $data[0] );
+ $this->assertArrayHasKey( 'protections', $data[0]['protect'] );
+ $this->assertEquals( 1, count( $data[0]['protect']['protections'] ) );
+ $this->assertArrayHasKey( 'edit', $data[0]['protect']['protections'][0] );
+ }
+
+ public function testGetRollbackToken() {
+ $this->getTokens();
+
+ if ( !Title::newFromText( 'Help:UTPage' )->exists() ) {
+ $this->markTestSkipped( "The article [[Help:UTPage]] does not exist" ); // TODO: just create it?
+ }
+
+ $data = $this->doApiRequest( [
+ 'action' => 'query',
+ 'prop' => 'revisions',
+ 'titles' => 'Help:UTPage',
+ 'rvtoken' => 'rollback' ] );
+
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'pages', $data[0]['query'] );
+ $keys = array_keys( $data[0]['query']['pages'] );
+ $key = array_pop( $keys );
+
+ if ( isset( $data[0]['query']['pages'][$key]['missing'] ) ) {
+ $this->markTestSkipped( "Target page (Help:UTPage) doesn't exist" );
+ }
+
+ $this->assertArrayHasKey( 'pageid', $data[0]['query']['pages'][$key] );
+ $this->assertArrayHasKey( 'revisions', $data[0]['query']['pages'][$key] );
+ $this->assertArrayHasKey( 0, $data[0]['query']['pages'][$key]['revisions'] );
+ $this->assertArrayHasKey( 'rollbacktoken', $data[0]['query']['pages'][$key]['revisions'][0] );
+
+ return $data;
+ }
+
+ /**
+ * @group Broken
+ * Broken because there is currently no revision info in the $pageinfo
+ *
+ * @depends testGetRollbackToken
+ */
+ public function testWatchRollback( $data ) {
+ $keys = array_keys( $data[0]['query']['pages'] );
+ $key = array_pop( $keys );
+ $pageinfo = $data[0]['query']['pages'][$key];
+ $revinfo = $pageinfo['revisions'][0];
+
+ try {
+ $data = $this->doApiRequest( [
+ 'action' => 'rollback',
+ 'title' => 'Help:UTPage',
+ 'user' => $revinfo['user'],
+ 'token' => $pageinfo['rollbacktoken'],
+ 'watchlist' => 'watch' ] );
+
+ $this->assertArrayHasKey( 'rollback', $data[0] );
+ $this->assertArrayHasKey( 'title', $data[0]['rollback'] );
+ } catch ( ApiUsageException $ue ) {
+ if ( self::apiExceptionHasCode( $ue, 'onlyauthor' ) ) {
+ $this->markTestIncomplete( "Only one author to 'Help:UTPage', cannot test rollback" );
+ } else {
+ $this->fail( "Received error '" . $ue->getMessage() . "'" );
+ }
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/MockApi.php b/www/wiki/tests/phpunit/includes/api/MockApi.php
new file mode 100644
index 00000000..1407c10d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/MockApi.php
@@ -0,0 +1,27 @@
+<?php
+
+class MockApi extends ApiBase {
+ public $warnings = [];
+
+ public function execute() {
+ }
+
+ public function __construct() {
+ }
+
+ public function getModulePath() {
+ return $this->getModuleName();
+ }
+
+ public function addWarning( $warning, $code = null, $data = null ) {
+ $this->warnings[] = $warning;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'filename' => null,
+ 'enablechunks' => false,
+ 'sessionkey' => null,
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/MockApiQueryBase.php b/www/wiki/tests/phpunit/includes/api/MockApiQueryBase.php
new file mode 100644
index 00000000..9915a38d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/MockApiQueryBase.php
@@ -0,0 +1,19 @@
+<?php
+class MockApiQueryBase extends ApiQueryBase {
+ private $name;
+
+ public function execute() {
+ }
+
+ public function __construct( $name = 'mock' ) {
+ $this->name = $name;
+ }
+
+ public function getModuleName() {
+ return $this->name;
+ }
+
+ public function getModulePath() {
+ return 'query+' . $this->getModuleName();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/PrefixUniquenessTest.php b/www/wiki/tests/phpunit/includes/api/PrefixUniquenessTest.php
new file mode 100644
index 00000000..d125a7d5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/PrefixUniquenessTest.php
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * Checks that all API query modules, core and extensions, have unique prefixes.
+ *
+ * @group API
+ */
+class PrefixUniquenessTest extends MediaWikiTestCase {
+
+ public function testPrefixes() {
+ $main = new ApiMain( new FauxRequest() );
+ $query = new ApiQuery( $main, 'foo', 'bar' );
+ $moduleManager = $query->getModuleManager();
+
+ $modules = $moduleManager->getNames();
+ $prefixes = [];
+
+ foreach ( $modules as $name ) {
+ $module = $moduleManager->getModule( $name );
+ $class = get_class( $module );
+
+ $prefix = $module->getModulePrefix();
+ if ( $prefix !== '' && isset( $prefixes[$prefix] ) ) {
+ $this->fail( "Module prefix '{$prefix}' is shared between {$class} and {$prefixes[$prefix]}" );
+ }
+ $prefixes[$module->getModulePrefix()] = $class;
+ }
+ $this->assertTrue( true ); // dummy call to make this test non-incomplete
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/RandomImageGenerator.php b/www/wiki/tests/phpunit/includes/api/RandomImageGenerator.php
new file mode 100644
index 00000000..50a59f97
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/RandomImageGenerator.php
@@ -0,0 +1,497 @@
+<?php
+/**
+ * RandomImageGenerator -- does what it says on the tin.
+ * Requires Imagick, the ImageMagick library for PHP, or the command line
+ * equivalent (usually 'convert').
+ *
+ * Because MediaWiki tests the uniqueness of media upload content, and
+ * filenames, it is sometimes useful to generate files that are guaranteed (or
+ * at least very likely) to be unique in both those ways. This generates a
+ * number of filenames with random names and random content (colored triangles).
+ *
+ * It is also useful to have fresh content because our tests currently run in a
+ * "destructive" mode, and don't create a fresh new wiki for each test run.
+ * Consequently, if we just had a few static files we kept re-uploading, we'd
+ * get lots of warnings about matching content or filenames, and even if we
+ * deleted those files, we'd get warnings about archived files.
+ *
+ * This can also be used with a cronjob to generate random files all the time.
+ * I use it to have a constant, never ending supply when I'm testing
+ * interactively.
+ *
+ * @file
+ * @author Neil Kandalgaonkar <neilk@wikimedia.org>
+ */
+
+/**
+ * RandomImageGenerator: does what it says on the tin.
+ * Can fetch a random image, or also write a number of them to disk with random filenames.
+ */
+class RandomImageGenerator {
+ private $dictionaryFile;
+ private $minWidth = 400;
+ private $maxWidth = 800;
+ private $minHeight = 400;
+ private $maxHeight = 800;
+ private $shapesToDraw = 5;
+
+ /**
+ * Orientations: 0th row, 0th column, Exif orientation code, rotation 2x2
+ * matrix that is opposite of orientation. N.b. we do not handle the
+ * 'flipped' orientations, which is why there is no entry for 2, 4, 5, or 7.
+ * Those seem to be rare in real images anyway (we also would need a
+ * non-symmetric shape for the images to test those, like a letter F).
+ */
+ private static $orientations = [
+ [
+ '0thRow' => 'top',
+ '0thCol' => 'left',
+ 'exifCode' => 1,
+ 'counterRotation' => [ [ 1, 0 ], [ 0, 1 ] ]
+ ],
+ [
+ '0thRow' => 'bottom',
+ '0thCol' => 'right',
+ 'exifCode' => 3,
+ 'counterRotation' => [ [ -1, 0 ], [ 0, -1 ] ]
+ ],
+ [
+ '0thRow' => 'right',
+ '0thCol' => 'top',
+ 'exifCode' => 6,
+ 'counterRotation' => [ [ 0, 1 ], [ 1, 0 ] ]
+ ],
+ [
+ '0thRow' => 'left',
+ '0thCol' => 'bottom',
+ 'exifCode' => 8,
+ 'counterRotation' => [ [ 0, -1 ], [ -1, 0 ] ]
+ ]
+ ];
+
+ public function __construct( $options = [] ) {
+ foreach ( [ 'dictionaryFile', 'minWidth', 'minHeight',
+ 'maxWidth', 'maxHeight', 'shapesToDraw' ] as $property
+ ) {
+ if ( isset( $options[$property] ) ) {
+ $this->$property = $options[$property];
+ }
+ }
+
+ // find the dictionary file, to generate random names
+ if ( !isset( $this->dictionaryFile ) ) {
+ foreach (
+ [
+ '/usr/share/dict/words',
+ '/usr/dict/words',
+ __DIR__ . '/words.txt'
+ ] as $dictionaryFile
+ ) {
+ if ( is_file( $dictionaryFile ) && is_readable( $dictionaryFile ) ) {
+ $this->dictionaryFile = $dictionaryFile;
+ break;
+ }
+ }
+ }
+ if ( !isset( $this->dictionaryFile ) ) {
+ throw new Exception( "RandomImageGenerator: dictionary file not "
+ . "found or not specified properly" );
+ }
+ }
+
+ /**
+ * Writes random images with random filenames to disk in the directory you
+ * specify, or current working directory.
+ *
+ * @param int $number Number of filenames to write
+ * @param string $format Optional, must be understood by ImageMagick, such as 'jpg' or 'gif'
+ * @param string $dir Directory, optional (will default to current working directory)
+ * @return array Filenames we just wrote
+ */
+ function writeImages( $number, $format = 'jpg', $dir = null ) {
+ $filenames = $this->getRandomFilenames( $number, $format, $dir );
+ $imageWriteMethod = $this->getImageWriteMethod( $format );
+ foreach ( $filenames as $filename ) {
+ $this->{$imageWriteMethod}( $this->getImageSpec(), $format, $filename );
+ }
+
+ return $filenames;
+ }
+
+ /**
+ * Figure out how we write images. This is a factor of both format and the local system
+ *
+ * @param string $format (a typical extension like 'svg', 'jpg', etc.)
+ *
+ * @throws Exception
+ * @return string
+ */
+ function getImageWriteMethod( $format ) {
+ global $wgUseImageMagick, $wgImageMagickConvertCommand;
+ if ( $format === 'svg' ) {
+ return 'writeSvg';
+ } else {
+ // figure out how to write images
+ global $wgExiv2Command;
+ if ( class_exists( 'Imagick' ) && $wgExiv2Command && is_executable( $wgExiv2Command ) ) {
+ return 'writeImageWithApi';
+ } elseif ( $wgUseImageMagick
+ && $wgImageMagickConvertCommand
+ && is_executable( $wgImageMagickConvertCommand )
+ ) {
+ return 'writeImageWithCommandLine';
+ }
+ }
+ throw new Exception( "RandomImageGenerator: could not find a suitable "
+ . "method to write images in '$format' format" );
+ }
+
+ /**
+ * Return a number of randomly-generated filenames
+ * Each filename uses two words randomly drawn from the dictionary, like elephantine_spatula.jpg
+ *
+ * @param int $number Number of filenames to generate
+ * @param string $extension Optional, defaults to 'jpg'
+ * @param string $dir Optional, defaults to current working directory
+ * @return array Array of filenames
+ */
+ private function getRandomFilenames( $number, $extension = 'jpg', $dir = null ) {
+ if ( is_null( $dir ) ) {
+ $dir = getcwd();
+ }
+ $filenames = [];
+ foreach ( $this->getRandomWordPairs( $number ) as $pair ) {
+ $basename = $pair[0] . '_' . $pair[1];
+ if ( !is_null( $extension ) ) {
+ $basename .= '.' . $extension;
+ }
+ $basename = preg_replace( '/\s+/', '', $basename );
+ $filenames[] = "$dir/$basename";
+ }
+
+ return $filenames;
+ }
+
+ /**
+ * Generate data representing an image of random size (within limits),
+ * consisting of randomly colored and sized upward pointing triangles
+ * against a random background color. (This data is used in the
+ * writeImage* methods).
+ *
+ * @return mixed
+ */
+ public function getImageSpec() {
+ $spec = [];
+
+ $spec['width'] = mt_rand( $this->minWidth, $this->maxWidth );
+ $spec['height'] = mt_rand( $this->minHeight, $this->maxHeight );
+ $spec['fill'] = $this->getRandomColor();
+
+ $diagonalLength = sqrt( pow( $spec['width'], 2 ) + pow( $spec['height'], 2 ) );
+
+ $draws = [];
+ for ( $i = 0; $i <= $this->shapesToDraw; $i++ ) {
+ $radius = mt_rand( 0, $diagonalLength / 4 );
+ if ( $radius == 0 ) {
+ continue;
+ }
+ $originX = mt_rand( -1 * $radius, $spec['width'] + $radius );
+ $originY = mt_rand( -1 * $radius, $spec['height'] + $radius );
+ $angle = mt_rand( 0, ( 3.141592 / 2 ) * $radius ) / $radius;
+ $legDeltaX = round( $radius * sin( $angle ) );
+ $legDeltaY = round( $radius * cos( $angle ) );
+
+ $draw = [];
+ $draw['fill'] = $this->getRandomColor();
+ $draw['shape'] = [
+ [ 'x' => $originX, 'y' => $originY - $radius ],
+ [ 'x' => $originX + $legDeltaX, 'y' => $originY + $legDeltaY ],
+ [ 'x' => $originX - $legDeltaX, 'y' => $originY + $legDeltaY ],
+ [ 'x' => $originX, 'y' => $originY - $radius ]
+ ];
+ $draws[] = $draw;
+ }
+
+ $spec['draws'] = $draws;
+
+ return $spec;
+ }
+
+ /**
+ * Given [ [ 'x' => 10, 'y' => 20 ], [ 'x' => 30, y=> 5 ] ]
+ * returns "10,20 30,5"
+ * Useful for SVG and imagemagick command line arguments
+ * @param array $shape Array of arrays, each array containing x & y keys mapped to numeric values
+ * @return string
+ */
+ static function shapePointsToString( $shape ) {
+ $points = [];
+ foreach ( $shape as $point ) {
+ $points[] = $point['x'] . ',' . $point['y'];
+ }
+
+ return implode( " ", $points );
+ }
+
+ /**
+ * Based on image specification, write a very simple SVG file to disk.
+ * Ignores the background spec because transparency is cool. :)
+ *
+ * @param array $spec Spec describing background and shapes to draw
+ * @param string $format File format to write (which is obviously always svg here)
+ * @param string $filename Filename to write to
+ *
+ * @throws Exception
+ */
+ public function writeSvg( $spec, $format, $filename ) {
+ $svg = new SimpleXmlElement( '<svg/>' );
+ $svg->addAttribute( 'xmlns', 'http://www.w3.org/2000/svg' );
+ $svg->addAttribute( 'version', '1.1' );
+ $svg->addAttribute( 'width', $spec['width'] );
+ $svg->addAttribute( 'height', $spec['height'] );
+ $g = $svg->addChild( 'g' );
+ foreach ( $spec['draws'] as $drawSpec ) {
+ $shape = $g->addChild( 'polygon' );
+ $shape->addAttribute( 'fill', $drawSpec['fill'] );
+ $shape->addAttribute( 'points', self::shapePointsToString( $drawSpec['shape'] ) );
+ }
+
+ $fh = fopen( $filename, 'w' );
+ if ( !$fh ) {
+ throw new Exception( "couldn't open $filename for writing" );
+ }
+ fwrite( $fh, $svg->asXML() );
+ if ( !fclose( $fh ) ) {
+ throw new Exception( "couldn't close $filename" );
+ }
+ }
+
+ /**
+ * Based on an image specification, write such an image to disk, using Imagick PHP extension
+ * @param array $spec Spec describing background and circles to draw
+ * @param string $format File format to write
+ * @param string $filename Filename to write to
+ */
+ public function writeImageWithApi( $spec, $format, $filename ) {
+ // this is a hack because I can't get setImageOrientation() to work. See below.
+ global $wgExiv2Command;
+
+ $image = new Imagick();
+ /**
+ * If the format is 'jpg', will also add a random orientation -- the
+ * image will be drawn rotated with triangle points facing in some
+ * direction (0, 90, 180 or 270 degrees) and a countering rotation
+ * should turn the triangle points upward again.
+ */
+ $orientation = self::$orientations[0]; // default is normal orientation
+ if ( $format == 'jpg' ) {
+ $orientation = self::$orientations[array_rand( self::$orientations )];
+ $spec = self::rotateImageSpec( $spec, $orientation['counterRotation'] );
+ }
+
+ $image->newImage( $spec['width'], $spec['height'], new ImagickPixel( $spec['fill'] ) );
+
+ foreach ( $spec['draws'] as $drawSpec ) {
+ $draw = new ImagickDraw();
+ $draw->setFillColor( $drawSpec['fill'] );
+ $draw->polygon( $drawSpec['shape'] );
+ $image->drawImage( $draw );
+ }
+
+ $image->setImageFormat( $format );
+
+ // this doesn't work, even though it's documented to do so...
+ // $image->setImageOrientation( $orientation['exifCode'] );
+
+ $image->writeImage( $filename );
+
+ // because the above setImageOrientation call doesn't work... nor can I
+ // get an external imagemagick binary to do this either... Hacking this
+ // for now (only works if you have exiv2 installed, a program to read
+ // and manipulate exif).
+ if ( $wgExiv2Command ) {
+ $cmd = wfEscapeShellArg( $wgExiv2Command )
+ . " -M "
+ . wfEscapeShellArg( "set Exif.Image.Orientation " . $orientation['exifCode'] )
+ . " "
+ . wfEscapeShellArg( $filename );
+
+ $retval = 0;
+ $err = wfShellExec( $cmd, $retval );
+ if ( $retval !== 0 ) {
+ print "Error with $cmd: $retval, $err\n";
+ }
+ }
+ }
+
+ /**
+ * Given an image specification, produce rotated version
+ * This is used when simulating a rotated image capture with Exif orientation
+ * @param array $spec Returned by getImageSpec
+ * @param array $matrix 2x2 transformation matrix
+ * @return array Transformed Spec
+ */
+ private static function rotateImageSpec( &$spec, $matrix ) {
+ $tSpec = [];
+ $dims = self::matrixMultiply2x2( $matrix, $spec['width'], $spec['height'] );
+ $correctionX = 0;
+ $correctionY = 0;
+ if ( $dims['x'] < 0 ) {
+ $correctionX = abs( $dims['x'] );
+ }
+ if ( $dims['y'] < 0 ) {
+ $correctionY = abs( $dims['y'] );
+ }
+ $tSpec['width'] = abs( $dims['x'] );
+ $tSpec['height'] = abs( $dims['y'] );
+ $tSpec['fill'] = $spec['fill'];
+ $tSpec['draws'] = [];
+ foreach ( $spec['draws'] as $draw ) {
+ $tDraw = [
+ 'fill' => $draw['fill'],
+ 'shape' => []
+ ];
+ foreach ( $draw['shape'] as $point ) {
+ $tPoint = self::matrixMultiply2x2( $matrix, $point['x'], $point['y'] );
+ $tPoint['x'] += $correctionX;
+ $tPoint['y'] += $correctionY;
+ $tDraw['shape'][] = $tPoint;
+ }
+ $tSpec['draws'][] = $tDraw;
+ }
+
+ return $tSpec;
+ }
+
+ /**
+ * Given a matrix and a pair of images, return new position
+ * @param array $matrix 2x2 rotation matrix
+ * @param int $x The x-coordinate number
+ * @param int $y The y-coordinate number
+ * @return array Transformed with properties x, y
+ */
+ private static function matrixMultiply2x2( $matrix, $x, $y ) {
+ return [
+ 'x' => $x * $matrix[0][0] + $y * $matrix[0][1],
+ 'y' => $x * $matrix[1][0] + $y * $matrix[1][1]
+ ];
+ }
+
+ /**
+ * Based on an image specification, write such an image to disk, using the
+ * command line ImageMagick program ('convert').
+ *
+ * Sample command line:
+ * $ convert -size 100x60 xc:rgb(90,87,45) \
+ * -draw 'fill rgb(12,34,56) polygon 41,39 44,57 50,57 41,39' \
+ * -draw 'fill rgb(99,123,231) circle 59,39 56,57' \
+ * -draw 'fill rgb(240,12,32) circle 50,21 50,3' filename.png
+ *
+ * @param array $spec Spec describing background and shapes to draw
+ * @param string $format File format to write (unused by this method but
+ * kept so it has the same signature as writeImageWithApi).
+ * @param string $filename Filename to write to
+ *
+ * @return bool
+ */
+ public function writeImageWithCommandLine( $spec, $format, $filename ) {
+ global $wgImageMagickConvertCommand;
+ $args = [];
+ $args[] = "-size " . wfEscapeShellArg( $spec['width'] . 'x' . $spec['height'] );
+ $args[] = wfEscapeShellArg( "xc:" . $spec['fill'] );
+ foreach ( $spec['draws'] as $draw ) {
+ $fill = $draw['fill'];
+ $polygon = self::shapePointsToString( $draw['shape'] );
+ $drawCommand = "fill $fill polygon $polygon";
+ $args[] = '-draw ' . wfEscapeShellArg( $drawCommand );
+ }
+ $args[] = wfEscapeShellArg( $filename );
+
+ $command = wfEscapeShellArg( $wgImageMagickConvertCommand ) . " " . implode( " ", $args );
+ $retval = null;
+ wfShellExec( $command, $retval );
+
+ return ( $retval === 0 );
+ }
+
+ /**
+ * Generate a string of random colors for ImageMagick or SVG, like "rgb(12, 37, 98)"
+ *
+ * @return string
+ */
+ public function getRandomColor() {
+ $components = [];
+ for ( $i = 0; $i <= 2; $i++ ) {
+ $components[] = mt_rand( 0, 255 );
+ }
+
+ return 'rgb(' . implode( ', ', $components ) . ')';
+ }
+
+ /**
+ * Get an array of random pairs of random words, like
+ * [ [ 'foo', 'bar' ], [ 'quux', 'baz' ] ];
+ *
+ * @param int $number Number of pairs
+ * @return array Two-element arrays
+ */
+ private function getRandomWordPairs( $number ) {
+ $lines = $this->getRandomLines( $number * 2 );
+ // construct pairs of words
+ $pairs = [];
+ $count = count( $lines );
+ for ( $i = 0; $i < $count; $i += 2 ) {
+ $pairs[] = [ $lines[$i], $lines[$i + 1] ];
+ }
+
+ return $pairs;
+ }
+
+ /**
+ * Return N random lines from a file
+ *
+ * Will throw exception if the file could not be read or if it had fewer lines than requested.
+ *
+ * @param int $number_desired Number of lines desired
+ *
+ * @throws Exception
+ * @return array Array of exactly n elements, drawn randomly from lines the file
+ */
+ private function getRandomLines( $number_desired ) {
+ $filepath = $this->dictionaryFile;
+
+ // initialize array of lines
+ $lines = [];
+ for ( $i = 0; $i < $number_desired; $i++ ) {
+ $lines[] = null;
+ }
+
+ /*
+ * This algorithm obtains N random lines from a file in one single pass.
+ * It does this by replacing elements of a fixed-size array of lines,
+ * less and less frequently as it reads the file.
+ */
+ $fh = fopen( $filepath, "r" );
+ if ( !$fh ) {
+ throw new Exception( "couldn't open $filepath" );
+ }
+ $line_number = 0;
+ $max_index = $number_desired - 1;
+ while ( !feof( $fh ) ) {
+ $line = fgets( $fh );
+ if ( $line !== false ) {
+ $line_number++;
+ $line = trim( $line );
+ if ( mt_rand( 0, $line_number ) <= $max_index ) {
+ $lines[mt_rand( 0, $max_index )] = $line;
+ }
+ }
+ }
+ fclose( $fh );
+ if ( $line_number < $number_desired ) {
+ throw new Exception( "not enough lines in $filepath" );
+ }
+
+ return $lines;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/UserWrapper.php b/www/wiki/tests/phpunit/includes/api/UserWrapper.php
new file mode 100644
index 00000000..9942a0f2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/UserWrapper.php
@@ -0,0 +1,25 @@
+<?php
+
+class UserWrapper {
+ public $userName;
+ public $password;
+ public $user;
+
+ public function __construct( $userName, $password, $group = '' ) {
+ $this->userName = $userName;
+ $this->password = $password;
+
+ $this->user = User::newFromName( $this->userName );
+ if ( !$this->user->getId() ) {
+ $this->user = User::createNew( $this->userName, [
+ "email" => "test@example.com",
+ "real_name" => "Test User" ] );
+ }
+ TestUser::setPasswordForUser( $this->user, $this->password );
+
+ if ( $group !== '' ) {
+ $this->user->addGroup( $group );
+ }
+ $this->user->saveSettings();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatBaseTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatBaseTest.php
new file mode 100644
index 00000000..55f760f6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatBaseTest.php
@@ -0,0 +1,388 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group API
+ * @covers ApiFormatBase
+ */
+class ApiFormatBaseTest extends ApiFormatTestBase {
+
+ protected $printerName = 'mockbase';
+
+ public function getMockFormatter( ApiMain $main = null, $format, $methods = [] ) {
+ if ( $main === null ) {
+ $context = new RequestContext;
+ $context->setRequest( new FauxRequest( [], true ) );
+ $main = new ApiMain( $context );
+ }
+
+ $mock = $this->getMockBuilder( ApiFormatBase::class )
+ ->setConstructorArgs( [ $main, $format ] )
+ ->setMethods( array_unique( array_merge( $methods, [ 'getMimeType', 'execute' ] ) ) )
+ ->getMock();
+ if ( !in_array( 'getMimeType', $methods, true ) ) {
+ $mock->method( 'getMimeType' )->willReturn( 'text/x-mock' );
+ }
+ return $mock;
+ }
+
+ protected function encodeData( array $params, array $data, $options = [] ) {
+ $options += [
+ 'name' => 'mock',
+ 'class' => ApiFormatBase::class,
+ 'factory' => function ( ApiMain $main, $format ) use ( $options ) {
+ $mock = $this->getMockFormatter( $main, $format );
+ $mock->expects( $this->once() )->method( 'execute' )
+ ->willReturnCallback( function () use ( $mock ) {
+ $mock->printText( "Format {$mock->getFormat()}: " );
+ $mock->printText( "<b>ok</b>" );
+ } );
+
+ if ( isset( $options['status'] ) ) {
+ $mock->setHttpStatus( $options['status'] );
+ }
+
+ return $mock;
+ },
+ 'returnPrinter' => true,
+ ];
+
+ $this->setMwGlobals( [
+ 'wgApiFrameOptions' => 'DENY',
+ ] );
+
+ $ret = parent::encodeData( $params, $data, $options );
+ $printer = TestingAccessWrapper::newFromObject( $ret['printer'] );
+ $text = $ret['text'];
+
+ if ( $options['name'] !== 'mockfm' ) {
+ $ct = 'text/x-mock';
+ $file = 'api-result.mock';
+ $status = isset( $options['status'] ) ? $options['status'] : null;
+ } elseif ( isset( $params['wrappedhtml'] ) ) {
+ $ct = 'text/mediawiki-api-prettyprint-wrapped';
+ $file = 'api-result-wrapped.json';
+ $status = null;
+
+ // Replace varying field
+ $text = preg_replace( '/"time":\d+/', '"time":1234', $text );
+ } else {
+ $ct = 'text/html';
+ $file = 'api-result.html';
+ $status = null;
+
+ // Strip OutputPage-generated HTML
+ if ( preg_match( '!<pre class="api-pretty-content">.*</pre>!s', $text, $m ) ) {
+ $text = $m[0];
+ }
+ }
+
+ $response = $printer->getMain()->getRequest()->response();
+ $this->assertSame( "$ct; charset=utf-8", strtolower( $response->getHeader( 'Content-Type' ) ) );
+ $this->assertSame( 'DENY', $response->getHeader( 'X-Frame-Options' ) );
+ $this->assertSame( $file, $printer->getFilename() );
+ $this->assertSame( "inline; filename=$file", $response->getHeader( 'Content-Disposition' ) );
+ $this->assertSame( $status, $response->getStatusCode() );
+
+ return $text;
+ }
+
+ public static function provideGeneralEncoding() {
+ return [
+ 'normal' => [
+ [],
+ "Format MOCK: <b>ok</b>",
+ [],
+ [ 'name' => 'mock' ]
+ ],
+ 'normal ignores wrappedhtml' => [
+ [],
+ "Format MOCK: <b>ok</b>",
+ [ 'wrappedhtml' => 1 ],
+ [ 'name' => 'mock' ]
+ ],
+ 'HTML format' => [
+ [],
+ '<pre class="api-pretty-content">Format MOCK: &lt;b>ok&lt;/b></pre>',
+ [],
+ [ 'name' => 'mockfm' ]
+ ],
+ 'wrapped HTML format' => [
+ [],
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ '{"status":200,"statustext":"OK","html":"<pre class=\"api-pretty-content\">Format MOCK: &lt;b>ok&lt;/b></pre>","modules":["mediawiki.apipretty"],"continue":null,"time":1234}',
+ [ 'wrappedhtml' => 1 ],
+ [ 'name' => 'mockfm' ]
+ ],
+ 'normal, with set status' => [
+ [],
+ "Format MOCK: <b>ok</b>",
+ [],
+ [ 'name' => 'mock', 'status' => 400 ]
+ ],
+ 'HTML format, with set status' => [
+ [],
+ '<pre class="api-pretty-content">Format MOCK: &lt;b>ok&lt;/b></pre>',
+ [],
+ [ 'name' => 'mockfm', 'status' => 400 ]
+ ],
+ 'wrapped HTML format, with set status' => [
+ [],
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ '{"status":400,"statustext":"Bad Request","html":"<pre class=\"api-pretty-content\">Format MOCK: &lt;b>ok&lt;/b></pre>","modules":["mediawiki.apipretty"],"continue":null,"time":1234}',
+ [ 'wrappedhtml' => 1 ],
+ [ 'name' => 'mockfm', 'status' => 400 ]
+ ],
+ 'wrapped HTML format, cross-domain-policy' => [
+ [ 'continue' => '< CrOsS-DoMaIn-PoLiCy >' ],
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ '{"status":200,"statustext":"OK","html":"<pre class=\"api-pretty-content\">Format MOCK: &lt;b>ok&lt;/b></pre>","modules":["mediawiki.apipretty"],"continue":"\u003C CrOsS-DoMaIn-PoLiCy \u003E","time":1234}',
+ [ 'wrappedhtml' => 1 ],
+ [ 'name' => 'mockfm' ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideFilenameEncoding
+ */
+ public function testFilenameEncoding( $filename, $expect ) {
+ $ret = parent::encodeData( [], [], [
+ 'name' => 'mock',
+ 'class' => ApiFormatBase::class,
+ 'factory' => function ( ApiMain $main, $format ) use ( $filename ) {
+ $mock = $this->getMockFormatter( $main, $format, [ 'getFilename' ] );
+ $mock->method( 'getFilename' )->willReturn( $filename );
+ return $mock;
+ },
+ 'returnPrinter' => true,
+ ] );
+ $response = $ret['printer']->getMain()->getRequest()->response();
+
+ $this->assertSame( "inline; $expect", $response->getHeader( 'Content-Disposition' ) );
+ }
+
+ public static function provideFilenameEncoding() {
+ return [
+ 'something simple' => [
+ 'foo.xyz', 'filename=foo.xyz'
+ ],
+ 'more complicated, but still simple' => [
+ 'foo.!#$%&\'*+-^_`|~', 'filename=foo.!#$%&\'*+-^_`|~'
+ ],
+ 'Needs quoting' => [
+ 'foo\\bar.xyz', 'filename="foo\\\\bar.xyz"'
+ ],
+ 'Needs quoting (2)' => [
+ 'foo (bar).xyz', 'filename="foo (bar).xyz"'
+ ],
+ 'Needs quoting (3)' => [
+ "foo\t\"b\x5car\"\0.xyz", "filename=\"foo\x5c\t\x5c\"b\x5c\x5car\x5c\"\x5c\0.xyz\""
+ ],
+ 'Non-ASCII characters' => [
+ 'fóo bár.🙌!',
+ "filename=\"f\xF3o b\xE1r.?!\"; filename*=UTF-8''f%C3%B3o%20b%C3%A1r.%F0%9F%99%8C!"
+ ]
+ ];
+ }
+
+ public function testBasics() {
+ $printer = $this->getMockFormatter( null, 'mock' );
+ $this->assertTrue( $printer->canPrintErrors() );
+ $this->assertSame(
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Data_formats',
+ $printer->getHelpUrls()
+ );
+ }
+
+ public function testDisable() {
+ $this->setMwGlobals( [
+ 'wgApiFrameOptions' => 'DENY',
+ ] );
+
+ $printer = $this->getMockFormatter( null, 'mock' );
+ $printer->method( 'execute' )->willReturnCallback( function () use ( $printer ) {
+ $printer->printText( 'Foo' );
+ } );
+ $this->assertFalse( $printer->isDisabled() );
+ $printer->disable();
+ $this->assertTrue( $printer->isDisabled() );
+
+ $printer->setHttpStatus( 400 );
+ $printer->initPrinter();
+ $printer->execute();
+ ob_start();
+ $printer->closePrinter();
+ $this->assertSame( '', ob_get_clean() );
+ $response = $printer->getMain()->getRequest()->response();
+ $this->assertNull( $response->getHeader( 'Content-Type' ) );
+ $this->assertNull( $response->getHeader( 'X-Frame-Options' ) );
+ $this->assertNull( $response->getHeader( 'Content-Disposition' ) );
+ $this->assertNull( $response->getStatusCode() );
+ }
+
+ public function testNullMimeType() {
+ $this->setMwGlobals( [
+ 'wgApiFrameOptions' => 'DENY',
+ ] );
+
+ $printer = $this->getMockFormatter( null, 'mock', [ 'getMimeType' ] );
+ $printer->method( 'execute' )->willReturnCallback( function () use ( $printer ) {
+ $printer->printText( 'Foo' );
+ } );
+ $printer->method( 'getMimeType' )->willReturn( null );
+ $this->assertNull( $printer->getMimeType(), 'sanity check' );
+
+ $printer->initPrinter();
+ $printer->execute();
+ ob_start();
+ $printer->closePrinter();
+ $this->assertSame( 'Foo', ob_get_clean() );
+ $response = $printer->getMain()->getRequest()->response();
+ $this->assertNull( $response->getHeader( 'Content-Type' ) );
+ $this->assertNull( $response->getHeader( 'X-Frame-Options' ) );
+ $this->assertNull( $response->getHeader( 'Content-Disposition' ) );
+
+ $printer = $this->getMockFormatter( null, 'mockfm', [ 'getMimeType' ] );
+ $printer->method( 'execute' )->willReturnCallback( function () use ( $printer ) {
+ $printer->printText( 'Foo' );
+ } );
+ $printer->method( 'getMimeType' )->willReturn( null );
+ $this->assertNull( $printer->getMimeType(), 'sanity check' );
+ $this->assertTrue( $printer->getIsHtml(), 'sanity check' );
+
+ $printer->initPrinter();
+ $printer->execute();
+ ob_start();
+ $printer->closePrinter();
+ $this->assertSame( 'Foo', ob_get_clean() );
+ $response = $printer->getMain()->getRequest()->response();
+ $this->assertSame(
+ 'text/html; charset=utf-8', strtolower( $response->getHeader( 'Content-Type' ) )
+ );
+ $this->assertSame( 'DENY', $response->getHeader( 'X-Frame-Options' ) );
+ $this->assertSame(
+ 'inline; filename=api-result.html', $response->getHeader( 'Content-Disposition' )
+ );
+ }
+
+ public function testApiFrameOptions() {
+ $this->setMwGlobals( [ 'wgApiFrameOptions' => 'DENY' ] );
+ $printer = $this->getMockFormatter( null, 'mock' );
+ $printer->initPrinter();
+ $this->assertSame(
+ 'DENY',
+ $printer->getMain()->getRequest()->response()->getHeader( 'X-Frame-Options' )
+ );
+
+ $this->setMwGlobals( [ 'wgApiFrameOptions' => 'SAMEORIGIN' ] );
+ $printer = $this->getMockFormatter( null, 'mock' );
+ $printer->initPrinter();
+ $this->assertSame(
+ 'SAMEORIGIN',
+ $printer->getMain()->getRequest()->response()->getHeader( 'X-Frame-Options' )
+ );
+
+ $this->setMwGlobals( [ 'wgApiFrameOptions' => false ] );
+ $printer = $this->getMockFormatter( null, 'mock' );
+ $printer->initPrinter();
+ $this->assertNull(
+ $printer->getMain()->getRequest()->response()->getHeader( 'X-Frame-Options' )
+ );
+ }
+
+ public function testForceDefaultParams() {
+ $context = new RequestContext;
+ $context->setRequest( new FauxRequest( [ 'foo' => '1', 'bar' => '2', 'baz' => '3' ], true ) );
+ $main = new ApiMain( $context );
+ $allowedParams = [
+ 'foo' => [],
+ 'bar' => [ ApiBase::PARAM_DFLT => 'bar?' ],
+ 'baz' => 'baz!',
+ ];
+
+ $printer = $this->getMockFormatter( $main, 'mock', [ 'getAllowedParams' ] );
+ $printer->method( 'getAllowedParams' )->willReturn( $allowedParams );
+ $this->assertEquals(
+ [ 'foo' => '1', 'bar' => '2', 'baz' => '3' ],
+ $printer->extractRequestParams(),
+ 'sanity check'
+ );
+
+ $printer = $this->getMockFormatter( $main, 'mock', [ 'getAllowedParams' ] );
+ $printer->method( 'getAllowedParams' )->willReturn( $allowedParams );
+ $printer->forceDefaultParams();
+ $this->assertEquals(
+ [ 'foo' => null, 'bar' => 'bar?', 'baz' => 'baz!' ],
+ $printer->extractRequestParams()
+ );
+ }
+
+ public function testGetAllowedParams() {
+ $printer = $this->getMockFormatter( null, 'mock' );
+ $this->assertSame( [], $printer->getAllowedParams() );
+
+ $printer = $this->getMockFormatter( null, 'mockfm' );
+ $this->assertSame( [
+ 'wrappedhtml' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-format-param-wrappedhtml',
+ ]
+ ], $printer->getAllowedParams() );
+ }
+
+ public function testGetExamplesMessages() {
+ $printer = TestingAccessWrapper::newFromObject( $this->getMockFormatter( null, 'mock' ) );
+ $this->assertSame( [
+ 'action=query&meta=siteinfo&siprop=namespaces&format=mock'
+ => [ 'apihelp-format-example-generic', 'MOCK' ]
+ ], $printer->getExamplesMessages() );
+
+ $printer = TestingAccessWrapper::newFromObject( $this->getMockFormatter( null, 'mockfm' ) );
+ $this->assertSame( [
+ 'action=query&meta=siteinfo&siprop=namespaces&format=mockfm'
+ => [ 'apihelp-format-example-generic', 'MOCK' ]
+ ], $printer->getExamplesMessages() );
+ }
+
+ /**
+ * @dataProvider provideHtmlHeader
+ */
+ public function testHtmlHeader( $post, $registerNonHtml, $expect ) {
+ $context = new RequestContext;
+ $request = new FauxRequest( [ 'a' => 1, 'b' => 2 ], $post );
+ $request->setRequestURL( 'http://example.org/wx/api.php' );
+ $context->setRequest( $request );
+ $context->setLanguage( 'qqx' );
+ $main = new ApiMain( $context );
+ $printer = $this->getMockFormatter( $main, 'mockfm' );
+ $mm = $printer->getMain()->getModuleManager();
+ $mm->addModule( 'mockfm', 'format', ApiFormatBase::class, function () {
+ return $mock;
+ } );
+ if ( $registerNonHtml ) {
+ $mm->addModule( 'mock', 'format', ApiFormatBase::class, function () {
+ return $mock;
+ } );
+ }
+
+ $printer->initPrinter();
+ $printer->execute();
+ ob_start();
+ $printer->closePrinter();
+ $text = ob_get_clean();
+ $this->assertContains( $expect, $text );
+ }
+
+ public static function provideHtmlHeader() {
+ return [
+ [ false, false, '(api-format-prettyprint-header-only-html: MOCK)' ],
+ [ true, false, '(api-format-prettyprint-header-only-html: MOCK)' ],
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ [ false, true, '(api-format-prettyprint-header-hyperlinked: MOCK, mock, <a rel="nofollow" class="external free" href="http://example.org/wx/api.php?a=1&amp;b=2&amp;format=mock">http://example.org/wx/api.php?a=1&amp;b=2&amp;format=mock</a>)' ],
+ [ true, true, '(api-format-prettyprint-header: MOCK, mock)' ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatJsonTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatJsonTest.php
new file mode 100644
index 00000000..7eb2a35e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatJsonTest.php
@@ -0,0 +1,129 @@
+<?php
+
+/**
+ * @group API
+ * @covers ApiFormatJson
+ */
+class ApiFormatJsonTest extends ApiFormatTestBase {
+
+ protected $printerName = 'json';
+
+ private static function addFormatVersion( $format, $arr ) {
+ foreach ( $arr as &$p ) {
+ if ( !isset( $p[2] ) ) {
+ $p[2] = [ 'formatversion' => $format ];
+ } else {
+ $p[2]['formatversion'] = $format;
+ }
+ }
+ return $arr;
+ }
+
+ public static function provideGeneralEncoding() {
+ return array_merge(
+ self::addFormatVersion( 1, [
+ // Basic types
+ [ [ null ], '[null]' ],
+ [ [ true ], '[""]' ],
+ [ [ false ], '[]' ],
+ [ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ], '[true]' ],
+ [ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ], '[false]' ],
+ [ [ 42 ], '[42]' ],
+ [ [ 42.5 ], '[42.5]' ],
+ [ [ 1e42 ], '[1.0e+42]' ],
+ [ [ 'foo' ], '["foo"]' ],
+ [ [ 'fóo' ], '["f\u00f3o"]' ],
+ [ [ 'fóo' ], '["fóo"]', [ 'utf8' => 1 ] ],
+
+ // Arrays and objects
+ [ [ [] ], '[[]]' ],
+ [ [ [ 1 ] ], '[[1]]' ],
+ [ [ [ 'x' => 1 ] ], '[{"x":1}]' ],
+ [ [ [ 2 => 1 ] ], '[{"2":1}]' ],
+ [ [ (object)[] ], '[{}]' ],
+ [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '[{"0":1}]' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '[[1]]' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], '[{"x":1}]' ],
+ [
+ [ [
+ 'x' => 1,
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'key'
+ ] ],
+ '[[{"key":"x","*":1}]]'
+ ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '[{"x":1}]' ],
+ [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], '[["a","b"]]' ],
+
+ // Content
+ [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ '{"*":"foo"}' ],
+
+ // BC Subelements
+ [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
+ '{"foo":{"*":"foo"}}' ],
+
+ // Callbacks
+ [ [ 1 ], '/**/myCallback([1])', [ 'callback' => 'myCallback' ] ],
+
+ // Cross-domain mangling
+ [ [ '< Cross-Domain-Policy >' ], '["\u003C Cross-Domain-Policy >"]' ],
+ ] ),
+ self::addFormatVersion( 2, [
+ // Basic types
+ [ [ null ], '[null]' ],
+ [ [ true ], '[true]' ],
+ [ [ false ], '[false]' ],
+ [ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ], '[true]' ],
+ [ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ], '[false]' ],
+ [ [ 42 ], '[42]' ],
+ [ [ 42.5 ], '[42.5]' ],
+ [ [ 1e42 ], '[1.0e+42]' ],
+ [ [ 'foo' ], '["foo"]' ],
+ [ [ 'fóo' ], '["fóo"]' ],
+ [ [ 'fóo' ], '["f\u00f3o"]', [ 'ascii' => 1 ] ],
+
+ // Arrays and objects
+ [ [ [] ], '[[]]' ],
+ [ [ [ 'x' => 1 ] ], '[{"x":1}]' ],
+ [ [ [ 2 => 1 ] ], '[{"2":1}]' ],
+ [ [ (object)[] ], '[{}]' ],
+ [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '[{"0":1}]' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '[[1]]' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], '[{"x":1}]' ],
+ [
+ [ [
+ 'x' => 1,
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'key'
+ ] ],
+ '[{"x":1}]'
+ ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '[[1]]' ],
+ [
+ [ [
+ 'a',
+ 'b',
+ ApiResult::META_TYPE => 'BCassoc'
+ ] ],
+ '[{"0":"a","1":"b"}]'
+ ],
+
+ // Content
+ [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ '{"content":"foo"}' ],
+
+ // BC Subelements
+ [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
+ '{"foo":"foo"}' ],
+
+ // Callbacks
+ [ [ 1 ], '/**/myCallback([1])', [ 'callback' => 'myCallback' ] ],
+
+ // Cross-domain mangling
+ [ [ '< Cross-Domain-Policy >' ], '["\u003C Cross-Domain-Policy >"]' ],
+ ] )
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatNoneTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatNoneTest.php
new file mode 100644
index 00000000..87e36703
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatNoneTest.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * @group API
+ * @covers ApiFormatNone
+ */
+class ApiFormatNoneTest extends ApiFormatTestBase {
+
+ protected $printerName = 'none';
+
+ public static function provideGeneralEncoding() {
+ return [
+ // Basic types
+ [ [ null ], '' ],
+ [ [ true ], '' ],
+ [ [ false ], '' ],
+ [ [ 42 ], '' ],
+ [ [ 42.5 ], '' ],
+ [ [ 1e42 ], '' ],
+ [ [ 'foo' ], '' ],
+ [ [ 'fóo' ], '' ],
+
+ // Arrays and objects
+ [ [ [] ], '' ],
+ [ [ [ 1 ] ], '' ],
+ [ [ [ 'x' => 1 ] ], '' ],
+ [ [ [ 2 => 1 ] ], '' ],
+ [ [ (object)[] ], '' ],
+ [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], '' ],
+ [
+ [ [
+ 'x' => 1,
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'key'
+ ] ],
+ ''
+ ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '' ],
+ [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], '' ],
+
+ // Content
+ [ [ '*' => 'foo' ], '' ],
+
+ // BC Subelements
+ [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ], '' ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatPhpTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatPhpTest.php
new file mode 100644
index 00000000..66e620e8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatPhpTest.php
@@ -0,0 +1,139 @@
+<?php
+
+/**
+ * @group API
+ * @covers ApiFormatPhp
+ */
+class ApiFormatPhpTest extends ApiFormatTestBase {
+
+ protected $printerName = 'php';
+
+ private static function addFormatVersion( $format, $arr ) {
+ foreach ( $arr as &$p ) {
+ if ( !isset( $p[2] ) ) {
+ $p[2] = [ 'formatversion' => $format ];
+ } else {
+ $p[2]['formatversion'] = $format;
+ }
+ }
+ return $arr;
+ }
+
+ public static function provideGeneralEncoding() {
+ // phpcs:disable Generic.Files.LineLength
+ return array_merge(
+ self::addFormatVersion( 1, [
+ // Basic types
+ [ [ null ], 'a:1:{i:0;N;}' ],
+ [ [ true ], 'a:1:{i:0;s:0:"";}' ],
+ [ [ false ], 'a:0:{}' ],
+ [ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ],
+ 'a:1:{i:0;b:1;}' ],
+ [ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ],
+ 'a:1:{i:0;b:0;}' ],
+ [ [ 42 ], 'a:1:{i:0;i:42;}' ],
+ [ [ 42.5 ], 'a:1:{i:0;d:42.5;}' ],
+ [ [ 1e42 ], 'a:1:{i:0;d:1.0E+42;}' ],
+ [ [ 'foo' ], 'a:1:{i:0;s:3:"foo";}' ],
+ [ [ 'fóo' ], 'a:1:{i:0;s:4:"fóo";}' ],
+
+ // Arrays and objects
+ [ [ [] ], 'a:1:{i:0;a:0:{}}' ],
+ [ [ [ 1 ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'x' => 1 ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
+ [ [ [ 2 => 1 ] ], 'a:1:{i:0;a:1:{i:2;i:1;}}' ],
+ [ [ (object)[] ], 'a:1:{i:0;a:0:{}}' ],
+ [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCkvp', ApiResult::META_KVP_KEY_NAME => 'key' ] ],
+ 'a:1:{i:0;a:1:{i:0;a:2:{s:3:"key";s:1:"x";s:1:"*";i:1;}}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
+ [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], 'a:1:{i:0;a:2:{i:0;s:1:"a";i:1;s:1:"b";}}' ],
+
+ // Content
+ [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ 'a:1:{s:1:"*";s:3:"foo";}' ],
+
+ // BC Subelements
+ [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
+ 'a:1:{s:3:"foo";a:1:{s:1:"*";s:3:"foo";}}' ],
+ ] ),
+ self::addFormatVersion( 2, [
+ // Basic types
+ [ [ null ], 'a:1:{i:0;N;}' ],
+ [ [ true ], 'a:1:{i:0;b:1;}' ],
+ [ [ false ], 'a:1:{i:0;b:0;}' ],
+ [ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ],
+ 'a:1:{i:0;b:1;}' ],
+ [ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ],
+ 'a:1:{i:0;b:0;}' ],
+ [ [ 42 ], 'a:1:{i:0;i:42;}' ],
+ [ [ 42.5 ], 'a:1:{i:0;d:42.5;}' ],
+ [ [ 1e42 ], 'a:1:{i:0;d:1.0E+42;}' ],
+ [ [ 'foo' ], 'a:1:{i:0;s:3:"foo";}' ],
+ [ [ 'fóo' ], 'a:1:{i:0;s:4:"fóo";}' ],
+
+ // Arrays and objects
+ [ [ [] ], 'a:1:{i:0;a:0:{}}' ],
+ [ [ [ 1 ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'x' => 1 ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
+ [ [ [ 2 => 1 ] ], 'a:1:{i:0;a:1:{i:2;i:1;}}' ],
+ [ [ (object)[] ], 'a:1:{i:0;a:0:{}}' ],
+ [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCkvp', ApiResult::META_KVP_KEY_NAME => 'key' ] ],
+ 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], 'a:1:{i:0;a:2:{i:0;s:1:"a";i:1;s:1:"b";}}' ],
+
+ // Content
+ [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ 'a:1:{s:7:"content";s:3:"foo";}' ],
+
+ // BC Subelements
+ [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
+ 'a:1:{s:3:"foo";s:3:"foo";}' ],
+ ] )
+ );
+ // phpcs:enable
+ }
+
+ public function testCrossDomainMangling() {
+ $config = new HashConfig( [ 'MangleFlashPolicy' => false ] );
+ $context = new RequestContext;
+ $context->setConfig( new MultiConfig( [
+ $config,
+ $context->getConfig(),
+ ] ) );
+ $main = new ApiMain( $context );
+ $main->getResult()->addValue( null, null, '< Cross-Domain-Policy >' );
+
+ $printer = $main->createPrinterByName( 'php' );
+ ob_start( 'MediaWiki\\OutputHandler::handle' );
+ $printer->initPrinter();
+ $printer->execute();
+ $printer->closePrinter();
+ $ret = ob_get_clean();
+ $this->assertSame( 'a:1:{i:0;s:23:"< Cross-Domain-Policy >";}', $ret );
+
+ $config->set( 'MangleFlashPolicy', true );
+ $printer = $main->createPrinterByName( 'php' );
+ ob_start( 'MediaWiki\\OutputHandler::handle' );
+ try {
+ $printer->initPrinter();
+ $printer->execute();
+ $printer->closePrinter();
+ ob_end_clean();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ ob_end_clean();
+ $this->assertTrue(
+ $ex->getStatusValue()->hasMessage( 'apierror-formatphp' ),
+ 'Expected exception'
+ );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatRawTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatRawTest.php
new file mode 100644
index 00000000..f64af6d3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatRawTest.php
@@ -0,0 +1,120 @@
+<?php
+
+/**
+ * @group API
+ * @covers ApiFormatRaw
+ */
+class ApiFormatRawTest extends ApiFormatTestBase {
+
+ protected $printerName = 'raw';
+
+ /**
+ * Test basic encoding and missing mime and text exceptions
+ * @return array datasets
+ */
+ public static function provideGeneralEncoding() {
+ $options = [
+ 'class' => ApiFormatRaw::class,
+ 'factory' => function ( ApiMain $main ) {
+ return new ApiFormatRaw( $main, new ApiFormatJson( $main, 'json' ) );
+ }
+ ];
+
+ return [
+ [
+ [ 'mime' => 'text/plain', 'text' => 'foo' ],
+ 'foo',
+ [],
+ $options
+ ],
+ [
+ [ 'mime' => 'text/plain', 'text' => 'fóo' ],
+ 'fóo',
+ [],
+ $options
+ ],
+ [
+ [ 'text' => 'some text' ],
+ new MWException( 'No MIME type set for raw formatter' ),
+ [],
+ $options
+ ],
+ [
+ [ 'mime' => 'text/plain' ],
+ new MWException( 'No text given for raw formatter' ),
+ [],
+ $options
+ ],
+ 'test error fallback' => [
+ [ 'mime' => 'text/plain', 'text' => 'some text', 'error' => 'some error' ],
+ '{"mime":"text/plain","text":"some text","error":"some error"}',
+ [],
+ $options
+ ]
+ ];
+ }
+
+ /**
+ * Test specifying filename
+ */
+ public function testFilename() {
+ $printer = new ApiFormatRaw( new ApiMain );
+ $printer->getResult()->addValue( null, 'filename', 'whatever.raw' );
+ $this->assertSame( 'whatever.raw', $printer->getFilename() );
+ }
+
+ /**
+ * Test specifying filename with error fallback printer
+ */
+ public function testErrorFallbackFilename() {
+ $apiMain = new ApiMain;
+ $printer = new ApiFormatRaw( $apiMain, new ApiFormatJson( $apiMain, 'json' ) );
+ $printer->getResult()->addValue( null, 'error', 'some error' );
+ $printer->getResult()->addValue( null, 'filename', 'whatever.raw' );
+ $this->assertSame( 'api-result.json', $printer->getFilename() );
+ }
+
+ /**
+ * Test specifying mime
+ */
+ public function testMime() {
+ $printer = new ApiFormatRaw( new ApiMain );
+ $printer->getResult()->addValue( null, 'mime', 'text/plain' );
+ $this->assertSame( 'text/plain', $printer->getMimeType() );
+ }
+
+ /**
+ * Test specifying mime with error fallback printer
+ */
+ public function testErrorFallbackMime() {
+ $apiMain = new ApiMain;
+ $printer = new ApiFormatRaw( $apiMain, new ApiFormatJson( $apiMain, 'json' ) );
+ $printer->getResult()->addValue( null, 'error', 'some error' );
+ $printer->getResult()->addValue( null, 'mime', 'text/plain' );
+ $this->assertSame( 'application/json', $printer->getMimeType() );
+ }
+
+ /**
+ * Check that setting failWithHTTPError to true will result in 400 response status code
+ */
+ public function testFailWithHTTPError() {
+ $apiMain = null;
+
+ $this->testGeneralEncoding(
+ [ 'mime' => 'text/plain', 'text' => 'some text', 'error' => 'some error' ],
+ '{"mime":"text/plain","text":"some text","error":"some error"}',
+ [],
+ [
+ 'class' => ApiFormatRaw::class,
+ 'factory' => function ( ApiMain $main ) use ( &$apiMain ) {
+ $apiMain = $main;
+ $printer = new ApiFormatRaw( $main, new ApiFormatJson( $main, 'json' ) );
+ $printer->setFailWithHTTPError( true );
+ return $printer;
+ }
+ ]
+ );
+ $this->assertEquals( 400, $apiMain->getRequest()->response()->getStatusCode() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatTestBase.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatTestBase.php
new file mode 100644
index 00000000..4169dab2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatTestBase.php
@@ -0,0 +1,93 @@
+<?php
+
+abstract class ApiFormatTestBase extends MediaWikiTestCase {
+
+ /**
+ * Name of the formatter being tested
+ * @var string
+ */
+ protected $printerName;
+
+ /**
+ * Return general data to be encoded for testing
+ * @return array See self::testGeneralEncoding
+ * @throws BadMethodCallException
+ */
+ public static function provideGeneralEncoding() {
+ throw new BadMethodCallException( static::class . ' must implement ' . __METHOD__ );
+ }
+
+ /**
+ * Get the formatter output for the given input data
+ * @param array $params Query parameters
+ * @param array $data Data to encode
+ * @param array $options Options. If passed a string, the string is treated
+ * as the 'class' option.
+ * - name: Format name, rather than $this->printerName
+ * - class: If set, register 'name' with this class (and 'factory', if that's set)
+ * - factory: Used with 'class' to register at runtime
+ * - returnPrinter: Return the printer object
+ * @param callable|null $factory Factory to use instead of the normal one
+ * @return string|array The string if $options['returnPrinter'] isn't set, or an array if it is:
+ * - text: Output text string
+ * - printer: ApiFormatBase
+ * @throws Exception
+ */
+ protected function encodeData( array $params, array $data, $options = [] ) {
+ if ( is_string( $options ) ) {
+ $options = [ 'class' => $options ];
+ }
+ $printerName = isset( $options['name'] ) ? $options['name'] : $this->printerName;
+
+ $context = new RequestContext;
+ $context->setRequest( new FauxRequest( $params, true ) );
+ $main = new ApiMain( $context );
+ if ( isset( $options['class'] ) ) {
+ $factory = isset( $options['factory'] ) ? $options['factory'] : null;
+ $main->getModuleManager()->addModule( $printerName, 'format', $options['class'], $factory );
+ }
+ $result = $main->getResult();
+ $result->addArrayType( null, 'default' );
+ foreach ( $data as $k => $v ) {
+ $result->addValue( null, $k, $v );
+ }
+
+ $ret = [];
+ $printer = $main->createPrinterByName( $printerName );
+ $printer->initPrinter();
+ $printer->execute();
+ ob_start();
+ try {
+ $printer->closePrinter();
+ $ret['text'] = ob_get_clean();
+ } catch ( Exception $ex ) {
+ ob_end_clean();
+ throw $ex;
+ }
+
+ if ( !empty( $options['returnPrinter'] ) ) {
+ $ret['printer'] = $printer;
+ }
+
+ return count( $ret ) === 1 ? $ret['text'] : $ret;
+ }
+
+ /**
+ * @dataProvider provideGeneralEncoding
+ * @param array $data Data to be encoded
+ * @param string|Exception $expect String to expect, or exception expected to be thrown
+ * @param array $params Query parameters to set in the FauxRequest
+ * @param array $options Options to pass to self::encodeData()
+ */
+ public function testGeneralEncoding(
+ array $data, $expect, array $params = [], array $options = []
+ ) {
+ if ( $expect instanceof Exception ) {
+ $this->setExpectedException( get_class( $expect ), $expect->getMessage() );
+ $this->encodeData( $params, $data, $options ); // Should throw
+ } else {
+ $this->assertSame( $expect, $this->encodeData( $params, $data, $options ) );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatXmlTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatXmlTest.php
new file mode 100644
index 00000000..915fb5c5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatXmlTest.php
@@ -0,0 +1,123 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @covers ApiFormatXml
+ */
+class ApiFormatXmlTest extends ApiFormatTestBase {
+
+ protected $printerName = 'xml';
+
+ public static function setUpBeforeClass() {
+ parent::setUpBeforeClass();
+ $page = WikiPage::factory( Title::newFromText( 'MediaWiki:ApiFormatXmlTest.xsl' ) );
+ // phpcs:disable Generic.Files.LineLength
+ $page->doEditContent( new WikitextContent(
+ '<?xml version="1.0"?><xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" />'
+ ), 'Summary' );
+ // phpcs:enable
+ $page = WikiPage::factory( Title::newFromText( 'MediaWiki:ApiFormatXmlTest' ) );
+ $page->doEditContent( new WikitextContent( 'Bogus' ), 'Summary' );
+ $page = WikiPage::factory( Title::newFromText( 'ApiFormatXmlTest' ) );
+ $page->doEditContent( new WikitextContent( 'Bogus' ), 'Summary' );
+ }
+
+ public static function provideGeneralEncoding() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ // Basic types
+ [ [ null, 'a' => null ], '<?xml version="1.0"?><api><_v _idx="0" /></api>' ],
+ [ [ true, 'a' => true ], '<?xml version="1.0"?><api a=""><_v _idx="0">true</_v></api>' ],
+ [ [ false, 'a' => false ], '<?xml version="1.0"?><api><_v _idx="0">false</_v></api>' ],
+ [ [ true, 'a' => true, ApiResult::META_BC_BOOLS => [ 0, 'a' ] ],
+ '<?xml version="1.0"?><api a=""><_v _idx="0">1</_v></api>' ],
+ [ [ false, 'a' => false, ApiResult::META_BC_BOOLS => [ 0, 'a' ] ],
+ '<?xml version="1.0"?><api><_v _idx="0"></_v></api>' ],
+ [ [ 42, 'a' => 42 ], '<?xml version="1.0"?><api a="42"><_v _idx="0">42</_v></api>' ],
+ [ [ 42.5, 'a' => 42.5 ], '<?xml version="1.0"?><api a="42.5"><_v _idx="0">42.5</_v></api>' ],
+ [ [ 1e42, 'a' => 1e42 ], '<?xml version="1.0"?><api a="1.0E+42"><_v _idx="0">1.0E+42</_v></api>' ],
+ [ [ 'foo', 'a' => 'foo' ], '<?xml version="1.0"?><api a="foo"><_v _idx="0">foo</_v></api>' ],
+ [ [ 'fóo', 'a' => 'fóo' ], '<?xml version="1.0"?><api a="fóo"><_v _idx="0">fóo</_v></api>' ],
+
+ // Arrays and objects
+ [ [ [] ], '<?xml version="1.0"?><api><_v /></api>' ],
+ [ [ [ 'x' => 1 ] ], '<?xml version="1.0"?><api><_v x="1" /></api>' ],
+ [ [ [ 2 => 1 ] ], '<?xml version="1.0"?><api><_v><_v _idx="2">1</_v></_v></api>' ],
+ [ [ (object)[] ], '<?xml version="1.0"?><api><_v /></api>' ],
+ [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '<?xml version="1.0"?><api><_v><_v _idx="0">1</_v></_v></api>' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '<?xml version="1.0"?><api><_v><_v>1</_v></_v></api>' ],
+ [ [ [ 'x' => 1, 'y' => [ 'z' => 1 ], ApiResult::META_TYPE => 'kvp' ] ],
+ '<?xml version="1.0"?><api><_v><_v _name="x" xml:space="preserve">1</_v><_v _name="y"><z xml:space="preserve">1</z></_v></_v></api>' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp', ApiResult::META_INDEXED_TAG_NAME => 'i', ApiResult::META_KVP_KEY_NAME => 'key' ] ],
+ '<?xml version="1.0"?><api><_v><i key="x" xml:space="preserve">1</i></_v></api>' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCkvp', ApiResult::META_KVP_KEY_NAME => 'key' ] ],
+ '<?xml version="1.0"?><api><_v><_v key="x" xml:space="preserve">1</_v></_v></api>' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '<?xml version="1.0"?><api><_v x="1" /></api>' ],
+ [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], '<?xml version="1.0"?><api><_v><_v _idx="0">a</_v><_v _idx="1">b</_v></_v></api>' ],
+
+ // Content
+ [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ '<?xml version="1.0"?><api xml:space="preserve">foo</api>' ],
+
+ // Specified element name
+ [ [ 'foo', 'bar', ApiResult::META_INDEXED_TAG_NAME => 'itn' ],
+ '<?xml version="1.0"?><api><itn>foo</itn><itn>bar</itn></api>' ],
+
+ // Subelements
+ [ [ 'a' => 1, 's' => 1, '_subelements' => [ 's' ] ],
+ '<?xml version="1.0"?><api a="1"><s xml:space="preserve">1</s></api>' ],
+
+ // Content and subelement
+ [ [ 'a' => 1, 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ '<?xml version="1.0"?><api a="1" xml:space="preserve">foo</api>' ],
+ [ [ 's' => [], 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ '<?xml version="1.0"?><api><s /><content xml:space="preserve">foo</content></api>' ],
+ [
+ [
+ 's' => 1,
+ 'content' => 'foo',
+ ApiResult::META_CONTENT => 'content',
+ ApiResult::META_SUBELEMENTS => [ 's' ]
+ ],
+ '<?xml version="1.0"?><api><s xml:space="preserve">1</s><content xml:space="preserve">foo</content></api>'
+ ],
+
+ // BC Subelements
+ [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
+ '<?xml version="1.0"?><api><foo xml:space="preserve">foo</foo></api>' ],
+
+ // Name mangling
+ [ [ 'foo.bar' => 1 ], '<?xml version="1.0"?><api foo.bar="1" />' ],
+ [ [ '' => 1 ], '<?xml version="1.0"?><api _="1" />' ],
+ [ [ 'foo bar' => 1 ], '<?xml version="1.0"?><api _foo.20.bar="1" />' ],
+ [ [ 'foo:bar' => 1 ], '<?xml version="1.0"?><api _foo.3A.bar="1" />' ],
+ [ [ 'foo%.bar' => 1 ], '<?xml version="1.0"?><api _foo.25..2E.bar="1" />' ],
+ [ [ '4foo' => 1, 'foo4' => 1 ], '<?xml version="1.0"?><api _4foo="1" foo4="1" />' ],
+ [ [ "foo\xe3\x80\x80bar" => 1 ], '<?xml version="1.0"?><api _foo.3000.bar="1" />' ],
+ [ [ 'foo:bar' => 1, ApiResult::META_PRESERVE_KEYS => [ 'foo:bar' ] ],
+ '<?xml version="1.0"?><api foo:bar="1" />' ],
+ [ [ 'a', 'b', ApiResult::META_INDEXED_TAG_NAME => 'foo bar' ],
+ '<?xml version="1.0"?><api><_foo.20.bar>a</_foo.20.bar><_foo.20.bar>b</_foo.20.bar></api>' ],
+
+ // includenamespace param
+ [ [ 'x' => 'foo' ], '<?xml version="1.0"?><api x="foo" xmlns="http://www.mediawiki.org/xml/api/" />',
+ [ 'includexmlnamespace' => 1 ] ],
+
+ // xslt param
+ [ [], '<?xml version="1.0"?><api><warnings><xml xml:space="preserve">Invalid or non-existent stylesheet specified.</xml></warnings></api>',
+ [ 'xslt' => 'DoesNotExist' ] ],
+ [ [], '<?xml version="1.0"?><api><warnings><xml xml:space="preserve">Stylesheet should be in the MediaWiki namespace.</xml></warnings></api>',
+ [ 'xslt' => 'ApiFormatXmlTest' ] ],
+ [ [], '<?xml version="1.0"?><api><warnings><xml xml:space="preserve">Stylesheet should have &quot;.xsl&quot; extension.</xml></warnings></api>',
+ [ 'xslt' => 'MediaWiki:ApiFormatXmlTest' ] ],
+ [ [],
+ '<?xml version="1.0"?><?xml-stylesheet href="' .
+ htmlspecialchars( Title::newFromText( 'MediaWiki:ApiFormatXmlTest.xsl' )->getLocalURL( 'action=raw' ) ) .
+ '" type="text/xsl" ?><api />',
+ [ 'xslt' => 'MediaWiki:ApiFormatXmlTest.xsl' ] ],
+ ];
+ // phpcs:enable
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/generateRandomImages.php b/www/wiki/tests/phpunit/includes/api/generateRandomImages.php
new file mode 100644
index 00000000..d4a5acff
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/generateRandomImages.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * Bootstrapping for test image file generation
+ *
+ * @file
+ */
+
+// Start up MediaWiki in command-line mode
+require_once __DIR__ . "/../../../../maintenance/Maintenance.php";
+require __DIR__ . "/RandomImageGenerator.php";
+
+class GenerateRandomImages extends Maintenance {
+
+ public function getDbType() {
+ return Maintenance::DB_NONE;
+ }
+
+ public function execute() {
+ $getOptSpec = [
+ 'dictionaryFile::',
+ 'minWidth::',
+ 'maxWidth::',
+ 'minHeight::',
+ 'maxHeight::',
+ 'shapesToDraw::',
+ 'shape::',
+
+ 'number::',
+ 'format::'
+ ];
+ $options = getopt( null, $getOptSpec );
+
+ $format = isset( $options['format'] ) ? $options['format'] : 'jpg';
+ unset( $options['format'] );
+
+ $number = isset( $options['number'] ) ? intval( $options['number'] ) : 10;
+ unset( $options['number'] );
+
+ $randomImageGenerator = new RandomImageGenerator( $options );
+ $randomImageGenerator->writeImages( $number, $format );
+ }
+}
+
+$maintClass = 'GenerateRandomImages';
+require RUN_MAINTENANCE_IF_MAIN;
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryBasicTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryBasicTest.php
new file mode 100644
index 00000000..e49e1d8b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryBasicTest.php
@@ -0,0 +1,346 @@
+<?php
+/**
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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
+ */
+
+/**
+ * These tests validate basic functionality of the api query module
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQuery
+ */
+class ApiQueryBasicTest extends ApiQueryTestBase {
+ protected $exceptionFromAddDBData;
+
+ /**
+ * Create a set of pages. These must not change, otherwise the tests might give wrong results.
+ *
+*@see MediaWikiTestCase::addDBDataOnce()
+ */
+ function addDBDataOnce() {
+ try {
+ if ( Title::newFromText( 'AQBT-All' )->exists() ) {
+ return;
+ }
+
+ // Ordering is important, as it will be returned in the same order as stored in the index
+ $this->editPage( 'AQBT-All', '[[Category:AQBT-Cat]] [[AQBT-Links]] {{AQBT-T}}' );
+ $this->editPage( 'AQBT-Categories', '[[Category:AQBT-Cat]]' );
+ $this->editPage( 'AQBT-Links', '[[AQBT-All]] [[AQBT-Categories]] [[AQBT-Templates]]' );
+ $this->editPage( 'AQBT-Templates', '{{AQBT-T}}' );
+ $this->editPage( 'AQBT-T', 'Content', '', NS_TEMPLATE );
+
+ // Refresh due to the bug with listing transclusions as links if they don't exist
+ $this->editPage( 'AQBT-All', '[[Category:AQBT-Cat]] [[AQBT-Links]] {{AQBT-T}}' );
+ $this->editPage( 'AQBT-Templates', '{{AQBT-T}}' );
+ } catch ( Exception $e ) {
+ $this->exceptionFromAddDBData = $e;
+ }
+ }
+
+ private static $links = [
+ [ 'prop' => 'links', 'titles' => 'AQBT-All' ],
+ [ 'pages' => [
+ '1' => [
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All',
+ 'links' => [
+ [ 'ns' => 0, 'title' => 'AQBT-Links' ],
+ ]
+ ]
+ ] ]
+ ];
+
+ private static $templates = [
+ [ 'prop' => 'templates', 'titles' => 'AQBT-All' ],
+ [ 'pages' => [
+ '1' => [
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All',
+ 'templates' => [
+ [ 'ns' => 10, 'title' => 'Template:AQBT-T' ],
+ ]
+ ]
+ ] ]
+ ];
+
+ private static $categories = [
+ [ 'prop' => 'categories', 'titles' => 'AQBT-All' ],
+ [ 'pages' => [
+ '1' => [
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All',
+ 'categories' => [
+ [ 'ns' => 14, 'title' => 'Category:AQBT-Cat' ],
+ ]
+ ]
+ ] ]
+ ];
+
+ private static $allpages = [
+ [ 'list' => 'allpages', 'apprefix' => 'AQBT-' ],
+ [ 'allpages' => [
+ [ 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ],
+ [ 'pageid' => 2, 'ns' => 0, 'title' => 'AQBT-Categories' ],
+ [ 'pageid' => 3, 'ns' => 0, 'title' => 'AQBT-Links' ],
+ [ 'pageid' => 4, 'ns' => 0, 'title' => 'AQBT-Templates' ],
+ ] ]
+ ];
+
+ private static $alllinks = [
+ [ 'list' => 'alllinks', 'alprefix' => 'AQBT-' ],
+ [ 'alllinks' => [
+ [ 'ns' => 0, 'title' => 'AQBT-All' ],
+ [ 'ns' => 0, 'title' => 'AQBT-Categories' ],
+ [ 'ns' => 0, 'title' => 'AQBT-Links' ],
+ [ 'ns' => 0, 'title' => 'AQBT-Templates' ],
+ ] ]
+ ];
+
+ private static $alltransclusions = [
+ [ 'list' => 'alltransclusions', 'atprefix' => 'AQBT-' ],
+ [ 'alltransclusions' => [
+ [ 'ns' => 10, 'title' => 'Template:AQBT-T' ],
+ [ 'ns' => 10, 'title' => 'Template:AQBT-T' ],
+ ] ]
+ ];
+
+ // Although this appears to have no use it is used by testLists()
+ private static $allcategories = [
+ [ 'list' => 'allcategories', 'acprefix' => 'AQBT-' ],
+ [ 'allcategories' => [
+ [ 'category' => 'AQBT-Cat' ],
+ ] ]
+ ];
+
+ private static $backlinks = [
+ [ 'list' => 'backlinks', 'bltitle' => 'AQBT-Links' ],
+ [ 'backlinks' => [
+ [ 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ],
+ ] ]
+ ];
+
+ private static $embeddedin = [
+ [ 'list' => 'embeddedin', 'eititle' => 'Template:AQBT-T' ],
+ [ 'embeddedin' => [
+ [ 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ],
+ [ 'pageid' => 4, 'ns' => 0, 'title' => 'AQBT-Templates' ],
+ ] ]
+ ];
+
+ private static $categorymembers = [
+ [ 'list' => 'categorymembers', 'cmtitle' => 'Category:AQBT-Cat' ],
+ [ 'categorymembers' => [
+ [ 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ],
+ [ 'pageid' => 2, 'ns' => 0, 'title' => 'AQBT-Categories' ],
+ ] ]
+ ];
+
+ private static $generatorAllpages = [
+ [ 'generator' => 'allpages', 'gapprefix' => 'AQBT-' ],
+ [ 'pages' => [
+ '1' => [
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All' ],
+ '2' => [
+ 'pageid' => 2,
+ 'ns' => 0,
+ 'title' => 'AQBT-Categories' ],
+ '3' => [
+ 'pageid' => 3,
+ 'ns' => 0,
+ 'title' => 'AQBT-Links' ],
+ '4' => [
+ 'pageid' => 4,
+ 'ns' => 0,
+ 'title' => 'AQBT-Templates' ],
+ ] ]
+ ];
+
+ private static $generatorLinks = [
+ [ 'generator' => 'links', 'titles' => 'AQBT-Links' ],
+ [ 'pages' => [
+ '1' => [
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All' ],
+ '2' => [
+ 'pageid' => 2,
+ 'ns' => 0,
+ 'title' => 'AQBT-Categories' ],
+ '4' => [
+ 'pageid' => 4,
+ 'ns' => 0,
+ 'title' => 'AQBT-Templates' ],
+ ] ]
+ ];
+
+ private static $generatorLinksPropLinks = [
+ [ 'prop' => 'links' ],
+ [ 'pages' => [
+ '1' => [ 'links' => [
+ [ 'ns' => 0, 'title' => 'AQBT-Links' ],
+ ] ]
+ ] ]
+ ];
+
+ private static $generatorLinksPropTemplates = [
+ [ 'prop' => 'templates' ],
+ [ 'pages' => [
+ '1' => [ 'templates' => [
+ [ 'ns' => 10, 'title' => 'Template:AQBT-T' ] ] ],
+ '4' => [ 'templates' => [
+ [ 'ns' => 10, 'title' => 'Template:AQBT-T' ] ] ],
+ ] ]
+ ];
+
+ /**
+ * Test basic props
+ */
+ public function testProps() {
+ $this->check( self::$links );
+ $this->check( self::$templates );
+ $this->check( self::$categories );
+ }
+
+ /**
+ * Test basic lists
+ */
+ public function testLists() {
+ $this->check( self::$allpages );
+ $this->check( self::$alllinks );
+ $this->check( self::$alltransclusions );
+ $this->check( self::$allcategories );
+ $this->check( self::$backlinks );
+ $this->check( self::$embeddedin );
+ $this->check( self::$categorymembers );
+ }
+
+ /**
+ * Test basic lists
+ */
+ public function testAllTogether() {
+ // All props together
+ $this->check( $this->merge(
+ self::$links,
+ self::$templates,
+ self::$categories
+ ) );
+
+ // All lists together
+ $this->check( $this->merge(
+ self::$allpages,
+ self::$alllinks,
+ self::$alltransclusions,
+ // This test is temporarily disabled until a sqlite bug is fixed
+ // self::$allcategories,
+ self::$backlinks,
+ self::$embeddedin,
+ self::$categorymembers
+ ) );
+
+ // All props+lists together
+ $this->check( $this->merge(
+ self::$links,
+ self::$templates,
+ self::$categories,
+ self::$allpages,
+ self::$alllinks,
+ self::$alltransclusions,
+ // This test is temporarily disabled until a sqlite bug is fixed
+ // self::$allcategories,
+ self::$backlinks,
+ self::$embeddedin,
+ self::$categorymembers
+ ) );
+ }
+
+ /**
+ * Test basic lists
+ */
+ public function testGenerator() {
+ // generator=allpages
+ $this->check( self::$generatorAllpages );
+ // generator=allpages & list=allpages
+ $this->check( $this->merge(
+ self::$generatorAllpages,
+ self::$allpages ) );
+ // generator=links
+ $this->check( self::$generatorLinks );
+ // generator=links & prop=links
+ $this->check( $this->merge(
+ self::$generatorLinks,
+ self::$generatorLinksPropLinks ) );
+ // generator=links & prop=templates
+ $this->check( $this->merge(
+ self::$generatorLinks,
+ self::$generatorLinksPropTemplates ) );
+ // generator=links & prop=links|templates
+ $this->check( $this->merge(
+ self::$generatorLinks,
+ self::$generatorLinksPropLinks,
+ self::$generatorLinksPropTemplates ) );
+ // generator=links & prop=links|templates & list=allpages|...
+ $this->check( $this->merge(
+ self::$generatorLinks,
+ self::$generatorLinksPropLinks,
+ self::$generatorLinksPropTemplates,
+ self::$allpages,
+ self::$alllinks,
+ self::$alltransclusions,
+ // This test is temporarily disabled until a sqlite bug is fixed
+ // self::$allcategories,
+ self::$backlinks,
+ self::$embeddedin,
+ self::$categorymembers ) );
+ }
+
+ /**
+ * Test T53821
+ */
+ public function testGeneratorRedirects() {
+ $this->editPage( 'AQBT-Target', 'test' );
+ $this->editPage( 'AQBT-Redir', '#REDIRECT [[AQBT-Target]]' );
+ $this->check( [
+ [ 'generator' => 'backlinks', 'gbltitle' => 'AQBT-Target', 'redirects' => '1' ],
+ [
+ 'redirects' => [
+ [
+ 'from' => 'AQBT-Redir',
+ 'to' => 'AQBT-Target',
+ ]
+ ],
+ 'pages' => [
+ '6' => [
+ 'pageid' => 6,
+ 'ns' => 0,
+ 'title' => 'AQBT-Target',
+ ]
+ ],
+ ]
+ ] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php
new file mode 100644
index 00000000..334fd5da
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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 3 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
+ */
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQuery
+ */
+class ApiQueryContinue2Test extends ApiQueryContinueTestBase {
+ protected $exceptionFromAddDBData;
+
+ /**
+ * Create a set of pages. These must not change, otherwise the tests might give wrong results.
+ *
+*@see MediaWikiTestCase::addDBDataOnce()
+ */
+ function addDBDataOnce() {
+ try {
+ $this->editPage( 'AQCT73462-A', '**AQCT73462-A** [[AQCT73462-B]] [[AQCT73462-C]]' );
+ $this->editPage( 'AQCT73462-B', '[[AQCT73462-A]] **AQCT73462-B** [[AQCT73462-C]]' );
+ $this->editPage( 'AQCT73462-C', '[[AQCT73462-A]] [[AQCT73462-B]] **AQCT73462-C**' );
+ $this->editPage( 'AQCT73462-A', '**AQCT73462-A** [[AQCT73462-B]] [[AQCT73462-C]]' );
+ $this->editPage( 'AQCT73462-B', '[[AQCT73462-A]] **AQCT73462-B** [[AQCT73462-C]]' );
+ $this->editPage( 'AQCT73462-C', '[[AQCT73462-A]] [[AQCT73462-B]] **AQCT73462-C**' );
+ } catch ( Exception $e ) {
+ $this->exceptionFromAddDBData = $e;
+ }
+ }
+
+ /**
+ * @group medium
+ */
+ public function testA() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p, $gDir ) {
+ return [
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT73462-',
+ 'prop' => 'links',
+ 'gaplimit' => "$g",
+ 'pllimit' => "$p",
+ 'gapdir' => $gDir ? "ascending" : "descending",
+ ];
+ };
+ // generator + 1 prop + 1 list
+ $data = $this->query( $mk( 99, 99, true ), 1, 'g1p', false ) +
+ [ 'batchcomplete' => true ];
+ $this->checkC( $data, $mk( 1, 1, true ), 6, 'g1p-11t' );
+ $this->checkC( $data, $mk( 2, 2, true ), 3, 'g1p-22t' );
+ $this->checkC( $data, $mk( 1, 1, false ), 6, 'g1p-11f' );
+ $this->checkC( $data, $mk( 2, 2, false ), 3, 'g1p-22f' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTest.php
new file mode 100644
index 00000000..7259bb81
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTest.php
@@ -0,0 +1,323 @@
+<?php
+/**
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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
+ */
+
+/**
+ * These tests validate the new continue functionality of the api query module by
+ * doing multiple requests with varying parameters, merging the results, and checking
+ * that the result matches the full data received in one no-limits call.
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQuery
+ */
+class ApiQueryContinueTest extends ApiQueryContinueTestBase {
+ protected $exceptionFromAddDBData;
+
+ /**
+ * Create a set of pages. These must not change, otherwise the tests might give wrong results.
+ *
+*@see MediaWikiTestCase::addDBDataOnce()
+ */
+ function addDBDataOnce() {
+ try {
+ $this->editPage( 'Template:AQCT-T1', '**Template:AQCT-T1**' );
+ $this->editPage( 'Template:AQCT-T2', '**Template:AQCT-T2**' );
+ $this->editPage( 'Template:AQCT-T3', '**Template:AQCT-T3**' );
+ $this->editPage( 'Template:AQCT-T4', '**Template:AQCT-T4**' );
+ $this->editPage( 'Template:AQCT-T5', '**Template:AQCT-T5**' );
+
+ $this->editPage( 'AQCT-1', '**AQCT-1** {{AQCT-T2}} {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' );
+ $this->editPage( 'AQCT-2', '[[AQCT-1]] **AQCT-2** {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' );
+ $this->editPage( 'AQCT-3', '[[AQCT-1]] [[AQCT-2]] **AQCT-3** {{AQCT-T4}} {{AQCT-T5}}' );
+ $this->editPage( 'AQCT-4', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] **AQCT-4** {{AQCT-T5}}' );
+ $this->editPage( 'AQCT-5', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] [[AQCT-4]] **AQCT-5**' );
+ } catch ( Exception $e ) {
+ $this->exceptionFromAddDBData = $e;
+ }
+ }
+
+ /**
+ * Test smart continue - list=allpages
+ * @group medium
+ */
+ public function test1List() {
+ $this->mVerbose = false;
+ $mk = function ( $l ) {
+ return [
+ 'list' => 'allpages',
+ 'apprefix' => 'AQCT-',
+ 'aplimit' => "$l",
+ ];
+ };
+ $data = $this->query( $mk( 99 ), 1, '1L', false ) +
+ [ 'batchcomplete' => true ];
+
+ // 1 list
+ $this->checkC( $data, $mk( 1 ), 5, '1L-1' );
+ $this->checkC( $data, $mk( 2 ), 3, '1L-2' );
+ $this->checkC( $data, $mk( 3 ), 2, '1L-3' );
+ $this->checkC( $data, $mk( 4 ), 2, '1L-4' );
+ $this->checkC( $data, $mk( 5 ), 1, '1L-5' );
+ }
+
+ /**
+ * Test smart continue - list=allpages|alltransclusions
+ * @group medium
+ */
+ public function test2Lists() {
+ $this->mVerbose = false;
+ $mk = function ( $l1, $l2 ) {
+ return [
+ 'list' => 'allpages|alltransclusions',
+ 'apprefix' => 'AQCT-',
+ 'atprefix' => 'AQCT-',
+ 'atunique' => '',
+ 'aplimit' => "$l1",
+ 'atlimit' => "$l2",
+ ];
+ };
+ // 2 lists
+ $data = $this->query( $mk( 99, 99 ), 1, '2L', false ) +
+ [ 'batchcomplete' => true ];
+ $this->checkC( $data, $mk( 1, 1 ), 5, '2L-11' );
+ $this->checkC( $data, $mk( 2, 2 ), 3, '2L-22' );
+ $this->checkC( $data, $mk( 3, 3 ), 2, '2L-33' );
+ $this->checkC( $data, $mk( 4, 4 ), 2, '2L-44' );
+ $this->checkC( $data, $mk( 5, 5 ), 1, '2L-55' );
+ }
+
+ /**
+ * Test smart continue - generator=allpages, prop=links
+ * @group medium
+ */
+ public function testGen1Prop() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p ) {
+ return [
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT-',
+ 'gaplimit' => "$g",
+ 'prop' => 'links',
+ 'pllimit' => "$p",
+ ];
+ };
+ // generator + 1 prop
+ $data = $this->query( $mk( 99, 99 ), 1, 'G1P', false ) +
+ [ 'batchcomplete' => true ];
+ $this->checkC( $data, $mk( 1, 1 ), 11, 'G1P-11' );
+ $this->checkC( $data, $mk( 2, 2 ), 6, 'G1P-22' );
+ $this->checkC( $data, $mk( 3, 3 ), 4, 'G1P-33' );
+ $this->checkC( $data, $mk( 4, 4 ), 3, 'G1P-44' );
+ $this->checkC( $data, $mk( 5, 5 ), 2, 'G1P-55' );
+ }
+
+ /**
+ * Test smart continue - generator=allpages, prop=links|templates
+ * @group medium
+ */
+ public function testGen2Prop() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p1, $p2 ) {
+ return [
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT-',
+ 'gaplimit' => "$g",
+ 'prop' => 'links|templates',
+ 'pllimit' => "$p1",
+ 'tllimit' => "$p2",
+ ];
+ };
+ // generator + 2 props
+ $data = $this->query( $mk( 99, 99, 99 ), 1, 'G2P', false ) +
+ [ 'batchcomplete' => true ];
+ $this->checkC( $data, $mk( 1, 1, 1 ), 16, 'G2P-111' );
+ $this->checkC( $data, $mk( 2, 2, 2 ), 9, 'G2P-222' );
+ $this->checkC( $data, $mk( 3, 3, 3 ), 6, 'G2P-333' );
+ $this->checkC( $data, $mk( 4, 4, 4 ), 4, 'G2P-444' );
+ $this->checkC( $data, $mk( 5, 5, 5 ), 2, 'G2P-555' );
+ $this->checkC( $data, $mk( 5, 1, 1 ), 10, 'G2P-511' );
+ $this->checkC( $data, $mk( 4, 2, 2 ), 7, 'G2P-422' );
+ $this->checkC( $data, $mk( 2, 3, 3 ), 7, 'G2P-233' );
+ $this->checkC( $data, $mk( 2, 4, 4 ), 5, 'G2P-244' );
+ $this->checkC( $data, $mk( 1, 5, 5 ), 5, 'G2P-155' );
+ }
+
+ /**
+ * Test smart continue - generator=allpages, prop=links, list=alltransclusions
+ * @group medium
+ */
+ public function testGen1Prop1List() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p, $l ) {
+ return [
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT-',
+ 'gaplimit' => "$g",
+ 'prop' => 'links',
+ 'pllimit' => "$p",
+ 'list' => 'alltransclusions',
+ 'atprefix' => 'AQCT-',
+ 'atunique' => '',
+ 'atlimit' => "$l",
+ ];
+ };
+ // generator + 1 prop + 1 list
+ $data = $this->query( $mk( 99, 99, 99 ), 1, 'G1P1L', false ) +
+ [ 'batchcomplete' => true ];
+ $this->checkC( $data, $mk( 1, 1, 1 ), 11, 'G1P1L-111' );
+ $this->checkC( $data, $mk( 2, 2, 2 ), 6, 'G1P1L-222' );
+ $this->checkC( $data, $mk( 3, 3, 3 ), 4, 'G1P1L-333' );
+ $this->checkC( $data, $mk( 4, 4, 4 ), 3, 'G1P1L-444' );
+ $this->checkC( $data, $mk( 5, 5, 5 ), 2, 'G1P1L-555' );
+ $this->checkC( $data, $mk( 5, 5, 1 ), 4, 'G1P1L-551' );
+ $this->checkC( $data, $mk( 5, 5, 2 ), 2, 'G1P1L-552' );
+ }
+
+ /**
+ * Test smart continue - generator=allpages, prop=links|templates,
+ * list=alllinks|alltransclusions, meta=siteinfo
+ * @group medium
+ */
+ public function testGen2Prop2List1Meta() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p1, $p2, $l1, $l2 ) {
+ return [
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT-',
+ 'gaplimit' => "$g",
+ 'prop' => 'links|templates',
+ 'pllimit' => "$p1",
+ 'tllimit' => "$p2",
+ 'list' => 'alllinks|alltransclusions',
+ 'alprefix' => 'AQCT-',
+ 'alunique' => '',
+ 'allimit' => "$l1",
+ 'atprefix' => 'AQCT-',
+ 'atunique' => '',
+ 'atlimit' => "$l2",
+ 'meta' => 'siteinfo',
+ 'siprop' => 'namespaces',
+ ];
+ };
+ // generator + 1 prop + 1 list
+ $data = $this->query( $mk( 99, 99, 99, 99, 99 ), 1, 'G2P2L1M', false ) +
+ [ 'batchcomplete' => true ];
+ $this->checkC( $data, $mk( 1, 1, 1, 1, 1 ), 16, 'G2P2L1M-11111' );
+ $this->checkC( $data, $mk( 2, 2, 2, 2, 2 ), 9, 'G2P2L1M-22222' );
+ $this->checkC( $data, $mk( 3, 3, 3, 3, 3 ), 6, 'G2P2L1M-33333' );
+ $this->checkC( $data, $mk( 4, 4, 4, 4, 4 ), 4, 'G2P2L1M-44444' );
+ $this->checkC( $data, $mk( 5, 5, 5, 5, 5 ), 2, 'G2P2L1M-55555' );
+ $this->checkC( $data, $mk( 5, 5, 5, 1, 1 ), 4, 'G2P2L1M-55511' );
+ $this->checkC( $data, $mk( 5, 5, 5, 2, 2 ), 2, 'G2P2L1M-55522' );
+ $this->checkC( $data, $mk( 5, 1, 1, 5, 5 ), 10, 'G2P2L1M-51155' );
+ $this->checkC( $data, $mk( 5, 2, 2, 5, 5 ), 5, 'G2P2L1M-52255' );
+ }
+
+ /**
+ * Test smart continue - generator=templates, prop=templates
+ * @group medium
+ */
+ public function testSameGenAndProp() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $gDir, $p, $pDir ) {
+ return [
+ 'titles' => 'AQCT-1',
+ 'generator' => 'templates',
+ 'gtllimit' => "$g",
+ 'gtldir' => $gDir ? 'ascending' : 'descending',
+ 'prop' => 'templates',
+ 'tllimit' => "$p",
+ 'tldir' => $pDir ? 'ascending' : 'descending',
+ ];
+ };
+ // generator + 1 prop
+ $data = $this->query( $mk( 99, true, 99, true ), 1, 'G=P', false ) +
+ [ 'batchcomplete' => true ];
+
+ $this->checkC( $data, $mk( 1, true, 1, true ), 4, 'G=P-1t1t' );
+ $this->checkC( $data, $mk( 2, true, 2, true ), 2, 'G=P-2t2t' );
+ $this->checkC( $data, $mk( 3, true, 3, true ), 2, 'G=P-3t3t' );
+ $this->checkC( $data, $mk( 1, true, 3, true ), 4, 'G=P-1t3t' );
+ $this->checkC( $data, $mk( 3, true, 1, true ), 2, 'G=P-3t1t' );
+
+ $this->checkC( $data, $mk( 1, true, 1, false ), 4, 'G=P-1t1f' );
+ $this->checkC( $data, $mk( 2, true, 2, false ), 2, 'G=P-2t2f' );
+ $this->checkC( $data, $mk( 3, true, 3, false ), 2, 'G=P-3t3f' );
+ $this->checkC( $data, $mk( 1, true, 3, false ), 4, 'G=P-1t3f' );
+ $this->checkC( $data, $mk( 3, true, 1, false ), 2, 'G=P-3t1f' );
+
+ $this->checkC( $data, $mk( 1, false, 1, true ), 4, 'G=P-1f1t' );
+ $this->checkC( $data, $mk( 2, false, 2, true ), 2, 'G=P-2f2t' );
+ $this->checkC( $data, $mk( 3, false, 3, true ), 2, 'G=P-3f3t' );
+ $this->checkC( $data, $mk( 1, false, 3, true ), 4, 'G=P-1f3t' );
+ $this->checkC( $data, $mk( 3, false, 1, true ), 2, 'G=P-3f1t' );
+
+ $this->checkC( $data, $mk( 1, false, 1, false ), 4, 'G=P-1f1f' );
+ $this->checkC( $data, $mk( 2, false, 2, false ), 2, 'G=P-2f2f' );
+ $this->checkC( $data, $mk( 3, false, 3, false ), 2, 'G=P-3f3f' );
+ $this->checkC( $data, $mk( 1, false, 3, false ), 4, 'G=P-1f3f' );
+ $this->checkC( $data, $mk( 3, false, 1, false ), 2, 'G=P-3f1f' );
+ }
+
+ /**
+ * Test smart continue - generator=allpages, list=allpages
+ * @group medium
+ */
+ public function testSameGenList() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $gDir, $l, $pDir ) {
+ return [
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT-',
+ 'gaplimit' => "$g",
+ 'gapdir' => $gDir ? 'ascending' : 'descending',
+ 'list' => 'allpages',
+ 'apprefix' => 'AQCT-',
+ 'aplimit' => "$l",
+ 'apdir' => $pDir ? 'ascending' : 'descending',
+ ];
+ };
+ // generator + 1 list
+ $data = $this->query( $mk( 99, true, 99, true ), 1, 'G=L', false ) +
+ [ 'batchcomplete' => true ];
+
+ $this->checkC( $data, $mk( 1, true, 1, true ), 5, 'G=L-1t1t' );
+ $this->checkC( $data, $mk( 2, true, 2, true ), 3, 'G=L-2t2t' );
+ $this->checkC( $data, $mk( 3, true, 3, true ), 2, 'G=L-3t3t' );
+ $this->checkC( $data, $mk( 1, true, 3, true ), 5, 'G=L-1t3t' );
+ $this->checkC( $data, $mk( 3, true, 1, true ), 5, 'G=L-3t1t' );
+ $this->checkC( $data, $mk( 1, true, 1, false ), 5, 'G=L-1t1f' );
+ $this->checkC( $data, $mk( 2, true, 2, false ), 3, 'G=L-2t2f' );
+ $this->checkC( $data, $mk( 3, true, 3, false ), 2, 'G=L-3t3f' );
+ $this->checkC( $data, $mk( 1, true, 3, false ), 5, 'G=L-1t3f' );
+ $this->checkC( $data, $mk( 3, true, 1, false ), 5, 'G=L-3t1f' );
+ $this->checkC( $data, $mk( 1, false, 1, true ), 5, 'G=L-1f1t' );
+ $this->checkC( $data, $mk( 2, false, 2, true ), 3, 'G=L-2f2t' );
+ $this->checkC( $data, $mk( 3, false, 3, true ), 2, 'G=L-3f3t' );
+ $this->checkC( $data, $mk( 1, false, 3, true ), 5, 'G=L-1f3t' );
+ $this->checkC( $data, $mk( 3, false, 1, true ), 5, 'G=L-3f1t' );
+ $this->checkC( $data, $mk( 1, false, 1, false ), 5, 'G=L-1f1f' );
+ $this->checkC( $data, $mk( 2, false, 2, false ), 3, 'G=L-2f2f' );
+ $this->checkC( $data, $mk( 3, false, 3, false ), 2, 'G=L-3f3f' );
+ $this->checkC( $data, $mk( 1, false, 3, false ), 5, 'G=L-1f3f' );
+ $this->checkC( $data, $mk( 3, false, 1, false ), 5, 'G=L-3f1f' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php
new file mode 100644
index 00000000..d2bdb496
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php
@@ -0,0 +1,210 @@
+<?php
+/**
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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
+ */
+abstract class ApiQueryContinueTestBase extends ApiQueryTestBase {
+
+ /**
+ * Enable to print in-depth debugging info during the test run
+ */
+ protected $mVerbose = false;
+
+ /**
+ * Run query() and compare against expected values
+ * @param array $expected
+ * @param array $params Api parameters
+ * @param int $expectedCount Max number of iterations
+ * @param string $id Unit test id
+ * @param bool $continue True to use smart continue
+ * @return array Merged results data array
+ */
+ protected function checkC( $expected, $params, $expectedCount, $id, $continue = true ) {
+ $result = $this->query( $params, $expectedCount, $id, $continue );
+ $this->assertResult( $expected, $result, $id );
+ }
+
+ /**
+ * Run query in a loop until no more values are available
+ * @param array $params Api parameters
+ * @param int $expectedCount Max number of iterations
+ * @param string $id Unit test id
+ * @param bool $useContinue True to use smart continue
+ * @return array Merged results data array
+ * @throws Exception
+ */
+ protected function query( $params, $expectedCount, $id, $useContinue = true ) {
+ if ( isset( $params['action'] ) ) {
+ $this->assertEquals( 'query', $params['action'], 'Invalid query action' );
+ } else {
+ $params['action'] = 'query';
+ }
+ $count = 0;
+ $result = [];
+ $continue = [];
+ do {
+ $request = array_merge( $params, $continue );
+ uksort( $request, function ( $a, $b ) {
+ // put 'continue' params at the end - lazy method
+ $a = strpos( $a, 'continue' ) !== false ? 'zzz ' . $a : $a;
+ $b = strpos( $b, 'continue' ) !== false ? 'zzz ' . $b : $b;
+
+ return strcmp( $a, $b );
+ } );
+ $reqStr = http_build_query( $request );
+ // $reqStr = str_replace( '&', ' & ', $reqStr );
+ $this->assertLessThan( $expectedCount, $count, "$id more data: $reqStr" );
+ if ( $this->mVerbose ) {
+ print "$id (#$count): $reqStr\n";
+ }
+ try {
+ $data = $this->doApiRequest( $request );
+ } catch ( Exception $e ) {
+ throw new Exception( "$id on $count", 0, $e );
+ }
+ $data = $data[0];
+ if ( isset( $data['warnings'] ) ) {
+ $warnings = json_encode( $data['warnings'] );
+ $this->fail( "$id Warnings on #$count in $reqStr\n$warnings" );
+ }
+ $this->assertArrayHasKey( 'query', $data, "$id no 'query' on #$count in $reqStr" );
+ if ( isset( $data['continue'] ) ) {
+ $continue = $data['continue'];
+ unset( $data['continue'] );
+ } else {
+ $continue = [];
+ }
+ if ( $this->mVerbose ) {
+ $this->printResult( $data );
+ }
+ $this->mergeResult( $result, $data );
+ $count++;
+ if ( empty( $continue ) ) {
+ $this->assertEquals( $expectedCount, $count, "$id finished early" );
+
+ return $result;
+ } elseif ( !$useContinue ) {
+ $this->assertFalse( 'Non-smart query must be requested all at once' );
+ }
+ } while ( true );
+ }
+
+ /**
+ * @param array $data
+ */
+ private function printResult( $data ) {
+ $q = $data['query'];
+ $print = [];
+ if ( isset( $q['pages'] ) ) {
+ foreach ( $q['pages'] as $p ) {
+ $m = $p['title'];
+ if ( isset( $p['links'] ) ) {
+ $m .= '/[' . implode( ',', array_map(
+ function ( $v ) {
+ return $v['title'];
+ },
+ $p['links'] ) ) . ']';
+ }
+ if ( isset( $p['categories'] ) ) {
+ $m .= '/(' . implode( ',', array_map(
+ function ( $v ) {
+ return str_replace( 'Category:', '', $v['title'] );
+ },
+ $p['categories'] ) ) . ')';
+ }
+ $print[] = $m;
+ }
+ }
+ if ( isset( $q['allcategories'] ) ) {
+ $print[] = '*Cats/(' . implode( ',', array_map(
+ function ( $v ) {
+ return $v['*'];
+ },
+ $q['allcategories'] ) ) . ')';
+ }
+ self::GetItems( $q, 'allpages', 'Pages', $print );
+ self::GetItems( $q, 'alllinks', 'Links', $print );
+ self::GetItems( $q, 'alltransclusions', 'Trnscl', $print );
+ print ' ' . implode( ' ', $print ) . "\n";
+ }
+
+ private static function GetItems( $q, $moduleName, $name, &$print ) {
+ if ( isset( $q[$moduleName] ) ) {
+ $print[] = "*$name/[" . implode( ',',
+ array_map(
+ function ( $v ) {
+ return $v['title'];
+ },
+ $q[$moduleName] ) ) . ']';
+ }
+ }
+
+ /**
+ * Recursively merge the new result returned from the query to the previous results.
+ * @param mixed &$results
+ * @param mixed $newResult
+ * @param bool $numericIds If true, treat keys as ids to be merged instead of appending
+ */
+ protected function mergeResult( &$results, $newResult, $numericIds = false ) {
+ $this->assertEquals(
+ is_array( $results ),
+ is_array( $newResult ),
+ 'Type of result and data do not match'
+ );
+ if ( !is_array( $results ) ) {
+ $this->assertEquals( $results, $newResult, 'Repeated result must be the same as before' );
+ } else {
+ $sort = null;
+ foreach ( $newResult as $key => $value ) {
+ if ( !$numericIds && $sort === null ) {
+ if ( !is_array( $value ) ) {
+ $sort = false;
+ } elseif ( array_key_exists( 'title', $value ) ) {
+ $sort = function ( $a, $b ) {
+ return strcmp( $a['title'], $b['title'] );
+ };
+ } else {
+ $sort = false;
+ }
+ }
+ $keyExists = array_key_exists( $key, $results );
+ if ( is_numeric( $key ) ) {
+ if ( $numericIds ) {
+ if ( !$keyExists ) {
+ $results[$key] = $value;
+ } else {
+ $this->mergeResult( $results[$key], $value );
+ }
+ } else {
+ $results[] = $value;
+ }
+ } elseif ( !$keyExists ) {
+ $results[$key] = $value;
+ } else {
+ $this->mergeResult( $results[$key], $value, $key === 'pages' );
+ }
+ }
+ if ( $numericIds ) {
+ ksort( $results, SORT_NUMERIC );
+ } elseif ( $sort !== null && $sort !== false ) {
+ usort( $results, $sort );
+ }
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php
new file mode 100644
index 00000000..38a1d685
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php
@@ -0,0 +1,44 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQueryRevisions
+ */
+class ApiQueryRevisionsTest extends ApiTestCase {
+
+ /**
+ * @group medium
+ */
+ public function testContentComesWithContentModelAndFormat() {
+ $pageName = 'Help:' . __METHOD__;
+ $title = Title::newFromText( $pageName );
+ $page = WikiPage::factory( $title );
+
+ $page->doEditContent(
+ ContentHandler::makeContent( 'Some text', $page->getTitle() ),
+ 'inserting content'
+ );
+
+ $apiResult = $this->doApiRequest( [
+ 'action' => 'query',
+ 'prop' => 'revisions',
+ 'titles' => $pageName,
+ 'rvprop' => 'content',
+ ] );
+ $this->assertArrayHasKey( 'query', $apiResult[0] );
+ $this->assertArrayHasKey( 'pages', $apiResult[0]['query'] );
+ foreach ( $apiResult[0]['query']['pages'] as $page ) {
+ $this->assertArrayHasKey( 'revisions', $page );
+ foreach ( $page['revisions'] as $revision ) {
+ $this->assertArrayHasKey( 'contentformat', $revision,
+ 'contentformat should be included when asking content so client knows how to interpret it'
+ );
+ $this->assertArrayHasKey( 'contentmodel', $revision,
+ 'contentmodel should be included when asking content so client knows how to interpret it'
+ );
+ }
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryTest.php
new file mode 100644
index 00000000..de8d8156
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryTest.php
@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQuery
+ */
+class ApiQueryTest extends ApiTestCase {
+ protected function setUp() {
+ parent::setUp();
+
+ // Setup apiquerytestiw: as interwiki prefix
+ $this->setMwGlobals( 'wgHooks', [
+ 'InterwikiLoadPrefix' => [
+ function ( $prefix, &$data ) {
+ if ( $prefix == 'apiquerytestiw' ) {
+ $data = [ 'iw_url' => 'wikipedia' ];
+ }
+ return false;
+ }
+ ]
+ ] );
+ }
+
+ public function testTitlesGetNormalized() {
+ global $wgMetaNamespace;
+
+ $this->setMwGlobals( [
+ 'wgCapitalLinks' => true,
+ ] );
+
+ $data = $this->doApiRequest( [
+ 'action' => 'query',
+ 'titles' => 'Project:articleA|article_B' ] );
+
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'normalized', $data[0]['query'] );
+
+ // Forge a normalized title
+ $to = Title::newFromText( $wgMetaNamespace . ':ArticleA' );
+
+ $this->assertEquals(
+ [
+ 'fromencoded' => false,
+ 'from' => 'Project:articleA',
+ 'to' => $to->getPrefixedText(),
+ ],
+ $data[0]['query']['normalized'][0]
+ );
+
+ $this->assertEquals(
+ [
+ 'fromencoded' => false,
+ 'from' => 'article_B',
+ 'to' => 'Article B'
+ ],
+ $data[0]['query']['normalized'][1]
+ );
+ }
+
+ public function testTitlesAreRejectedIfInvalid() {
+ $title = false;
+ while ( !$title || Title::newFromText( $title )->exists() ) {
+ $title = md5( mt_rand( 0, 100000 ) );
+ }
+
+ $data = $this->doApiRequest( [
+ 'action' => 'query',
+ 'titles' => $title . '|Talk:' ] );
+
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'pages', $data[0]['query'] );
+ $this->assertEquals( 2, count( $data[0]['query']['pages'] ) );
+
+ $this->assertArrayHasKey( -2, $data[0]['query']['pages'] );
+ $this->assertArrayHasKey( -1, $data[0]['query']['pages'] );
+
+ $this->assertArrayHasKey( 'missing', $data[0]['query']['pages'][-2] );
+ $this->assertArrayHasKey( 'invalid', $data[0]['query']['pages'][-1] );
+ }
+
+ public function testTitlesWithWhitespaces() {
+ $data = $this->doApiRequest( [
+ 'action' => 'query',
+ 'titles' => ' '
+ ] );
+
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'pages', $data[0]['query'] );
+ $this->assertEquals( 1, count( $data[0]['query']['pages'] ) );
+ $this->assertArrayHasKey( -1, $data[0]['query']['pages'] );
+ $this->assertArrayHasKey( 'invalid', $data[0]['query']['pages'][-1] );
+ }
+
+ /**
+ * Test the ApiBase::titlePartToKey function
+ *
+ * @param string $titlePart
+ * @param int $namespace
+ * @param string $expected
+ * @param string $expectException
+ * @dataProvider provideTestTitlePartToKey
+ */
+ function testTitlePartToKey( $titlePart, $namespace, $expected, $expectException ) {
+ $this->setMwGlobals( [
+ 'wgCapitalLinks' => true,
+ ] );
+
+ $api = new MockApiQueryBase();
+ $exceptionCaught = false;
+ try {
+ $this->assertEquals( $expected, $api->titlePartToKey( $titlePart, $namespace ) );
+ } catch ( ApiUsageException $e ) {
+ $exceptionCaught = true;
+ }
+ $this->assertEquals( $expectException, $exceptionCaught,
+ 'ApiUsageException thrown by titlePartToKey' );
+ }
+
+ function provideTestTitlePartToKey() {
+ return [
+ [ 'a b c', NS_MAIN, 'A_b_c', false ],
+ [ 'x', NS_MAIN, 'X', false ],
+ [ 'y ', NS_MAIN, 'Y_', false ],
+ [ 'template:foo', NS_CATEGORY, 'Template:foo', false ],
+ [ 'apiquerytestiw:foo', NS_CATEGORY, 'Apiquerytestiw:foo', false ],
+ [ "\xF7", NS_MAIN, null, true ],
+ [ 'template:foo', NS_MAIN, null, true ],
+ [ 'apiquerytestiw:foo', NS_MAIN, null, true ],
+ ];
+ }
+
+ /**
+ * Test if all classes in the query module manager exists
+ */
+ public function testClassNamesInModuleManager() {
+ $api = new ApiMain(
+ new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
+ );
+ $queryApi = new ApiQuery( $api, 'query' );
+ $modules = $queryApi->getModuleManager()->getNamesWithClasses();
+
+ foreach ( $modules as $name => $class ) {
+ $this->assertTrue(
+ class_exists( $class ),
+ 'Class ' . $class . ' for api module ' . $name . ' does not exist (with exact case)'
+ );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryTestBase.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryTestBase.php
new file mode 100644
index 00000000..e7588cb5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryTestBase.php
@@ -0,0 +1,158 @@
+<?php
+/**
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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 3 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
+ */
+
+/** This class has some common functionality for testing query module
+ */
+abstract class ApiQueryTestBase extends ApiTestCase {
+
+ const PARAM_ASSERT = <<<STR
+Each parameter must be an array of two elements,
+first - an array of params to the API call,
+and the second array - expected results as returned by the API
+STR;
+
+ /**
+ * Merges all requests parameter + expected values into one
+ * @param array $v,... List of arrays, each of which contains exactly two
+ * @return array
+ */
+ protected function merge( /*...*/ ) {
+ $request = [];
+ $expected = [];
+ foreach ( func_get_args() as $v ) {
+ list( $req, $exp ) = $this->validateRequestExpectedPair( $v );
+ $request = array_merge_recursive( $request, $req );
+ $this->mergeExpected( $expected, $exp );
+ }
+
+ return [ $request, $expected ];
+ }
+
+ /**
+ * Check that the parameter is a valid two element array,
+ * with the first element being API request and the second - expected result
+ * @param array $v
+ * @return array
+ */
+ private function validateRequestExpectedPair( $v ) {
+ $this->assertInternalType( 'array', $v, self::PARAM_ASSERT );
+ $this->assertEquals( 2, count( $v ), self::PARAM_ASSERT );
+ $this->assertArrayHasKey( 0, $v, self::PARAM_ASSERT );
+ $this->assertArrayHasKey( 1, $v, self::PARAM_ASSERT );
+ $this->assertInternalType( 'array', $v[0], self::PARAM_ASSERT );
+ $this->assertInternalType( 'array', $v[1], self::PARAM_ASSERT );
+
+ return $v;
+ }
+
+ /**
+ * Recursively merges the expected values in the $item into the $all
+ * @param array &$all
+ * @param array $item
+ */
+ private function mergeExpected( &$all, $item ) {
+ foreach ( $item as $k => $v ) {
+ if ( array_key_exists( $k, $all ) ) {
+ if ( is_array( $all[$k] ) ) {
+ $this->mergeExpected( $all[$k], $v );
+ } else {
+ $this->assertEquals( $all[$k], $v );
+ }
+ } else {
+ $all[$k] = $v;
+ }
+ }
+ }
+
+ /**
+ * Checks that the request's result matches the expected results.
+ * Assumes no rawcontinue and a complete batch.
+ * @param array $values Array is a two element array( request, expected_results )
+ * @param array $session
+ * @param bool $appendModule
+ * @param User $user
+ */
+ protected function check( $values, array $session = null,
+ $appendModule = false, User $user = null
+ ) {
+ list( $req, $exp ) = $this->validateRequestExpectedPair( $values );
+ if ( !array_key_exists( 'action', $req ) ) {
+ $req['action'] = 'query';
+ }
+ foreach ( $req as &$val ) {
+ if ( is_array( $val ) ) {
+ $val = implode( '|', array_unique( $val ) );
+ }
+ }
+ $result = $this->doApiRequest( $req, $session, $appendModule, $user );
+ $this->assertResult( [ 'batchcomplete' => true, 'query' => $exp ], $result[0], $req );
+ }
+
+ protected function assertResult( $exp, $result, $message = '' ) {
+ try {
+ $exp = self::sanitizeResultArray( $exp );
+ $result = self::sanitizeResultArray( $result );
+ $this->assertEquals( $exp, $result );
+ } catch ( PHPUnit_Framework_ExpectationFailedException $e ) {
+ if ( is_array( $message ) ) {
+ $message = http_build_query( $message );
+ }
+
+ // FIXME: once we migrate to phpunit 4.1+, hardcode ComparisonFailure exception use
+ $compEx = 'SebastianBergmann\Comparator\ComparisonFailure';
+ if ( !class_exists( $compEx ) ) {
+ $compEx = 'PHPUnit_Framework_ComparisonFailure';
+ }
+
+ throw new PHPUnit_Framework_ExpectationFailedException(
+ $e->getMessage() . "\nRequest: $message",
+ new $compEx(
+ $exp,
+ $result,
+ print_r( $exp, true ),
+ print_r( $result, true ),
+ false,
+ $e->getComparisonFailure()->getMessage() . "\nRequest: $message"
+ )
+ );
+ }
+ }
+
+ /**
+ * Recursively ksorts a result array and removes any 'pageid' keys.
+ * @param array $result
+ * @return array
+ */
+ private static function sanitizeResultArray( $result ) {
+ unset( $result['pageid'] );
+ foreach ( $result as $key => $value ) {
+ if ( is_array( $value ) ) {
+ $result[$key] = self::sanitizeResultArray( $value );
+ }
+ }
+
+ // Sort the result by keys, then take advantage of how array_merge will
+ // renumber numeric keys while leaving others alone.
+ ksort( $result );
+ return array_merge( $result );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php
new file mode 100644
index 00000000..ca6a929a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php
@@ -0,0 +1,194 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQueryContributions
+ */
+class ApiQueryContributionsTest extends ApiTestCase {
+ public function addDBDataOnce() {
+ global $wgActorTableSchemaMigrationStage;
+
+ $reset = new \Wikimedia\ScopedCallback( function ( $v ) {
+ global $wgActorTableSchemaMigrationStage;
+ $wgActorTableSchemaMigrationStage = $v;
+ $this->overrideMwServices();
+ }, [ $wgActorTableSchemaMigrationStage ] );
+ $wgActorTableSchemaMigrationStage = MIGRATION_WRITE_BOTH;
+ $this->overrideMwServices();
+
+ $users = [
+ User::newFromName( '192.168.2.2', false ),
+ User::newFromName( '192.168.2.1', false ),
+ User::newFromName( '192.168.2.3', false ),
+ User::createNew( __CLASS__ . ' B' ),
+ User::createNew( __CLASS__ . ' A' ),
+ User::createNew( __CLASS__ . ' C' ),
+ User::newFromName( 'IW>' . __CLASS__, false ),
+ ];
+
+ $title = Title::newFromText( __CLASS__ );
+ $page = WikiPage::factory( $title );
+ for ( $i = 0; $i < 3; $i++ ) {
+ foreach ( array_reverse( $users ) as $user ) {
+ $status = $page->doEditContent(
+ ContentHandler::makeContent( "Test revision $user #$i", $title ), 'Test edit', 0, false, $user
+ );
+ if ( !$status->isOK() ) {
+ $this->fail( "Failed to edit $title: " . $status->getWikiText( false, false, 'en' ) );
+ }
+ }
+ }
+ }
+
+ /**
+ * @dataProvider provideSorting
+ * @param int $stage One of the MIGRATION_* constants for $wgActorTableSchemaMigrationStage
+ * @param array $params Extra parameters for the query
+ * @param bool $reverse Reverse order?
+ * @param int $revs Number of revisions to expect
+ */
+ public function testSorting( $stage, $params, $reverse, $revs ) {
+ if ( isset( $params['ucuserprefix'] ) &&
+ ( $stage === MIGRATION_WRITE_BOTH || $stage === MIGRATION_WRITE_NEW ) &&
+ $this->db->getType() === 'mysql' && $this->usesTemporaryTables()
+ ) {
+ // https://bugs.mysql.com/bug.php?id=10327
+ $this->markTestSkipped( 'MySQL bug 10327 - can\'t reopen temporary tables' );
+ }
+
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', $stage );
+ $this->overrideMwServices();
+
+ if ( isset( $params['ucuserids'] ) ) {
+ $params['ucuserids'] = implode( '|', array_map( 'User::idFromName', $params['ucuserids'] ) );
+ }
+ if ( isset( $params['ucuser'] ) ) {
+ $params['ucuser'] = implode( '|', $params['ucuser'] );
+ }
+
+ $sort = 'rsort';
+ if ( $reverse ) {
+ $params['ucdir'] = 'newer';
+ $sort = 'sort';
+ }
+
+ $params += [
+ 'action' => 'query',
+ 'list' => 'usercontribs',
+ 'ucprop' => 'ids',
+ ];
+
+ $apiResult = $this->doApiRequest( $params + [ 'uclimit' => 500 ] );
+ $this->assertArrayNotHasKey( 'continue', $apiResult[0] );
+ $this->assertArrayHasKey( 'query', $apiResult[0] );
+ $this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'] );
+
+ $count = 0;
+ $ids = [];
+ foreach ( $apiResult[0]['query']['usercontribs'] as $page ) {
+ $count++;
+ $ids[$page['user']][] = $page['revid'];
+ }
+ $this->assertSame( $revs, $count, 'Expected number of revisions' );
+ foreach ( $ids as $user => $revids ) {
+ $sorted = $revids;
+ call_user_func_array( $sort, [ &$sorted ] );
+ $this->assertSame( $sorted, $revids, "IDs for $user are sorted" );
+ }
+
+ for ( $limit = 1; $limit < $revs; $limit++ ) {
+ $continue = [];
+ $count = 0;
+ $batchedIds = [];
+ while ( $continue !== null ) {
+ $apiResult = $this->doApiRequest( $params + [ 'uclimit' => $limit ] + $continue );
+ $this->assertArrayHasKey( 'query', $apiResult[0], "Batching with limit $limit" );
+ $this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'],
+ "Batching with limit $limit" );
+ $continue = isset( $apiResult[0]['continue'] ) ? $apiResult[0]['continue'] : null;
+ foreach ( $apiResult[0]['query']['usercontribs'] as $page ) {
+ $count++;
+ $batchedIds[$page['user']][] = $page['revid'];
+ }
+ $this->assertLessThanOrEqual( $revs, $count, "Batching with limit $limit" );
+ }
+ $this->assertSame( $ids, $batchedIds, "Result set is the same when batching with limit $limit" );
+ }
+ }
+
+ public static function provideSorting() {
+ $users = [ __CLASS__ . ' A', __CLASS__ . ' B', __CLASS__ . ' C' ];
+ $users2 = [ __CLASS__ . ' A', __CLASS__ . ' B', __CLASS__ . ' D' ];
+ $ips = [ '192.168.2.1', '192.168.2.2', '192.168.2.3', '192.168.2.4' ];
+
+ foreach (
+ [
+ 'old' => MIGRATION_OLD,
+ 'write both' => MIGRATION_WRITE_BOTH,
+ 'write new' => MIGRATION_WRITE_NEW,
+ 'new' => MIGRATION_NEW,
+ ] as $stageName => $stage
+ ) {
+ foreach ( [ false, true ] as $reverse ) {
+ $name = $stageName . ( $reverse ? ', reverse' : '' );
+ yield "Named users, $name" => [ $stage, [ 'ucuser' => $users ], $reverse, 9 ];
+ yield "Named users including a no-edit user, $name" => [
+ $stage, [ 'ucuser' => $users2 ], $reverse, 6
+ ];
+ yield "IP users, $name" => [ $stage, [ 'ucuser' => $ips ], $reverse, 9 ];
+ yield "All users, $name" => [
+ $stage, [ 'ucuser' => array_merge( $users, $ips ) ], $reverse, 18
+ ];
+ yield "User IDs, $name" => [ $stage, [ 'ucuserids' => $users ], $reverse, 9 ];
+ yield "Users by prefix, $name" => [ $stage, [ 'ucuserprefix' => __CLASS__ ], $reverse, 9 ];
+ yield "IPs by prefix, $name" => [ $stage, [ 'ucuserprefix' => '192.168.2.' ], $reverse, 9 ];
+ }
+ }
+ }
+
+ /**
+ * @dataProvider provideInterwikiUser
+ * @param int $stage One of the MIGRATION_* constants for $wgActorTableSchemaMigrationStage
+ */
+ public function testInterwikiUser( $stage ) {
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', $stage );
+ $this->overrideMwServices();
+
+ $params = [
+ 'action' => 'query',
+ 'list' => 'usercontribs',
+ 'ucuser' => 'IW>' . __CLASS__,
+ 'ucprop' => 'ids',
+ 'uclimit' => 'max',
+ ];
+
+ $apiResult = $this->doApiRequest( $params );
+ $this->assertArrayNotHasKey( 'continue', $apiResult[0] );
+ $this->assertArrayHasKey( 'query', $apiResult[0] );
+ $this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'] );
+
+ $count = 0;
+ $ids = [];
+ foreach ( $apiResult[0]['query']['usercontribs'] as $page ) {
+ $count++;
+ $this->assertSame( 'IW>' . __CLASS__, $page['user'], 'Correct user returned' );
+ $ids[] = $page['revid'];
+ }
+ $this->assertSame( 3, $count, 'Expected number of revisions' );
+ $sorted = $ids;
+ rsort( $sorted );
+ $this->assertSame( $sorted, $ids, "IDs are sorted" );
+ }
+
+ public static function provideInterwikiUser() {
+ return [
+ 'old' => [ MIGRATION_OLD ],
+ 'write both' => [ MIGRATION_WRITE_BOTH ],
+ 'write new' => [ MIGRATION_WRITE_NEW ],
+ 'new' => [ MIGRATION_NEW ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/words.txt b/www/wiki/tests/phpunit/includes/api/words.txt
new file mode 100644
index 00000000..7ce23ee3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/words.txt
@@ -0,0 +1,1000 @@
+Andaquian
+Anoplanthus
+Araquaju
+Astrophyton
+Avarish
+Batonga
+Bdellidae
+Betoyan
+Bismarck
+Britishness
+Carmen
+Chatillon
+Clement
+Coryphaena
+Croton
+Cyrillianism
+Dagomba
+Decimus
+Dichorisandra
+Duculinae
+Empusa
+Escallonia
+Fathometer
+Fon
+Fundulinae
+Gadswoons
+Gederathite
+Gemini
+Gerbera
+Gregarinida
+Gyracanthus
+Halopsychidae
+Hasidim
+Hemerobius
+Ichthyosauridae
+Iscariot
+Jeames
+Jesuitry
+Jovian
+Judaization
+Katie
+Ladin
+Langhian
+Lapithaean
+Lisette
+Macrochira
+Malaxis
+Malvastrum
+Maranhao
+Marxian
+Maurist
+Metrosideros
+Micky
+Microsporon
+Odacidae
+Ophiuchid
+Osmorhiza
+Paguma
+Palesman
+Papayaceae
+Pastinaca
+Philoxenian
+Pleurostigma
+Rarotongan
+Rhodoraceae
+Rong
+Saho
+Sanyakoan
+Sardanapalian
+Sauropoda
+Sedentaria
+Shambu
+Shukulumbwe
+Solonian
+Spaniardization
+Spirochaetaceae
+Stomatopoda
+Stratiotes
+Taiwanhemp
+Titanically
+Venetianed
+Victrola
+Yuman
+abatis
+abaton
+abjoint
+acanthoma
+acari
+acceptance
+actinography
+acuteness
+addiment
+adelite
+adelomorphic
+adelphogamy
+adipocele
+aelurophobia
+affined
+aflaunt
+agathokakological
+aischrolatreia
+alarmedly
+alebench
+aleurone
+allelotropic
+allerion
+alloplastic
+allowable
+alternacy
+alternariose
+altricial
+ambitionist
+amendment
+amiableness
+amicableness
+ammo
+amortizable
+anchorate
+anemometrically
+angelocracy
+angelological
+anodal
+anomalure
+antedate
+antiagglutinin
+antirationalist
+antiscorbutic
+antisplasher
+antithesize
+antiunionist
+antoecian
+apolegamic
+appropriation
+archididascalian
+archival
+arteriophlebotomy
+articulable
+asseveration
+assignation
+atelo
+atrienses
+atrophy
+atterminement
+atypic
+automower
+aveloz
+awrist
+azteca
+bairnteam
+balsamweed
+bannerman
+beardy
+becry
+beek
+beggarwise
+bescab
+bestness
+bethel
+bewildering
+bibliophilism
+bitterblain
+blakeberyed
+boccarella
+bocedization
+boobyalla
+bourbon
+bowbent
+bowerbird
+brachygnathous
+brail
+branchiferous
+brelaw
+brew
+brideweed
+bridgeable
+brombenzamide
+buddler
+burbankian
+burr
+buskin
+cacochymical
+calefactory
+caliper
+canaliculus
+candidature
+canellaceous
+canniness
+canning
+cantilene
+carbonatation
+carthamic
+caseum
+caudated
+causationist
+ceruleite
+chalder
+chalta
+charmel
+chekan
+chillness
+chirogymnast
+chirpling
+chlorinous
+cholanthrene
+chondroblast
+chromatography
+chromophilous
+chronical
+cicatrice
+cinchonine
+city
+clubbing
+coastal
+coaxially
+coercible
+coeternity
+coff
+coinventor
+collyba
+combinator
+complanation
+comprehensibility
+conchuela
+congenital
+context
+contranatural
+corallum
+cordately
+cornupete
+corolliferous
+coroneted
+corticosterone
+coseat
+cottage
+crocetin
+crossleted
+crottels
+curvedness
+cycadeous
+cyclism
+cylindrically
+cynanche
+cyrtoceratitic
+cystospasm
+danceress
+dancette
+dawny
+daydreamy
+debar
+decarburization
+decorousness
+decrepitness
+delirious
+deozonizer
+dermatosis
+desma
+deutencephalic
+diacetate
+diarthrodial
+diathermy
+dicolic
+dimastigate
+dimidiation
+dipetto
+disavowable
+disintrench
+disman
+dismay
+disorder
+disoxygenation
+dithionous
+dogman
+dragonfly
+dramatical
+drawspan
+drubbly
+drunk
+duskly
+ecderonic
+ectocuniform
+ectocyst
+ehrwaldite
+electrocute
+elemicin
+embracing
+emotionality
+enactment
+enamor
+enclave
+endameba
+endochylous
+endocrinologist
+endolymph
+endothecal
+entasia
+epigeous
+episcopicide
+epitrichial
+erminee
+erraticalness
+eruptivity
+erythrocytoschisis
+esperance
+estuous
+eucrystalline
+eugeny
+evacuant
+everbloomer
+evocation
+exarchateship
+exasperate
+excorticate
+excrementary
+exile
+expandedly
+exponency
+expressionist
+expulsion
+extemporary
+extollation
+extortive
+extrabulbar
+extraprostatic
+facticide
+fairer
+fakery
+fasibitikite
+fatiscent
+fearless
+febrifuge
+ferie
+fibrousness
+fingered
+fisheye
+flagpole
+flagrantness
+fleche
+fluidism
+folliculin
+footbreadth
+forceps
+forecontrive
+forthbring
+foveated
+fuchsin
+fungicidal
+funori
+gamelang
+gametically
+garvanzo
+gasoliner
+gastrophile
+germproof
+gerontism
+gigantical
+glaciology
+godmotherhood
+gooseherd
+gordunite
+gove
+gracilis
+greathead
+grieveship
+guidable
+gyromancy
+gyrostat
+habitus
+hailweed
+handhole
+hangalai
+haznadar
+heliced
+hemihypertrophy
+hemimorphic
+hemistrumectomy
+heptavalent
+heptite
+herbalist
+herpetology
+hesperid
+hexacarbon
+hieromnemon
+hobbyless
+holodactylic
+homoeoarchy
+hopperings
+hospitable
+houseboat
+huh
+huntedly
+hydroponics
+hydrosomal
+hyperdactylia
+hyperperistalsis
+hypogeocarpous
+ideogram
+idiopathical
+illegitimate
+imambarah
+impotently
+improvise
+impuberal
+inaccurately
+incarnant
+inchoation
+incliner
+incredulous
+indiscriminateness
+indulgenced
+inebriation
+inexpressiveness
+infibulate
+inflectedness
+iniome
+ink
+inquietly
+insaturable
+insinuative
+instiller
+institutive
+insultproof
+interactionist
+intercensal
+interpenetrable
+intertranspicuous
+intrinsicality
+inwards
+iridiocyte
+iridoparalysis
+irreportable
+isoprene
+isosmotic
+izard
+jacuaru
+jaculative
+jerkined
+joe
+joyous
+julienne
+justicehood
+kali
+kalidium
+katha
+kathal
+keelage
+keratomycosis
+khaki
+khedival
+kinkily
+knife
+kolo
+kraken
+kwarta
+labba
+labber
+laboress
+lacunar
+latch
+lauric
+lawter
+lectotype
+leeches
+legible
+lepidosteoid
+leucobasalt
+leverer
+libellate
+limnimeter
+lithography
+lithotypic
+locomotor
+logarithmetically
+logistician
+lyncine
+lysogenesis
+machan
+macromyelon
+maharana
+mandibulate
+manganapatite
+marchpane
+mas
+masochistic
+mastaba
+matching
+meditatively
+megalopolitan
+melaniline
+mentum
+mercaptides
+mestome
+metasomatism
+meterless
+micronuclear
+micropetalous
+microreaction
+microsporophore
+mileway
+milliarium
+millisecond
+misbind
+miscollocation
+misreader
+modernicide
+modification
+modulant
+monkfish
+monoamino
+monocarbide
+monographical
+morphinomaniac
+mullein
+munge
+mutilate
+mycophagist
+myelosarcoma
+myospasm
+myriadly
+nagaika
+naphthionate
+natant
+naviculaeform
+nayward
+neallotype
+necrophilia
+nectared
+neigher
+neogamous
+neurodynia
+neurorthopteran
+nidation
+nieceship
+nitrobacteria
+nitrosification
+nogheaded
+nonassertive
+noneuphonious
+nonextant
+nonincrease
+nonintermittent
+nonmetallic
+nonprehensile
+nonremunerative
+nonsocial
+nonvesting
+noontime
+noreaster
+nounal
+nub
+nucleoplasm
+nullisome
+numero
+numerous
+oblongatal
+observe
+obtusilingual
+obvert
+occipitoatlantal
+oceanside
+ochlophobist
+odontiasis
+opalescence
+opticon
+oraculousness
+orarium
+organically
+orthopedically
+ostosis
+overadvance
+overbuilt
+overdiscouragement
+overdoer
+overhardy
+overjocular
+overmagnify
+overofficered
+overpotent
+overprizer
+overrunner
+overshrink
+oversimply
+oversplash
+ovology
+oxskin
+oxychloride
+oxygenant
+ozokerite
+pactional
+palaeoanthropography
+palaeographical
+palaeopsychology
+palliasse
+palpebral
+pandaric
+pantelegraph
+papicolist
+papulate
+parakinetic
+parasitism
+parochialic
+parochialize
+passionlike
+patch
+paucidentate
+pawnbrokeress
+pecite
+pecky
+pedipulation
+pellitory
+perfilograph
+periblast
+perigemmal
+periost
+periplus
+perishable
+periwig
+permansive
+persistingly
+persymmetrical
+phantom
+phasmatrope
+philocaly
+philogyny
+philosophister
+philotherianism
+phorology
+phototrophic
+phrator
+phratral
+phthisipneumony
+physogastry
+phytologic
+phytoptid
+pianograph
+picqueter
+piculet
+pigeoner
+pimaric
+pinesap
+pist
+planometer
+platano
+playful
+plea
+pleuropneumonic
+plowwoman
+plump
+pluviographical
+pneumocele
+podophthalmate
+polyad
+polythalamian
+poppyhead
+portamento
+portmanteau
+portraitlike
+possible
+potassamide
+powderer
+praepubis
+preanesthetic
+prebarbaric
+predealer
+predomination
+prefactory
+preirrigational
+prelector
+presbytership
+presecure
+preservable
+prespecialist
+preventionism
+prewound
+princely
+priorship
+proannexationist
+proanthropos
+probeable
+probouleutic
+profitless
+proplasma
+prosectorial
+protecting
+protochemistry
+protosulphate
+pseudoataxia
+psilology
+psychoneurotic
+pterygial
+publicist
+purgation
+purplishness
+putatively
+pyracene
+pyrenomycete
+pyromancy
+pyrophone
+quadroon
+quailhead
+qualifier
+quaternal
+rabblelike
+rambunctious
+rapidness
+ratably
+rationalism
+razor
+reannoy
+recultivation
+regulable
+reimplant
+reimposition
+reimprison
+reinjure
+reinspiration
+reintroduce
+remantle
+reprehensibility
+reptant
+require
+resteal
+restful
+returnability
+revisableness
+rewash
+rewhirl
+reyield
+rhizotomy
+rhodamine
+rigwiddie
+rimester
+ripper
+rippet
+rockish
+rockwards
+rollicky
+roosters
+rooted
+rosal
+rozum
+saccharated
+sagamore
+sagy
+salesmanship
+salivous
+sallet
+salta
+saprostomous
+satiation
+sauropsid
+sawarra
+sawback
+scabish
+scabrate
+scampavia
+scientificophilosophical
+scirrosity
+scoliometer
+scolopendrelloid
+secantly
+seignioral
+semibull
+semic
+seminarianism
+semiped
+semiprivate
+semispherical
+semispontaneous
+seneschal
+septendecimal
+serotherapist
+servation
+sesquisulphuret
+severish
+sextipartite
+sextubercular
+shipyard
+shuckpen
+siderosis
+silex
+sillyhow
+silverbelly
+silverbelly
+simulacrum
+sisham
+sixte
+skeiner
+skiapod
+slopped
+slubby
+smalts
+sockmaker
+solute
+somethingness
+somnify
+southwester
+spathilla
+spectrochemical
+sphagnology
+spinales
+spiriting
+spirling
+spirochetemia
+spreadboard
+spurflower
+squawdom
+squeezing
+staircase
+staker
+stamphead
+statolith
+stekan
+stellulate
+stinker
+stomodaea
+streamingly
+strikingness
+strouthocamelian
+stuprum
+subacutely
+subboreal
+subcontractor
+subendorsement
+subprofitable
+subserviate
+subsneer
+subungual
+sucuruju
+sugan
+sulphocarbolate
+summerwood
+superficialist
+superinference
+superregenerative
+supplicate
+suspendible
+synchronizer
+syntectic
+tachyglossate
+tailless
+taintment
+takingly
+taletelling
+tarpon
+tasteful
+taxeater
+taxy
+teache
+teachless
+teg
+tegmen
+teletyper
+temperable
+ten
+tenent
+teskere
+testes
+thallogen
+thapsia
+thewness
+thickety
+thiobacteria
+thorniness
+throwing
+thyroprivic
+tinnitus
+tocalote
+tolerationist
+tonalamatl
+torvous
+totality
+tottering
+toug
+tracheopathia
+tragedical
+translucent
+trifoveolate
+trilaurin
+trophoplasmatic
+trunkless
+turbanless
+turnpiker
+twangle
+twitterboned
+ultraornate
+umbilication
+unabatingly
+unabjured
+unadequateness
+unaffectedness
+unarriving
+unassorted
+unattacked
+unbenumbed
+unboasted
+unburning
+uncensorious
+uncongested
+uncontemnedly
+uncontemporary
+uncrook
+uncrystallizability
+uncurb
+uncustomariness
+underbillow
+undercanopy
+underestimation
+underhanging
+underpetticoated
+underpropped
+undersole
+understocking
+underworld
+undevout
+undisappointing
+undistinctive
+unfiscal
+unfluted
+unfreckled
+ungentilize
+unglobe
+unhelped
+unhomogeneously
+unifoliate
+uninflammable
+uninterrogated
+unisonal
+unkindled
+unlikeableness
+unlisty
+unlocked
+unmoving
+unmultipliable
+unnestled
+unnoticed
+unobservable
+unobviated
+unoffensively
+unofficerlike
+unpoetic
+unpractically
+unquestionableness
+unrehearsed
+unrevised
+unrhetorical
+unsadden
+unsaluting
+unscriptural
+unseeking
+unshowed
+unsolicitous
+unsprouted
+unsubjective
+unsubsidized
+unsymbolic
+untenant
+unterrified
+untranquil
+untraversed
+untrusty
+untying
+unwillful
+unwinding
+upspring
+uptwist
+urachovesical
+uropygial
+vagabondism
+varicoid
+varletess
+vasal
+ventrocaudal
+verisimilitude
+vermigerous
+vibrometer
+viminal
+virus
+vocationalism
+voguey
+vulnerability
+waggle
+wamblingly
+warmus
+waxer
+waying
+wedgeable
+wellmaker
+whomever
+wigged
+witchlike
+wokas
+woodrowel
+woodsman
+woolding
+xanthelasmic
+xiphosternum
+yachtman
+yachtsmanlike
+yelp
+zoophytal \ No newline at end of file
diff --git a/www/wiki/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php
new file mode 100644
index 00000000..b271b701
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/AbstractAuthenticationProviderTest.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AbstractAuthenticationProvider
+ */
+class AbstractAuthenticationProviderTest extends \MediaWikiTestCase {
+ public function testAbstractAuthenticationProvider() {
+ $provider = $this->getMockForAbstractClass( AbstractAuthenticationProvider::class );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+
+ $obj = $this->getMockForAbstractClass( \Psr\Log\LoggerInterface::class );
+ $provider->setLogger( $obj );
+ $this->assertSame( $obj, $providerPriv->logger, 'setLogger' );
+
+ $obj = AuthManager::singleton();
+ $provider->setManager( $obj );
+ $this->assertSame( $obj, $providerPriv->manager, 'setManager' );
+
+ $obj = $this->getMockForAbstractClass( \Config::class );
+ $provider->setConfig( $obj );
+ $this->assertSame( $obj, $providerPriv->config, 'setConfig' );
+
+ $this->assertType( 'string', $provider->getUniqueId(), 'getUniqueId' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php
new file mode 100644
index 00000000..cb015df6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/AbstractPasswordPrimaryAuthenticationProviderTest.php
@@ -0,0 +1,228 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AbstractPasswordPrimaryAuthenticationProvider
+ */
+class AbstractPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestCase {
+ public function testConstructor() {
+ $provider = $this->getMockForAbstractClass(
+ AbstractPasswordPrimaryAuthenticationProvider::class
+ );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+ $this->assertTrue( $providerPriv->authoritative );
+
+ $provider = $this->getMockForAbstractClass(
+ AbstractPasswordPrimaryAuthenticationProvider::class,
+ [ [ 'authoritative' => false ] ]
+ );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+ $this->assertFalse( $providerPriv->authoritative );
+ }
+
+ public function testGetPasswordFactory() {
+ $provider = $this->getMockForAbstractClass(
+ AbstractPasswordPrimaryAuthenticationProvider::class
+ );
+ $provider->setConfig( MediaWikiServices::getInstance()->getMainConfig() );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+
+ $obj = $providerPriv->getPasswordFactory();
+ $this->assertInstanceOf( \PasswordFactory::class, $obj );
+ $this->assertSame( $obj, $providerPriv->getPasswordFactory() );
+ }
+
+ public function testGetPassword() {
+ $provider = $this->getMockForAbstractClass(
+ AbstractPasswordPrimaryAuthenticationProvider::class
+ );
+ $provider->setConfig( MediaWikiServices::getInstance()->getMainConfig() );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+
+ $obj = $providerPriv->getPassword( null );
+ $this->assertInstanceOf( \Password::class, $obj );
+
+ $obj = $providerPriv->getPassword( 'invalid' );
+ $this->assertInstanceOf( \Password::class, $obj );
+ }
+
+ public function testGetNewPasswordExpiry() {
+ $config = new \HashConfig;
+ $provider = $this->getMockForAbstractClass(
+ AbstractPasswordPrimaryAuthenticationProvider::class
+ );
+ $provider->setConfig( new \MultiConfig( [
+ $config,
+ MediaWikiServices::getInstance()->getMainConfig()
+ ] ) );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'ResetPasswordExpiration' => [] ] );
+
+ $config->set( 'PasswordExpirationDays', 0 );
+ $this->assertNull( $providerPriv->getNewPasswordExpiry( 'UTSysop' ) );
+
+ $config->set( 'PasswordExpirationDays', 5 );
+ $this->assertEquals(
+ time() + 5 * 86400,
+ wfTimestamp( TS_UNIX, $providerPriv->getNewPasswordExpiry( 'UTSysop' ) ),
+ '',
+ 2 /* Fuzz */
+ );
+
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'ResetPasswordExpiration' => [ function ( $user, &$expires ) {
+ $this->assertSame( 'UTSysop', $user->getName() );
+ $expires = '30001231235959';
+ } ]
+ ] );
+ $this->assertEquals( '30001231235959', $providerPriv->getNewPasswordExpiry( 'UTSysop' ) );
+ }
+
+ public function testCheckPasswordValidity() {
+ $uppCalled = 0;
+ $uppStatus = \Status::newGood();
+ $this->setMwGlobals( [
+ 'wgPasswordPolicy' => [
+ 'policies' => [
+ 'default' => [
+ 'Check' => true,
+ ],
+ ],
+ 'checks' => [
+ 'Check' => function () use ( &$uppCalled, &$uppStatus ) {
+ $uppCalled++;
+ return $uppStatus;
+ },
+ ],
+ ]
+ ] );
+
+ $provider = $this->getMockForAbstractClass(
+ AbstractPasswordPrimaryAuthenticationProvider::class
+ );
+ $provider->setConfig( MediaWikiServices::getInstance()->getMainConfig() );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+
+ $this->assertEquals( $uppStatus, $providerPriv->checkPasswordValidity( 'foo', 'bar' ) );
+
+ $uppStatus->fatal( 'arbitrary-warning' );
+ $this->assertEquals( $uppStatus, $providerPriv->checkPasswordValidity( 'foo', 'bar' ) );
+ }
+
+ public function testSetPasswordResetFlag() {
+ $config = new \HashConfig( [
+ 'InvalidPasswordReset' => true,
+ ] );
+
+ $manager = new AuthManager(
+ new \FauxRequest(),
+ MediaWikiServices::getInstance()->getMainConfig()
+ );
+
+ $provider = $this->getMockForAbstractClass(
+ AbstractPasswordPrimaryAuthenticationProvider::class
+ );
+ $provider->setConfig( $config );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setManager( $manager );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+
+ $manager->removeAuthenticationSessionData( null );
+ $status = \Status::newGood();
+ $providerPriv->setPasswordResetFlag( 'Foo', $status );
+ $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
+
+ $manager->removeAuthenticationSessionData( null );
+ $status = \Status::newGood();
+ $status->error( 'testing' );
+ $providerPriv->setPasswordResetFlag( 'Foo', $status );
+ $ret = $manager->getAuthenticationSessionData( 'reset-pass' );
+ $this->assertNotNull( $ret );
+ $this->assertSame( 'resetpass-validity-soft', $ret->msg->getKey() );
+ $this->assertFalse( $ret->hard );
+
+ $config->set( 'InvalidPasswordReset', false );
+ $manager->removeAuthenticationSessionData( null );
+ $providerPriv->setPasswordResetFlag( 'Foo', $status );
+ $ret = $manager->getAuthenticationSessionData( 'reset-pass' );
+ $this->assertNull( $ret );
+ }
+
+ public function testFailResponse() {
+ $provider = $this->getMockForAbstractClass(
+ AbstractPasswordPrimaryAuthenticationProvider::class,
+ [ [ 'authoritative' => false ] ]
+ );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+
+ $req = new PasswordAuthenticationRequest;
+
+ $ret = $providerPriv->failResponse( $req );
+ $this->assertSame( AuthenticationResponse::ABSTAIN, $ret->status );
+
+ $provider = $this->getMockForAbstractClass(
+ AbstractPasswordPrimaryAuthenticationProvider::class,
+ [ [ 'authoritative' => true ] ]
+ );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+
+ $req->password = '';
+ $ret = $providerPriv->failResponse( $req );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'wrongpasswordempty', $ret->message->getKey() );
+
+ $req->password = 'X';
+ $ret = $providerPriv->failResponse( $req );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'wrongpassword', $ret->message->getKey() );
+ }
+
+ /**
+ * @dataProvider provideGetAuthenticationRequests
+ * @param string $action
+ * @param array $response
+ */
+ public function testGetAuthenticationRequests( $action, $response ) {
+ $provider = $this->getMockForAbstractClass(
+ AbstractPasswordPrimaryAuthenticationProvider::class
+ );
+
+ $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) );
+ }
+
+ public static function provideGetAuthenticationRequests() {
+ return [
+ [ AuthManager::ACTION_LOGIN, [ new PasswordAuthenticationRequest() ] ],
+ [ AuthManager::ACTION_CREATE, [ new PasswordAuthenticationRequest() ] ],
+ [ AuthManager::ACTION_LINK, [] ],
+ [ AuthManager::ACTION_CHANGE, [ new PasswordAuthenticationRequest() ] ],
+ [ AuthManager::ACTION_REMOVE, [ new PasswordAuthenticationRequest() ] ],
+ ];
+ }
+
+ public function testProviderRevokeAccessForUser() {
+ $req = new PasswordAuthenticationRequest;
+ $req->action = AuthManager::ACTION_REMOVE;
+ $req->username = 'foo';
+ $req->password = null;
+
+ $provider = $this->getMockForAbstractClass(
+ AbstractPasswordPrimaryAuthenticationProvider::class
+ );
+ $provider->expects( $this->once() )
+ ->method( 'providerChangeAuthenticationData' )
+ ->with( $this->equalTo( $req ) );
+
+ $provider->providerRevokeAccessForUser( 'foo' );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php
new file mode 100644
index 00000000..96384518
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AbstractPreAuthenticationProvider
+ */
+class AbstractPreAuthenticationProviderTest extends \MediaWikiTestCase {
+ public function testAbstractPreAuthenticationProvider() {
+ $user = \User::newFromName( 'UTSysop' );
+
+ $provider = $this->getMockForAbstractClass( AbstractPreAuthenticationProvider::class );
+
+ $this->assertEquals(
+ [],
+ $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] )
+ );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAuthentication( [] )
+ );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAccountCreation( $user, $user, [] )
+ );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION )
+ );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testUserForCreation( $user, false )
+ );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAccountLink( $user )
+ );
+
+ $res = AuthenticationResponse::newPass();
+ $provider->postAuthentication( $user, $res );
+ $provider->postAccountCreation( $user, $user, $res );
+ $provider->postAccountLink( $user, $res );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php
new file mode 100644
index 00000000..8d84f4ca
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/AbstractPrimaryAuthenticationProviderTest.php
@@ -0,0 +1,174 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AbstractPrimaryAuthenticationProvider
+ */
+class AbstractPrimaryAuthenticationProviderTest extends \MediaWikiTestCase {
+ public function testAbstractPrimaryAuthenticationProvider() {
+ $user = \User::newFromName( 'UTSysop' );
+
+ $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class );
+
+ try {
+ $provider->continuePrimaryAuthentication( [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \BadMethodCallException $ex ) {
+ }
+
+ try {
+ $provider->continuePrimaryAccountCreation( $user, $user, [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \BadMethodCallException $ex ) {
+ }
+
+ $req = $this->getMockForAbstractClass( AuthenticationRequest::class );
+
+ $this->assertTrue( $provider->providerAllowsPropertyChange( 'foo' ) );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAccountCreation( $user, $user, [] )
+ );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION )
+ );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testUserForCreation( $user, false )
+ );
+
+ $this->assertNull(
+ $provider->finishAccountCreation( $user, $user, AuthenticationResponse::newPass() )
+ );
+ $provider->autoCreatedAccount( $user, AuthManager::AUTOCREATE_SOURCE_SESSION );
+
+ $res = AuthenticationResponse::newPass();
+ $provider->postAuthentication( $user, $res );
+ $provider->postAccountCreation( $user, $user, $res );
+ $provider->postAccountLink( $user, $res );
+
+ $provider->expects( $this->once() )
+ ->method( 'testUserExists' )
+ ->with( $this->equalTo( 'foo' ) )
+ ->will( $this->returnValue( true ) );
+ $this->assertTrue( $provider->testUserCanAuthenticate( 'foo' ) );
+ }
+
+ public function testProviderRevokeAccessForUser() {
+ $reqs = [];
+ for ( $i = 0; $i < 3; $i++ ) {
+ $reqs[$i] = $this->createMock( AuthenticationRequest::class );
+ $reqs[$i]->done = false;
+ }
+
+ $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class );
+ $provider->expects( $this->once() )->method( 'getAuthenticationRequests' )
+ ->with(
+ $this->identicalTo( AuthManager::ACTION_REMOVE ),
+ $this->identicalTo( [ 'username' => 'UTSysop' ] )
+ )
+ ->will( $this->returnValue( $reqs ) );
+ $provider->expects( $this->exactly( 3 ) )->method( 'providerChangeAuthenticationData' )
+ ->will( $this->returnCallback( function ( $req ) {
+ $this->assertSame( 'UTSysop', $req->username );
+ $this->assertFalse( $req->done );
+ $req->done = true;
+ } ) );
+
+ $provider->providerRevokeAccessForUser( 'UTSysop' );
+
+ foreach ( $reqs as $i => $req ) {
+ $this->assertTrue( $req->done, "#$i" );
+ }
+ }
+
+ /**
+ * @dataProvider providePrimaryAccountLink
+ * @param string $type PrimaryAuthenticationProvider::TYPE_* constant
+ * @param string $msg Error message from beginPrimaryAccountLink
+ */
+ public function testPrimaryAccountLink( $type, $msg ) {
+ $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class );
+ $provider->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( $type ) );
+
+ $class = AbstractPrimaryAuthenticationProvider::class;
+ $msg1 = "{$class}::beginPrimaryAccountLink $msg";
+ $msg2 = "{$class}::continuePrimaryAccountLink is not implemented.";
+
+ $user = \User::newFromName( 'Whatever' );
+
+ try {
+ $provider->beginPrimaryAccountLink( $user, [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \BadMethodCallException $ex ) {
+ $this->assertSame( $msg1, $ex->getMessage() );
+ }
+ try {
+ $provider->continuePrimaryAccountLink( $user, [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \BadMethodCallException $ex ) {
+ $this->assertSame( $msg2, $ex->getMessage() );
+ }
+ }
+
+ public static function providePrimaryAccountLink() {
+ return [
+ [
+ PrimaryAuthenticationProvider::TYPE_NONE,
+ 'should not be called on a non-link provider.',
+ ],
+ [
+ PrimaryAuthenticationProvider::TYPE_CREATE,
+ 'should not be called on a non-link provider.',
+ ],
+ [
+ PrimaryAuthenticationProvider::TYPE_LINK,
+ 'is not implemented.',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideProviderNormalizeUsername
+ */
+ public function testProviderNormalizeUsername( $name, $expect ) {
+ // fake interwiki map for the 'Interwiki prefix' testcase
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'InterwikiLoadPrefix' => [
+ function ( $prefix, &$iwdata ) {
+ if ( $prefix === 'interwiki' ) {
+ $iwdata = [
+ 'iw_url' => 'http://example.com/',
+ 'iw_local' => 0,
+ 'iw_trans' => 0,
+ ];
+ return false;
+ }
+ },
+ ],
+ ] );
+
+ $provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class );
+ $this->assertSame( $expect, $provider->providerNormalizeUsername( $name ) );
+ }
+
+ public static function provideProviderNormalizeUsername() {
+ return [
+ 'Leading space' => [ ' Leading space', 'Leading space' ],
+ 'Trailing space ' => [ 'Trailing space ', 'Trailing space' ],
+ 'Namespace prefix' => [ 'Talk:Username', null ],
+ 'Interwiki prefix' => [ 'interwiki:Username', null ],
+ 'With hash' => [ 'name with # hash', null ],
+ 'Multi spaces' => [ 'Multi spaces', 'Multi spaces' ],
+ 'Lowercase' => [ 'lowercase', 'Lowercase' ],
+ 'Invalid character' => [ 'in[]valid', null ],
+ 'With slash' => [ 'with / slash', null ],
+ 'Underscores' => [ '___under__scores___', 'Under scores' ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php
new file mode 100644
index 00000000..41cf62ea
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AbstractSecondaryAuthenticationProvider
+ */
+class AbstractSecondaryAuthenticationProviderTest extends \MediaWikiTestCase {
+ public function testAbstractSecondaryAuthenticationProvider() {
+ $user = \User::newFromName( 'UTSysop' );
+
+ $provider = $this->getMockForAbstractClass( AbstractSecondaryAuthenticationProvider::class );
+
+ try {
+ $provider->continueSecondaryAuthentication( $user, [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \BadMethodCallException $ex ) {
+ }
+
+ try {
+ $provider->continueSecondaryAccountCreation( $user, $user, [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \BadMethodCallException $ex ) {
+ }
+
+ $req = $this->getMockForAbstractClass( AuthenticationRequest::class );
+
+ $this->assertTrue( $provider->providerAllowsPropertyChange( 'foo' ) );
+ $this->assertEquals(
+ \StatusValue::newGood( 'ignored' ),
+ $provider->providerAllowsAuthenticationDataChange( $req )
+ );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAccountCreation( $user, $user, [] )
+ );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION )
+ );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testUserForCreation( $user, false )
+ );
+
+ $provider->providerChangeAuthenticationData( $req );
+ $provider->autoCreatedAccount( $user, AuthManager::AUTOCREATE_SOURCE_SESSION );
+
+ $res = AuthenticationResponse::newPass();
+ $provider->postAuthentication( $user, $res );
+ $provider->postAccountCreation( $user, $user, $res );
+ }
+
+ public function testProviderRevokeAccessForUser() {
+ $reqs = [];
+ for ( $i = 0; $i < 3; $i++ ) {
+ $reqs[$i] = $this->createMock( AuthenticationRequest::class );
+ $reqs[$i]->done = false;
+ }
+
+ $provider = $this->getMockBuilder( AbstractSecondaryAuthenticationProvider::class )
+ ->setMethods( [ 'providerChangeAuthenticationData' ] )
+ ->getMockForAbstractClass();
+ $provider->expects( $this->once() )->method( 'getAuthenticationRequests' )
+ ->with(
+ $this->identicalTo( AuthManager::ACTION_REMOVE ),
+ $this->identicalTo( [ 'username' => 'UTSysop' ] )
+ )
+ ->will( $this->returnValue( $reqs ) );
+ $provider->expects( $this->exactly( 3 ) )->method( 'providerChangeAuthenticationData' )
+ ->will( $this->returnCallback( function ( $req ) {
+ $this->assertSame( 'UTSysop', $req->username );
+ $this->assertFalse( $req->done );
+ $req->done = true;
+ } ) );
+
+ $provider->providerRevokeAccessForUser( 'UTSysop' );
+
+ foreach ( $reqs as $i => $req ) {
+ $this->assertTrue( $req->done, "#$i" );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/AuthManagerTest.php b/www/wiki/tests/phpunit/includes/auth/AuthManagerTest.php
new file mode 100644
index 00000000..cc162487
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/AuthManagerTest.php
@@ -0,0 +1,3629 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use Config;
+use MediaWiki\Session\SessionInfo;
+use MediaWiki\Session\UserInfo;
+use Psr\Log\LoggerInterface;
+use Psr\Log\LogLevel;
+use StatusValue;
+use WebRequest;
+use Wikimedia\ScopedCallback;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group AuthManager
+ * @group Database
+ * @covers MediaWiki\Auth\AuthManager
+ */
+class AuthManagerTest extends \MediaWikiTestCase {
+ /** @var WebRequest */
+ protected $request;
+ /** @var Config */
+ protected $config;
+ /** @var LoggerInterface */
+ protected $logger;
+
+ protected $preauthMocks = [];
+ protected $primaryauthMocks = [];
+ protected $secondaryauthMocks = [];
+
+ /** @var AuthManager */
+ protected $manager;
+ /** @var TestingAccessWrapper */
+ protected $managerPriv;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [ 'wgAuth' => null ] );
+ $this->stashMwGlobals( [ 'wgHooks' ] );
+ }
+
+ /**
+ * Sets a mock on a hook
+ * @param string $hook
+ * @param object $expect From $this->once(), $this->never(), etc.
+ * @return object $mock->expects( $expect )->method( ... ).
+ */
+ protected function hook( $hook, $expect ) {
+ global $wgHooks;
+ $mock = $this->getMockBuilder( __CLASS__ )
+ ->setMethods( [ "on$hook" ] )
+ ->getMock();
+ $wgHooks[$hook] = [ $mock ];
+ return $mock->expects( $expect )->method( "on$hook" );
+ }
+
+ /**
+ * Unsets a hook
+ * @param string $hook
+ */
+ protected function unhook( $hook ) {
+ global $wgHooks;
+ $wgHooks[$hook] = [];
+ }
+
+ /**
+ * Ensure a value is a clean Message object
+ * @param string|Message $key
+ * @param array $params
+ * @return Message
+ */
+ protected function message( $key, $params = [] ) {
+ if ( $key === null ) {
+ return null;
+ }
+ if ( $key instanceof \MessageSpecifier ) {
+ $params = $key->getParams();
+ $key = $key->getKey();
+ }
+ return new \Message( $key, $params, \Language::factory( 'en' ) );
+ }
+
+ /**
+ * Initialize the AuthManagerConfig variable in $this->config
+ *
+ * Uses data from the various 'mocks' fields.
+ */
+ protected function initializeConfig() {
+ $config = [
+ 'preauth' => [
+ ],
+ 'primaryauth' => [
+ ],
+ 'secondaryauth' => [
+ ],
+ ];
+
+ foreach ( [ 'preauth', 'primaryauth', 'secondaryauth' ] as $type ) {
+ $key = $type . 'Mocks';
+ foreach ( $this->$key as $mock ) {
+ $config[$type][$mock->getUniqueId()] = [ 'factory' => function () use ( $mock ) {
+ return $mock;
+ } ];
+ }
+ }
+
+ $this->config->set( 'AuthManagerConfig', $config );
+ $this->config->set( 'LanguageCode', 'en' );
+ $this->config->set( 'NewUserLog', false );
+ }
+
+ /**
+ * Initialize $this->manager
+ * @param bool $regen Force a call to $this->initializeConfig()
+ */
+ protected function initializeManager( $regen = false ) {
+ if ( $regen || !$this->config ) {
+ $this->config = new \HashConfig();
+ }
+ if ( $regen || !$this->request ) {
+ $this->request = new \FauxRequest();
+ }
+ if ( !$this->logger ) {
+ $this->logger = new \TestLogger();
+ }
+
+ if ( $regen || !$this->config->has( 'AuthManagerConfig' ) ) {
+ $this->initializeConfig();
+ }
+ $this->manager = new AuthManager( $this->request, $this->config );
+ $this->manager->setLogger( $this->logger );
+ $this->managerPriv = TestingAccessWrapper::newFromObject( $this->manager );
+ }
+
+ /**
+ * Setup SessionManager with a mock session provider
+ * @param bool|null $canChangeUser If non-null, canChangeUser will be mocked to return this
+ * @param array $methods Additional methods to mock
+ * @return array (MediaWiki\Session\SessionProvider, ScopedCallback)
+ */
+ protected function getMockSessionProvider( $canChangeUser = null, array $methods = [] ) {
+ if ( !$this->config ) {
+ $this->config = new \HashConfig();
+ $this->initializeConfig();
+ }
+ $this->config->set( 'ObjectCacheSessionExpiry', 100 );
+
+ $methods[] = '__toString';
+ $methods[] = 'describe';
+ if ( $canChangeUser !== null ) {
+ $methods[] = 'canChangeUser';
+ }
+ $provider = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( $methods )
+ ->getMock();
+ $provider->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'MockSessionProvider' ) );
+ $provider->expects( $this->any() )->method( 'describe' )
+ ->will( $this->returnValue( 'MockSessionProvider sessions' ) );
+ if ( $canChangeUser !== null ) {
+ $provider->expects( $this->any() )->method( 'canChangeUser' )
+ ->will( $this->returnValue( $canChangeUser ) );
+ }
+ $this->config->set( 'SessionProviders', [
+ [ 'factory' => function () use ( $provider ) {
+ return $provider;
+ } ],
+ ] );
+
+ $manager = new \MediaWiki\Session\SessionManager( [
+ 'config' => $this->config,
+ 'logger' => new \Psr\Log\NullLogger(),
+ 'store' => new \HashBagOStuff(),
+ ] );
+ TestingAccessWrapper::newFromObject( $manager )->getProvider( (string)$provider );
+
+ $reset = \MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager );
+
+ if ( $this->request ) {
+ $manager->getSessionForRequest( $this->request );
+ }
+
+ return [ $provider, $reset ];
+ }
+
+ public function testSingleton() {
+ // Temporarily clear out the global singleton, if any, to test creating
+ // one.
+ $rProp = new \ReflectionProperty( AuthManager::class, 'instance' );
+ $rProp->setAccessible( true );
+ $old = $rProp->getValue();
+ $cb = new ScopedCallback( [ $rProp, 'setValue' ], [ $old ] );
+ $rProp->setValue( null );
+
+ $singleton = AuthManager::singleton();
+ $this->assertInstanceOf( AuthManager::class, AuthManager::singleton() );
+ $this->assertSame( $singleton, AuthManager::singleton() );
+ $this->assertSame( \RequestContext::getMain()->getRequest(), $singleton->getRequest() );
+ $this->assertSame(
+ \RequestContext::getMain()->getConfig(),
+ TestingAccessWrapper::newFromObject( $singleton )->config
+ );
+ }
+
+ public function testCanAuthenticateNow() {
+ $this->initializeManager();
+
+ list( $provider, $reset ) = $this->getMockSessionProvider( false );
+ $this->assertFalse( $this->manager->canAuthenticateNow() );
+ ScopedCallback::consume( $reset );
+
+ list( $provider, $reset ) = $this->getMockSessionProvider( true );
+ $this->assertTrue( $this->manager->canAuthenticateNow() );
+ ScopedCallback::consume( $reset );
+ }
+
+ public function testNormalizeUsername() {
+ $mocks = [
+ $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ),
+ $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ),
+ $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ),
+ $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ),
+ ];
+ foreach ( $mocks as $key => $mock ) {
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $key ) );
+ }
+ $mocks[0]->expects( $this->once() )->method( 'providerNormalizeUsername' )
+ ->with( $this->identicalTo( 'XYZ' ) )
+ ->willReturn( 'Foo' );
+ $mocks[1]->expects( $this->once() )->method( 'providerNormalizeUsername' )
+ ->with( $this->identicalTo( 'XYZ' ) )
+ ->willReturn( 'Foo' );
+ $mocks[2]->expects( $this->once() )->method( 'providerNormalizeUsername' )
+ ->with( $this->identicalTo( 'XYZ' ) )
+ ->willReturn( null );
+ $mocks[3]->expects( $this->once() )->method( 'providerNormalizeUsername' )
+ ->with( $this->identicalTo( 'XYZ' ) )
+ ->willReturn( 'Bar!' );
+
+ $this->primaryauthMocks = $mocks;
+
+ $this->initializeManager();
+
+ $this->assertSame( [ 'Foo', 'Bar!' ], $this->manager->normalizeUsername( 'XYZ' ) );
+ }
+
+ /**
+ * @dataProvider provideSecuritySensitiveOperationStatus
+ * @param bool $mutableSession
+ */
+ public function testSecuritySensitiveOperationStatus( $mutableSession ) {
+ $this->logger = new \Psr\Log\NullLogger();
+ $user = \User::newFromName( 'UTSysop' );
+ $provideUser = null;
+ $reauth = $mutableSession ? AuthManager::SEC_REAUTH : AuthManager::SEC_FAIL;
+
+ list( $provider, $reset ) = $this->getMockSessionProvider(
+ $mutableSession, [ 'provideSessionInfo' ]
+ );
+ $provider->expects( $this->any() )->method( 'provideSessionInfo' )
+ ->will( $this->returnCallback( function () use ( $provider, &$provideUser ) {
+ return new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => \DummySessionProvider::ID,
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newFromUser( $provideUser, true )
+ ] );
+ } ) );
+ $this->initializeManager();
+
+ $this->config->set( 'ReauthenticateTime', [] );
+ $this->config->set( 'AllowSecuritySensitiveOperationIfCannotReauthenticate', [] );
+ $provideUser = new \User;
+ $session = $provider->getManager()->getSessionForRequest( $this->request );
+ $this->assertSame( 0, $session->getUser()->getId(), 'sanity check' );
+
+ // Anonymous user => reauth
+ $session->set( 'AuthManager:lastAuthId', 0 );
+ $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 );
+ $this->assertSame( $reauth, $this->manager->securitySensitiveOperationStatus( 'foo' ) );
+
+ $provideUser = $user;
+ $session = $provider->getManager()->getSessionForRequest( $this->request );
+ $this->assertSame( $user->getId(), $session->getUser()->getId(), 'sanity check' );
+
+ // Error for no default (only gets thrown for non-anonymous user)
+ $session->set( 'AuthManager:lastAuthId', $user->getId() + 1 );
+ $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 );
+ try {
+ $this->manager->securitySensitiveOperationStatus( 'foo' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ $mutableSession
+ ? '$wgReauthenticateTime lacks a default'
+ : '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default',
+ $ex->getMessage()
+ );
+ }
+
+ if ( $mutableSession ) {
+ $this->config->set( 'ReauthenticateTime', [
+ 'test' => 100,
+ 'test2' => -1,
+ 'default' => 10,
+ ] );
+
+ // Mismatched user ID
+ $session->set( 'AuthManager:lastAuthId', $user->getId() + 1 );
+ $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 );
+ $this->assertSame(
+ AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' )
+ );
+ $this->assertSame(
+ AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'test' )
+ );
+ $this->assertSame(
+ AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test2' )
+ );
+
+ // Missing time
+ $session->set( 'AuthManager:lastAuthId', $user->getId() );
+ $session->set( 'AuthManager:lastAuthTimestamp', null );
+ $this->assertSame(
+ AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' )
+ );
+ $this->assertSame(
+ AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'test' )
+ );
+ $this->assertSame(
+ AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test2' )
+ );
+
+ // Recent enough to pass
+ $session->set( 'AuthManager:lastAuthTimestamp', time() - 5 );
+ $this->assertSame(
+ AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'foo' )
+ );
+
+ // Not recent enough to pass
+ $session->set( 'AuthManager:lastAuthTimestamp', time() - 20 );
+ $this->assertSame(
+ AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' )
+ );
+ // But recent enough for the 'test' operation
+ $this->assertSame(
+ AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test' )
+ );
+ } else {
+ $this->config->set( 'AllowSecuritySensitiveOperationIfCannotReauthenticate', [
+ 'test' => false,
+ 'default' => true,
+ ] );
+
+ $this->assertEquals(
+ AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'foo' )
+ );
+
+ $this->assertEquals(
+ AuthManager::SEC_FAIL, $this->manager->securitySensitiveOperationStatus( 'test' )
+ );
+ }
+
+ // Test hook, all three possible values
+ foreach ( [
+ AuthManager::SEC_OK => AuthManager::SEC_OK,
+ AuthManager::SEC_REAUTH => $reauth,
+ AuthManager::SEC_FAIL => AuthManager::SEC_FAIL,
+ ] as $hook => $expect ) {
+ $this->hook( 'SecuritySensitiveOperationStatus', $this->exactly( 2 ) )
+ ->with(
+ $this->anything(),
+ $this->anything(),
+ $this->callback( function ( $s ) use ( $session ) {
+ return $s->getId() === $session->getId();
+ } ),
+ $mutableSession ? $this->equalTo( 500, 1 ) : $this->equalTo( -1 )
+ )
+ ->will( $this->returnCallback( function ( &$v ) use ( $hook ) {
+ $v = $hook;
+ return true;
+ } ) );
+ $session->set( 'AuthManager:lastAuthTimestamp', time() - 500 );
+ $this->assertEquals(
+ $expect, $this->manager->securitySensitiveOperationStatus( 'test' ), "hook $hook"
+ );
+ $this->assertEquals(
+ $expect, $this->manager->securitySensitiveOperationStatus( 'test2' ), "hook $hook"
+ );
+ $this->unhook( 'SecuritySensitiveOperationStatus' );
+ }
+
+ ScopedCallback::consume( $reset );
+ }
+
+ public function onSecuritySensitiveOperationStatus( &$status, $operation, $session, $time ) {
+ }
+
+ public static function provideSecuritySensitiveOperationStatus() {
+ return [
+ [ true ],
+ [ false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideUserCanAuthenticate
+ * @param bool $primary1Can
+ * @param bool $primary2Can
+ * @param bool $expect
+ */
+ public function testUserCanAuthenticate( $primary1Can, $primary2Can, $expect ) {
+ $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock1->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( 'primary1' ) );
+ $mock1->expects( $this->any() )->method( 'testUserCanAuthenticate' )
+ ->with( $this->equalTo( 'UTSysop' ) )
+ ->will( $this->returnValue( $primary1Can ) );
+ $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock2->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( 'primary2' ) );
+ $mock2->expects( $this->any() )->method( 'testUserCanAuthenticate' )
+ ->with( $this->equalTo( 'UTSysop' ) )
+ ->will( $this->returnValue( $primary2Can ) );
+ $this->primaryauthMocks = [ $mock1, $mock2 ];
+
+ $this->initializeManager( true );
+ $this->assertSame( $expect, $this->manager->userCanAuthenticate( 'UTSysop' ) );
+ }
+
+ public static function provideUserCanAuthenticate() {
+ return [
+ [ false, false, false ],
+ [ true, false, true ],
+ [ false, true, true ],
+ [ true, true, true ],
+ ];
+ }
+
+ public function testRevokeAccessForUser() {
+ $this->initializeManager();
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( 'primary' ) );
+ $mock->expects( $this->once() )->method( 'providerRevokeAccessForUser' )
+ ->with( $this->equalTo( 'UTSysop' ) );
+ $this->primaryauthMocks = [ $mock ];
+
+ $this->initializeManager( true );
+ $this->logger->setCollect( true );
+
+ $this->manager->revokeAccessForUser( 'UTSysop' );
+
+ $this->assertSame( [
+ [ LogLevel::INFO, 'Revoking access for {user}' ],
+ ], $this->logger->getBuffer() );
+ }
+
+ public function testProviderCreation() {
+ $mocks = [
+ 'pre' => $this->getMockForAbstractClass( PreAuthenticationProvider::class ),
+ 'primary' => $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ),
+ 'secondary' => $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class ),
+ ];
+ foreach ( $mocks as $key => $mock ) {
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $key ) );
+ $mock->expects( $this->once() )->method( 'setLogger' );
+ $mock->expects( $this->once() )->method( 'setManager' );
+ $mock->expects( $this->once() )->method( 'setConfig' );
+ }
+ $this->preauthMocks = [ $mocks['pre'] ];
+ $this->primaryauthMocks = [ $mocks['primary'] ];
+ $this->secondaryauthMocks = [ $mocks['secondary'] ];
+
+ // Normal operation
+ $this->initializeManager();
+ $this->assertSame(
+ $mocks['primary'],
+ $this->managerPriv->getAuthenticationProvider( 'primary' )
+ );
+ $this->assertSame(
+ $mocks['secondary'],
+ $this->managerPriv->getAuthenticationProvider( 'secondary' )
+ );
+ $this->assertSame(
+ $mocks['pre'],
+ $this->managerPriv->getAuthenticationProvider( 'pre' )
+ );
+ $this->assertSame(
+ [ 'pre' => $mocks['pre'] ],
+ $this->managerPriv->getPreAuthenticationProviders()
+ );
+ $this->assertSame(
+ [ 'primary' => $mocks['primary'] ],
+ $this->managerPriv->getPrimaryAuthenticationProviders()
+ );
+ $this->assertSame(
+ [ 'secondary' => $mocks['secondary'] ],
+ $this->managerPriv->getSecondaryAuthenticationProviders()
+ );
+
+ // Duplicate IDs
+ $mock1 = $this->getMockForAbstractClass( PreAuthenticationProvider::class );
+ $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $this->preauthMocks = [ $mock1 ];
+ $this->primaryauthMocks = [ $mock2 ];
+ $this->secondaryauthMocks = [];
+ $this->initializeManager( true );
+ try {
+ $this->managerPriv->getAuthenticationProvider( 'Y' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \RuntimeException $ex ) {
+ $class1 = get_class( $mock1 );
+ $class2 = get_class( $mock2 );
+ $this->assertSame(
+ "Duplicate specifications for id X (classes $class1 and $class2)", $ex->getMessage()
+ );
+ }
+
+ // Wrong classes
+ $mock = $this->getMockForAbstractClass( AuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $class = get_class( $mock );
+ $this->preauthMocks = [ $mock ];
+ $this->primaryauthMocks = [ $mock ];
+ $this->secondaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+ try {
+ $this->managerPriv->getPreAuthenticationProviders();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \RuntimeException $ex ) {
+ $this->assertSame(
+ "Expected instance of MediaWiki\\Auth\\PreAuthenticationProvider, got $class",
+ $ex->getMessage()
+ );
+ }
+ try {
+ $this->managerPriv->getPrimaryAuthenticationProviders();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \RuntimeException $ex ) {
+ $this->assertSame(
+ "Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got $class",
+ $ex->getMessage()
+ );
+ }
+ try {
+ $this->managerPriv->getSecondaryAuthenticationProviders();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \RuntimeException $ex ) {
+ $this->assertSame(
+ "Expected instance of MediaWiki\\Auth\\SecondaryAuthenticationProvider, got $class",
+ $ex->getMessage()
+ );
+ }
+
+ // Sorting
+ $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock3 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'A' ) );
+ $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'B' ) );
+ $mock3->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'C' ) );
+ $this->preauthMocks = [];
+ $this->primaryauthMocks = [ $mock1, $mock2, $mock3 ];
+ $this->secondaryauthMocks = [];
+ $this->initializeConfig();
+ $config = $this->config->get( 'AuthManagerConfig' );
+
+ $this->initializeManager( false );
+ $this->assertSame(
+ [ 'A' => $mock1, 'B' => $mock2, 'C' => $mock3 ],
+ $this->managerPriv->getPrimaryAuthenticationProviders(),
+ 'sanity check'
+ );
+
+ $config['primaryauth']['A']['sort'] = 100;
+ $config['primaryauth']['C']['sort'] = -1;
+ $this->config->set( 'AuthManagerConfig', $config );
+ $this->initializeManager( false );
+ $this->assertSame(
+ [ 'C' => $mock3, 'B' => $mock2, 'A' => $mock1 ],
+ $this->managerPriv->getPrimaryAuthenticationProviders()
+ );
+ }
+
+ public function testSetDefaultUserOptions() {
+ $this->initializeManager();
+
+ $context = \RequestContext::getMain();
+ $reset = new ScopedCallback( [ $context, 'setLanguage' ], [ $context->getLanguage() ] );
+ $context->setLanguage( 'de' );
+ $this->setMwGlobals( 'wgContLang', \Language::factory( 'zh' ) );
+
+ $user = \User::newFromName( self::usernameForCreation() );
+ $user->addToDatabase();
+ $oldToken = $user->getToken();
+ $this->managerPriv->setDefaultUserOptions( $user, false );
+ $user->saveSettings();
+ $this->assertNotEquals( $oldToken, $user->getToken() );
+ $this->assertSame( 'zh', $user->getOption( 'language' ) );
+ $this->assertSame( 'zh', $user->getOption( 'variant' ) );
+
+ $user = \User::newFromName( self::usernameForCreation() );
+ $user->addToDatabase();
+ $oldToken = $user->getToken();
+ $this->managerPriv->setDefaultUserOptions( $user, true );
+ $user->saveSettings();
+ $this->assertNotEquals( $oldToken, $user->getToken() );
+ $this->assertSame( 'de', $user->getOption( 'language' ) );
+ $this->assertSame( 'zh', $user->getOption( 'variant' ) );
+
+ $this->setMwGlobals( 'wgContLang', \Language::factory( 'fr' ) );
+
+ $user = \User::newFromName( self::usernameForCreation() );
+ $user->addToDatabase();
+ $oldToken = $user->getToken();
+ $this->managerPriv->setDefaultUserOptions( $user, true );
+ $user->saveSettings();
+ $this->assertNotEquals( $oldToken, $user->getToken() );
+ $this->assertSame( 'de', $user->getOption( 'language' ) );
+ $this->assertSame( null, $user->getOption( 'variant' ) );
+ }
+
+ public function testForcePrimaryAuthenticationProviders() {
+ $mockA = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mockB = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mockB2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mockA->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'A' ) );
+ $mockB->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'B' ) );
+ $mockB2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'B' ) );
+ $this->primaryauthMocks = [ $mockA ];
+
+ $this->logger = new \TestLogger( true );
+
+ // Test without first initializing the configured providers
+ $this->initializeManager();
+ $this->manager->forcePrimaryAuthenticationProviders( [ $mockB ], 'testing' );
+ $this->assertSame(
+ [ 'B' => $mockB ], $this->managerPriv->getPrimaryAuthenticationProviders()
+ );
+ $this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'A' ) );
+ $this->assertSame( $mockB, $this->managerPriv->getAuthenticationProvider( 'B' ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Overriding AuthManager primary authn because testing' ],
+ ], $this->logger->getBuffer() );
+ $this->logger->clearBuffer();
+
+ // Test with first initializing the configured providers
+ $this->initializeManager();
+ $this->assertSame( $mockA, $this->managerPriv->getAuthenticationProvider( 'A' ) );
+ $this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'B' ) );
+ $this->request->getSession()->setSecret( 'AuthManager::authnState', 'test' );
+ $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', 'test' );
+ $this->manager->forcePrimaryAuthenticationProviders( [ $mockB ], 'testing' );
+ $this->assertSame(
+ [ 'B' => $mockB ], $this->managerPriv->getPrimaryAuthenticationProviders()
+ );
+ $this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'A' ) );
+ $this->assertSame( $mockB, $this->managerPriv->getAuthenticationProvider( 'B' ) );
+ $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::authnState' ) );
+ $this->assertNull(
+ $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' )
+ );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Overriding AuthManager primary authn because testing' ],
+ [
+ LogLevel::WARNING,
+ 'PrimaryAuthenticationProviders have already been accessed! I hope nothing breaks.'
+ ],
+ ], $this->logger->getBuffer() );
+ $this->logger->clearBuffer();
+
+ // Test duplicate IDs
+ $this->initializeManager();
+ try {
+ $this->manager->forcePrimaryAuthenticationProviders( [ $mockB, $mockB2 ], 'testing' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \RuntimeException $ex ) {
+ $class1 = get_class( $mockB );
+ $class2 = get_class( $mockB2 );
+ $this->assertSame(
+ "Duplicate specifications for id B (classes $class2 and $class1)", $ex->getMessage()
+ );
+ }
+
+ // Wrong classes
+ $mock = $this->getMockForAbstractClass( AuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $class = get_class( $mock );
+ try {
+ $this->manager->forcePrimaryAuthenticationProviders( [ $mock ], 'testing' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \RuntimeException $ex ) {
+ $this->assertSame(
+ "Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got $class",
+ $ex->getMessage()
+ );
+ }
+ }
+
+ public function testBeginAuthentication() {
+ $this->initializeManager();
+
+ // Immutable session
+ list( $provider, $reset ) = $this->getMockSessionProvider( false );
+ $this->hook( 'UserLoggedIn', $this->never() );
+ $this->request->getSession()->setSecret( 'AuthManager::authnState', 'test' );
+ try {
+ $this->manager->beginAuthentication( [], 'http://localhost/' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \LogicException $ex ) {
+ $this->assertSame( 'Authentication is not possible now', $ex->getMessage() );
+ }
+ $this->unhook( 'UserLoggedIn' );
+ $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::authnState' ) );
+ ScopedCallback::consume( $reset );
+ $this->initializeManager( true );
+
+ // CreatedAccountAuthenticationRequest
+ $user = \User::newFromName( 'UTSysop' );
+ $reqs = [
+ new CreatedAccountAuthenticationRequest( $user->getId(), $user->getName() )
+ ];
+ $this->hook( 'UserLoggedIn', $this->never() );
+ try {
+ $this->manager->beginAuthentication( $reqs, 'http://localhost/' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \LogicException $ex ) {
+ $this->assertSame(
+ 'CreatedAccountAuthenticationRequests are only valid on the same AuthManager ' .
+ 'that created the account',
+ $ex->getMessage()
+ );
+ }
+ $this->unhook( 'UserLoggedIn' );
+
+ $this->request->getSession()->clear();
+ $this->request->getSession()->setSecret( 'AuthManager::authnState', 'test' );
+ $this->managerPriv->createdAccountAuthenticationRequests = [ $reqs[0] ];
+ $this->hook( 'UserLoggedIn', $this->once() )
+ ->with( $this->callback( function ( $u ) use ( $user ) {
+ return $user->getId() === $u->getId() && $user->getName() === $u->getName();
+ } ) );
+ $this->hook( 'AuthManagerLoginAuthenticateAudit', $this->once() );
+ $this->logger->setCollect( true );
+ $ret = $this->manager->beginAuthentication( $reqs, 'http://localhost/' );
+ $this->logger->setCollect( false );
+ $this->unhook( 'UserLoggedIn' );
+ $this->unhook( 'AuthManagerLoginAuthenticateAudit' );
+ $this->assertSame( AuthenticationResponse::PASS, $ret->status );
+ $this->assertSame( $user->getName(), $ret->username );
+ $this->assertSame( $user->getId(), $this->request->getSessionData( 'AuthManager:lastAuthId' ) );
+ $this->assertEquals(
+ time(), $this->request->getSessionData( 'AuthManager:lastAuthTimestamp' ),
+ 'timestamp ±1', 1
+ );
+ $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::authnState' ) );
+ $this->assertSame( $user->getId(), $this->request->getSession()->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::INFO, 'Logging in {user} after account creation' ],
+ ], $this->logger->getBuffer() );
+ }
+
+ public function testCreateFromLogin() {
+ $user = \User::newFromName( 'UTSysop' );
+ $req1 = $this->createMock( AuthenticationRequest::class );
+ $req2 = $this->createMock( AuthenticationRequest::class );
+ $req3 = $this->createMock( AuthenticationRequest::class );
+ $userReq = new UsernameAuthenticationRequest;
+ $userReq->username = 'UTDummy';
+
+ $req1->returnToUrl = 'http://localhost/';
+ $req2->returnToUrl = 'http://localhost/';
+ $req3->returnToUrl = 'http://localhost/';
+ $req3->username = 'UTDummy';
+ $userReq->returnToUrl = 'http://localhost/';
+
+ // Passing one into beginAuthentication(), and an immediate FAIL
+ $primary = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class );
+ $this->primaryauthMocks = [ $primary ];
+ $this->initializeManager( true );
+ $res = AuthenticationResponse::newFail( wfMessage( 'foo' ) );
+ $res->createRequest = $req1;
+ $primary->expects( $this->any() )->method( 'beginPrimaryAuthentication' )
+ ->will( $this->returnValue( $res ) );
+ $createReq = new CreateFromLoginAuthenticationRequest(
+ null, [ $req2->getUniqueId() => $req2 ]
+ );
+ $this->logger->setCollect( true );
+ $ret = $this->manager->beginAuthentication( [ $createReq ], 'http://localhost/' );
+ $this->logger->setCollect( false );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertInstanceOf( CreateFromLoginAuthenticationRequest::class, $ret->createRequest );
+ $this->assertSame( $req1, $ret->createRequest->createRequest );
+ $this->assertEquals( [ $req2->getUniqueId() => $req2 ], $ret->createRequest->maybeLink );
+
+ // UI, then FAIL in beginAuthentication()
+ $primary = $this->getMockBuilder( AbstractPrimaryAuthenticationProvider::class )
+ ->setMethods( [ 'continuePrimaryAuthentication' ] )
+ ->getMockForAbstractClass();
+ $this->primaryauthMocks = [ $primary ];
+ $this->initializeManager( true );
+ $primary->expects( $this->any() )->method( 'beginPrimaryAuthentication' )
+ ->will( $this->returnValue(
+ AuthenticationResponse::newUI( [ $req1 ], wfMessage( 'foo' ) )
+ ) );
+ $res = AuthenticationResponse::newFail( wfMessage( 'foo' ) );
+ $res->createRequest = $req2;
+ $primary->expects( $this->any() )->method( 'continuePrimaryAuthentication' )
+ ->will( $this->returnValue( $res ) );
+ $this->logger->setCollect( true );
+ $ret = $this->manager->beginAuthentication( [], 'http://localhost/' );
+ $this->assertSame( AuthenticationResponse::UI, $ret->status, 'sanity check' );
+ $ret = $this->manager->continueAuthentication( [] );
+ $this->logger->setCollect( false );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertInstanceOf( CreateFromLoginAuthenticationRequest::class, $ret->createRequest );
+ $this->assertSame( $req2, $ret->createRequest->createRequest );
+ $this->assertEquals( [], $ret->createRequest->maybeLink );
+
+ // Pass into beginAccountCreation(), see that maybeLink and createRequest get copied
+ $primary = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class );
+ $this->primaryauthMocks = [ $primary ];
+ $this->initializeManager( true );
+ $createReq = new CreateFromLoginAuthenticationRequest( $req3, [ $req2 ] );
+ $createReq->returnToUrl = 'http://localhost/';
+ $createReq->username = 'UTDummy';
+ $res = AuthenticationResponse::newUI( [ $req1 ], wfMessage( 'foo' ) );
+ $primary->expects( $this->any() )->method( 'beginPrimaryAccountCreation' )
+ ->with( $this->anything(), $this->anything(), [ $userReq, $createReq, $req3 ] )
+ ->will( $this->returnValue( $res ) );
+ $primary->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $this->logger->setCollect( true );
+ $ret = $this->manager->beginAccountCreation(
+ $user, [ $userReq, $createReq ], 'http://localhost/'
+ );
+ $this->logger->setCollect( false );
+ $this->assertSame( AuthenticationResponse::UI, $ret->status );
+ $state = $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' );
+ $this->assertNotNull( $state );
+ $this->assertEquals( [ $userReq, $createReq, $req3 ], $state['reqs'] );
+ $this->assertEquals( [ $req2 ], $state['maybeLink'] );
+ }
+
+ /**
+ * @dataProvider provideAuthentication
+ * @param StatusValue $preResponse
+ * @param array $primaryResponses
+ * @param array $secondaryResponses
+ * @param array $managerResponses
+ * @param bool $link Whether the primary authentication provider is a "link" provider
+ */
+ public function testAuthentication(
+ StatusValue $preResponse, array $primaryResponses, array $secondaryResponses,
+ array $managerResponses, $link = false
+ ) {
+ $this->initializeManager();
+ $user = \User::newFromName( 'UTSysop' );
+ $id = $user->getId();
+ $name = $user->getName();
+
+ // Set up lots of mocks...
+ $req = new RememberMeAuthenticationRequest;
+ $req->rememberMe = (bool)rand( 0, 1 );
+ $req->pre = $preResponse;
+ $req->primary = $primaryResponses;
+ $req->secondary = $secondaryResponses;
+ $mocks = [];
+ foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) {
+ $class = ucfirst( $key ) . 'AuthenticationProvider';
+ $mocks[$key] = $this->getMockForAbstractClass(
+ "MediaWiki\\Auth\\$class", [], "Mock$class"
+ );
+ $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( $key ) );
+ $mocks[$key . '2'] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" );
+ $mocks[$key . '2']->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( $key . '2' ) );
+ $mocks[$key . '3'] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" );
+ $mocks[$key . '3']->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( $key . '3' ) );
+ }
+ foreach ( $mocks as $mock ) {
+ $mock->expects( $this->any() )->method( 'getAuthenticationRequests' )
+ ->will( $this->returnValue( [] ) );
+ }
+
+ $mocks['pre']->expects( $this->once() )->method( 'testForAuthentication' )
+ ->will( $this->returnCallback( function ( $reqs ) use ( $req ) {
+ $this->assertContains( $req, $reqs );
+ return $req->pre;
+ } ) );
+
+ $ct = count( $req->primary );
+ $callback = $this->returnCallback( function ( $reqs ) use ( $req ) {
+ $this->assertContains( $req, $reqs );
+ return array_shift( $req->primary );
+ } );
+ $mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) )
+ ->method( 'beginPrimaryAuthentication' )
+ ->will( $callback );
+ $mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) )
+ ->method( 'continuePrimaryAuthentication' )
+ ->will( $callback );
+ if ( $link ) {
+ $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) );
+ }
+
+ $ct = count( $req->secondary );
+ $callback = $this->returnCallback( function ( $user, $reqs ) use ( $id, $name, $req ) {
+ $this->assertSame( $id, $user->getId() );
+ $this->assertSame( $name, $user->getName() );
+ $this->assertContains( $req, $reqs );
+ return array_shift( $req->secondary );
+ } );
+ $mocks['secondary']->expects( $this->exactly( min( 1, $ct ) ) )
+ ->method( 'beginSecondaryAuthentication' )
+ ->will( $callback );
+ $mocks['secondary']->expects( $this->exactly( max( 0, $ct - 1 ) ) )
+ ->method( 'continueSecondaryAuthentication' )
+ ->will( $callback );
+
+ $abstain = AuthenticationResponse::newAbstain();
+ $mocks['pre2']->expects( $this->atMost( 1 ) )->method( 'testForAuthentication' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+ $mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAuthentication' )
+ ->will( $this->returnValue( $abstain ) );
+ $mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAuthentication' );
+ $mocks['secondary2']->expects( $this->atMost( 1 ) )->method( 'beginSecondaryAuthentication' )
+ ->will( $this->returnValue( $abstain ) );
+ $mocks['secondary2']->expects( $this->never() )->method( 'continueSecondaryAuthentication' );
+ $mocks['secondary3']->expects( $this->atMost( 1 ) )->method( 'beginSecondaryAuthentication' )
+ ->will( $this->returnValue( $abstain ) );
+ $mocks['secondary3']->expects( $this->never() )->method( 'continueSecondaryAuthentication' );
+
+ $this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ];
+ $this->primaryauthMocks = [ $mocks['primary'], $mocks['primary2'] ];
+ $this->secondaryauthMocks = [
+ $mocks['secondary3'], $mocks['secondary'], $mocks['secondary2'],
+ // So linking happens
+ new ConfirmLinkSecondaryAuthenticationProvider,
+ ];
+ $this->initializeManager( true );
+ $this->logger->setCollect( true );
+
+ $constraint = \PHPUnit_Framework_Assert::logicalOr(
+ $this->equalTo( AuthenticationResponse::PASS ),
+ $this->equalTo( AuthenticationResponse::FAIL )
+ );
+ $providers = array_filter(
+ array_merge(
+ $this->preauthMocks, $this->primaryauthMocks, $this->secondaryauthMocks
+ ),
+ function ( $p ) {
+ return is_callable( [ $p, 'expects' ] );
+ }
+ );
+ foreach ( $providers as $p ) {
+ $p->postCalled = false;
+ $p->expects( $this->atMost( 1 ) )->method( 'postAuthentication' )
+ ->willReturnCallback( function ( $user, $response ) use ( $constraint, $p ) {
+ if ( $user !== null ) {
+ $this->assertInstanceOf( \User::class, $user );
+ $this->assertSame( 'UTSysop', $user->getName() );
+ }
+ $this->assertInstanceOf( AuthenticationResponse::class, $response );
+ $this->assertThat( $response->status, $constraint );
+ $p->postCalled = $response->status;
+ } );
+ }
+
+ $session = $this->request->getSession();
+ $session->setRememberUser( !$req->rememberMe );
+
+ foreach ( $managerResponses as $i => $response ) {
+ $success = $response instanceof AuthenticationResponse &&
+ $response->status === AuthenticationResponse::PASS;
+ if ( $success ) {
+ $this->hook( 'UserLoggedIn', $this->once() )
+ ->with( $this->callback( function ( $user ) use ( $id, $name ) {
+ return $user->getId() === $id && $user->getName() === $name;
+ } ) );
+ } else {
+ $this->hook( 'UserLoggedIn', $this->never() );
+ }
+ if ( $success || (
+ $response instanceof AuthenticationResponse &&
+ $response->status === AuthenticationResponse::FAIL &&
+ $response->message->getKey() !== 'authmanager-authn-not-in-progress' &&
+ $response->message->getKey() !== 'authmanager-authn-no-primary'
+ )
+ ) {
+ $this->hook( 'AuthManagerLoginAuthenticateAudit', $this->once() );
+ } else {
+ $this->hook( 'AuthManagerLoginAuthenticateAudit', $this->never() );
+ }
+
+ $ex = null;
+ try {
+ if ( !$i ) {
+ $ret = $this->manager->beginAuthentication( [ $req ], 'http://localhost/' );
+ } else {
+ $ret = $this->manager->continueAuthentication( [ $req ] );
+ }
+ if ( $response instanceof \Exception ) {
+ $this->fail( 'Expected exception not thrown', "Response $i" );
+ }
+ } catch ( \Exception $ex ) {
+ if ( !$response instanceof \Exception ) {
+ throw $ex;
+ }
+ $this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" );
+ $this->assertNull( $session->getSecret( 'AuthManager::authnState' ),
+ "Response $i, exception, session state" );
+ $this->unhook( 'UserLoggedIn' );
+ $this->unhook( 'AuthManagerLoginAuthenticateAudit' );
+ return;
+ }
+
+ $this->unhook( 'UserLoggedIn' );
+ $this->unhook( 'AuthManagerLoginAuthenticateAudit' );
+
+ $this->assertSame( 'http://localhost/', $req->returnToUrl );
+
+ $ret->message = $this->message( $ret->message );
+ $this->assertEquals( $response, $ret, "Response $i, response" );
+ if ( $success ) {
+ $this->assertSame( $id, $session->getUser()->getId(),
+ "Response $i, authn" );
+ } else {
+ $this->assertSame( 0, $session->getUser()->getId(),
+ "Response $i, authn" );
+ }
+ if ( $success || $response->status === AuthenticationResponse::FAIL ) {
+ $this->assertNull( $session->getSecret( 'AuthManager::authnState' ),
+ "Response $i, session state" );
+ foreach ( $providers as $p ) {
+ $this->assertSame( $response->status, $p->postCalled,
+ "Response $i, post-auth callback called" );
+ }
+ } else {
+ $this->assertNotNull( $session->getSecret( 'AuthManager::authnState' ),
+ "Response $i, session state" );
+ foreach ( $ret->neededRequests as $neededReq ) {
+ $this->assertEquals( AuthManager::ACTION_LOGIN, $neededReq->action,
+ "Response $i, neededRequest action" );
+ }
+ $this->assertEquals(
+ $ret->neededRequests,
+ $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN_CONTINUE ),
+ "Response $i, continuation check"
+ );
+ foreach ( $providers as $p ) {
+ $this->assertFalse( $p->postCalled, "Response $i, post-auth callback not called" );
+ }
+ }
+
+ $state = $session->getSecret( 'AuthManager::authnState' );
+ $maybeLink = isset( $state['maybeLink'] ) ? $state['maybeLink'] : [];
+ if ( $link && $response->status === AuthenticationResponse::RESTART ) {
+ $this->assertEquals(
+ $response->createRequest->maybeLink,
+ $maybeLink,
+ "Response $i, maybeLink"
+ );
+ } else {
+ $this->assertEquals( [], $maybeLink, "Response $i, maybeLink" );
+ }
+ }
+
+ if ( $success ) {
+ $this->assertSame( $req->rememberMe, $session->shouldRememberUser(),
+ 'rememberMe checkbox had effect' );
+ } else {
+ $this->assertNotSame( $req->rememberMe, $session->shouldRememberUser(),
+ 'rememberMe checkbox wasn\'t applied' );
+ }
+ }
+
+ public function provideAuthentication() {
+ $rememberReq = new RememberMeAuthenticationRequest;
+ $rememberReq->action = AuthManager::ACTION_LOGIN;
+
+ $req = $this->getMockForAbstractClass( AuthenticationRequest::class );
+ $req->foobar = 'baz';
+ $restartResponse = AuthenticationResponse::newRestart(
+ $this->message( 'authmanager-authn-no-local-user' )
+ );
+ $restartResponse->neededRequests = [ $rememberReq ];
+
+ $restartResponse2Pass = AuthenticationResponse::newPass( null );
+ $restartResponse2Pass->linkRequest = $req;
+ $restartResponse2 = AuthenticationResponse::newRestart(
+ $this->message( 'authmanager-authn-no-local-user-link' )
+ );
+ $restartResponse2->createRequest = new CreateFromLoginAuthenticationRequest(
+ null, [ $req->getUniqueId() => $req ]
+ );
+ $restartResponse2->createRequest->action = AuthManager::ACTION_LOGIN;
+ $restartResponse2->neededRequests = [ $rememberReq, $restartResponse2->createRequest ];
+
+ $userName = 'UTSysop';
+
+ return [
+ 'Failure in pre-auth' => [
+ StatusValue::newFatal( 'fail-from-pre' ),
+ [],
+ [],
+ [
+ AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ),
+ AuthenticationResponse::newFail(
+ $this->message( 'authmanager-authn-not-in-progress' )
+ ),
+ ]
+ ],
+ 'Failure in primary' => [
+ StatusValue::newGood(),
+ $tmp = [
+ AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ),
+ ],
+ [],
+ $tmp
+ ],
+ 'All primary abstain' => [
+ StatusValue::newGood(),
+ [
+ AuthenticationResponse::newAbstain(),
+ ],
+ [],
+ [
+ AuthenticationResponse::newFail( $this->message( 'authmanager-authn-no-primary' ) )
+ ]
+ ],
+ 'Primary UI, then redirect, then fail' => [
+ StatusValue::newGood(),
+ $tmp = [
+ AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
+ AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ),
+ AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ),
+ ],
+ [],
+ $tmp
+ ],
+ 'Primary redirect, then abstain' => [
+ StatusValue::newGood(),
+ [
+ $tmp = AuthenticationResponse::newRedirect(
+ [ $req ], '/foo.html', [ 'foo' => 'bar' ]
+ ),
+ AuthenticationResponse::newAbstain(),
+ ],
+ [],
+ [
+ $tmp,
+ new \DomainException(
+ 'MockPrimaryAuthenticationProvider::continuePrimaryAuthentication() returned ABSTAIN'
+ )
+ ]
+ ],
+ 'Primary UI, then pass with no local user' => [
+ StatusValue::newGood(),
+ [
+ $tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
+ AuthenticationResponse::newPass( null ),
+ ],
+ [],
+ [
+ $tmp,
+ $restartResponse,
+ ]
+ ],
+ 'Primary UI, then pass with no local user (link type)' => [
+ StatusValue::newGood(),
+ [
+ $tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
+ $restartResponse2Pass,
+ ],
+ [],
+ [
+ $tmp,
+ $restartResponse2,
+ ],
+ true
+ ],
+ 'Primary pass with invalid username' => [
+ StatusValue::newGood(),
+ [
+ AuthenticationResponse::newPass( '<>' ),
+ ],
+ [],
+ [
+ new \DomainException( 'MockPrimaryAuthenticationProvider returned an invalid username: <>' ),
+ ]
+ ],
+ 'Secondary fail' => [
+ StatusValue::newGood(),
+ [
+ AuthenticationResponse::newPass( $userName ),
+ ],
+ $tmp = [
+ AuthenticationResponse::newFail( $this->message( 'fail-in-secondary' ) ),
+ ],
+ $tmp
+ ],
+ 'Secondary UI, then abstain' => [
+ StatusValue::newGood(),
+ [
+ AuthenticationResponse::newPass( $userName ),
+ ],
+ [
+ $tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
+ AuthenticationResponse::newAbstain()
+ ],
+ [
+ $tmp,
+ AuthenticationResponse::newPass( $userName ),
+ ]
+ ],
+ 'Secondary pass' => [
+ StatusValue::newGood(),
+ [
+ AuthenticationResponse::newPass( $userName ),
+ ],
+ [
+ AuthenticationResponse::newPass()
+ ],
+ [
+ AuthenticationResponse::newPass( $userName ),
+ ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideUserExists
+ * @param bool $primary1Exists
+ * @param bool $primary2Exists
+ * @param bool $expect
+ */
+ public function testUserExists( $primary1Exists, $primary2Exists, $expect ) {
+ $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock1->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( 'primary1' ) );
+ $mock1->expects( $this->any() )->method( 'testUserExists' )
+ ->with( $this->equalTo( 'UTSysop' ) )
+ ->will( $this->returnValue( $primary1Exists ) );
+ $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock2->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( 'primary2' ) );
+ $mock2->expects( $this->any() )->method( 'testUserExists' )
+ ->with( $this->equalTo( 'UTSysop' ) )
+ ->will( $this->returnValue( $primary2Exists ) );
+ $this->primaryauthMocks = [ $mock1, $mock2 ];
+
+ $this->initializeManager( true );
+ $this->assertSame( $expect, $this->manager->userExists( 'UTSysop' ) );
+ }
+
+ public static function provideUserExists() {
+ return [
+ [ false, false, false ],
+ [ true, false, true ],
+ [ false, true, true ],
+ [ true, true, true ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideAllowsAuthenticationDataChange
+ * @param StatusValue $primaryReturn
+ * @param StatusValue $secondaryReturn
+ * @param Status $expect
+ */
+ public function testAllowsAuthenticationDataChange( $primaryReturn, $secondaryReturn, $expect ) {
+ $req = $this->getMockForAbstractClass( AuthenticationRequest::class );
+
+ $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '1' ) );
+ $mock1->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' )
+ ->with( $this->equalTo( $req ) )
+ ->will( $this->returnValue( $primaryReturn ) );
+ $mock2 = $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class );
+ $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '2' ) );
+ $mock2->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' )
+ ->with( $this->equalTo( $req ) )
+ ->will( $this->returnValue( $secondaryReturn ) );
+
+ $this->primaryauthMocks = [ $mock1 ];
+ $this->secondaryauthMocks = [ $mock2 ];
+ $this->initializeManager( true );
+ $this->assertEquals( $expect, $this->manager->allowsAuthenticationDataChange( $req ) );
+ }
+
+ public static function provideAllowsAuthenticationDataChange() {
+ $ignored = \Status::newGood( 'ignored' );
+ $ignored->warning( 'authmanager-change-not-supported' );
+
+ $okFromPrimary = StatusValue::newGood();
+ $okFromPrimary->warning( 'warning-from-primary' );
+ $okFromSecondary = StatusValue::newGood();
+ $okFromSecondary->warning( 'warning-from-secondary' );
+
+ return [
+ [
+ StatusValue::newGood(),
+ StatusValue::newGood(),
+ \Status::newGood(),
+ ],
+ [
+ StatusValue::newGood(),
+ StatusValue::newGood( 'ignore' ),
+ \Status::newGood(),
+ ],
+ [
+ StatusValue::newGood( 'ignored' ),
+ StatusValue::newGood(),
+ \Status::newGood(),
+ ],
+ [
+ StatusValue::newGood( 'ignored' ),
+ StatusValue::newGood( 'ignored' ),
+ $ignored,
+ ],
+ [
+ StatusValue::newFatal( 'fail from primary' ),
+ StatusValue::newGood(),
+ \Status::newFatal( 'fail from primary' ),
+ ],
+ [
+ $okFromPrimary,
+ StatusValue::newGood(),
+ \Status::wrap( $okFromPrimary ),
+ ],
+ [
+ StatusValue::newGood(),
+ StatusValue::newFatal( 'fail from secondary' ),
+ \Status::newFatal( 'fail from secondary' ),
+ ],
+ [
+ StatusValue::newGood(),
+ $okFromSecondary,
+ \Status::wrap( $okFromSecondary ),
+ ],
+ ];
+ }
+
+ public function testChangeAuthenticationData() {
+ $req = $this->getMockForAbstractClass( AuthenticationRequest::class );
+ $req->username = 'UTSysop';
+
+ $mock1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock1->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '1' ) );
+ $mock1->expects( $this->once() )->method( 'providerChangeAuthenticationData' )
+ ->with( $this->equalTo( $req ) );
+ $mock2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock2->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( '2' ) );
+ $mock2->expects( $this->once() )->method( 'providerChangeAuthenticationData' )
+ ->with( $this->equalTo( $req ) );
+
+ $this->primaryauthMocks = [ $mock1, $mock2 ];
+ $this->initializeManager( true );
+ $this->logger->setCollect( true );
+ $this->manager->changeAuthenticationData( $req );
+ $this->assertSame( [
+ [ LogLevel::INFO, 'Changing authentication data for {user} class {what}' ],
+ ], $this->logger->getBuffer() );
+ }
+
+ public function testCanCreateAccounts() {
+ $types = [
+ PrimaryAuthenticationProvider::TYPE_CREATE => true,
+ PrimaryAuthenticationProvider::TYPE_LINK => true,
+ PrimaryAuthenticationProvider::TYPE_NONE => false,
+ ];
+
+ foreach ( $types as $type => $can ) {
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $type ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( $type ) );
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+ $this->assertSame( $can, $this->manager->canCreateAccounts(), $type );
+ }
+ }
+
+ public function testCheckAccountCreatePermissions() {
+ global $wgGroupPermissions;
+
+ $this->stashMwGlobals( [ 'wgGroupPermissions' ] );
+
+ $this->initializeManager( true );
+
+ $wgGroupPermissions['*']['createaccount'] = true;
+ $this->assertEquals(
+ \Status::newGood(),
+ $this->manager->checkAccountCreatePermissions( new \User )
+ );
+
+ $readOnlyMode = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode();
+ $readOnlyMode->setReason( 'Because' );
+ $this->assertEquals(
+ \Status::newFatal( wfMessage( 'readonlytext', 'Because' ) ),
+ $this->manager->checkAccountCreatePermissions( new \User )
+ );
+ $readOnlyMode->setReason( false );
+
+ $wgGroupPermissions['*']['createaccount'] = false;
+ $status = $this->manager->checkAccountCreatePermissions( new \User );
+ $this->assertFalse( $status->isOK() );
+ $this->assertTrue( $status->hasMessage( 'badaccess-groups' ) );
+ $wgGroupPermissions['*']['createaccount'] = true;
+
+ $user = \User::newFromName( 'UTBlockee' );
+ if ( $user->getID() == 0 ) {
+ $user->addToDatabase();
+ \TestUser::setPasswordForUser( $user, 'UTBlockeePassword' );
+ $user->saveSettings();
+ }
+ $oldBlock = \Block::newFromTarget( 'UTBlockee' );
+ if ( $oldBlock ) {
+ // An old block will prevent our new one from saving.
+ $oldBlock->delete();
+ }
+ $blockOptions = [
+ 'address' => 'UTBlockee',
+ 'user' => $user->getID(),
+ 'by' => $this->getTestSysop()->getUser()->getId(),
+ 'reason' => __METHOD__,
+ 'expiry' => time() + 100500,
+ 'createAccount' => true,
+ ];
+ $block = new \Block( $blockOptions );
+ $block->insert();
+ $status = $this->manager->checkAccountCreatePermissions( $user );
+ $this->assertFalse( $status->isOK() );
+ $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) );
+
+ $blockOptions = [
+ 'address' => '127.0.0.0/24',
+ 'by' => $this->getTestSysop()->getUser()->getId(),
+ 'reason' => __METHOD__,
+ 'expiry' => time() + 100500,
+ 'createAccount' => true,
+ ];
+ $block = new \Block( $blockOptions );
+ $block->insert();
+ $scopeVariable = new ScopedCallback( [ $block, 'delete' ] );
+ $status = $this->manager->checkAccountCreatePermissions( new \User );
+ $this->assertFalse( $status->isOK() );
+ $this->assertTrue( $status->hasMessage( 'cantcreateaccount-range-text' ) );
+ ScopedCallback::consume( $scopeVariable );
+
+ $this->setMwGlobals( [
+ 'wgEnableDnsBlacklist' => true,
+ 'wgDnsBlacklistUrls' => [
+ 'local.wmftest.net', // This will resolve for every subdomain, which works to test "listed?"
+ ],
+ 'wgProxyWhitelist' => [],
+ ] );
+ $status = $this->manager->checkAccountCreatePermissions( new \User );
+ $this->assertFalse( $status->isOK() );
+ $this->assertTrue( $status->hasMessage( 'sorbs_create_account_reason' ) );
+ $this->setMwGlobals( 'wgProxyWhitelist', [ '127.0.0.1' ] );
+ $status = $this->manager->checkAccountCreatePermissions( new \User );
+ $this->assertTrue( $status->isGood() );
+ }
+
+ /**
+ * @param string $uniq
+ * @return string
+ */
+ private static function usernameForCreation( $uniq = '' ) {
+ $i = 0;
+ do {
+ $username = "UTAuthManagerTestAccountCreation" . $uniq . ++$i;
+ } while ( \User::newFromName( $username )->getId() !== 0 );
+ return $username;
+ }
+
+ public function testCanCreateAccount() {
+ $username = self::usernameForCreation();
+ $this->initializeManager();
+
+ $this->assertEquals(
+ \Status::newFatal( 'authmanager-create-disabled' ),
+ $this->manager->canCreateAccount( $username )
+ );
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+
+ $this->assertEquals(
+ \Status::newFatal( 'userexists' ),
+ $this->manager->canCreateAccount( $username )
+ );
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+
+ $this->assertEquals(
+ \Status::newFatal( 'noname' ),
+ $this->manager->canCreateAccount( $username . '<>' )
+ );
+
+ $this->assertEquals(
+ \Status::newFatal( 'userexists' ),
+ $this->manager->canCreateAccount( 'UTSysop' )
+ );
+
+ $this->assertEquals(
+ \Status::newGood(),
+ $this->manager->canCreateAccount( $username )
+ );
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) );
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+
+ $this->assertEquals(
+ \Status::newFatal( 'fail' ),
+ $this->manager->canCreateAccount( $username )
+ );
+ }
+
+ public function testBeginAccountCreation() {
+ $creator = \User::newFromName( 'UTSysop' );
+ $userReq = new UsernameAuthenticationRequest;
+ $this->logger = new \TestLogger( false, function ( $message, $level ) {
+ return $level === LogLevel::DEBUG ? null : $message;
+ } );
+ $this->initializeManager();
+
+ $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', 'test' );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ try {
+ $this->manager->beginAccountCreation(
+ $creator, [], 'http://localhost/'
+ );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \LogicException $ex ) {
+ $this->assertEquals( 'Account creation is not possible', $ex->getMessage() );
+ }
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertNull(
+ $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' )
+ );
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->beginAccountCreation( $creator, [], 'http://localhost/' );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'noname', $ret->message->getKey() );
+
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $userReq->username = self::usernameForCreation();
+ $userReq2 = new UsernameAuthenticationRequest;
+ $userReq2->username = $userReq->username . 'X';
+ $ret = $this->manager->beginAccountCreation(
+ $creator, [ $userReq, $userReq2 ], 'http://localhost/'
+ );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'noname', $ret->message->getKey() );
+
+ $readOnlyMode = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode();
+ $readOnlyMode->setReason( 'Because' );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $userReq->username = self::usernameForCreation();
+ $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'readonlytext', $ret->message->getKey() );
+ $this->assertSame( [ 'Because' ], $ret->message->getParams() );
+ $readOnlyMode->setReason( false );
+
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $userReq->username = self::usernameForCreation();
+ $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'userexists', $ret->message->getKey() );
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) );
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $userReq->username = self::usernameForCreation();
+ $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'fail', $ret->message->getKey() );
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $userReq->username = self::usernameForCreation() . '<>';
+ $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'noname', $ret->message->getKey() );
+
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $userReq->username = $creator->getName();
+ $ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'userexists', $ret->message->getKey() );
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+ $mock->expects( $this->any() )->method( 'testForAccountCreation' )
+ ->will( $this->returnValue( StatusValue::newFatal( 'fail' ) ) );
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+
+ $req = $this->getMockBuilder( UserDataAuthenticationRequest::class )
+ ->setMethods( [ 'populateUser' ] )
+ ->getMock();
+ $req->expects( $this->any() )->method( 'populateUser' )
+ ->willReturn( \StatusValue::newFatal( 'populatefail' ) );
+ $userReq->username = self::usernameForCreation();
+ $ret = $this->manager->beginAccountCreation(
+ $creator, [ $userReq, $req ], 'http://localhost/'
+ );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'populatefail', $ret->message->getKey() );
+
+ $req = new UserDataAuthenticationRequest;
+ $userReq->username = self::usernameForCreation();
+
+ $ret = $this->manager->beginAccountCreation(
+ $creator, [ $userReq, $req ], 'http://localhost/'
+ );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'fail', $ret->message->getKey() );
+
+ $this->manager->beginAccountCreation(
+ \User::newFromName( $userReq->username ), [ $userReq, $req ], 'http://localhost/'
+ );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'fail', $ret->message->getKey() );
+ }
+
+ public function testContinueAccountCreation() {
+ $creator = \User::newFromName( 'UTSysop' );
+ $username = self::usernameForCreation();
+ $this->logger = new \TestLogger( false, function ( $message, $level ) {
+ return $level === LogLevel::DEBUG ? null : $message;
+ } );
+ $this->initializeManager();
+
+ $session = [
+ 'userid' => 0,
+ 'username' => $username,
+ 'creatorid' => 0,
+ 'creatorname' => $username,
+ 'reqs' => [],
+ 'primary' => null,
+ 'primaryResponse' => null,
+ 'secondary' => [],
+ 'ranPreTests' => true,
+ ];
+
+ $this->hook( 'LocalUserCreated', $this->never() );
+ try {
+ $this->manager->continueAccountCreation( [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \LogicException $ex ) {
+ $this->assertEquals( 'Account creation is not possible', $ex->getMessage() );
+ }
+ $this->unhook( 'LocalUserCreated' );
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )->method( 'beginPrimaryAccountCreation' )->will(
+ $this->returnValue( AuthenticationResponse::newFail( $this->message( 'fail' ) ) )
+ );
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+
+ $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', null );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->continueAccountCreation( [] );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'authmanager-create-not-in-progress', $ret->message->getKey() );
+
+ $this->request->getSession()->setSecret( 'AuthManager::accountCreationState',
+ [ 'username' => "$username<>" ] + $session );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->continueAccountCreation( [] );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'noname', $ret->message->getKey() );
+ $this->assertNull(
+ $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' )
+ );
+
+ $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', $session );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $cache = \ObjectCache::getLocalClusterInstance();
+ $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) );
+ $ret = $this->manager->continueAccountCreation( [] );
+ unset( $lock );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'usernameinprogress', $ret->message->getKey() );
+ // This error shouldn't remove the existing session, because the
+ // raced-with process "owns" it.
+ $this->assertSame(
+ $session, $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' )
+ );
+
+ $this->request->getSession()->setSecret( 'AuthManager::accountCreationState',
+ [ 'username' => $creator->getName() ] + $session );
+ $readOnlyMode = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode();
+ $readOnlyMode->setReason( 'Because' );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->continueAccountCreation( [] );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'readonlytext', $ret->message->getKey() );
+ $this->assertSame( [ 'Because' ], $ret->message->getParams() );
+ $readOnlyMode->setReason( false );
+
+ $this->request->getSession()->setSecret( 'AuthManager::accountCreationState',
+ [ 'username' => $creator->getName() ] + $session );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->continueAccountCreation( [] );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'userexists', $ret->message->getKey() );
+ $this->assertNull(
+ $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' )
+ );
+
+ $this->request->getSession()->setSecret( 'AuthManager::accountCreationState',
+ [ 'userid' => $creator->getId() ] + $session );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ try {
+ $ret = $this->manager->continueAccountCreation( [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertEquals( "User \"{$username}\" should exist now, but doesn't!", $ex->getMessage() );
+ }
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertNull(
+ $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' )
+ );
+
+ $id = $creator->getId();
+ $name = $creator->getName();
+ $this->request->getSession()->setSecret( 'AuthManager::accountCreationState',
+ [ 'username' => $name, 'userid' => $id + 1 ] + $session );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ try {
+ $ret = $this->manager->continueAccountCreation( [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertEquals(
+ "User \"{$name}\" exists, but ID $id != " . ( $id + 1 ) . '!', $ex->getMessage()
+ );
+ }
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertNull(
+ $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' )
+ );
+
+ $req = $this->getMockBuilder( UserDataAuthenticationRequest::class )
+ ->setMethods( [ 'populateUser' ] )
+ ->getMock();
+ $req->expects( $this->any() )->method( 'populateUser' )
+ ->willReturn( \StatusValue::newFatal( 'populatefail' ) );
+ $this->request->getSession()->setSecret( 'AuthManager::accountCreationState',
+ [ 'reqs' => [ $req ] ] + $session );
+ $ret = $this->manager->continueAccountCreation( [] );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'populatefail', $ret->message->getKey() );
+ $this->assertNull(
+ $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' )
+ );
+ }
+
+ /**
+ * @dataProvider provideAccountCreation
+ * @param StatusValue $preTest
+ * @param StatusValue $primaryTest
+ * @param StatusValue $secondaryTest
+ * @param array $primaryResponses
+ * @param array $secondaryResponses
+ * @param array $managerResponses
+ */
+ public function testAccountCreation(
+ StatusValue $preTest, $primaryTest, $secondaryTest,
+ array $primaryResponses, array $secondaryResponses, array $managerResponses
+ ) {
+ $creator = \User::newFromName( 'UTSysop' );
+ $username = self::usernameForCreation();
+
+ $this->initializeManager();
+
+ // Set up lots of mocks...
+ $req = $this->getMockForAbstractClass( AuthenticationRequest::class );
+ $req->preTest = $preTest;
+ $req->primaryTest = $primaryTest;
+ $req->secondaryTest = $secondaryTest;
+ $req->primary = $primaryResponses;
+ $req->secondary = $secondaryResponses;
+ $mocks = [];
+ foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) {
+ $class = ucfirst( $key ) . 'AuthenticationProvider';
+ $mocks[$key] = $this->getMockForAbstractClass(
+ "MediaWiki\\Auth\\$class", [], "Mock$class"
+ );
+ $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( $key ) );
+ $mocks[$key]->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+ $mocks[$key]->expects( $this->any() )->method( 'testForAccountCreation' )
+ ->will( $this->returnCallback(
+ function ( $user, $creatorIn, $reqs )
+ use ( $username, $creator, $req, $key )
+ {
+ $this->assertSame( $username, $user->getName() );
+ $this->assertSame( $creator->getId(), $creatorIn->getId() );
+ $this->assertSame( $creator->getName(), $creatorIn->getName() );
+ $foundReq = false;
+ foreach ( $reqs as $r ) {
+ $this->assertSame( $username, $r->username );
+ $foundReq = $foundReq || get_class( $r ) === get_class( $req );
+ }
+ $this->assertTrue( $foundReq, '$reqs contains $req' );
+ $k = $key . 'Test';
+ return $req->$k;
+ }
+ ) );
+
+ for ( $i = 2; $i <= 3; $i++ ) {
+ $mocks[$key . $i] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" );
+ $mocks[$key . $i]->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( $key . $i ) );
+ $mocks[$key . $i]->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+ $mocks[$key . $i]->expects( $this->atMost( 1 ) )->method( 'testForAccountCreation' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+ }
+ }
+
+ $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mocks['primary']->expects( $this->any() )->method( 'testUserExists' )
+ ->will( $this->returnValue( false ) );
+ $ct = count( $req->primary );
+ $callback = $this->returnCallback( function ( $user, $creator, $reqs ) use ( $username, $req ) {
+ $this->assertSame( $username, $user->getName() );
+ $this->assertSame( 'UTSysop', $creator->getName() );
+ $foundReq = false;
+ foreach ( $reqs as $r ) {
+ $this->assertSame( $username, $r->username );
+ $foundReq = $foundReq || get_class( $r ) === get_class( $req );
+ }
+ $this->assertTrue( $foundReq, '$reqs contains $req' );
+ return array_shift( $req->primary );
+ } );
+ $mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) )
+ ->method( 'beginPrimaryAccountCreation' )
+ ->will( $callback );
+ $mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) )
+ ->method( 'continuePrimaryAccountCreation' )
+ ->will( $callback );
+
+ $ct = count( $req->secondary );
+ $callback = $this->returnCallback( function ( $user, $creator, $reqs ) use ( $username, $req ) {
+ $this->assertSame( $username, $user->getName() );
+ $this->assertSame( 'UTSysop', $creator->getName() );
+ $foundReq = false;
+ foreach ( $reqs as $r ) {
+ $this->assertSame( $username, $r->username );
+ $foundReq = $foundReq || get_class( $r ) === get_class( $req );
+ }
+ $this->assertTrue( $foundReq, '$reqs contains $req' );
+ return array_shift( $req->secondary );
+ } );
+ $mocks['secondary']->expects( $this->exactly( min( 1, $ct ) ) )
+ ->method( 'beginSecondaryAccountCreation' )
+ ->will( $callback );
+ $mocks['secondary']->expects( $this->exactly( max( 0, $ct - 1 ) ) )
+ ->method( 'continueSecondaryAccountCreation' )
+ ->will( $callback );
+
+ $abstain = AuthenticationResponse::newAbstain();
+ $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) );
+ $mocks['primary2']->expects( $this->any() )->method( 'testUserExists' )
+ ->will( $this->returnValue( false ) );
+ $mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAccountCreation' )
+ ->will( $this->returnValue( $abstain ) );
+ $mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAccountCreation' );
+ $mocks['primary3']->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_NONE ) );
+ $mocks['primary3']->expects( $this->any() )->method( 'testUserExists' )
+ ->will( $this->returnValue( false ) );
+ $mocks['primary3']->expects( $this->never() )->method( 'beginPrimaryAccountCreation' );
+ $mocks['primary3']->expects( $this->never() )->method( 'continuePrimaryAccountCreation' );
+ $mocks['secondary2']->expects( $this->atMost( 1 ) )
+ ->method( 'beginSecondaryAccountCreation' )
+ ->will( $this->returnValue( $abstain ) );
+ $mocks['secondary2']->expects( $this->never() )->method( 'continueSecondaryAccountCreation' );
+ $mocks['secondary3']->expects( $this->atMost( 1 ) )
+ ->method( 'beginSecondaryAccountCreation' )
+ ->will( $this->returnValue( $abstain ) );
+ $mocks['secondary3']->expects( $this->never() )->method( 'continueSecondaryAccountCreation' );
+
+ $this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ];
+ $this->primaryauthMocks = [ $mocks['primary3'], $mocks['primary'], $mocks['primary2'] ];
+ $this->secondaryauthMocks = [
+ $mocks['secondary3'], $mocks['secondary'], $mocks['secondary2']
+ ];
+
+ $this->logger = new \TestLogger( true, function ( $message, $level ) {
+ return $level === LogLevel::DEBUG ? null : $message;
+ } );
+ $expectLog = [];
+ $this->initializeManager( true );
+
+ $constraint = \PHPUnit_Framework_Assert::logicalOr(
+ $this->equalTo( AuthenticationResponse::PASS ),
+ $this->equalTo( AuthenticationResponse::FAIL )
+ );
+ $providers = array_merge(
+ $this->preauthMocks, $this->primaryauthMocks, $this->secondaryauthMocks
+ );
+ foreach ( $providers as $p ) {
+ $p->postCalled = false;
+ $p->expects( $this->atMost( 1 ) )->method( 'postAccountCreation' )
+ ->willReturnCallback( function ( $user, $creator, $response )
+ use ( $constraint, $p, $username )
+ {
+ $this->assertInstanceOf( \User::class, $user );
+ $this->assertSame( $username, $user->getName() );
+ $this->assertSame( 'UTSysop', $creator->getName() );
+ $this->assertInstanceOf( AuthenticationResponse::class, $response );
+ $this->assertThat( $response->status, $constraint );
+ $p->postCalled = $response->status;
+ } );
+ }
+
+ // We're testing with $wgNewUserLog = false, so assert that it worked
+ $dbw = wfGetDB( DB_MASTER );
+ $maxLogId = $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] );
+
+ $first = true;
+ $created = false;
+ foreach ( $managerResponses as $i => $response ) {
+ $success = $response instanceof AuthenticationResponse &&
+ $response->status === AuthenticationResponse::PASS;
+ if ( $i === 'created' ) {
+ $created = true;
+ $this->hook( 'LocalUserCreated', $this->once() )
+ ->with(
+ $this->callback( function ( $user ) use ( $username ) {
+ return $user->getName() === $username;
+ } ),
+ $this->equalTo( false )
+ );
+ $expectLog[] = [ LogLevel::INFO, "Creating user {user} during account creation" ];
+ } else {
+ $this->hook( 'LocalUserCreated', $this->never() );
+ }
+
+ $ex = null;
+ try {
+ if ( $first ) {
+ $userReq = new UsernameAuthenticationRequest;
+ $userReq->username = $username;
+ $ret = $this->manager->beginAccountCreation(
+ $creator, [ $userReq, $req ], 'http://localhost/'
+ );
+ } else {
+ $ret = $this->manager->continueAccountCreation( [ $req ] );
+ }
+ if ( $response instanceof \Exception ) {
+ $this->fail( 'Expected exception not thrown', "Response $i" );
+ }
+ } catch ( \Exception $ex ) {
+ if ( !$response instanceof \Exception ) {
+ throw $ex;
+ }
+ $this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" );
+ $this->assertNull(
+ $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ),
+ "Response $i, exception, session state"
+ );
+ $this->unhook( 'LocalUserCreated' );
+ return;
+ }
+
+ $this->unhook( 'LocalUserCreated' );
+
+ $this->assertSame( 'http://localhost/', $req->returnToUrl );
+
+ if ( $success ) {
+ $this->assertNotNull( $ret->loginRequest, "Response $i, login marker" );
+ $this->assertContains(
+ $ret->loginRequest, $this->managerPriv->createdAccountAuthenticationRequests,
+ "Response $i, login marker"
+ );
+
+ $expectLog[] = [
+ LogLevel::INFO,
+ "MediaWiki\Auth\AuthManager::continueAccountCreation: Account creation succeeded for {user}"
+ ];
+
+ // Set some fields in the expected $response that we couldn't
+ // know in provideAccountCreation().
+ $response->username = $username;
+ $response->loginRequest = $ret->loginRequest;
+ } else {
+ $this->assertNull( $ret->loginRequest, "Response $i, login marker" );
+ $this->assertSame( [], $this->managerPriv->createdAccountAuthenticationRequests,
+ "Response $i, login marker" );
+ }
+ $ret->message = $this->message( $ret->message );
+ $this->assertEquals( $response, $ret, "Response $i, response" );
+ if ( $success || $response->status === AuthenticationResponse::FAIL ) {
+ $this->assertNull(
+ $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ),
+ "Response $i, session state"
+ );
+ foreach ( $providers as $p ) {
+ $this->assertSame( $response->status, $p->postCalled,
+ "Response $i, post-auth callback called" );
+ }
+ } else {
+ $this->assertNotNull(
+ $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' ),
+ "Response $i, session state"
+ );
+ foreach ( $ret->neededRequests as $neededReq ) {
+ $this->assertEquals( AuthManager::ACTION_CREATE, $neededReq->action,
+ "Response $i, neededRequest action" );
+ }
+ $this->assertEquals(
+ $ret->neededRequests,
+ $this->manager->getAuthenticationRequests( AuthManager::ACTION_CREATE_CONTINUE ),
+ "Response $i, continuation check"
+ );
+ foreach ( $providers as $p ) {
+ $this->assertFalse( $p->postCalled, "Response $i, post-auth callback not called" );
+ }
+ }
+
+ if ( $created ) {
+ $this->assertNotEquals( 0, \User::idFromName( $username ) );
+ } else {
+ $this->assertEquals( 0, \User::idFromName( $username ) );
+ }
+
+ $first = false;
+ }
+
+ $this->assertSame( $expectLog, $this->logger->getBuffer() );
+
+ $this->assertSame(
+ $maxLogId,
+ $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] )
+ );
+ }
+
+ public function provideAccountCreation() {
+ $req = $this->getMockForAbstractClass( AuthenticationRequest::class );
+ $good = StatusValue::newGood();
+
+ return [
+ 'Pre-creation test fail in pre' => [
+ StatusValue::newFatal( 'fail-from-pre' ), $good, $good,
+ [],
+ [],
+ [
+ AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ),
+ ]
+ ],
+ 'Pre-creation test fail in primary' => [
+ $good, StatusValue::newFatal( 'fail-from-primary' ), $good,
+ [],
+ [],
+ [
+ AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ),
+ ]
+ ],
+ 'Pre-creation test fail in secondary' => [
+ $good, $good, StatusValue::newFatal( 'fail-from-secondary' ),
+ [],
+ [],
+ [
+ AuthenticationResponse::newFail( $this->message( 'fail-from-secondary' ) ),
+ ]
+ ],
+ 'Failure in primary' => [
+ $good, $good, $good,
+ $tmp = [
+ AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ),
+ ],
+ [],
+ $tmp
+ ],
+ 'All primary abstain' => [
+ $good, $good, $good,
+ [
+ AuthenticationResponse::newAbstain(),
+ ],
+ [],
+ [
+ AuthenticationResponse::newFail( $this->message( 'authmanager-create-no-primary' ) )
+ ]
+ ],
+ 'Primary UI, then redirect, then fail' => [
+ $good, $good, $good,
+ $tmp = [
+ AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
+ AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ),
+ AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ),
+ ],
+ [],
+ $tmp
+ ],
+ 'Primary redirect, then abstain' => [
+ $good, $good, $good,
+ [
+ $tmp = AuthenticationResponse::newRedirect(
+ [ $req ], '/foo.html', [ 'foo' => 'bar' ]
+ ),
+ AuthenticationResponse::newAbstain(),
+ ],
+ [],
+ [
+ $tmp,
+ new \DomainException(
+ 'MockPrimaryAuthenticationProvider::continuePrimaryAccountCreation() returned ABSTAIN'
+ )
+ ]
+ ],
+ 'Primary UI, then pass; secondary abstain' => [
+ $good, $good, $good,
+ [
+ $tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
+ AuthenticationResponse::newPass(),
+ ],
+ [
+ AuthenticationResponse::newAbstain(),
+ ],
+ [
+ $tmp1,
+ 'created' => AuthenticationResponse::newPass( '' ),
+ ]
+ ],
+ 'Primary pass; secondary UI then pass' => [
+ $good, $good, $good,
+ [
+ AuthenticationResponse::newPass( '' ),
+ ],
+ [
+ $tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
+ AuthenticationResponse::newPass( '' ),
+ ],
+ [
+ 'created' => $tmp1,
+ AuthenticationResponse::newPass( '' ),
+ ]
+ ],
+ 'Primary pass; secondary fail' => [
+ $good, $good, $good,
+ [
+ AuthenticationResponse::newPass(),
+ ],
+ [
+ AuthenticationResponse::newFail( $this->message( '...' ) ),
+ ],
+ [
+ 'created' => new \DomainException(
+ 'MockSecondaryAuthenticationProvider::beginSecondaryAccountCreation() returned FAIL. ' .
+ 'Secondary providers are not allowed to fail account creation, ' .
+ 'that should have been done via testForAccountCreation().'
+ )
+ ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideAccountCreationLogging
+ * @param bool $isAnon
+ * @param string|null $logSubtype
+ */
+ public function testAccountCreationLogging( $isAnon, $logSubtype ) {
+ $creator = $isAnon ? new \User : \User::newFromName( 'UTSysop' );
+ $username = self::usernameForCreation();
+
+ $this->initializeManager();
+
+ // Set up lots of mocks...
+ $mock = $this->getMockForAbstractClass(
+ \MediaWiki\Auth\PrimaryAuthenticationProvider::class, []
+ );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( 'primary' ) );
+ $mock->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+ $mock->expects( $this->any() )->method( 'testForAccountCreation' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mock->expects( $this->any() )->method( 'testUserExists' )
+ ->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )->method( 'beginPrimaryAccountCreation' )
+ ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) );
+ $mock->expects( $this->any() )->method( 'finishAccountCreation' )
+ ->will( $this->returnValue( $logSubtype ) );
+
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+ $this->logger->setCollect( true );
+
+ $this->config->set( 'NewUserLog', true );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $maxLogId = $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] );
+
+ $userReq = new UsernameAuthenticationRequest;
+ $userReq->username = $username;
+ $reasonReq = new CreationReasonAuthenticationRequest;
+ $reasonReq->reason = $this->toString();
+ $ret = $this->manager->beginAccountCreation(
+ $creator, [ $userReq, $reasonReq ], 'http://localhost/'
+ );
+
+ $this->assertSame( AuthenticationResponse::PASS, $ret->status );
+
+ $user = \User::newFromName( $username );
+ $this->assertNotEquals( 0, $user->getId(), 'sanity check' );
+ $this->assertNotEquals( $creator->getId(), $user->getId(), 'sanity check' );
+
+ $data = \DatabaseLogEntry::getSelectQueryData();
+ $rows = iterator_to_array( $dbw->select(
+ $data['tables'],
+ $data['fields'],
+ [
+ 'log_id > ' . (int)$maxLogId,
+ 'log_type' => 'newusers'
+ ] + $data['conds'],
+ __METHOD__,
+ $data['options'],
+ $data['join_conds']
+ ) );
+ $this->assertCount( 1, $rows );
+ $entry = \DatabaseLogEntry::newFromRow( reset( $rows ) );
+
+ $this->assertSame( $logSubtype ?: ( $isAnon ? 'create' : 'create2' ), $entry->getSubtype() );
+ $this->assertSame(
+ $isAnon ? $user->getId() : $creator->getId(),
+ $entry->getPerformer()->getId()
+ );
+ $this->assertSame(
+ $isAnon ? $user->getName() : $creator->getName(),
+ $entry->getPerformer()->getName()
+ );
+ $this->assertSame( $user->getUserPage()->getFullText(), $entry->getTarget()->getFullText() );
+ $this->assertSame( [ '4::userid' => $user->getId() ], $entry->getParameters() );
+ $this->assertSame( $this->toString(), $entry->getComment() );
+ }
+
+ public static function provideAccountCreationLogging() {
+ return [
+ [ true, null ],
+ [ true, 'foobar' ],
+ [ false, null ],
+ [ false, 'byemail' ],
+ ];
+ }
+
+ public function testAutoAccountCreation() {
+ global $wgGroupPermissions, $wgHooks;
+
+ // PHPUnit seems to have a bug where it will call the ->with()
+ // callbacks for our hooks again after the test is run (WTF?), which
+ // breaks here because $username no longer matches $user by the end of
+ // the testing.
+ $workaroundPHPUnitBug = false;
+
+ $username = self::usernameForCreation();
+ $this->initializeManager();
+
+ $this->stashMwGlobals( [ 'wgGroupPermissions' ] );
+ $wgGroupPermissions['*']['createaccount'] = true;
+ $wgGroupPermissions['*']['autocreateaccount'] = false;
+
+ \ObjectCache::$instances[__METHOD__] = new \HashBagOStuff();
+ $this->setMwGlobals( [ 'wgMainCacheType' => __METHOD__ ] );
+
+ // Set up lots of mocks...
+ $mocks = [];
+ foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) {
+ $class = ucfirst( $key ) . 'AuthenticationProvider';
+ $mocks[$key] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" );
+ $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( $key ) );
+ }
+
+ $good = StatusValue::newGood();
+ $callback = $this->callback( function ( $user ) use ( &$username, &$workaroundPHPUnitBug ) {
+ return $workaroundPHPUnitBug || $user->getName() === $username;
+ } );
+
+ $mocks['pre']->expects( $this->exactly( 12 ) )->method( 'testUserForCreation' )
+ ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) )
+ ->will( $this->onConsecutiveCalls(
+ StatusValue::newFatal( 'ok' ), StatusValue::newFatal( 'ok' ), // For testing permissions
+ StatusValue::newFatal( 'fail-in-pre' ), $good, $good,
+ $good, // backoff test
+ $good, // addToDatabase fails test
+ $good, // addToDatabase throws test
+ $good, // addToDatabase exists test
+ $good, $good, $good // success
+ ) );
+
+ $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mocks['primary']->expects( $this->any() )->method( 'testUserExists' )
+ ->will( $this->returnValue( true ) );
+ $mocks['primary']->expects( $this->exactly( 9 ) )->method( 'testUserForCreation' )
+ ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) )
+ ->will( $this->onConsecutiveCalls(
+ StatusValue::newFatal( 'fail-in-primary' ), $good,
+ $good, // backoff test
+ $good, // addToDatabase fails test
+ $good, // addToDatabase throws test
+ $good, // addToDatabase exists test
+ $good, $good, $good
+ ) );
+ $mocks['primary']->expects( $this->exactly( 3 ) )->method( 'autoCreatedAccount' )
+ ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) );
+
+ $mocks['secondary']->expects( $this->exactly( 8 ) )->method( 'testUserForCreation' )
+ ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) )
+ ->will( $this->onConsecutiveCalls(
+ StatusValue::newFatal( 'fail-in-secondary' ),
+ $good, // backoff test
+ $good, // addToDatabase fails test
+ $good, // addToDatabase throws test
+ $good, // addToDatabase exists test
+ $good, $good, $good
+ ) );
+ $mocks['secondary']->expects( $this->exactly( 3 ) )->method( 'autoCreatedAccount' )
+ ->with( $callback, $this->identicalTo( AuthManager::AUTOCREATE_SOURCE_SESSION ) );
+
+ $this->preauthMocks = [ $mocks['pre'] ];
+ $this->primaryauthMocks = [ $mocks['primary'] ];
+ $this->secondaryauthMocks = [ $mocks['secondary'] ];
+ $this->initializeManager( true );
+ $session = $this->request->getSession();
+
+ $logger = new \TestLogger( true, function ( $m ) {
+ $m = str_replace( 'MediaWiki\\Auth\\AuthManager::autoCreateUser: ', '', $m );
+ return $m;
+ } );
+ $this->manager->setLogger( $logger );
+
+ try {
+ $user = \User::newFromName( 'UTSysop' );
+ $this->manager->autoCreateUser( $user, 'InvalidSource', true );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Unknown auto-creation source: InvalidSource', $ex->getMessage() );
+ }
+
+ // First, check an existing user
+ $session->clear();
+ $user = \User::newFromName( 'UTSysop' );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $expect = \Status::newGood();
+ $expect->warning( 'userexists' );
+ $this->assertEquals( $expect, $ret );
+ $this->assertNotEquals( 0, $user->getId() );
+ $this->assertSame( 'UTSysop', $user->getName() );
+ $this->assertEquals( $user->getId(), $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, '{username} already exists locally' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $session->clear();
+ $user = \User::newFromName( 'UTSysop' );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false );
+ $this->unhook( 'LocalUserCreated' );
+ $expect = \Status::newGood();
+ $expect->warning( 'userexists' );
+ $this->assertEquals( $expect, $ret );
+ $this->assertNotEquals( 0, $user->getId() );
+ $this->assertSame( 'UTSysop', $user->getName() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, '{username} already exists locally' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Wiki is read-only
+ $session->clear();
+ $readOnlyMode = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode();
+ $readOnlyMode->setReason( 'Because' );
+ $user = \User::newFromName( $username );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newFatal( wfMessage( 'readonlytext', 'Because' ) ), $ret );
+ $this->assertEquals( 0, $user->getId() );
+ $this->assertNotEquals( $username, $user->getName() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, 'denied by wfReadOnly(): {reason}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ $readOnlyMode->setReason( false );
+
+ // Session blacklisted
+ $session->clear();
+ $session->set( 'AuthManager::AutoCreateBlacklist', 'test' );
+ $user = \User::newFromName( $username );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newFatal( 'test' ), $ret );
+ $this->assertEquals( 0, $user->getId() );
+ $this->assertNotEquals( $username, $user->getName() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, 'blacklisted in session {sessionid}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $session->clear();
+ $session->set( 'AuthManager::AutoCreateBlacklist', StatusValue::newFatal( 'test2' ) );
+ $user = \User::newFromName( $username );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newFatal( 'test2' ), $ret );
+ $this->assertEquals( 0, $user->getId() );
+ $this->assertNotEquals( $username, $user->getName() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, 'blacklisted in session {sessionid}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Uncreatable name
+ $session->clear();
+ $user = \User::newFromName( $username . '@' );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newFatal( 'noname' ), $ret );
+ $this->assertEquals( 0, $user->getId() );
+ $this->assertNotEquals( $username . '@', $user->getId() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, 'name "{username}" is not creatable' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ $this->assertSame( 'noname', $session->get( 'AuthManager::AutoCreateBlacklist' ) );
+
+ // IP unable to create accounts
+ $wgGroupPermissions['*']['createaccount'] = false;
+ $wgGroupPermissions['*']['autocreateaccount'] = false;
+ $session->clear();
+ $user = \User::newFromName( $username );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newFatal( 'authmanager-autocreate-noperm' ), $ret );
+ $this->assertEquals( 0, $user->getId() );
+ $this->assertNotEquals( $username, $user->getName() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, 'IP lacks the ability to create or autocreate accounts' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ $this->assertSame(
+ 'authmanager-autocreate-noperm', $session->get( 'AuthManager::AutoCreateBlacklist' )
+ );
+
+ // Test that both permutations of permissions are allowed
+ // (this hits the two "ok" entries in $mocks['pre'])
+ $wgGroupPermissions['*']['createaccount'] = false;
+ $wgGroupPermissions['*']['autocreateaccount'] = true;
+ $session->clear();
+ $user = \User::newFromName( $username );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newFatal( 'ok' ), $ret );
+
+ $wgGroupPermissions['*']['createaccount'] = true;
+ $wgGroupPermissions['*']['autocreateaccount'] = false;
+ $session->clear();
+ $user = \User::newFromName( $username );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newFatal( 'ok' ), $ret );
+ $logger->clearBuffer();
+
+ // Test lock fail
+ $session->clear();
+ $user = \User::newFromName( $username );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $cache = \ObjectCache::getLocalClusterInstance();
+ $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ unset( $lock );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newFatal( 'usernameinprogress' ), $ret );
+ $this->assertEquals( 0, $user->getId() );
+ $this->assertNotEquals( $username, $user->getName() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, 'Could not acquire account creation lock' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Test pre-authentication provider fail
+ $session->clear();
+ $user = \User::newFromName( $username );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newFatal( 'fail-in-pre' ), $ret );
+ $this->assertEquals( 0, $user->getId() );
+ $this->assertNotEquals( $username, $user->getName() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ $this->assertEquals(
+ StatusValue::newFatal( 'fail-in-pre' ), $session->get( 'AuthManager::AutoCreateBlacklist' )
+ );
+
+ $session->clear();
+ $user = \User::newFromName( $username );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newFatal( 'fail-in-primary' ), $ret );
+ $this->assertEquals( 0, $user->getId() );
+ $this->assertNotEquals( $username, $user->getName() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ $this->assertEquals(
+ StatusValue::newFatal( 'fail-in-primary' ), $session->get( 'AuthManager::AutoCreateBlacklist' )
+ );
+
+ $session->clear();
+ $user = \User::newFromName( $username );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newFatal( 'fail-in-secondary' ), $ret );
+ $this->assertEquals( 0, $user->getId() );
+ $this->assertNotEquals( $username, $user->getName() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ $this->assertEquals(
+ StatusValue::newFatal( 'fail-in-secondary' ), $session->get( 'AuthManager::AutoCreateBlacklist' )
+ );
+
+ // Test backoff
+ $cache = \ObjectCache::getLocalClusterInstance();
+ $backoffKey = wfMemcKey( 'AuthManager', 'autocreate-failed', md5( $username ) );
+ $cache->set( $backoffKey, true );
+ $session->clear();
+ $user = \User::newFromName( $username );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newFatal( 'authmanager-autocreate-exception' ), $ret );
+ $this->assertEquals( 0, $user->getId() );
+ $this->assertNotEquals( $username, $user->getName() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, '{username} denied by prior creation attempt failures' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) );
+ $cache->delete( $backoffKey );
+
+ // Test addToDatabase fails
+ $session->clear();
+ $user = $this->getMockBuilder( \User::class )
+ ->setMethods( [ 'addToDatabase' ] )->getMock();
+ $user->expects( $this->once() )->method( 'addToDatabase' )
+ ->will( $this->returnValue( \Status::newFatal( 'because' ) ) );
+ $user->setName( $username );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->assertEquals( \Status::newFatal( 'because' ), $ret );
+ $this->assertEquals( 0, $user->getId() );
+ $this->assertNotEquals( $username, $user->getName() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ],
+ [ LogLevel::ERROR, '{username} failed with message {msg}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) );
+
+ // Test addToDatabase throws an exception
+ $cache = \ObjectCache::getLocalClusterInstance();
+ $backoffKey = wfMemcKey( 'AuthManager', 'autocreate-failed', md5( $username ) );
+ $this->assertFalse( $cache->get( $backoffKey ), 'sanity check' );
+ $session->clear();
+ $user = $this->getMockBuilder( \User::class )
+ ->setMethods( [ 'addToDatabase' ] )->getMock();
+ $user->expects( $this->once() )->method( 'addToDatabase' )
+ ->will( $this->throwException( new \Exception( 'Excepted' ) ) );
+ $user->setName( $username );
+ try {
+ $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \Exception $ex ) {
+ $this->assertSame( 'Excepted', $ex->getMessage() );
+ }
+ $this->assertEquals( 0, $user->getId() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ],
+ [ LogLevel::ERROR, '{username} failed with exception {exception}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) );
+ $this->assertNotEquals( false, $cache->get( $backoffKey ) );
+ $cache->delete( $backoffKey );
+
+ // Test addToDatabase fails because the user already exists.
+ $session->clear();
+ $user = $this->getMockBuilder( \User::class )
+ ->setMethods( [ 'addToDatabase' ] )->getMock();
+ $user->expects( $this->once() )->method( 'addToDatabase' )
+ ->will( $this->returnCallback( function () use ( $username, &$user ) {
+ $oldUser = \User::newFromName( $username );
+ $status = $oldUser->addToDatabase();
+ $this->assertTrue( $status->isOK(), 'sanity check' );
+ $user->setId( $oldUser->getId() );
+ return \Status::newFatal( 'userexists' );
+ } ) );
+ $user->setName( $username );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $expect = \Status::newGood();
+ $expect->warning( 'userexists' );
+ $this->assertEquals( $expect, $ret );
+ $this->assertNotEquals( 0, $user->getId() );
+ $this->assertEquals( $username, $user->getName() );
+ $this->assertEquals( $user->getId(), $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ],
+ [ LogLevel::INFO, '{username} already exists locally (race)' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ $this->assertSame( null, $session->get( 'AuthManager::AutoCreateBlacklist' ) );
+
+ // Success!
+ $session->clear();
+ $username = self::usernameForCreation();
+ $user = \User::newFromName( $username );
+ $this->hook( 'AuthPluginAutoCreate', $this->once() )
+ ->with( $callback );
+ $this->hideDeprecated( 'AuthPluginAutoCreate hook (used in ' .
+ get_class( $wgHooks['AuthPluginAutoCreate'][0] ) . '::onAuthPluginAutoCreate)' );
+ $this->hook( 'LocalUserCreated', $this->once() )
+ ->with( $callback, $this->equalTo( true ) );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true );
+ $this->unhook( 'LocalUserCreated' );
+ $this->unhook( 'AuthPluginAutoCreate' );
+ $this->assertEquals( \Status::newGood(), $ret );
+ $this->assertNotEquals( 0, $user->getId() );
+ $this->assertEquals( $username, $user->getName() );
+ $this->assertEquals( $user->getId(), $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $dbw = wfGetDB( DB_MASTER );
+ $maxLogId = $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] );
+ $session->clear();
+ $username = self::usernameForCreation();
+ $user = \User::newFromName( $username );
+ $this->hook( 'LocalUserCreated', $this->once() )
+ ->with( $callback, $this->equalTo( true ) );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false );
+ $this->unhook( 'LocalUserCreated' );
+ $this->assertEquals( \Status::newGood(), $ret );
+ $this->assertNotEquals( 0, $user->getId() );
+ $this->assertEquals( $username, $user->getName() );
+ $this->assertEquals( 0, $session->getUser()->getId() );
+ $this->assertSame( [
+ [ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ $this->assertSame(
+ $maxLogId,
+ $dbw->selectField( 'logging', 'MAX(log_id)', [ 'log_type' => 'newusers' ] )
+ );
+
+ $this->config->set( 'NewUserLog', true );
+ $session->clear();
+ $username = self::usernameForCreation();
+ $user = \User::newFromName( $username );
+ $ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false );
+ $this->assertEquals( \Status::newGood(), $ret );
+ $logger->clearBuffer();
+
+ $data = \DatabaseLogEntry::getSelectQueryData();
+ $rows = iterator_to_array( $dbw->select(
+ $data['tables'],
+ $data['fields'],
+ [
+ 'log_id > ' . (int)$maxLogId,
+ 'log_type' => 'newusers'
+ ] + $data['conds'],
+ __METHOD__,
+ $data['options'],
+ $data['join_conds']
+ ) );
+ $this->assertCount( 1, $rows );
+ $entry = \DatabaseLogEntry::newFromRow( reset( $rows ) );
+
+ $this->assertSame( 'autocreate', $entry->getSubtype() );
+ $this->assertSame( $user->getId(), $entry->getPerformer()->getId() );
+ $this->assertSame( $user->getName(), $entry->getPerformer()->getName() );
+ $this->assertSame( $user->getUserPage()->getFullText(), $entry->getTarget()->getFullText() );
+ $this->assertSame( [ '4::userid' => $user->getId() ], $entry->getParameters() );
+
+ $workaroundPHPUnitBug = true;
+ }
+
+ /**
+ * @dataProvider provideGetAuthenticationRequests
+ * @param string $action
+ * @param array $expect
+ * @param array $state
+ */
+ public function testGetAuthenticationRequests( $action, $expect, $state = [] ) {
+ $makeReq = function ( $key ) use ( $action ) {
+ $req = $this->createMock( AuthenticationRequest::class );
+ $req->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( $key ) );
+ $req->action = $action === AuthManager::ACTION_UNLINK ? AuthManager::ACTION_REMOVE : $action;
+ $req->key = $key;
+ return $req;
+ };
+ $cmpReqs = function ( $a, $b ) {
+ $ret = strcmp( get_class( $a ), get_class( $b ) );
+ if ( !$ret ) {
+ $ret = strcmp( $a->key, $b->key );
+ }
+ return $ret;
+ };
+
+ $good = StatusValue::newGood();
+
+ $mocks = [];
+ foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) {
+ $class = ucfirst( $key ) . 'AuthenticationProvider';
+ $mocks[$key] = $this->getMockBuilder( "MediaWiki\\Auth\\$class" )
+ ->setMethods( [
+ 'getUniqueId', 'getAuthenticationRequests', 'providerAllowsAuthenticationDataChange',
+ ] )
+ ->getMockForAbstractClass();
+ $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( $key ) );
+ $mocks[$key]->expects( $this->any() )->method( 'getAuthenticationRequests' )
+ ->will( $this->returnCallback( function ( $action ) use ( $key, $makeReq ) {
+ return [ $makeReq( "$key-$action" ), $makeReq( 'generic' ) ];
+ } ) );
+ $mocks[$key]->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' )
+ ->will( $this->returnValue( $good ) );
+ }
+
+ $primaries = [];
+ foreach ( [
+ PrimaryAuthenticationProvider::TYPE_NONE,
+ PrimaryAuthenticationProvider::TYPE_CREATE,
+ PrimaryAuthenticationProvider::TYPE_LINK
+ ] as $type ) {
+ $class = 'PrimaryAuthenticationProvider';
+ $mocks["primary-$type"] = $this->getMockBuilder( "MediaWiki\\Auth\\$class" )
+ ->setMethods( [
+ 'getUniqueId', 'accountCreationType', 'getAuthenticationRequests',
+ 'providerAllowsAuthenticationDataChange',
+ ] )
+ ->getMockForAbstractClass();
+ $mocks["primary-$type"]->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( "primary-$type" ) );
+ $mocks["primary-$type"]->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( $type ) );
+ $mocks["primary-$type"]->expects( $this->any() )->method( 'getAuthenticationRequests' )
+ ->will( $this->returnCallback( function ( $action ) use ( $type, $makeReq ) {
+ return [ $makeReq( "primary-$type-$action" ), $makeReq( 'generic' ) ];
+ } ) );
+ $mocks["primary-$type"]->expects( $this->any() )
+ ->method( 'providerAllowsAuthenticationDataChange' )
+ ->will( $this->returnValue( $good ) );
+ $this->primaryauthMocks[] = $mocks["primary-$type"];
+ }
+
+ $mocks['primary2'] = $this->getMockBuilder( PrimaryAuthenticationProvider::class )
+ ->setMethods( [
+ 'getUniqueId', 'accountCreationType', 'getAuthenticationRequests',
+ 'providerAllowsAuthenticationDataChange',
+ ] )
+ ->getMockForAbstractClass();
+ $mocks['primary2']->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( 'primary2' ) );
+ $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) );
+ $mocks['primary2']->expects( $this->any() )->method( 'getAuthenticationRequests' )
+ ->will( $this->returnValue( [] ) );
+ $mocks['primary2']->expects( $this->any() )
+ ->method( 'providerAllowsAuthenticationDataChange' )
+ ->will( $this->returnCallback( function ( $req ) use ( $good ) {
+ return $req->key === 'generic' ? StatusValue::newFatal( 'no' ) : $good;
+ } ) );
+ $this->primaryauthMocks[] = $mocks['primary2'];
+
+ $this->preauthMocks = [ $mocks['pre'] ];
+ $this->secondaryauthMocks = [ $mocks['secondary'] ];
+ $this->initializeManager( true );
+
+ if ( $state ) {
+ if ( isset( $state['continueRequests'] ) ) {
+ $state['continueRequests'] = array_map( $makeReq, $state['continueRequests'] );
+ }
+ if ( $action === AuthManager::ACTION_LOGIN_CONTINUE ) {
+ $this->request->getSession()->setSecret( 'AuthManager::authnState', $state );
+ } elseif ( $action === AuthManager::ACTION_CREATE_CONTINUE ) {
+ $this->request->getSession()->setSecret( 'AuthManager::accountCreationState', $state );
+ } elseif ( $action === AuthManager::ACTION_LINK_CONTINUE ) {
+ $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', $state );
+ }
+ }
+
+ $expectReqs = array_map( $makeReq, $expect );
+ if ( $action === AuthManager::ACTION_LOGIN ) {
+ $req = new RememberMeAuthenticationRequest;
+ $req->action = $action;
+ $req->required = AuthenticationRequest::REQUIRED;
+ $expectReqs[] = $req;
+ } elseif ( $action === AuthManager::ACTION_CREATE ) {
+ $req = new UsernameAuthenticationRequest;
+ $req->action = $action;
+ $expectReqs[] = $req;
+ $req = new UserDataAuthenticationRequest;
+ $req->action = $action;
+ $req->required = AuthenticationRequest::REQUIRED;
+ $expectReqs[] = $req;
+ }
+ usort( $expectReqs, $cmpReqs );
+
+ $actual = $this->manager->getAuthenticationRequests( $action );
+ foreach ( $actual as $req ) {
+ // Don't test this here.
+ $req->required = AuthenticationRequest::REQUIRED;
+ }
+ usort( $actual, $cmpReqs );
+
+ $this->assertEquals( $expectReqs, $actual );
+
+ // Test CreationReasonAuthenticationRequest gets returned
+ if ( $action === AuthManager::ACTION_CREATE ) {
+ $req = new CreationReasonAuthenticationRequest;
+ $req->action = $action;
+ $req->required = AuthenticationRequest::REQUIRED;
+ $expectReqs[] = $req;
+ usort( $expectReqs, $cmpReqs );
+
+ $actual = $this->manager->getAuthenticationRequests( $action, \User::newFromName( 'UTSysop' ) );
+ foreach ( $actual as $req ) {
+ // Don't test this here.
+ $req->required = AuthenticationRequest::REQUIRED;
+ }
+ usort( $actual, $cmpReqs );
+
+ $this->assertEquals( $expectReqs, $actual );
+ }
+ }
+
+ public static function provideGetAuthenticationRequests() {
+ return [
+ [
+ AuthManager::ACTION_LOGIN,
+ [ 'pre-login', 'primary-none-login', 'primary-create-login',
+ 'primary-link-login', 'secondary-login', 'generic' ],
+ ],
+ [
+ AuthManager::ACTION_CREATE,
+ [ 'pre-create', 'primary-none-create', 'primary-create-create',
+ 'primary-link-create', 'secondary-create', 'generic' ],
+ ],
+ [
+ AuthManager::ACTION_LINK,
+ [ 'primary-link-link', 'generic' ],
+ ],
+ [
+ AuthManager::ACTION_CHANGE,
+ [ 'primary-none-change', 'primary-create-change', 'primary-link-change',
+ 'secondary-change' ],
+ ],
+ [
+ AuthManager::ACTION_REMOVE,
+ [ 'primary-none-remove', 'primary-create-remove', 'primary-link-remove',
+ 'secondary-remove' ],
+ ],
+ [
+ AuthManager::ACTION_UNLINK,
+ [ 'primary-link-remove' ],
+ ],
+ [
+ AuthManager::ACTION_LOGIN_CONTINUE,
+ [],
+ ],
+ [
+ AuthManager::ACTION_LOGIN_CONTINUE,
+ $reqs = [ 'continue-login', 'foo', 'bar' ],
+ [
+ 'continueRequests' => $reqs,
+ ],
+ ],
+ [
+ AuthManager::ACTION_CREATE_CONTINUE,
+ [],
+ ],
+ [
+ AuthManager::ACTION_CREATE_CONTINUE,
+ $reqs = [ 'continue-create', 'foo', 'bar' ],
+ [
+ 'continueRequests' => $reqs,
+ ],
+ ],
+ [
+ AuthManager::ACTION_LINK_CONTINUE,
+ [],
+ ],
+ [
+ AuthManager::ACTION_LINK_CONTINUE,
+ $reqs = [ 'continue-link', 'foo', 'bar' ],
+ [
+ 'continueRequests' => $reqs,
+ ],
+ ],
+ ];
+ }
+
+ public function testGetAuthenticationRequestsRequired() {
+ $makeReq = function ( $key, $required ) {
+ $req = $this->createMock( AuthenticationRequest::class );
+ $req->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( $key ) );
+ $req->action = AuthManager::ACTION_LOGIN;
+ $req->key = $key;
+ $req->required = $required;
+ return $req;
+ };
+ $cmpReqs = function ( $a, $b ) {
+ $ret = strcmp( get_class( $a ), get_class( $b ) );
+ if ( !$ret ) {
+ $ret = strcmp( $a->key, $b->key );
+ }
+ return $ret;
+ };
+
+ $good = StatusValue::newGood();
+
+ $primary1 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $primary1->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( 'primary1' ) );
+ $primary1->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $primary1->expects( $this->any() )->method( 'getAuthenticationRequests' )
+ ->will( $this->returnCallback( function ( $action ) use ( $makeReq ) {
+ return [
+ $makeReq( "primary-shared", AuthenticationRequest::REQUIRED ),
+ $makeReq( "required", AuthenticationRequest::REQUIRED ),
+ $makeReq( "optional", AuthenticationRequest::OPTIONAL ),
+ $makeReq( "foo", AuthenticationRequest::REQUIRED ),
+ $makeReq( "bar", AuthenticationRequest::REQUIRED ),
+ $makeReq( "baz", AuthenticationRequest::OPTIONAL ),
+ ];
+ } ) );
+
+ $primary2 = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $primary2->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( 'primary2' ) );
+ $primary2->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $primary2->expects( $this->any() )->method( 'getAuthenticationRequests' )
+ ->will( $this->returnCallback( function ( $action ) use ( $makeReq ) {
+ return [
+ $makeReq( "primary-shared", AuthenticationRequest::REQUIRED ),
+ $makeReq( "required2", AuthenticationRequest::REQUIRED ),
+ $makeReq( "optional2", AuthenticationRequest::OPTIONAL ),
+ ];
+ } ) );
+
+ $secondary = $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class );
+ $secondary->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( 'secondary' ) );
+ $secondary->expects( $this->any() )->method( 'getAuthenticationRequests' )
+ ->will( $this->returnCallback( function ( $action ) use ( $makeReq ) {
+ return [
+ $makeReq( "foo", AuthenticationRequest::OPTIONAL ),
+ $makeReq( "bar", AuthenticationRequest::REQUIRED ),
+ $makeReq( "baz", AuthenticationRequest::REQUIRED ),
+ ];
+ } ) );
+
+ $rememberReq = new RememberMeAuthenticationRequest;
+ $rememberReq->action = AuthManager::ACTION_LOGIN;
+
+ $this->primaryauthMocks = [ $primary1, $primary2 ];
+ $this->secondaryauthMocks = [ $secondary ];
+ $this->initializeManager( true );
+
+ $actual = $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN );
+ $expected = [
+ $rememberReq,
+ $makeReq( "primary-shared", AuthenticationRequest::PRIMARY_REQUIRED ),
+ $makeReq( "required", AuthenticationRequest::PRIMARY_REQUIRED ),
+ $makeReq( "required2", AuthenticationRequest::PRIMARY_REQUIRED ),
+ $makeReq( "optional", AuthenticationRequest::OPTIONAL ),
+ $makeReq( "optional2", AuthenticationRequest::OPTIONAL ),
+ $makeReq( "foo", AuthenticationRequest::PRIMARY_REQUIRED ),
+ $makeReq( "bar", AuthenticationRequest::REQUIRED ),
+ $makeReq( "baz", AuthenticationRequest::REQUIRED ),
+ ];
+ usort( $actual, $cmpReqs );
+ usort( $expected, $cmpReqs );
+ $this->assertEquals( $expected, $actual );
+
+ $this->primaryauthMocks = [ $primary1 ];
+ $this->secondaryauthMocks = [ $secondary ];
+ $this->initializeManager( true );
+
+ $actual = $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN );
+ $expected = [
+ $rememberReq,
+ $makeReq( "primary-shared", AuthenticationRequest::PRIMARY_REQUIRED ),
+ $makeReq( "required", AuthenticationRequest::PRIMARY_REQUIRED ),
+ $makeReq( "optional", AuthenticationRequest::OPTIONAL ),
+ $makeReq( "foo", AuthenticationRequest::PRIMARY_REQUIRED ),
+ $makeReq( "bar", AuthenticationRequest::REQUIRED ),
+ $makeReq( "baz", AuthenticationRequest::REQUIRED ),
+ ];
+ usort( $actual, $cmpReqs );
+ usort( $expected, $cmpReqs );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public function testAllowsPropertyChange() {
+ $mocks = [];
+ foreach ( [ 'primary', 'secondary' ] as $key ) {
+ $class = ucfirst( $key ) . 'AuthenticationProvider';
+ $mocks[$key] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" );
+ $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( $key ) );
+ $mocks[$key]->expects( $this->any() )->method( 'providerAllowsPropertyChange' )
+ ->will( $this->returnCallback( function ( $prop ) use ( $key ) {
+ return $prop !== $key;
+ } ) );
+ }
+
+ $this->primaryauthMocks = [ $mocks['primary'] ];
+ $this->secondaryauthMocks = [ $mocks['secondary'] ];
+ $this->initializeManager( true );
+
+ $this->assertTrue( $this->manager->allowsPropertyChange( 'foo' ) );
+ $this->assertFalse( $this->manager->allowsPropertyChange( 'primary' ) );
+ $this->assertFalse( $this->manager->allowsPropertyChange( 'secondary' ) );
+ }
+
+ public function testAutoCreateOnLogin() {
+ $username = self::usernameForCreation();
+
+ $req = $this->createMock( AuthenticationRequest::class );
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'primary' ) );
+ $mock->expects( $this->any() )->method( 'beginPrimaryAuthentication' )
+ ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+
+ $mock2 = $this->getMockForAbstractClass( SecondaryAuthenticationProvider::class );
+ $mock2->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( 'secondary' ) );
+ $mock2->expects( $this->any() )->method( 'beginSecondaryAuthentication' )->will(
+ $this->returnValue(
+ AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) )
+ )
+ );
+ $mock2->expects( $this->any() )->method( 'continueSecondaryAuthentication' )
+ ->will( $this->returnValue( AuthenticationResponse::newAbstain() ) );
+ $mock2->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+
+ $this->primaryauthMocks = [ $mock ];
+ $this->secondaryauthMocks = [ $mock2 ];
+ $this->initializeManager( true );
+ $this->manager->setLogger( new \Psr\Log\NullLogger() );
+ $session = $this->request->getSession();
+ $session->clear();
+
+ $this->assertSame( 0, \User::newFromName( $username )->getId(),
+ 'sanity check' );
+
+ $callback = $this->callback( function ( $user ) use ( $username ) {
+ return $user->getName() === $username;
+ } );
+
+ $this->hook( 'UserLoggedIn', $this->never() );
+ $this->hook( 'LocalUserCreated', $this->once() )->with( $callback, $this->equalTo( true ) );
+ $ret = $this->manager->beginAuthentication( [], 'http://localhost/' );
+ $this->unhook( 'LocalUserCreated' );
+ $this->unhook( 'UserLoggedIn' );
+ $this->assertSame( AuthenticationResponse::UI, $ret->status );
+
+ $id = (int)\User::newFromName( $username )->getId();
+ $this->assertNotSame( 0, \User::newFromName( $username )->getId() );
+ $this->assertSame( 0, $session->getUser()->getId() );
+
+ $this->hook( 'UserLoggedIn', $this->once() )->with( $callback );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->continueAuthentication( [] );
+ $this->unhook( 'LocalUserCreated' );
+ $this->unhook( 'UserLoggedIn' );
+ $this->assertSame( AuthenticationResponse::PASS, $ret->status );
+ $this->assertSame( $username, $ret->username );
+ $this->assertSame( $id, $session->getUser()->getId() );
+ }
+
+ public function testAutoCreateFailOnLogin() {
+ $username = self::usernameForCreation();
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'primary' ) );
+ $mock->expects( $this->any() )->method( 'beginPrimaryAuthentication' )
+ ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mock->expects( $this->any() )->method( 'testUserExists' )->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )->method( 'testUserForCreation' )
+ ->will( $this->returnValue( StatusValue::newFatal( 'fail-from-primary' ) ) );
+
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+ $this->manager->setLogger( new \Psr\Log\NullLogger() );
+ $session = $this->request->getSession();
+ $session->clear();
+
+ $this->assertSame( 0, $session->getUser()->getId(),
+ 'sanity check' );
+ $this->assertSame( 0, \User::newFromName( $username )->getId(),
+ 'sanity check' );
+
+ $this->hook( 'UserLoggedIn', $this->never() );
+ $this->hook( 'LocalUserCreated', $this->never() );
+ $ret = $this->manager->beginAuthentication( [], 'http://localhost/' );
+ $this->unhook( 'LocalUserCreated' );
+ $this->unhook( 'UserLoggedIn' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'authmanager-authn-autocreate-failed', $ret->message->getKey() );
+
+ $this->assertSame( 0, \User::newFromName( $username )->getId() );
+ $this->assertSame( 0, $session->getUser()->getId() );
+ }
+
+ public function testAuthenticationSessionData() {
+ $this->initializeManager( true );
+
+ $this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) );
+ $this->manager->setAuthenticationSessionData( 'foo', 'foo!' );
+ $this->manager->setAuthenticationSessionData( 'bar', 'bar!' );
+ $this->assertSame( 'foo!', $this->manager->getAuthenticationSessionData( 'foo' ) );
+ $this->assertSame( 'bar!', $this->manager->getAuthenticationSessionData( 'bar' ) );
+ $this->manager->removeAuthenticationSessionData( 'foo' );
+ $this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) );
+ $this->assertSame( 'bar!', $this->manager->getAuthenticationSessionData( 'bar' ) );
+ $this->manager->removeAuthenticationSessionData( 'bar' );
+ $this->assertNull( $this->manager->getAuthenticationSessionData( 'bar' ) );
+
+ $this->manager->setAuthenticationSessionData( 'foo', 'foo!' );
+ $this->manager->setAuthenticationSessionData( 'bar', 'bar!' );
+ $this->manager->removeAuthenticationSessionData( null );
+ $this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) );
+ $this->assertNull( $this->manager->getAuthenticationSessionData( 'bar' ) );
+ }
+
+ public function testCanLinkAccounts() {
+ $types = [
+ PrimaryAuthenticationProvider::TYPE_CREATE => true,
+ PrimaryAuthenticationProvider::TYPE_LINK => true,
+ PrimaryAuthenticationProvider::TYPE_NONE => false,
+ ];
+
+ foreach ( $types as $type => $can ) {
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( $type ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( $type ) );
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+ $this->assertSame( $can, $this->manager->canCreateAccounts(), $type );
+ }
+ }
+
+ public function testBeginAccountLink() {
+ $user = \User::newFromName( 'UTSysop' );
+ $this->initializeManager();
+
+ $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', 'test' );
+ try {
+ $this->manager->beginAccountLink( $user, [], 'http://localhost/' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \LogicException $ex ) {
+ $this->assertEquals( 'Account linking is not possible', $ex->getMessage() );
+ }
+ $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ) );
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) );
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+
+ $ret = $this->manager->beginAccountLink( new \User, [], 'http://localhost/' );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'noname', $ret->message->getKey() );
+
+ $ret = $this->manager->beginAccountLink(
+ \User::newFromName( 'UTDoesNotExist' ), [], 'http://localhost/'
+ );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'authmanager-userdoesnotexist', $ret->message->getKey() );
+ }
+
+ public function testContinueAccountLink() {
+ $user = \User::newFromName( 'UTSysop' );
+ $this->initializeManager();
+
+ $session = [
+ 'userid' => $user->getId(),
+ 'username' => $user->getName(),
+ 'primary' => 'X',
+ ];
+
+ try {
+ $this->manager->continueAccountLink( [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \LogicException $ex ) {
+ $this->assertEquals( 'Account linking is not possible', $ex->getMessage() );
+ }
+
+ $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
+ $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'X' ) );
+ $mock->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) );
+ $mock->expects( $this->any() )->method( 'beginPrimaryAccountLink' )->will(
+ $this->returnValue( AuthenticationResponse::newFail( $this->message( 'fail' ) ) )
+ );
+ $this->primaryauthMocks = [ $mock ];
+ $this->initializeManager( true );
+
+ $this->request->getSession()->setSecret( 'AuthManager::accountLinkState', null );
+ $ret = $this->manager->continueAccountLink( [] );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'authmanager-link-not-in-progress', $ret->message->getKey() );
+
+ $this->request->getSession()->setSecret( 'AuthManager::accountLinkState',
+ [ 'username' => $user->getName() . '<>' ] + $session );
+ $ret = $this->manager->continueAccountLink( [] );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'noname', $ret->message->getKey() );
+ $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ) );
+
+ $id = $user->getId();
+ $this->request->getSession()->setSecret( 'AuthManager::accountLinkState',
+ [ 'userid' => $id + 1 ] + $session );
+ try {
+ $ret = $this->manager->continueAccountLink( [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertEquals(
+ "User \"{$user->getName()}\" is valid, but ID $id != " . ( $id + 1 ) . '!',
+ $ex->getMessage()
+ );
+ }
+ $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ) );
+ }
+
+ /**
+ * @dataProvider provideAccountLink
+ * @param StatusValue $preTest
+ * @param array $primaryResponses
+ * @param array $managerResponses
+ */
+ public function testAccountLink(
+ StatusValue $preTest, array $primaryResponses, array $managerResponses
+ ) {
+ $user = \User::newFromName( 'UTSysop' );
+
+ $this->initializeManager();
+
+ // Set up lots of mocks...
+ $req = $this->getMockForAbstractClass( AuthenticationRequest::class );
+ $req->primary = $primaryResponses;
+ $mocks = [];
+
+ foreach ( [ 'pre', 'primary' ] as $key ) {
+ $class = ucfirst( $key ) . 'AuthenticationProvider';
+ $mocks[$key] = $this->getMockForAbstractClass(
+ "MediaWiki\\Auth\\$class", [], "Mock$class"
+ );
+ $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( $key ) );
+
+ for ( $i = 2; $i <= 3; $i++ ) {
+ $mocks[$key . $i] = $this->getMockForAbstractClass(
+ "MediaWiki\\Auth\\$class", [], "Mock$class"
+ );
+ $mocks[$key . $i]->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( $key . $i ) );
+ }
+ }
+
+ $mocks['pre']->expects( $this->any() )->method( 'testForAccountLink' )
+ ->will( $this->returnCallback(
+ function ( $u )
+ use ( $user, $preTest )
+ {
+ $this->assertSame( $user->getId(), $u->getId() );
+ $this->assertSame( $user->getName(), $u->getName() );
+ return $preTest;
+ }
+ ) );
+
+ $mocks['pre2']->expects( $this->atMost( 1 ) )->method( 'testForAccountLink' )
+ ->will( $this->returnValue( StatusValue::newGood() ) );
+
+ $mocks['primary']->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) );
+ $ct = count( $req->primary );
+ $callback = $this->returnCallback( function ( $u, $reqs ) use ( $user, $req ) {
+ $this->assertSame( $user->getId(), $u->getId() );
+ $this->assertSame( $user->getName(), $u->getName() );
+ $foundReq = false;
+ foreach ( $reqs as $r ) {
+ $this->assertSame( $user->getName(), $r->username );
+ $foundReq = $foundReq || get_class( $r ) === get_class( $req );
+ }
+ $this->assertTrue( $foundReq, '$reqs contains $req' );
+ return array_shift( $req->primary );
+ } );
+ $mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) )
+ ->method( 'beginPrimaryAccountLink' )
+ ->will( $callback );
+ $mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) )
+ ->method( 'continuePrimaryAccountLink' )
+ ->will( $callback );
+
+ $abstain = AuthenticationResponse::newAbstain();
+ $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_LINK ) );
+ $mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAccountLink' )
+ ->will( $this->returnValue( $abstain ) );
+ $mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAccountLink' );
+ $mocks['primary3']->expects( $this->any() )->method( 'accountCreationType' )
+ ->will( $this->returnValue( PrimaryAuthenticationProvider::TYPE_CREATE ) );
+ $mocks['primary3']->expects( $this->never() )->method( 'beginPrimaryAccountLink' );
+ $mocks['primary3']->expects( $this->never() )->method( 'continuePrimaryAccountLink' );
+
+ $this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ];
+ $this->primaryauthMocks = [ $mocks['primary3'], $mocks['primary2'], $mocks['primary'] ];
+ $this->logger = new \TestLogger( true, function ( $message, $level ) {
+ return $level === LogLevel::DEBUG ? null : $message;
+ } );
+ $this->initializeManager( true );
+
+ $constraint = \PHPUnit_Framework_Assert::logicalOr(
+ $this->equalTo( AuthenticationResponse::PASS ),
+ $this->equalTo( AuthenticationResponse::FAIL )
+ );
+ $providers = array_merge( $this->preauthMocks, $this->primaryauthMocks );
+ foreach ( $providers as $p ) {
+ $p->postCalled = false;
+ $p->expects( $this->atMost( 1 ) )->method( 'postAccountLink' )
+ ->willReturnCallback( function ( $user, $response ) use ( $constraint, $p ) {
+ $this->assertInstanceOf( \User::class, $user );
+ $this->assertSame( 'UTSysop', $user->getName() );
+ $this->assertInstanceOf( AuthenticationResponse::class, $response );
+ $this->assertThat( $response->status, $constraint );
+ $p->postCalled = $response->status;
+ } );
+ }
+
+ $first = true;
+ $created = false;
+ $expectLog = [];
+ foreach ( $managerResponses as $i => $response ) {
+ if ( $response instanceof AuthenticationResponse &&
+ $response->status === AuthenticationResponse::PASS
+ ) {
+ $expectLog[] = [ LogLevel::INFO, 'Account linked to {user} by primary' ];
+ }
+
+ $ex = null;
+ try {
+ if ( $first ) {
+ $ret = $this->manager->beginAccountLink( $user, [ $req ], 'http://localhost/' );
+ } else {
+ $ret = $this->manager->continueAccountLink( [ $req ] );
+ }
+ if ( $response instanceof \Exception ) {
+ $this->fail( 'Expected exception not thrown', "Response $i" );
+ }
+ } catch ( \Exception $ex ) {
+ if ( !$response instanceof \Exception ) {
+ throw $ex;
+ }
+ $this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" );
+ $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ),
+ "Response $i, exception, session state" );
+ return;
+ }
+
+ $this->assertSame( 'http://localhost/', $req->returnToUrl );
+
+ $ret->message = $this->message( $ret->message );
+ $this->assertEquals( $response, $ret, "Response $i, response" );
+ if ( $response->status === AuthenticationResponse::PASS ||
+ $response->status === AuthenticationResponse::FAIL
+ ) {
+ $this->assertNull( $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ),
+ "Response $i, session state" );
+ foreach ( $providers as $p ) {
+ $this->assertSame( $response->status, $p->postCalled,
+ "Response $i, post-auth callback called" );
+ }
+ } else {
+ $this->assertNotNull(
+ $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' ),
+ "Response $i, session state"
+ );
+ foreach ( $ret->neededRequests as $neededReq ) {
+ $this->assertEquals( AuthManager::ACTION_LINK, $neededReq->action,
+ "Response $i, neededRequest action" );
+ }
+ $this->assertEquals(
+ $ret->neededRequests,
+ $this->manager->getAuthenticationRequests( AuthManager::ACTION_LINK_CONTINUE ),
+ "Response $i, continuation check"
+ );
+ foreach ( $providers as $p ) {
+ $this->assertFalse( $p->postCalled, "Response $i, post-auth callback not called" );
+ }
+ }
+
+ $first = false;
+ }
+
+ $this->assertSame( $expectLog, $this->logger->getBuffer() );
+ }
+
+ public function provideAccountLink() {
+ $req = $this->getMockForAbstractClass( AuthenticationRequest::class );
+ $good = StatusValue::newGood();
+
+ return [
+ 'Pre-link test fail in pre' => [
+ StatusValue::newFatal( 'fail-from-pre' ),
+ [],
+ [
+ AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ),
+ ]
+ ],
+ 'Failure in primary' => [
+ $good,
+ $tmp = [
+ AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ),
+ ],
+ $tmp
+ ],
+ 'All primary abstain' => [
+ $good,
+ [
+ AuthenticationResponse::newAbstain(),
+ ],
+ [
+ AuthenticationResponse::newFail( $this->message( 'authmanager-link-no-primary' ) )
+ ]
+ ],
+ 'Primary UI, then redirect, then fail' => [
+ $good,
+ $tmp = [
+ AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
+ AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ),
+ AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ),
+ ],
+ $tmp
+ ],
+ 'Primary redirect, then abstain' => [
+ $good,
+ [
+ $tmp = AuthenticationResponse::newRedirect(
+ [ $req ], '/foo.html', [ 'foo' => 'bar' ]
+ ),
+ AuthenticationResponse::newAbstain(),
+ ],
+ [
+ $tmp,
+ new \DomainException(
+ 'MockPrimaryAuthenticationProvider::continuePrimaryAccountLink() returned ABSTAIN'
+ )
+ ]
+ ],
+ 'Primary UI, then pass' => [
+ $good,
+ [
+ $tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
+ AuthenticationResponse::newPass(),
+ ],
+ [
+ $tmp1,
+ AuthenticationResponse::newPass( '' ),
+ ]
+ ],
+ 'Primary pass' => [
+ $good,
+ [
+ AuthenticationResponse::newPass( '' ),
+ ],
+ [
+ AuthenticationResponse::newPass( '' ),
+ ]
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php
new file mode 100644
index 00000000..57c3e7eb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/AuthPluginPrimaryAuthenticationProviderTest.php
@@ -0,0 +1,716 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AuthPluginPrimaryAuthenticationProvider
+ */
+class AuthPluginPrimaryAuthenticationProviderTest extends \MediaWikiTestCase {
+ public function testConstruction() {
+ $plugin = new AuthManagerAuthPlugin();
+ try {
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Trying to wrap AuthManagerAuthPlugin in AuthPluginPrimaryAuthenticationProvider ' .
+ 'makes no sense.',
+ $ex->getMessage()
+ );
+ }
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->assertEquals(
+ [ new PasswordAuthenticationRequest ],
+ $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] )
+ );
+
+ $req = $this->createMock( PasswordAuthenticationRequest::class );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin, get_class( $req ) );
+ $this->assertEquals(
+ [ $req ],
+ $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] )
+ );
+
+ $reqType = get_class( $this->createMock( AuthenticationRequest::class ) );
+ try {
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin, $reqType );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ "$reqType is not a MediaWiki\\Auth\\PasswordAuthenticationRequest",
+ $ex->getMessage()
+ );
+ }
+ }
+
+ public function testOnUserSaveSettings() {
+ $user = \User::newFromName( 'UTSysop' );
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->once() )->method( 'updateExternalDB' )
+ ->with( $this->identicalTo( $user ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+
+ \Hooks::run( 'UserSaveSettings', [ $user ] );
+ }
+
+ public function testOnUserGroupsChanged() {
+ $user = \User::newFromName( 'UTSysop' );
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->once() )->method( 'updateExternalDBGroups' )
+ ->with(
+ $this->identicalTo( $user ),
+ $this->identicalTo( [ 'added' ] ),
+ $this->identicalTo( [ 'removed' ] )
+ );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+
+ \Hooks::run( 'UserGroupsChanged', [ $user, [ 'added' ], [ 'removed' ], false, false, [], [] ] );
+ }
+
+ public function testOnUserLoggedIn() {
+ $user = \User::newFromName( 'UTSysop' );
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->exactly( 2 ) )->method( 'updateUser' )
+ ->with( $this->identicalTo( $user ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ \Hooks::run( 'UserLoggedIn', [ $user ] );
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->once() )->method( 'updateUser' )
+ ->will( $this->returnCallback( function ( &$user ) {
+ $user = \User::newFromName( 'UTSysop' );
+ } ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ try {
+ \Hooks::run( 'UserLoggedIn', [ $user ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ get_class( $plugin ) . '::updateUser() tried to replace $user!',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ public function testOnLocalUserCreated() {
+ $user = \User::newFromName( 'UTSysop' );
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->exactly( 2 ) )->method( 'initUser' )
+ ->with( $this->identicalTo( $user ), $this->identicalTo( false ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ \Hooks::run( 'LocalUserCreated', [ $user, false ] );
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->once() )->method( 'initUser' )
+ ->will( $this->returnCallback( function ( &$user ) {
+ $user = \User::newFromName( 'UTSysop' );
+ } ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ try {
+ \Hooks::run( 'LocalUserCreated', [ $user, false ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ get_class( $plugin ) . '::initUser() tried to replace $user!',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ public function testGetUniqueId() {
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->assertSame(
+ 'MediaWiki\\Auth\\AuthPluginPrimaryAuthenticationProvider:' . get_class( $plugin ),
+ $provider->getUniqueId()
+ );
+ }
+
+ /**
+ * @dataProvider provideGetAuthenticationRequests
+ * @param string $action
+ * @param array $response
+ * @param bool $allowPasswordChange
+ */
+ public function testGetAuthenticationRequests( $action, $response, $allowPasswordChange ) {
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->any() )->method( 'allowPasswordChange' )
+ ->will( $this->returnValue( $allowPasswordChange ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) );
+ }
+
+ public static function provideGetAuthenticationRequests() {
+ $arr = [ new PasswordAuthenticationRequest() ];
+ return [
+ [ AuthManager::ACTION_LOGIN, $arr, true ],
+ [ AuthManager::ACTION_LOGIN, $arr, false ],
+ [ AuthManager::ACTION_CREATE, $arr, true ],
+ [ AuthManager::ACTION_CREATE, $arr, false ],
+ [ AuthManager::ACTION_LINK, [], true ],
+ [ AuthManager::ACTION_LINK, [], false ],
+ [ AuthManager::ACTION_CHANGE, $arr, true ],
+ [ AuthManager::ACTION_CHANGE, [], false ],
+ [ AuthManager::ACTION_REMOVE, $arr, true ],
+ [ AuthManager::ACTION_REMOVE, [], false ],
+ ];
+ }
+
+ public function testAuthentication() {
+ $req = new PasswordAuthenticationRequest();
+ $req->action = AuthManager::ACTION_LOGIN;
+ $reqs = [ PasswordAuthenticationRequest::class => $req ];
+
+ $plugin = $this->getMockBuilder( \AuthPlugin::class )
+ ->setMethods( [ 'authenticate' ] )
+ ->getMock();
+ $plugin->expects( $this->never() )->method( 'authenticate' );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( [] )
+ );
+
+ $req->username = 'foo';
+ $req->password = null;
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $req->username = null;
+ $req->password = 'bar';
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $req->username = 'foo';
+ $req->password = 'bar';
+
+ $plugin = $this->getMockBuilder( \AuthPlugin::class )
+ ->setMethods( [ 'userExists', 'authenticate' ] )
+ ->getMock();
+ $plugin->expects( $this->once() )->method( 'userExists' )
+ ->will( $this->returnValue( true ) );
+ $plugin->expects( $this->once() )->method( 'authenticate' )
+ ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) )
+ ->will( $this->returnValue( true ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->assertEquals(
+ AuthenticationResponse::newPass( 'Foo', $req ),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $plugin = $this->getMockBuilder( \AuthPlugin::class )
+ ->setMethods( [ 'userExists', 'authenticate' ] )
+ ->getMock();
+ $plugin->expects( $this->once() )->method( 'userExists' )
+ ->will( $this->returnValue( false ) );
+ $plugin->expects( $this->never() )->method( 'authenticate' );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $pluginUser = $this->getMockBuilder( \AuthPluginUser::class )
+ ->setMethods( [ 'isLocked' ] )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $pluginUser->expects( $this->once() )->method( 'isLocked' )
+ ->will( $this->returnValue( true ) );
+ $plugin = $this->getMockBuilder( \AuthPlugin::class )
+ ->setMethods( [ 'userExists', 'getUserInstance', 'authenticate' ] )
+ ->getMock();
+ $plugin->expects( $this->once() )->method( 'userExists' )
+ ->will( $this->returnValue( true ) );
+ $plugin->expects( $this->once() )->method( 'getUserInstance' )
+ ->will( $this->returnValue( $pluginUser ) );
+ $plugin->expects( $this->never() )->method( 'authenticate' );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $plugin = $this->getMockBuilder( \AuthPlugin::class )
+ ->setMethods( [ 'userExists', 'authenticate' ] )
+ ->getMock();
+ $plugin->expects( $this->once() )->method( 'userExists' )
+ ->will( $this->returnValue( true ) );
+ $plugin->expects( $this->once() )->method( 'authenticate' )
+ ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) )
+ ->will( $this->returnValue( false ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $plugin = $this->getMockBuilder( \AuthPlugin::class )
+ ->setMethods( [ 'userExists', 'authenticate', 'strict' ] )
+ ->getMock();
+ $plugin->expects( $this->once() )->method( 'userExists' )
+ ->will( $this->returnValue( true ) );
+ $plugin->expects( $this->once() )->method( 'authenticate' )
+ ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) )
+ ->will( $this->returnValue( false ) );
+ $plugin->expects( $this->any() )->method( 'strict' )->will( $this->returnValue( true ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'wrongpassword', $ret->message->getKey() );
+
+ $plugin = $this->getMockBuilder( \AuthPlugin::class )
+ ->setMethods( [ 'userExists', 'authenticate', 'strictUserAuth' ] )
+ ->getMock();
+ $plugin->expects( $this->once() )->method( 'userExists' )
+ ->will( $this->returnValue( true ) );
+ $plugin->expects( $this->once() )->method( 'authenticate' )
+ ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) )
+ ->will( $this->returnValue( false ) );
+ $plugin->expects( $this->any() )->method( 'strictUserAuth' )
+ ->with( $this->equalTo( 'Foo' ) )
+ ->will( $this->returnValue( true ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'wrongpassword', $ret->message->getKey() );
+
+ $plugin = $this->getMockBuilder( \AuthPlugin::class )
+ ->setMethods( [ 'domainList', 'validDomain', 'setDomain', 'userExists', 'authenticate' ] )
+ ->getMock();
+ $plugin->expects( $this->any() )->method( 'domainList' )
+ ->will( $this->returnValue( [ 'Domain1', 'Domain2' ] ) );
+ $plugin->expects( $this->any() )->method( 'validDomain' )
+ ->will( $this->returnCallback( function ( $domain ) {
+ return in_array( $domain, [ 'Domain1', 'Domain2' ] );
+ } ) );
+ $plugin->expects( $this->once() )->method( 'setDomain' )
+ ->with( $this->equalTo( 'Domain2' ) );
+ $plugin->expects( $this->once() )->method( 'userExists' )
+ ->will( $this->returnValue( true ) );
+ $plugin->expects( $this->once() )->method( 'authenticate' )
+ ->with( $this->equalTo( 'Foo' ), $this->equalTo( 'bar' ) )
+ ->will( $this->returnValue( true ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ list( $req ) = $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] );
+ $req->username = 'foo';
+ $req->password = 'bar';
+ $req->domain = 'Domain2';
+ $provider->beginPrimaryAuthentication( [ $req ] );
+ }
+
+ public function testTestUserExists() {
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->once() )->method( 'userExists' )
+ ->with( $this->equalTo( 'Foo' ) )
+ ->will( $this->returnValue( true ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+
+ $this->assertTrue( $provider->testUserExists( 'foo' ) );
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->once() )->method( 'userExists' )
+ ->with( $this->equalTo( 'Foo' ) )
+ ->will( $this->returnValue( false ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+
+ $this->assertFalse( $provider->testUserExists( 'foo' ) );
+ }
+
+ public function testTestUserCanAuthenticate() {
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->once() )->method( 'userExists' )
+ ->with( $this->equalTo( 'Foo' ) )
+ ->will( $this->returnValue( false ) );
+ $plugin->expects( $this->never() )->method( 'getUserInstance' );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->assertFalse( $provider->testUserCanAuthenticate( 'foo' ) );
+
+ $pluginUser = $this->getMockBuilder( \AuthPluginUser::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $pluginUser->expects( $this->once() )->method( 'isLocked' )
+ ->will( $this->returnValue( true ) );
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->once() )->method( 'userExists' )
+ ->with( $this->equalTo( 'Foo' ) )
+ ->will( $this->returnValue( true ) );
+ $plugin->expects( $this->once() )->method( 'getUserInstance' )
+ ->with( $this->callback( function ( $user ) {
+ $this->assertInstanceOf( \User::class, $user );
+ $this->assertEquals( 'Foo', $user->getName() );
+ return true;
+ } ) )
+ ->will( $this->returnValue( $pluginUser ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->assertFalse( $provider->testUserCanAuthenticate( 'foo' ) );
+
+ $pluginUser = $this->getMockBuilder( \AuthPluginUser::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $pluginUser->expects( $this->once() )->method( 'isLocked' )
+ ->will( $this->returnValue( false ) );
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->once() )->method( 'userExists' )
+ ->with( $this->equalTo( 'Foo' ) )
+ ->will( $this->returnValue( true ) );
+ $plugin->expects( $this->once() )->method( 'getUserInstance' )
+ ->with( $this->callback( function ( $user ) {
+ $this->assertInstanceOf( \User::class, $user );
+ $this->assertEquals( 'Foo', $user->getName() );
+ return true;
+ } ) )
+ ->will( $this->returnValue( $pluginUser ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->assertTrue( $provider->testUserCanAuthenticate( 'foo' ) );
+ }
+
+ public function testProviderRevokeAccessForUser() {
+ $plugin = $this->getMockBuilder( \AuthPlugin::class )
+ ->setMethods( [ 'userExists', 'setPassword' ] )
+ ->getMock();
+ $plugin->expects( $this->once() )->method( 'userExists' )->willReturn( true );
+ $plugin->expects( $this->once() )->method( 'setPassword' )
+ ->with( $this->callback( function ( $u ) {
+ return $u instanceof \User && $u->getName() === 'Foo';
+ } ), $this->identicalTo( null ) )
+ ->willReturn( true );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $provider->providerRevokeAccessForUser( 'foo' );
+
+ $plugin = $this->getMockBuilder( \AuthPlugin::class )
+ ->setMethods( [ 'domainList', 'userExists', 'setPassword' ] )
+ ->getMock();
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [ 'D1', 'D2', 'D3' ] );
+ $plugin->expects( $this->exactly( 3 ) )->method( 'userExists' )
+ ->willReturnCallback( function () use ( $plugin ) {
+ return $plugin->getDomain() !== 'D2';
+ } );
+ $plugin->expects( $this->exactly( 2 ) )->method( 'setPassword' )
+ ->with( $this->callback( function ( $u ) {
+ return $u instanceof \User && $u->getName() === 'Foo';
+ } ), $this->identicalTo( null ) )
+ ->willReturnCallback( function () use ( $plugin ) {
+ $this->assertNotEquals( 'D2', $plugin->getDomain() );
+ return $plugin->getDomain() !== 'D1';
+ } );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ try {
+ $provider->providerRevokeAccessForUser( 'foo' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'AuthPlugin failed to reset password for Foo in the following domains: D1',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ public function testProviderAllowsPropertyChange() {
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->any() )->method( 'allowPropChange' )
+ ->will( $this->returnCallback( function ( $prop ) {
+ return $prop === 'allow';
+ } ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+
+ $this->assertTrue( $provider->providerAllowsPropertyChange( 'allow' ) );
+ $this->assertFalse( $provider->providerAllowsPropertyChange( 'deny' ) );
+ }
+
+ /**
+ * @dataProvider provideProviderAllowsAuthenticationDataChange
+ * @param string $type
+ * @param bool|null $allow
+ * @param StatusValue $expect
+ */
+ public function testProviderAllowsAuthenticationDataChange( $type, $allow, $expect ) {
+ $domains = $type instanceof PasswordDomainAuthenticationRequest ? [ 'foo', 'bar' ] : [];
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( $domains );
+ $plugin->expects( $allow === null ? $this->never() : $this->once() )
+ ->method( 'allowPasswordChange' )->will( $this->returnValue( $allow ) );
+ $plugin->expects( $this->any() )->method( 'validDomain' )
+ ->willReturnCallback( function ( $d ) use ( $domains ) {
+ return in_array( $d, $domains, true );
+ } );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+
+ if ( is_object( $type ) ) {
+ $req = $type;
+ } else {
+ $req = $this->createMock( $type );
+ }
+ $req->action = AuthManager::ACTION_CHANGE;
+ $req->username = 'UTSysop';
+ $req->password = 'Pa$$w0Rd!!!';
+ $req->retype = 'Pa$$w0Rd!!!';
+ $this->assertEquals( $expect, $provider->providerAllowsAuthenticationDataChange( $req ) );
+ }
+
+ public static function provideProviderAllowsAuthenticationDataChange() {
+ $domains = [ 'foo', 'bar' ];
+ $reqNoDomain = new PasswordDomainAuthenticationRequest( $domains );
+ $reqValidDomain = new PasswordDomainAuthenticationRequest( $domains );
+ $reqValidDomain->domain = 'foo';
+ $reqInvalidDomain = new PasswordDomainAuthenticationRequest( $domains );
+ $reqInvalidDomain->domain = 'invalid';
+
+ return [
+ [ AuthenticationRequest::class, null, \StatusValue::newGood( 'ignored' ) ],
+ [ new PasswordAuthenticationRequest, true, \StatusValue::newGood() ],
+ [
+ new PasswordAuthenticationRequest,
+ false,
+ \StatusValue::newFatal( 'authmanager-authplugin-setpass-denied' )
+ ],
+ [ $reqNoDomain, true, \StatusValue::newGood( 'ignored' ) ],
+ [ $reqValidDomain, true, \StatusValue::newGood() ],
+ [
+ $reqInvalidDomain,
+ true,
+ \StatusValue::newFatal( 'authmanager-authplugin-setpass-bad-domain' )
+ ],
+ ];
+ }
+
+ public function testProviderChangeAuthenticationData() {
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->never() )->method( 'setPassword' );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $provider->providerChangeAuthenticationData(
+ $this->createMock( AuthenticationRequest::class )
+ );
+
+ $req = new PasswordAuthenticationRequest();
+ $req->action = AuthManager::ACTION_CHANGE;
+ $req->username = 'foo';
+ $req->password = 'bar';
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->once() )->method( 'setPassword' )
+ ->with( $this->callback( function ( $u ) {
+ return $u instanceof \User && $u->getName() === 'Foo';
+ } ), $this->equalTo( 'bar' ) )
+ ->will( $this->returnValue( true ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $provider->providerChangeAuthenticationData( $req );
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->once() )->method( 'setPassword' )
+ ->with( $this->callback( function ( $u ) {
+ return $u instanceof \User && $u->getName() === 'Foo';
+ } ), $this->equalTo( 'bar' ) )
+ ->will( $this->returnValue( false ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ try {
+ $provider->providerChangeAuthenticationData( $req );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \ErrorPageError $e ) {
+ $this->assertSame( 'authmanager-authplugin-setpass-failed-title', $e->title );
+ $this->assertSame( 'authmanager-authplugin-setpass-failed-message', $e->msg );
+ }
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )
+ ->will( $this->returnValue( [ 'Domain1', 'Domain2' ] ) );
+ $plugin->expects( $this->any() )->method( 'validDomain' )
+ ->will( $this->returnCallback( function ( $domain ) {
+ return in_array( $domain, [ 'Domain1', 'Domain2' ] );
+ } ) );
+ $plugin->expects( $this->once() )->method( 'setDomain' )
+ ->with( $this->equalTo( 'Domain2' ) );
+ $plugin->expects( $this->once() )->method( 'setPassword' )
+ ->with( $this->callback( function ( $u ) {
+ return $u instanceof \User && $u->getName() === 'Foo';
+ } ), $this->equalTo( 'bar' ) )
+ ->will( $this->returnValue( true ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ list( $req ) = $provider->getAuthenticationRequests( AuthManager::ACTION_CREATE, [] );
+ $req->username = 'foo';
+ $req->password = 'bar';
+ $req->domain = 'Domain2';
+ $provider->providerChangeAuthenticationData( $req );
+ }
+
+ /**
+ * @dataProvider provideAccountCreationType
+ * @param bool $can
+ * @param string $expect
+ */
+ public function testAccountCreationType( $can, $expect ) {
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->once() )
+ ->method( 'canCreateAccounts' )->will( $this->returnValue( $can ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+
+ $this->assertSame( $expect, $provider->accountCreationType() );
+ }
+
+ public static function provideAccountCreationType() {
+ return [
+ [ true, PrimaryAuthenticationProvider::TYPE_CREATE ],
+ [ false, PrimaryAuthenticationProvider::TYPE_NONE ],
+ ];
+ }
+
+ public function testTestForAccountCreation() {
+ $user = \User::newFromName( 'foo' );
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAccountCreation( $user, $user, [] )
+ );
+ }
+
+ public function testAccountCreation() {
+ $user = \User::newFromName( 'foo' );
+ $user->setEmail( 'email' );
+ $user->setRealName( 'realname' );
+
+ $req = new PasswordAuthenticationRequest();
+ $req->action = AuthManager::ACTION_CREATE;
+ $reqs = [ PasswordAuthenticationRequest::class => $req ];
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->any() )->method( 'canCreateAccounts' )
+ ->will( $this->returnValue( false ) );
+ $plugin->expects( $this->never() )->method( 'addUser' );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ try {
+ $provider->beginPrimaryAccountCreation( $user, $user, [] );
+ $this->fail( 'Expected exception was not thrown' );
+ } catch ( \BadMethodCallException $ex ) {
+ $this->assertSame(
+ 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage()
+ );
+ }
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->any() )->method( 'canCreateAccounts' )
+ ->will( $this->returnValue( true ) );
+ $plugin->expects( $this->never() )->method( 'addUser' );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAccountCreation( $user, $user, [] )
+ );
+
+ $req->username = 'foo';
+ $req->password = null;
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAccountCreation( $user, $user, $reqs )
+ );
+
+ $req->username = null;
+ $req->password = 'bar';
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAccountCreation( $user, $user, $reqs )
+ );
+
+ $req->username = 'foo';
+ $req->password = 'bar';
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->any() )->method( 'canCreateAccounts' )
+ ->will( $this->returnValue( true ) );
+ $plugin->expects( $this->once() )->method( 'addUser' )
+ ->with(
+ $this->callback( function ( $u ) {
+ return $u instanceof \User && $u->getName() === 'Foo';
+ } ),
+ $this->equalTo( 'bar' ),
+ $this->equalTo( 'email' ),
+ $this->equalTo( 'realname' )
+ )
+ ->will( $this->returnValue( true ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $this->assertEquals(
+ AuthenticationResponse::newPass(),
+ $provider->beginPrimaryAccountCreation( $user, $user, $reqs )
+ );
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'domainList' )->willReturn( [] );
+ $plugin->expects( $this->any() )->method( 'canCreateAccounts' )
+ ->will( $this->returnValue( true ) );
+ $plugin->expects( $this->once() )->method( 'addUser' )
+ ->with(
+ $this->callback( function ( $u ) {
+ return $u instanceof \User && $u->getName() === 'Foo';
+ } ),
+ $this->equalTo( 'bar' ),
+ $this->equalTo( 'email' ),
+ $this->equalTo( 'realname' )
+ )
+ ->will( $this->returnValue( false ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ $ret = $provider->beginPrimaryAccountCreation( $user, $user, $reqs );
+ $this->assertSame( AuthenticationResponse::FAIL, $ret->status );
+ $this->assertSame( 'authmanager-authplugin-create-fail', $ret->message->getKey() );
+
+ $plugin = $this->createMock( \AuthPlugin::class );
+ $plugin->expects( $this->any() )->method( 'canCreateAccounts' )
+ ->will( $this->returnValue( true ) );
+ $plugin->expects( $this->any() )->method( 'domainList' )
+ ->will( $this->returnValue( [ 'Domain1', 'Domain2' ] ) );
+ $plugin->expects( $this->any() )->method( 'validDomain' )
+ ->will( $this->returnCallback( function ( $domain ) {
+ return in_array( $domain, [ 'Domain1', 'Domain2' ] );
+ } ) );
+ $plugin->expects( $this->once() )->method( 'setDomain' )
+ ->with( $this->equalTo( 'Domain2' ) );
+ $plugin->expects( $this->once() )->method( 'addUser' )
+ ->with( $this->callback( function ( $u ) {
+ return $u instanceof \User && $u->getName() === 'Foo';
+ } ), $this->equalTo( 'bar' ) )
+ ->will( $this->returnValue( true ) );
+ $provider = new AuthPluginPrimaryAuthenticationProvider( $plugin );
+ list( $req ) = $provider->getAuthenticationRequests( AuthManager::ACTION_CREATE, [] );
+ $req->username = 'foo';
+ $req->password = 'bar';
+ $req->domain = 'Domain2';
+ $provider->beginPrimaryAccountCreation( $user, $user, [ $req ] );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTest.php
new file mode 100644
index 00000000..1bc0f31f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTest.php
@@ -0,0 +1,517 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AuthenticationRequest
+ */
+class AuthenticationRequestTest extends \MediaWikiTestCase {
+ public function testBasics() {
+ $mock = $this->getMockForAbstractClass( AuthenticationRequest::class );
+
+ $this->assertSame( get_class( $mock ), $mock->getUniqueId() );
+
+ $this->assertType( 'array', $mock->getMetadata() );
+
+ $ret = $mock->describeCredentials();
+ $this->assertInternalType( 'array', $ret );
+ $this->assertArrayHasKey( 'provider', $ret );
+ $this->assertInstanceOf( \Message::class, $ret['provider'] );
+ $this->assertArrayHasKey( 'account', $ret );
+ $this->assertInstanceOf( \Message::class, $ret['account'] );
+ }
+
+ public function testLoadRequestsFromSubmission() {
+ $mb = $this->getMockBuilder( AuthenticationRequest::class )
+ ->setMethods( [ 'loadFromSubmission' ] );
+
+ $data = [ 'foo', 'bar' ];
+
+ $req1 = $mb->getMockForAbstractClass();
+ $req1->expects( $this->once() )->method( 'loadFromSubmission' )
+ ->with( $this->identicalTo( $data ) )
+ ->will( $this->returnValue( false ) );
+
+ $req2 = $mb->getMockForAbstractClass();
+ $req2->expects( $this->once() )->method( 'loadFromSubmission' )
+ ->with( $this->identicalTo( $data ) )
+ ->will( $this->returnValue( true ) );
+
+ $this->assertSame(
+ [ $req2 ],
+ AuthenticationRequest::loadRequestsFromSubmission( [ $req1, $req2 ], $data )
+ );
+ }
+
+ public function testGetRequestByClass() {
+ $mb = $this->getMockBuilder(
+ AuthenticationRequest::class, 'AuthenticationRequestTest_AuthenticationRequest2'
+ );
+
+ $reqs = [
+ $this->getMockForAbstractClass(
+ AuthenticationRequest::class, [], 'AuthenticationRequestTest_AuthenticationRequest1'
+ ),
+ $mb->getMockForAbstractClass(),
+ $mb->getMockForAbstractClass(),
+ $this->getMockForAbstractClass(
+ PasswordAuthenticationRequest::class, [],
+ 'AuthenticationRequestTest_PasswordAuthenticationRequest'
+ ),
+ ];
+
+ $this->assertNull( AuthenticationRequest::getRequestByClass(
+ $reqs, 'AuthenticationRequestTest_AuthenticationRequest0'
+ ) );
+ $this->assertSame( $reqs[0], AuthenticationRequest::getRequestByClass(
+ $reqs, 'AuthenticationRequestTest_AuthenticationRequest1'
+ ) );
+ $this->assertNull( AuthenticationRequest::getRequestByClass(
+ $reqs, 'AuthenticationRequestTest_AuthenticationRequest2'
+ ) );
+ $this->assertNull( AuthenticationRequest::getRequestByClass(
+ $reqs, PasswordAuthenticationRequest::class
+ ) );
+ $this->assertNull( AuthenticationRequest::getRequestByClass(
+ $reqs, 'ClassThatDoesNotExist'
+ ) );
+
+ $this->assertNull( AuthenticationRequest::getRequestByClass(
+ $reqs, 'AuthenticationRequestTest_AuthenticationRequest0', true
+ ) );
+ $this->assertSame( $reqs[0], AuthenticationRequest::getRequestByClass(
+ $reqs, 'AuthenticationRequestTest_AuthenticationRequest1', true
+ ) );
+ $this->assertNull( AuthenticationRequest::getRequestByClass(
+ $reqs, 'AuthenticationRequestTest_AuthenticationRequest2', true
+ ) );
+ $this->assertSame( $reqs[3], AuthenticationRequest::getRequestByClass(
+ $reqs, PasswordAuthenticationRequest::class, true
+ ) );
+ $this->assertNull( AuthenticationRequest::getRequestByClass(
+ $reqs, 'ClassThatDoesNotExist', true
+ ) );
+ }
+
+ public function testGetUsernameFromRequests() {
+ $mb = $this->getMockBuilder( AuthenticationRequest::class );
+
+ for ( $i = 0; $i < 3; $i++ ) {
+ $req = $mb->getMockForAbstractClass();
+ $req->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [
+ 'username' => [
+ 'type' => 'string',
+ ],
+ ] ) );
+ $reqs[] = $req;
+ }
+
+ $req = $mb->getMockForAbstractClass();
+ $req->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [] ) );
+ $req->username = 'baz';
+ $reqs[] = $req;
+
+ $this->assertNull( AuthenticationRequest::getUsernameFromRequests( $reqs ) );
+
+ $reqs[1]->username = 'foo';
+ $this->assertSame( 'foo', AuthenticationRequest::getUsernameFromRequests( $reqs ) );
+
+ $reqs[0]->username = 'foo';
+ $reqs[2]->username = 'foo';
+ $this->assertSame( 'foo', AuthenticationRequest::getUsernameFromRequests( $reqs ) );
+
+ $reqs[1]->username = 'bar';
+ try {
+ AuthenticationRequest::getUsernameFromRequests( $reqs );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'Conflicting username fields: "bar" from ' .
+ get_class( $reqs[1] ) . '::$username vs. "foo" from ' .
+ get_class( $reqs[0] ) . '::$username',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ public function testMergeFieldInfo() {
+ $msg = wfMessage( 'foo' );
+
+ $req1 = $this->createMock( AuthenticationRequest::class );
+ $req1->required = AuthenticationRequest::REQUIRED;
+ $req1->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [
+ 'string1' => [
+ 'type' => 'string',
+ 'label' => $msg,
+ 'help' => $msg,
+ ],
+ 'string2' => [
+ 'type' => 'string',
+ 'label' => $msg,
+ 'help' => $msg,
+ ],
+ 'optional' => [
+ 'type' => 'string',
+ 'label' => $msg,
+ 'help' => $msg,
+ 'optional' => true,
+ ],
+ 'select' => [
+ 'type' => 'select',
+ 'options' => [ 'foo' => $msg, 'baz' => $msg ],
+ 'label' => $msg,
+ 'help' => $msg,
+ ],
+ ] ) );
+
+ $req2 = $this->createMock( AuthenticationRequest::class );
+ $req2->required = AuthenticationRequest::REQUIRED;
+ $req2->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [
+ 'string1' => [
+ 'type' => 'string',
+ 'label' => $msg,
+ 'help' => $msg,
+ 'sensitive' => true,
+ ],
+ 'string3' => [
+ 'type' => 'string',
+ 'label' => $msg,
+ 'help' => $msg,
+ ],
+ 'select' => [
+ 'type' => 'select',
+ 'options' => [ 'bar' => $msg, 'baz' => $msg ],
+ 'label' => $msg,
+ 'help' => $msg,
+ ],
+ ] ) );
+
+ $req3 = $this->createMock( AuthenticationRequest::class );
+ $req3->required = AuthenticationRequest::REQUIRED;
+ $req3->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [
+ 'string1' => [
+ 'type' => 'checkbox',
+ 'label' => $msg,
+ 'help' => $msg,
+ ],
+ ] ) );
+
+ $req4 = $this->createMock( AuthenticationRequest::class );
+ $req4->required = AuthenticationRequest::REQUIRED;
+ $req4->expects( $this->any() )->method( 'getFieldInfo' )->will( $this->returnValue( [] ) );
+
+ // Basic combining
+
+ $fields = AuthenticationRequest::mergeFieldInfo( [ $req1 ] );
+ $expect = $req1->getFieldInfo();
+ foreach ( $expect as $name => &$options ) {
+ $options['optional'] = !empty( $options['optional'] );
+ $options['sensitive'] = !empty( $options['sensitive'] );
+ }
+ unset( $options );
+ $this->assertEquals( $expect, $fields );
+
+ $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req4 ] );
+ $this->assertEquals( $expect, $fields );
+
+ try {
+ AuthenticationRequest::mergeFieldInfo( [ $req1, $req3 ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'Field type conflict for "string1", "string" vs "checkbox"',
+ $ex->getMessage()
+ );
+ }
+
+ $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] );
+ $expect += $req2->getFieldInfo();
+ $expect['string1']['sensitive'] = true;
+ $expect['string2']['optional'] = false;
+ $expect['string3']['optional'] = false;
+ $expect['string3']['sensitive'] = false;
+ $expect['select']['options']['bar'] = $msg;
+ $this->assertEquals( $expect, $fields );
+
+ // Combining with something not required
+
+ $req1->required = AuthenticationRequest::PRIMARY_REQUIRED;
+
+ $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] );
+ $expect += $req2->getFieldInfo();
+ $expect['string1']['optional'] = false;
+ $expect['string1']['sensitive'] = true;
+ $expect['string3']['optional'] = false;
+ $expect['select']['optional'] = false;
+ $expect['select']['options']['bar'] = $msg;
+ $this->assertEquals( $expect, $fields );
+
+ $req2->required = AuthenticationRequest::PRIMARY_REQUIRED;
+
+ $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] );
+ $expect = $req1->getFieldInfo() + $req2->getFieldInfo();
+ foreach ( $expect as $name => &$options ) {
+ $options['sensitive'] = !empty( $options['sensitive'] );
+ }
+ $expect['string1']['optional'] = false;
+ $expect['string1']['sensitive'] = true;
+ $expect['string2']['optional'] = true;
+ $expect['string3']['optional'] = true;
+ $expect['select']['optional'] = false;
+ $expect['select']['options']['bar'] = $msg;
+ $this->assertEquals( $expect, $fields );
+ }
+
+ /**
+ * @dataProvider provideLoadFromSubmission
+ * @param array $fieldInfo
+ * @param array $data
+ * @param array|bool $expectState
+ */
+ public function testLoadFromSubmission( $fieldInfo, $data, $expectState ) {
+ $mock = $this->getMockForAbstractClass( AuthenticationRequest::class );
+ $mock->expects( $this->any() )->method( 'getFieldInfo' )
+ ->will( $this->returnValue( $fieldInfo ) );
+
+ $ret = $mock->loadFromSubmission( $data );
+ if ( is_array( $expectState ) ) {
+ $this->assertTrue( $ret );
+ $expect = call_user_func( [ get_class( $mock ), '__set_state' ], $expectState );
+ $this->assertEquals( $expect, $mock );
+ } else {
+ $this->assertFalse( $ret );
+ }
+ }
+
+ public static function provideLoadFromSubmission() {
+ return [
+ 'No fields' => [
+ [],
+ $data = [ 'foo' => 'bar' ],
+ false
+ ],
+
+ 'Simple field' => [
+ [
+ 'field' => [
+ 'type' => 'string',
+ ],
+ ],
+ $data = [ 'field' => 'string!' ],
+ $data
+ ],
+ 'Simple field, not supplied' => [
+ [
+ 'field' => [
+ 'type' => 'string',
+ ],
+ ],
+ [],
+ false
+ ],
+ 'Simple field, empty' => [
+ [
+ 'field' => [
+ 'type' => 'string',
+ ],
+ ],
+ [ 'field' => '' ],
+ false
+ ],
+ 'Simple field, optional, not supplied' => [
+ [
+ 'field' => [
+ 'type' => 'string',
+ 'optional' => true,
+ ],
+ ],
+ [],
+ false
+ ],
+ 'Simple field, optional, empty' => [
+ [
+ 'field' => [
+ 'type' => 'string',
+ 'optional' => true,
+ ],
+ ],
+ $data = [ 'field' => '' ],
+ $data
+ ],
+
+ 'Checkbox, checked' => [
+ [
+ 'check' => [
+ 'type' => 'checkbox',
+ ],
+ ],
+ [ 'check' => '' ],
+ [ 'check' => true ]
+ ],
+ 'Checkbox, unchecked' => [
+ [
+ 'check' => [
+ 'type' => 'checkbox',
+ ],
+ ],
+ [],
+ false
+ ],
+ 'Checkbox, optional, unchecked' => [
+ [
+ 'check' => [
+ 'type' => 'checkbox',
+ 'optional' => true,
+ ],
+ ],
+ [],
+ [ 'check' => false ]
+ ],
+
+ 'Button, used' => [
+ [
+ 'push' => [
+ 'type' => 'button',
+ ],
+ ],
+ [ 'push' => '' ],
+ [ 'push' => true ]
+ ],
+ 'Button, unused' => [
+ [
+ 'push' => [
+ 'type' => 'button',
+ ],
+ ],
+ [],
+ false
+ ],
+ 'Button, optional, unused' => [
+ [
+ 'push' => [
+ 'type' => 'button',
+ 'optional' => true,
+ ],
+ ],
+ [],
+ [ 'push' => false ]
+ ],
+ 'Button, image-style' => [
+ [
+ 'push' => [
+ 'type' => 'button',
+ ],
+ ],
+ [ 'push_x' => 0, 'push_y' => 0 ],
+ [ 'push' => true ]
+ ],
+
+ 'Select' => [
+ [
+ 'choose' => [
+ 'type' => 'select',
+ 'options' => [
+ 'foo' => wfMessage( 'mainpage' ),
+ 'bar' => wfMessage( 'mainpage' ),
+ ],
+ ],
+ ],
+ $data = [ 'choose' => 'foo' ],
+ $data
+ ],
+ 'Select, invalid choice' => [
+ [
+ 'choose' => [
+ 'type' => 'select',
+ 'options' => [
+ 'foo' => wfMessage( 'mainpage' ),
+ 'bar' => wfMessage( 'mainpage' ),
+ ],
+ ],
+ ],
+ $data = [ 'choose' => 'baz' ],
+ false
+ ],
+ 'Multiselect (2)' => [
+ [
+ 'choose' => [
+ 'type' => 'multiselect',
+ 'options' => [
+ 'foo' => wfMessage( 'mainpage' ),
+ 'bar' => wfMessage( 'mainpage' ),
+ ],
+ ],
+ ],
+ $data = [ 'choose' => [ 'foo', 'bar' ] ],
+ $data
+ ],
+ 'Multiselect (1)' => [
+ [
+ 'choose' => [
+ 'type' => 'multiselect',
+ 'options' => [
+ 'foo' => wfMessage( 'mainpage' ),
+ 'bar' => wfMessage( 'mainpage' ),
+ ],
+ ],
+ ],
+ $data = [ 'choose' => [ 'bar' ] ],
+ $data
+ ],
+ 'Multiselect, string for some reason' => [
+ [
+ 'choose' => [
+ 'type' => 'multiselect',
+ 'options' => [
+ 'foo' => wfMessage( 'mainpage' ),
+ 'bar' => wfMessage( 'mainpage' ),
+ ],
+ ],
+ ],
+ [ 'choose' => 'foo' ],
+ [ 'choose' => [ 'foo' ] ]
+ ],
+ 'Multiselect, invalid choice' => [
+ [
+ 'choose' => [
+ 'type' => 'multiselect',
+ 'options' => [
+ 'foo' => wfMessage( 'mainpage' ),
+ 'bar' => wfMessage( 'mainpage' ),
+ ],
+ ],
+ ],
+ [ 'choose' => [ 'foo', 'baz' ] ],
+ false
+ ],
+ 'Multiselect, empty' => [
+ [
+ 'choose' => [
+ 'type' => 'multiselect',
+ 'options' => [
+ 'foo' => wfMessage( 'mainpage' ),
+ 'bar' => wfMessage( 'mainpage' ),
+ ],
+ ],
+ ],
+ [ 'choose' => [] ],
+ false
+ ],
+ 'Multiselect, optional, nothing submitted' => [
+ [
+ 'choose' => [
+ 'type' => 'multiselect',
+ 'options' => [
+ 'foo' => wfMessage( 'mainpage' ),
+ 'bar' => wfMessage( 'mainpage' ),
+ ],
+ 'optional' => true,
+ ],
+ ],
+ [],
+ [ 'choose' => [] ]
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php b/www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php
new file mode 100644
index 00000000..f483b9b6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ */
+abstract class AuthenticationRequestTestCase extends \MediaWikiTestCase {
+ abstract protected function getInstance( array $args = [] );
+
+ /**
+ * @dataProvider provideGetFieldInfo
+ */
+ public function testGetFieldInfo( array $args ) {
+ $info = $this->getInstance( $args )->getFieldInfo();
+ $this->assertType( 'array', $info );
+
+ foreach ( $info as $field => $data ) {
+ $this->assertType( 'array', $data, "Field $field" );
+ $this->assertArrayHasKey( 'type', $data, "Field $field" );
+ $this->assertArrayHasKey( 'label', $data, "Field $field" );
+ $this->assertInstanceOf( \Message::class, $data['label'], "Field $field, label" );
+
+ if ( $data['type'] !== 'null' ) {
+ $this->assertArrayHasKey( 'help', $data, "Field $field" );
+ $this->assertInstanceOf( \Message::class, $data['help'], "Field $field, help" );
+ }
+
+ if ( isset( $data['optional'] ) ) {
+ $this->assertType( 'bool', $data['optional'], "Field $field, optional" );
+ }
+ if ( isset( $data['image'] ) ) {
+ $this->assertType( 'string', $data['image'], "Field $field, image" );
+ }
+ if ( isset( $data['sensitive'] ) ) {
+ $this->assertType( 'bool', $data['sensitive'], "Field $field, sensitive" );
+ }
+ if ( $data['type'] === 'password' ) {
+ $this->assertTrue( !empty( $data['sensitive'] ),
+ "Field $field, password field must be sensitive" );
+ }
+
+ switch ( $data['type'] ) {
+ case 'string':
+ case 'password':
+ case 'hidden':
+ break;
+ case 'select':
+ case 'multiselect':
+ $this->assertArrayHasKey( 'options', $data, "Field $field" );
+ $this->assertType( 'array', $data['options'], "Field $field, options" );
+ foreach ( $data['options'] as $val => $msg ) {
+ $this->assertInstanceOf( \Message::class, $msg, "Field $field, option $val" );
+ }
+ break;
+ case 'checkbox':
+ break;
+ case 'button':
+ break;
+ case 'null':
+ break;
+ default:
+ $this->fail( "Field $field, unknown type " . $data['type'] );
+ break;
+ }
+ }
+ }
+
+ public static function provideGetFieldInfo() {
+ return [
+ [ [] ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideLoadFromSubmission
+ * @param array $args
+ * @param array $data
+ * @param array|bool $expectState
+ */
+ public function testLoadFromSubmission( array $args, array $data, $expectState ) {
+ $instance = $this->getInstance( $args );
+ $ret = $instance->loadFromSubmission( $data );
+ if ( is_array( $expectState ) ) {
+ $this->assertTrue( $ret );
+ $expect = call_user_func( [ get_class( $instance ), '__set_state' ], $expectState );
+ $this->assertEquals( $expect, $instance );
+ } else {
+ $this->assertFalse( $ret );
+ }
+ }
+
+ abstract public function provideLoadFromSubmission();
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/AuthenticationResponseTest.php b/www/wiki/tests/phpunit/includes/auth/AuthenticationResponseTest.php
new file mode 100644
index 00000000..194b49e0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/AuthenticationResponseTest.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\AuthenticationResponse
+ */
+class AuthenticationResponseTest extends \MediaWikiTestCase {
+ /**
+ * @dataProvider provideConstructors
+ * @param string $constructor
+ * @param array $args
+ * @param array|Exception $expect
+ */
+ public function testConstructors( $constructor, $args, $expect ) {
+ if ( is_array( $expect ) ) {
+ $res = new AuthenticationResponse();
+ $res->messageType = 'warning';
+ foreach ( $expect as $field => $value ) {
+ $res->$field = $value;
+ }
+ $ret = call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args );
+ $this->assertEquals( $res, $ret );
+ } else {
+ try {
+ call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \Exception $ex ) {
+ $this->assertEquals( $expect, $ex );
+ }
+ }
+ }
+
+ public function provideConstructors() {
+ $req = $this->getMockForAbstractClass( AuthenticationRequest::class );
+ $msg = new \Message( 'mainpage' );
+
+ return [
+ [ 'newPass', [], [
+ 'status' => AuthenticationResponse::PASS,
+ ] ],
+ [ 'newPass', [ 'name' ], [
+ 'status' => AuthenticationResponse::PASS,
+ 'username' => 'name',
+ ] ],
+ [ 'newPass', [ 'name', null ], [
+ 'status' => AuthenticationResponse::PASS,
+ 'username' => 'name',
+ ] ],
+
+ [ 'newFail', [ $msg ], [
+ 'status' => AuthenticationResponse::FAIL,
+ 'message' => $msg,
+ 'messageType' => 'error',
+ ] ],
+
+ [ 'newRestart', [ $msg ], [
+ 'status' => AuthenticationResponse::RESTART,
+ 'message' => $msg,
+ ] ],
+
+ [ 'newAbstain', [], [
+ 'status' => AuthenticationResponse::ABSTAIN,
+ ] ],
+
+ [ 'newUI', [ [ $req ], $msg ], [
+ 'status' => AuthenticationResponse::UI,
+ 'neededRequests' => [ $req ],
+ 'message' => $msg,
+ 'messageType' => 'warning',
+ ] ],
+
+ [ 'newUI', [ [ $req ], $msg, 'warning' ], [
+ 'status' => AuthenticationResponse::UI,
+ 'neededRequests' => [ $req ],
+ 'message' => $msg,
+ 'messageType' => 'warning',
+ ] ],
+
+ [ 'newUI', [ [ $req ], $msg, 'error' ], [
+ 'status' => AuthenticationResponse::UI,
+ 'neededRequests' => [ $req ],
+ 'message' => $msg,
+ 'messageType' => 'error',
+ ] ],
+ [ 'newUI', [ [], $msg ],
+ new \InvalidArgumentException( '$reqs may not be empty' )
+ ],
+
+ [ 'newRedirect', [ [ $req ], 'http://example.org/redir' ], [
+ 'status' => AuthenticationResponse::REDIRECT,
+ 'neededRequests' => [ $req ],
+ 'redirectTarget' => 'http://example.org/redir',
+ ] ],
+ [
+ 'newRedirect',
+ [ [ $req ], 'http://example.org/redir', [ 'foo' => 'bar' ] ],
+ [
+ 'status' => AuthenticationResponse::REDIRECT,
+ 'neededRequests' => [ $req ],
+ 'redirectTarget' => 'http://example.org/redir',
+ 'redirectApiData' => [ 'foo' => 'bar' ],
+ ]
+ ],
+ [ 'newRedirect', [ [], 'http://example.org/redir' ],
+ new \InvalidArgumentException( '$reqs may not be empty' )
+ ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php
new file mode 100644
index 00000000..3bc077cb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/ButtonAuthenticationRequestTest.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\ButtonAuthenticationRequest
+ */
+class ButtonAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+ protected function getInstance( array $args = [] ) {
+ $data = array_intersect_key( $args, [ 'name' => 1, 'label' => 1, 'help' => 1 ] );
+ return ButtonAuthenticationRequest::__set_state( $data );
+ }
+
+ public static function provideGetFieldInfo() {
+ return [
+ [ [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ] ]
+ ];
+ }
+
+ public function provideLoadFromSubmission() {
+ return [
+ 'Empty request' => [
+ [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ],
+ [],
+ false
+ ],
+ 'Button present' => [
+ [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ],
+ [ 'foo' => 'Foobar' ],
+ [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz', 'foo' => true ]
+ ],
+ ];
+ }
+
+ public function testGetUniqueId() {
+ $req = new ButtonAuthenticationRequest( 'foo', wfMessage( 'bar' ), wfMessage( 'baz' ) );
+ $this->assertSame(
+ 'MediaWiki\\Auth\\ButtonAuthenticationRequest:foo', $req->getUniqueId()
+ );
+ }
+
+ public function testGetRequestByName() {
+ $reqs = [];
+ $reqs['testOne'] = new ButtonAuthenticationRequest(
+ 'foo', wfMessage( 'msg' ), wfMessage( 'help' )
+ );
+ $reqs[] = new ButtonAuthenticationRequest( 'bar', wfMessage( 'msg1' ), wfMessage( 'help1' ) );
+ $reqs[] = new ButtonAuthenticationRequest( 'bar', wfMessage( 'msg2' ), wfMessage( 'help2' ) );
+ $reqs['testSub'] = $this->getMockBuilder( ButtonAuthenticationRequest::class )
+ ->setConstructorArgs( [ 'subclass', wfMessage( 'msg3' ), wfMessage( 'help3' ) ] )
+ ->getMock();
+
+ $this->assertNull( ButtonAuthenticationRequest::getRequestByName( $reqs, 'missing' ) );
+ $this->assertSame(
+ $reqs['testOne'], ButtonAuthenticationRequest::getRequestByName( $reqs, 'foo' )
+ );
+ $this->assertNull( ButtonAuthenticationRequest::getRequestByName( $reqs, 'bar' ) );
+ $this->assertSame(
+ $reqs['testSub'], ButtonAuthenticationRequest::getRequestByName( $reqs, 'subclass' )
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php
new file mode 100644
index 00000000..e8b61c59
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php
@@ -0,0 +1,191 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group AuthManager
+ * @group Database
+ * @covers MediaWiki\Auth\CheckBlocksSecondaryAuthenticationProvider
+ */
+class CheckBlocksSecondaryAuthenticationProviderTest extends \MediaWikiTestCase {
+ public function testConstructor() {
+ $provider = new CheckBlocksSecondaryAuthenticationProvider();
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+ $config = new \HashConfig( [
+ 'BlockDisablesLogin' => false
+ ] );
+ $provider->setConfig( $config );
+ $this->assertSame( false, $providerPriv->blockDisablesLogin );
+
+ $provider = new CheckBlocksSecondaryAuthenticationProvider(
+ [ 'blockDisablesLogin' => true ]
+ );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+ $config = new \HashConfig( [
+ 'BlockDisablesLogin' => false
+ ] );
+ $provider->setConfig( $config );
+ $this->assertSame( true, $providerPriv->blockDisablesLogin );
+ }
+
+ public function testBasics() {
+ $provider = new CheckBlocksSecondaryAuthenticationProvider();
+ $user = \User::newFromName( 'UTSysop' );
+
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginSecondaryAccountCreation( $user, $user, [] )
+ );
+ }
+
+ /**
+ * @dataProvider provideGetAuthenticationRequests
+ * @param string $action
+ * @param array $response
+ */
+ public function testGetAuthenticationRequests( $action, $response ) {
+ $provider = new CheckBlocksSecondaryAuthenticationProvider();
+
+ $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) );
+ }
+
+ public static function provideGetAuthenticationRequests() {
+ return [
+ [ AuthManager::ACTION_LOGIN, [] ],
+ [ AuthManager::ACTION_CREATE, [] ],
+ [ AuthManager::ACTION_LINK, [] ],
+ [ AuthManager::ACTION_CHANGE, [] ],
+ [ AuthManager::ACTION_REMOVE, [] ],
+ ];
+ }
+
+ private function getBlockedUser() {
+ $user = \User::newFromName( 'UTBlockee' );
+ if ( $user->getID() == 0 ) {
+ $user->addToDatabase();
+ \TestUser::setPasswordForUser( $user, 'UTBlockeePassword' );
+ $user->saveSettings();
+ }
+ $oldBlock = \Block::newFromTarget( 'UTBlockee' );
+ if ( $oldBlock ) {
+ // An old block will prevent our new one from saving.
+ $oldBlock->delete();
+ }
+ $blockOptions = [
+ 'address' => 'UTBlockee',
+ 'user' => $user->getID(),
+ 'by' => $this->getTestSysop()->getUser()->getId(),
+ 'reason' => __METHOD__,
+ 'expiry' => time() + 100500,
+ 'createAccount' => true,
+ ];
+ $block = new \Block( $blockOptions );
+ $block->insert();
+ return $user;
+ }
+
+ public function testBeginSecondaryAuthentication() {
+ $unblockedUser = \User::newFromName( 'UTSysop' );
+ $blockedUser = $this->getBlockedUser();
+
+ $provider = new CheckBlocksSecondaryAuthenticationProvider(
+ [ 'blockDisablesLogin' => false ]
+ );
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginSecondaryAuthentication( $unblockedUser, [] )
+ );
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginSecondaryAuthentication( $blockedUser, [] )
+ );
+
+ $provider = new CheckBlocksSecondaryAuthenticationProvider(
+ [ 'blockDisablesLogin' => true ]
+ );
+ $this->assertEquals(
+ AuthenticationResponse::newPass(),
+ $provider->beginSecondaryAuthentication( $unblockedUser, [] )
+ );
+ $ret = $provider->beginSecondaryAuthentication( $blockedUser, [] );
+ $this->assertEquals( AuthenticationResponse::FAIL, $ret->status );
+ }
+
+ public function testTestUserForCreation() {
+ $provider = new CheckBlocksSecondaryAuthenticationProvider(
+ [ 'blockDisablesLogin' => false ]
+ );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setConfig( new \HashConfig() );
+ $provider->setManager( AuthManager::singleton() );
+
+ $unblockedUser = \User::newFromName( 'UTSysop' );
+ $blockedUser = $this->getBlockedUser();
+
+ $user = \User::newFromName( 'RandomUser' );
+
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testUserForCreation( $unblockedUser, AuthManager::AUTOCREATE_SOURCE_SESSION )
+ );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testUserForCreation( $unblockedUser, false )
+ );
+
+ $status = $provider->testUserForCreation( $blockedUser, AuthManager::AUTOCREATE_SOURCE_SESSION );
+ $this->assertInstanceOf( \StatusValue::class, $status );
+ $this->assertFalse( $status->isOK() );
+ $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) );
+
+ $status = $provider->testUserForCreation( $blockedUser, false );
+ $this->assertInstanceOf( \StatusValue::class, $status );
+ $this->assertFalse( $status->isOK() );
+ $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) );
+ }
+
+ public function testRangeBlock() {
+ $blockOptions = [
+ 'address' => '127.0.0.0/24',
+ 'reason' => __METHOD__,
+ 'by' => $this->getTestSysop()->getUser()->getId(),
+ 'expiry' => time() + 100500,
+ 'createAccount' => true,
+ ];
+ $block = new \Block( $blockOptions );
+ $block->insert();
+ $scopeVariable = new \Wikimedia\ScopedCallback( [ $block, 'delete' ] );
+
+ $user = \User::newFromName( 'UTNormalUser' );
+ if ( $user->getID() == 0 ) {
+ $user->addToDatabase();
+ \TestUser::setPasswordForUser( $user, 'UTNormalUserPassword' );
+ $user->saveSettings();
+ }
+ $this->setMwGlobals( [ 'wgUser' => $user ] );
+ \RequestContext::getMain()->setUser( $user );
+ $newuser = \User::newFromName( 'RandomUser' );
+
+ $provider = new CheckBlocksSecondaryAuthenticationProvider(
+ [ 'blockDisablesLogin' => true ]
+ );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setConfig( new \HashConfig() );
+ $provider->setManager( AuthManager::singleton() );
+
+ $ret = $provider->beginSecondaryAuthentication( $user, [] );
+ $this->assertEquals( AuthenticationResponse::FAIL, $ret->status );
+
+ $status = $provider->testUserForCreation( $newuser, AuthManager::AUTOCREATE_SOURCE_SESSION );
+ $this->assertInstanceOf( \StatusValue::class, $status );
+ $this->assertFalse( $status->isOK() );
+ $this->assertTrue( $status->hasMessage( 'cantcreateaccount-range-text' ) );
+
+ $status = $provider->testUserForCreation( $newuser, false );
+ $this->assertInstanceOf( \StatusValue::class, $status );
+ $this->assertFalse( $status->isOK() );
+ $this->assertTrue( $status->hasMessage( 'cantcreateaccount-range-text' ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php
new file mode 100644
index 00000000..f208cc4b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/ConfirmLinkAuthenticationRequestTest.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use InvalidArgumentException;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\ConfirmLinkAuthenticationRequest
+ */
+class ConfirmLinkAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+ protected function getInstance( array $args = [] ) {
+ return new ConfirmLinkAuthenticationRequest( $this->getLinkRequests() );
+ }
+
+ /**
+ * @expectedException InvalidArgumentException
+ * @expectedExceptionMessage $linkRequests must not be empty
+ */
+ public function testConstructorException() {
+ new ConfirmLinkAuthenticationRequest( [] );
+ }
+
+ /**
+ * Get requests for testing
+ * @return AuthenticationRequest[]
+ */
+ private function getLinkRequests() {
+ $reqs = [];
+
+ $mb = $this->getMockBuilder( AuthenticationRequest::class )
+ ->setMethods( [ 'getUniqueId' ] );
+ for ( $i = 1; $i <= 3; $i++ ) {
+ $req = $mb->getMockForAbstractClass();
+ $req->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( "Request$i" ) );
+ $reqs[$req->getUniqueId()] = $req;
+ }
+
+ return $reqs;
+ }
+
+ public function provideLoadFromSubmission() {
+ $reqs = $this->getLinkRequests();
+
+ return [
+ 'Empty request' => [
+ [],
+ [],
+ [ 'linkRequests' => $reqs ],
+ ],
+ 'Some confirmed' => [
+ [],
+ [ 'confirmedLinkIDs' => [ 'Request1', 'Request3' ] ],
+ [ 'confirmedLinkIDs' => [ 'Request1', 'Request3' ], 'linkRequests' => $reqs ],
+ ],
+ ];
+ }
+
+ public function testGetUniqueId() {
+ $req = new ConfirmLinkAuthenticationRequest( $this->getLinkRequests() );
+ $this->assertSame(
+ get_class( $req ) . ':Request1|Request2|Request3',
+ $req->getUniqueId()
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php
new file mode 100644
index 00000000..9222843c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php
@@ -0,0 +1,289 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\ConfirmLinkSecondaryAuthenticationProvider
+ */
+class ConfirmLinkSecondaryAuthenticationProviderTest extends \MediaWikiTestCase {
+ /**
+ * @dataProvider provideGetAuthenticationRequests
+ * @param string $action
+ * @param array $response
+ */
+ public function testGetAuthenticationRequests( $action, $response ) {
+ $provider = new ConfirmLinkSecondaryAuthenticationProvider();
+
+ $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) );
+ }
+
+ public static function provideGetAuthenticationRequests() {
+ return [
+ [ AuthManager::ACTION_LOGIN, [] ],
+ [ AuthManager::ACTION_CREATE, [] ],
+ [ AuthManager::ACTION_LINK, [] ],
+ [ AuthManager::ACTION_CHANGE, [] ],
+ [ AuthManager::ACTION_REMOVE, [] ],
+ ];
+ }
+
+ public function testBeginSecondaryAuthentication() {
+ $user = \User::newFromName( 'UTSysop' );
+ $obj = new \stdClass;
+
+ $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class )
+ ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] )
+ ->getMock();
+ $mock->expects( $this->once() )->method( 'beginLinkAttempt' )
+ ->with( $this->identicalTo( $user ), $this->identicalTo( 'AuthManager::authnState' ) )
+ ->will( $this->returnValue( $obj ) );
+ $mock->expects( $this->never() )->method( 'continueLinkAttempt' );
+
+ $this->assertSame( $obj, $mock->beginSecondaryAuthentication( $user, [] ) );
+ }
+
+ public function testContinueSecondaryAuthentication() {
+ $user = \User::newFromName( 'UTSysop' );
+ $obj = new \stdClass;
+ $reqs = [ new \stdClass ];
+
+ $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class )
+ ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] )
+ ->getMock();
+ $mock->expects( $this->never() )->method( 'beginLinkAttempt' );
+ $mock->expects( $this->once() )->method( 'continueLinkAttempt' )
+ ->with(
+ $this->identicalTo( $user ),
+ $this->identicalTo( 'AuthManager::authnState' ),
+ $this->identicalTo( $reqs )
+ )
+ ->will( $this->returnValue( $obj ) );
+
+ $this->assertSame( $obj, $mock->continueSecondaryAuthentication( $user, $reqs ) );
+ }
+
+ public function testBeginSecondaryAccountCreation() {
+ $user = \User::newFromName( 'UTSysop' );
+ $obj = new \stdClass;
+
+ $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class )
+ ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] )
+ ->getMock();
+ $mock->expects( $this->once() )->method( 'beginLinkAttempt' )
+ ->with( $this->identicalTo( $user ), $this->identicalTo( 'AuthManager::accountCreationState' ) )
+ ->will( $this->returnValue( $obj ) );
+ $mock->expects( $this->never() )->method( 'continueLinkAttempt' );
+
+ $this->assertSame( $obj, $mock->beginSecondaryAccountCreation( $user, $user, [] ) );
+ }
+
+ public function testContinueSecondaryAccountCreation() {
+ $user = \User::newFromName( 'UTSysop' );
+ $obj = new \stdClass;
+ $reqs = [ new \stdClass ];
+
+ $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class )
+ ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] )
+ ->getMock();
+ $mock->expects( $this->never() )->method( 'beginLinkAttempt' );
+ $mock->expects( $this->once() )->method( 'continueLinkAttempt' )
+ ->with(
+ $this->identicalTo( $user ),
+ $this->identicalTo( 'AuthManager::accountCreationState' ),
+ $this->identicalTo( $reqs )
+ )
+ ->will( $this->returnValue( $obj ) );
+
+ $this->assertSame( $obj, $mock->continueSecondaryAccountCreation( $user, $user, $reqs ) );
+ }
+
+ /**
+ * Get requests for testing
+ * @return AuthenticationRequest[]
+ */
+ private function getLinkRequests() {
+ $reqs = [];
+
+ $mb = $this->getMockBuilder( AuthenticationRequest::class )
+ ->setMethods( [ 'getUniqueId' ] );
+ for ( $i = 1; $i <= 3; $i++ ) {
+ $req = $mb->getMockForAbstractClass();
+ $req->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( "Request$i" ) );
+ $req->id = $i - 1;
+ $reqs[$req->getUniqueId()] = $req;
+ }
+
+ return $reqs;
+ }
+
+ public function testBeginLinkAttempt() {
+ $badReq = $this->getMockBuilder( AuthenticationRequest::class )
+ ->setMethods( [ 'getUniqueId' ] )
+ ->getMockForAbstractClass();
+ $badReq->expects( $this->any() )->method( 'getUniqueId' )
+ ->will( $this->returnValue( "BadReq" ) );
+
+ $user = \User::newFromName( 'UTSysop' );
+ $provider = TestingAccessWrapper::newFromObject(
+ new ConfirmLinkSecondaryAuthenticationProvider
+ );
+ $request = new \FauxRequest();
+ $manager = $this->getMockBuilder( AuthManager::class )
+ ->setMethods( [ 'allowsAuthenticationDataChange' ] )
+ ->setConstructorArgs( [ $request, \RequestContext::getMain()->getConfig() ] )
+ ->getMock();
+ $manager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' )
+ ->will( $this->returnCallback( function ( $req ) {
+ return $req->getUniqueId() !== 'BadReq'
+ ? \StatusValue::newGood()
+ : \StatusValue::newFatal( 'no' );
+ } ) );
+ $provider->setManager( $manager );
+
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginLinkAttempt( $user, 'state' )
+ );
+
+ $request->getSession()->setSecret( 'state', [
+ 'maybeLink' => [],
+ ] );
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginLinkAttempt( $user, 'state' )
+ );
+
+ $reqs = $this->getLinkRequests();
+ $request->getSession()->setSecret( 'state', [
+ 'maybeLink' => $reqs + [ 'BadReq' => $badReq ]
+ ] );
+ $res = $provider->beginLinkAttempt( $user, 'state' );
+ $this->assertInstanceOf( AuthenticationResponse::class, $res );
+ $this->assertSame( AuthenticationResponse::UI, $res->status );
+ $this->assertSame( 'authprovider-confirmlink-message', $res->message->getKey() );
+ $this->assertCount( 1, $res->neededRequests );
+ $req = $res->neededRequests[0];
+ $this->assertInstanceOf( ConfirmLinkAuthenticationRequest::class, $req );
+ $expectReqs = $this->getLinkRequests();
+ foreach ( $expectReqs as $r ) {
+ $r->action = AuthManager::ACTION_CHANGE;
+ $r->username = $user->getName();
+ }
+ $this->assertEquals( $expectReqs, TestingAccessWrapper::newFromObject( $req )->linkRequests );
+ }
+
+ public function testContinueLinkAttempt() {
+ $user = \User::newFromName( 'UTSysop' );
+ $obj = new \stdClass;
+ $reqs = $this->getLinkRequests();
+
+ $done = [ false, false, false ];
+
+ // First, test the pass-through for not containing the ConfirmLinkAuthenticationRequest
+ $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class )
+ ->setMethods( [ 'beginLinkAttempt' ] )
+ ->getMock();
+ $mock->expects( $this->once() )->method( 'beginLinkAttempt' )
+ ->with( $this->identicalTo( $user ), $this->identicalTo( 'state' ) )
+ ->will( $this->returnValue( $obj ) );
+ $this->assertSame(
+ $obj,
+ TestingAccessWrapper::newFromObject( $mock )->continueLinkAttempt( $user, 'state', $reqs )
+ );
+
+ // Now test the actual functioning
+ $provider = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class )
+ ->setMethods( [
+ 'beginLinkAttempt', 'providerAllowsAuthenticationDataChange',
+ 'providerChangeAuthenticationData'
+ ] )
+ ->getMock();
+ $provider->expects( $this->never() )->method( 'beginLinkAttempt' );
+ $provider->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' )
+ ->will( $this->returnCallback( function ( $req ) use ( $reqs ) {
+ return $req->getUniqueId() === 'Request3'
+ ? \StatusValue::newFatal( 'foo' ) : \StatusValue::newGood();
+ } ) );
+ $provider->expects( $this->any() )->method( 'providerChangeAuthenticationData' )
+ ->will( $this->returnCallback( function ( $req ) use ( &$done ) {
+ $done[$req->id] = true;
+ } ) );
+ $config = new \HashConfig( [
+ 'AuthManagerConfig' => [
+ 'preauth' => [],
+ 'primaryauth' => [],
+ 'secondaryauth' => [
+ [ 'factory' => function () use ( $provider ) {
+ return $provider;
+ } ],
+ ],
+ ],
+ ] );
+ $request = new \FauxRequest();
+ $manager = new AuthManager( $request, $config );
+ $provider->setManager( $manager );
+ $provider = TestingAccessWrapper::newFromObject( $provider );
+
+ $req = new ConfirmLinkAuthenticationRequest( $reqs );
+
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->continueLinkAttempt( $user, 'state', [ $req ] )
+ );
+
+ $request->getSession()->setSecret( 'state', [
+ 'maybeLink' => [],
+ ] );
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->continueLinkAttempt( $user, 'state', [ $req ] )
+ );
+
+ $request->getSession()->setSecret( 'state', [
+ 'maybeLink' => $reqs
+ ] );
+ $this->assertEquals(
+ AuthenticationResponse::newPass(),
+ $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] )
+ );
+ $this->assertSame( [ false, false, false ], $done );
+
+ $request->getSession()->setSecret( 'state', [
+ 'maybeLink' => [ $reqs['Request2'] ],
+ ] );
+ $req->confirmedLinkIDs = [ 'Request1', 'Request2' ];
+ $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] );
+ $this->assertEquals( AuthenticationResponse::newPass(), $res );
+ $this->assertSame( [ false, true, false ], $done );
+ $done = [ false, false, false ];
+
+ $request->getSession()->setSecret( 'state', [
+ 'maybeLink' => $reqs,
+ ] );
+ $req->confirmedLinkIDs = [ 'Request1', 'Request2' ];
+ $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] );
+ $this->assertEquals( AuthenticationResponse::newPass(), $res );
+ $this->assertSame( [ true, true, false ], $done );
+ $done = [ false, false, false ];
+
+ $request->getSession()->setSecret( 'state', [
+ 'maybeLink' => $reqs,
+ ] );
+ $req->confirmedLinkIDs = [ 'Request1', 'Request3' ];
+ $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] );
+ $this->assertEquals( AuthenticationResponse::UI, $res->status );
+ $this->assertCount( 1, $res->neededRequests );
+ $this->assertInstanceOf( ButtonAuthenticationRequest::class, $res->neededRequests[0] );
+ $this->assertSame( [ true, false, false ], $done );
+ $done = [ false, false, false ];
+
+ $res = $provider->continueLinkAttempt( $user, 'state', [ $res->neededRequests[0] ] );
+ $this->assertEquals( AuthenticationResponse::newPass(), $res );
+ $this->assertSame( [ false, false, false ], $done );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php
new file mode 100644
index 00000000..d166caa6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/CreateFromLoginAuthenticationRequestTest.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\CreateFromLoginAuthenticationRequest
+ */
+class CreateFromLoginAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+ protected function getInstance( array $args = [] ) {
+ return new CreateFromLoginAuthenticationRequest(
+ null, []
+ );
+ }
+
+ public function provideLoadFromSubmission() {
+ return [
+ 'Empty request' => [
+ [],
+ [],
+ [],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideState
+ */
+ public function testState(
+ $createReq, $maybeLink, $username, $loginState, $createState, $createPrimaryState
+ ) {
+ $req = new CreateFromLoginAuthenticationRequest( $createReq, $maybeLink );
+ $this->assertSame( $username, $req->username );
+ $this->assertSame( $loginState, $req->hasStateForAction( AuthManager::ACTION_LOGIN ) );
+ $this->assertSame( $createState, $req->hasStateForAction( AuthManager::ACTION_CREATE ) );
+ $this->assertFalse( $req->hasStateForAction( AuthManager::ACTION_LINK ) );
+ $this->assertFalse( $req->hasPrimaryStateForAction( AuthManager::ACTION_LOGIN ) );
+ $this->assertSame( $createPrimaryState,
+ $req->hasPrimaryStateForAction( AuthManager::ACTION_CREATE ) );
+ }
+
+ public static function provideState() {
+ $req1 = new UsernameAuthenticationRequest;
+ $req2 = new UsernameAuthenticationRequest;
+ $req2->username = 'Bob';
+
+ return [
+ 'Nothing' => [ null, [], null, false, false, false ],
+ 'Link, no create' => [ null, [ $req2 ], null, true, true, false ],
+ 'No link, create but no name' => [ $req1, [], null, false, true, true ],
+ 'Link and create but no name' => [ $req1, [ $req2 ], null, true, true, true ],
+ 'No link, create with name' => [ $req2, [], 'Bob', false, true, true ],
+ 'Link and create with name' => [ $req2, [ $req2 ], 'Bob', true, true, true ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php
new file mode 100644
index 00000000..fc1e6f15
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/CreatedAccountAuthenticationRequestTest.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\CreatedAccountAuthenticationRequest
+ */
+class CreatedAccountAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+ protected function getInstance( array $args = [] ) {
+ return new CreatedAccountAuthenticationRequest( 42, 'Test' );
+ }
+
+ public function testConstructor() {
+ $ret = new CreatedAccountAuthenticationRequest( 42, 'Test' );
+ $this->assertSame( 42, $ret->id );
+ $this->assertSame( 'Test', $ret->username );
+ }
+
+ public function provideLoadFromSubmission() {
+ return [
+ 'Empty request' => [
+ [],
+ [],
+ false
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php
new file mode 100644
index 00000000..cce1e8cd
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/CreationReasonAuthenticationRequestTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\CreationReasonAuthenticationRequest
+ */
+class CreationReasonAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+ protected function getInstance( array $args = [] ) {
+ return new CreationReasonAuthenticationRequest();
+ }
+
+ public function provideLoadFromSubmission() {
+ return [
+ 'Empty request' => [
+ [],
+ [],
+ false
+ ],
+ 'Reason given' => [
+ [],
+ $data = [ 'reason' => 'Because' ],
+ $data,
+ ],
+ 'Reason empty' => [
+ [],
+ [ 'reason' => '' ],
+ false
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php
new file mode 100644
index 00000000..1a7ed12d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use Psr\Log\LoggerInterface;
+use Wikimedia\TestingAccessWrapper;
+
+class EmailNotificationSecondaryAuthenticationProviderTest extends \PHPUnit\Framework\TestCase {
+ public function testConstructor() {
+ $config = new \HashConfig( [
+ 'EnableEmail' => true,
+ 'EmailAuthentication' => true,
+ ] );
+
+ $provider = new EmailNotificationSecondaryAuthenticationProvider();
+ $provider->setConfig( $config );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+ $this->assertTrue( $providerPriv->sendConfirmationEmail );
+
+ $provider = new EmailNotificationSecondaryAuthenticationProvider( [
+ 'sendConfirmationEmail' => false,
+ ] );
+ $provider->setConfig( $config );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+ $this->assertFalse( $providerPriv->sendConfirmationEmail );
+ }
+
+ /**
+ * @dataProvider provideGetAuthenticationRequests
+ * @param string $action
+ * @param AuthenticationRequest[] $expected
+ */
+ public function testGetAuthenticationRequests( $action, $expected ) {
+ $provider = new EmailNotificationSecondaryAuthenticationProvider( [
+ 'sendConfirmationEmail' => true,
+ ] );
+ $this->assertSame( $expected, $provider->getAuthenticationRequests( $action, [] ) );
+ }
+
+ public function provideGetAuthenticationRequests() {
+ return [
+ [ AuthManager::ACTION_LOGIN, [] ],
+ [ AuthManager::ACTION_CREATE, [] ],
+ [ AuthManager::ACTION_LINK, [] ],
+ [ AuthManager::ACTION_CHANGE, [] ],
+ [ AuthManager::ACTION_REMOVE, [] ],
+ ];
+ }
+
+ public function testBeginSecondaryAuthentication() {
+ $provider = new EmailNotificationSecondaryAuthenticationProvider( [
+ 'sendConfirmationEmail' => true,
+ ] );
+ $this->assertEquals( AuthenticationResponse::newAbstain(),
+ $provider->beginSecondaryAuthentication( \User::newFromName( 'Foo' ), [] ) );
+ }
+
+ public function testBeginSecondaryAccountCreation() {
+ $authManager = new AuthManager( new \FauxRequest(), new \HashConfig() );
+
+ $creator = $this->getMockBuilder( \User::class )->getMock();
+ $userWithoutEmail = $this->getMockBuilder( \User::class )->getMock();
+ $userWithoutEmail->expects( $this->any() )->method( 'getEmail' )->willReturn( '' );
+ $userWithoutEmail->expects( $this->any() )->method( 'getInstanceForUpdate' )->willReturnSelf();
+ $userWithoutEmail->expects( $this->never() )->method( 'sendConfirmationMail' );
+ $userWithEmailError = $this->getMockBuilder( \User::class )->getMock();
+ $userWithEmailError->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' );
+ $userWithEmailError->expects( $this->any() )->method( 'getInstanceForUpdate' )->willReturnSelf();
+ $userWithEmailError->expects( $this->any() )->method( 'sendConfirmationMail' )
+ ->willReturn( \Status::newFatal( 'fail' ) );
+ $userExpectsConfirmation = $this->getMockBuilder( \User::class )->getMock();
+ $userExpectsConfirmation->expects( $this->any() )->method( 'getEmail' )
+ ->willReturn( 'foo@bar.baz' );
+ $userExpectsConfirmation->expects( $this->any() )->method( 'getInstanceForUpdate' )
+ ->willReturnSelf();
+ $userExpectsConfirmation->expects( $this->once() )->method( 'sendConfirmationMail' )
+ ->willReturn( \Status::newGood() );
+ $userNotExpectsConfirmation = $this->getMockBuilder( \User::class )->getMock();
+ $userNotExpectsConfirmation->expects( $this->any() )->method( 'getEmail' )
+ ->willReturn( 'foo@bar.baz' );
+ $userNotExpectsConfirmation->expects( $this->any() )->method( 'getInstanceForUpdate' )
+ ->willReturnSelf();
+ $userNotExpectsConfirmation->expects( $this->never() )->method( 'sendConfirmationMail' );
+
+ $provider = new EmailNotificationSecondaryAuthenticationProvider( [
+ 'sendConfirmationEmail' => false,
+ ] );
+ $provider->setManager( $authManager );
+ $provider->beginSecondaryAccountCreation( $userNotExpectsConfirmation, $creator, [] );
+
+ $provider = new EmailNotificationSecondaryAuthenticationProvider( [
+ 'sendConfirmationEmail' => true,
+ ] );
+ $provider->setManager( $authManager );
+ $provider->beginSecondaryAccountCreation( $userWithoutEmail, $creator, [] );
+ $provider->beginSecondaryAccountCreation( $userExpectsConfirmation, $creator, [] );
+
+ // test logging of email errors
+ $logger = $this->getMockForAbstractClass( LoggerInterface::class );
+ $logger->expects( $this->once() )->method( 'warning' );
+ $provider->setLogger( $logger );
+ $provider->beginSecondaryAccountCreation( $userWithEmailError, $creator, [] );
+
+ // test disable flag used by other providers
+ $authManager->setAuthenticationSessionData( 'no-email', true );
+ $provider->setManager( $authManager );
+ $provider->beginSecondaryAccountCreation( $userNotExpectsConfirmation, $creator, [] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php
new file mode 100644
index 00000000..38ccb8a3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/LegacyHookPreAuthenticationProviderTest.php
@@ -0,0 +1,373 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group AuthManager
+ * @group Database
+ * @covers MediaWiki\Auth\LegacyHookPreAuthenticationProvider
+ */
+class LegacyHookPreAuthenticationProviderTest extends \MediaWikiTestCase {
+ /**
+ * Get an instance of the provider
+ * @return LegacyHookPreAuthenticationProvider
+ */
+ protected function getProvider() {
+ $request = $this->getMockBuilder( \FauxRequest::class )
+ ->setMethods( [ 'getIP' ] )->getMock();
+ $request->expects( $this->any() )->method( 'getIP' )->will( $this->returnValue( '127.0.0.42' ) );
+
+ $manager = new AuthManager(
+ $request,
+ MediaWikiServices::getInstance()->getMainConfig()
+ );
+
+ $provider = new LegacyHookPreAuthenticationProvider();
+ $provider->setManager( $manager );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setConfig( new \HashConfig( [
+ 'PasswordAttemptThrottle' => [ 'count' => 23, 'seconds' => 42 ],
+ ] ) );
+ return $provider;
+ }
+
+ /**
+ * Sets a mock on a hook
+ * @param string $hook
+ * @param object $expect From $this->once(), $this->never(), etc.
+ * @return object $mock->expects( $expect )->method( ... ).
+ */
+ protected function hook( $hook, $expect ) {
+ $mock = $this->getMockBuilder( __CLASS__ )->setMethods( [ "on$hook" ] )->getMock();
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ $hook => [ $mock ],
+ ] );
+ return $mock->expects( $expect )->method( "on$hook" );
+ }
+
+ /**
+ * Unsets a hook
+ * @param string $hook
+ */
+ protected function unhook( $hook ) {
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ $hook => [],
+ ] );
+ }
+
+ // Stubs for hooks taking reference parameters
+ public function onLoginUserMigrated( $user, &$msg ) {
+ }
+ public function onAbortLogin( $user, $password, &$abort, &$msg ) {
+ }
+ public function onAbortNewAccount( $user, &$abortError, &$abortStatus ) {
+ }
+ public function onAbortAutoAccount( $user, &$abortError ) {
+ }
+
+ /**
+ * @dataProvider provideTestForAuthentication
+ * @param string|null $username
+ * @param string|null $password
+ * @param string|null $msgForLoginUserMigrated
+ * @param int|null $abortForAbortLogin
+ * @param string|null $msgForAbortLogin
+ * @param string|null $failMsg
+ * @param array $failParams
+ */
+ public function testTestForAuthentication(
+ $username, $password,
+ $msgForLoginUserMigrated, $abortForAbortLogin, $msgForAbortLogin,
+ $failMsg, $failParams = []
+ ) {
+ $reqs = [];
+ if ( $username === null ) {
+ $this->hook( 'LoginUserMigrated', $this->never() );
+ $this->hook( 'AbortLogin', $this->never() );
+ } else {
+ if ( $password === null ) {
+ $req = $this->getMockForAbstractClass( AuthenticationRequest::class );
+ } else {
+ $req = new PasswordAuthenticationRequest();
+ $req->action = AuthManager::ACTION_LOGIN;
+ $req->password = $password;
+ }
+ $req->username = $username;
+ $reqs[get_class( $req )] = $req;
+
+ $h = $this->hook( 'LoginUserMigrated', $this->once() );
+ if ( $msgForLoginUserMigrated !== null ) {
+ $h->will( $this->returnCallback(
+ function ( $user, &$msg ) use ( $username, $msgForLoginUserMigrated ) {
+ $this->assertInstanceOf( \User::class, $user );
+ $this->assertSame( $username, $user->getName() );
+ $msg = $msgForLoginUserMigrated;
+ return false;
+ }
+ ) );
+ $this->hook( 'AbortLogin', $this->never() );
+ } else {
+ $h->will( $this->returnCallback(
+ function ( $user, &$msg ) use ( $username ) {
+ $this->assertInstanceOf( \User::class, $user );
+ $this->assertSame( $username, $user->getName() );
+ return true;
+ }
+ ) );
+ $h2 = $this->hook( 'AbortLogin', $this->once() );
+ if ( $abortForAbortLogin !== null ) {
+ $h2->will( $this->returnCallback(
+ function ( $user, $pass, &$abort, &$msg )
+ use ( $username, $password, $abortForAbortLogin, $msgForAbortLogin )
+ {
+ $this->assertInstanceOf( \User::class, $user );
+ $this->assertSame( $username, $user->getName() );
+ if ( $password !== null ) {
+ $this->assertSame( $password, $pass );
+ } else {
+ $this->assertInternalType( 'string', $pass );
+ }
+ $abort = $abortForAbortLogin;
+ $msg = $msgForAbortLogin;
+ return false;
+ }
+ ) );
+ } else {
+ $h2->will( $this->returnCallback(
+ function ( $user, $pass, &$abort, &$msg ) use ( $username, $password ) {
+ $this->assertInstanceOf( \User::class, $user );
+ $this->assertSame( $username, $user->getName() );
+ if ( $password !== null ) {
+ $this->assertSame( $password, $pass );
+ } else {
+ $this->assertInternalType( 'string', $pass );
+ }
+ return true;
+ }
+ ) );
+ }
+ }
+ }
+ unset( $h, $h2 );
+
+ $status = $this->getProvider()->testForAuthentication( $reqs );
+
+ $this->unhook( 'LoginUserMigrated' );
+ $this->unhook( 'AbortLogin' );
+
+ if ( $failMsg === null ) {
+ $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' );
+ } else {
+ $this->assertInstanceOf( \StatusValue::class, $status, 'should fail (type)' );
+ $this->assertFalse( $status->isOk(), 'should fail (ok)' );
+ $errors = $status->getErrors();
+ $this->assertEquals( $failMsg, $errors[0]['message'], 'should fail (message)' );
+ $this->assertEquals( $failParams, $errors[0]['params'], 'should fail (params)' );
+ }
+ }
+
+ public static function provideTestForAuthentication() {
+ return [
+ 'No valid requests' => [
+ null, null, null, null, null, null
+ ],
+ 'No hook errors' => [
+ 'User', 'PaSsWoRd', null, null, null, null
+ ],
+ 'No hook errors, no password' => [
+ 'User', null, null, null, null, null
+ ],
+ 'LoginUserMigrated no message' => [
+ 'User', 'PaSsWoRd', false, null, null, 'login-migrated-generic'
+ ],
+ 'LoginUserMigrated with message' => [
+ 'User', 'PaSsWoRd', 'LUM-abort', null, null, 'LUM-abort'
+ ],
+ 'LoginUserMigrated with message and params' => [
+ 'User', 'PaSsWoRd', [ 'LUM-abort', 'foo' ], null, null, 'LUM-abort', [ 'foo' ]
+ ],
+ 'AbortLogin, SUCCESS' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::SUCCESS, null, null
+ ],
+ 'AbortLogin, NEED_TOKEN, no message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::NEED_TOKEN, null, 'nocookiesforlogin'
+ ],
+ 'AbortLogin, NEED_TOKEN, with message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::NEED_TOKEN, 'needtoken', 'needtoken'
+ ],
+ 'AbortLogin, WRONG_TOKEN, no message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::WRONG_TOKEN, null, 'sessionfailure'
+ ],
+ 'AbortLogin, WRONG_TOKEN, with message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::WRONG_TOKEN, 'wrongtoken', 'wrongtoken'
+ ],
+ 'AbortLogin, ILLEGAL, no message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::ILLEGAL, null, 'noname'
+ ],
+ 'AbortLogin, ILLEGAL, with message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::ILLEGAL, 'badname', 'badname'
+ ],
+ 'AbortLogin, NO_NAME, no message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::NO_NAME, null, 'noname'
+ ],
+ 'AbortLogin, NO_NAME, with message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::NO_NAME, 'badname', 'badname'
+ ],
+ 'AbortLogin, WRONG_PASS, no message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PASS, null, 'wrongpassword'
+ ],
+ 'AbortLogin, WRONG_PASS, with message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PASS, 'badpass', 'badpass'
+ ],
+ 'AbortLogin, WRONG_PLUGIN_PASS, no message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PLUGIN_PASS, null, 'wrongpassword'
+ ],
+ 'AbortLogin, WRONG_PLUGIN_PASS, with message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::WRONG_PLUGIN_PASS, 'badpass', 'badpass'
+ ],
+ 'AbortLogin, NOT_EXISTS, no message' => [
+ "User'", 'A', null, \LoginForm::NOT_EXISTS, null, 'nosuchusershort', [ 'User&#39;' ]
+ ],
+ 'AbortLogin, NOT_EXISTS, with message' => [
+ "User'", 'A', null, \LoginForm::NOT_EXISTS, 'badname', 'badname', [ 'User&#39;' ]
+ ],
+ 'AbortLogin, EMPTY_PASS, no message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::EMPTY_PASS, null, 'wrongpasswordempty'
+ ],
+ 'AbortLogin, EMPTY_PASS, with message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::EMPTY_PASS, 'badpass', 'badpass'
+ ],
+ 'AbortLogin, RESET_PASS, no message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::RESET_PASS, null, 'resetpass_announce'
+ ],
+ 'AbortLogin, RESET_PASS, with message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::RESET_PASS, 'resetpass', 'resetpass'
+ ],
+ 'AbortLogin, THROTTLED, no message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::THROTTLED, null, 'login-throttled',
+ [ \Message::durationParam( 42 ) ]
+ ],
+ 'AbortLogin, THROTTLED, with message' => [
+ 'User', 'PaSsWoRd', null, \LoginForm::THROTTLED, 't', 't',
+ [ \Message::durationParam( 42 ) ]
+ ],
+ 'AbortLogin, USER_BLOCKED, no message' => [
+ "User'", 'P', null, \LoginForm::USER_BLOCKED, null, 'login-userblocked', [ 'User&#39;' ]
+ ],
+ 'AbortLogin, USER_BLOCKED, with message' => [
+ "User'", 'P', null, \LoginForm::USER_BLOCKED, 'blocked', 'blocked', [ 'User&#39;' ]
+ ],
+ 'AbortLogin, ABORTED, no message' => [
+ "User'", 'P', null, \LoginForm::ABORTED, null, 'login-abort-generic', [ 'User&#39;' ]
+ ],
+ 'AbortLogin, ABORTED, with message' => [
+ "User'", 'P', null, \LoginForm::ABORTED, 'aborted', 'aborted', [ 'User&#39;' ]
+ ],
+ 'AbortLogin, USER_MIGRATED, no message' => [
+ 'User', 'P', null, \LoginForm::USER_MIGRATED, null, 'login-migrated-generic'
+ ],
+ 'AbortLogin, USER_MIGRATED, with message' => [
+ 'User', 'P', null, \LoginForm::USER_MIGRATED, 'migrated', 'migrated'
+ ],
+ 'AbortLogin, USER_MIGRATED, with message and params' => [
+ 'User', 'P', null, \LoginForm::USER_MIGRATED, [ 'migrated', 'foo' ],
+ 'migrated', [ 'foo' ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideTestForAccountCreation
+ * @param string $msg
+ * @param Status|null $status
+ * @param StatusValue $result
+ */
+ public function testTestForAccountCreation( $msg, $status, $result ) {
+ $this->hook( 'AbortNewAccount', $this->once() )
+ ->will( $this->returnCallback( function ( $user, &$error, &$abortStatus )
+ use ( $msg, $status )
+ {
+ $this->assertInstanceOf( \User::class, $user );
+ $this->assertSame( 'User', $user->getName() );
+ $error = $msg;
+ $abortStatus = $status;
+ return $error === null && $status === null;
+ } ) );
+
+ $user = \User::newFromName( 'User' );
+ $creator = \User::newFromName( 'UTSysop' );
+ $ret = $this->getProvider()->testForAccountCreation( $user, $creator, [] );
+
+ $this->unhook( 'AbortNewAccount' );
+
+ $this->assertEquals( $result, $ret );
+ }
+
+ public static function provideTestForAccountCreation() {
+ return [
+ 'No hook errors' => [
+ null, null, \StatusValue::newGood()
+ ],
+ 'AbortNewAccount, old style' => [
+ 'foobar', null, \StatusValue::newFatal(
+ \Message::newFromKey( 'createaccount-hook-aborted' )->rawParams( 'foobar' )
+ )
+ ],
+ 'AbortNewAccount, new style' => [
+ 'foobar',
+ \Status::newFatal( 'aborted!', 'param' ),
+ \StatusValue::newFatal( 'aborted!', 'param' )
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideTestUserForCreation
+ * @param string|null $error
+ * @param string|null $failMsg
+ */
+ public function testTestUserForCreation( $error, $failMsg ) {
+ $testUser = self::getTestUser()->getUser();
+ $provider = $this->getProvider();
+ $options = [ 'flags' => \User::READ_LOCKING, 'creating' => true ];
+
+ $this->hook( 'AbortNewAccount', $this->never() );
+ $this->hook( 'AbortAutoAccount', $this->once() )
+ ->will( $this->returnCallback( function ( $user, &$abortError ) use ( $testUser, $error ) {
+ $this->assertInstanceOf( \User::class, $user );
+ $this->assertSame( $testUser->getName(), $user->getName() );
+ $abortError = $error;
+ return $error === null;
+ } ) );
+ $status = $provider->testUserForCreation(
+ $testUser, AuthManager::AUTOCREATE_SOURCE_SESSION, $options
+ );
+ $this->unhook( 'AbortNewAccount' );
+ $this->unhook( 'AbortAutoAccount' );
+ if ( $failMsg === null ) {
+ $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' );
+ } else {
+ $this->assertInstanceOf( \StatusValue::class, $status, 'should fail (type)' );
+ $this->assertFalse( $status->isOk(), 'should fail (ok)' );
+ $errors = $status->getErrors();
+ $this->assertEquals( $failMsg, $errors[0]['message'], 'should fail (message)' );
+ }
+
+ $this->hook( 'AbortAutoAccount', $this->never() );
+ $this->hook( 'AbortNewAccount', $this->never() );
+ $status = $provider->testUserForCreation( $testUser, false, $options );
+ $this->unhook( 'AbortNewAccount' );
+ $this->unhook( 'AbortAutoAccount' );
+ $this->assertEquals( \StatusValue::newGood(), $status, 'should succeed' );
+ }
+
+ public static function provideTestUserForCreation() {
+ return [
+ 'Success' => [ null, null ],
+ 'Fail, no message' => [ false, 'login-abort-generic' ],
+ 'Fail, with message' => [ 'fail', 'fail' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php
new file mode 100644
index 00000000..5f370785
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php
@@ -0,0 +1,658 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group AuthManager
+ * @group Database
+ * @covers MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider
+ */
+class LocalPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestCase {
+
+ private $manager = null;
+ private $config = null;
+ private $validity = null;
+
+ /**
+ * Get an instance of the provider
+ *
+ * $provider->checkPasswordValidity is mocked to return $this->validity,
+ * because we don't need to test that here.
+ *
+ * @param bool $loginOnly
+ * @return LocalPasswordPrimaryAuthenticationProvider
+ */
+ protected function getProvider( $loginOnly = false ) {
+ if ( !$this->config ) {
+ $this->config = new \HashConfig();
+ }
+ $config = new \MultiConfig( [
+ $this->config,
+ MediaWikiServices::getInstance()->getMainConfig()
+ ] );
+
+ if ( !$this->manager ) {
+ $this->manager = new AuthManager( new \FauxRequest(), $config );
+ }
+ $this->validity = \Status::newGood();
+
+ $provider = $this->getMockBuilder( LocalPasswordPrimaryAuthenticationProvider::class )
+ ->setMethods( [ 'checkPasswordValidity' ] )
+ ->setConstructorArgs( [ [ 'loginOnly' => $loginOnly ] ] )
+ ->getMock();
+ $provider->expects( $this->any() )->method( 'checkPasswordValidity' )
+ ->will( $this->returnCallback( function () {
+ return $this->validity;
+ } ) );
+ $provider->setConfig( $config );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setManager( $this->manager );
+
+ return $provider;
+ }
+
+ public function testBasics() {
+ $user = $this->getMutableTestUser()->getUser();
+ $userName = $user->getName();
+ $lowerInitialUserName = mb_strtolower( $userName[0] ) . substr( $userName, 1 );
+
+ $provider = new LocalPasswordPrimaryAuthenticationProvider();
+
+ $this->assertSame(
+ PrimaryAuthenticationProvider::TYPE_CREATE,
+ $provider->accountCreationType()
+ );
+
+ $this->assertTrue( $provider->testUserExists( $userName ) );
+ $this->assertTrue( $provider->testUserExists( $lowerInitialUserName ) );
+ $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) );
+ $this->assertFalse( $provider->testUserExists( '<invalid>' ) );
+
+ $provider = new LocalPasswordPrimaryAuthenticationProvider( [ 'loginOnly' => true ] );
+
+ $this->assertSame(
+ PrimaryAuthenticationProvider::TYPE_NONE,
+ $provider->accountCreationType()
+ );
+
+ $this->assertTrue( $provider->testUserExists( $userName ) );
+ $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) );
+
+ $req = new PasswordAuthenticationRequest;
+ $req->action = AuthManager::ACTION_CHANGE;
+ $req->username = '<invalid>';
+ $provider->providerChangeAuthenticationData( $req );
+ }
+
+ public function testTestUserCanAuthenticate() {
+ $user = $this->getMutableTestUser()->getUser();
+ $userName = $user->getName();
+ $dbw = wfGetDB( DB_MASTER );
+
+ $provider = $this->getProvider();
+
+ $this->assertFalse( $provider->testUserCanAuthenticate( '<invalid>' ) );
+
+ $this->assertFalse( $provider->testUserCanAuthenticate( 'DoesNotExist' ) );
+
+ $this->assertTrue( $provider->testUserCanAuthenticate( $userName ) );
+ $lowerInitialUserName = mb_strtolower( $userName[0] ) . substr( $userName, 1 );
+ $this->assertTrue( $provider->testUserCanAuthenticate( $lowerInitialUserName ) );
+
+ $dbw->update(
+ 'user',
+ [ 'user_password' => \PasswordFactory::newInvalidPassword()->toString() ],
+ [ 'user_name' => $userName ]
+ );
+ $this->assertFalse( $provider->testUserCanAuthenticate( $userName ) );
+
+ // Really old format
+ $dbw->update(
+ 'user',
+ [ 'user_password' => '0123456789abcdef0123456789abcdef' ],
+ [ 'user_name' => $userName ]
+ );
+ $this->assertTrue( $provider->testUserCanAuthenticate( $userName ) );
+ }
+
+ public function testSetPasswordResetFlag() {
+ // Set instance vars
+ $this->getProvider();
+
+ /// @todo: Because we're currently using User, which uses the global config...
+ $this->setMwGlobals( [ 'wgPasswordExpireGrace' => 100 ] );
+
+ $this->config->set( 'PasswordExpireGrace', 100 );
+ $this->config->set( 'InvalidPasswordReset', true );
+
+ $provider = new LocalPasswordPrimaryAuthenticationProvider();
+ $provider->setConfig( $this->config );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setManager( $this->manager );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+
+ $user = $this->getMutableTestUser()->getUser();
+ $userName = $user->getName();
+ $dbw = wfGetDB( DB_MASTER );
+ $row = $dbw->selectRow(
+ 'user',
+ '*',
+ [ 'user_name' => $userName ],
+ __METHOD__
+ );
+
+ $this->manager->removeAuthenticationSessionData( null );
+ $row->user_password_expires = wfTimestamp( TS_MW, time() + 200 );
+ $providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row );
+ $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
+
+ $this->manager->removeAuthenticationSessionData( null );
+ $row->user_password_expires = wfTimestamp( TS_MW, time() - 200 );
+ $providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row );
+ $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
+ $this->assertNotNull( $ret );
+ $this->assertSame( 'resetpass-expired', $ret->msg->getKey() );
+ $this->assertTrue( $ret->hard );
+
+ $this->manager->removeAuthenticationSessionData( null );
+ $row->user_password_expires = wfTimestamp( TS_MW, time() - 1 );
+ $providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row );
+ $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
+ $this->assertNotNull( $ret );
+ $this->assertSame( 'resetpass-expired-soft', $ret->msg->getKey() );
+ $this->assertFalse( $ret->hard );
+
+ $this->manager->removeAuthenticationSessionData( null );
+ $row->user_password_expires = null;
+ $status = \Status::newGood();
+ $status->error( 'testing' );
+ $providerPriv->setPasswordResetFlag( $userName, $status, $row );
+ $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
+ $this->assertNotNull( $ret );
+ $this->assertSame( 'resetpass-validity-soft', $ret->msg->getKey() );
+ $this->assertFalse( $ret->hard );
+ }
+
+ public function testAuthentication() {
+ $testUser = $this->getMutableTestUser();
+ $userName = $testUser->getUser()->getName();
+
+ $dbw = wfGetDB( DB_MASTER );
+ $id = \User::idFromName( $userName );
+
+ $req = new PasswordAuthenticationRequest();
+ $req->action = AuthManager::ACTION_LOGIN;
+ $reqs = [ PasswordAuthenticationRequest::class => $req ];
+
+ $provider = $this->getProvider();
+
+ // General failures
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( [] )
+ );
+
+ $req->username = 'foo';
+ $req->password = null;
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $req->username = null;
+ $req->password = 'bar';
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $req->username = '<invalid>';
+ $req->password = 'WhoCares';
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $req->username = 'DoesNotExist';
+ $req->password = 'DoesNotExist';
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals(
+ AuthenticationResponse::FAIL,
+ $ret->status
+ );
+ $this->assertEquals(
+ 'wrongpassword',
+ $ret->message->getKey()
+ );
+
+ // Validation failure
+ $req->username = $userName;
+ $req->password = $testUser->getPassword();
+ $this->validity = \Status::newFatal( 'arbitrary-failure' );
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals(
+ AuthenticationResponse::FAIL,
+ $ret->status
+ );
+ $this->assertEquals(
+ 'arbitrary-failure',
+ $ret->message->getKey()
+ );
+
+ // Successful auth
+ $this->manager->removeAuthenticationSessionData( null );
+ $this->validity = \Status::newGood();
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $userName ),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+ $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
+
+ // Successful auth after normalizing name
+ $this->manager->removeAuthenticationSessionData( null );
+ $this->validity = \Status::newGood();
+ $req->username = mb_strtolower( $userName[0] ) . substr( $userName, 1 );
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $userName ),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+ $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
+ $req->username = $userName;
+
+ // Successful auth with reset
+ $this->manager->removeAuthenticationSessionData( null );
+ $this->validity->error( 'arbitrary-warning' );
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $userName ),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+ $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
+
+ // Wrong password
+ $this->validity = \Status::newGood();
+ $req->password = 'Wrong';
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals(
+ AuthenticationResponse::FAIL,
+ $ret->status
+ );
+ $this->assertEquals(
+ 'wrongpassword',
+ $ret->message->getKey()
+ );
+
+ // Correct handling of legacy encodings
+ $password = ':B:salt:' . md5( 'salt-' . md5( "\xe1\xe9\xed\xf3\xfa" ) );
+ $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] );
+ $req->password = 'áéíóú';
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals(
+ AuthenticationResponse::FAIL,
+ $ret->status
+ );
+ $this->assertEquals(
+ 'wrongpassword',
+ $ret->message->getKey()
+ );
+
+ $this->config->set( 'LegacyEncoding', true );
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $userName ),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $req->password = 'áéíóú Wrong';
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals(
+ AuthenticationResponse::FAIL,
+ $ret->status
+ );
+ $this->assertEquals(
+ 'wrongpassword',
+ $ret->message->getKey()
+ );
+
+ // Correct handling of really old password hashes
+ $this->config->set( 'PasswordSalt', false );
+ $password = md5( 'FooBar' );
+ $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] );
+ $req->password = 'FooBar';
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $userName ),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $this->config->set( 'PasswordSalt', true );
+ $password = md5( "$id-" . md5( 'FooBar' ) );
+ $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] );
+ $req->password = 'FooBar';
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $userName ),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+ }
+
+ /**
+ * @dataProvider provideProviderAllowsAuthenticationDataChange
+ * @param string $type
+ * @param string $user
+ * @param \Status $validity Result of the password validity check
+ * @param \StatusValue $expect1 Expected result with $checkData = false
+ * @param \StatusValue $expect2 Expected result with $checkData = true
+ */
+ public function testProviderAllowsAuthenticationDataChange( $type, $user, \Status $validity,
+ \StatusValue $expect1, \StatusValue $expect2
+ ) {
+ if ( $type === PasswordAuthenticationRequest::class ) {
+ $req = new $type();
+ } elseif ( $type === PasswordDomainAuthenticationRequest::class ) {
+ $req = new $type( [] );
+ } else {
+ $req = $this->createMock( $type );
+ }
+ $req->action = AuthManager::ACTION_CHANGE;
+ $req->username = $user;
+ $req->password = 'NewPassword';
+ $req->retype = 'NewPassword';
+
+ $provider = $this->getProvider();
+ $this->validity = $validity;
+ $this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) );
+ $this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) );
+
+ $req->retype = 'BadRetype';
+ $this->assertEquals(
+ $expect1,
+ $provider->providerAllowsAuthenticationDataChange( $req, false )
+ );
+ $this->assertEquals(
+ $expect2->getValue() === 'ignored' ? $expect2 : \StatusValue::newFatal( 'badretype' ),
+ $provider->providerAllowsAuthenticationDataChange( $req, true )
+ );
+
+ $provider = $this->getProvider( true );
+ $this->assertEquals(
+ \StatusValue::newGood( 'ignored' ),
+ $provider->providerAllowsAuthenticationDataChange( $req, true ),
+ 'loginOnly mode should claim to ignore all changes'
+ );
+ }
+
+ public static function provideProviderAllowsAuthenticationDataChange() {
+ $err = \StatusValue::newGood();
+ $err->error( 'arbitrary-warning' );
+
+ return [
+ [ AuthenticationRequest::class, 'UTSysop', \Status::newGood(),
+ \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ],
+ [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(),
+ \StatusValue::newGood(), \StatusValue::newGood() ],
+ [ PasswordAuthenticationRequest::class, 'uTSysop', \Status::newGood(),
+ \StatusValue::newGood(), \StatusValue::newGood() ],
+ [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::wrap( $err ),
+ \StatusValue::newGood(), $err ],
+ [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newFatal( 'arbitrary-error' ),
+ \StatusValue::newGood(), \StatusValue::newFatal( 'arbitrary-error' ) ],
+ [ PasswordAuthenticationRequest::class, 'DoesNotExist', \Status::newGood(),
+ \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ],
+ [ PasswordDomainAuthenticationRequest::class, 'UTSysop', \Status::newGood(),
+ \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideProviderChangeAuthenticationData
+ * @param callable|bool $usernameTransform
+ * @param string $type
+ * @param bool $loginOnly
+ * @param bool $changed
+ */
+ public function testProviderChangeAuthenticationData(
+ $usernameTransform, $type, $loginOnly, $changed ) {
+ $testUser = $this->getMutableTestUser();
+ $user = $testUser->getUser()->getName();
+ if ( is_callable( $usernameTransform ) ) {
+ $user = call_user_func( $usernameTransform, $user );
+ }
+ $cuser = ucfirst( $user );
+ $oldpass = $testUser->getPassword();
+ $newpass = 'NewPassword';
+
+ $dbw = wfGetDB( DB_MASTER );
+ $oldExpiry = $dbw->selectField( 'user', 'user_password_expires', [ 'user_name' => $cuser ] );
+
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'ResetPasswordExpiration' => [ function ( $user, &$expires ) {
+ $expires = '30001231235959';
+ } ]
+ ] );
+
+ $provider = $this->getProvider( $loginOnly );
+
+ // Sanity check
+ $loginReq = new PasswordAuthenticationRequest();
+ $loginReq->action = AuthManager::ACTION_LOGIN;
+ $loginReq->username = $user;
+ $loginReq->password = $oldpass;
+ $loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ];
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $cuser ),
+ $provider->beginPrimaryAuthentication( $loginReqs ),
+ 'Sanity check'
+ );
+
+ if ( $type === PasswordAuthenticationRequest::class ) {
+ $changeReq = new $type();
+ } else {
+ $changeReq = $this->createMock( $type );
+ }
+ $changeReq->action = AuthManager::ACTION_CHANGE;
+ $changeReq->username = $user;
+ $changeReq->password = $newpass;
+ $provider->providerChangeAuthenticationData( $changeReq );
+
+ if ( $loginOnly && $changed ) {
+ $old = 'fail';
+ $new = 'fail';
+ $expectExpiry = null;
+ } elseif ( $changed ) {
+ $old = 'fail';
+ $new = 'pass';
+ $expectExpiry = '30001231235959';
+ } else {
+ $old = 'pass';
+ $new = 'fail';
+ $expectExpiry = $oldExpiry;
+ }
+
+ $loginReq->password = $oldpass;
+ $ret = $provider->beginPrimaryAuthentication( $loginReqs );
+ if ( $old === 'pass' ) {
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $cuser ),
+ $ret,
+ 'old password should pass'
+ );
+ } else {
+ $this->assertEquals(
+ AuthenticationResponse::FAIL,
+ $ret->status,
+ 'old password should fail'
+ );
+ $this->assertEquals(
+ 'wrongpassword',
+ $ret->message->getKey(),
+ 'old password should fail'
+ );
+ }
+
+ $loginReq->password = $newpass;
+ $ret = $provider->beginPrimaryAuthentication( $loginReqs );
+ if ( $new === 'pass' ) {
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $cuser ),
+ $ret,
+ 'new password should pass'
+ );
+ } else {
+ $this->assertEquals(
+ AuthenticationResponse::FAIL,
+ $ret->status,
+ 'new password should fail'
+ );
+ $this->assertEquals(
+ 'wrongpassword',
+ $ret->message->getKey(),
+ 'new password should fail'
+ );
+ }
+
+ $this->assertSame(
+ $expectExpiry,
+ wfTimestampOrNull(
+ TS_MW,
+ $dbw->selectField( 'user', 'user_password_expires', [ 'user_name' => $cuser ] )
+ )
+ );
+ }
+
+ public static function provideProviderChangeAuthenticationData() {
+ return [
+ [ false, AuthenticationRequest::class, false, false ],
+ [ false, PasswordAuthenticationRequest::class, false, true ],
+ [ false, AuthenticationRequest::class, true, false ],
+ [ false, PasswordAuthenticationRequest::class, true, true ],
+ [ 'ucfirst', PasswordAuthenticationRequest::class, false, true ],
+ [ 'ucfirst', PasswordAuthenticationRequest::class, true, true ],
+ ];
+ }
+
+ public function testTestForAccountCreation() {
+ $user = \User::newFromName( 'foo' );
+ $req = new PasswordAuthenticationRequest();
+ $req->action = AuthManager::ACTION_CREATE;
+ $req->username = 'Foo';
+ $req->password = 'Bar';
+ $req->retype = 'Bar';
+ $reqs = [ PasswordAuthenticationRequest::class => $req ];
+
+ $provider = $this->getProvider();
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAccountCreation( $user, $user, [] ),
+ 'No password request'
+ );
+
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAccountCreation( $user, $user, $reqs ),
+ 'Password request, validated'
+ );
+
+ $req->retype = 'Baz';
+ $this->assertEquals(
+ \StatusValue::newFatal( 'badretype' ),
+ $provider->testForAccountCreation( $user, $user, $reqs ),
+ 'Password request, bad retype'
+ );
+ $req->retype = 'Bar';
+
+ $this->validity->error( 'arbitrary warning' );
+ $expect = \StatusValue::newGood();
+ $expect->error( 'arbitrary warning' );
+ $this->assertEquals(
+ $expect,
+ $provider->testForAccountCreation( $user, $user, $reqs ),
+ 'Password request, not validated'
+ );
+
+ $provider = $this->getProvider( true );
+ $this->validity->error( 'arbitrary warning' );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAccountCreation( $user, $user, $reqs ),
+ 'Password request, not validated, loginOnly'
+ );
+ }
+
+ public function testAccountCreation() {
+ $user = \User::newFromName( 'Foo' );
+
+ $req = new PasswordAuthenticationRequest();
+ $req->action = AuthManager::ACTION_CREATE;
+ $reqs = [ PasswordAuthenticationRequest::class => $req ];
+
+ $provider = $this->getProvider( true );
+ try {
+ $provider->beginPrimaryAccountCreation( $user, $user, [] );
+ $this->fail( 'Expected exception was not thrown' );
+ } catch ( \BadMethodCallException $ex ) {
+ $this->assertSame(
+ 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage()
+ );
+ }
+
+ try {
+ $provider->finishAccountCreation( $user, $user, AuthenticationResponse::newPass() );
+ $this->fail( 'Expected exception was not thrown' );
+ } catch ( \BadMethodCallException $ex ) {
+ $this->assertSame(
+ 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage()
+ );
+ }
+
+ $provider = $this->getProvider( false );
+
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAccountCreation( $user, $user, [] )
+ );
+
+ $req->username = 'foo';
+ $req->password = null;
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAccountCreation( $user, $user, $reqs )
+ );
+
+ $req->username = null;
+ $req->password = 'bar';
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAccountCreation( $user, $user, $reqs )
+ );
+
+ $req->username = 'foo';
+ $req->password = 'bar';
+
+ $expect = AuthenticationResponse::newPass( 'Foo' );
+ $expect->createRequest = clone $req;
+ $expect->createRequest->username = 'Foo';
+ $this->assertEquals( $expect, $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) );
+
+ // We have to cheat a bit to avoid having to add a new user to
+ // the database to test the actual setting of the password works right
+ $dbw = wfGetDB( DB_MASTER );
+
+ $user = \User::newFromName( 'UTSysop' );
+ $req->username = $user->getName();
+ $req->password = 'NewPassword';
+ $expect = AuthenticationResponse::newPass( 'UTSysop' );
+ $expect->createRequest = $req;
+
+ $res2 = $provider->beginPrimaryAccountCreation( $user, $user, $reqs );
+ $this->assertEquals( $expect, $res2, 'Sanity check' );
+
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals( AuthenticationResponse::FAIL, $ret->status, 'sanity check' );
+
+ $this->assertNull( $provider->finishAccountCreation( $user, $user, $res2 ) );
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals( AuthenticationResponse::PASS, $ret->status, 'new password is set' );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php
new file mode 100644
index 00000000..1ef675b6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/PasswordAuthenticationRequestTest.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\PasswordAuthenticationRequest
+ */
+class PasswordAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+ protected function getInstance( array $args = [] ) {
+ $ret = new PasswordAuthenticationRequest();
+ $ret->action = $args[0];
+ return $ret;
+ }
+
+ public static function provideGetFieldInfo() {
+ return [
+ [ [ AuthManager::ACTION_LOGIN ] ],
+ [ [ AuthManager::ACTION_CREATE ] ],
+ [ [ AuthManager::ACTION_CHANGE ] ],
+ [ [ AuthManager::ACTION_REMOVE ] ],
+ ];
+ }
+
+ public function testGetFieldInfo2() {
+ $info = [];
+ foreach ( [
+ AuthManager::ACTION_LOGIN,
+ AuthManager::ACTION_CREATE,
+ AuthManager::ACTION_CHANGE,
+ AuthManager::ACTION_REMOVE,
+ ] as $action ) {
+ $req = new PasswordAuthenticationRequest();
+ $req->action = $action;
+ $info[$action] = $req->getFieldInfo();
+ }
+
+ $this->assertSame( [], $info[AuthManager::ACTION_REMOVE], 'No data needed to remove' );
+
+ $this->assertArrayNotHasKey( 'retype', $info[AuthManager::ACTION_LOGIN],
+ 'No need to retype password on login' );
+ $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CREATE],
+ 'Need to retype when creating new password' );
+ $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CHANGE],
+ 'Need to retype when changing password' );
+
+ $this->assertNotEquals(
+ $info[AuthManager::ACTION_LOGIN]['password']['label'],
+ $info[AuthManager::ACTION_CHANGE]['password']['label'],
+ 'Password field for change is differentiated from login'
+ );
+ $this->assertNotEquals(
+ $info[AuthManager::ACTION_CREATE]['password']['label'],
+ $info[AuthManager::ACTION_CHANGE]['password']['label'],
+ 'Password field for change is differentiated from create'
+ );
+ $this->assertNotEquals(
+ $info[AuthManager::ACTION_CREATE]['retype']['label'],
+ $info[AuthManager::ACTION_CHANGE]['retype']['label'],
+ 'Retype field for change is differentiated from create'
+ );
+ }
+
+ public function provideLoadFromSubmission() {
+ return [
+ 'Empty request, login' => [
+ [ AuthManager::ACTION_LOGIN ],
+ [],
+ false,
+ ],
+ 'Empty request, change' => [
+ [ AuthManager::ACTION_CHANGE ],
+ [],
+ false,
+ ],
+ 'Empty request, remove' => [
+ [ AuthManager::ACTION_REMOVE ],
+ [],
+ false,
+ ],
+ 'Username + password, login' => [
+ [ AuthManager::ACTION_LOGIN ],
+ $data = [ 'username' => 'User', 'password' => 'Bar' ],
+ $data + [ 'action' => AuthManager::ACTION_LOGIN ],
+ ],
+ 'Username + password, change' => [
+ [ AuthManager::ACTION_CHANGE ],
+ [ 'username' => 'User', 'password' => 'Bar' ],
+ false,
+ ],
+ 'Username + password + retype' => [
+ [ AuthManager::ACTION_CHANGE ],
+ [ 'username' => 'User', 'password' => 'Bar', 'retype' => 'baz' ],
+ [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ],
+ ],
+ 'Username empty, login' => [
+ [ AuthManager::ACTION_LOGIN ],
+ [ 'username' => '', 'password' => 'Bar' ],
+ false,
+ ],
+ 'Username empty, change' => [
+ [ AuthManager::ACTION_CHANGE ],
+ [ 'username' => '', 'password' => 'Bar', 'retype' => 'baz' ],
+ [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ],
+ ],
+ 'Password empty, login' => [
+ [ AuthManager::ACTION_LOGIN ],
+ [ 'username' => 'User', 'password' => '' ],
+ false,
+ ],
+ 'Password empty, login, with retype' => [
+ [ AuthManager::ACTION_LOGIN ],
+ [ 'username' => 'User', 'password' => '', 'retype' => 'baz' ],
+ false,
+ ],
+ 'Retype empty' => [
+ [ AuthManager::ACTION_CHANGE ],
+ [ 'username' => 'User', 'password' => 'Bar', 'retype' => '' ],
+ false,
+ ],
+ ];
+ }
+
+ public function testDescribeCredentials() {
+ $req = new PasswordAuthenticationRequest;
+ $req->action = AuthManager::ACTION_LOGIN;
+ $req->username = 'UTSysop';
+ $ret = $req->describeCredentials();
+ $this->assertInternalType( 'array', $ret );
+ $this->assertArrayHasKey( 'provider', $ret );
+ $this->assertInstanceOf( \Message::class, $ret['provider'] );
+ $this->assertSame( 'authmanager-provider-password', $ret['provider']->getKey() );
+ $this->assertArrayHasKey( 'account', $ret );
+ $this->assertInstanceOf( \Message::class, $ret['account'] );
+ $this->assertSame( [ 'UTSysop' ], $ret['account']->getParams() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php
new file mode 100644
index 00000000..36be4243
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/PasswordDomainAuthenticationRequestTest.php
@@ -0,0 +1,159 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\PasswordDomainAuthenticationRequest
+ */
+class PasswordDomainAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+ protected function getInstance( array $args = [] ) {
+ $ret = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] );
+ $ret->action = $args[0];
+ return $ret;
+ }
+
+ public static function provideGetFieldInfo() {
+ return [
+ [ [ AuthManager::ACTION_LOGIN ] ],
+ [ [ AuthManager::ACTION_CREATE ] ],
+ [ [ AuthManager::ACTION_CHANGE ] ],
+ [ [ AuthManager::ACTION_REMOVE ] ],
+ ];
+ }
+
+ public function testGetFieldInfo2() {
+ $info = [];
+ foreach ( [
+ AuthManager::ACTION_LOGIN,
+ AuthManager::ACTION_CREATE,
+ AuthManager::ACTION_CHANGE,
+ AuthManager::ACTION_REMOVE,
+ ] as $action ) {
+ $req = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] );
+ $req->action = $action;
+ $info[$action] = $req->getFieldInfo();
+ }
+
+ $this->assertSame( [], $info[AuthManager::ACTION_REMOVE], 'No data needed to remove' );
+
+ $this->assertArrayNotHasKey( 'retype', $info[AuthManager::ACTION_LOGIN],
+ 'No need to retype password on login' );
+ $this->assertArrayHasKey( 'domain', $info[AuthManager::ACTION_LOGIN],
+ 'Domain needed on login' );
+ $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CREATE],
+ 'Need to retype when creating new password' );
+ $this->assertArrayHasKey( 'domain', $info[AuthManager::ACTION_CREATE],
+ 'Domain needed on account creation' );
+ $this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CHANGE],
+ 'Need to retype when changing password' );
+ $this->assertArrayNotHasKey( 'domain', $info[AuthManager::ACTION_CHANGE],
+ 'Domain not needed on account creation' );
+
+ $this->assertNotEquals(
+ $info[AuthManager::ACTION_LOGIN]['password']['label'],
+ $info[AuthManager::ACTION_CHANGE]['password']['label'],
+ 'Password field for change is differentiated from login'
+ );
+ $this->assertNotEquals(
+ $info[AuthManager::ACTION_CREATE]['password']['label'],
+ $info[AuthManager::ACTION_CHANGE]['password']['label'],
+ 'Password field for change is differentiated from create'
+ );
+ $this->assertNotEquals(
+ $info[AuthManager::ACTION_CREATE]['retype']['label'],
+ $info[AuthManager::ACTION_CHANGE]['retype']['label'],
+ 'Retype field for change is differentiated from create'
+ );
+ }
+
+ public function provideLoadFromSubmission() {
+ $domainList = [ 'domainList' => [ 'd1', 'd2' ] ];
+ return [
+ 'Empty request, login' => [
+ [ AuthManager::ACTION_LOGIN ],
+ [],
+ false,
+ ],
+ 'Empty request, change' => [
+ [ AuthManager::ACTION_CHANGE ],
+ [],
+ false,
+ ],
+ 'Empty request, remove' => [
+ [ AuthManager::ACTION_REMOVE ],
+ [],
+ false,
+ ],
+ 'Username + password, login' => [
+ [ AuthManager::ACTION_LOGIN ],
+ $data = [ 'username' => 'User', 'password' => 'Bar' ],
+ false,
+ ],
+ 'Username + password + domain, login' => [
+ [ AuthManager::ACTION_LOGIN ],
+ $data = [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd1' ],
+ $data + [ 'action' => AuthManager::ACTION_LOGIN ] + $domainList,
+ ],
+ 'Username + password + bad domain, login' => [
+ [ AuthManager::ACTION_LOGIN ],
+ $data = [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd5' ],
+ false,
+ ],
+ 'Username + password + domain, change' => [
+ [ AuthManager::ACTION_CHANGE ],
+ [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd1' ],
+ false,
+ ],
+ 'Username + password + domain + retype' => [
+ [ AuthManager::ACTION_CHANGE ],
+ [ 'username' => 'User', 'password' => 'Bar', 'retype' => 'baz', 'domain' => 'd1' ],
+ [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ] +
+ $domainList,
+ ],
+ 'Username empty, login' => [
+ [ AuthManager::ACTION_LOGIN ],
+ [ 'username' => '', 'password' => 'Bar', 'domain' => 'd1' ],
+ false,
+ ],
+ 'Username empty, change' => [
+ [ AuthManager::ACTION_CHANGE ],
+ [ 'username' => '', 'password' => 'Bar', 'retype' => 'baz', 'domain' => 'd1' ],
+ [ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ] +
+ $domainList,
+ ],
+ 'Password empty, login' => [
+ [ AuthManager::ACTION_LOGIN ],
+ [ 'username' => 'User', 'password' => '', 'domain' => 'd1' ],
+ false,
+ ],
+ 'Password empty, login, with retype' => [
+ [ AuthManager::ACTION_LOGIN ],
+ [ 'username' => 'User', 'password' => '', 'retype' => 'baz', 'domain' => 'd1' ],
+ false,
+ ],
+ 'Retype empty' => [
+ [ AuthManager::ACTION_CHANGE ],
+ [ 'username' => 'User', 'password' => 'Bar', 'retype' => '', 'domain' => 'd1' ],
+ false,
+ ],
+ ];
+ }
+
+ public function testDescribeCredentials() {
+ $req = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] );
+ $req->action = AuthManager::ACTION_LOGIN;
+ $req->username = 'UTSysop';
+ $req->domain = 'd2';
+ $ret = $req->describeCredentials();
+ $this->assertInternalType( 'array', $ret );
+ $this->assertArrayHasKey( 'provider', $ret );
+ $this->assertInstanceOf( \Message::class, $ret['provider'] );
+ $this->assertSame( 'authmanager-provider-password-domain', $ret['provider']->getKey() );
+ $this->assertArrayHasKey( 'account', $ret );
+ $this->assertInstanceOf( \Message::class, $ret['account'] );
+ $this->assertSame( 'authmanager-account-password-domain', $ret['account']->getKey() );
+ $this->assertSame( [ 'UTSysop', 'd2' ], $ret['account']->getParams() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php
new file mode 100644
index 00000000..9bcab777
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/RememberMeAuthenticationRequestTest.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\RememberMeAuthenticationRequest
+ */
+class RememberMeAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+ public static function provideGetFieldInfo() {
+ return [
+ [ [ 1 ] ],
+ [ [ null ] ],
+ ];
+ }
+
+ public function testGetFieldInfo_2() {
+ $req = new RememberMeAuthenticationRequest();
+ $reqWrapper = TestingAccessWrapper::newFromObject( $req );
+
+ $reqWrapper->expiration = 30 * 24 * 3600;
+ $this->assertNotEmpty( $req->getFieldInfo() );
+
+ $reqWrapper->expiration = null;
+ $this->assertEmpty( $req->getFieldInfo() );
+ }
+
+ protected function getInstance( array $args = [] ) {
+ $req = new RememberMeAuthenticationRequest();
+ $reqWrapper = TestingAccessWrapper::newFromObject( $req );
+ $reqWrapper->expiration = $args[0];
+ return $req;
+ }
+
+ public function provideLoadFromSubmission() {
+ return [
+ 'Empty request' => [
+ [ 30 * 24 * 3600 ],
+ [],
+ [ 'expiration' => 30 * 24 * 3600, 'rememberMe' => false ]
+ ],
+ 'RememberMe present' => [
+ [ 30 * 24 * 3600 ],
+ [ 'rememberMe' => '' ],
+ [ 'expiration' => 30 * 24 * 3600, 'rememberMe' => true ]
+ ],
+ 'RememberMe present but session provider cannot remember' => [
+ [ null ],
+ [ 'rememberMe' => '' ],
+ false
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php
new file mode 100644
index 00000000..f454a96a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/ResetPasswordSecondaryAuthenticationProviderTest.php
@@ -0,0 +1,310 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\ResetPasswordSecondaryAuthenticationProvider
+ */
+class ResetPasswordSecondaryAuthenticationProviderTest extends \MediaWikiTestCase {
+ /**
+ * @dataProvider provideGetAuthenticationRequests
+ * @param string $action
+ * @param array $response
+ */
+ public function testGetAuthenticationRequests( $action, $response ) {
+ $provider = new ResetPasswordSecondaryAuthenticationProvider();
+
+ $this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) );
+ }
+
+ public static function provideGetAuthenticationRequests() {
+ return [
+ [ AuthManager::ACTION_LOGIN, [] ],
+ [ AuthManager::ACTION_CREATE, [] ],
+ [ AuthManager::ACTION_LINK, [] ],
+ [ AuthManager::ACTION_CHANGE, [] ],
+ [ AuthManager::ACTION_REMOVE, [] ],
+ ];
+ }
+
+ public function testBasics() {
+ $user = \User::newFromName( 'UTSysop' );
+ $user2 = new \User;
+ $obj = new \stdClass;
+ $reqs = [ new \stdClass ];
+
+ $mb = $this->getMockBuilder( ResetPasswordSecondaryAuthenticationProvider::class )
+ ->setMethods( [ 'tryReset' ] );
+
+ $methods = [
+ 'beginSecondaryAuthentication' => [ $user, $reqs ],
+ 'continueSecondaryAuthentication' => [ $user, $reqs ],
+ 'beginSecondaryAccountCreation' => [ $user, $user2, $reqs ],
+ 'continueSecondaryAccountCreation' => [ $user, $user2, $reqs ],
+ ];
+ foreach ( $methods as $method => $args ) {
+ $mock = $mb->getMock();
+ $mock->expects( $this->once() )->method( 'tryReset' )
+ ->with( $this->identicalTo( $user ), $this->identicalTo( $reqs ) )
+ ->will( $this->returnValue( $obj ) );
+ $this->assertSame( $obj, call_user_func_array( [ $mock, $method ], $args ) );
+ }
+ }
+
+ public function testTryReset() {
+ $user = \User::newFromName( 'UTSysop' );
+
+ $provider = $this->getMockBuilder(
+ ResetPasswordSecondaryAuthenticationProvider::class
+ )
+ ->setMethods( [
+ 'providerAllowsAuthenticationDataChange', 'providerChangeAuthenticationData'
+ ] )
+ ->getMock();
+ $provider->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' )
+ ->will( $this->returnCallback( function ( $req ) {
+ $this->assertSame( 'UTSysop', $req->username );
+ return $req->allow;
+ } ) );
+ $provider->expects( $this->any() )->method( 'providerChangeAuthenticationData' )
+ ->will( $this->returnCallback( function ( $req ) {
+ $this->assertSame( 'UTSysop', $req->username );
+ $req->done = true;
+ } ) );
+ $config = new \HashConfig( [
+ 'AuthManagerConfig' => [
+ 'preauth' => [],
+ 'primaryauth' => [],
+ 'secondaryauth' => [
+ [ 'factory' => function () use ( $provider ) {
+ return $provider;
+ } ],
+ ],
+ ],
+ ] );
+ $manager = new AuthManager( new \FauxRequest, $config );
+ $provider->setManager( $manager );
+ $provider = TestingAccessWrapper::newFromObject( $provider );
+
+ $msg = wfMessage( 'foo' );
+ $skipReq = new ButtonAuthenticationRequest(
+ 'skipReset',
+ wfMessage( 'authprovider-resetpass-skip-label' ),
+ wfMessage( 'authprovider-resetpass-skip-help' )
+ );
+ $passReq = new PasswordAuthenticationRequest();
+ $passReq->action = AuthManager::ACTION_CHANGE;
+ $passReq->password = 'Foo';
+ $passReq->retype = 'Bar';
+ $passReq->allow = \StatusValue::newGood();
+ $passReq->done = false;
+
+ $passReq2 = $this->getMockBuilder( PasswordAuthenticationRequest::class )
+ ->enableProxyingToOriginalMethods()
+ ->getMock();
+ $passReq2->action = AuthManager::ACTION_CHANGE;
+ $passReq2->password = 'Foo';
+ $passReq2->retype = 'Foo';
+ $passReq2->allow = \StatusValue::newGood();
+ $passReq2->done = false;
+
+ $passReq3 = new PasswordAuthenticationRequest();
+ $passReq3->action = AuthManager::ACTION_LOGIN;
+ $passReq3->password = 'Foo';
+ $passReq3->retype = 'Foo';
+ $passReq3->allow = \StatusValue::newGood();
+ $passReq3->done = false;
+
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->tryReset( $user, [] )
+ );
+
+ $manager->setAuthenticationSessionData( 'reset-pass', 'foo' );
+ try {
+ $provider->tryReset( $user, [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame( 'reset-pass is not valid', $ex->getMessage() );
+ }
+
+ $manager->setAuthenticationSessionData( 'reset-pass', (object)[] );
+ try {
+ $provider->tryReset( $user, [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame( 'reset-pass msg is missing', $ex->getMessage() );
+ }
+
+ $manager->setAuthenticationSessionData( 'reset-pass', [
+ 'msg' => 'foo',
+ ] );
+ try {
+ $provider->tryReset( $user, [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame( 'reset-pass msg is not valid', $ex->getMessage() );
+ }
+
+ $manager->setAuthenticationSessionData( 'reset-pass', [
+ 'msg' => $msg,
+ ] );
+ try {
+ $provider->tryReset( $user, [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame( 'reset-pass hard is missing', $ex->getMessage() );
+ }
+
+ $manager->setAuthenticationSessionData( 'reset-pass', [
+ 'msg' => $msg,
+ 'hard' => true,
+ 'req' => 'foo',
+ ] );
+ try {
+ $provider->tryReset( $user, [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame( 'reset-pass req is not valid', $ex->getMessage() );
+ }
+
+ $manager->setAuthenticationSessionData( 'reset-pass', [
+ 'msg' => $msg,
+ 'hard' => false,
+ 'req' => $passReq3,
+ ] );
+ try {
+ $provider->tryReset( $user, [ $passReq ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame( 'reset-pass req is not valid', $ex->getMessage() );
+ }
+
+ $manager->setAuthenticationSessionData( 'reset-pass', [
+ 'msg' => $msg,
+ 'hard' => true,
+ ] );
+ $res = $provider->tryReset( $user, [] );
+ $this->assertInstanceOf( AuthenticationResponse::class, $res );
+ $this->assertSame( AuthenticationResponse::UI, $res->status );
+ $this->assertEquals( $msg, $res->message );
+ $this->assertCount( 1, $res->neededRequests );
+ $this->assertInstanceOf(
+ PasswordAuthenticationRequest::class,
+ $res->neededRequests[0]
+ );
+ $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
+ $this->assertFalse( $passReq->done );
+
+ $manager->setAuthenticationSessionData( 'reset-pass', [
+ 'msg' => $msg,
+ 'hard' => false,
+ 'req' => $passReq,
+ ] );
+ $res = $provider->tryReset( $user, [] );
+ $this->assertInstanceOf( AuthenticationResponse::class, $res );
+ $this->assertSame( AuthenticationResponse::UI, $res->status );
+ $this->assertEquals( $msg, $res->message );
+ $this->assertCount( 2, $res->neededRequests );
+ $expectedPassReq = clone $passReq;
+ $expectedPassReq->required = AuthenticationRequest::OPTIONAL;
+ $this->assertEquals( $expectedPassReq, $res->neededRequests[0] );
+ $this->assertEquals( $skipReq, $res->neededRequests[1] );
+ $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
+ $this->assertFalse( $passReq->done );
+
+ $passReq->retype = 'Bad';
+ $manager->setAuthenticationSessionData( 'reset-pass', [
+ 'msg' => $msg,
+ 'hard' => false,
+ 'req' => $passReq,
+ ] );
+ $res = $provider->tryReset( $user, [ $skipReq, $passReq ] );
+ $this->assertEquals( AuthenticationResponse::newPass(), $res );
+ $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
+ $this->assertFalse( $passReq->done );
+
+ $passReq->retype = 'Bad';
+ $manager->setAuthenticationSessionData( 'reset-pass', [
+ 'msg' => $msg,
+ 'hard' => true,
+ ] );
+ $res = $provider->tryReset( $user, [ $skipReq, $passReq ] );
+ $this->assertSame( AuthenticationResponse::UI, $res->status );
+ $this->assertSame( 'badretype', $res->message->getKey() );
+ $this->assertCount( 1, $res->neededRequests );
+ $this->assertInstanceOf(
+ PasswordAuthenticationRequest::class,
+ $res->neededRequests[0]
+ );
+ $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
+ $this->assertFalse( $passReq->done );
+
+ $manager->setAuthenticationSessionData( 'reset-pass', [
+ 'msg' => $msg,
+ 'hard' => true,
+ ] );
+ $res = $provider->tryReset( $user, [ $skipReq, $passReq3 ] );
+ $this->assertSame( AuthenticationResponse::UI, $res->status );
+ $this->assertEquals( $msg, $res->message );
+ $this->assertCount( 1, $res->neededRequests );
+ $this->assertInstanceOf(
+ PasswordAuthenticationRequest::class,
+ $res->neededRequests[0]
+ );
+ $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
+ $this->assertFalse( $passReq->done );
+
+ $passReq->retype = $passReq->password;
+ $passReq->allow = \StatusValue::newFatal( 'arbitrary-fail' );
+ $res = $provider->tryReset( $user, [ $skipReq, $passReq ] );
+ $this->assertSame( AuthenticationResponse::UI, $res->status );
+ $this->assertSame( 'arbitrary-fail', $res->message->getKey() );
+ $this->assertCount( 1, $res->neededRequests );
+ $this->assertInstanceOf(
+ PasswordAuthenticationRequest::class,
+ $res->neededRequests[0]
+ );
+ $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
+ $this->assertFalse( $passReq->done );
+
+ $passReq->allow = \StatusValue::newGood();
+ $res = $provider->tryReset( $user, [ $skipReq, $passReq ] );
+ $this->assertEquals( AuthenticationResponse::newPass(), $res );
+ $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
+ $this->assertTrue( $passReq->done );
+
+ $manager->setAuthenticationSessionData( 'reset-pass', [
+ 'msg' => $msg,
+ 'hard' => false,
+ 'req' => $passReq2,
+ ] );
+ $res = $provider->tryReset( $user, [ $passReq2 ] );
+ $this->assertEquals( AuthenticationResponse::newPass(), $res );
+ $this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
+ $this->assertTrue( $passReq2->done );
+
+ $passReq->done = false;
+ $passReq2->done = false;
+ $manager->setAuthenticationSessionData( 'reset-pass', [
+ 'msg' => $msg,
+ 'hard' => false,
+ 'req' => $passReq2,
+ ] );
+ $res = $provider->tryReset( $user, [ $passReq ] );
+ $this->assertInstanceOf( AuthenticationResponse::class, $res );
+ $this->assertSame( AuthenticationResponse::UI, $res->status );
+ $this->assertEquals( $msg, $res->message );
+ $this->assertCount( 2, $res->neededRequests );
+ $expectedPassReq = clone $passReq2;
+ $expectedPassReq->required = AuthenticationRequest::OPTIONAL;
+ $this->assertEquals( $expectedPassReq, $res->neededRequests[0] );
+ $this->assertEquals( $skipReq, $res->neededRequests[1] );
+ $this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
+ $this->assertFalse( $passReq->done );
+ $this->assertFalse( $passReq2->done );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php
new file mode 100644
index 00000000..ab4a174e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/TemporaryPasswordAuthenticationRequestTest.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\TemporaryPasswordAuthenticationRequest
+ */
+class TemporaryPasswordAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+ protected function getInstance( array $args = [] ) {
+ $ret = new TemporaryPasswordAuthenticationRequest;
+ $ret->action = $args[0];
+ return $ret;
+ }
+
+ public static function provideGetFieldInfo() {
+ return [
+ [ [ AuthManager::ACTION_CREATE ] ],
+ [ [ AuthManager::ACTION_CHANGE ] ],
+ [ [ AuthManager::ACTION_REMOVE ] ],
+ ];
+ }
+
+ public function testNewRandom() {
+ global $wgPasswordPolicy;
+
+ $this->stashMwGlobals( 'wgPasswordPolicy' );
+ $wgPasswordPolicy['policies']['default'] += [
+ 'MinimalPasswordLength' => 1,
+ 'MinimalPasswordLengthToLogin' => 1,
+ ];
+
+ $ret1 = TemporaryPasswordAuthenticationRequest::newRandom();
+ $ret2 = TemporaryPasswordAuthenticationRequest::newRandom();
+ $this->assertNotSame( '', $ret1->password );
+ $this->assertNotSame( '', $ret2->password );
+ $this->assertNotSame( $ret1->password, $ret2->password );
+ }
+
+ public function testNewInvalid() {
+ $ret = TemporaryPasswordAuthenticationRequest::newInvalid();
+ $this->assertNull( $ret->password );
+ }
+
+ public function provideLoadFromSubmission() {
+ return [
+ 'Empty request' => [
+ [ AuthManager::ACTION_REMOVE ],
+ [],
+ false,
+ ],
+ 'Create, empty request' => [
+ [ AuthManager::ACTION_CREATE ],
+ [],
+ false,
+ ],
+ 'Create, mailpassword set' => [
+ [ AuthManager::ACTION_CREATE ],
+ [ 'mailpassword' => 1 ],
+ [ 'mailpassword' => true, 'action' => AuthManager::ACTION_CREATE ],
+ ],
+ ];
+ }
+
+ public function testDescribeCredentials() {
+ $req = new TemporaryPasswordAuthenticationRequest;
+ $req->action = AuthManager::ACTION_LOGIN;
+ $req->username = 'UTSysop';
+ $ret = $req->describeCredentials();
+ $this->assertInternalType( 'array', $ret );
+ $this->assertArrayHasKey( 'provider', $ret );
+ $this->assertInstanceOf( \Message::class, $ret['provider'] );
+ $this->assertSame( 'authmanager-provider-temporarypassword', $ret['provider']->getKey() );
+ $this->assertArrayHasKey( 'account', $ret );
+ $this->assertInstanceOf( \Message::class, $ret['account'] );
+ $this->assertSame( [ 'UTSysop' ], $ret['account']->getParams() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php
new file mode 100644
index 00000000..1708f1c0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php
@@ -0,0 +1,720 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\ScopedCallback;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group AuthManager
+ * @group Database
+ * @covers MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider
+ */
+class TemporaryPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestCase {
+
+ private $manager = null;
+ private $config = null;
+ private $validity = null;
+
+ /**
+ * Get an instance of the provider
+ *
+ * $provider->checkPasswordValidity is mocked to return $this->validity,
+ * because we don't need to test that here.
+ *
+ * @param array $params
+ * @return TemporaryPasswordPrimaryAuthenticationProvider
+ */
+ protected function getProvider( $params = [] ) {
+ if ( !$this->config ) {
+ $this->config = new \HashConfig( [
+ 'EmailEnabled' => true,
+ ] );
+ }
+ $config = new \MultiConfig( [
+ $this->config,
+ MediaWikiServices::getInstance()->getMainConfig()
+ ] );
+
+ if ( !$this->manager ) {
+ $this->manager = new AuthManager( new \FauxRequest(), $config );
+ }
+ $this->validity = \Status::newGood();
+
+ $mockedMethods[] = 'checkPasswordValidity';
+ $provider = $this->getMockBuilder( TemporaryPasswordPrimaryAuthenticationProvider::class )
+ ->setMethods( $mockedMethods )
+ ->setConstructorArgs( [ $params ] )
+ ->getMock();
+ $provider->expects( $this->any() )->method( 'checkPasswordValidity' )
+ ->will( $this->returnCallback( function () {
+ return $this->validity;
+ } ) );
+ $provider->setConfig( $config );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setManager( $this->manager );
+
+ return $provider;
+ }
+
+ protected function hookMailer( $func = null ) {
+ \Hooks::clear( 'AlternateUserMailer' );
+ if ( $func ) {
+ \Hooks::register( 'AlternateUserMailer', $func );
+ // Safety
+ \Hooks::register( 'AlternateUserMailer', function () {
+ return false;
+ } );
+ } else {
+ \Hooks::register( 'AlternateUserMailer', function () {
+ $this->fail( 'AlternateUserMailer hook called unexpectedly' );
+ return false;
+ } );
+ }
+
+ return new ScopedCallback( function () {
+ \Hooks::clear( 'AlternateUserMailer' );
+ \Hooks::register( 'AlternateUserMailer', function () {
+ return false;
+ } );
+ } );
+ }
+
+ public function testBasics() {
+ $provider = new TemporaryPasswordPrimaryAuthenticationProvider();
+
+ $this->assertSame(
+ PrimaryAuthenticationProvider::TYPE_CREATE,
+ $provider->accountCreationType()
+ );
+
+ $this->assertTrue( $provider->testUserExists( 'UTSysop' ) );
+ $this->assertTrue( $provider->testUserExists( 'uTSysop' ) );
+ $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) );
+ $this->assertFalse( $provider->testUserExists( '<invalid>' ) );
+
+ $req = new PasswordAuthenticationRequest;
+ $req->action = AuthManager::ACTION_CHANGE;
+ $req->username = '<invalid>';
+ $provider->providerChangeAuthenticationData( $req );
+ }
+
+ public function testConfig() {
+ $config = new \HashConfig( [
+ 'EnableEmail' => false,
+ 'NewPasswordExpiry' => 100,
+ 'PasswordReminderResendTime' => 101,
+ ] );
+
+ $p = TestingAccessWrapper::newFromObject( new TemporaryPasswordPrimaryAuthenticationProvider() );
+ $p->setConfig( $config );
+ $this->assertSame( false, $p->emailEnabled );
+ $this->assertSame( 100, $p->newPasswordExpiry );
+ $this->assertSame( 101, $p->passwordReminderResendTime );
+
+ $p = TestingAccessWrapper::newFromObject( new TemporaryPasswordPrimaryAuthenticationProvider( [
+ 'emailEnabled' => true,
+ 'newPasswordExpiry' => 42,
+ 'passwordReminderResendTime' => 43,
+ ] ) );
+ $p->setConfig( $config );
+ $this->assertSame( true, $p->emailEnabled );
+ $this->assertSame( 42, $p->newPasswordExpiry );
+ $this->assertSame( 43, $p->passwordReminderResendTime );
+ }
+
+ public function testTestUserCanAuthenticate() {
+ $user = self::getMutableTestUser()->getUser();
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ $passwordFactory = new \PasswordFactory();
+ $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+ // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+ $passwordFactory->setDefaultType( 'A' );
+ $pwhash = $passwordFactory->newFromPlaintext( 'password' )->toString();
+
+ $provider = $this->getProvider();
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+
+ $this->assertFalse( $provider->testUserCanAuthenticate( '<invalid>' ) );
+ $this->assertFalse( $provider->testUserCanAuthenticate( 'DoesNotExist' ) );
+
+ $dbw->update(
+ 'user',
+ [
+ 'user_newpassword' => \PasswordFactory::newInvalidPassword()->toString(),
+ 'user_newpass_time' => null,
+ ],
+ [ 'user_id' => $user->getId() ]
+ );
+ $this->assertFalse( $provider->testUserCanAuthenticate( $user->getName() ) );
+
+ $dbw->update(
+ 'user',
+ [
+ 'user_newpassword' => $pwhash,
+ 'user_newpass_time' => null,
+ ],
+ [ 'user_id' => $user->getId() ]
+ );
+ $this->assertTrue( $provider->testUserCanAuthenticate( $user->getName() ) );
+ $this->assertTrue( $provider->testUserCanAuthenticate( lcfirst( $user->getName() ) ) );
+
+ $dbw->update(
+ 'user',
+ [
+ 'user_newpassword' => $pwhash,
+ 'user_newpass_time' => $dbw->timestamp( time() - 10 ),
+ ],
+ [ 'user_id' => $user->getId() ]
+ );
+ $providerPriv->newPasswordExpiry = 100;
+ $this->assertTrue( $provider->testUserCanAuthenticate( $user->getName() ) );
+ $providerPriv->newPasswordExpiry = 1;
+ $this->assertFalse( $provider->testUserCanAuthenticate( $user->getName() ) );
+
+ $dbw->update(
+ 'user',
+ [
+ 'user_newpassword' => \PasswordFactory::newInvalidPassword()->toString(),
+ 'user_newpass_time' => null,
+ ],
+ [ 'user_id' => $user->getId() ]
+ );
+ }
+
+ /**
+ * @dataProvider provideGetAuthenticationRequests
+ * @param string $action
+ * @param array $options
+ * @param array $expected
+ */
+ public function testGetAuthenticationRequests( $action, $options, $expected ) {
+ $actual = $this->getProvider()->getAuthenticationRequests( $action, $options );
+ foreach ( $actual as $req ) {
+ if ( $req instanceof TemporaryPasswordAuthenticationRequest && $req->password !== null ) {
+ $req->password = 'random';
+ }
+ }
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideGetAuthenticationRequests() {
+ $anon = [ 'username' => null ];
+ $loggedIn = [ 'username' => 'UTSysop' ];
+
+ return [
+ [ AuthManager::ACTION_LOGIN, $anon, [
+ new PasswordAuthenticationRequest
+ ] ],
+ [ AuthManager::ACTION_LOGIN, $loggedIn, [
+ new PasswordAuthenticationRequest
+ ] ],
+ [ AuthManager::ACTION_CREATE, $anon, [] ],
+ [ AuthManager::ACTION_CREATE, $loggedIn, [
+ new TemporaryPasswordAuthenticationRequest( 'random' )
+ ] ],
+ [ AuthManager::ACTION_LINK, $anon, [] ],
+ [ AuthManager::ACTION_LINK, $loggedIn, [] ],
+ [ AuthManager::ACTION_CHANGE, $anon, [
+ new TemporaryPasswordAuthenticationRequest( 'random' )
+ ] ],
+ [ AuthManager::ACTION_CHANGE, $loggedIn, [
+ new TemporaryPasswordAuthenticationRequest( 'random' )
+ ] ],
+ [ AuthManager::ACTION_REMOVE, $anon, [
+ new TemporaryPasswordAuthenticationRequest
+ ] ],
+ [ AuthManager::ACTION_REMOVE, $loggedIn, [
+ new TemporaryPasswordAuthenticationRequest
+ ] ],
+ ];
+ }
+
+ public function testAuthentication() {
+ $user = self::getMutableTestUser()->getUser();
+
+ $password = 'TemporaryPassword';
+ $hash = ':A:' . md5( $password );
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update(
+ 'user',
+ [ 'user_newpassword' => $hash, 'user_newpass_time' => $dbw->timestamp( time() - 10 ) ],
+ [ 'user_id' => $user->getId() ]
+ );
+
+ $req = new PasswordAuthenticationRequest();
+ $req->action = AuthManager::ACTION_LOGIN;
+ $reqs = [ PasswordAuthenticationRequest::class => $req ];
+
+ $provider = $this->getProvider();
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+
+ $providerPriv->newPasswordExpiry = 100;
+
+ // General failures
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( [] )
+ );
+
+ $req->username = 'foo';
+ $req->password = null;
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $req->username = null;
+ $req->password = 'bar';
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $req->username = '<invalid>';
+ $req->password = 'WhoCares';
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ $req->username = 'DoesNotExist';
+ $req->password = 'DoesNotExist';
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+
+ // Validation failure
+ $req->username = $user->getName();
+ $req->password = $password;
+ $this->validity = \Status::newFatal( 'arbitrary-failure' );
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals(
+ AuthenticationResponse::FAIL,
+ $ret->status
+ );
+ $this->assertEquals(
+ 'arbitrary-failure',
+ $ret->message->getKey()
+ );
+
+ // Successful auth
+ $this->manager->removeAuthenticationSessionData( null );
+ $this->validity = \Status::newGood();
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $user->getName() ),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+ $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
+
+ $this->manager->removeAuthenticationSessionData( null );
+ $this->validity = \Status::newGood();
+ $req->username = lcfirst( $user->getName() );
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $user->getName() ),
+ $provider->beginPrimaryAuthentication( $reqs )
+ );
+ $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
+ $req->username = $user->getName();
+
+ // Expired password
+ $providerPriv->newPasswordExpiry = 1;
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals(
+ AuthenticationResponse::FAIL,
+ $ret->status
+ );
+ $this->assertEquals(
+ 'wrongpassword',
+ $ret->message->getKey()
+ );
+
+ // Bad password
+ $providerPriv->newPasswordExpiry = 100;
+ $this->validity = \Status::newGood();
+ $req->password = 'Wrong';
+ $ret = $provider->beginPrimaryAuthentication( $reqs );
+ $this->assertEquals(
+ AuthenticationResponse::FAIL,
+ $ret->status
+ );
+ $this->assertEquals(
+ 'wrongpassword',
+ $ret->message->getKey()
+ );
+ }
+
+ /**
+ * @dataProvider provideProviderAllowsAuthenticationDataChange
+ * @param string $type
+ * @param string $user
+ * @param \Status $validity Result of the password validity check
+ * @param \StatusValue $expect1 Expected result with $checkData = false
+ * @param \StatusValue $expect2 Expected result with $checkData = true
+ */
+ public function testProviderAllowsAuthenticationDataChange( $type, $user, \Status $validity,
+ \StatusValue $expect1, \StatusValue $expect2
+ ) {
+ if ( $type === PasswordAuthenticationRequest::class ||
+ $type === TemporaryPasswordAuthenticationRequest::class
+ ) {
+ $req = new $type();
+ } else {
+ $req = $this->createMock( $type );
+ }
+ $req->action = AuthManager::ACTION_CHANGE;
+ $req->username = $user;
+ $req->password = 'NewPassword';
+
+ $provider = $this->getProvider();
+ $this->validity = $validity;
+ $this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) );
+ $this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) );
+ }
+
+ public static function provideProviderAllowsAuthenticationDataChange() {
+ $err = \StatusValue::newGood();
+ $err->error( 'arbitrary-warning' );
+
+ return [
+ [ AuthenticationRequest::class, 'UTSysop', \Status::newGood(),
+ \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ],
+ [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(),
+ \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ],
+ [ TemporaryPasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(),
+ \StatusValue::newGood(), \StatusValue::newGood() ],
+ [ TemporaryPasswordAuthenticationRequest::class, 'uTSysop', \Status::newGood(),
+ \StatusValue::newGood(), \StatusValue::newGood() ],
+ [ TemporaryPasswordAuthenticationRequest::class, 'UTSysop', \Status::wrap( $err ),
+ \StatusValue::newGood(), $err ],
+ [ TemporaryPasswordAuthenticationRequest::class, 'UTSysop',
+ \Status::newFatal( 'arbitrary-error' ), \StatusValue::newGood(),
+ \StatusValue::newFatal( 'arbitrary-error' ) ],
+ [ TemporaryPasswordAuthenticationRequest::class, 'DoesNotExist', \Status::newGood(),
+ \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ],
+ [ TemporaryPasswordAuthenticationRequest::class, '<invalid>', \Status::newGood(),
+ \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideProviderChangeAuthenticationData
+ * @param string $user
+ * @param string $type
+ * @param bool $changed
+ */
+ public function testProviderChangeAuthenticationData( $user, $type, $changed ) {
+ $cuser = ucfirst( $user );
+ $oldpass = 'OldTempPassword';
+ $newpass = 'NewTempPassword';
+
+ $dbw = wfGetDB( DB_MASTER );
+ $oldHash = $dbw->selectField( 'user', 'user_newpassword', [ 'user_name' => $cuser ] );
+ $cb = new ScopedCallback( function () use ( $dbw, $cuser, $oldHash ) {
+ $dbw->update( 'user', [ 'user_newpassword' => $oldHash ], [ 'user_name' => $cuser ] );
+ } );
+
+ $hash = ':A:' . md5( $oldpass );
+ $dbw->update(
+ 'user',
+ [ 'user_newpassword' => $hash, 'user_newpass_time' => $dbw->timestamp( time() + 10 ) ],
+ [ 'user_name' => $cuser ]
+ );
+
+ $provider = $this->getProvider();
+
+ // Sanity check
+ $loginReq = new PasswordAuthenticationRequest();
+ $loginReq->action = AuthManager::ACTION_CHANGE;
+ $loginReq->username = $user;
+ $loginReq->password = $oldpass;
+ $loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ];
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $cuser ),
+ $provider->beginPrimaryAuthentication( $loginReqs ),
+ 'Sanity check'
+ );
+
+ if ( $type === PasswordAuthenticationRequest::class ||
+ $type === TemporaryPasswordAuthenticationRequest::class
+ ) {
+ $changeReq = new $type();
+ } else {
+ $changeReq = $this->createMock( $type );
+ }
+ $changeReq->action = AuthManager::ACTION_CHANGE;
+ $changeReq->username = $user;
+ $changeReq->password = $newpass;
+ $resetMailer = $this->hookMailer();
+ $provider->providerChangeAuthenticationData( $changeReq );
+ ScopedCallback::consume( $resetMailer );
+
+ $loginReq->password = $oldpass;
+ $ret = $provider->beginPrimaryAuthentication( $loginReqs );
+ $this->assertEquals(
+ AuthenticationResponse::FAIL,
+ $ret->status,
+ 'old password should fail'
+ );
+ $this->assertEquals(
+ 'wrongpassword',
+ $ret->message->getKey(),
+ 'old password should fail'
+ );
+
+ $loginReq->password = $newpass;
+ $ret = $provider->beginPrimaryAuthentication( $loginReqs );
+ if ( $changed ) {
+ $this->assertEquals(
+ AuthenticationResponse::newPass( $cuser ),
+ $ret,
+ 'new password should pass'
+ );
+ $this->assertNotNull(
+ $dbw->selectField( 'user', 'user_newpass_time', [ 'user_name' => $cuser ] )
+ );
+ } else {
+ $this->assertEquals(
+ AuthenticationResponse::FAIL,
+ $ret->status,
+ 'new password should fail'
+ );
+ $this->assertEquals(
+ 'wrongpassword',
+ $ret->message->getKey(),
+ 'new password should fail'
+ );
+ $this->assertNull(
+ $dbw->selectField( 'user', 'user_newpass_time', [ 'user_name' => $cuser ] )
+ );
+ }
+ }
+
+ public static function provideProviderChangeAuthenticationData() {
+ return [
+ [ 'UTSysop', AuthenticationRequest::class, false ],
+ [ 'UTSysop', PasswordAuthenticationRequest::class, false ],
+ [ 'UTSysop', TemporaryPasswordAuthenticationRequest::class, true ],
+ ];
+ }
+
+ public function testProviderChangeAuthenticationDataEmail() {
+ $user = self::getMutableTestUser()->getUser();
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update(
+ 'user',
+ [ 'user_newpass_time' => $dbw->timestamp( time() - 5 * 3600 ) ],
+ [ 'user_id' => $user->getId() ]
+ );
+
+ $req = TemporaryPasswordAuthenticationRequest::newRandom();
+ $req->username = $user->getName();
+ $req->mailpassword = true;
+
+ $provider = $this->getProvider( [ 'emailEnabled' => false ] );
+ $status = $provider->providerAllowsAuthenticationDataChange( $req, true );
+ $this->assertEquals( \StatusValue::newFatal( 'passwordreset-emaildisabled' ), $status );
+
+ $provider = $this->getProvider( [ 'passwordReminderResendTime' => 10 ] );
+ $status = $provider->providerAllowsAuthenticationDataChange( $req, true );
+ $this->assertEquals( \StatusValue::newFatal( 'throttled-mailpassword', 10 ), $status );
+
+ $provider = $this->getProvider( [ 'passwordReminderResendTime' => 3 ] );
+ $status = $provider->providerAllowsAuthenticationDataChange( $req, true );
+ $this->assertFalse( $status->hasMessage( 'throttled-mailpassword' ) );
+
+ $dbw->update(
+ 'user',
+ [ 'user_newpass_time' => $dbw->timestamp( time() + 5 * 3600 ) ],
+ [ 'user_id' => $user->getId() ]
+ );
+ $provider = $this->getProvider( [ 'passwordReminderResendTime' => 0 ] );
+ $status = $provider->providerAllowsAuthenticationDataChange( $req, true );
+ $this->assertFalse( $status->hasMessage( 'throttled-mailpassword' ) );
+
+ $req->caller = null;
+ $status = $provider->providerAllowsAuthenticationDataChange( $req, true );
+ $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nocaller' ), $status );
+
+ $req->caller = '127.0.0.256';
+ $status = $provider->providerAllowsAuthenticationDataChange( $req, true );
+ $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nosuchcaller', '127.0.0.256' ),
+ $status );
+
+ $req->caller = '<Invalid>';
+ $status = $provider->providerAllowsAuthenticationDataChange( $req, true );
+ $this->assertEquals( \StatusValue::newFatal( 'passwordreset-nosuchcaller', '<Invalid>' ),
+ $status );
+
+ $req->caller = '127.0.0.1';
+ $status = $provider->providerAllowsAuthenticationDataChange( $req, true );
+ $this->assertEquals( \StatusValue::newGood(), $status );
+
+ $req->caller = $user->getName();
+ $status = $provider->providerAllowsAuthenticationDataChange( $req, true );
+ $this->assertEquals( \StatusValue::newGood(), $status );
+
+ $mailed = false;
+ $resetMailer = $this->hookMailer( function ( $headers, $to, $from, $subject, $body )
+ use ( &$mailed, $req, $user )
+ {
+ $mailed = true;
+ $this->assertSame( $user->getEmail(), $to[0]->address );
+ $this->assertContains( $req->password, $body );
+ return false;
+ } );
+ $provider->providerChangeAuthenticationData( $req );
+ ScopedCallback::consume( $resetMailer );
+ $this->assertTrue( $mailed );
+
+ $priv = TestingAccessWrapper::newFromObject( $provider );
+ $req->username = '<invalid>';
+ $status = $priv->sendPasswordResetEmail( $req );
+ $this->assertEquals( \Status::newFatal( 'noname' ), $status );
+ }
+
+ public function testTestForAccountCreation() {
+ $user = \User::newFromName( 'foo' );
+ $req = new TemporaryPasswordAuthenticationRequest();
+ $req->username = 'Foo';
+ $req->password = 'Bar';
+ $reqs = [ TemporaryPasswordAuthenticationRequest::class => $req ];
+
+ $provider = $this->getProvider();
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAccountCreation( $user, $user, [] ),
+ 'No password request'
+ );
+
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAccountCreation( $user, $user, $reqs ),
+ 'Password request, validated'
+ );
+
+ $this->validity->error( 'arbitrary warning' );
+ $expect = \StatusValue::newGood();
+ $expect->error( 'arbitrary warning' );
+ $this->assertEquals(
+ $expect,
+ $provider->testForAccountCreation( $user, $user, $reqs ),
+ 'Password request, not validated'
+ );
+ }
+
+ public function testAccountCreation() {
+ $resetMailer = $this->hookMailer();
+
+ $user = \User::newFromName( 'Foo' );
+
+ $req = new TemporaryPasswordAuthenticationRequest();
+ $reqs = [ TemporaryPasswordAuthenticationRequest::class => $req ];
+
+ $authreq = new PasswordAuthenticationRequest();
+ $authreq->action = AuthManager::ACTION_CREATE;
+ $authreqs = [ PasswordAuthenticationRequest::class => $authreq ];
+
+ $provider = $this->getProvider();
+
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAccountCreation( $user, $user, [] )
+ );
+
+ $req->username = 'foo';
+ $req->password = null;
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAccountCreation( $user, $user, $reqs )
+ );
+
+ $req->username = null;
+ $req->password = 'bar';
+ $this->assertEquals(
+ AuthenticationResponse::newAbstain(),
+ $provider->beginPrimaryAccountCreation( $user, $user, $reqs )
+ );
+
+ $req->username = 'foo';
+ $req->password = 'bar';
+
+ $expect = AuthenticationResponse::newPass( 'Foo' );
+ $expect->createRequest = clone $req;
+ $expect->createRequest->username = 'Foo';
+ $this->assertEquals( $expect, $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) );
+ $this->assertNull( $this->manager->getAuthenticationSessionData( 'no-email' ) );
+
+ $user = self::getMutableTestUser()->getUser();
+ $req->username = $authreq->username = $user->getName();
+ $req->password = $authreq->password = 'NewPassword';
+ $expect = AuthenticationResponse::newPass( $user->getName() );
+ $expect->createRequest = $req;
+
+ $res2 = $provider->beginPrimaryAccountCreation( $user, $user, $reqs );
+ $this->assertEquals( $expect, $res2, 'Sanity check' );
+
+ $ret = $provider->beginPrimaryAuthentication( $authreqs );
+ $this->assertEquals( AuthenticationResponse::FAIL, $ret->status, 'sanity check' );
+
+ $this->assertSame( null, $provider->finishAccountCreation( $user, $user, $res2 ) );
+
+ $ret = $provider->beginPrimaryAuthentication( $authreqs );
+ $this->assertEquals( AuthenticationResponse::PASS, $ret->status, 'new password is set' );
+ }
+
+ public function testAccountCreationEmail() {
+ $creator = \User::newFromName( 'Foo' );
+
+ $user = self::getMutableTestUser()->getUser();
+ $user->setEmail( null );
+
+ $req = TemporaryPasswordAuthenticationRequest::newRandom();
+ $req->username = $user->getName();
+ $req->mailpassword = true;
+
+ $provider = $this->getProvider( [ 'emailEnabled' => false ] );
+ $status = $provider->testForAccountCreation( $user, $creator, [ $req ] );
+ $this->assertEquals( \StatusValue::newFatal( 'emaildisabled' ), $status );
+
+ $provider = $this->getProvider( [ 'emailEnabled' => true ] );
+ $status = $provider->testForAccountCreation( $user, $creator, [ $req ] );
+ $this->assertEquals( \StatusValue::newFatal( 'noemailcreate' ), $status );
+
+ $user->setEmail( 'test@localhost.localdomain' );
+ $status = $provider->testForAccountCreation( $user, $creator, [ $req ] );
+ $this->assertEquals( \StatusValue::newGood(), $status );
+
+ $mailed = false;
+ $resetMailer = $this->hookMailer( function ( $headers, $to, $from, $subject, $body )
+ use ( &$mailed, $req )
+ {
+ $mailed = true;
+ $this->assertSame( 'test@localhost.localdomain', $to[0]->address );
+ $this->assertContains( $req->password, $body );
+ return false;
+ } );
+
+ $expect = AuthenticationResponse::newPass( $user->getName() );
+ $expect->createRequest = clone $req;
+ $expect->createRequest->username = $user->getName();
+ $res = $provider->beginPrimaryAccountCreation( $user, $creator, [ $req ] );
+ $this->assertEquals( $expect, $res );
+ $this->assertTrue( $this->manager->getAuthenticationSessionData( 'no-email' ) );
+ $this->assertFalse( $mailed );
+
+ $this->assertSame( 'byemail', $provider->finishAccountCreation( $user, $creator, $res ) );
+ $this->assertTrue( $mailed );
+
+ ScopedCallback::consume( $resetMailer );
+ $this->assertTrue( $mailed );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php b/www/wiki/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php
new file mode 100644
index 00000000..d03b1515
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/ThrottlePreAuthenticationProviderTest.php
@@ -0,0 +1,236 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group AuthManager
+ * @group Database
+ * @covers MediaWiki\Auth\ThrottlePreAuthenticationProvider
+ */
+class ThrottlePreAuthenticationProviderTest extends \MediaWikiTestCase {
+ public function testConstructor() {
+ $provider = new ThrottlePreAuthenticationProvider();
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+ $config = new \HashConfig( [
+ 'AccountCreationThrottle' => [ [
+ 'count' => 123,
+ 'seconds' => 86400,
+ ] ],
+ 'PasswordAttemptThrottle' => [ [
+ 'count' => 5,
+ 'seconds' => 300,
+ ] ],
+ ] );
+ $provider->setConfig( $config );
+ $this->assertSame( [
+ 'accountCreationThrottle' => [ [ 'count' => 123, 'seconds' => 86400 ] ],
+ 'passwordAttemptThrottle' => [ [ 'count' => 5, 'seconds' => 300 ] ]
+ ], $providerPriv->throttleSettings );
+ $accountCreationThrottle = TestingAccessWrapper::newFromObject(
+ $providerPriv->accountCreationThrottle );
+ $this->assertSame( [ [ 'count' => 123, 'seconds' => 86400 ] ],
+ $accountCreationThrottle->conditions );
+ $passwordAttemptThrottle = TestingAccessWrapper::newFromObject(
+ $providerPriv->passwordAttemptThrottle );
+ $this->assertSame( [ [ 'count' => 5, 'seconds' => 300 ] ],
+ $passwordAttemptThrottle->conditions );
+
+ $provider = new ThrottlePreAuthenticationProvider( [
+ 'accountCreationThrottle' => [ [ 'count' => 43, 'seconds' => 10000 ] ],
+ 'passwordAttemptThrottle' => [ [ 'count' => 11, 'seconds' => 100 ] ],
+ ] );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+ $config = new \HashConfig( [
+ 'AccountCreationThrottle' => [ [
+ 'count' => 123,
+ 'seconds' => 86400,
+ ] ],
+ 'PasswordAttemptThrottle' => [ [
+ 'count' => 5,
+ 'seconds' => 300,
+ ] ],
+ ] );
+ $provider->setConfig( $config );
+ $this->assertSame( [
+ 'accountCreationThrottle' => [ [ 'count' => 43, 'seconds' => 10000 ] ],
+ 'passwordAttemptThrottle' => [ [ 'count' => 11, 'seconds' => 100 ] ],
+ ], $providerPriv->throttleSettings );
+
+ $cache = new \HashBagOStuff();
+ $provider = new ThrottlePreAuthenticationProvider( [ 'cache' => $cache ] );
+ $providerPriv = TestingAccessWrapper::newFromObject( $provider );
+ $provider->setConfig( new \HashConfig( [
+ 'AccountCreationThrottle' => [ [ 'count' => 1, 'seconds' => 1 ] ],
+ 'PasswordAttemptThrottle' => [ [ 'count' => 1, 'seconds' => 1 ] ],
+ ] ) );
+ $accountCreationThrottle = TestingAccessWrapper::newFromObject(
+ $providerPriv->accountCreationThrottle );
+ $this->assertSame( $cache, $accountCreationThrottle->cache );
+ $passwordAttemptThrottle = TestingAccessWrapper::newFromObject(
+ $providerPriv->passwordAttemptThrottle );
+ $this->assertSame( $cache, $passwordAttemptThrottle->cache );
+ }
+
+ public function testDisabled() {
+ $provider = new ThrottlePreAuthenticationProvider( [
+ 'accountCreationThrottle' => [],
+ 'passwordAttemptThrottle' => [],
+ 'cache' => new \HashBagOStuff(),
+ ] );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setConfig( new \HashConfig( [
+ 'AccountCreationThrottle' => null,
+ 'PasswordAttemptThrottle' => null,
+ ] ) );
+ $provider->setManager( AuthManager::singleton() );
+
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAccountCreation(
+ \User::newFromName( 'Created' ),
+ \User::newFromName( 'Creator' ),
+ []
+ )
+ );
+ $this->assertEquals(
+ \StatusValue::newGood(),
+ $provider->testForAuthentication( [] )
+ );
+ }
+
+ /**
+ * @dataProvider provideTestForAccountCreation
+ * @param string $creatorname
+ * @param bool $succeed
+ * @param bool $hook
+ */
+ public function testTestForAccountCreation( $creatorname, $succeed, $hook ) {
+ $provider = new ThrottlePreAuthenticationProvider( [
+ 'accountCreationThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ],
+ 'cache' => new \HashBagOStuff(),
+ ] );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setConfig( new \HashConfig( [
+ 'AccountCreationThrottle' => null,
+ 'PasswordAttemptThrottle' => null,
+ ] ) );
+ $provider->setManager( AuthManager::singleton() );
+
+ $user = \User::newFromName( 'RandomUser' );
+ $creator = \User::newFromName( $creatorname );
+ if ( $hook ) {
+ $mock = $this->getMockBuilder( stdClass::class )
+ ->setMethods( [ 'onExemptFromAccountCreationThrottle' ] )
+ ->getMock();
+ $mock->expects( $this->any() )->method( 'onExemptFromAccountCreationThrottle' )
+ ->will( $this->returnValue( false ) );
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'ExemptFromAccountCreationThrottle' => [ $mock ],
+ ] );
+ }
+
+ $this->assertEquals(
+ true,
+ $provider->testForAccountCreation( $user, $creator, [] )->isOK(),
+ 'attempt #1'
+ );
+ $this->assertEquals(
+ true,
+ $provider->testForAccountCreation( $user, $creator, [] )->isOK(),
+ 'attempt #2'
+ );
+ $this->assertEquals(
+ $succeed ? true : false,
+ $provider->testForAccountCreation( $user, $creator, [] )->isOK(),
+ 'attempt #3'
+ );
+ }
+
+ public static function provideTestForAccountCreation() {
+ return [
+ 'Normal user' => [ 'NormalUser', false, false ],
+ 'Sysop' => [ 'UTSysop', true, false ],
+ 'Normal user with hook' => [ 'NormalUser', true, true ],
+ ];
+ }
+
+ public function testTestForAuthentication() {
+ $provider = new ThrottlePreAuthenticationProvider( [
+ 'passwordAttemptThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ],
+ 'cache' => new \HashBagOStuff(),
+ ] );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setConfig( new \HashConfig( [
+ 'AccountCreationThrottle' => null,
+ 'PasswordAttemptThrottle' => null,
+ ] ) );
+ $provider->setManager( AuthManager::singleton() );
+
+ $req = new UsernameAuthenticationRequest;
+ $req->username = 'SomeUser';
+ for ( $i = 1; $i <= 3; $i++ ) {
+ $status = $provider->testForAuthentication( [ $req ] );
+ $this->assertEquals( $i < 3, $status->isGood(), "attempt #$i" );
+ }
+ $this->assertCount( 1, $status->getErrors() );
+ $msg = new \Message( $status->getErrors()[0]['message'], $status->getErrors()[0]['params'] );
+ $this->assertEquals( 'login-throttled', $msg->getKey() );
+
+ $provider->postAuthentication( \User::newFromName( 'SomeUser' ),
+ AuthenticationResponse::newFail( wfMessage( 'foo' ) ) );
+ $this->assertFalse( $provider->testForAuthentication( [ $req ] )->isGood(), 'after FAIL' );
+
+ $provider->postAuthentication( \User::newFromName( 'SomeUser' ),
+ AuthenticationResponse::newPass() );
+ $this->assertTrue( $provider->testForAuthentication( [ $req ] )->isGood(), 'after PASS' );
+
+ $req1 = new UsernameAuthenticationRequest;
+ $req1->username = 'foo';
+ $req2 = new UsernameAuthenticationRequest;
+ $req2->username = 'bar';
+ $this->assertTrue( $provider->testForAuthentication( [ $req1, $req2 ] )->isGood() );
+
+ $req = new UsernameAuthenticationRequest;
+ $req->username = 'Some user';
+ $provider->testForAuthentication( [ $req ] );
+ $req->username = 'Some_user';
+ $provider->testForAuthentication( [ $req ] );
+ $req->username = 'some user';
+ $status = $provider->testForAuthentication( [ $req ] );
+ $this->assertFalse( $status->isGood(), 'denormalized usernames are normalized' );
+ }
+
+ public function testPostAuthentication() {
+ $provider = new ThrottlePreAuthenticationProvider( [
+ 'passwordAttemptThrottle' => [],
+ 'cache' => new \HashBagOStuff(),
+ ] );
+ $provider->setLogger( new \TestLogger );
+ $provider->setConfig( new \HashConfig( [
+ 'AccountCreationThrottle' => null,
+ 'PasswordAttemptThrottle' => null,
+ ] ) );
+ $provider->setManager( AuthManager::singleton() );
+ $provider->postAuthentication( \User::newFromName( 'SomeUser' ),
+ AuthenticationResponse::newPass() );
+
+ $provider = new ThrottlePreAuthenticationProvider( [
+ 'passwordAttemptThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ],
+ 'cache' => new \HashBagOStuff(),
+ ] );
+ $logger = new \TestLogger( true );
+ $provider->setLogger( $logger );
+ $provider->setConfig( new \HashConfig( [
+ 'AccountCreationThrottle' => null,
+ 'PasswordAttemptThrottle' => null,
+ ] ) );
+ $provider->setManager( AuthManager::singleton() );
+ $provider->postAuthentication( \User::newFromName( 'SomeUser' ),
+ AuthenticationResponse::newPass() );
+ $this->assertSame( [
+ [ \Psr\Log\LogLevel::INFO, 'throttler data not found for {user}' ],
+ ], $logger->getBuffer() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/ThrottlerTest.php b/www/wiki/tests/phpunit/includes/auth/ThrottlerTest.php
new file mode 100644
index 00000000..f963ad9c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/ThrottlerTest.php
@@ -0,0 +1,238 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use BagOStuff;
+use HashBagOStuff;
+use Psr\Log\AbstractLogger;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\Throttler
+ */
+class ThrottlerTest extends \MediaWikiTestCase {
+ public function testConstructor() {
+ $cache = new \HashBagOStuff();
+ $logger = $this->getMockBuilder( AbstractLogger::class )
+ ->setMethods( [ 'log' ] )
+ ->getMockForAbstractClass();
+
+ $throttler = new Throttler(
+ [ [ 'count' => 123, 'seconds' => 456 ] ],
+ [ 'type' => 'foo', 'cache' => $cache ]
+ );
+ $throttler->setLogger( $logger );
+ $throttlerPriv = TestingAccessWrapper::newFromObject( $throttler );
+ $this->assertSame( [ [ 'count' => 123, 'seconds' => 456 ] ], $throttlerPriv->conditions );
+ $this->assertSame( 'foo', $throttlerPriv->type );
+ $this->assertSame( $cache, $throttlerPriv->cache );
+ $this->assertSame( $logger, $throttlerPriv->logger );
+
+ $throttler = new Throttler( [ [ 'count' => 123, 'seconds' => 456 ] ] );
+ $throttler->setLogger( new NullLogger() );
+ $throttlerPriv = TestingAccessWrapper::newFromObject( $throttler );
+ $this->assertSame( [ [ 'count' => 123, 'seconds' => 456 ] ], $throttlerPriv->conditions );
+ $this->assertSame( 'custom', $throttlerPriv->type );
+ $this->assertInstanceOf( BagOStuff::class, $throttlerPriv->cache );
+ $this->assertInstanceOf( LoggerInterface::class, $throttlerPriv->logger );
+
+ $this->setMwGlobals( [ 'wgPasswordAttemptThrottle' => [ [ 'count' => 321,
+ 'seconds' => 654 ] ] ] );
+ $throttler = new Throttler();
+ $throttler->setLogger( new NullLogger() );
+ $throttlerPriv = TestingAccessWrapper::newFromObject( $throttler );
+ $this->assertSame( [ [ 'count' => 321, 'seconds' => 654 ] ], $throttlerPriv->conditions );
+ $this->assertSame( 'password', $throttlerPriv->type );
+ $this->assertInstanceOf( BagOStuff::class, $throttlerPriv->cache );
+ $this->assertInstanceOf( LoggerInterface::class, $throttlerPriv->logger );
+
+ try {
+ new Throttler( [], [ 'foo' => 1, 'bar' => 2, 'baz' => 3 ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'unrecognized parameters: foo, bar, baz', $ex->getMessage() );
+ }
+ }
+
+ /**
+ * @dataProvider provideNormalizeThrottleConditions
+ */
+ public function testNormalizeThrottleConditions( $condition, $normalized ) {
+ $throttler = new Throttler( $condition );
+ $throttler->setLogger( new NullLogger() );
+ $throttlerPriv = TestingAccessWrapper::newFromObject( $throttler );
+ $this->assertSame( $normalized, $throttlerPriv->conditions );
+ }
+
+ public function provideNormalizeThrottleConditions() {
+ return [
+ [
+ [],
+ [],
+ ],
+ [
+ [ 'count' => 1, 'seconds' => 2 ],
+ [ [ 'count' => 1, 'seconds' => 2 ] ],
+ ],
+ [
+ [ [ 'count' => 1, 'seconds' => 2 ], [ 'count' => 2, 'seconds' => 3 ] ],
+ [ [ 'count' => 1, 'seconds' => 2 ], [ 'count' => 2, 'seconds' => 3 ] ],
+ ],
+ ];
+ }
+
+ public function testNormalizeThrottleConditions2() {
+ $priv = TestingAccessWrapper::newFromClass( Throttler::class );
+ $this->assertSame( [], $priv->normalizeThrottleConditions( null ) );
+ $this->assertSame( [], $priv->normalizeThrottleConditions( 'bad' ) );
+ }
+
+ public function testIncrease() {
+ $cache = new \HashBagOStuff();
+ $throttler = new Throttler( [
+ [ 'count' => 2, 'seconds' => 10, ],
+ [ 'count' => 4, 'seconds' => 15, 'allIPs' => true ],
+ ], [ 'cache' => $cache ] );
+ $throttler->setLogger( new NullLogger() );
+
+ $result = $throttler->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertFalse( $result, 'should not throttle' );
+
+ $result = $throttler->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertFalse( $result, 'should not throttle' );
+
+ $result = $throttler->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertSame( [ 'throttleIndex' => 0, 'count' => 2, 'wait' => 10 ], $result );
+
+ $result = $throttler->increase( 'OtherUser', '1.2.3.4' );
+ $this->assertFalse( $result, 'should not throttle' );
+
+ $result = $throttler->increase( 'SomeUser', '2.3.4.5' );
+ $this->assertFalse( $result, 'should not throttle' );
+
+ $result = $throttler->increase( 'SomeUser', '3.4.5.6' );
+ $this->assertFalse( $result, 'should not throttle' );
+
+ $result = $throttler->increase( 'SomeUser', '3.4.5.6' );
+ $this->assertSame( [ 'throttleIndex' => 1, 'count' => 4, 'wait' => 15 ], $result );
+ }
+
+ public function testZeroCount() {
+ $cache = new \HashBagOStuff();
+ $throttler = new Throttler( [ [ 'count' => 0, 'seconds' => 10 ] ], [ 'cache' => $cache ] );
+ $throttler->setLogger( new NullLogger() );
+
+ $result = $throttler->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertFalse( $result, 'should not throttle, count=0 is ignored' );
+
+ $result = $throttler->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertFalse( $result, 'should not throttle, count=0 is ignored' );
+
+ $result = $throttler->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertFalse( $result, 'should not throttle, count=0 is ignored' );
+ }
+
+ public function testNamespacing() {
+ $cache = new \HashBagOStuff();
+ $throttler1 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ],
+ [ 'cache' => $cache, 'type' => 'foo' ] );
+ $throttler2 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ],
+ [ 'cache' => $cache, 'type' => 'foo' ] );
+ $throttler3 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ],
+ [ 'cache' => $cache, 'type' => 'bar' ] );
+ $throttler1->setLogger( new NullLogger() );
+ $throttler2->setLogger( new NullLogger() );
+ $throttler3->setLogger( new NullLogger() );
+
+ $throttled = [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ];
+
+ $result = $throttler1->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertFalse( $result, 'should not throttle' );
+
+ $result = $throttler1->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertEquals( $throttled, $result, 'should throttle' );
+
+ $result = $throttler2->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertEquals( $throttled, $result, 'should throttle, same namespace' );
+
+ $result = $throttler3->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertFalse( $result, 'should not throttle, different namespace' );
+ }
+
+ public function testExpiration() {
+ $cache = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'add' ] )->getMock();
+ $throttler = new Throttler( [ [ 'count' => 3, 'seconds' => 10 ] ], [ 'cache' => $cache ] );
+ $throttler->setLogger( new NullLogger() );
+
+ $cache->expects( $this->once() )->method( 'add' )->with( $this->anything(), 1, 10 );
+ $throttler->increase( 'SomeUser' );
+ }
+
+ /**
+ * @expectedException \InvalidArgumentException
+ */
+ public function testException() {
+ $throttler = new Throttler( [ [ 'count' => 3, 'seconds' => 10 ] ] );
+ $throttler->setLogger( new NullLogger() );
+ $throttler->increase();
+ }
+
+ public function testLog() {
+ $cache = new \HashBagOStuff();
+ $throttler = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], [ 'cache' => $cache ] );
+
+ $logger = $this->getMockBuilder( AbstractLogger::class )
+ ->setMethods( [ 'log' ] )
+ ->getMockForAbstractClass();
+ $logger->expects( $this->never() )->method( 'log' );
+ $throttler->setLogger( $logger );
+ $result = $throttler->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertFalse( $result, 'should not throttle' );
+
+ $logger = $this->getMockBuilder( AbstractLogger::class )
+ ->setMethods( [ 'log' ] )
+ ->getMockForAbstractClass();
+ $logger->expects( $this->once() )->method( 'log' )->with( $this->anything(), $this->anything(), [
+ 'throttle' => 'custom',
+ 'index' => 0,
+ 'ip' => '1.2.3.4',
+ 'username' => 'SomeUser',
+ 'count' => 1,
+ 'expiry' => 10,
+ 'method' => 'foo',
+ ] );
+ $throttler->setLogger( $logger );
+ $result = $throttler->increase( 'SomeUser', '1.2.3.4', 'foo' );
+ $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result );
+ }
+
+ public function testClear() {
+ $cache = new \HashBagOStuff();
+ $throttler = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], [ 'cache' => $cache ] );
+ $throttler->setLogger( new NullLogger() );
+
+ $result = $throttler->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertFalse( $result, 'should not throttle' );
+
+ $result = $throttler->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result );
+
+ $result = $throttler->increase( 'OtherUser', '1.2.3.4' );
+ $this->assertFalse( $result, 'should not throttle' );
+
+ $result = $throttler->increase( 'OtherUser', '1.2.3.4' );
+ $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result );
+
+ $throttler->clear( 'SomeUser', '1.2.3.4' );
+
+ $result = $throttler->increase( 'SomeUser', '1.2.3.4' );
+ $this->assertFalse( $result, 'should not throttle' );
+
+ $result = $throttler->increase( 'OtherUser', '1.2.3.4' );
+ $this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php
new file mode 100644
index 00000000..7dea123c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/UserDataAuthenticationRequestTest.php
@@ -0,0 +1,176 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\UserDataAuthenticationRequest
+ */
+class UserDataAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+ protected function getInstance( array $args = [] ) {
+ return new UserDataAuthenticationRequest;
+ }
+
+ protected function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( 'wgHiddenPrefs', [] );
+ }
+
+ /**
+ * @dataProvider providePopulateUser
+ * @param string $email Email to set
+ * @param string $realname Realname to set
+ * @param StatusValue $expect Expected return
+ */
+ public function testPopulateUser( $email, $realname, $expect ) {
+ $user = new \User();
+ $user->setEmail( 'default@example.com' );
+ $user->setRealName( 'Fake Name' );
+
+ $req = new UserDataAuthenticationRequest;
+ $req->email = $email;
+ $req->realname = $realname;
+ $this->assertEquals( $expect, $req->populateUser( $user ) );
+ if ( $expect->isOk() ) {
+ $this->assertSame( $email ?: 'default@example.com', $user->getEmail() );
+ $this->assertSame( $realname ?: 'Fake Name', $user->getRealName() );
+ }
+ }
+
+ public static function providePopulateUser() {
+ $good = \StatusValue::newGood();
+ return [
+ [ 'email@example.com', 'Real Name', $good ],
+ [ 'email@example.com', '', $good ],
+ [ '', 'Real Name', $good ],
+ [ '', '', $good ],
+ [ 'invalid-email', 'Real Name', \StatusValue::newFatal( 'invalidemailaddress' ) ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideLoadFromSubmission
+ */
+ public function testLoadFromSubmission(
+ array $args, array $data, $expectState /* $hiddenPref, $enableEmail */
+ ) {
+ list( $args, $data, $expectState, $hiddenPref, $enableEmail ) = func_get_args();
+ $this->setMwGlobals( 'wgHiddenPrefs', $hiddenPref );
+ $this->setMwGlobals( 'wgEnableEmail', $enableEmail );
+ parent::testLoadFromSubmission( $args, $data, $expectState );
+ }
+
+ public function provideLoadFromSubmission() {
+ $unhidden = [];
+ $hidden = [ 'realname' ];
+
+ return [
+ 'Empty request, unhidden, email enabled' => [
+ [],
+ [],
+ false,
+ $unhidden,
+ true
+ ],
+ 'email + realname, unhidden, email enabled' => [
+ [],
+ $data = [ 'email' => 'Email', 'realname' => 'Name' ],
+ $data,
+ $unhidden,
+ true
+ ],
+ 'email empty, unhidden, email enabled' => [
+ [],
+ $data = [ 'email' => '', 'realname' => 'Name' ],
+ $data,
+ $unhidden,
+ true
+ ],
+ 'email omitted, unhidden, email enabled' => [
+ [],
+ [ 'realname' => 'Name' ],
+ false,
+ $unhidden,
+ true
+ ],
+ 'realname empty, unhidden, email enabled' => [
+ [],
+ $data = [ 'email' => 'Email', 'realname' => '' ],
+ $data,
+ $unhidden,
+ true
+ ],
+ 'realname omitted, unhidden, email enabled' => [
+ [],
+ [ 'email' => 'Email' ],
+ false,
+ $unhidden,
+ true
+ ],
+ 'Empty request, hidden, email enabled' => [
+ [],
+ [],
+ false,
+ $hidden,
+ true
+ ],
+ 'email + realname, hidden, email enabled' => [
+ [],
+ [ 'email' => 'Email', 'realname' => 'Name' ],
+ [ 'email' => 'Email' ],
+ $hidden,
+ true
+ ],
+ 'email empty, hidden, email enabled' => [
+ [],
+ $data = [ 'email' => '', 'realname' => 'Name' ],
+ [ 'email' => '' ],
+ $hidden,
+ true
+ ],
+ 'email omitted, hidden, email enabled' => [
+ [],
+ [ 'realname' => 'Name' ],
+ false,
+ $hidden,
+ true
+ ],
+ 'realname empty, hidden, email enabled' => [
+ [],
+ $data = [ 'email' => 'Email', 'realname' => '' ],
+ [ 'email' => 'Email' ],
+ $hidden,
+ true
+ ],
+ 'realname omitted, hidden, email enabled' => [
+ [],
+ [ 'email' => 'Email' ],
+ [ 'email' => 'Email' ],
+ $hidden,
+ true
+ ],
+ 'email + realname, unhidden, email disabled' => [
+ [],
+ [ 'email' => 'Email', 'realname' => 'Name' ],
+ [ 'realname' => 'Name' ],
+ $unhidden,
+ false
+ ],
+ 'email omitted, unhidden, email disabled' => [
+ [],
+ [ 'realname' => 'Name' ],
+ [ 'realname' => 'Name' ],
+ $unhidden,
+ false
+ ],
+ 'email empty, unhidden, email disabled' => [
+ [],
+ [ 'email' => '', 'realname' => 'Name' ],
+ [ 'realname' => 'Name' ],
+ $unhidden,
+ false
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php b/www/wiki/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php
new file mode 100644
index 00000000..63628dd8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/auth/UsernameAuthenticationRequestTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * @group AuthManager
+ * @covers MediaWiki\Auth\UsernameAuthenticationRequest
+ */
+class UsernameAuthenticationRequestTest extends AuthenticationRequestTestCase {
+
+ protected function getInstance( array $args = [] ) {
+ return new UsernameAuthenticationRequest();
+ }
+
+ public function provideLoadFromSubmission() {
+ return [
+ 'Empty request' => [
+ [],
+ [],
+ false
+ ],
+ 'Username' => [
+ [],
+ $data = [ 'username' => 'User' ],
+ $data,
+ ],
+ 'Username empty' => [
+ [],
+ [ 'username' => '' ],
+ false
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/cache/GenderCacheTest.php b/www/wiki/tests/phpunit/includes/cache/GenderCacheTest.php
new file mode 100644
index 00000000..e5bb2379
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/cache/GenderCacheTest.php
@@ -0,0 +1,87 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group Database
+ * @group Cache
+ */
+class GenderCacheTest extends MediaWikiLangTestCase {
+
+ /** @var string[] User key => username */
+ private static $nameMap;
+
+ function addDBDataOnce() {
+ // ensure the correct default gender
+ $this->mergeMwGlobalArrayValue( 'wgDefaultUserOptions', [ 'gender' => 'unknown' ] );
+
+ $male = $this->getMutableTestUser()->getUser();
+ $male->setOption( 'gender', 'male' );
+ $male->saveSettings();
+
+ $female = $this->getMutableTestUser()->getUser();
+ $female->setOption( 'gender', 'female' );
+ $female->saveSettings();
+
+ $default = $this->getMutableTestUser()->getUser();
+ $default->setOption( 'gender', null );
+ $default->saveSettings();
+
+ self::$nameMap = [
+ 'UTMale' => $male->getName(),
+ 'UTFemale' => $female->getName(),
+ 'UTDefaultGender' => $default->getName()
+ ];
+ }
+
+ /**
+ * test usernames
+ *
+ * @dataProvider provideUserGenders
+ * @covers GenderCache::getGenderOf
+ */
+ public function testUserName( $userKey, $expectedGender ) {
+ $genderCache = MediaWikiServices::getInstance()->getGenderCache();
+ $username = isset( self::$nameMap[$userKey] ) ? self::$nameMap[$userKey] : $userKey;
+ $gender = $genderCache->getGenderOf( $username );
+ $this->assertEquals( $gender, $expectedGender, "GenderCache normal" );
+ }
+
+ /**
+ * genderCache should work with user objects, too
+ *
+ * @dataProvider provideUserGenders
+ * @covers GenderCache::getGenderOf
+ */
+ public function testUserObjects( $userKey, $expectedGender ) {
+ $username = isset( self::$nameMap[$userKey] ) ? self::$nameMap[$userKey] : $userKey;
+ $genderCache = MediaWikiServices::getInstance()->getGenderCache();
+ $gender = $genderCache->getGenderOf( $username );
+ $this->assertEquals( $gender, $expectedGender, "GenderCache normal" );
+ }
+
+ public static function provideUserGenders() {
+ return [
+ [ 'UTMale', 'male' ],
+ [ 'UTFemale', 'female' ],
+ [ 'UTDefaultGender', 'unknown' ],
+ [ 'UTNotExist', 'unknown' ],
+ // some not valid user
+ [ '127.0.0.1', 'unknown' ],
+ [ 'user@test', 'unknown' ],
+ ];
+ }
+
+ /**
+ * test strip of subpages to avoid unnecessary queries
+ * against the never existing username
+ *
+ * @dataProvider provideUserGenders
+ * @covers GenderCache::getGenderOf
+ */
+ public function testStripSubpages( $userKey, $expectedGender ) {
+ $username = isset( self::$nameMap[$userKey] ) ? self::$nameMap[$userKey] : $userKey;
+ $genderCache = MediaWikiServices::getInstance()->getGenderCache();
+ $gender = $genderCache->getGenderOf( "$username/subpage" );
+ $this->assertEquals( $gender, $expectedGender, "GenderCache must strip of subpages" );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/cache/LocalisationCacheTest.php b/www/wiki/tests/phpunit/includes/cache/LocalisationCacheTest.php
new file mode 100644
index 00000000..42957b60
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/cache/LocalisationCacheTest.php
@@ -0,0 +1,107 @@
+<?php
+/**
+ * @group Database
+ * @group Cache
+ * @covers LocalisationCache
+ * @author Niklas Laxström
+ */
+class LocalisationCacheTest extends MediaWikiTestCase {
+ protected function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( [
+ 'wgExtensionMessagesFiles' => [],
+ 'wgHooks' => [],
+ ] );
+ }
+
+ /**
+ * @return LocalisationCache
+ */
+ protected function getMockLocalisationCache() {
+ global $IP;
+ $lc = $this->getMockBuilder( \LocalisationCache::class )
+ ->setConstructorArgs( [ [ 'store' => 'detect' ] ] )
+ ->setMethods( [ 'getMessagesDirs' ] )
+ ->getMock();
+ $lc->expects( $this->any() )->method( 'getMessagesDirs' )
+ ->will( $this->returnValue(
+ [ "$IP/tests/phpunit/data/localisationcache" ]
+ ) );
+
+ return $lc;
+ }
+
+ public function testPuralRulesFallback() {
+ $cache = $this->getMockLocalisationCache();
+
+ $this->assertEquals(
+ $cache->getItem( 'ar', 'pluralRules' ),
+ $cache->getItem( 'arz', 'pluralRules' ),
+ 'arz plural rules (undefined) fallback to ar (defined)'
+ );
+
+ $this->assertEquals(
+ $cache->getItem( 'ar', 'compiledPluralRules' ),
+ $cache->getItem( 'arz', 'compiledPluralRules' ),
+ 'arz compiled plural rules (undefined) fallback to ar (defined)'
+ );
+
+ $this->assertNotEquals(
+ $cache->getItem( 'ksh', 'pluralRules' ),
+ $cache->getItem( 'de', 'pluralRules' ),
+ 'ksh plural rules (defined) dont fallback to de (defined)'
+ );
+
+ $this->assertNotEquals(
+ $cache->getItem( 'ksh', 'compiledPluralRules' ),
+ $cache->getItem( 'de', 'compiledPluralRules' ),
+ 'ksh compiled plural rules (defined) dont fallback to de (defined)'
+ );
+ }
+
+ public function testRecacheFallbacks() {
+ $lc = $this->getMockLocalisationCache();
+ $lc->recache( 'ba' );
+ $this->assertEquals(
+ [
+ 'present-ba' => 'ba',
+ 'present-ru' => 'ru',
+ 'present-en' => 'en',
+ ],
+ $lc->getItem( 'ba', 'messages' ),
+ 'Fallbacks are only used to fill missing data'
+ );
+ }
+
+ public function testRecacheFallbacksWithHooks() {
+ // Use hook to provide updates for messages. This is what the
+ // LocalisationUpdate extension does. See T70781.
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'LocalisationCacheRecacheFallback' => [
+ function (
+ LocalisationCache $lc,
+ $code,
+ array &$cache
+ ) {
+ if ( $code === 'ru' ) {
+ $cache['messages']['present-ba'] = 'ru-override';
+ $cache['messages']['present-ru'] = 'ru-override';
+ $cache['messages']['present-en'] = 'ru-override';
+ }
+ }
+ ]
+ ] );
+
+ $lc = $this->getMockLocalisationCache();
+ $lc->recache( 'ba' );
+ $this->assertEquals(
+ [
+ 'present-ba' => 'ba',
+ 'present-ru' => 'ru-override',
+ 'present-en' => 'ru-override',
+ ],
+ $lc->getItem( 'ba', 'messages' ),
+ 'Updates provided by hooks follow the normal fallback order.'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/cache/MessageCacheTest.php b/www/wiki/tests/phpunit/includes/cache/MessageCacheTest.php
new file mode 100644
index 00000000..b03eeba2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/cache/MessageCacheTest.php
@@ -0,0 +1,174 @@
+<?php
+
+/**
+ * @group Database
+ * @group Cache
+ * @covers MessageCache
+ */
+class MessageCacheTest extends MediaWikiLangTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ $this->configureLanguages();
+ MessageCache::singleton()->enable();
+ }
+
+ /**
+ * Helper function -- setup site language for testing
+ */
+ protected function configureLanguages() {
+ // for the test, we need the content language to be anything but English,
+ // let's choose e.g. German (de)
+ $this->setUserLang( 'de' );
+ $this->setContentLang( 'de' );
+ }
+
+ function addDBDataOnce() {
+ $this->configureLanguages();
+
+ // Set up messages and fallbacks ab -> ru -> de
+ $this->makePage( 'FallbackLanguageTest-Full', 'ab' );
+ $this->makePage( 'FallbackLanguageTest-Full', 'ru' );
+ $this->makePage( 'FallbackLanguageTest-Full', 'de' );
+
+ // Fallbacks where ab does not exist
+ $this->makePage( 'FallbackLanguageTest-Partial', 'ru' );
+ $this->makePage( 'FallbackLanguageTest-Partial', 'de' );
+
+ // Fallback to the content language
+ $this->makePage( 'FallbackLanguageTest-ContLang', 'de' );
+
+ // Add customizations for an existing message.
+ $this->makePage( 'sunday', 'ru' );
+
+ // Full key tests -- always want russian
+ $this->makePage( 'MessageCacheTest-FullKeyTest', 'ab' );
+ $this->makePage( 'MessageCacheTest-FullKeyTest', 'ru' );
+
+ // In content language -- get base if no derivative
+ $this->makePage( 'FallbackLanguageTest-NoDervContLang', 'de', 'de/none' );
+ }
+
+ /**
+ * Helper function for addDBData -- adds a simple page to the database
+ *
+ * @param string $title Title of page to be created
+ * @param string $lang Language and content of the created page
+ * @param string|null $content Content of the created page, or null for a generic string
+ */
+ protected function makePage( $title, $lang, $content = null ) {
+ global $wgContLang;
+
+ if ( $content === null ) {
+ $content = $lang;
+ }
+ if ( $lang !== $wgContLang->getCode() ) {
+ $title = "$title/$lang";
+ }
+
+ $title = Title::newFromText( $title, NS_MEDIAWIKI );
+ $wikiPage = new WikiPage( $title );
+ $contentHandler = ContentHandler::makeContent( $content, $title );
+ $wikiPage->doEditContent( $contentHandler, "$lang translation test case" );
+ }
+
+ /**
+ * Test message fallbacks, bug #1495
+ *
+ * @dataProvider provideMessagesForFallback
+ */
+ public function testMessageFallbacks( $message, $lang, $expectedContent ) {
+ $result = MessageCache::singleton()->get( $message, true, $lang );
+ $this->assertEquals( $expectedContent, $result, "Message fallback failed." );
+ }
+
+ function provideMessagesForFallback() {
+ return [
+ [ 'FallbackLanguageTest-Full', 'ab', 'ab' ],
+ [ 'FallbackLanguageTest-Partial', 'ab', 'ru' ],
+ [ 'FallbackLanguageTest-ContLang', 'ab', 'de' ],
+ [ 'FallbackLanguageTest-None', 'ab', false ],
+
+ // Existing message with customizations on the fallbacks
+ [ 'sunday', 'ab', 'амҽыш' ],
+
+ // T48579
+ [ 'FallbackLanguageTest-NoDervContLang', 'de', 'de/none' ],
+ // UI language different from content language should only use de/none as last option
+ [ 'FallbackLanguageTest-NoDervContLang', 'fit', 'de/none' ],
+ ];
+ }
+
+ public function testReplaceMsg() {
+ global $wgContLang;
+
+ $messageCache = MessageCache::singleton();
+ $message = 'go';
+ $uckey = $wgContLang->ucfirst( $message );
+ $oldText = $messageCache->get( $message ); // "Ausführen"
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ ); // simulate request and block deferred updates
+ $messageCache->replace( $uckey, 'Allez!' );
+ $this->assertEquals( 'Allez!',
+ $messageCache->getMsgFromNamespace( $uckey, 'de' ),
+ 'Updates are reflected in-process immediately' );
+ $this->assertEquals( 'Allez!',
+ $messageCache->get( $message ),
+ 'Updates are reflected in-process immediately' );
+ $this->makePage( 'Go', 'de', 'Race!' );
+ $dbw->endAtomic( __METHOD__ );
+
+ $this->assertEquals( 0,
+ DeferredUpdates::pendingUpdatesCount(),
+ 'Post-commit deferred update triggers a run of all updates' );
+
+ $this->assertEquals( 'Race!', $messageCache->get( $message ), 'Correct final contents' );
+
+ $this->makePage( 'Go', 'de', $oldText );
+ $messageCache->replace( $uckey, $oldText ); // deferred update runs immediately
+ $this->assertEquals( $oldText, $messageCache->get( $message ), 'Content restored' );
+ }
+
+ /**
+ * There's a fallback case where the message key is given as fully qualified -- this
+ * should ignore the passed $lang and use the language from the key
+ *
+ * @dataProvider provideMessagesForFullKeys
+ */
+ public function testFullKeyBehaviour( $message, $lang, $expectedContent ) {
+ $result = MessageCache::singleton()->get( $message, true, $lang, true );
+ $this->assertEquals( $expectedContent, $result, "Full key message fallback failed." );
+ }
+
+ function provideMessagesForFullKeys() {
+ return [
+ [ 'MessageCacheTest-FullKeyTest/ru', 'ru', 'ru' ],
+ [ 'MessageCacheTest-FullKeyTest/ru', 'ab', 'ru' ],
+ [ 'MessageCacheTest-FullKeyTest/ru/foo', 'ru', false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideNormalizeKey
+ */
+ public function testNormalizeKey( $key, $expected ) {
+ $actual = MessageCache::normalizeKey( $key );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public function provideNormalizeKey() {
+ return [
+ [ 'Foo', 'foo' ],
+ [ 'foo', 'foo' ],
+ [ 'fOo', 'fOo' ],
+ [ 'FOO', 'fOO' ],
+ [ 'Foo bar', 'foo_bar' ],
+ [ 'Ćab', 'ćab' ],
+ [ 'Ćab_e 3', 'ćab_e_3' ],
+ [ 'ĆAB', 'ćAB' ],
+ [ 'ćab', 'ćab' ],
+ [ 'ćaB', 'ćaB' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/changes/CategoryMembershipChangeTest.php b/www/wiki/tests/phpunit/includes/changes/CategoryMembershipChangeTest.php
new file mode 100644
index 00000000..ca3ac1b6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/changes/CategoryMembershipChangeTest.php
@@ -0,0 +1,156 @@
+<?php
+
+/**
+ * @covers CategoryMembershipChange
+ *
+ * @group Database
+ *
+ * @author Addshore
+ */
+class CategoryMembershipChangeTest extends MediaWikiLangTestCase {
+
+ /**
+ * @var array|Title[]|User[]
+ */
+ private static $lastNotifyArgs;
+
+ /**
+ * @var int
+ */
+ private static $notifyCallCounter = 0;
+
+ /**
+ * @var RecentChange
+ */
+ private static $mockRecentChange;
+
+ /**
+ * @var Revision
+ */
+ private static $pageRev = null;
+
+ /**
+ * @var User
+ */
+ private static $revUser = null;
+
+ /**
+ * @var string
+ */
+ private static $pageName = 'CategoryMembershipChangeTestPage';
+
+ public static function newForCategorizationCallback() {
+ self::$lastNotifyArgs = func_get_args();
+ self::$notifyCallCounter += 1;
+ return self::$mockRecentChange;
+ }
+
+ public function setUp() {
+ parent::setUp();
+ self::$notifyCallCounter = 0;
+ self::$mockRecentChange = self::getMock( RecentChange::class );
+
+ $this->setContentLang( 'qqx' );
+ }
+
+ public function addDBDataOnce() {
+ $info = $this->insertPage( self::$pageName );
+ $title = $info['title'];
+
+ $page = WikiPage::factory( $title );
+ self::$pageRev = $page->getRevision();
+ self::$revUser = User::newFromId( self::$pageRev->getUser( Revision::RAW ) );
+ }
+
+ private function newChange( Revision $revision = null ) {
+ $change = new CategoryMembershipChange( Title::newFromText( self::$pageName ), $revision );
+ $change->overrideNewForCategorizationCallback(
+ 'CategoryMembershipChangeTest::newForCategorizationCallback'
+ );
+
+ return $change;
+ }
+
+ public function testChangeAddedNoRev() {
+ $change = $this->newChange();
+ $change->triggerCategoryAddedNotification( Title::newFromText( 'CategoryName', NS_CATEGORY ) );
+
+ $this->assertEquals( 1, self::$notifyCallCounter );
+
+ $this->assertTrue( strlen( self::$lastNotifyArgs[0] ) === 14 );
+ $this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
+ $this->assertEquals( '(autochange-username)', self::$lastNotifyArgs[2]->getName() );
+ $this->assertEquals( '(recentchanges-page-added-to-category: ' . self::$pageName . ')',
+ self::$lastNotifyArgs[3] );
+ $this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() );
+ $this->assertEquals( 0, self::$lastNotifyArgs[5] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[6] );
+ $this->assertEquals( null, self::$lastNotifyArgs[7] );
+ $this->assertEquals( 1, self::$lastNotifyArgs[8] );
+ $this->assertEquals( null, self::$lastNotifyArgs[9] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[10] );
+ }
+
+ public function testChangeRemovedNoRev() {
+ $change = $this->newChange();
+ $change->triggerCategoryRemovedNotification( Title::newFromText( 'CategoryName', NS_CATEGORY ) );
+
+ $this->assertEquals( 1, self::$notifyCallCounter );
+
+ $this->assertTrue( strlen( self::$lastNotifyArgs[0] ) === 14 );
+ $this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
+ $this->assertEquals( '(autochange-username)', self::$lastNotifyArgs[2]->getName() );
+ $this->assertEquals( '(recentchanges-page-removed-from-category: ' . self::$pageName . ')',
+ self::$lastNotifyArgs[3] );
+ $this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() );
+ $this->assertEquals( 0, self::$lastNotifyArgs[5] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[6] );
+ $this->assertEquals( null, self::$lastNotifyArgs[7] );
+ $this->assertEquals( 1, self::$lastNotifyArgs[8] );
+ $this->assertEquals( null, self::$lastNotifyArgs[9] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[10] );
+ }
+
+ public function testChangeAddedWithRev() {
+ $revision = Revision::newFromId( Title::newFromText( self::$pageName )->getLatestRevID() );
+ $change = $this->newChange( $revision );
+ $change->triggerCategoryAddedNotification( Title::newFromText( 'CategoryName', NS_CATEGORY ) );
+
+ $this->assertEquals( 1, self::$notifyCallCounter );
+
+ $this->assertTrue( strlen( self::$lastNotifyArgs[0] ) === 14 );
+ $this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
+ $this->assertEquals( self::$revUser->getName(), self::$lastNotifyArgs[2]->getName() );
+ $this->assertEquals( '(recentchanges-page-added-to-category: ' . self::$pageName . ')',
+ self::$lastNotifyArgs[3] );
+ $this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() );
+ $this->assertEquals( self::$pageRev->getParentId(), self::$lastNotifyArgs[5] );
+ $this->assertEquals( $revision->getId(), self::$lastNotifyArgs[6] );
+ $this->assertEquals( null, self::$lastNotifyArgs[7] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[8] );
+ $this->assertEquals( '127.0.0.1', self::$lastNotifyArgs[9] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[10] );
+ }
+
+ public function testChangeRemovedWithRev() {
+ $revision = Revision::newFromId( Title::newFromText( self::$pageName )->getLatestRevID() );
+ $change = $this->newChange( $revision );
+ $change->triggerCategoryRemovedNotification( Title::newFromText( 'CategoryName', NS_CATEGORY ) );
+
+ $this->assertEquals( 1, self::$notifyCallCounter );
+
+ $this->assertTrue( strlen( self::$lastNotifyArgs[0] ) === 14 );
+ $this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
+ $this->assertEquals( self::$revUser->getName(), self::$lastNotifyArgs[2]->getName() );
+ $this->assertEquals( '(recentchanges-page-removed-from-category: ' . self::$pageName . ')',
+ self::$lastNotifyArgs[3] );
+ $this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() );
+ $this->assertEquals( self::$pageRev->getParentId(), self::$lastNotifyArgs[5] );
+ $this->assertEquals( $revision->getId(), self::$lastNotifyArgs[6] );
+ $this->assertEquals( null, self::$lastNotifyArgs[7] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[8] );
+ $this->assertEquals( '127.0.0.1', self::$lastNotifyArgs[9] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[10] );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/changes/ChangesListBooleanFilterGroupTest.php b/www/wiki/tests/phpunit/includes/changes/ChangesListBooleanFilterGroupTest.php
new file mode 100644
index 00000000..d80b6c10
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/changes/ChangesListBooleanFilterGroupTest.php
@@ -0,0 +1,96 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers ChangesListBooleanFilterGroup
+ */
+class ChangesListBooleanFilterGroupTest extends MediaWikiTestCase {
+ public function testIsFullCoverage() {
+ $hideGroupDefault = TestingAccessWrapper::newFromObject(
+ new ChangesListBooleanFilterGroup( [
+ 'name' => 'groupName',
+ 'priority' => 1,
+ 'filters' => [],
+ ] )
+ );
+
+ $this->assertSame(
+ true,
+ $hideGroupDefault->isFullCoverage
+ );
+ }
+
+ public function testGetJsData() {
+ $definition = [
+ 'name' => 'some-group',
+ 'title' => 'some-group-title',
+ 'priority' => 1,
+ 'filters' => [
+ [
+ 'name' => 'hidefoo',
+ 'label' => 'foo-label',
+ 'description' => 'foo-description',
+ 'default' => true,
+ 'showHide' => 'showhidefoo',
+ 'priority' => 2,
+ ],
+ [
+ 'name' => 'hidebar',
+ 'label' => 'bar-label',
+ 'description' => 'bar-description',
+ 'default' => false,
+ 'priority' => 4,
+ ]
+ ],
+ ];
+
+ $group = new ChangesListBooleanFilterGroup( $definition );
+
+ $this->assertArrayEquals(
+ [
+ 'name' => 'some-group',
+ 'title' => 'some-group-title',
+ 'type' => ChangesListBooleanFilterGroup::TYPE,
+ 'priority' => 1,
+ 'filters' => [
+ [
+ 'name' => 'hidebar',
+ 'label' => 'bar-label',
+ 'description' => 'bar-description',
+ 'default' => false,
+ 'priority' => 4,
+ 'cssClass' => null,
+ 'conflicts' => [],
+ 'subset' => [],
+ 'defaultHighlightColor' => null,
+ ],
+ [
+ 'name' => 'hidefoo',
+ 'label' => 'foo-label',
+ 'description' => 'foo-description',
+ 'default' => true,
+ 'priority' => 2,
+ 'cssClass' => null,
+ 'conflicts' => [],
+ 'subset' => [],
+ 'defaultHighlightColor' => null,
+ ],
+ ],
+ 'conflicts' => [],
+ 'fullCoverage' => true,
+ 'messageKeys' => [
+ 'some-group-title',
+ 'bar-label',
+ 'bar-description',
+ 'foo-label',
+ 'foo-description',
+ ],
+ ],
+
+ $group->getJsData(),
+ /** ordered= */ false,
+ /** named= */ true
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/changes/ChangesListBooleanFilterTest.php b/www/wiki/tests/phpunit/includes/changes/ChangesListBooleanFilterTest.php
new file mode 100644
index 00000000..35dc1a83
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/changes/ChangesListBooleanFilterTest.php
@@ -0,0 +1,166 @@
+<?php
+
+/**
+ * @covers ChangesListBooleanFilter
+ */
+class ChangesListBooleanFilterTest extends MediaWikiTestCase {
+ public function testGetJsData() {
+ $group = new ChangesListBooleanFilterGroup( [
+ 'name' => 'group',
+ 'priority' => 2,
+ 'filters' => [],
+ ] );
+
+ $definition = [
+ 'group' => $group,
+ 'label' => 'main-label',
+ 'description' => 'main-description',
+ 'default' => 1,
+ 'priority' => 1,
+ ];
+
+ $fooFilter = new ChangesListBooleanFilter(
+ $definition + [ 'name' => 'hidefoo' ]
+ );
+
+ $barFilter = new ChangesListBooleanFilter(
+ $definition + [ 'name' => 'hidebar' ]
+ );
+
+ $bazFilter = new ChangesListBooleanFilter(
+ $definition + [ 'name' => 'hidebaz' ]
+ );
+
+ $fooFilter->conflictsWith(
+ $barFilter,
+ 'foo-bar-global-conflict',
+ 'foo-conflicts-bar',
+ 'bar-conflicts-foo'
+ );
+
+ $fooFilter->setAsSupersetOf( $bazFilter, 'foo-superset-of-baz' );
+
+ $fooData = $fooFilter->getJsData();
+ $this->assertArrayEquals(
+ [
+ 'name' => 'hidefoo',
+ 'label' => 'main-label',
+ 'description' => 'main-description',
+ 'default' => 1,
+ 'priority' => 1,
+ 'cssClass' => null,
+ 'defaultHighlightColor' => null,
+ 'conflicts' => [
+ [
+ 'group' => 'group',
+ 'filter' => 'hidebar',
+ 'globalDescription' => 'foo-bar-global-conflict',
+ 'contextDescription' => 'foo-conflicts-bar',
+ ]
+ ],
+ 'subset' => [
+ [
+ 'group' => 'group',
+ 'filter' => 'hidebaz',
+ ],
+
+ ],
+ 'messageKeys' => [
+ 'main-label',
+ 'main-description',
+ 'foo-bar-global-conflict',
+ 'foo-conflicts-bar',
+ ],
+ ],
+ $fooData,
+ /** ordered= */ false,
+ /** named= */ true
+ );
+
+ $barData = $barFilter->getJsData();
+ $this->assertArrayEquals(
+ [
+ 'name' => 'hidebar',
+ 'label' => 'main-label',
+ 'description' => 'main-description',
+ 'default' => 1,
+ 'priority' => 1,
+ 'cssClass' => null,
+ 'defaultHighlightColor' => null,
+ 'conflicts' => [
+ [
+ 'group' => 'group',
+ 'filter' => 'hidefoo',
+ 'globalDescription' => 'foo-bar-global-conflict',
+ 'contextDescription' => 'bar-conflicts-foo',
+ ]
+ ],
+ 'subset' => [],
+ 'messageKeys' => [
+ 'main-label',
+ 'main-description',
+ 'foo-bar-global-conflict',
+ 'bar-conflicts-foo',
+ ],
+ ],
+ $barData,
+ /** ordered= */ false,
+ /** named= */ true
+ );
+ }
+
+ public function testIsFeatureAvailableOnStructuredUi() {
+ $groupA = new ChangesListBooleanFilterGroup( [
+ 'name' => 'groupA',
+ 'priority' => 1,
+ 'filters' => [],
+ ] );
+
+ $foo = new ChangesListBooleanFilter( [
+ 'name' => 'hidefoo',
+ 'group' => $groupA,
+ 'label' => 'foo-label',
+ 'description' => 'foo-description',
+ 'default' => true,
+ 'showHide' => 'showhidefoo',
+ 'priority' => 2,
+ ] );
+
+ $this->assertEquals(
+ true,
+ $foo->isFeatureAvailableOnStructuredUi(),
+ 'Same filter appears on both'
+ );
+
+ // Should only be legacy ones that haven't been ported yet
+ $bar = new ChangesListBooleanFilter( [
+ 'name' => 'hidebar',
+ 'default' => true,
+ 'group' => $groupA,
+ 'showHide' => 'showhidebar',
+ 'priority' => 2,
+ ] );
+
+ $this->assertEquals(
+ false,
+ $bar->isFeatureAvailableOnStructuredUi(),
+ 'Only on unstructured UI'
+ );
+
+ $baz = new ChangesListBooleanFilter( [
+ 'name' => 'hidebaz',
+ 'default' => true,
+ 'group' => $groupA,
+ 'showHide' => 'showhidebaz',
+ 'isReplacedInStructuredUi' => true,
+ 'priority' => 2,
+ ] );
+
+ $this->assertEquals(
+ true,
+ $baz->isFeatureAvailableOnStructuredUi(),
+ 'Legacy filter does not appear directly in new UI, but equivalent ' .
+ 'does and is marked with isReplacedInStructuredUi'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/changes/ChangesListFilterGroupTest.php b/www/wiki/tests/phpunit/includes/changes/ChangesListFilterGroupTest.php
new file mode 100644
index 00000000..6190516e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/changes/ChangesListFilterGroupTest.php
@@ -0,0 +1,79 @@
+<?php
+
+/**
+ * @covers ChangesListFilterGroup
+ */
+class ChangesListFilterGroupTest extends MediaWikiTestCase {
+ /**
+ * phpcs:disable Generic.Files.LineLength
+ * @expectedException MWException
+ * @expectedExceptionMessage Group names may not contain '_'. Use the naming convention: 'camelCase'
+ * phpcs:enable
+ */
+ public function testReservedCharacter() {
+ new MockChangesListFilterGroup(
+ [
+ 'type' => 'some_type',
+ 'name' => 'group_name',
+ 'priority' => 1,
+ 'filters' => [],
+ ]
+ );
+ }
+
+ public function testAutoPriorities() {
+ $group = new MockChangesListFilterGroup(
+ [
+ 'type' => 'some_type',
+ 'name' => 'groupName',
+ 'isFullCoverage' => true,
+ 'priority' => 1,
+ 'filters' => [
+ [ 'name' => 'hidefoo' ],
+ [ 'name' => 'hidebar' ],
+ [ 'name' => 'hidebaz' ],
+ ],
+ ]
+ );
+
+ $filters = $group->getFilters();
+ $this->assertEquals(
+ [
+ -2,
+ -3,
+ -4,
+ ],
+ array_map(
+ function ( $f ) {
+ return $f->getPriority();
+ },
+ array_values( $filters )
+ )
+ );
+ }
+
+ // Get without warnings
+ public function testGetFilter() {
+ $group = new MockChangesListFilterGroup(
+ [
+ 'type' => 'some_type',
+ 'name' => 'groupName',
+ 'isFullCoverage' => true,
+ 'priority' => 1,
+ 'filters' => [
+ [ 'name' => 'foo' ],
+ ],
+ ]
+ );
+
+ $this->assertEquals(
+ 'foo',
+ $group->getFilter( 'foo' )->getName()
+ );
+
+ $this->assertEquals(
+ null,
+ $group->getFilter( 'bar' )
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/changes/ChangesListFilterTest.php b/www/wiki/tests/phpunit/includes/changes/ChangesListFilterTest.php
new file mode 100644
index 00000000..039658e2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/changes/ChangesListFilterTest.php
@@ -0,0 +1,116 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers ChangesListFilter
+ */
+class ChangesListFilterTest extends MediaWikiTestCase {
+ protected $group;
+
+ public function setUp() {
+ $this->group = $this->getGroup( [ 'name' => 'group' ] );
+
+ parent::setUp();
+ }
+
+ protected function getGroup( $groupDefinition ) {
+ return new MockChangesListFilterGroup(
+ $groupDefinition + [
+ 'isFullCoverage' => true,
+ 'type' => 'some_type',
+ 'name' => 'group',
+ 'filters' => [],
+ ]
+ );
+ }
+
+ /**
+ * phpcs:disable Generic.Files.LineLength
+ * @expectedException MWException
+ * @expectedExceptionMessage Filter names may not contain '_'. Use the naming convention: 'lowercase'
+ * phpcs:enable
+ */
+ public function testReservedCharacter() {
+ $filter = new MockChangesListFilter(
+ [
+ 'group' => $this->group,
+ 'name' => 'some_name',
+ 'priority' => 1,
+ ]
+ );
+ }
+
+ /**
+ * @expectedException MWException
+ * @expectedExceptionMessage Two filters in a group cannot have the same name: 'somename'
+ */
+ public function testDuplicateName() {
+ new MockChangesListFilter(
+ [
+ 'group' => $this->group,
+ 'name' => 'somename',
+ 'priority' => 1,
+ ]
+ );
+
+ new MockChangesListFilter(
+ [
+ 'group' => $this->group,
+ 'name' => 'somename',
+ 'priority' => 2,
+ ]
+ );
+ }
+
+ /**
+ * @expectedException MWException
+ * @expectedExceptionMessage Supersets can only be defined for filters in the same group
+ */
+ public function testSetAsSupersetOf() {
+ $groupA = $this->getGroup(
+ [
+ 'name' => 'groupA',
+ 'filters' => [
+ [
+ 'name' => 'foo',
+ ],
+ [
+ 'name' => 'bar',
+ ]
+ ],
+ ]
+ );
+
+ $groupB = $this->getGroup(
+ [
+ 'name' => 'groupB',
+ 'filters' => [
+ [
+ 'name' => 'baz',
+ ],
+ ],
+ ]
+ );
+
+ $foo = TestingAccessWrapper::newFromObject( $groupA->getFilter( 'foo' ) );
+
+ $bar = $groupA->getFilter( 'bar' );
+
+ $baz = $groupB->getFilter( 'baz' );
+
+ $foo->setAsSupersetOf( $bar );
+ $this->assertArrayEquals( [
+ [
+ 'group' => 'groupA',
+ 'filter' => 'bar',
+ ],
+ ],
+ $foo->subsetFilters,
+ /** ordered= */ false,
+ /** named= */ true
+ );
+
+ $foo->setAsSupersetOf( $baz );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/changes/ChangesListStringOptionsFilterGroupTest.php b/www/wiki/tests/phpunit/includes/changes/ChangesListStringOptionsFilterGroupTest.php
new file mode 100644
index 00000000..b627178a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/changes/ChangesListStringOptionsFilterGroupTest.php
@@ -0,0 +1,279 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers ChangesListStringOptionsFilterGroup
+ */
+class ChangesListStringOptionsFilterGroupTest extends MediaWikiTestCase {
+ /**
+ * @expectedException MWException
+ */
+ public function testIsFullCoverage() {
+ $falseGroup = TestingAccessWrapper::newFromObject(
+ new ChangesListStringOptionsFilterGroup( [
+ 'name' => 'group',
+ 'filters' => [],
+ 'isFullCoverage' => false,
+ 'queryCallable' => function () {
+ }
+ ] )
+ );
+
+ $this->assertSame(
+ false,
+ $falseGroup->isFullCoverage
+ );
+
+ // Should throw due to missing isFullCoverage
+ $undefinedFullCoverageGroup = new ChangesListStringOptionsFilterGroup( [
+ 'name' => 'othergroup',
+ 'filters' => [],
+ ] );
+ }
+
+ /**
+ * @param array $filterDefinitions Array of filter definitions
+ * @param array $expectedValues Array of values callback should receive
+ * @param string $input Value in URL
+ *
+ * @dataProvider provideModifyQuery
+ */
+ public function testModifyQuery( $filterDefinitions, $expectedValues, $input ) {
+ $queryCallable = function (
+ $className,
+ $ctx,
+ $dbr,
+ &$tables,
+ &$fields,
+ &$conds,
+ &$query_options,
+ &$join_conds,
+ $actualSelectedValues
+ ) use ( $expectedValues ) {
+ $this->assertSame(
+ $expectedValues,
+ $actualSelectedValues
+ );
+ };
+
+ $groupDefinition = [
+ 'name' => 'group',
+ 'default' => '',
+ 'isFullCoverage' => true,
+ 'filters' => $filterDefinitions,
+ 'queryCallable' => $queryCallable,
+ ];
+
+ $this->modifyQueryHelper( $groupDefinition, $input );
+ }
+
+ public function provideModifyQuery() {
+ $mixedFilters = [
+ [
+ 'name' => 'foo',
+ ],
+ [
+ 'name' => 'baz',
+ ],
+ [
+ 'name' => 'goo'
+ ],
+ ];
+
+ return [
+ [
+ $mixedFilters,
+ [ 'baz', 'foo', ],
+ 'foo;bar;BaZ;invalid',
+ ],
+
+ [
+ $mixedFilters,
+ [ 'baz', 'foo', 'goo' ],
+ 'all',
+ ],
+ ];
+ }
+
+ /**
+ * @param array $filterDefinitions Array of filter definitions
+ * @param string $input Value in URL
+ * @param string $message Message thrown by exception
+ *
+ * @dataProvider provideNoOpModifyQuery
+ */
+ public function testNoOpModifyQuery( $filterDefinitions, $input, $message ) {
+ $noFiltersAllowedCallable = function (
+ $className,
+ $ctx,
+ $dbr,
+ &$tables,
+ &$fields,
+ &$conds,
+ &$query_options,
+ &$join_conds,
+ $actualSelectedValues
+ ) use ( $message ) {
+ throw new MWException( $message );
+ };
+
+ $groupDefinition = [
+ 'name' => 'group',
+ 'default' => '',
+ 'isFullCoverage' => true,
+ 'filters' => $filterDefinitions,
+ 'queryCallable' => $noFiltersAllowedCallable,
+ ];
+
+ $this->modifyQueryHelper( $groupDefinition, $input );
+
+ $this->assertTrue(
+ true,
+ 'Test successfully completed without calling queryCallable'
+ );
+ }
+
+ public function provideNoOpModifyQuery() {
+ $noFilters = [];
+
+ $normalFilters = [
+ [
+ 'name' => 'foo',
+ ],
+ [
+ 'name' => 'bar',
+ ]
+ ];
+
+ return [
+ [
+ $noFilters,
+ 'disallowed1;disallowed3',
+ 'The queryCallable should not be called if there are no filters',
+ ],
+
+ [
+ $normalFilters,
+ '',
+ 'The queryCallable should not be called if no filters are selected',
+ ],
+
+ [
+ $normalFilters,
+ 'invalid1',
+ 'The queryCallable should not be called if no valid filters are selected',
+ ],
+ ];
+ }
+
+ protected function getSpecialPage() {
+ return $this->getMockBuilder( ChangesListSpecialPage::class )
+ ->setConstructorArgs( [
+ 'ChangesListSpecialPage',
+ '',
+ ] )
+ ->getMockForAbstractClass();
+ }
+
+ /**
+ * @param array $groupDefinition Group definition
+ * @param string $input Value in URL
+ */
+ protected function modifyQueryHelper( $groupDefinition, $input ) {
+ $ctx = $this->createMock( IContextSource::class );
+ $dbr = $this->createMock( Wikimedia\Rdbms\IDatabase::class );
+ $tables = $fields = $conds = $query_options = $join_conds = [];
+
+ $group = new ChangesListStringOptionsFilterGroup( $groupDefinition );
+
+ $specialPage = $this->getSpecialPage();
+ $opts = new FormOptions();
+ $opts->add( $groupDefinition[ 'name' ], $input );
+
+ $group->modifyQuery(
+ $dbr,
+ $specialPage,
+ $tables,
+ $fields,
+ $conds,
+ $query_options,
+ $join_conds,
+ $opts,
+ true
+ );
+ }
+
+ public function testGetJsData() {
+ $definition = [
+ 'name' => 'some-group',
+ 'title' => 'some-group-title',
+ 'default' => 'foo',
+ 'priority' => 1,
+ 'isFullCoverage' => false,
+ 'queryCallable' => function () {
+ },
+ 'filters' => [
+ [
+ 'name' => 'foo',
+ 'label' => 'foo-label',
+ 'description' => 'foo-description',
+ 'priority' => 2,
+ ],
+ [
+ 'name' => 'bar',
+ 'label' => 'bar-label',
+ 'description' => 'bar-description',
+ 'priority' => 4,
+ ]
+ ],
+ ];
+
+ $group = new ChangesListStringOptionsFilterGroup( $definition );
+
+ $this->assertArrayEquals(
+ [
+ 'name' => 'some-group',
+ 'title' => 'some-group-title',
+ 'type' => ChangesListStringOptionsFilterGroup::TYPE,
+ 'default' => 'foo',
+ 'priority' => 1,
+ 'fullCoverage' => false,
+ 'filters' => [
+ [
+ 'name' => 'bar',
+ 'label' => 'bar-label',
+ 'description' => 'bar-description',
+ 'priority' => 4,
+ 'cssClass' => null,
+ 'conflicts' => [],
+ 'subset' => [],
+ 'defaultHighlightColor' => null,
+ ],
+ [
+ 'name' => 'foo',
+ 'label' => 'foo-label',
+ 'description' => 'foo-description',
+ 'priority' => 2,
+ 'cssClass' => null,
+ 'conflicts' => [],
+ 'subset' => [],
+ 'defaultHighlightColor' => null,
+ ],
+ ],
+ 'conflicts' => [],
+ 'separator' => ';',
+ 'messageKeys' => [
+ 'some-group-title',
+ 'bar-label',
+ 'bar-description',
+ 'foo-label',
+ 'foo-description',
+ ],
+ ],
+ $group->getJsData(),
+ /** ordered= */ false,
+ /** named= */ true
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/changes/EnhancedChangesListTest.php b/www/wiki/tests/phpunit/includes/changes/EnhancedChangesListTest.php
new file mode 100644
index 00000000..420fe749
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/changes/EnhancedChangesListTest.php
@@ -0,0 +1,228 @@
+<?php
+
+/**
+ * @covers EnhancedChangesList
+ *
+ * @group Database
+ *
+ * @author Katie Filbert < aude.wiki@gmail.com >
+ */
+class EnhancedChangesListTest extends MediaWikiLangTestCase {
+
+ /**
+ * @var TestRecentChangesHelper
+ */
+ private $testRecentChangesHelper;
+
+ public function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+
+ $this->testRecentChangesHelper = new TestRecentChangesHelper();
+ }
+
+ public function testBeginRecentChangesList_styleModules() {
+ $enhancedChangesList = $this->newEnhancedChangesList();
+ $enhancedChangesList->beginRecentChangesList();
+
+ $styleModules = $enhancedChangesList->getOutput()->getModuleStyles();
+
+ $this->assertContains(
+ 'mediawiki.special.changeslist',
+ $styleModules,
+ 'has mediawiki.special.changeslist'
+ );
+
+ $this->assertContains(
+ 'mediawiki.special.changeslist.enhanced',
+ $styleModules,
+ 'has mediawiki.special.changeslist.enhanced'
+ );
+ }
+
+ public function testBeginRecentChangesList_jsModules() {
+ $enhancedChangesList = $this->newEnhancedChangesList();
+ $enhancedChangesList->beginRecentChangesList();
+
+ $modules = $enhancedChangesList->getOutput()->getModules();
+
+ $this->assertContains( 'jquery.makeCollapsible', $modules, 'has jquery.makeCollapsible' );
+ $this->assertContains( 'mediawiki.icon', $modules, 'has mediawiki.icon' );
+ }
+
+ public function testBeginRecentChangesList_html() {
+ $enhancedChangesList = $this->newEnhancedChangesList();
+ $html = $enhancedChangesList->beginRecentChangesList();
+
+ $this->assertEquals( '<div class="mw-changeslist">', $html );
+ }
+
+ /**
+ * @todo more tests
+ */
+ public function testRecentChangesLine() {
+ $enhancedChangesList = $this->newEnhancedChangesList();
+ $enhancedChangesList->beginRecentChangesList();
+
+ $recentChange = $this->getEditChange( '20131103092153' );
+ $html = $enhancedChangesList->recentChangesLine( $recentChange, false );
+
+ $this->assertInternalType( 'string', $html );
+
+ $recentChange2 = $this->getEditChange( '20131103092253' );
+ $html = $enhancedChangesList->recentChangesLine( $recentChange2, false );
+
+ $this->assertEquals( '', $html );
+ }
+
+ public function testRecentChangesPrefix() {
+ $mockContext = $this->getMockBuilder( RequestContext::class )
+ ->setMethods( [ 'getTitle' ] )
+ ->getMock();
+ $mockContext->method( 'getTitle' )
+ ->will( $this->returnValue( Title::newFromText( 'Expected Context Title' ) ) );
+
+ // One group of two lines
+ $enhancedChangesList = $this->newEnhancedChangesList();
+ $enhancedChangesList->setContext( $mockContext );
+ $enhancedChangesList->setChangeLinePrefixer( function ( $rc, $changesList ) {
+ // Make sure RecentChange and ChangesList objects are the same
+ $this->assertEquals( 'Expected Context Title', $changesList->getContext()->getTitle() );
+ $this->assertTrue( $rc->getTitle() == 'Cat' || $rc->getTitle() == 'Dog' );
+ return 'Hello world prefix';
+ } );
+ $enhancedChangesList->beginRecentChangesList();
+
+ $recentChange = $this->getEditChange( '20131103092153' );
+ $enhancedChangesList->recentChangesLine( $recentChange );
+ $recentChange = $this->getEditChange( '20131103092154' );
+ $enhancedChangesList->recentChangesLine( $recentChange );
+
+ $html = $enhancedChangesList->endRecentChangesList();
+
+ $this->assertRegExp( '/Hello world prefix/', $html );
+
+ // Two separate lines
+ $enhancedChangesList->beginRecentChangesList();
+
+ $recentChange = $this->getEditChange( '20131103092153' );
+ $enhancedChangesList->recentChangesLine( $recentChange );
+ $recentChange = $this->getEditChange( '20131103092154', 'Dog' );
+ $enhancedChangesList->recentChangesLine( $recentChange );
+
+ $html = $enhancedChangesList->endRecentChangesList();
+
+ preg_match_all( '/Hello world prefix/', $html, $matches );
+ $this->assertCount( 2, $matches[0] );
+ }
+
+ public function testCategorizationLineFormatting() {
+ $html = $this->createCategorizationLine(
+ $this->getCategorizationChange( '20150629191735', 0, 0 )
+ );
+ $this->assertNotContains( '(diff | hist)', strip_tags( $html ) );
+ }
+
+ public function testCategorizationLineFormattingWithRevision() {
+ $html = $this->createCategorizationLine(
+ $this->getCategorizationChange( '20150629191735', 1025, 1024 )
+ );
+ $this->assertContains( '(diff | hist)', strip_tags( $html ) );
+ }
+
+ /**
+ * @todo more tests for actual formatting, this is more of a smoke test
+ */
+ public function testEndRecentChangesList() {
+ $enhancedChangesList = $this->newEnhancedChangesList();
+ $enhancedChangesList->beginRecentChangesList();
+
+ $recentChange = $this->getEditChange( '20131103092153' );
+ $enhancedChangesList->recentChangesLine( $recentChange, false );
+
+ $html = $enhancedChangesList->endRecentChangesList();
+ $this->assertRegExp(
+ '/data-mw-revid="5" data-mw-ts="20131103092153" class="[^"]*mw-enhanced-rc[^"]*"/',
+ $html
+ );
+
+ $recentChange2 = $this->getEditChange( '20131103092253' );
+ $enhancedChangesList->recentChangesLine( $recentChange2, false );
+
+ $html = $enhancedChangesList->endRecentChangesList();
+
+ preg_match_all( '/td class="mw-enhanced-rc-nested"/', $html, $matches );
+ $this->assertCount( 2, $matches[0] );
+
+ preg_match_all( '/data-target-page="Cat"/', $html, $matches );
+ $this->assertCount( 2, $matches[0] );
+
+ $recentChange3 = $this->getLogChange();
+ $enhancedChangesList->recentChangesLine( $recentChange3, false );
+
+ $html = $enhancedChangesList->endRecentChangesList();
+ $this->assertContains( 'data-mw-logaction="foo/bar"', $html );
+ $this->assertContains( 'data-mw-logid="25"', $html );
+ $this->assertContains( 'data-target-page="Title"', $html );
+ }
+
+ /**
+ * @return EnhancedChangesList
+ */
+ private function newEnhancedChangesList() {
+ $user = User::newFromId( 0 );
+ $context = $this->testRecentChangesHelper->getTestContext( $user );
+
+ return new EnhancedChangesList( $context );
+ }
+
+ /**
+ * @return RecentChange
+ */
+ private function getEditChange( $timestamp, $pageTitle = 'Cat' ) {
+ $user = $this->getMutableTestUser()->getUser();
+ $recentChange = $this->testRecentChangesHelper->makeEditRecentChange(
+ $user, $pageTitle, 0, 5, 191, $timestamp, 0, 0
+ );
+
+ return $recentChange;
+ }
+
+ private function getLogChange() {
+ $user = $this->getMutableTestUser()->getUser();
+ $recentChange = $this->testRecentChangesHelper->makeLogRecentChange( 'foo', 'bar', $user,
+ 'Title', '20131103092153', 0, 0
+ );
+
+ return $recentChange;
+ }
+
+ /**
+ * @return RecentChange
+ */
+ private function getCategorizationChange( $timestamp, $thisId, $lastId ) {
+ $wikiPage = new WikiPage( Title::newFromText( 'Testpage' ) );
+ $wikiPage->doEditContent( new WikitextContent( 'Some random text' ), 'page created' );
+
+ $wikiPage = new WikiPage( Title::newFromText( 'Category:Foo' ) );
+ $wikiPage->doEditContent( new WikitextContent( 'Some random text' ), 'category page created' );
+
+ $user = $this->getMutableTestUser()->getUser();
+ $recentChange = $this->testRecentChangesHelper->makeCategorizationRecentChange(
+ $user, 'Category:Foo', $wikiPage->getId(), $thisId, $lastId, $timestamp
+ );
+
+ return $recentChange;
+ }
+
+ private function createCategorizationLine( $recentChange ) {
+ $enhancedChangesList = $this->newEnhancedChangesList();
+ $cacheEntry = $this->testRecentChangesHelper->getCacheEntry( $recentChange );
+
+ $reflection = new \ReflectionClass( get_class( $enhancedChangesList ) );
+ $method = $reflection->getMethod( 'recentChangesBlockLine' );
+ $method->setAccessible( true );
+
+ return $method->invokeArgs( $enhancedChangesList, [ $cacheEntry ] );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/changes/OldChangesListTest.php b/www/wiki/tests/phpunit/includes/changes/OldChangesListTest.php
new file mode 100644
index 00000000..91dc7312
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/changes/OldChangesListTest.php
@@ -0,0 +1,234 @@
+<?php
+
+/**
+ * @covers OldChangesList
+ *
+ * @todo add tests to cover article link, timestamp, character difference,
+ * log entry, user tool links, direction marks, tags, rollback,
+ * watching users, and date header.
+ *
+ * @group Database
+ *
+ * @author Katie Filbert < aude.wiki@gmail.com >
+ */
+class OldChangesListTest extends MediaWikiLangTestCase {
+
+ /**
+ * @var TestRecentChangesHelper
+ */
+ private $testRecentChangesHelper;
+
+ public function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+
+ $this->testRecentChangesHelper = new TestRecentChangesHelper();
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgArticlePath' => '/wiki/$1',
+ ] );
+ $this->setUserLang( 'qqx' );
+ }
+
+ /**
+ * @dataProvider recentChangesLine_CssForLineNumberProvider
+ */
+ public function testRecentChangesLine_CssForLineNumber( $expected, $linenumber, $message ) {
+ $oldChangesList = $this->getOldChangesList();
+ $recentChange = $this->getEditChange();
+
+ $line = $oldChangesList->recentChangesLine( $recentChange, false, $linenumber );
+
+ $this->assertRegExp( $expected, $line, $message );
+ }
+
+ public function recentChangesLine_CssForLineNumberProvider() {
+ return [
+ [ '/mw-line-odd/', 1, 'odd line number' ],
+ [ '/mw-line-even/', 2, 'even line number' ]
+ ];
+ }
+
+ public function testRecentChangesLine_NotWatchedCssClass() {
+ $oldChangesList = $this->getOldChangesList();
+ $recentChange = $this->getEditChange();
+
+ $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
+
+ $this->assertRegExp( '/mw-changeslist-line-not-watched/', $line );
+ }
+
+ public function testRecentChangesLine_WatchedCssClass() {
+ $oldChangesList = $this->getOldChangesList();
+ $recentChange = $this->getEditChange();
+
+ $line = $oldChangesList->recentChangesLine( $recentChange, true, 1 );
+
+ $this->assertRegExp( '/mw-changeslist-line-watched/', $line );
+ }
+
+ public function testRecentChangesLine_LogTitle() {
+ $oldChangesList = $this->getOldChangesList();
+ $recentChange = $this->getLogChange( 'delete', 'delete' );
+
+ $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
+
+ $this->assertRegExp( '/href="\/wiki\/Special:Log\/delete/', $line, 'link has href attribute' );
+ $this->assertRegExp( '/title="Special:Log\/delete/', $line, 'link has title attribute' );
+ $this->assertRegExp( "/dellogpage/", $line, 'link text' );
+ }
+
+ public function testRecentChangesLine_DiffHistLinks() {
+ $oldChangesList = $this->getOldChangesList();
+ $recentChange = $this->getEditChange();
+
+ $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
+
+ $this->assertRegExp(
+ '/title=Cat&amp;curid=20131103212153&amp;diff=5&amp;oldid=191/',
+ $line,
+ 'assert diff link'
+ );
+
+ $this->assertRegExp(
+ '/title=Cat&amp;curid=20131103212153&amp;action=history"/',
+ $line,
+ 'assert history link'
+ );
+ }
+
+ public function testRecentChangesLine_Flags() {
+ $oldChangesList = $this->getOldChangesList();
+ $recentChange = $this->getNewBotEditChange();
+
+ $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
+
+ $this->assertContains(
+ '<abbr class="newpage" title="(recentchanges-label-newpage)">(newpageletter)</abbr>',
+ $line,
+ 'new page flag'
+ );
+
+ $this->assertContains(
+ '<abbr class="botedit" title="(recentchanges-label-bot)">(boteditletter)</abbr>',
+ $line,
+ 'bot flag'
+ );
+ }
+
+ public function testRecentChangesLine_Attribs() {
+ $recentChange = $this->getEditChange();
+ $recentChange->mAttribs['ts_tags'] = 'vandalism,newbie';
+
+ $oldChangesList = $this->getOldChangesList();
+ $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
+
+ $this->assertRegExp(
+ '/<li data-mw-revid="\d+" data-mw-ts="\d+" class="[\w\s-]*mw-tag-vandalism[\w\s-]*">/',
+ $line
+ );
+ $this->assertRegExp(
+ '/<li data-mw-revid="\d+" data-mw-ts="\d+" class="[\w\s-]*mw-tag-newbie[\w\s-]*">/',
+ $line
+ );
+ }
+
+ public function testRecentChangesLine_numberOfWatchingUsers() {
+ $oldChangesList = $this->getOldChangesList();
+
+ $recentChange = $this->getEditChange();
+ $recentChange->numberofWatchingusers = 100;
+
+ $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
+ $this->assertRegExp( "/(number_of_watching_users_RCview: 100)/", $line );
+ }
+
+ public function testRecentChangesLine_watchlistCssClass() {
+ $oldChangesList = $this->getOldChangesList();
+ $oldChangesList->setWatchlistDivs( true );
+
+ $recentChange = $this->getEditChange();
+ $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
+ $this->assertRegExp( "/watchlist-0-Cat/", $line );
+ }
+
+ public function testRecentChangesLine_dataAttribute() {
+ $oldChangesList = $this->getOldChangesList();
+ $oldChangesList->setWatchlistDivs( true );
+
+ $recentChange = $this->getEditChange();
+ $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
+ $this->assertRegExp( '/data-target-page=\"Cat\"/', $line );
+
+ $recentChange = $this->getLogChange( 'delete', 'delete' );
+ $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
+ $this->assertRegExp( '/data-target-page="Abc"/', $line );
+ }
+
+ public function testRecentChangesLine_prefix() {
+ $mockContext = $this->getMockBuilder( RequestContext::class )
+ ->setMethods( [ 'getTitle' ] )
+ ->getMock();
+ $mockContext->method( 'getTitle' )
+ ->will( $this->returnValue( Title::newFromText( 'Expected Context Title' ) ) );
+
+ $oldChangesList = $this->getOldChangesList();
+ $oldChangesList->setContext( $mockContext );
+ $recentChange = $this->getEditChange();
+
+ $oldChangesList->setChangeLinePrefixer( function ( $rc, $changesList ) {
+ // Make sure RecentChange and ChangesList objects are the same
+ $this->assertEquals( 'Expected Context Title', $changesList->getContext()->getTitle() );
+ $this->assertEquals( 'Cat', $rc->getTitle() );
+ return 'I am a prefix';
+ } );
+ $line = $oldChangesList->recentChangesLine( $recentChange );
+ $this->assertRegExp( "/I am a prefix/", $line );
+ }
+
+ private function getNewBotEditChange() {
+ $user = $this->getMutableTestUser()->getUser();
+
+ $recentChange = $this->testRecentChangesHelper->makeNewBotEditRecentChange(
+ $user, 'Abc', '20131103212153', 5, 191, 190, 0, 0
+ );
+
+ return $recentChange;
+ }
+
+ private function getLogChange( $logType, $logAction ) {
+ $user = $this->getMutableTestUser()->getUser();
+
+ $recentChange = $this->testRecentChangesHelper->makeLogRecentChange(
+ $logType, $logAction, $user, 'Abc', '20131103212153', 0, 0
+ );
+
+ return $recentChange;
+ }
+
+ private function getEditChange() {
+ $user = $this->getMutableTestUser()->getUser();
+ $recentChange = $this->testRecentChangesHelper->makeEditRecentChange(
+ $user, 'Cat', '20131103212153', 5, 191, 190, 0, 0
+ );
+
+ return $recentChange;
+ }
+
+ private function getOldChangesList() {
+ $context = $this->getContext();
+ return new OldChangesList( $context );
+ }
+
+ private function getContext() {
+ $user = $this->getMutableTestUser()->getUser();
+ $context = $this->testRecentChangesHelper->getTestContext( $user );
+ $context->setLanguage( 'qqx' );
+
+ return $context;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php b/www/wiki/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php
new file mode 100644
index 00000000..b1857ccc
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php
@@ -0,0 +1,236 @@
+<?php
+
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @covers RCCacheEntryFactory
+ *
+ * @group Database
+ *
+ * @author Katie Filbert < aude.wiki@gmail.com >
+ */
+class RCCacheEntryFactoryTest extends MediaWikiLangTestCase {
+
+ /**
+ * @var TestRecentChangesHelper
+ */
+ private $testRecentChangesHelper;
+
+ /**
+ * @var LinkRenderer
+ */
+ private $linkRenderer;
+
+ public function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+
+ $this->testRecentChangesHelper = new TestRecentChangesHelper();
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgArticlePath' => '/wiki/$1'
+ ] );
+
+ $this->linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ }
+
+ public function testNewFromRecentChange() {
+ $user = $this->getMutableTestUser()->getUser();
+ $recentChange = $this->testRecentChangesHelper->makeEditRecentChange(
+ $user,
+ 'Xyz',
+ 5, // curid
+ 191, // thisid
+ 190, // lastid
+ '20131103212153',
+ 0, // counter
+ 0 // number of watching users
+ );
+ $cacheEntryFactory = new RCCacheEntryFactory(
+ $this->getContext(),
+ $this->getMessages(),
+ $this->linkRenderer
+ );
+ $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, false );
+
+ $this->assertInstanceOf( RCCacheEntry::class, $cacheEntry );
+
+ $this->assertEquals( false, $cacheEntry->watched, 'watched' );
+ $this->assertEquals( '21:21', $cacheEntry->timestamp, 'timestamp' );
+ $this->assertEquals( 0, $cacheEntry->numberofWatchingusers, 'watching users' );
+ $this->assertEquals( false, $cacheEntry->unpatrolled, 'unpatrolled' );
+
+ $this->assertUserLinks( $user->getName(), $cacheEntry );
+ $this->assertTitleLink( 'Xyz', $cacheEntry );
+
+ $diff = [ 'curid' => 5, 'diff' => 191, 'oldid' => 190 ];
+ $cur = [ 'curid' => 5, 'diff' => 0, 'oldid' => 191 ];
+ $this->assertQueryLink( 'cur', $cur, $cacheEntry->curlink );
+ $this->assertQueryLink( 'prev', $diff, $cacheEntry->lastlink );
+ $this->assertQueryLink( 'diff', $diff, $cacheEntry->difflink );
+ }
+
+ public function testNewForDeleteChange() {
+ $user = $this->getMutableTestUser()->getUser();
+ $recentChange = $this->testRecentChangesHelper->makeLogRecentChange(
+ 'delete',
+ 'delete',
+ $user,
+ 'Abc',
+ '20131103212153',
+ 0, // counter
+ 0 // number of watching users
+ );
+ $cacheEntryFactory = new RCCacheEntryFactory(
+ $this->getContext(),
+ $this->getMessages(),
+ $this->linkRenderer
+ );
+ $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, false );
+
+ $this->assertInstanceOf( RCCacheEntry::class, $cacheEntry );
+
+ $this->assertEquals( false, $cacheEntry->watched, 'watched' );
+ $this->assertEquals( '21:21', $cacheEntry->timestamp, 'timestamp' );
+ $this->assertEquals( 0, $cacheEntry->numberofWatchingusers, 'watching users' );
+ $this->assertEquals( false, $cacheEntry->unpatrolled, 'unpatrolled' );
+
+ $this->assertDeleteLogLink( $cacheEntry );
+ $this->assertUserLinks( $user->getName(), $cacheEntry );
+
+ $this->assertEquals( 'cur', $cacheEntry->curlink, 'cur link for delete log or rev' );
+ $this->assertEquals( 'diff', $cacheEntry->difflink, 'diff link for delete log or rev' );
+ $this->assertEquals( 'prev', $cacheEntry->lastlink, 'pref link for delete log or rev' );
+ }
+
+ public function testNewForRevUserDeleteChange() {
+ $user = $this->getMutableTestUser()->getUser();
+ $recentChange = $this->testRecentChangesHelper->makeDeletedEditRecentChange(
+ $user,
+ 'Zzz',
+ '20131103212153',
+ 191, // thisid
+ 190, // lastid
+ '20131103212153',
+ 0, // counter
+ 0 // number of watching users
+ );
+ $cacheEntryFactory = new RCCacheEntryFactory(
+ $this->getContext(),
+ $this->getMessages(),
+ $this->linkRenderer
+ );
+ $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, false );
+
+ $this->assertInstanceOf( RCCacheEntry::class, $cacheEntry );
+
+ $this->assertEquals( false, $cacheEntry->watched, 'watched' );
+ $this->assertEquals( '21:21', $cacheEntry->timestamp, 'timestamp' );
+ $this->assertEquals( 0, $cacheEntry->numberofWatchingusers, 'watching users' );
+ $this->assertEquals( false, $cacheEntry->unpatrolled, 'unpatrolled' );
+
+ $this->assertRevDel( $cacheEntry );
+ $this->assertTitleLink( 'Zzz', $cacheEntry );
+
+ $this->assertEquals( 'cur', $cacheEntry->curlink, 'cur link for delete log or rev' );
+ $this->assertEquals( 'diff', $cacheEntry->difflink, 'diff link for delete log or rev' );
+ $this->assertEquals( 'prev', $cacheEntry->lastlink, 'pref link for delete log or rev' );
+ }
+
+ private function assertValidHTML( $actual ) {
+ // Throws if invalid
+ $doc = PHPUnit_Util_XML::load( $actual, /* isHtml */ true );
+ }
+
+ private function assertUserLinks( $user, $cacheEntry ) {
+ $this->assertValidHTML( $cacheEntry->userlink );
+ $this->assertRegExp(
+ '#^<a .*class="new mw-userlink".*><bdi>' . $user . '</bdi></a>#',
+ $cacheEntry->userlink,
+ 'verify user link'
+ );
+
+ $this->assertValidHTML( $cacheEntry->usertalklink );
+ $this->assertRegExp(
+ '#^ <span class="mw-usertoollinks">\(.*<a .+>talk</a>.*\)</span>#',
+ $cacheEntry->usertalklink,
+ 'verify user talk link'
+ );
+
+ $this->assertValidHTML( $cacheEntry->usertalklink );
+ $this->assertRegExp(
+ '#^ <span class="mw-usertoollinks">\(.*<a .+>contribs</a>.*\)</span>$#',
+ $cacheEntry->usertalklink,
+ 'verify user tool links'
+ );
+ }
+
+ private function assertDeleteLogLink( $cacheEntry ) {
+ $this->assertEquals(
+ '(<a href="/wiki/Special:Log/delete" title="Special:Log/delete">Deletion log</a>)',
+ $cacheEntry->link,
+ 'verify deletion log link'
+ );
+
+ $this->assertValidHTML( $cacheEntry->link );
+ }
+
+ private function assertRevDel( $cacheEntry ) {
+ $this->assertEquals(
+ ' <span class="history-deleted">(username removed)</span>',
+ $cacheEntry->userlink,
+ 'verify user link for change with deleted revision and user'
+ );
+ $this->assertValidHTML( $cacheEntry->userlink );
+ }
+
+ private function assertTitleLink( $title, $cacheEntry ) {
+ $this->assertEquals(
+ '<a href="/wiki/' . $title . '" title="' . $title . '">' . $title . '</a>',
+ $cacheEntry->link,
+ 'verify title link'
+ );
+ $this->assertValidHTML( $cacheEntry->link );
+ }
+
+ private function assertQueryLink( $content, $params, $link ) {
+ $this->assertRegExp(
+ "#^<a .+>$content</a>$#",
+ $link,
+ 'verify query link element'
+ );
+ $this->assertValidHTML( $link );
+
+ foreach ( $params as $key => $value ) {
+ $this->assertRegExp( '/' . $key . '=' . $value . '/', $link, "verify $key link params" );
+ }
+ }
+
+ private function getMessages() {
+ return [
+ 'cur' => 'cur',
+ 'diff' => 'diff',
+ 'hist' => 'hist',
+ 'enhancedrc-history' => 'history',
+ 'last' => 'prev',
+ 'blocklink' => 'block',
+ 'history' => 'Page history',
+ 'semicolon-separator' => '; ',
+ 'pipe-separator' => ' | '
+ ];
+ }
+
+ private function getContext() {
+ $user = $this->getMutableTestUser()->getUser();
+ $context = $this->testRecentChangesHelper->getTestContext( $user );
+
+ $title = Title::newFromText( 'RecentChanges', NS_SPECIAL );
+ $context->setTitle( $title );
+
+ return $context;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/changes/RecentChangeTest.php b/www/wiki/tests/phpunit/includes/changes/RecentChangeTest.php
new file mode 100644
index 00000000..333eb286
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/changes/RecentChangeTest.php
@@ -0,0 +1,224 @@
+<?php
+use Wikimedia\ScopedCallback;
+
+/**
+ * @group Database
+ */
+class RecentChangeTest extends MediaWikiTestCase {
+ protected $title;
+ protected $target;
+ protected $user;
+ protected $user_comment;
+ protected $context;
+
+ public function setUp() {
+ parent::setUp();
+
+ $this->title = Title::newFromText( 'SomeTitle' );
+ $this->target = Title::newFromText( 'TestTarget' );
+ $this->user = User::newFromName( 'UserName' );
+
+ $this->user_comment = '<User comment about action>';
+ $this->context = RequestContext::newExtraneousContext( $this->title );
+ }
+
+ /**
+ * @covers RecentChange::newFromRow
+ * @covers RecentChange::loadFromRow
+ */
+ public function testNewFromRow() {
+ $user = $this->getTestUser()->getUser();
+ $actorId = $user->getActorId();
+
+ $row = new stdClass();
+ $row->rc_foo = 'AAA';
+ $row->rc_timestamp = '20150921134808';
+ $row->rc_deleted = 'bar';
+ $row->rc_comment_text = 'comment';
+ $row->rc_comment_data = null;
+ $row->rc_user = $user->getId();
+
+ $rc = RecentChange::newFromRow( $row );
+
+ $expected = [
+ 'rc_foo' => 'AAA',
+ 'rc_timestamp' => '20150921134808',
+ 'rc_deleted' => 'bar',
+ 'rc_comment' => 'comment',
+ 'rc_comment_text' => 'comment',
+ 'rc_comment_data' => null,
+ 'rc_user' => $user->getId(),
+ 'rc_user_text' => $user->getName(),
+ 'rc_actor' => $actorId,
+ ];
+ $this->assertEquals( $expected, $rc->getAttributes() );
+
+ $row = new stdClass();
+ $row->rc_foo = 'AAA';
+ $row->rc_timestamp = '20150921134808';
+ $row->rc_deleted = 'bar';
+ $row->rc_comment = 'comment';
+ $row->rc_user = $user->getId();
+
+ Wikimedia\suppressWarnings();
+ $rc = RecentChange::newFromRow( $row );
+ Wikimedia\restoreWarnings();
+
+ $expected = [
+ 'rc_foo' => 'AAA',
+ 'rc_timestamp' => '20150921134808',
+ 'rc_deleted' => 'bar',
+ 'rc_comment' => 'comment',
+ 'rc_comment_text' => 'comment',
+ 'rc_comment_data' => null,
+ 'rc_user' => $user->getId(),
+ 'rc_user_text' => $user->getName(),
+ 'rc_actor' => $actorId,
+ ];
+ $this->assertEquals( $expected, $rc->getAttributes() );
+ }
+
+ /**
+ * @covers RecentChange::parseParams
+ */
+ public function testParseParams() {
+ $params = [
+ 'root' => [
+ 'A' => 1,
+ 'B' => 'two'
+ ]
+ ];
+
+ $this->assertParseParams(
+ $params,
+ 'a:1:{s:4:"root";a:2:{s:1:"A";i:1;s:1:"B";s:3:"two";}}'
+ );
+
+ $this->assertParseParams(
+ null,
+ null
+ );
+
+ $this->assertParseParams(
+ null,
+ serialize( false )
+ );
+
+ $this->assertParseParams(
+ null,
+ 'not-an-array'
+ );
+ }
+
+ /**
+ * @param array $expectedParseParams
+ * @param string|null $rawRcParams
+ */
+ protected function assertParseParams( $expectedParseParams, $rawRcParams ) {
+ $rc = new RecentChange;
+ $rc->setAttribs( [ 'rc_params' => $rawRcParams ] );
+
+ $actualParseParams = $rc->parseParams();
+
+ $this->assertEquals( $expectedParseParams, $actualParseParams );
+ }
+
+ /**
+ * @return array
+ */
+ public function provideIsInRCLifespan() {
+ return [
+ [ 6000, -3000, 0, true ],
+ [ 3000, -6000, 0, false ],
+ [ 6000, -3000, 6000, true ],
+ [ 3000, -6000, 6000, true ],
+ ];
+ }
+
+ /**
+ * @covers RecentChange::isInRCLifespan
+ * @dataProvider provideIsInRCLifespan
+ */
+ public function testIsInRCLifespan( $maxAge, $offset, $tolerance, $expected ) {
+ $this->setMwGlobals( 'wgRCMaxAge', $maxAge );
+ // Calculate this here instead of the data provider because the provider
+ // is expanded early on and the full test suite may take longer than 100 minutes
+ // when coverage is enabled.
+ $timestamp = time() + $offset;
+ $this->assertEquals( $expected, RecentChange::isInRCLifespan( $timestamp, $tolerance ) );
+ }
+
+ public function provideRCTypes() {
+ return [
+ [ RC_EDIT, 'edit' ],
+ [ RC_NEW, 'new' ],
+ [ RC_LOG, 'log' ],
+ [ RC_EXTERNAL, 'external' ],
+ [ RC_CATEGORIZE, 'categorize' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideRCTypes
+ * @covers RecentChange::parseFromRCType
+ */
+ public function testParseFromRCType( $rcType, $type ) {
+ $this->assertEquals( $type, RecentChange::parseFromRCType( $rcType ) );
+ }
+
+ /**
+ * @dataProvider provideRCTypes
+ * @covers RecentChange::parseToRCType
+ */
+ public function testParseToRCType( $rcType, $type ) {
+ $this->assertEquals( $rcType, RecentChange::parseToRCType( $type ) );
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|PageProps
+ */
+ private function getMockPageProps() {
+ return $this->getMockBuilder( PageProps::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ public function provideCategoryContent() {
+ return [
+ [ true ],
+ [ false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCategoryContent
+ * @covers RecentChange::newForCategorization
+ */
+ public function testHiddenCategoryChange( $isHidden ) {
+ $categoryTitle = Title::newFromText( 'CategoryPage', NS_CATEGORY );
+
+ $pageProps = $this->getMockPageProps();
+ $pageProps->expects( $this->once() )
+ ->method( 'getProperties' )
+ ->with( $categoryTitle, 'hiddencat' )
+ ->will( $this->returnValue( $isHidden ? [ $categoryTitle->getArticleID() => '' ] : [] ) );
+
+ $scopedOverride = PageProps::overrideInstance( $pageProps );
+
+ $rc = RecentChange::newForCategorization(
+ '0',
+ $categoryTitle,
+ $this->user,
+ $this->user_comment,
+ $this->title,
+ $categoryTitle->getLatestRevID(),
+ $categoryTitle->getLatestRevID(),
+ '0',
+ false
+ );
+
+ $this->assertEquals( $isHidden, $rc->getParam( 'hidden-cat' ) );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/changes/TestRecentChangesHelper.php b/www/wiki/tests/phpunit/includes/changes/TestRecentChangesHelper.php
new file mode 100644
index 00000000..2c309487
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/changes/TestRecentChangesHelper.php
@@ -0,0 +1,170 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Helper for generating test recent changes entries.
+ *
+ * @author Katie Filbert < aude.wiki@gmail.com >
+ */
+class TestRecentChangesHelper {
+
+ public function makeEditRecentChange( User $user, $titleText, $curid, $thisid, $lastid,
+ $timestamp, $counter, $watchingUsers
+ ) {
+ $attribs = array_merge(
+ $this->getDefaultAttributes( $titleText, $timestamp ),
+ [
+ 'rc_user' => $user->getId(),
+ 'rc_user_text' => $user->getName(),
+ 'rc_this_oldid' => $thisid,
+ 'rc_last_oldid' => $lastid,
+ 'rc_cur_id' => $curid
+ ]
+ );
+
+ return $this->makeRecentChange( $attribs, $counter, $watchingUsers );
+ }
+
+ public function makeLogRecentChange(
+ $logType, $logAction, User $user, $titleText, $timestamp, $counter, $watchingUsers
+ ) {
+ $attribs = array_merge(
+ $this->getDefaultAttributes( $titleText, $timestamp ),
+ [
+ 'rc_cur_id' => 0,
+ 'rc_user' => $user->getId(),
+ 'rc_user_text' => $user->getName(),
+ 'rc_this_oldid' => 0,
+ 'rc_last_oldid' => 0,
+ 'rc_old_len' => null,
+ 'rc_new_len' => null,
+ 'rc_type' => 3,
+ 'rc_logid' => 25,
+ 'rc_log_type' => $logType,
+ 'rc_log_action' => $logAction,
+ 'rc_source' => 'mw.log'
+ ]
+ );
+
+ return $this->makeRecentChange( $attribs, $counter, $watchingUsers );
+ }
+
+ public function makeDeletedEditRecentChange( User $user, $titleText, $timestamp, $curid,
+ $thisid, $lastid, $counter, $watchingUsers
+ ) {
+ $attribs = array_merge(
+ $this->getDefaultAttributes( $titleText, $timestamp ),
+ [
+ 'rc_user' => $user->getId(),
+ 'rc_user_text' => $user->getName(),
+ 'rc_deleted' => 5,
+ 'rc_cur_id' => $curid,
+ 'rc_this_oldid' => $thisid,
+ 'rc_last_oldid' => $lastid
+ ]
+ );
+
+ return $this->makeRecentChange( $attribs, $counter, $watchingUsers );
+ }
+
+ public function makeNewBotEditRecentChange( User $user, $titleText, $curid, $thisid, $lastid,
+ $timestamp, $counter, $watchingUsers
+ ) {
+ $attribs = array_merge(
+ $this->getDefaultAttributes( $titleText, $timestamp ),
+ [
+ 'rc_user' => $user->getId(),
+ 'rc_user_text' => $user->getName(),
+ 'rc_this_oldid' => $thisid,
+ 'rc_last_oldid' => $lastid,
+ 'rc_cur_id' => $curid,
+ 'rc_type' => 1,
+ 'rc_bot' => 1,
+ 'rc_source' => 'mw.new'
+ ]
+ );
+
+ return $this->makeRecentChange( $attribs, $counter, $watchingUsers );
+ }
+
+ private function makeRecentChange( $attribs, $counter, $watchingUsers ) {
+ $change = new RecentChange();
+ $change->setAttribs( $attribs );
+ $change->counter = $counter;
+ $change->numberofWatchingusers = $watchingUsers;
+
+ return $change;
+ }
+
+ public function getCacheEntry( $recentChange ) {
+ $rcCacheFactory = new RCCacheEntryFactory(
+ new RequestContext(),
+ [ 'diff' => 'diff', 'cur' => 'cur', 'last' => 'last' ],
+ MediaWikiServices::getInstance()->getLinkRenderer()
+ );
+ return $rcCacheFactory->newFromRecentChange( $recentChange, false );
+ }
+
+ public function makeCategorizationRecentChange(
+ User $user, $titleText, $curid, $thisid, $lastid, $timestamp
+ ) {
+ $attribs = array_merge(
+ $this->getDefaultAttributes( $titleText, $timestamp ),
+ [
+ 'rc_type' => RC_CATEGORIZE,
+ 'rc_user' => $user->getId(),
+ 'rc_user_text' => $user->getName(),
+ 'rc_this_oldid' => $thisid,
+ 'rc_last_oldid' => $lastid,
+ 'rc_cur_id' => $curid,
+ 'rc_comment' => '[[:Testpage]] added to category',
+ 'rc_comment_text' => '[[:Testpage]] added to category',
+ 'rc_comment_data' => null,
+ 'rc_old_len' => 0,
+ 'rc_new_len' => 0,
+ ]
+ );
+
+ return $this->makeRecentChange( $attribs, 0, 0 );
+ }
+
+ private function getDefaultAttributes( $titleText, $timestamp ) {
+ return [
+ 'rc_id' => 545,
+ 'rc_user' => 0,
+ 'rc_user_text' => '127.0.0.1',
+ 'rc_ip' => '127.0.0.1',
+ 'rc_title' => $titleText,
+ 'rc_namespace' => 0,
+ 'rc_timestamp' => $timestamp,
+ 'rc_old_len' => 212,
+ 'rc_new_len' => 188,
+ 'rc_comment' => '',
+ 'rc_comment_text' => '',
+ 'rc_comment_data' => null,
+ 'rc_minor' => 0,
+ 'rc_bot' => 0,
+ 'rc_type' => 0,
+ 'rc_patrolled' => 1,
+ 'rc_deleted' => 0,
+ 'rc_logid' => 0,
+ 'rc_log_type' => null,
+ 'rc_log_action' => '',
+ 'rc_params' => '',
+ 'rc_source' => 'mw.edit'
+ ];
+ }
+
+ public function getTestContext( User $user ) {
+ $context = new RequestContext();
+ $context->setLanguage( 'en' );
+
+ $context->setUser( $user );
+
+ $title = Title::newFromText( 'RecentChanges', NS_SPECIAL );
+ $context->setTitle( $title );
+
+ return $context;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/changetags/ChangeTagsTest.php b/www/wiki/tests/phpunit/includes/changetags/ChangeTagsTest.php
new file mode 100644
index 00000000..63e0ec22
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/changetags/ChangeTagsTest.php
@@ -0,0 +1,309 @@
+<?php
+
+/**
+ * @covers ChangeTags
+ */
+class ChangeTagsTest extends MediaWikiTestCase {
+
+ // TODO only modifyDisplayQuery and getSoftwareTags are tested, nothing else is
+
+ /** @dataProvider provideModifyDisplayQuery */
+ public function testModifyDisplayQuery( $origQuery, $filter_tag, $useTags, $modifiedQuery ) {
+ $this->setMwGlobals( 'wgUseTagFilter', $useTags );
+ // HACK resolve deferred group concats (see comment in provideModifyDisplayQuery)
+ if ( isset( $modifiedQuery['fields']['ts_tags'] ) ) {
+ $modifiedQuery['fields']['ts_tags'] = call_user_func_array(
+ [ wfGetDB( DB_REPLICA ), 'buildGroupConcatField' ],
+ $modifiedQuery['fields']['ts_tags']
+ );
+ }
+ if ( isset( $modifiedQuery['exception'] ) ) {
+ $this->setExpectedException( $modifiedQuery['exception'] );
+ }
+ ChangeTags::modifyDisplayQuery(
+ $origQuery['tables'],
+ $origQuery['fields'],
+ $origQuery['conds'],
+ $origQuery['join_conds'],
+ $origQuery['options'],
+ $filter_tag
+ );
+ if ( !isset( $modifiedQuery['exception'] ) ) {
+ $this->assertArrayEquals(
+ $modifiedQuery,
+ $origQuery,
+ /* ordered = */ false,
+ /* named = */ true
+ );
+ }
+ }
+
+ public function provideModifyDisplayQuery() {
+ // HACK if we call $dbr->buildGroupConcatField() now, it will return the wrong table names
+ // We have to have the test runner call it instead
+ $groupConcats = [
+ 'recentchanges' => [ ',', 'change_tag', 'ct_tag', 'ct_rc_id=rc_id' ],
+ 'logging' => [ ',', 'change_tag', 'ct_tag', 'ct_log_id=log_id' ],
+ 'revision' => [ ',', 'change_tag', 'ct_tag', 'ct_rev_id=rev_id' ],
+ 'archive' => [ ',', 'change_tag', 'ct_tag', 'ct_rev_id=ar_rev_id' ],
+ ];
+
+ return [
+ 'simple recentchanges query' => [
+ [
+ 'tables' => [ 'recentchanges' ],
+ 'fields' => [ 'rc_id', 'rc_timestamp' ],
+ 'conds' => [ "rc_timestamp > '20170714183203'" ],
+ 'join_conds' => [],
+ 'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
+ ],
+ '', // no tag filter
+ true, // tag filtering enabled
+ [
+ 'tables' => [ 'recentchanges' ],
+ 'fields' => [ 'rc_id', 'rc_timestamp', 'ts_tags' => $groupConcats['recentchanges'] ],
+ 'conds' => [ "rc_timestamp > '20170714183203'" ],
+ 'join_conds' => [],
+ 'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
+ ]
+ ],
+ 'simple query with strings' => [
+ [
+ 'tables' => 'recentchanges',
+ 'fields' => 'rc_id',
+ 'conds' => "rc_timestamp > '20170714183203'",
+ 'join_conds' => [],
+ 'options' => 'ORDER BY rc_timestamp DESC',
+ ],
+ '', // no tag filter
+ true, // tag filtering enabled
+ [
+ 'tables' => [ 'recentchanges' ],
+ 'fields' => [ 'rc_id', 'ts_tags' => $groupConcats['recentchanges'] ],
+ 'conds' => [ "rc_timestamp > '20170714183203'" ],
+ 'join_conds' => [],
+ 'options' => [ 'ORDER BY rc_timestamp DESC' ],
+ ]
+ ],
+ 'recentchanges query with single tag filter' => [
+ [
+ 'tables' => [ 'recentchanges' ],
+ 'fields' => [ 'rc_id', 'rc_timestamp' ],
+ 'conds' => [ "rc_timestamp > '20170714183203'" ],
+ 'join_conds' => [],
+ 'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
+ ],
+ 'foo',
+ true, // tag filtering enabled
+ [
+ 'tables' => [ 'recentchanges', 'change_tag' ],
+ 'fields' => [ 'rc_id', 'rc_timestamp', 'ts_tags' => $groupConcats['recentchanges'] ],
+ 'conds' => [ "rc_timestamp > '20170714183203'", 'ct_tag' => 'foo' ],
+ 'join_conds' => [ 'change_tag' => [ 'INNER JOIN', 'ct_rc_id=rc_id' ] ],
+ 'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
+ ]
+ ],
+ 'logging query with single tag filter and strings' => [
+ [
+ 'tables' => 'logging',
+ 'fields' => 'log_id',
+ 'conds' => "log_timestamp > '20170714183203'",
+ 'join_conds' => [],
+ 'options' => 'ORDER BY log_timestamp DESC',
+ ],
+ 'foo',
+ true, // tag filtering enabled
+ [
+ 'tables' => [ 'logging', 'change_tag' ],
+ 'fields' => [ 'log_id', 'ts_tags' => $groupConcats['logging'] ],
+ 'conds' => [ "log_timestamp > '20170714183203'", 'ct_tag' => 'foo' ],
+ 'join_conds' => [ 'change_tag' => [ 'INNER JOIN', 'ct_log_id=log_id' ] ],
+ 'options' => [ 'ORDER BY log_timestamp DESC' ],
+ ]
+ ],
+ 'revision query with single tag filter' => [
+ [
+ 'tables' => [ 'revision' ],
+ 'fields' => [ 'rev_id', 'rev_timestamp' ],
+ 'conds' => [ "rev_timestamp > '20170714183203'" ],
+ 'join_conds' => [],
+ 'options' => [ 'ORDER BY' => 'rev_timestamp DESC' ],
+ ],
+ 'foo',
+ true, // tag filtering enabled
+ [
+ 'tables' => [ 'revision', 'change_tag' ],
+ 'fields' => [ 'rev_id', 'rev_timestamp', 'ts_tags' => $groupConcats['revision'] ],
+ 'conds' => [ "rev_timestamp > '20170714183203'", 'ct_tag' => 'foo' ],
+ 'join_conds' => [ 'change_tag' => [ 'INNER JOIN', 'ct_rev_id=rev_id' ] ],
+ 'options' => [ 'ORDER BY' => 'rev_timestamp DESC' ],
+ ]
+ ],
+ 'archive query with single tag filter' => [
+ [
+ 'tables' => [ 'archive' ],
+ 'fields' => [ 'ar_id', 'ar_timestamp' ],
+ 'conds' => [ "ar_timestamp > '20170714183203'" ],
+ 'join_conds' => [],
+ 'options' => [ 'ORDER BY' => 'ar_timestamp DESC' ],
+ ],
+ 'foo',
+ true, // tag filtering enabled
+ [
+ 'tables' => [ 'archive', 'change_tag' ],
+ 'fields' => [ 'ar_id', 'ar_timestamp', 'ts_tags' => $groupConcats['archive'] ],
+ 'conds' => [ "ar_timestamp > '20170714183203'", 'ct_tag' => 'foo' ],
+ 'join_conds' => [ 'change_tag' => [ 'INNER JOIN', 'ct_rev_id=ar_rev_id' ] ],
+ 'options' => [ 'ORDER BY' => 'ar_timestamp DESC' ],
+ ]
+ ],
+ 'unsupported table name throws exception (even without tag filter)' => [
+ [
+ 'tables' => [ 'foobar' ],
+ 'fields' => [ 'fb_id', 'fb_timestamp' ],
+ 'conds' => [ "fb_timestamp > '20170714183203'" ],
+ 'join_conds' => [],
+ 'options' => [ 'ORDER BY' => 'fb_timestamp DESC' ],
+ ],
+ '',
+ true, // tag filtering enabled
+ [ 'exception' => MWException::class ]
+ ],
+ 'tag filter ignored when tag filtering is disabled' => [
+ [
+ 'tables' => [ 'archive' ],
+ 'fields' => [ 'ar_id', 'ar_timestamp' ],
+ 'conds' => [ "ar_timestamp > '20170714183203'" ],
+ 'join_conds' => [],
+ 'options' => [ 'ORDER BY' => 'ar_timestamp DESC' ],
+ ],
+ 'foo',
+ false, // tag filtering disabled
+ [
+ 'tables' => [ 'archive' ],
+ 'fields' => [ 'ar_id', 'ar_timestamp', 'ts_tags' => $groupConcats['archive'] ],
+ 'conds' => [ "ar_timestamp > '20170714183203'" ],
+ 'join_conds' => [],
+ 'options' => [ 'ORDER BY' => 'ar_timestamp DESC' ],
+ ]
+ ],
+ 'recentchanges query with multiple tag filter' => [
+ [
+ 'tables' => [ 'recentchanges' ],
+ 'fields' => [ 'rc_id', 'rc_timestamp' ],
+ 'conds' => [ "rc_timestamp > '20170714183203'" ],
+ 'join_conds' => [],
+ 'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
+ ],
+ [ 'foo', 'bar' ],
+ true, // tag filtering enabled
+ [
+ 'tables' => [ 'recentchanges', 'change_tag' ],
+ 'fields' => [ 'rc_id', 'rc_timestamp', 'ts_tags' => $groupConcats['recentchanges'] ],
+ 'conds' => [ "rc_timestamp > '20170714183203'", 'ct_tag' => [ 'foo', 'bar' ] ],
+ 'join_conds' => [ 'change_tag' => [ 'INNER JOIN', 'ct_rc_id=rc_id' ] ],
+ 'options' => [ 'ORDER BY' => 'rc_timestamp DESC', 'DISTINCT' ],
+ ]
+ ],
+ 'recentchanges query with multiple tag filter that already has DISTINCT' => [
+ [
+ 'tables' => [ 'recentchanges' ],
+ 'fields' => [ 'rc_id', 'rc_timestamp' ],
+ 'conds' => [ "rc_timestamp > '20170714183203'" ],
+ 'join_conds' => [],
+ 'options' => [ 'DISTINCT', 'ORDER BY' => 'rc_timestamp DESC' ],
+ ],
+ [ 'foo', 'bar' ],
+ true, // tag filtering enabled
+ [
+ 'tables' => [ 'recentchanges', 'change_tag' ],
+ 'fields' => [ 'rc_id', 'rc_timestamp', 'ts_tags' => $groupConcats['recentchanges'] ],
+ 'conds' => [ "rc_timestamp > '20170714183203'", 'ct_tag' => [ 'foo', 'bar' ] ],
+ 'join_conds' => [ 'change_tag' => [ 'INNER JOIN', 'ct_rc_id=rc_id' ] ],
+ 'options' => [ 'DISTINCT', 'ORDER BY' => 'rc_timestamp DESC' ],
+ ]
+ ],
+ 'recentchanges query with multiple tag filter with strings' => [
+ [
+ 'tables' => 'recentchanges',
+ 'fields' => 'rc_id',
+ 'conds' => "rc_timestamp > '20170714183203'",
+ 'join_conds' => [],
+ 'options' => 'ORDER BY rc_timestamp DESC',
+ ],
+ [ 'foo', 'bar' ],
+ true, // tag filtering enabled
+ [
+ 'tables' => [ 'recentchanges', 'change_tag' ],
+ 'fields' => [ 'rc_id', 'ts_tags' => $groupConcats['recentchanges'] ],
+ 'conds' => [ "rc_timestamp > '20170714183203'", 'ct_tag' => [ 'foo', 'bar' ] ],
+ 'join_conds' => [ 'change_tag' => [ 'INNER JOIN', 'ct_rc_id=rc_id' ] ],
+ 'options' => [ 'ORDER BY rc_timestamp DESC', 'DISTINCT' ],
+ ]
+ ],
+ ];
+ }
+
+ public static function dataGetSoftwareTags() {
+ return [
+ [
+ [
+ 'mw-contentModelChange' => true,
+ 'mw-redirect' => true,
+ 'mw-rollback' => true,
+ 'mw-blank' => true,
+ 'mw-replace' => true
+ ],
+ [
+ 'mw-rollback',
+ 'mw-replace',
+ 'mw-blank'
+ ]
+ ],
+
+ [
+ [
+ 'mw-contentmodelchanged' => true,
+ 'mw-replace' => true,
+ 'mw-new-redirects' => true,
+ 'mw-changed-redirect-target' => true,
+ 'mw-rolback' => true,
+ 'mw-blanking' => false
+ ],
+ [
+ 'mw-replace',
+ 'mw-changed-redirect-target'
+ ]
+ ],
+
+ [
+ [
+ null,
+ false,
+ 'Lorem ipsum',
+ 'mw-translation'
+ ],
+ []
+ ],
+
+ [
+ [],
+ []
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetSoftwareTags
+ * @covers ChangeTags::getSoftwareTags
+ */
+ public function testGetSoftwareTags( $softwareTags, $expected ) {
+ $this->setMwGlobals( 'wgSoftwareTags', $softwareTags );
+
+ $actual = ChangeTags::getSoftwareTags();
+ // Order of tags in arrays is not important
+ sort( $expected );
+ sort( $actual );
+ $this->assertEquals( $expected, $actual );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/collation/CollationFaTest.php b/www/wiki/tests/phpunit/includes/collation/CollationFaTest.php
new file mode 100644
index 00000000..f7455419
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/collation/CollationFaTest.php
@@ -0,0 +1,55 @@
+<?php
+
+/**
+ * @covers CollationFa
+ */
+class CollationFaTest extends MediaWikiTestCase {
+
+ /*
+ * The ordering is a weird hack designed to work only with a very
+ * specific version of libicu, and as such can't really be unit tested
+ * against a random version of libicu
+ */
+
+ public function setUp() {
+ parent::setUp();
+ $this->checkPHPExtension( 'intl' );
+ }
+
+ /**
+ * @dataProvider provideGetFirstLetter
+ */
+ public function testGetFirstLetter( $letter, $str ) {
+ $coll = new CollationFa;
+ $this->assertEquals( $letter, $coll->getFirstLetter( $str ), $str );
+ }
+
+ public function provideGetFirstLetter() {
+ return [
+ [
+ '۷',
+ '۷'
+ ],
+ [
+ 'ا',
+ 'ا'
+ ],
+ [
+ 'ا',
+ 'ایران'
+ ],
+ [
+ 'ب',
+ 'برلین'
+ ],
+ [
+ 'و',
+ 'واو'
+ ],
+ [ "\xd8\xa7", "\xd8\xa7Foo" ],
+ [ "\xd9\x88", "\xd9\x88Foo" ],
+ [ "\xd9\xb2", "\xd9\xb2Foo" ],
+ [ "\xd9\xb3", "\xd9\xb3Foo" ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/collation/CollationTest.php b/www/wiki/tests/phpunit/includes/collation/CollationTest.php
new file mode 100644
index 00000000..b92e651e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/collation/CollationTest.php
@@ -0,0 +1,118 @@
+<?php
+
+/**
+ * Class CollationTest
+ * @covers Collation
+ * @covers IcuCollation
+ * @covers IdentityCollation
+ * @covers UppercaseCollation
+ */
+class CollationTest extends MediaWikiLangTestCase {
+ protected function setUp() {
+ parent::setUp();
+ $this->checkPHPExtension( 'intl' );
+ }
+
+ /**
+ * Test to make sure, that if you
+ * have "X" and "XY", the binary
+ * sortkey also has "X" being a
+ * prefix of "XY". Our collation
+ * code makes this assumption.
+ *
+ * @param string $lang Language code for collator
+ * @param string $base
+ * @param string $extended String containing base as a prefix.
+ *
+ * @dataProvider prefixDataProvider
+ */
+ public function testIsPrefix( $lang, $base, $extended ) {
+ $cp = Collator::create( $lang );
+ $cp->setStrength( Collator::PRIMARY );
+ $baseBin = $cp->getSortKey( $base );
+ // Remove sortkey terminator
+ $baseBin = rtrim( $baseBin, "\0" );
+ $extendedBin = $cp->getSortKey( $extended );
+ $this->assertStringStartsWith( $baseBin, $extendedBin, "$base is not a prefix of $extended" );
+ }
+
+ public static function prefixDataProvider() {
+ return [
+ [ 'en', 'A', 'AA' ],
+ [ 'en', 'A', 'AAA' ],
+ [ 'en', 'Д', 'ДЂ' ],
+ [ 'en', 'Д', 'ДA' ],
+ // 'Ʒ' should expand to 'Z ' (note space).
+ [ 'fi', 'Z', 'Ʒ' ],
+ // 'Þ' should expand to 'th'
+ [ 'sv', 't', 'Þ' ],
+ // Javanese is a limited use alphabet, so should have 3 bytes
+ // per character, so do some tests with it.
+ [ 'en', 'ꦲ', 'ꦲꦤ' ],
+ [ 'en', 'ꦲ', 'ꦲД' ],
+ [ 'en', 'A', 'Aꦲ' ],
+ ];
+ }
+
+ /**
+ * Opposite of testIsPrefix
+ *
+ * @dataProvider notPrefixDataProvider
+ */
+ public function testNotIsPrefix( $lang, $base, $extended ) {
+ $cp = Collator::create( $lang );
+ $cp->setStrength( Collator::PRIMARY );
+ $baseBin = $cp->getSortKey( $base );
+ // Remove sortkey terminator
+ $baseBin = rtrim( $baseBin, "\0" );
+ $extendedBin = $cp->getSortKey( $extended );
+ $this->assertStringStartsNotWith( $baseBin, $extendedBin, "$base is a prefix of $extended" );
+ }
+
+ public static function notPrefixDataProvider() {
+ return [
+ [ 'en', 'A', 'B' ],
+ [ 'en', 'AC', 'ABC' ],
+ [ 'en', 'Z', 'Ʒ' ],
+ [ 'en', 'A', 'ꦲ' ],
+ ];
+ }
+
+ /**
+ * Test correct first letter is fetched.
+ *
+ * @param string $collation Collation name (aka uca-en)
+ * @param string $string String to get first letter of
+ * @param string $firstLetter Expected first letter.
+ *
+ * @dataProvider firstLetterProvider
+ */
+ public function testGetFirstLetter( $collation, $string, $firstLetter ) {
+ $col = Collation::factory( $collation );
+ $this->assertEquals( $firstLetter, $col->getFirstLetter( $string ) );
+ }
+
+ function firstLetterProvider() {
+ return [
+ [ 'uppercase', 'Abc', 'A' ],
+ [ 'uppercase', 'abc', 'A' ],
+ [ 'identity', 'abc', 'a' ],
+ [ 'uca-en', 'abc', 'A' ],
+ [ 'uca-en', ' ', ' ' ],
+ [ 'uca-en', 'Êveryone', 'E' ],
+ [ 'uca-vi', 'Êveryone', 'Ê' ],
+ // Make sure thorn is not a first letter.
+ [ 'uca-sv', 'The', 'T' ],
+ [ 'uca-sv', 'Å', 'Å' ],
+ [ 'uca-hu', 'dzsdo', 'Dzs' ],
+ [ 'uca-hu', 'dzdso', 'Dz' ],
+ [ 'uca-hu', 'CSD', 'Cs' ],
+ [ 'uca-root', 'CSD', 'C' ],
+ [ 'uca-fi', 'Ǥ', 'G' ],
+ [ 'uca-fi', 'Ŧ', 'T' ],
+ [ 'uca-fi', 'Ʒ', 'Z' ],
+ [ 'uca-fi', 'Ŋ', 'N' ],
+ [ 'uppercase-ba', 'в', 'В' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/collation/CustomUppercaseCollationTest.php b/www/wiki/tests/phpunit/includes/collation/CustomUppercaseCollationTest.php
new file mode 100644
index 00000000..f9e0bc9b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/collation/CustomUppercaseCollationTest.php
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @covers CustomUppercaseCollation
+ */
+class CustomUppercaseCollationTest extends MediaWikiTestCase {
+
+ public function setUp() {
+ $this->collation = new CustomUppercaseCollation( [
+ 'D',
+ 'C',
+ 'Cs',
+ 'B'
+ ], Language::factory( 'en' ) );
+
+ parent::setUp();
+ }
+
+ /**
+ * @dataProvider providerOrder
+ */
+ public function testOrder( $first, $second, $msg ) {
+ $sortkey1 = $this->collation->getSortKey( $first );
+ $sortkey2 = $this->collation->getSortKey( $second );
+
+ $this->assertTrue( strcmp( $sortkey1, $sortkey2 ) < 0, $msg );
+ }
+
+ public function providerOrder() {
+ return [
+ [ 'X', 'Z', 'Maintain order of unrearranged' ],
+ [ 'D', 'C', 'Actually resorts' ],
+ [ 'D', 'B', 'resort test 2' ],
+ [ 'Adobe', 'Abode', 'not first letter' ],
+ [ '💩 ', 'C', 'Test relocated to end' ],
+ [ 'c', 'b', 'lowercase' ],
+ [ 'x', 'z', 'lowercase original' ],
+ [ 'Cz', 'Cs', 'digraphs' ],
+ [ 'C50D', 'C100', 'Numbers' ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetFirstLetter
+ */
+ public function testGetFirstLetter( $string, $first ) {
+ $this->assertSame( $this->collation->getFirstLetter( $string ), $first );
+ }
+
+ public function provideGetFirstLetter() {
+ return [
+ [ 'Do', 'D' ],
+ [ 'do', 'D' ],
+ [ 'Ao', 'A' ],
+ [ 'afdsa', 'A' ],
+ [ "\xF3\xB3\x80\x80Foo", 'D' ],
+ [ "\xF3\xB3\x80\x81Foo", 'C' ],
+ [ "\xF3\xB3\x80\x82Foo", 'Cs' ],
+ [ "\xF3\xB3\x80\x83Foo", 'B' ],
+ [ "\xF3\xB3\x80\x84Foo", "\xF3\xB3\x80\x84" ],
+ [ 'C', 'C' ],
+ [ 'Cz', 'C' ],
+ [ 'Cs', 'Cs' ],
+ [ 'CS', 'Cs' ],
+ [ 'cs', 'Cs' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php b/www/wiki/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php
new file mode 100644
index 00000000..c5c0dc7d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php
@@ -0,0 +1,163 @@
+<?php
+
+/**
+ * @covers ComposerVersionNormalizer
+ *
+ * @group ComposerHooks
+ *
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class ComposerVersionNormalizerTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ /**
+ * @dataProvider nonStringProvider
+ */
+ public function testGivenNonString_normalizeThrowsInvalidArgumentException( $nonString ) {
+ $normalizer = new ComposerVersionNormalizer();
+
+ $this->setExpectedException( InvalidArgumentException::class );
+ $normalizer->normalizeSuffix( $nonString );
+ }
+
+ public function nonStringProvider() {
+ return [
+ [ null ],
+ [ 42 ],
+ [ [] ],
+ [ new stdClass() ],
+ [ true ],
+ ];
+ }
+
+ /**
+ * @dataProvider simpleVersionProvider
+ */
+ public function testGivenSimpleVersion_normalizeSuffixReturnsAsIs( $simpleVersion ) {
+ $this->assertRemainsUnchanged( $simpleVersion );
+ }
+
+ protected function assertRemainsUnchanged( $version ) {
+ $normalizer = new ComposerVersionNormalizer();
+
+ $this->assertEquals(
+ $version,
+ $normalizer->normalizeSuffix( $version )
+ );
+ }
+
+ public function simpleVersionProvider() {
+ return [
+ [ '1.22.0' ],
+ [ '1.19.2' ],
+ [ '1.19.2.0' ],
+ [ '1.9' ],
+ [ '123.321.456.654' ],
+ ];
+ }
+
+ /**
+ * @dataProvider complexVersionProvider
+ */
+ public function testGivenComplexVersionWithoutDash_normalizeSuffixAddsDash(
+ $withoutDash, $withDash
+ ) {
+ $normalizer = new ComposerVersionNormalizer();
+
+ $this->assertEquals(
+ $withDash,
+ $normalizer->normalizeSuffix( $withoutDash )
+ );
+ }
+
+ public function complexVersionProvider() {
+ return [
+ [ '1.22.0alpha', '1.22.0-alpha' ],
+ [ '1.22.0RC', '1.22.0-RC' ],
+ [ '1.19beta', '1.19-beta' ],
+ [ '1.9RC4', '1.9-RC4' ],
+ [ '1.9.1.2RC4', '1.9.1.2-RC4' ],
+ [ '1.9.1.2RC', '1.9.1.2-RC' ],
+ [ '123.321.456.654RC9001', '123.321.456.654-RC9001' ],
+ ];
+ }
+
+ /**
+ * @dataProvider complexVersionProvider
+ */
+ public function testGivenComplexVersionWithDash_normalizeSuffixReturnsAsIs(
+ $withoutDash, $withDash
+ ) {
+ $this->assertRemainsUnchanged( $withDash );
+ }
+
+ /**
+ * @dataProvider fourLevelVersionsProvider
+ */
+ public function testGivenFourLevels_levelCountNormalizationDoesNothing( $version ) {
+ $normalizer = new ComposerVersionNormalizer();
+
+ $this->assertEquals(
+ $version,
+ $normalizer->normalizeLevelCount( $version )
+ );
+ }
+
+ public function fourLevelVersionsProvider() {
+ return [
+ [ '1.22.0.0' ],
+ [ '1.19.2.4' ],
+ [ '1.19.2.0' ],
+ [ '1.9.0.1' ],
+ [ '123.321.456.654' ],
+ [ '123.321.456.654RC4' ],
+ [ '123.321.456.654-RC4' ],
+ ];
+ }
+
+ /**
+ * @dataProvider levelNormalizationProvider
+ */
+ public function testGivenFewerLevels_levelCountNormalizationEnsuresFourLevels(
+ $expected, $version
+ ) {
+ $normalizer = new ComposerVersionNormalizer();
+
+ $this->assertEquals(
+ $expected,
+ $normalizer->normalizeLevelCount( $version )
+ );
+ }
+
+ public function levelNormalizationProvider() {
+ return [
+ [ '1.22.0.0', '1.22' ],
+ [ '1.22.0.0', '1.22.0' ],
+ [ '1.19.2.0', '1.19.2' ],
+ [ '12345.0.0.0', '12345' ],
+ [ '12345.0.0.0-RC4', '12345-RC4' ],
+ [ '12345.0.0.0-alpha', '12345-alpha' ],
+ ];
+ }
+
+ /**
+ * @dataProvider invalidVersionProvider
+ */
+ public function testGivenInvalidVersion_normalizeSuffixReturnsAsIs( $invalidVersion ) {
+ $this->assertRemainsUnchanged( $invalidVersion );
+ }
+
+ public function invalidVersionProvider() {
+ return [
+ [ '1.221-a' ],
+ [ '1.221-' ],
+ [ '1.22rc4a' ],
+ [ 'a1.22rc' ],
+ [ '.1.22rc' ],
+ [ 'a' ],
+ [ 'alpha42' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/config/ConfigFactoryTest.php b/www/wiki/tests/phpunit/includes/config/ConfigFactoryTest.php
new file mode 100644
index 00000000..ea747afa
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/config/ConfigFactoryTest.php
@@ -0,0 +1,168 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+class ConfigFactoryTest extends MediaWikiTestCase {
+
+ /**
+ * @covers ConfigFactory::register
+ */
+ public function testRegister() {
+ $factory = new ConfigFactory();
+ $factory->register( 'unittest', 'GlobalVarConfig::newInstance' );
+ $this->assertInstanceOf( GlobalVarConfig::class, $factory->makeConfig( 'unittest' ) );
+ }
+
+ /**
+ * @covers ConfigFactory::register
+ */
+ public function testRegisterInvalid() {
+ $factory = new ConfigFactory();
+ $this->setExpectedException( InvalidArgumentException::class );
+ $factory->register( 'invalid', 'Invalid callback' );
+ }
+
+ /**
+ * @covers ConfigFactory::register
+ */
+ public function testRegisterInvalidInstance() {
+ $factory = new ConfigFactory();
+ $this->setExpectedException( InvalidArgumentException::class );
+ $factory->register( 'invalidInstance', new stdClass );
+ }
+
+ /**
+ * @covers ConfigFactory::register
+ */
+ public function testRegisterInstance() {
+ $config = GlobalVarConfig::newInstance();
+ $factory = new ConfigFactory();
+ $factory->register( 'unittest', $config );
+ $this->assertSame( $config, $factory->makeConfig( 'unittest' ) );
+ }
+
+ /**
+ * @covers ConfigFactory::register
+ */
+ public function testRegisterAgain() {
+ $factory = new ConfigFactory();
+ $factory->register( 'unittest', 'GlobalVarConfig::newInstance' );
+ $config1 = $factory->makeConfig( 'unittest' );
+
+ $factory->register( 'unittest', 'GlobalVarConfig::newInstance' );
+ $config2 = $factory->makeConfig( 'unittest' );
+
+ $this->assertNotSame( $config1, $config2 );
+ }
+
+ /**
+ * @covers ConfigFactory::salvage
+ */
+ public function testSalvage() {
+ $oldFactory = new ConfigFactory();
+ $oldFactory->register( 'foo', 'GlobalVarConfig::newInstance' );
+ $oldFactory->register( 'bar', 'GlobalVarConfig::newInstance' );
+ $oldFactory->register( 'quux', 'GlobalVarConfig::newInstance' );
+
+ // instantiate two of the three defined configurations
+ $foo = $oldFactory->makeConfig( 'foo' );
+ $bar = $oldFactory->makeConfig( 'bar' );
+ $quux = $oldFactory->makeConfig( 'quux' );
+
+ // define new config instance
+ $newFactory = new ConfigFactory();
+ $newFactory->register( 'foo', 'GlobalVarConfig::newInstance' );
+ $newFactory->register( 'bar', function () {
+ return new HashConfig();
+ } );
+
+ // "foo" and "quux" are defined in the old and the new factory.
+ // The old factory has instances for "foo" and "bar", but not "quux".
+ $newFactory->salvage( $oldFactory );
+
+ $newFoo = $newFactory->makeConfig( 'foo' );
+ $this->assertSame( $foo, $newFoo, 'existing instance should be salvaged' );
+
+ $newBar = $newFactory->makeConfig( 'bar' );
+ $this->assertNotSame( $bar, $newBar, 'don\'t salvage if callbacks differ' );
+
+ // the new factory doesn't have quux defined, so the quux instance should not be salvaged
+ $this->setExpectedException( ConfigException::class );
+ $newFactory->makeConfig( 'quux' );
+ }
+
+ /**
+ * @covers ConfigFactory::getConfigNames
+ */
+ public function testGetConfigNames() {
+ $factory = new ConfigFactory();
+ $factory->register( 'foo', 'GlobalVarConfig::newInstance' );
+ $factory->register( 'bar', new HashConfig() );
+
+ $this->assertEquals( [ 'foo', 'bar' ], $factory->getConfigNames() );
+ }
+
+ /**
+ * @covers ConfigFactory::makeConfig
+ */
+ public function testMakeConfigWithCallback() {
+ $factory = new ConfigFactory();
+ $factory->register( 'unittest', 'GlobalVarConfig::newInstance' );
+
+ $conf = $factory->makeConfig( 'unittest' );
+ $this->assertInstanceOf( Config::class, $conf );
+ $this->assertSame( $conf, $factory->makeConfig( 'unittest' ) );
+ }
+
+ /**
+ * @covers ConfigFactory::makeConfig
+ */
+ public function testMakeConfigWithObject() {
+ $factory = new ConfigFactory();
+ $conf = new HashConfig();
+ $factory->register( 'test', $conf );
+ $this->assertSame( $conf, $factory->makeConfig( 'test' ) );
+ }
+
+ /**
+ * @covers ConfigFactory::makeConfig
+ */
+ public function testMakeConfigFallback() {
+ $factory = new ConfigFactory();
+ $factory->register( '*', 'GlobalVarConfig::newInstance' );
+ $conf = $factory->makeConfig( 'unittest' );
+ $this->assertInstanceOf( Config::class, $conf );
+ }
+
+ /**
+ * @covers ConfigFactory::makeConfig
+ */
+ public function testMakeConfigWithNoBuilders() {
+ $factory = new ConfigFactory();
+ $this->setExpectedException( ConfigException::class );
+ $factory->makeConfig( 'nobuilderregistered' );
+ }
+
+ /**
+ * @covers ConfigFactory::makeConfig
+ */
+ public function testMakeConfigWithInvalidCallback() {
+ $factory = new ConfigFactory();
+ $factory->register( 'unittest', function () {
+ return true; // Not a Config object
+ } );
+ $this->setExpectedException( UnexpectedValueException::class );
+ $factory->makeConfig( 'unittest' );
+ }
+
+ /**
+ * @covers ConfigFactory::getDefaultInstance
+ */
+ public function testGetDefaultInstance() {
+ // NOTE: the global config factory returned here has been overwritten
+ // for operation in test mode. It may not reflect LocalSettings.
+ $factory = MediaWikiServices::getInstance()->getConfigFactory();
+ $this->assertInstanceOf( Config::class, $factory->makeConfig( 'main' ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/config/EtcdConfigTest.php b/www/wiki/tests/phpunit/includes/config/EtcdConfigTest.php
new file mode 100644
index 00000000..3eecf827
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/config/EtcdConfigTest.php
@@ -0,0 +1,621 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+class EtcdConfigTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ private function createConfigMock( array $options = [] ) {
+ return $this->getMockBuilder( EtcdConfig::class )
+ ->setConstructorArgs( [ $options + [
+ 'host' => 'etcd-tcp.example.net',
+ 'directory' => '/',
+ 'timeout' => 0.1,
+ ] ] )
+ ->setMethods( [ 'fetchAllFromEtcd' ] )
+ ->getMock();
+ }
+
+ private static function createEtcdResponse( array $response ) {
+ $baseResponse = [
+ 'config' => null,
+ 'error' => null,
+ 'retry' => false,
+ 'modifiedIndex' => 0,
+ ];
+ return array_merge( $baseResponse, $response );
+ }
+
+ private function createSimpleConfigMock( array $config, $index = 0 ) {
+ $mock = $this->createConfigMock();
+ $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
+ ->willReturn( self::createEtcdResponse( [
+ 'config' => $config,
+ 'modifiedIndex' => $index,
+ ] ) );
+ return $mock;
+ }
+
+ /**
+ * @covers EtcdConfig::has
+ */
+ public function testHasKnown() {
+ $config = $this->createSimpleConfigMock( [
+ 'known' => 'value'
+ ] );
+ $this->assertSame( true, $config->has( 'known' ) );
+ }
+
+ /**
+ * @covers EtcdConfig::__construct
+ * @covers EtcdConfig::get
+ */
+ public function testGetKnown() {
+ $config = $this->createSimpleConfigMock( [
+ 'known' => 'value'
+ ] );
+ $this->assertSame( 'value', $config->get( 'known' ) );
+ }
+
+ /**
+ * @covers EtcdConfig::has
+ */
+ public function testHasUnknown() {
+ $config = $this->createSimpleConfigMock( [
+ 'known' => 'value'
+ ] );
+ $this->assertSame( false, $config->has( 'unknown' ) );
+ }
+
+ /**
+ * @covers EtcdConfig::get
+ */
+ public function testGetUnknown() {
+ $config = $this->createSimpleConfigMock( [
+ 'known' => 'value'
+ ] );
+ $this->setExpectedException( ConfigException::class );
+ $config->get( 'unknown' );
+ }
+
+ /**
+ * @covers EtcdConfig::getModifiedIndex
+ */
+ public function testGetModifiedIndex() {
+ $config = $this->createSimpleConfigMock(
+ [ 'some' => 'value' ],
+ 123
+ );
+ $this->assertSame( 123, $config->getModifiedIndex() );
+ }
+
+ /**
+ * @covers EtcdConfig::__construct
+ */
+ public function testConstructCacheObj() {
+ $cache = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'get' ] )
+ ->getMock();
+ $cache->expects( $this->once() )->method( 'get' )
+ ->willReturn( [
+ 'config' => [ 'known' => 'from-cache' ],
+ 'expires' => INF,
+ 'modifiedIndex' => 123
+ ] );
+ $config = $this->createConfigMock( [ 'cache' => $cache ] );
+
+ $this->assertSame( 'from-cache', $config->get( 'known' ) );
+ }
+
+ /**
+ * @covers EtcdConfig::__construct
+ */
+ public function testConstructCacheSpec() {
+ $config = $this->createConfigMock( [ 'cache' => [
+ 'class' => HashBagOStuff::class
+ ] ] );
+ $config->expects( $this->once() )->method( 'fetchAllFromEtcd' )
+ ->willReturn( self::createEtcdResponse(
+ [ 'config' => [ 'known' => 'from-fetch' ], ] ) );
+
+ $this->assertSame( 'from-fetch', $config->get( 'known' ) );
+ }
+
+ /**
+ * Test matrix
+ *
+ * - [x] Cache miss
+ * Result: Fetched value
+ * > cache miss | gets lock | backend succeeds
+ *
+ * - [x] Cache miss with backend error
+ * Result: ConfigException
+ * > cache miss | gets lock | backend error (no retry)
+ *
+ * - [x] Cache hit after retry
+ * Result: Cached value (populated by process holding lock)
+ * > cache miss | no lock | cache retry
+ *
+ * - [x] Cache hit
+ * Result: Cached value
+ * > cache hit
+ *
+ * - [x] Process cache hit
+ * Result: Cached value
+ * > process cache hit
+ *
+ * - [x] Cache expired
+ * Result: Fetched value
+ * > cache expired | gets lock | backend succeeds
+ *
+ * - [x] Cache expired with backend failure
+ * Result: Cached value (stale)
+ * > cache expired | gets lock | backend fails (allows retry)
+ *
+ * - [x] Cache expired and no lock
+ * Result: Cached value (stale)
+ * > cache expired | no lock
+ *
+ * Other notable scenarios:
+ *
+ * - [ ] Cache miss with backend retry
+ * Result: Fetched value
+ * > cache expired | gets lock | backend failure (allows retry)
+ */
+
+ /**
+ * @covers EtcdConfig::load
+ */
+ public function testLoadCacheMiss() {
+ // Create cache mock
+ $cache = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'get', 'lock' ] )
+ ->getMock();
+ // .. misses cache
+ $cache->expects( $this->once() )->method( 'get' )
+ ->willReturn( false );
+ // .. gets lock
+ $cache->expects( $this->once() )->method( 'lock' )
+ ->willReturn( true );
+
+ // Create config mock
+ $mock = $this->createConfigMock( [
+ 'cache' => $cache,
+ ] );
+ $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
+ ->willReturn(
+ self::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) );
+
+ $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
+ }
+
+ /**
+ * @covers EtcdConfig::load
+ */
+ public function testLoadCacheMissBackendError() {
+ // Create cache mock
+ $cache = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'get', 'lock' ] )
+ ->getMock();
+ // .. misses cache
+ $cache->expects( $this->once() )->method( 'get' )
+ ->willReturn( false );
+ // .. gets lock
+ $cache->expects( $this->once() )->method( 'lock' )
+ ->willReturn( true );
+
+ // Create config mock
+ $mock = $this->createConfigMock( [
+ 'cache' => $cache,
+ ] );
+ $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
+ ->willReturn( self::createEtcdResponse( [ 'error' => 'Fake error', ] ) );
+
+ $this->setExpectedException( ConfigException::class );
+ $mock->get( 'key' );
+ }
+
+ /**
+ * @covers EtcdConfig::load
+ */
+ public function testLoadCacheMissWithoutLock() {
+ // Create cache mock
+ $cache = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'get', 'lock' ] )
+ ->getMock();
+ $cache->expects( $this->exactly( 2 ) )->method( 'get' )
+ ->will( $this->onConsecutiveCalls(
+ // .. misses cache first time
+ false,
+ // .. hits cache on retry
+ [
+ 'config' => [ 'known' => 'from-cache' ],
+ 'expires' => INF,
+ 'modifiedIndex' => 123
+ ]
+ ) );
+ // .. misses lock
+ $cache->expects( $this->once() )->method( 'lock' )
+ ->willReturn( false );
+
+ // Create config mock
+ $mock = $this->createConfigMock( [
+ 'cache' => $cache,
+ ] );
+ $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
+
+ $this->assertSame( 'from-cache', $mock->get( 'known' ) );
+ }
+
+ /**
+ * @covers EtcdConfig::load
+ */
+ public function testLoadCacheHit() {
+ // Create cache mock
+ $cache = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'get', 'lock' ] )
+ ->getMock();
+ $cache->expects( $this->once() )->method( 'get' )
+ // .. hits cache
+ ->willReturn( [
+ 'config' => [ 'known' => 'from-cache' ],
+ 'expires' => INF,
+ 'modifiedIndex' => 0,
+ ] );
+ $cache->expects( $this->never() )->method( 'lock' );
+
+ // Create config mock
+ $mock = $this->createConfigMock( [
+ 'cache' => $cache,
+ ] );
+ $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
+
+ $this->assertSame( 'from-cache', $mock->get( 'known' ) );
+ }
+
+ /**
+ * @covers EtcdConfig::load
+ */
+ public function testLoadProcessCacheHit() {
+ // Create cache mock
+ $cache = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'get', 'lock' ] )
+ ->getMock();
+ $cache->expects( $this->once() )->method( 'get' )
+ // .. hits cache
+ ->willReturn( [
+ 'config' => [ 'known' => 'from-cache' ],
+ 'expires' => INF,
+ 'modifiedIndex' => 0,
+ ] );
+ $cache->expects( $this->never() )->method( 'lock' );
+
+ // Create config mock
+ $mock = $this->createConfigMock( [
+ 'cache' => $cache,
+ ] );
+ $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
+
+ $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Cache hit' );
+ $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Process cache hit' );
+ }
+
+ /**
+ * @covers EtcdConfig::load
+ */
+ public function testLoadCacheExpiredLockFetchSucceeded() {
+ // Create cache mock
+ $cache = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'get', 'lock' ] )
+ ->getMock();
+ $cache->expects( $this->once() )->method( 'get' )->willReturn(
+ // .. stale cache
+ [
+ 'config' => [ 'known' => 'from-cache-expired' ],
+ 'expires' => -INF,
+ 'modifiedIndex' => 0,
+ ]
+ );
+ // .. gets lock
+ $cache->expects( $this->once() )->method( 'lock' )
+ ->willReturn( true );
+
+ // Create config mock
+ $mock = $this->createConfigMock( [
+ 'cache' => $cache,
+ ] );
+ $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
+ ->willReturn( self::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) );
+
+ $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
+ }
+
+ /**
+ * @covers EtcdConfig::load
+ */
+ public function testLoadCacheExpiredLockFetchFails() {
+ // Create cache mock
+ $cache = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'get', 'lock' ] )
+ ->getMock();
+ $cache->expects( $this->once() )->method( 'get' )->willReturn(
+ // .. stale cache
+ [
+ 'config' => [ 'known' => 'from-cache-expired' ],
+ 'expires' => -INF,
+ 'modifiedIndex' => 0,
+ ]
+ );
+ // .. gets lock
+ $cache->expects( $this->once() )->method( 'lock' )
+ ->willReturn( true );
+
+ // Create config mock
+ $mock = $this->createConfigMock( [
+ 'cache' => $cache,
+ ] );
+ $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
+ ->willReturn( self::createEtcdResponse( [ 'error' => 'Fake failure', 'retry' => true ] ) );
+
+ $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
+ }
+
+ /**
+ * @covers EtcdConfig::load
+ */
+ public function testLoadCacheExpiredNoLock() {
+ // Create cache mock
+ $cache = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'get', 'lock' ] )
+ ->getMock();
+ $cache->expects( $this->once() )->method( 'get' )
+ // .. hits cache (expired value)
+ ->willReturn( [
+ 'config' => [ 'known' => 'from-cache-expired' ],
+ 'expires' => -INF,
+ 'modifiedIndex' => 0,
+ ] );
+ // .. misses lock
+ $cache->expects( $this->once() )->method( 'lock' )
+ ->willReturn( false );
+
+ // Create config mock
+ $mock = $this->createConfigMock( [
+ 'cache' => $cache,
+ ] );
+ $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
+
+ $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
+ }
+
+ public static function provideFetchFromServer() {
+ return [
+ '200 OK - Success' => [
+ 'http' => [
+ 'code' => 200,
+ 'reason' => 'OK',
+ 'headers' => [],
+ 'body' => json_encode( [ 'node' => [ 'nodes' => [
+ [
+ 'key' => '/example/foo',
+ 'value' => json_encode( [ 'val' => true ] ),
+ 'modifiedIndex' => 123
+ ],
+ ] ] ] ),
+ 'error' => '',
+ ],
+ 'expect' => self::createEtcdResponse( [
+ 'config' => [ 'foo' => true ], // data
+ 'modifiedIndex' => 123
+ ] ),
+ ],
+ '200 OK - Empty dir' => [
+ 'http' => [
+ 'code' => 200,
+ 'reason' => 'OK',
+ 'headers' => [],
+ 'body' => json_encode( [ 'node' => [ 'nodes' => [
+ [
+ 'key' => '/example/foo',
+ 'value' => json_encode( [ 'val' => true ] ),
+ 'modifiedIndex' => 123
+ ],
+ [
+ 'key' => '/example/sub',
+ 'dir' => true,
+ 'modifiedIndex' => 234,
+ 'nodes' => [],
+ ],
+ [
+ 'key' => '/example/bar',
+ 'value' => json_encode( [ 'val' => false ] ),
+ 'modifiedIndex' => 125
+ ],
+ ] ] ] ),
+ 'error' => '',
+ ],
+ 'expect' => self::createEtcdResponse( [
+ 'config' => [ 'foo' => true, 'bar' => false ], // data
+ 'modifiedIndex' => 125 // largest modified index
+ ] ),
+ ],
+ '200 OK - Recursive' => [
+ 'http' => [
+ 'code' => 200,
+ 'reason' => 'OK',
+ 'headers' => [],
+ 'body' => json_encode( [ 'node' => [ 'nodes' => [
+ [
+ 'key' => '/example/a',
+ 'dir' => true,
+ 'modifiedIndex' => 124,
+ 'nodes' => [
+ [
+ 'key' => 'b',
+ 'value' => json_encode( [ 'val' => true ] ),
+ 'modifiedIndex' => 123,
+
+ ],
+ [
+ 'key' => 'c',
+ 'value' => json_encode( [ 'val' => false ] ),
+ 'modifiedIndex' => 123,
+ ],
+ ],
+ ],
+ ] ] ] ),
+ 'error' => '',
+ ],
+ 'expect' => self::createEtcdResponse( [
+ 'config' => [ 'a/b' => true, 'a/c' => false ], // data
+ 'modifiedIndex' => 123 // largest modified index
+ ] ),
+ ],
+ '200 OK - Missing nodes at second level' => [
+ 'http' => [
+ 'code' => 200,
+ 'reason' => 'OK',
+ 'headers' => [],
+ 'body' => json_encode( [ 'node' => [ 'nodes' => [
+ [
+ 'key' => '/example/a',
+ 'dir' => true,
+ 'modifiedIndex' => 0,
+ ],
+ ] ] ] ),
+ 'error' => '',
+ ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => "Unexpected JSON response in dir 'a'; missing 'nodes' list.",
+ ] ),
+ ],
+ '200 OK - Directory with non-array "nodes" key' => [
+ 'http' => [
+ 'code' => 200,
+ 'reason' => 'OK',
+ 'headers' => [],
+ 'body' => json_encode( [ 'node' => [ 'nodes' => [
+ [
+ 'key' => '/example/a',
+ 'dir' => true,
+ 'nodes' => 'not an array'
+ ],
+ ] ] ] ),
+ 'error' => '',
+ ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => "Unexpected JSON response in dir 'a'; 'nodes' is not an array.",
+ ] ),
+ ],
+ '200 OK - Correctly encoded garbage response' => [
+ 'http' => [
+ 'code' => 200,
+ 'reason' => 'OK',
+ 'headers' => [],
+ 'body' => json_encode( [ 'foo' => 'bar' ] ),
+ 'error' => '',
+ ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => "Unexpected JSON response: Missing or invalid node at top level.",
+ ] ),
+ ],
+ '200 OK - Bad value' => [
+ 'http' => [
+ 'code' => 200,
+ 'reason' => 'OK',
+ 'headers' => [],
+ 'body' => json_encode( [ 'node' => [ 'nodes' => [
+ [
+ 'key' => '/example/foo',
+ 'value' => ';"broken{value',
+ 'modifiedIndex' => 123,
+ ]
+ ] ] ] ),
+ 'error' => '',
+ ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => "Failed to parse value for 'foo'.",
+ ] ),
+ ],
+ '200 OK - Empty node list' => [
+ 'http' => [
+ 'code' => 200,
+ 'reason' => 'OK',
+ 'headers' => [],
+ 'body' => '{"node":{"nodes":[], "modifiedIndex": 12 }}',
+ 'error' => '',
+ ],
+ 'expect' => self::createEtcdResponse( [
+ 'config' => [], // data
+ ] ),
+ ],
+ '200 OK - Invalid JSON' => [
+ 'http' => [
+ 'code' => 200,
+ 'reason' => 'OK',
+ 'headers' => [ 'content-length' => 0 ],
+ 'body' => '',
+ 'error' => '(curl error: no status set)',
+ ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => "Error unserializing JSON response.",
+ ] ),
+ ],
+ '404 Not Found' => [
+ 'http' => [
+ 'code' => 404,
+ 'reason' => 'Not Found',
+ 'headers' => [ 'content-length' => 0 ],
+ 'body' => '',
+ 'error' => '',
+ ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => 'HTTP 404 (Not Found)',
+ ] ),
+ ],
+ '400 Bad Request - custom error' => [
+ 'http' => [
+ 'code' => 400,
+ 'reason' => 'Bad Request',
+ 'headers' => [ 'content-length' => 0 ],
+ 'body' => '',
+ 'error' => 'No good reason',
+ ],
+ 'expect' => self::createEtcdResponse( [
+ 'error' => 'No good reason',
+ 'retry' => true, // retry
+ ] ),
+ ],
+ ];
+ }
+
+ /**
+ * @covers EtcdConfig::fetchAllFromEtcdServer
+ * @covers EtcdConfig::unserialize
+ * @covers EtcdConfig::parseResponse
+ * @covers EtcdConfig::parseDirectory
+ * @covers EtcdConfigParseError
+ * @dataProvider provideFetchFromServer
+ */
+ public function testFetchFromServer( array $httpResponse, array $expected ) {
+ $http = $this->getMockBuilder( MultiHttpClient::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $http->expects( $this->once() )->method( 'run' )
+ ->willReturn( array_values( $httpResponse ) );
+
+ $conf = $this->getMockBuilder( EtcdConfig::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ // Access for protected member and method
+ $conf = TestingAccessWrapper::newFromObject( $conf );
+ $conf->http = $http;
+
+ $this->assertSame(
+ $expected,
+ $conf->fetchAllFromEtcdServer( 'etcd-tcp.example.net' )
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/config/GlobalVarConfigTest.php b/www/wiki/tests/phpunit/includes/config/GlobalVarConfigTest.php
new file mode 100644
index 00000000..db5f73d4
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/config/GlobalVarConfigTest.php
@@ -0,0 +1,97 @@
+<?php
+
+class GlobalVarConfigTest extends MediaWikiTestCase {
+
+ /**
+ * @covers GlobalVarConfig::newInstance
+ */
+ public function testNewInstance() {
+ $config = GlobalVarConfig::newInstance();
+ $this->assertInstanceOf( GlobalVarConfig::class, $config );
+ $this->maybeStashGlobal( 'wgBaz' );
+ $GLOBALS['wgBaz'] = 'somevalue';
+ // Check prefix is set to 'wg'
+ $this->assertEquals( 'somevalue', $config->get( 'Baz' ) );
+ }
+
+ /**
+ * @covers GlobalVarConfig::__construct
+ * @dataProvider provideConstructor
+ */
+ public function testConstructor( $prefix ) {
+ $var = $prefix . 'GlobalVarConfigTest';
+ $rand = wfRandomString();
+ $this->maybeStashGlobal( $var );
+ $GLOBALS[$var] = $rand;
+ $config = new GlobalVarConfig( $prefix );
+ $this->assertInstanceOf( GlobalVarConfig::class, $config );
+ $this->assertEquals( $rand, $config->get( 'GlobalVarConfigTest' ) );
+ }
+
+ public static function provideConstructor() {
+ return [
+ [ 'wg' ],
+ [ 'ef' ],
+ [ 'smw' ],
+ [ 'blahblahblahblah' ],
+ [ '' ],
+ ];
+ }
+
+ /**
+ * @covers GlobalVarConfig::has
+ * @covers GlobalVarConfig::hasWithPrefix
+ */
+ public function testHas() {
+ $this->maybeStashGlobal( 'wgGlobalVarConfigTestHas' );
+ $GLOBALS['wgGlobalVarConfigTestHas'] = wfRandomString();
+ $this->maybeStashGlobal( 'wgGlobalVarConfigTestNotHas' );
+ $config = new GlobalVarConfig();
+ $this->assertTrue( $config->has( 'GlobalVarConfigTestHas' ) );
+ $this->assertFalse( $config->has( 'GlobalVarConfigTestNotHas' ) );
+ }
+
+ public static function provideGet() {
+ $set = [
+ 'wgSomething' => 'default1',
+ 'wgFoo' => 'default2',
+ 'efVariable' => 'default3',
+ 'BAR' => 'default4',
+ ];
+
+ foreach ( $set as $var => $value ) {
+ $GLOBALS[$var] = $value;
+ }
+
+ return [
+ [ 'Something', 'wg', 'default1' ],
+ [ 'Foo', 'wg', 'default2' ],
+ [ 'Variable', 'ef', 'default3' ],
+ [ 'BAR', '', 'default4' ],
+ [ 'ThisGlobalWasNotSetAbove', 'wg', false ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideGet
+ * @covers GlobalVarConfig::get
+ * @covers GlobalVarConfig::getWithPrefix
+ * @param string $name
+ * @param string $prefix
+ * @param string $expected
+ */
+ public function testGet( $name, $prefix, $expected ) {
+ $config = new GlobalVarConfig( $prefix );
+ if ( $expected === false ) {
+ $this->setExpectedException( ConfigException::class, 'GlobalVarConfig::get: undefined option:' );
+ }
+ $this->assertEquals( $config->get( $name ), $expected );
+ }
+
+ private function maybeStashGlobal( $var ) {
+ if ( array_key_exists( $var, $GLOBALS ) ) {
+ // Will be reset after this test is over
+ $this->stashMwGlobals( $var );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/config/HashConfigTest.php b/www/wiki/tests/phpunit/includes/config/HashConfigTest.php
new file mode 100644
index 00000000..bac8311c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/config/HashConfigTest.php
@@ -0,0 +1,63 @@
+<?php
+
+class HashConfigTest extends MediaWikiTestCase {
+
+ /**
+ * @covers HashConfig::newInstance
+ */
+ public function testNewInstance() {
+ $conf = HashConfig::newInstance();
+ $this->assertInstanceOf( HashConfig::class, $conf );
+ }
+
+ /**
+ * @covers HashConfig::__construct
+ */
+ public function testConstructor() {
+ $conf = new HashConfig();
+ $this->assertInstanceOf( HashConfig::class, $conf );
+
+ // Test passing arguments to the constructor
+ $conf2 = new HashConfig( [
+ 'one' => '1',
+ ] );
+ $this->assertEquals( '1', $conf2->get( 'one' ) );
+ }
+
+ /**
+ * @covers HashConfig::get
+ */
+ public function testGet() {
+ $conf = new HashConfig( [
+ 'one' => '1',
+ ] );
+ $this->assertEquals( '1', $conf->get( 'one' ) );
+ $this->setExpectedException( ConfigException::class, 'HashConfig::get: undefined option' );
+ $conf->get( 'two' );
+ }
+
+ /**
+ * @covers HashConfig::has
+ */
+ public function testHas() {
+ $conf = new HashConfig( [
+ 'one' => '1',
+ ] );
+ $this->assertTrue( $conf->has( 'one' ) );
+ $this->assertFalse( $conf->has( 'two' ) );
+ }
+
+ /**
+ * @covers HashConfig::set
+ */
+ public function testSet() {
+ $conf = new HashConfig( [
+ 'one' => '1',
+ ] );
+ $conf->set( 'two', '2' );
+ $this->assertEquals( '2', $conf->get( 'two' ) );
+ // Check that set overwrites
+ $conf->set( 'one', '3' );
+ $this->assertEquals( '3', $conf->get( 'one' ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/config/MultiConfigTest.php b/www/wiki/tests/phpunit/includes/config/MultiConfigTest.php
new file mode 100644
index 00000000..fc283951
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/config/MultiConfigTest.php
@@ -0,0 +1,39 @@
+<?php
+
+class MultiConfigTest extends MediaWikiTestCase {
+
+ /**
+ * Tests that settings are fetched in the right order
+ *
+ * @covers MultiConfig::__construct
+ * @covers MultiConfig::get
+ */
+ public function testGet() {
+ $multi = new MultiConfig( [
+ new HashConfig( [ 'foo' => 'bar' ] ),
+ new HashConfig( [ 'foo' => 'baz', 'bar' => 'foo' ] ),
+ new HashConfig( [ 'bar' => 'baz' ] ),
+ ] );
+
+ $this->assertEquals( 'bar', $multi->get( 'foo' ) );
+ $this->assertEquals( 'foo', $multi->get( 'bar' ) );
+ $this->setExpectedException( ConfigException::class, 'MultiConfig::get: undefined option:' );
+ $multi->get( 'notset' );
+ }
+
+ /**
+ * @covers MultiConfig::has
+ */
+ public function testHas() {
+ $conf = new MultiConfig( [
+ new HashConfig( [ 'foo' => 'foo' ] ),
+ new HashConfig( [ 'something' => 'bleh' ] ),
+ new HashConfig( [ 'meh' => 'eh' ] ),
+ ] );
+
+ $this->assertTrue( $conf->has( 'foo' ) );
+ $this->assertTrue( $conf->has( 'something' ) );
+ $this->assertTrue( $conf->has( 'meh' ) );
+ $this->assertFalse( $conf->has( 'what' ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/content/ContentHandlerTest.php b/www/wiki/tests/phpunit/includes/content/ContentHandlerTest.php
new file mode 100644
index 00000000..309b7b11
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/ContentHandlerTest.php
@@ -0,0 +1,497 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group ContentHandler
+ * @group Database
+ */
+class ContentHandlerTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ global $wgContLang;
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgExtraNamespaces' => [
+ 12312 => 'Dummy',
+ 12313 => 'Dummy_talk',
+ ],
+ // The below tests assume that namespaces not mentioned here (Help, User, MediaWiki, ..)
+ // default to CONTENT_MODEL_WIKITEXT.
+ 'wgNamespaceContentModels' => [
+ 12312 => 'testing',
+ ],
+ 'wgContentHandlers' => [
+ CONTENT_MODEL_WIKITEXT => WikitextContentHandler::class,
+ CONTENT_MODEL_JAVASCRIPT => JavaScriptContentHandler::class,
+ CONTENT_MODEL_JSON => JsonContentHandler::class,
+ CONTENT_MODEL_CSS => CssContentHandler::class,
+ CONTENT_MODEL_TEXT => TextContentHandler::class,
+ 'testing' => DummyContentHandlerForTesting::class,
+ 'testing-callbacks' => function ( $modelId ) {
+ return new DummyContentHandlerForTesting( $modelId );
+ }
+ ],
+ ] );
+
+ // Reset namespace cache
+ MWNamespace::clearCaches();
+ $wgContLang->resetNamespaces();
+ // And LinkCache
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'LinkCache' );
+ }
+
+ protected function tearDown() {
+ global $wgContLang;
+
+ // Reset namespace cache
+ MWNamespace::clearCaches();
+ $wgContLang->resetNamespaces();
+ // And LinkCache
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'LinkCache' );
+
+ parent::tearDown();
+ }
+
+ public function addDBDataOnce() {
+ $this->insertPage( 'Not_Main_Page', 'This is not a main page' );
+ $this->insertPage( 'Smithee', 'A smithee is one who smiths. See also [[Alan Smithee]]' );
+ }
+
+ public static function dataGetDefaultModelFor() {
+ return [
+ [ 'Help:Foo', CONTENT_MODEL_WIKITEXT ],
+ [ 'Help:Foo.js', CONTENT_MODEL_WIKITEXT ],
+ [ 'Help:Foo.css', CONTENT_MODEL_WIKITEXT ],
+ [ 'Help:Foo.json', CONTENT_MODEL_WIKITEXT ],
+ [ 'Help:Foo/bar.js', CONTENT_MODEL_WIKITEXT ],
+ [ 'User:Foo', CONTENT_MODEL_WIKITEXT ],
+ [ 'User:Foo.js', CONTENT_MODEL_WIKITEXT ],
+ [ 'User:Foo.css', CONTENT_MODEL_WIKITEXT ],
+ [ 'User:Foo.json', CONTENT_MODEL_WIKITEXT ],
+ [ 'User:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ],
+ [ 'User:Foo/bar.css', CONTENT_MODEL_CSS ],
+ [ 'User:Foo/bar.json', CONTENT_MODEL_JSON ],
+ [ 'User:Foo/bar.json.nope', CONTENT_MODEL_WIKITEXT ],
+ [ 'User talk:Foo/bar.css', CONTENT_MODEL_WIKITEXT ],
+ [ 'User:Foo/bar.js.xxx', CONTENT_MODEL_WIKITEXT ],
+ [ 'User:Foo/bar.xxx', CONTENT_MODEL_WIKITEXT ],
+ [ 'MediaWiki:Foo.js', CONTENT_MODEL_JAVASCRIPT ],
+ [ 'MediaWiki:Foo.JS', CONTENT_MODEL_WIKITEXT ],
+ [ 'MediaWiki:Foo.css', CONTENT_MODEL_CSS ],
+ [ 'MediaWiki:Foo.css.xxx', CONTENT_MODEL_WIKITEXT ],
+ [ 'MediaWiki:Foo.CSS', CONTENT_MODEL_WIKITEXT ],
+ [ 'MediaWiki:Foo.json', CONTENT_MODEL_JSON ],
+ [ 'MediaWiki:Foo.JSON', CONTENT_MODEL_WIKITEXT ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetDefaultModelFor
+ * @covers ContentHandler::getDefaultModelFor
+ */
+ public function testGetDefaultModelFor( $title, $expectedModelId ) {
+ $title = Title::newFromText( $title );
+ $this->assertEquals( $expectedModelId, ContentHandler::getDefaultModelFor( $title ) );
+ }
+
+ /**
+ * @dataProvider dataGetDefaultModelFor
+ * @covers ContentHandler::getForTitle
+ */
+ public function testGetForTitle( $title, $expectedContentModel ) {
+ $title = Title::newFromText( $title );
+ LinkCache::singleton()->addBadLinkObj( $title );
+ $handler = ContentHandler::getForTitle( $title );
+ $this->assertEquals( $expectedContentModel, $handler->getModelID() );
+ }
+
+ public static function dataGetLocalizedName() {
+ return [
+ [ null, null ],
+ [ "xyzzy", null ],
+
+ // XXX: depends on content language
+ [ CONTENT_MODEL_JAVASCRIPT, '/javascript/i' ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetLocalizedName
+ * @covers ContentHandler::getLocalizedName
+ */
+ public function testGetLocalizedName( $id, $expected ) {
+ $name = ContentHandler::getLocalizedName( $id );
+
+ if ( $expected ) {
+ $this->assertNotNull( $name, "no name found for content model $id" );
+ $this->assertTrue( preg_match( $expected, $name ) > 0,
+ "content model name for #$id did not match pattern $expected"
+ );
+ } else {
+ $this->assertEquals( $id, $name, "localization of unknown model $id should have "
+ . "fallen back to use the model id directly."
+ );
+ }
+ }
+
+ public static function dataGetPageLanguage() {
+ global $wgLanguageCode;
+
+ return [
+ [ "Main", $wgLanguageCode ],
+ [ "Dummy:Foo", $wgLanguageCode ],
+ [ "MediaWiki:common.js", 'en' ],
+ [ "User:Foo/common.js", 'en' ],
+ [ "MediaWiki:common.css", 'en' ],
+ [ "User:Foo/common.css", 'en' ],
+ [ "User:Foo", $wgLanguageCode ],
+
+ [ CONTENT_MODEL_JAVASCRIPT, 'javascript' ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetPageLanguage
+ * @covers ContentHandler::getPageLanguage
+ */
+ public function testGetPageLanguage( $title, $expected ) {
+ if ( is_string( $title ) ) {
+ $title = Title::newFromText( $title );
+ LinkCache::singleton()->addBadLinkObj( $title );
+ }
+
+ $expected = wfGetLangObj( $expected );
+
+ $handler = ContentHandler::getForTitle( $title );
+ $lang = $handler->getPageLanguage( $title );
+
+ $this->assertEquals( $expected->getCode(), $lang->getCode() );
+ }
+
+ public static function dataGetContentText_Null() {
+ return [
+ [ 'fail' ],
+ [ 'serialize' ],
+ [ 'ignore' ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetContentText_Null
+ * @covers ContentHandler::getContentText
+ */
+ public function testGetContentText_Null( $contentHandlerTextFallback ) {
+ $this->setMwGlobals( 'wgContentHandlerTextFallback', $contentHandlerTextFallback );
+
+ $content = null;
+
+ $text = ContentHandler::getContentText( $content );
+ $this->assertEquals( '', $text );
+ }
+
+ public static function dataGetContentText_TextContent() {
+ return [
+ [ 'fail' ],
+ [ 'serialize' ],
+ [ 'ignore' ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetContentText_TextContent
+ * @covers ContentHandler::getContentText
+ */
+ public function testGetContentText_TextContent( $contentHandlerTextFallback ) {
+ $this->setMwGlobals( 'wgContentHandlerTextFallback', $contentHandlerTextFallback );
+
+ $content = new WikitextContent( "hello world" );
+
+ $text = ContentHandler::getContentText( $content );
+ $this->assertEquals( $content->getNativeData(), $text );
+ }
+
+ /**
+ * ContentHandler::getContentText should have thrown an exception for non-text Content object
+ * @expectedException MWException
+ * @covers ContentHandler::getContentText
+ */
+ public function testGetContentText_NonTextContent_fail() {
+ $this->setMwGlobals( 'wgContentHandlerTextFallback', 'fail' );
+
+ $content = new DummyContentForTesting( "hello world" );
+
+ ContentHandler::getContentText( $content );
+ }
+
+ /**
+ * @covers ContentHandler::getContentText
+ */
+ public function testGetContentText_NonTextContent_serialize() {
+ $this->setMwGlobals( 'wgContentHandlerTextFallback', 'serialize' );
+
+ $content = new DummyContentForTesting( "hello world" );
+
+ $text = ContentHandler::getContentText( $content );
+ $this->assertEquals( $content->serialize(), $text );
+ }
+
+ /**
+ * @covers ContentHandler::getContentText
+ */
+ public function testGetContentText_NonTextContent_ignore() {
+ $this->setMwGlobals( 'wgContentHandlerTextFallback', 'ignore' );
+
+ $content = new DummyContentForTesting( "hello world" );
+
+ $text = ContentHandler::getContentText( $content );
+ $this->assertNull( $text );
+ }
+
+ public static function dataMakeContent() {
+ return [
+ [ 'hallo', 'Help:Test', null, null, CONTENT_MODEL_WIKITEXT, 'hallo', false ],
+ [ 'hallo', 'MediaWiki:Test.js', null, null, CONTENT_MODEL_JAVASCRIPT, 'hallo', false ],
+ [ serialize( 'hallo' ), 'Dummy:Test', null, null, "testing", 'hallo', false ],
+
+ [
+ 'hallo',
+ 'Help:Test',
+ null,
+ CONTENT_FORMAT_WIKITEXT,
+ CONTENT_MODEL_WIKITEXT,
+ 'hallo',
+ false
+ ],
+ [
+ 'hallo',
+ 'MediaWiki:Test.js',
+ null,
+ CONTENT_FORMAT_JAVASCRIPT,
+ CONTENT_MODEL_JAVASCRIPT,
+ 'hallo',
+ false
+ ],
+ [ serialize( 'hallo' ), 'Dummy:Test', null, "testing", "testing", 'hallo', false ],
+
+ [ 'hallo', 'Help:Test', CONTENT_MODEL_CSS, null, CONTENT_MODEL_CSS, 'hallo', false ],
+ [
+ 'hallo',
+ 'MediaWiki:Test.js',
+ CONTENT_MODEL_CSS,
+ null,
+ CONTENT_MODEL_CSS,
+ 'hallo',
+ false
+ ],
+ [
+ serialize( 'hallo' ),
+ 'Dummy:Test',
+ CONTENT_MODEL_CSS,
+ null,
+ CONTENT_MODEL_CSS,
+ serialize( 'hallo' ),
+ false
+ ],
+
+ [ 'hallo', 'Help:Test', CONTENT_MODEL_WIKITEXT, "testing", null, null, true ],
+ [ 'hallo', 'MediaWiki:Test.js', CONTENT_MODEL_CSS, "testing", null, null, true ],
+ [ 'hallo', 'Dummy:Test', CONTENT_MODEL_JAVASCRIPT, "testing", null, null, true ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataMakeContent
+ * @covers ContentHandler::makeContent
+ */
+ public function testMakeContent( $data, $title, $modelId, $format,
+ $expectedModelId, $expectedNativeData, $shouldFail
+ ) {
+ $title = Title::newFromText( $title );
+ LinkCache::singleton()->addBadLinkObj( $title );
+ try {
+ $content = ContentHandler::makeContent( $data, $title, $modelId, $format );
+
+ if ( $shouldFail ) {
+ $this->fail( "ContentHandler::makeContent should have failed!" );
+ }
+
+ $this->assertEquals( $expectedModelId, $content->getModel(), 'bad model id' );
+ $this->assertEquals( $expectedNativeData, $content->getNativeData(), 'bads native data' );
+ } catch ( MWException $ex ) {
+ if ( !$shouldFail ) {
+ $this->fail( "ContentHandler::makeContent failed unexpectedly: " . $ex->getMessage() );
+ } else {
+ // dummy, so we don't get the "test did not perform any assertions" message.
+ $this->assertTrue( true );
+ }
+ }
+ }
+
+ /**
+ * @covers ContentHandler::getAutosummary
+ *
+ * Test if we become a "Created blank page" summary from getAutoSummary if no Content added to
+ * page.
+ */
+ public function testGetAutosummary() {
+ $this->setMwGlobals( 'wgContLang', Language::factory( 'en' ) );
+
+ $content = new DummyContentHandlerForTesting( CONTENT_MODEL_WIKITEXT );
+ $title = Title::newFromText( 'Help:Test' );
+ // Create a new content object with no content
+ $newContent = ContentHandler::makeContent( '', $title, CONTENT_MODEL_WIKITEXT, null );
+ // first check, if we become a blank page created summary with the right bitmask
+ $autoSummary = $content->getAutosummary( null, $newContent, 97 );
+ $this->assertEquals( $autoSummary,
+ wfMessage( 'autosumm-newblank' )->inContentLanguage()->text() );
+ // now check, what we become with another bitmask
+ $autoSummary = $content->getAutosummary( null, $newContent, 92 );
+ $this->assertEquals( $autoSummary, '' );
+ }
+
+ /**
+ * Test software tag that is added when content model of the page changes
+ * @covers ContentHandler::getChangeTag
+ */
+ public function testGetChangeTag() {
+ $this->setMwGlobals( 'wgSoftwareTags', [ 'mw-contentmodelchange' => true ] );
+ $wikitextContentHandler = new DummyContentHandlerForTesting( CONTENT_MODEL_WIKITEXT );
+ // Create old content object with javascript content model
+ $oldContent = ContentHandler::makeContent( '', null, CONTENT_MODEL_JAVASCRIPT, null );
+ // Create new content object with wikitext content model
+ $newContent = ContentHandler::makeContent( '', null, CONTENT_MODEL_WIKITEXT, null );
+ // Get the tag for this edit
+ $tag = $wikitextContentHandler->getChangeTag( $oldContent, $newContent, EDIT_UPDATE );
+ $this->assertSame( $tag, 'mw-contentmodelchange' );
+ }
+
+ /**
+ * @covers ContentHandler::supportsCategories
+ */
+ public function testSupportsCategories() {
+ $handler = new DummyContentHandlerForTesting( CONTENT_MODEL_WIKITEXT );
+ $this->assertTrue( $handler->supportsCategories(), 'content model supports categories' );
+ }
+
+ /**
+ * @covers ContentHandler::supportsDirectEditing
+ */
+ public function testSupportsDirectEditing() {
+ $handler = new DummyContentHandlerForTesting( CONTENT_MODEL_JSON );
+ $this->assertFalse( $handler->supportsDirectEditing(), 'direct editing is not supported' );
+ }
+
+ public static function dummyHookHandler( $foo, &$text, $bar ) {
+ if ( $text === null || $text === false ) {
+ return false;
+ }
+
+ $text = strtoupper( $text );
+
+ return true;
+ }
+
+ public function provideGetModelForID() {
+ return [
+ [ CONTENT_MODEL_WIKITEXT, WikitextContentHandler::class ],
+ [ CONTENT_MODEL_JAVASCRIPT, JavaScriptContentHandler::class ],
+ [ CONTENT_MODEL_JSON, JsonContentHandler::class ],
+ [ CONTENT_MODEL_CSS, CssContentHandler::class ],
+ [ CONTENT_MODEL_TEXT, TextContentHandler::class ],
+ [ 'testing', DummyContentHandlerForTesting::class ],
+ [ 'testing-callbacks', DummyContentHandlerForTesting::class ],
+ ];
+ }
+
+ /**
+ * @covers ContentHandler::getForModelID
+ * @dataProvider provideGetModelForID
+ */
+ public function testGetModelForID( $modelId, $handlerClass ) {
+ $handler = ContentHandler::getForModelID( $modelId );
+
+ $this->assertInstanceOf( $handlerClass, $handler );
+ }
+
+ /**
+ * @covers ContentHandler::getFieldsForSearchIndex
+ */
+ public function testGetFieldsForSearchIndex() {
+ $searchEngine = $this->newSearchEngine();
+
+ $handler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT );
+
+ $fields = $handler->getFieldsForSearchIndex( $searchEngine );
+
+ $this->assertArrayHasKey( 'category', $fields );
+ $this->assertArrayHasKey( 'external_link', $fields );
+ $this->assertArrayHasKey( 'outgoing_link', $fields );
+ $this->assertArrayHasKey( 'template', $fields );
+ $this->assertArrayHasKey( 'content_model', $fields );
+ }
+
+ private function newSearchEngine() {
+ $searchEngine = $this->getMockBuilder( SearchEngine::class )
+ ->getMock();
+
+ $searchEngine->expects( $this->any() )
+ ->method( 'makeSearchFieldMapping' )
+ ->will( $this->returnCallback( function ( $name, $type ) {
+ return new DummySearchIndexFieldDefinition( $name, $type );
+ } ) );
+
+ return $searchEngine;
+ }
+
+ /**
+ * @covers ContentHandler::getDataForSearchIndex
+ */
+ public function testDataIndexFields() {
+ $mockEngine = $this->createMock( SearchEngine::class );
+ $title = Title::newFromText( 'Not_Main_Page', NS_MAIN );
+ $page = new WikiPage( $title );
+
+ $this->setTemporaryHook( 'SearchDataForIndex',
+ function (
+ &$fields,
+ ContentHandler $handler,
+ WikiPage $page,
+ ParserOutput $output,
+ SearchEngine $engine
+ ) {
+ $fields['testDataField'] = 'test content';
+ } );
+
+ $output = $page->getContent()->getParserOutput( $title );
+ $data = $page->getContentHandler()->getDataForSearchIndex( $page, $output, $mockEngine );
+ $this->assertArrayHasKey( 'text', $data );
+ $this->assertArrayHasKey( 'text_bytes', $data );
+ $this->assertArrayHasKey( 'language', $data );
+ $this->assertArrayHasKey( 'testDataField', $data );
+ $this->assertEquals( 'test content', $data['testDataField'] );
+ $this->assertEquals( 'wikitext', $data['content_model'] );
+ }
+
+ /**
+ * @covers ContentHandler::getParserOutputForIndexing
+ */
+ public function testParserOutputForIndexing() {
+ $title = Title::newFromText( 'Smithee', NS_MAIN );
+ $page = new WikiPage( $title );
+
+ $out = $page->getContentHandler()->getParserOutputForIndexing( $page );
+ $this->assertInstanceOf( ParserOutput::class, $out );
+ $this->assertContains( 'one who smiths', $out->getRawText() );
+ }
+
+ /**
+ * @covers ContentHandler::getContentModels
+ */
+ public function testGetContentModelsHook() {
+ $this->setTemporaryHook( 'GetContentModels', function ( &$models ) {
+ $models[] = 'Ferrari';
+ } );
+ $this->assertContains( 'Ferrari', ContentHandler::getContentModels() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/content/CssContentHandlerTest.php b/www/wiki/tests/phpunit/includes/content/CssContentHandlerTest.php
new file mode 100644
index 00000000..7ca1afce
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/CssContentHandlerTest.php
@@ -0,0 +1,41 @@
+<?php
+
+class CssContentHandlerTest extends MediaWikiLangTestCase {
+
+ /**
+ * @dataProvider provideMakeRedirectContent
+ * @covers CssContentHandler::makeRedirectContent
+ */
+ public function testMakeRedirectContent( $title, $expected ) {
+ $this->setMwGlobals( [
+ 'wgServer' => '//example.org',
+ 'wgScript' => '/w/index.php',
+ ] );
+ $ch = new CssContentHandler();
+ $content = $ch->makeRedirectContent( Title::newFromText( $title ) );
+ $this->assertInstanceOf( CssContent::class, $content );
+ $this->assertEquals( $expected, $content->serialize( CONTENT_FORMAT_CSS ) );
+ }
+
+ /**
+ * Keep this in sync with CssContentTest::provideGetRedirectTarget()
+ */
+ public static function provideMakeRedirectContent() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ 'MediaWiki:MonoBook.css',
+ "/* #REDIRECT */@import url(//example.org/w/index.php?title=MediaWiki:MonoBook.css&action=raw&ctype=text/css);"
+ ],
+ [
+ 'User:FooBar/common.css',
+ "/* #REDIRECT */@import url(//example.org/w/index.php?title=User:FooBar/common.css&action=raw&ctype=text/css);"
+ ],
+ [
+ 'Gadget:FooBaz.css',
+ "/* #REDIRECT */@import url(//example.org/w/index.php?title=Gadget:FooBaz.css&action=raw&ctype=text/css);"
+ ],
+ ];
+ // phpcs:enable
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/content/CssContentTest.php b/www/wiki/tests/phpunit/includes/content/CssContentTest.php
new file mode 100644
index 00000000..f5cc05e0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/CssContentTest.php
@@ -0,0 +1,133 @@
+<?php
+
+/**
+ * @group ContentHandler
+ * @group Database
+ * ^--- needed, because we do need the database to test link updates
+ *
+ * @FIXME this should not extend JavaScriptContentTest.
+ */
+class CssContentTest extends JavaScriptContentTest {
+
+ protected function setUp() {
+ parent::setUp();
+
+ // Anon user
+ $user = new User();
+ $user->setName( '127.0.0.1' );
+
+ $this->setMwGlobals( [
+ 'wgUser' => $user,
+ 'wgTextModelsToParse' => [
+ CONTENT_MODEL_CSS,
+ ]
+ ] );
+ }
+
+ public function newContent( $text ) {
+ return new CssContent( $text );
+ }
+
+ public static function dataGetParserOutput() {
+ return [
+ [
+ 'MediaWiki:Test.css',
+ null,
+ "hello <world>\n",
+ "<pre class=\"mw-code mw-css\" dir=\"ltr\">\nhello &lt;world&gt;\n\n</pre>"
+ ],
+ [
+ 'MediaWiki:Test.css',
+ null,
+ "/* hello [[world]] */\n",
+ "<pre class=\"mw-code mw-css\" dir=\"ltr\">\n/* hello [[world]] */\n\n</pre>",
+ [
+ 'Links' => [
+ [ 'World' => 0 ]
+ ]
+ ]
+ ],
+
+ // TODO: more...?
+ ];
+ }
+
+ /**
+ * @covers CssContent::getModel
+ */
+ public function testGetModel() {
+ $content = $this->newContent( 'hello world.' );
+
+ $this->assertEquals( CONTENT_MODEL_CSS, $content->getModel() );
+ }
+
+ /**
+ * @covers CssContent::getContentHandler
+ */
+ public function testGetContentHandler() {
+ $content = $this->newContent( 'hello world.' );
+
+ $this->assertEquals( CONTENT_MODEL_CSS, $content->getContentHandler()->getModelID() );
+ }
+
+ /**
+ * Redirects aren't supported
+ */
+ public static function provideUpdateRedirect() {
+ return [
+ [
+ '#REDIRECT [[Someplace]]',
+ '#REDIRECT [[Someplace]]',
+ ],
+ ];
+ }
+
+ /**
+ * @covers CssContent::getRedirectTarget
+ * @dataProvider provideGetRedirectTarget
+ */
+ public function testGetRedirectTarget( $title, $text ) {
+ $this->setMwGlobals( [
+ 'wgServer' => '//example.org',
+ 'wgScriptPath' => '/w',
+ 'wgScript' => '/w/index.php',
+ ] );
+ $content = new CssContent( $text );
+ $target = $content->getRedirectTarget();
+ $this->assertEquals( $title, $target ? $target->getPrefixedText() : null );
+ }
+
+ /**
+ * Keep this in sync with CssContentHandlerTest::provideMakeRedirectContent()
+ */
+ public static function provideGetRedirectTarget() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [ 'MediaWiki:MonoBook.css', "/* #REDIRECT */@import url(//example.org/w/index.php?title=MediaWiki:MonoBook.css&action=raw&ctype=text/css);" ],
+ [ 'User:FooBar/common.css', "/* #REDIRECT */@import url(//example.org/w/index.php?title=User:FooBar/common.css&action=raw&ctype=text/css);" ],
+ [ 'Gadget:FooBaz.css', "/* #REDIRECT */@import url(//example.org/w/index.php?title=Gadget:FooBaz.css&action=raw&ctype=text/css);" ],
+ # No #REDIRECT comment
+ [ null, "@import url(//example.org/w/index.php?title=Gadget:FooBaz.css&action=raw&ctype=text/css);" ],
+ # Wrong domain
+ [ null, "/* #REDIRECT */@import url(//example.com/w/index.php?title=Gadget:FooBaz.css&action=raw&ctype=text/css);" ],
+ ];
+ // phpcs:enable
+ }
+
+ public static function dataEquals() {
+ return [
+ [ new CssContent( 'hallo' ), null, false ],
+ [ new CssContent( 'hallo' ), new CssContent( 'hallo' ), true ],
+ [ new CssContent( 'hallo' ), new WikitextContent( 'hallo' ), false ],
+ [ new CssContent( 'hallo' ), new CssContent( 'HALLO' ), false ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataEquals
+ * @covers CssContent::equals
+ */
+ public function testEquals( Content $a, Content $b = null, $equal = false ) {
+ $this->assertEquals( $equal, $a->equals( $b ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/content/FileContentHandlerTest.php b/www/wiki/tests/phpunit/includes/content/FileContentHandlerTest.php
new file mode 100644
index 00000000..9149fc4f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/FileContentHandlerTest.php
@@ -0,0 +1,52 @@
+<?php
+
+/**
+ * @group ContentHandler
+ *
+ * @covers FileContentHandler
+ */
+class FileContentHandlerTest extends MediaWikiLangTestCase {
+ /**
+ * @var FileContentHandler
+ */
+ private $handler;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->handler = new FileContentHandler();
+ }
+
+ public function testIndexMapping() {
+ $mockEngine = $this->createMock( SearchEngine::class );
+
+ $mockEngine->expects( $this->atLeastOnce() )
+ ->method( 'makeSearchFieldMapping' )
+ ->willReturnCallback( function ( $name, $type ) {
+ $mockField =
+ $this->getMockBuilder( SearchIndexFieldDefinition::class )
+ ->setMethods( [ 'getMapping' ] )
+ ->setConstructorArgs( [ $name, $type ] )
+ ->getMock();
+ return $mockField;
+ } );
+
+ $map = $this->handler->getFieldsForSearchIndex( $mockEngine );
+ $expect = [
+ 'file_media_type' => 1,
+ 'file_mime' => 1,
+ 'file_size' => 1,
+ 'file_width' => 1,
+ 'file_height' => 1,
+ 'file_bits' => 1,
+ 'file_resolution' => 1,
+ 'file_text' => 1,
+ ];
+ foreach ( $map as $name => $field ) {
+ $this->assertInstanceOf( SearchIndexField::class, $field );
+ $this->assertEquals( $name, $field->getName() );
+ unset( $expect[$name] );
+ }
+ $this->assertEmpty( $expect );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/content/JavaScriptContentHandlerTest.php b/www/wiki/tests/phpunit/includes/content/JavaScriptContentHandlerTest.php
new file mode 100644
index 00000000..b5e3ab4a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/JavaScriptContentHandlerTest.php
@@ -0,0 +1,41 @@
+<?php
+
+class JavaScriptContentHandlerTest extends MediaWikiLangTestCase {
+
+ /**
+ * @dataProvider provideMakeRedirectContent
+ * @covers JavaScriptContentHandler::makeRedirectContent
+ */
+ public function testMakeRedirectContent( $title, $expected ) {
+ $this->setMwGlobals( [
+ 'wgServer' => '//example.org',
+ 'wgScript' => '/w/index.php',
+ ] );
+ $ch = new JavaScriptContentHandler();
+ $content = $ch->makeRedirectContent( Title::newFromText( $title ) );
+ $this->assertInstanceOf( JavaScriptContent::class, $content );
+ $this->assertEquals( $expected, $content->serialize( CONTENT_FORMAT_JAVASCRIPT ) );
+ }
+
+ /**
+ * Keep this in sync with JavaScriptContentTest::provideGetRedirectTarget()
+ */
+ public static function provideMakeRedirectContent() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ 'MediaWiki:MonoBook.js',
+ '/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=MediaWiki:MonoBook.js\u0026action=raw\u0026ctype=text/javascript");'
+ ],
+ [
+ 'User:FooBar/common.js',
+ '/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=User:FooBar/common.js\u0026action=raw\u0026ctype=text/javascript");'
+ ],
+ [
+ 'Gadget:FooBaz.js',
+ '/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=Gadget:FooBaz.js\u0026action=raw\u0026ctype=text/javascript");'
+ ],
+ ];
+ // phpcs:enable
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/content/JavaScriptContentTest.php b/www/wiki/tests/phpunit/includes/content/JavaScriptContentTest.php
new file mode 100644
index 00000000..823be6f7
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/JavaScriptContentTest.php
@@ -0,0 +1,327 @@
+<?php
+
+/**
+ * @group ContentHandler
+ * @group Database
+ * ^--- needed, because we do need the database to test link updates
+ */
+class JavaScriptContentTest extends TextContentTest {
+
+ public function newContent( $text ) {
+ return new JavaScriptContent( $text );
+ }
+
+ public static function dataGetParserOutput() {
+ return [
+ [
+ 'MediaWiki:Test.js',
+ null,
+ "hello <world>\n",
+ "<pre class=\"mw-code mw-js\" dir=\"ltr\">\nhello &lt;world&gt;\n\n</pre>"
+ ],
+ [
+ 'MediaWiki:Test.js',
+ null,
+ "hello(); // [[world]]\n",
+ "<pre class=\"mw-code mw-js\" dir=\"ltr\">\nhello(); // [[world]]\n\n</pre>",
+ [
+ 'Links' => [
+ [ 'World' => 0 ]
+ ]
+ ]
+ ],
+
+ // TODO: more...?
+ ];
+ }
+
+ // XXX: Unused function
+ public static function dataGetSection() {
+ return [
+ [ WikitextContentTest::$sections,
+ '0',
+ null
+ ],
+ [ WikitextContentTest::$sections,
+ '2',
+ null
+ ],
+ [ WikitextContentTest::$sections,
+ '8',
+ null
+ ],
+ ];
+ }
+
+ // XXX: Unused function
+ public static function dataReplaceSection() {
+ return [
+ [ WikitextContentTest::$sections,
+ '0',
+ 'No more',
+ null,
+ null
+ ],
+ [ WikitextContentTest::$sections,
+ '',
+ 'No more',
+ null,
+ null
+ ],
+ [ WikitextContentTest::$sections,
+ '2',
+ "== TEST ==\nmore fun",
+ null,
+ null
+ ],
+ [ WikitextContentTest::$sections,
+ '8',
+ 'No more',
+ null,
+ null
+ ],
+ [ WikitextContentTest::$sections,
+ 'new',
+ 'No more',
+ 'New',
+ null
+ ],
+ ];
+ }
+
+ /**
+ * @covers JavaScriptContent::addSectionHeader
+ */
+ public function testAddSectionHeader() {
+ $content = $this->newContent( 'hello world' );
+ $c = $content->addSectionHeader( 'test' );
+
+ $this->assertTrue( $content->equals( $c ) );
+ }
+
+ // XXX: currently, preSaveTransform is applied to scripts. this may change or become optional.
+ public static function dataPreSaveTransform() {
+ return [
+ [ 'hello this is ~~~',
+ "hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]",
+ ],
+ [ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
+ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
+ ],
+ [ " Foo \n ",
+ " Foo",
+ ],
+ ];
+ }
+
+ public static function dataPreloadTransform() {
+ return [
+ [
+ 'hello this is ~~~',
+ 'hello this is ~~~',
+ ],
+ [
+ 'hello \'\'this\'\' is <noinclude>foo</noinclude><includeonly>bar</includeonly>',
+ 'hello \'\'this\'\' is <noinclude>foo</noinclude><includeonly>bar</includeonly>',
+ ],
+ ];
+ }
+
+ public static function dataGetRedirectTarget() {
+ return [
+ [ '#REDIRECT [[Test]]',
+ null,
+ ],
+ [ '#REDIRECT Test',
+ null,
+ ],
+ [ '* #REDIRECT [[Test]]',
+ null,
+ ],
+ ];
+ }
+
+ public static function dataIsCountable() {
+ return [
+ [ '',
+ null,
+ 'any',
+ true
+ ],
+ [ 'Foo',
+ null,
+ 'any',
+ true
+ ],
+ [ 'Foo',
+ null,
+ 'link',
+ false
+ ],
+ [ 'Foo [[bar]]',
+ null,
+ 'link',
+ false
+ ],
+ [ 'Foo',
+ true,
+ 'link',
+ false
+ ],
+ [ 'Foo [[bar]]',
+ false,
+ 'link',
+ false
+ ],
+ [ '#REDIRECT [[bar]]',
+ true,
+ 'any',
+ true
+ ],
+ [ '#REDIRECT [[bar]]',
+ true,
+ 'link',
+ false
+ ],
+ ];
+ }
+
+ public static function dataGetTextForSummary() {
+ return [
+ [ "hello\nworld.",
+ 16,
+ 'hello world.',
+ ],
+ [ 'hello world.',
+ 8,
+ 'hello...',
+ ],
+ [ '[[hello world]].',
+ 8,
+ '[[hel...',
+ ],
+ ];
+ }
+
+ /**
+ * @covers JavaScriptContent::matchMagicWord
+ */
+ public function testMatchMagicWord() {
+ $mw = MagicWord::get( "staticredirect" );
+
+ $content = $this->newContent( "#REDIRECT [[FOO]]\n__STATICREDIRECT__" );
+ $this->assertFalse(
+ $content->matchMagicWord( $mw ),
+ "should not have matched magic word, since it's not wikitext"
+ );
+ }
+
+ /**
+ * @covers JavaScriptContent::updateRedirect
+ * @dataProvider provideUpdateRedirect
+ */
+ public function testUpdateRedirect( $oldText, $expectedText ) {
+ $this->setMwGlobals( [
+ 'wgServer' => '//example.org',
+ 'wgScriptPath' => '/w',
+ 'wgScript' => '/w/index.php',
+ 'wgResourceBasePath' => '/w',
+ ] );
+ $target = Title::newFromText( "testUpdateRedirect_target" );
+
+ $content = new JavaScriptContent( $oldText );
+ $newContent = $content->updateRedirect( $target );
+
+ $this->assertEquals( $expectedText, $newContent->getNativeData() );
+ }
+
+ public static function provideUpdateRedirect() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ '#REDIRECT [[Someplace]]',
+ '#REDIRECT [[Someplace]]',
+ ],
+ [
+ '/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=MediaWiki:MonoBook.js\u0026action=raw\u0026ctype=text/javascript");',
+ '/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=TestUpdateRedirect_target\u0026action=raw\u0026ctype=text/javascript");'
+ ]
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @covers JavaScriptContent::getModel
+ */
+ public function testGetModel() {
+ $content = $this->newContent( "hello world." );
+
+ $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $content->getModel() );
+ }
+
+ /**
+ * @covers JavaScriptContent::getContentHandler
+ */
+ public function testGetContentHandler() {
+ $content = $this->newContent( "hello world." );
+
+ $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $content->getContentHandler()->getModelID() );
+ }
+
+ public static function dataEquals() {
+ return [
+ [ new JavaScriptContent( "hallo" ), null, false ],
+ [ new JavaScriptContent( "hallo" ), new JavaScriptContent( "hallo" ), true ],
+ [ new JavaScriptContent( "hallo" ), new CssContent( "hallo" ), false ],
+ [ new JavaScriptContent( "hallo" ), new JavaScriptContent( "HALLO" ), false ],
+ ];
+ }
+
+ /**
+ * @covers JavaScriptContent::getRedirectTarget
+ * @dataProvider provideGetRedirectTarget
+ */
+ public function testGetRedirectTarget( $title, $text ) {
+ $this->setMwGlobals( [
+ 'wgServer' => '//example.org',
+ 'wgScriptPath' => '/w',
+ 'wgScript' => '/w/index.php',
+ 'wgResourceBasePath' => '/w',
+ ] );
+ $content = new JavaScriptContent( $text );
+ $target = $content->getRedirectTarget();
+ $this->assertEquals( $title, $target ? $target->getPrefixedText() : null );
+ }
+
+ /**
+ * Keep this in sync with JavaScriptContentHandlerTest::provideMakeRedirectContent()
+ */
+ public static function provideGetRedirectTarget() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ 'MediaWiki:MonoBook.js',
+ '/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=MediaWiki:MonoBook.js\u0026action=raw\u0026ctype=text/javascript");'
+ ],
+ [
+ 'User:FooBar/common.js',
+ '/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=User:FooBar/common.js\u0026action=raw\u0026ctype=text/javascript");'
+ ],
+ [
+ 'Gadget:FooBaz.js',
+ '/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=Gadget:FooBaz.js\u0026action=raw\u0026ctype=text/javascript");'
+ ],
+ // No #REDIRECT comment
+ [
+ null,
+ 'mw.loader.load("//example.org/w/index.php?title=MediaWiki:NoRedirect.js\u0026action=raw\u0026ctype=text/javascript");'
+ ],
+ // Different domain
+ [
+ null,
+ '/* #REDIRECT */mw.loader.load("//example.com/w/index.php?title=MediaWiki:OtherWiki.js\u0026action=raw\u0026ctype=text/javascript");'
+ ],
+ ];
+ // phpcs:enable
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/content/JsonContentHandlerTest.php b/www/wiki/tests/phpunit/includes/content/JsonContentHandlerTest.php
new file mode 100644
index 00000000..abfb6733
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/JsonContentHandlerTest.php
@@ -0,0 +1,14 @@
+<?php
+
+class JsonContentHandlerTest extends MediaWikiTestCase {
+
+ /**
+ * @covers JsonContentHandler::makeEmptyContent
+ */
+ public function testMakeEmptyContent() {
+ $handler = new JsonContentHandler();
+ $content = $handler->makeEmptyContent();
+ $this->assertInstanceOf( JsonContent::class, $content );
+ $this->assertTrue( $content->isValid() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/content/JsonContentTest.php b/www/wiki/tests/phpunit/includes/content/JsonContentTest.php
new file mode 100644
index 00000000..7cddbad2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/JsonContentTest.php
@@ -0,0 +1,152 @@
+<?php
+
+/**
+ * @author Addshore
+ * @covers JsonContent
+ */
+class JsonContentTest extends MediaWikiLangTestCase {
+
+ public static function provideValidConstruction() {
+ return [
+ [ 'foo', false, null ],
+ [ '[]', true, [] ],
+ [ '{}', true, (object)[] ],
+ [ '""', true, '' ],
+ [ '"0"', true, '0' ],
+ [ '"bar"', true, 'bar' ],
+ [ '0', true, '0' ],
+ [ '{ "0": "bar" }', true, (object)[ 'bar' ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideValidConstruction
+ */
+ public function testIsValid( $text, $isValid, $expected ) {
+ $obj = new JsonContent( $text, CONTENT_MODEL_JSON );
+ $this->assertEquals( $isValid, $obj->isValid() );
+ $this->assertEquals( $expected, $obj->getData()->getValue() );
+ }
+
+ public static function provideDataToEncode() {
+ return [
+ [
+ // Round-trip empty array
+ '[]',
+ '[]',
+ ],
+ [
+ // Round-trip empty object
+ '{}',
+ '{}',
+ ],
+ [
+ // Round-trip empty array/object (nested)
+ '{ "foo": {}, "bar": [] }',
+ "{\n \"foo\": {},\n \"bar\": []\n}",
+ ],
+ [
+ '{ "foo": "bar" }',
+ "{\n \"foo\": \"bar\"\n}",
+ ],
+ [
+ '{ "foo": 1000 }',
+ "{\n \"foo\": 1000\n}",
+ ],
+ [
+ '{ "foo": 1000, "0": "bar" }',
+ "{\n \"foo\": 1000,\n \"0\": \"bar\"\n}",
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideDataToEncode
+ */
+ public function testBeautifyJson( $input, $beautified ) {
+ $obj = new JsonContent( $input );
+ $this->assertEquals( $beautified, $obj->beautifyJSON() );
+ }
+
+ /**
+ * @dataProvider provideDataToEncode
+ */
+ public function testPreSaveTransform( $input, $transformed ) {
+ $obj = new JsonContent( $input );
+ $newObj = $obj->preSaveTransform(
+ $this->getMockTitle(),
+ $this->getMockUser(),
+ $this->getMockParserOptions()
+ );
+ $this->assertTrue( $newObj->equals( new JsonContent( $transformed ) ) );
+ }
+
+ private function getMockTitle() {
+ return $this->getMockBuilder( Title::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ private function getMockUser() {
+ return $this->getMockBuilder( User::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+ private function getMockParserOptions() {
+ return $this->getMockBuilder( ParserOptions::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ public static function provideDataAndParserText() {
+ return [
+ [
+ [],
+ '<table class="mw-json"><tbody><tr><td>' .
+ '<table class="mw-json"><tbody><tr><td class="mw-json-empty">Empty array</td></tr>'
+ . '</tbody></table></td></tr></tbody></table>'
+ ],
+ [
+ (object)[],
+ '<table class="mw-json"><tbody><tr><td class="mw-json-empty">Empty object</td></tr>' .
+ '</tbody></table>'
+ ],
+ [
+ (object)[ 'foo' ],
+ '<table class="mw-json"><tbody><tr><th>0</th><td class="value">"foo"</td></tr>' .
+ '</tbody></table>'
+ ],
+ [
+ (object)[ 'foo', 'bar' ],
+ '<table class="mw-json"><tbody><tr><th>0</th><td class="value">"foo"</td></tr>' .
+ '<tr><th>1</th><td class="value">"bar"</td></tr></tbody></table>'
+ ],
+ [
+ (object)[ 'baz' => 'foo', 'bar' ],
+ '<table class="mw-json"><tbody><tr><th>baz</th><td class="value">"foo"</td></tr>' .
+ '<tr><th>0</th><td class="value">"bar"</td></tr></tbody></table>'
+ ],
+ [
+ (object)[ 'baz' => 1000, 'bar' ],
+ '<table class="mw-json"><tbody><tr><th>baz</th><td class="value">1000</td></tr>' .
+ '<tr><th>0</th><td class="value">"bar"</td></tr></tbody></table>'
+ ],
+ [
+ (object)[ '<script>alert("evil!")</script>' ],
+ '<table class="mw-json"><tbody><tr><th>0</th><td class="value">"' .
+ '&lt;script>alert("evil!")&lt;/script>"' .
+ '</td></tr></tbody></table>',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideDataAndParserText
+ */
+ public function testFillParserOutput( $data, $expected ) {
+ $obj = new JsonContent( FormatJson::encode( $data ) );
+ $parserOutput = $obj->getParserOutput( $this->getMockTitle(), null, null, true );
+ $this->assertInstanceOf( ParserOutput::class, $parserOutput );
+ $this->assertEquals( $expected, $parserOutput->getText() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/content/TextContentHandlerTest.php b/www/wiki/tests/phpunit/includes/content/TextContentHandlerTest.php
new file mode 100644
index 00000000..6d0a3d5c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/TextContentHandlerTest.php
@@ -0,0 +1,55 @@
+<?php
+
+/**
+ * @group ContentHandler
+ */
+class TextContentHandlerTest extends MediaWikiLangTestCase {
+ /**
+ * @covers TextContentHandler::supportsDirectEditing
+ */
+ public function testSupportsDirectEditing() {
+ $handler = new TextContentHandler();
+ $this->assertTrue( $handler->supportsDirectEditing(), 'direct editing is supported' );
+ }
+
+ /**
+ * @covers SearchEngine::makeSearchFieldMapping
+ * @covers ContentHandler::getFieldsForSearchIndex
+ */
+ public function testFieldsForIndex() {
+ $handler = new TextContentHandler();
+
+ $mockEngine = $this->createMock( SearchEngine::class );
+
+ $mockEngine->expects( $this->atLeastOnce() )
+ ->method( 'makeSearchFieldMapping' )
+ ->willReturnCallback( function ( $name, $type ) {
+ $mockField =
+ $this->getMockBuilder( SearchIndexFieldDefinition::class )
+ ->setConstructorArgs( [ $name, $type ] )
+ ->getMock();
+ $mockField->expects( $this->atLeastOnce() )->method( 'getMapping' )->willReturn( [
+ 'testData' => 'test',
+ 'name' => $name,
+ 'type' => $type,
+ ] );
+ return $mockField;
+ } );
+
+ /**
+ * @var $mockEngine SearchEngine
+ */
+ $fields = $handler->getFieldsForSearchIndex( $mockEngine );
+ $mappedFields = [];
+ foreach ( $fields as $name => $field ) {
+ $this->assertInstanceOf( SearchIndexField::class, $field );
+ /**
+ * @var $field SearchIndexField
+ */
+ $mappedFields[$name] = $field->getMapping( $mockEngine );
+ }
+ $this->assertArrayHasKey( 'language', $mappedFields );
+ $this->assertEquals( 'test', $mappedFields['language']['testData'] );
+ $this->assertEquals( 'language', $mappedFields['language']['name'] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/content/TextContentTest.php b/www/wiki/tests/phpunit/includes/content/TextContentTest.php
new file mode 100644
index 00000000..406bc96b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/TextContentTest.php
@@ -0,0 +1,477 @@
+<?php
+
+/**
+ * @group ContentHandler
+ * @group Database
+ * ^--- needed, because we do need the database to test link updates
+ */
+class TextContentTest extends MediaWikiLangTestCase {
+ protected $context;
+
+ protected function setUp() {
+ parent::setUp();
+
+ // Anon user
+ $user = new User();
+ $user->setName( '127.0.0.1' );
+
+ $this->context = new RequestContext( new FauxRequest() );
+ $this->context->setTitle( Title::newFromText( 'Test' ) );
+ $this->context->setUser( $user );
+
+ $this->setMwGlobals( [
+ 'wgUser' => $user,
+ 'wgTextModelsToParse' => [
+ CONTENT_MODEL_WIKITEXT,
+ CONTENT_MODEL_CSS,
+ CONTENT_MODEL_JAVASCRIPT,
+ ],
+ 'wgUseTidy' => false,
+ 'wgCapitalLinks' => true,
+ 'wgHooks' => [], // bypass hook ContentGetParserOutput that force custom rendering
+ ] );
+
+ MWTidy::destroySingleton();
+ }
+
+ protected function tearDown() {
+ MWTidy::destroySingleton();
+ parent::tearDown();
+ }
+
+ public function newContent( $text ) {
+ return new TextContent( $text );
+ }
+
+ public static function dataGetParserOutput() {
+ return [
+ [
+ 'TextContentTest_testGetParserOutput',
+ CONTENT_MODEL_TEXT,
+ "hello ''world'' & [[stuff]]\n", "hello ''world'' &amp; [[stuff]]",
+ [
+ 'Links' => []
+ ]
+ ],
+ // TODO: more...?
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetParserOutput
+ * @covers TextContent::getParserOutput
+ */
+ public function testGetParserOutput( $title, $model, $text, $expectedHtml,
+ $expectedFields = null
+ ) {
+ $title = Title::newFromText( $title );
+ $content = ContentHandler::makeContent( $text, $title, $model );
+
+ $po = $content->getParserOutput( $title );
+
+ $html = $po->getText();
+ $html = preg_replace( '#<!--.*?-->#sm', '', $html ); // strip comments
+
+ $this->assertEquals( $expectedHtml, trim( $html ) );
+
+ if ( $expectedFields ) {
+ foreach ( $expectedFields as $field => $exp ) {
+ $f = 'get' . ucfirst( $field );
+ $v = call_user_func( [ $po, $f ] );
+
+ if ( is_array( $exp ) ) {
+ $this->assertArrayEquals( $exp, $v );
+ } else {
+ $this->assertEquals( $exp, $v );
+ }
+ }
+ }
+
+ // TODO: assert more properties
+ }
+
+ public static function dataPreSaveTransform() {
+ return [
+ [
+ # 0: no signature resolution
+ 'hello this is ~~~',
+ 'hello this is ~~~',
+ ],
+ [
+ # 1: rtrim
+ " Foo \n ",
+ ' Foo',
+ ],
+ [
+ # 2: newline normalization
+ "LF\n\nCRLF\r\n\r\nCR\r\rEND",
+ "LF\n\nCRLF\n\nCR\n\nEND",
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataPreSaveTransform
+ * @covers TextContent::preSaveTransform
+ */
+ public function testPreSaveTransform( $text, $expected ) {
+ global $wgContLang;
+
+ $options = ParserOptions::newFromUserAndLang( $this->context->getUser(), $wgContLang );
+
+ $content = $this->newContent( $text );
+ $content = $content->preSaveTransform(
+ $this->context->getTitle(),
+ $this->context->getUser(),
+ $options
+ );
+
+ $this->assertEquals( $expected, $content->getNativeData() );
+ }
+
+ public static function dataPreloadTransform() {
+ return [
+ [
+ 'hello this is ~~~',
+ 'hello this is ~~~',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataPreloadTransform
+ * @covers TextContent::preloadTransform
+ */
+ public function testPreloadTransform( $text, $expected ) {
+ global $wgContLang;
+ $options = ParserOptions::newFromUserAndLang( $this->context->getUser(), $wgContLang );
+
+ $content = $this->newContent( $text );
+ $content = $content->preloadTransform( $this->context->getTitle(), $options );
+
+ $this->assertEquals( $expected, $content->getNativeData() );
+ }
+
+ public static function dataGetRedirectTarget() {
+ return [
+ [ '#REDIRECT [[Test]]',
+ null,
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetRedirectTarget
+ * @covers TextContent::getRedirectTarget
+ */
+ public function testGetRedirectTarget( $text, $expected ) {
+ $content = $this->newContent( $text );
+ $t = $content->getRedirectTarget();
+
+ if ( is_null( $expected ) ) {
+ $this->assertNull( $t, "text should not have generated a redirect target: $text" );
+ } else {
+ $this->assertEquals( $expected, $t->getPrefixedText() );
+ }
+ }
+
+ /**
+ * @dataProvider dataGetRedirectTarget
+ * @covers TextContent::isRedirect
+ */
+ public function testIsRedirect( $text, $expected ) {
+ $content = $this->newContent( $text );
+
+ $this->assertEquals( !is_null( $expected ), $content->isRedirect() );
+ }
+
+ public static function dataIsCountable() {
+ return [
+ [ '',
+ null,
+ 'any',
+ true
+ ],
+ [ 'Foo',
+ null,
+ 'any',
+ true
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataIsCountable
+ * @covers TextContent::isCountable
+ */
+ public function testIsCountable( $text, $hasLinks, $mode, $expected ) {
+ $this->setMwGlobals( 'wgArticleCountMethod', $mode );
+
+ $content = $this->newContent( $text );
+
+ $v = $content->isCountable( $hasLinks, $this->context->getTitle() );
+
+ $this->assertEquals(
+ $expected,
+ $v,
+ 'isCountable() returned unexpected value ' . var_export( $v, true )
+ . ' instead of ' . var_export( $expected, true )
+ . " in mode `$mode` for text \"$text\""
+ );
+ }
+
+ public static function dataGetTextForSummary() {
+ return [
+ [ "hello\nworld.",
+ 16,
+ 'hello world.',
+ ],
+ [ 'hello world.',
+ 8,
+ 'hello...',
+ ],
+ [ '[[hello world]].',
+ 8,
+ '[[hel...',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetTextForSummary
+ * @covers TextContent::getTextForSummary
+ */
+ public function testGetTextForSummary( $text, $maxlength, $expected ) {
+ $content = $this->newContent( $text );
+
+ $this->assertEquals( $expected, $content->getTextForSummary( $maxlength ) );
+ }
+
+ /**
+ * @covers TextContent::getTextForSearchIndex
+ */
+ public function testGetTextForSearchIndex() {
+ $content = $this->newContent( 'hello world.' );
+
+ $this->assertEquals( 'hello world.', $content->getTextForSearchIndex() );
+ }
+
+ /**
+ * @covers TextContent::copy
+ */
+ public function testCopy() {
+ $content = $this->newContent( 'hello world.' );
+ $copy = $content->copy();
+
+ $this->assertTrue( $content->equals( $copy ), 'copy must be equal to original' );
+ $this->assertEquals( 'hello world.', $copy->getNativeData() );
+ }
+
+ /**
+ * @covers TextContent::getSize
+ */
+ public function testGetSize() {
+ $content = $this->newContent( 'hello world.' );
+
+ $this->assertEquals( 12, $content->getSize() );
+ }
+
+ /**
+ * @covers TextContent::getNativeData
+ */
+ public function testGetNativeData() {
+ $content = $this->newContent( 'hello world.' );
+
+ $this->assertEquals( 'hello world.', $content->getNativeData() );
+ }
+
+ /**
+ * @covers TextContent::getWikitextForTransclusion
+ */
+ public function testGetWikitextForTransclusion() {
+ $content = $this->newContent( 'hello world.' );
+
+ $this->assertEquals( 'hello world.', $content->getWikitextForTransclusion() );
+ }
+
+ /**
+ * @covers TextContent::getModel
+ */
+ public function testGetModel() {
+ $content = $this->newContent( "hello world." );
+
+ $this->assertEquals( CONTENT_MODEL_TEXT, $content->getModel() );
+ }
+
+ /**
+ * @covers TextContent::getContentHandler
+ */
+ public function testGetContentHandler() {
+ $content = $this->newContent( "hello world." );
+
+ $this->assertEquals( CONTENT_MODEL_TEXT, $content->getContentHandler()->getModelID() );
+ }
+
+ public static function dataIsEmpty() {
+ return [
+ [ '', true ],
+ [ ' ', false ],
+ [ '0', false ],
+ [ 'hallo welt.', false ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataIsEmpty
+ * @covers TextContent::isEmpty
+ */
+ public function testIsEmpty( $text, $empty ) {
+ $content = $this->newContent( $text );
+
+ $this->assertEquals( $empty, $content->isEmpty() );
+ }
+
+ public static function dataEquals() {
+ return [
+ [ new TextContent( "hallo" ), null, false ],
+ [ new TextContent( "hallo" ), new TextContent( "hallo" ), true ],
+ [ new TextContent( "hallo" ), new JavaScriptContent( "hallo" ), false ],
+ [ new TextContent( "hallo" ), new WikitextContent( "hallo" ), false ],
+ [ new TextContent( "hallo" ), new TextContent( "HALLO" ), false ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataEquals
+ * @covers TextContent::equals
+ */
+ public function testEquals( Content $a, Content $b = null, $equal = false ) {
+ $this->assertEquals( $equal, $a->equals( $b ) );
+ }
+
+ public static function dataGetDeletionUpdates() {
+ return [
+ [ "TextContentTest_testGetSecondaryDataUpdates_1",
+ CONTENT_MODEL_TEXT, "hello ''world''\n",
+ []
+ ],
+ [ "TextContentTest_testGetSecondaryDataUpdates_2",
+ CONTENT_MODEL_TEXT, "hello [[world test 21344]]\n",
+ []
+ ],
+ // TODO: more...?
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetDeletionUpdates
+ * @covers TextContent::getDeletionUpdates
+ */
+ public function testDeletionUpdates( $title, $model, $text, $expectedStuff ) {
+ $ns = $this->getDefaultWikitextNS();
+ $title = Title::newFromText( $title, $ns );
+
+ $content = ContentHandler::makeContent( $text, $title, $model );
+
+ $page = WikiPage::factory( $title );
+ $page->doEditContent( $content, '' );
+
+ $updates = $content->getDeletionUpdates( $page );
+
+ // make updates accessible by class name
+ foreach ( $updates as $update ) {
+ $class = get_class( $update );
+ $updates[$class] = $update;
+ }
+
+ if ( !$expectedStuff ) {
+ $this->assertTrue( true ); // make phpunit happy
+ return;
+ }
+
+ foreach ( $expectedStuff as $class => $fieldValues ) {
+ $this->assertArrayHasKey( $class, $updates, "missing an update of type $class" );
+
+ $update = $updates[$class];
+
+ foreach ( $fieldValues as $field => $value ) {
+ $v = $update->$field; # if the field doesn't exist, just crash and burn
+ $this->assertEquals( $value, $v, "unexpected value for field $field in instance of $class" );
+ }
+ }
+
+ $page->doDeleteArticle( '' );
+ }
+
+ public static function provideConvert() {
+ return [
+ [ // #0
+ 'Hallo Welt',
+ CONTENT_MODEL_WIKITEXT,
+ 'lossless',
+ 'Hallo Welt'
+ ],
+ [ // #1
+ 'Hallo Welt',
+ CONTENT_MODEL_WIKITEXT,
+ 'lossless',
+ 'Hallo Welt'
+ ],
+ [ // #1
+ 'Hallo Welt',
+ CONTENT_MODEL_CSS,
+ 'lossless',
+ 'Hallo Welt'
+ ],
+ [ // #1
+ 'Hallo Welt',
+ CONTENT_MODEL_JAVASCRIPT,
+ 'lossless',
+ 'Hallo Welt'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideConvert
+ * @covers TextContent::convert
+ */
+ public function testConvert( $text, $model, $lossy, $expectedNative ) {
+ $content = $this->newContent( $text );
+
+ $converted = $content->convert( $model, $lossy );
+
+ if ( $expectedNative === false ) {
+ $this->assertFalse( $converted, "conversion to $model was expected to fail!" );
+ } else {
+ $this->assertInstanceOf( Content::class, $converted );
+ $this->assertEquals( $expectedNative, $converted->getNativeData() );
+ }
+ }
+
+ /**
+ * @covers TextContent::normalizeLineEndings
+ * @dataProvider provideNormalizeLineEndings
+ */
+ public function testNormalizeLineEndings( $input, $expected ) {
+ $this->assertEquals( $expected, TextContent::normalizeLineEndings( $input ) );
+ }
+
+ public static function provideNormalizeLineEndings() {
+ return [
+ [
+ "Foo\r\nbar",
+ "Foo\nbar"
+ ],
+ [
+ "Foo\rbar",
+ "Foo\nbar"
+ ],
+ [
+ "Foobar\n ",
+ "Foobar"
+ ]
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/content/WikitextContentHandlerTest.php b/www/wiki/tests/phpunit/includes/content/WikitextContentHandlerTest.php
new file mode 100644
index 00000000..59984d85
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/WikitextContentHandlerTest.php
@@ -0,0 +1,365 @@
+<?php
+
+/**
+ * @group ContentHandler
+ */
+class WikitextContentHandlerTest extends MediaWikiLangTestCase {
+ /**
+ * @var ContentHandler
+ */
+ private $handler;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->handler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT );
+ }
+
+ /**
+ * @covers WikitextContentHandler::serializeContent
+ */
+ public function testSerializeContent() {
+ $content = new WikitextContent( 'hello world' );
+
+ $this->assertEquals( 'hello world', $this->handler->serializeContent( $content ) );
+ $this->assertEquals(
+ 'hello world',
+ $this->handler->serializeContent( $content, CONTENT_FORMAT_WIKITEXT )
+ );
+
+ try {
+ $this->handler->serializeContent( $content, 'dummy/foo' );
+ $this->fail( "serializeContent() should have failed on unknown format" );
+ } catch ( MWException $e ) {
+ // ok, as expected
+ }
+ }
+
+ /**
+ * @covers WikitextContentHandler::unserializeContent
+ */
+ public function testUnserializeContent() {
+ $content = $this->handler->unserializeContent( 'hello world' );
+ $this->assertEquals( 'hello world', $content->getNativeData() );
+
+ $content = $this->handler->unserializeContent( 'hello world', CONTENT_FORMAT_WIKITEXT );
+ $this->assertEquals( 'hello world', $content->getNativeData() );
+
+ try {
+ $this->handler->unserializeContent( 'hello world', 'dummy/foo' );
+ $this->fail( "unserializeContent() should have failed on unknown format" );
+ } catch ( MWException $e ) {
+ // ok, as expected
+ }
+ }
+
+ /**
+ * @covers WikitextContentHandler::makeEmptyContent
+ */
+ public function testMakeEmptyContent() {
+ $content = $this->handler->makeEmptyContent();
+
+ $this->assertTrue( $content->isEmpty() );
+ $this->assertEquals( '', $content->getNativeData() );
+ }
+
+ public static function dataIsSupportedFormat() {
+ return [
+ [ null, true ],
+ [ CONTENT_FORMAT_WIKITEXT, true ],
+ [ 99887766, false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideMakeRedirectContent
+ * @param Title|string $title Title object or string for Title::newFromText()
+ * @param string $expected Serialized form of the content object built
+ * @covers WikitextContentHandler::makeRedirectContent
+ */
+ public function testMakeRedirectContent( $title, $expected ) {
+ global $wgContLang;
+ $wgContLang->resetNamespaces();
+
+ MagicWord::clearCache();
+
+ if ( is_string( $title ) ) {
+ $title = Title::newFromText( $title );
+ }
+ $content = $this->handler->makeRedirectContent( $title );
+ $this->assertEquals( $expected, $content->serialize() );
+ }
+
+ public static function provideMakeRedirectContent() {
+ return [
+ [ 'Hello', '#REDIRECT [[Hello]]' ],
+ [ 'Template:Hello', '#REDIRECT [[Template:Hello]]' ],
+ [ 'Hello#section', '#REDIRECT [[Hello#section]]' ],
+ [ 'user:john_doe#section', '#REDIRECT [[User:John doe#section]]' ],
+ [ 'MEDIAWIKI:FOOBAR', '#REDIRECT [[MediaWiki:FOOBAR]]' ],
+ [ 'Category:Foo', '#REDIRECT [[:Category:Foo]]' ],
+ [ Title::makeTitle( NS_MAIN, 'en:Foo' ), '#REDIRECT [[en:Foo]]' ],
+ [ Title::makeTitle( NS_MAIN, 'Foo', '', 'en' ), '#REDIRECT [[:en:Foo]]' ],
+ [
+ Title::makeTitle( NS_MAIN, 'Bar', 'fragment', 'google' ),
+ '#REDIRECT [[google:Bar#fragment]]'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataIsSupportedFormat
+ * @covers WikitextContentHandler::isSupportedFormat
+ */
+ public function testIsSupportedFormat( $format, $supported ) {
+ $this->assertEquals( $supported, $this->handler->isSupportedFormat( $format ) );
+ }
+
+ /**
+ * @covers WikitextContentHandler::supportsDirectEditing
+ */
+ public function testSupportsDirectEditing() {
+ $handler = new WikiTextContentHandler();
+ $this->assertTrue( $handler->supportsDirectEditing(), 'direct editing is supported' );
+ }
+
+ public static function dataMerge3() {
+ return [
+ [
+ "first paragraph
+
+ second paragraph\n",
+
+ "FIRST paragraph
+
+ second paragraph\n",
+
+ "first paragraph
+
+ SECOND paragraph\n",
+
+ "FIRST paragraph
+
+ SECOND paragraph\n",
+ ],
+
+ [ "first paragraph
+ second paragraph\n",
+
+ "Bla bla\n",
+
+ "Blubberdibla\n",
+
+ false,
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataMerge3
+ * @covers WikitextContentHandler::merge3
+ */
+ public function testMerge3( $old, $mine, $yours, $expected ) {
+ $this->markTestSkippedIfNoDiff3();
+
+ // test merge
+ $oldContent = new WikitextContent( $old );
+ $myContent = new WikitextContent( $mine );
+ $yourContent = new WikitextContent( $yours );
+
+ $merged = $this->handler->merge3( $oldContent, $myContent, $yourContent );
+
+ $this->assertEquals( $expected, $merged ? $merged->getNativeData() : $merged );
+ }
+
+ public static function dataGetAutosummary() {
+ return [
+ [
+ 'Hello there, world!',
+ '#REDIRECT [[Foo]]',
+ 0,
+ '/^Redirected page .*Foo/'
+ ],
+
+ [
+ null,
+ 'Hello world!',
+ EDIT_NEW,
+ '/^Created page .*Hello/'
+ ],
+
+ [
+ null,
+ '',
+ EDIT_NEW,
+ '/^Created blank page$/'
+ ],
+
+ [
+ 'Hello there, world!',
+ '',
+ 0,
+ '/^Blanked/'
+ ],
+
+ [
+ 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
+ eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam
+ voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet
+ clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
+ 'Hello world!',
+ 0,
+ '/^Replaced .*Hello/'
+ ],
+
+ [
+ 'foo',
+ 'bar',
+ 0,
+ '/^$/'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetAutosummary
+ * @covers WikitextContentHandler::getAutosummary
+ */
+ public function testGetAutosummary( $old, $new, $flags, $expected ) {
+ $oldContent = is_null( $old ) ? null : new WikitextContent( $old );
+ $newContent = is_null( $new ) ? null : new WikitextContent( $new );
+
+ $summary = $this->handler->getAutosummary( $oldContent, $newContent, $flags );
+
+ $this->assertTrue(
+ (bool)preg_match( $expected, $summary ),
+ "Autosummary didn't match expected pattern $expected: $summary"
+ );
+ }
+
+ public static function dataGetChangeTag() {
+ return [
+ [
+ null,
+ '#REDIRECT [[Foo]]',
+ 0,
+ 'mw-new-redirect'
+ ],
+
+ [
+ 'Lorem ipsum dolor',
+ '#REDIRECT [[Foo]]',
+ 0,
+ 'mw-new-redirect'
+ ],
+
+ [
+ '#REDIRECT [[Foo]]',
+ 'Lorem ipsum dolor',
+ 0,
+ 'mw-removed-redirect'
+ ],
+
+ [
+ '#REDIRECT [[Foo]]',
+ '#REDIRECT [[Bar]]',
+ 0,
+ 'mw-changed-redirect-target'
+ ],
+
+ [
+ null,
+ 'Lorem ipsum dolor',
+ EDIT_NEW,
+ null // mw-newpage is not defined as a tag
+ ],
+
+ [
+ null,
+ '',
+ EDIT_NEW,
+ null // mw-newblank is not defined as a tag
+ ],
+
+ [
+ 'Lorem ipsum dolor',
+ '',
+ 0,
+ 'mw-blank'
+ ],
+
+ [
+ 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
+ eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam
+ voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet
+ clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
+ 'Ipsum',
+ 0,
+ 'mw-replace'
+ ],
+
+ [
+ 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
+ eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam
+ voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet
+ clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
+ 'Duis purus odio, rhoncus et finibus dapibus, facilisis ac urna. Pellentesque
+ arcu, tristique nec tempus nec, suscipit vel arcu. Sed non dolor nec ligula
+ congue tempor. Quisque pellentesque finibus orci a molestie. Nam maximus, purus
+ euismod finibus mollis, dui ante malesuada felis, dignissim rutrum diam sapien.',
+ 0,
+ null
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetChangeTag
+ * @covers WikitextContentHandler::getChangeTag
+ */
+ public function testGetChangeTag( $old, $new, $flags, $expected ) {
+ $this->setMwGlobals( 'wgSoftwareTags', [
+ 'mw-new-redirect' => true,
+ 'mw-removed-redirect' => true,
+ 'mw-changed-redirect-target' => true,
+ 'mw-newpage' => true,
+ 'mw-newblank' => true,
+ 'mw-blank' => true,
+ 'mw-replace' => true,
+ ] );
+ $oldContent = is_null( $old ) ? null : new WikitextContent( $old );
+ $newContent = is_null( $new ) ? null : new WikitextContent( $new );
+
+ $tag = $this->handler->getChangeTag( $oldContent, $newContent, $flags );
+
+ $this->assertSame( $expected, $tag );
+ }
+
+ /**
+ * @covers WikitextContentHandler::getDataForSearchIndex
+ */
+ public function testDataIndexFieldsFile() {
+ $mockEngine = $this->createMock( SearchEngine::class );
+ $title = Title::newFromText( 'Somefile.jpg', NS_FILE );
+ $page = new WikiPage( $title );
+
+ $fileHandler = $this->getMockBuilder( FileContentHandler::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'getDataForSearchIndex' ] )
+ ->getMock();
+
+ $handler = $this->getMockBuilder( WikitextContentHandler::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'getFileHandler' ] )
+ ->getMock();
+
+ $handler->method( 'getFileHandler' )->will( $this->returnValue( $fileHandler ) );
+ $fileHandler->expects( $this->once() )
+ ->method( 'getDataForSearchIndex' )
+ ->will( $this->returnValue( [ 'file_text' => 'This is file content' ] ) );
+
+ $data = $handler->getDataForSearchIndex( $page, new ParserOutput(), $mockEngine );
+ $this->assertArrayHasKey( 'file_text', $data );
+ $this->assertEquals( 'This is file content', $data['file_text'] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/content/WikitextContentTest.php b/www/wiki/tests/phpunit/includes/content/WikitextContentTest.php
new file mode 100644
index 00000000..1db6aab6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/WikitextContentTest.php
@@ -0,0 +1,443 @@
+<?php
+
+/**
+ * @group ContentHandler
+ *
+ * @group Database
+ * ^--- needed, because we do need the database to test link updates
+ */
+class WikitextContentTest extends TextContentTest {
+ public static $sections = "Intro
+
+== stuff ==
+hello world
+
+== test ==
+just a test
+
+== foo ==
+more stuff
+";
+
+ public function newContent( $text ) {
+ return new WikitextContent( $text );
+ }
+
+ public static function dataGetParserOutput() {
+ return [
+ [
+ "WikitextContentTest_testGetParserOutput",
+ CONTENT_MODEL_WIKITEXT,
+ "hello ''world''\n",
+ "<div class=\"mw-parser-output\"><p>hello <i>world</i>\n</p>\n\n\n</div>"
+ ],
+ // TODO: more...?
+ ];
+ }
+
+ public static function dataGetSecondaryDataUpdates() {
+ return [
+ [ "WikitextContentTest_testGetSecondaryDataUpdates_1",
+ CONTENT_MODEL_WIKITEXT, "hello ''world''\n",
+ [
+ LinksUpdate::class => [
+ 'mRecursive' => true,
+ 'mLinks' => []
+ ]
+ ]
+ ],
+ [ "WikitextContentTest_testGetSecondaryDataUpdates_2",
+ CONTENT_MODEL_WIKITEXT, "hello [[world test 21344]]\n",
+ [
+ LinksUpdate::class => [
+ 'mRecursive' => true,
+ 'mLinks' => [
+ [ 'World_test_21344' => 0 ]
+ ]
+ ]
+ ]
+ ],
+ // TODO: more...?
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetSecondaryDataUpdates
+ * @group Database
+ * @covers WikitextContent::getSecondaryDataUpdates
+ */
+ public function testGetSecondaryDataUpdates( $title, $model, $text, $expectedStuff ) {
+ $ns = $this->getDefaultWikitextNS();
+ $title = Title::newFromText( $title, $ns );
+
+ $content = ContentHandler::makeContent( $text, $title, $model );
+
+ $page = WikiPage::factory( $title );
+ $page->doEditContent( $content, '' );
+
+ $updates = $content->getSecondaryDataUpdates( $title );
+
+ // make updates accessible by class name
+ foreach ( $updates as $update ) {
+ $class = get_class( $update );
+ $updates[$class] = $update;
+ }
+
+ foreach ( $expectedStuff as $class => $fieldValues ) {
+ $this->assertArrayHasKey( $class, $updates, "missing an update of type $class" );
+
+ $update = $updates[$class];
+
+ foreach ( $fieldValues as $field => $value ) {
+ $v = $update->$field; # if the field doesn't exist, just crash and burn
+ $this->assertEquals(
+ $value,
+ $v,
+ "unexpected value for field $field in instance of $class"
+ );
+ }
+ }
+
+ $page->doDeleteArticle( '' );
+ }
+
+ public static function dataGetSection() {
+ return [
+ [ self::$sections,
+ "0",
+ "Intro"
+ ],
+ [ self::$sections,
+ "2",
+ "== test ==
+just a test"
+ ],
+ [ self::$sections,
+ "8",
+ false
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataGetSection
+ * @covers WikitextContent::getSection
+ */
+ public function testGetSection( $text, $sectionId, $expectedText ) {
+ $content = $this->newContent( $text );
+
+ $sectionContent = $content->getSection( $sectionId );
+ if ( is_object( $sectionContent ) ) {
+ $sectionText = $sectionContent->getNativeData();
+ } else {
+ $sectionText = $sectionContent;
+ }
+
+ $this->assertEquals( $expectedText, $sectionText );
+ }
+
+ public static function dataReplaceSection() {
+ return [
+ [ self::$sections,
+ "0",
+ "No more",
+ null,
+ trim( preg_replace( '/^Intro/sm', 'No more', self::$sections ) )
+ ],
+ [ self::$sections,
+ "",
+ "No more",
+ null,
+ "No more"
+ ],
+ [ self::$sections,
+ "2",
+ "== TEST ==\nmore fun",
+ null,
+ trim( preg_replace(
+ '/^== test ==.*== foo ==/sm', "== TEST ==\nmore fun\n\n== foo ==",
+ self::$sections
+ ) )
+ ],
+ [ self::$sections,
+ "8",
+ "No more",
+ null,
+ self::$sections
+ ],
+ [ self::$sections,
+ "new",
+ "No more",
+ "New",
+ trim( self::$sections ) . "\n\n\n== New ==\n\nNo more"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataReplaceSection
+ * @covers WikitextContent::replaceSection
+ */
+ public function testReplaceSection( $text, $section, $with, $sectionTitle, $expected ) {
+ $content = $this->newContent( $text );
+ $c = $content->replaceSection( $section, $this->newContent( $with ), $sectionTitle );
+
+ $this->assertEquals( $expected, is_null( $c ) ? null : $c->getNativeData() );
+ }
+
+ /**
+ * @covers WikitextContent::addSectionHeader
+ */
+ public function testAddSectionHeader() {
+ $content = $this->newContent( 'hello world' );
+ $content = $content->addSectionHeader( 'test' );
+
+ $this->assertEquals( "== test ==\n\nhello world", $content->getNativeData() );
+ }
+
+ public static function dataPreSaveTransform() {
+ return [
+ [ 'hello this is ~~~',
+ "hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]",
+ ],
+ [ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
+ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
+ ],
+ [ // rtrim
+ " Foo \n ",
+ " Foo",
+ ],
+ ];
+ }
+
+ public static function dataPreloadTransform() {
+ return [
+ [
+ 'hello this is ~~~',
+ "hello this is ~~~",
+ ],
+ [
+ 'hello \'\'this\'\' is <noinclude>foo</noinclude><includeonly>bar</includeonly>',
+ 'hello \'\'this\'\' is bar',
+ ],
+ ];
+ }
+
+ public static function dataGetRedirectTarget() {
+ return [
+ [ '#REDIRECT [[Test]]',
+ 'Test',
+ ],
+ [ '#REDIRECT Test',
+ null,
+ ],
+ [ '* #REDIRECT [[Test]]',
+ null,
+ ],
+ ];
+ }
+
+ public static function dataGetTextForSummary() {
+ return [
+ [ "hello\nworld.",
+ 16,
+ 'hello world.',
+ ],
+ [ 'hello world.',
+ 8,
+ 'hello...',
+ ],
+ [ '[[hello world]].',
+ 8,
+ 'hel...',
+ ],
+ ];
+ }
+
+ public static function dataIsCountable() {
+ return [
+ [ '',
+ null,
+ 'any',
+ true
+ ],
+ [ 'Foo',
+ null,
+ 'any',
+ true
+ ],
+ [ 'Foo',
+ null,
+ 'link',
+ false
+ ],
+ [ 'Foo [[bar]]',
+ null,
+ 'link',
+ true
+ ],
+ [ 'Foo',
+ true,
+ 'link',
+ true
+ ],
+ [ 'Foo [[bar]]',
+ false,
+ 'link',
+ false
+ ],
+ [ '#REDIRECT [[bar]]',
+ true,
+ 'any',
+ false
+ ],
+ [ '#REDIRECT [[bar]]',
+ true,
+ 'link',
+ false
+ ],
+ ];
+ }
+
+ /**
+ * @covers WikitextContent::matchMagicWord
+ */
+ public function testMatchMagicWord() {
+ $mw = MagicWord::get( "staticredirect" );
+
+ $content = $this->newContent( "#REDIRECT [[FOO]]\n__STATICREDIRECT__" );
+ $this->assertTrue( $content->matchMagicWord( $mw ), "should have matched magic word" );
+
+ $content = $this->newContent( "#REDIRECT [[FOO]]" );
+ $this->assertFalse(
+ $content->matchMagicWord( $mw ),
+ "should not have matched magic word"
+ );
+ }
+
+ /**
+ * @covers WikitextContent::updateRedirect
+ */
+ public function testUpdateRedirect() {
+ $target = Title::newFromText( "testUpdateRedirect_target" );
+
+ // test with non-redirect page
+ $content = $this->newContent( "hello world." );
+ $newContent = $content->updateRedirect( $target );
+
+ $this->assertTrue( $content->equals( $newContent ), "content should be unchanged" );
+
+ // test with actual redirect
+ $content = $this->newContent( "#REDIRECT [[Someplace]]" );
+ $newContent = $content->updateRedirect( $target );
+
+ $this->assertFalse( $content->equals( $newContent ), "content should have changed" );
+ $this->assertTrue( $newContent->isRedirect(), "new content should be a redirect" );
+
+ $this->assertEquals(
+ $target->getFullText(),
+ $newContent->getRedirectTarget()->getFullText()
+ );
+ }
+
+ /**
+ * @covers WikitextContent::getModel
+ */
+ public function testGetModel() {
+ $content = $this->newContent( "hello world." );
+
+ $this->assertEquals( CONTENT_MODEL_WIKITEXT, $content->getModel() );
+ }
+
+ /**
+ * @covers WikitextContent::getContentHandler
+ */
+ public function testGetContentHandler() {
+ $content = $this->newContent( "hello world." );
+
+ $this->assertEquals( CONTENT_MODEL_WIKITEXT, $content->getContentHandler()->getModelID() );
+ }
+
+ public function testRedirectParserOption() {
+ $title = Title::newFromText( 'testRedirectParserOption' );
+
+ // Set up hook and its reporting variables
+ $wikitext = null;
+ $redirectTarget = null;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'InternalParseBeforeLinks' => [
+ function ( &$parser, &$text, &$stripState ) use ( &$wikitext, &$redirectTarget ) {
+ $wikitext = $text;
+ $redirectTarget = $parser->getOptions()->getRedirectTarget();
+ }
+ ]
+ ] );
+
+ // Test with non-redirect page
+ $wikitext = false;
+ $redirectTarget = false;
+ $content = $this->newContent( 'hello world.' );
+ $options = $content->getContentHandler()->makeParserOptions( 'canonical' );
+ $options->setRedirectTarget( $title );
+ $content->getParserOutput( $title, null, $options );
+ $this->assertEquals( 'hello world.', $wikitext,
+ 'Wikitext passed to hook was not as expected'
+ );
+ $this->assertEquals( null, $redirectTarget, 'Redirect seen in hook was not null' );
+ $this->assertEquals( $title, $options->getRedirectTarget(),
+ 'ParserOptions\' redirectTarget was changed'
+ );
+
+ // Test with a redirect page
+ $wikitext = false;
+ $redirectTarget = false;
+ $content = $this->newContent(
+ "#REDIRECT [[TestRedirectParserOption/redir]]\nhello redirect."
+ );
+ $options = $content->getContentHandler()->makeParserOptions( 'canonical' );
+ $content->getParserOutput( $title, null, $options );
+ $this->assertEquals(
+ 'hello redirect.',
+ $wikitext,
+ 'Wikitext passed to hook was not as expected'
+ );
+ $this->assertNotEquals(
+ null,
+ $redirectTarget,
+ 'Redirect seen in hook was null' );
+ $this->assertEquals(
+ 'TestRedirectParserOption/redir',
+ $redirectTarget->getFullText(),
+ 'Redirect seen in hook was not the expected title'
+ );
+ $this->assertEquals(
+ null,
+ $options->getRedirectTarget(),
+ 'ParserOptions\' redirectTarget was changed'
+ );
+ }
+
+ public static function dataEquals() {
+ return [
+ [ new WikitextContent( "hallo" ), null, false ],
+ [ new WikitextContent( "hallo" ), new WikitextContent( "hallo" ), true ],
+ [ new WikitextContent( "hallo" ), new JavaScriptContent( "hallo" ), false ],
+ [ new WikitextContent( "hallo" ), new TextContent( "hallo" ), false ],
+ [ new WikitextContent( "hallo" ), new WikitextContent( "HALLO" ), false ],
+ ];
+ }
+
+ public static function dataGetDeletionUpdates() {
+ return [
+ [ "WikitextContentTest_testGetSecondaryDataUpdates_1",
+ CONTENT_MODEL_WIKITEXT, "hello ''world''\n",
+ [ LinksDeletionUpdate::class => [] ]
+ ],
+ [ "WikitextContentTest_testGetSecondaryDataUpdates_2",
+ CONTENT_MODEL_WIKITEXT, "hello [[world test 21344]]\n",
+ [ LinksDeletionUpdate::class => [] ]
+ ],
+ // @todo more...?
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/content/WikitextStructureTest.php b/www/wiki/tests/phpunit/includes/content/WikitextStructureTest.php
new file mode 100644
index 00000000..88f4d8f7
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/content/WikitextStructureTest.php
@@ -0,0 +1,110 @@
+<?php
+
+/**
+ * @covers WikiTextStructure
+ */
+class WikitextStructureTest extends MediaWikiLangTestCase {
+
+ private function getMockTitle() {
+ return Title::newFromText( "TestTitle" );
+ }
+
+ /**
+ * Get parser output for Wiki text
+ * @param string $text
+ * @return ParserOutput
+ */
+ private function getParserOutput( $text ) {
+ $content = new WikitextContent( $text );
+ return $content->getParserOutput( $this->getMockTitle() );
+ }
+
+ /**
+ * Get WikitextStructure for given text
+ * @param string $text
+ * @return WikiTextStructure
+ */
+ private function getStructure( $text ) {
+ return new WikiTextStructure( $this->getParserOutput( $text ) );
+ }
+
+ public function testHeadings() {
+ $text = <<<END
+Some text here
+== Heading one ==
+Some text
+==== heading two ====
+More text
+=== Applicability of the strict mass-energy equivalence formula, ''E'' = ''mc''<sup>2</sup> ===
+and more text
+== Wikitext '''in''' [[Heading]] and also <b>html</b> ==
+more text
+==== See also ====
+* Also things to see!
+END;
+ $struct = $this->getStructure( $text );
+ $headings = $struct->headings();
+ $this->assertCount( 4, $headings );
+ $this->assertContains( "Heading one", $headings );
+ $this->assertContains( "heading two", $headings );
+ $this->assertContains( "Applicability of the strict mass-energy equivalence formula, E = mc2",
+ $headings );
+ $this->assertContains( "Wikitext in Heading and also html", $headings );
+ }
+
+ public function testDefaultSort() {
+ $text = <<<END
+Louise Michel
+== Heading one ==
+Some text
+==== See also ====
+* Also things to see!
+{{DEFAULTSORT:Michel, Louise}}
+END;
+ $struct = $this->getStructure( $text );
+ $this->assertEquals( "Michel, Louise", $struct->getDefaultSort() );
+ }
+
+ public function testHeadingsFirst() {
+ $text = <<<END
+== Heading one ==
+Some text
+==== heading two ====
+END;
+ $struct = $this->getStructure( $text );
+ $headings = $struct->headings();
+ $this->assertCount( 2, $headings );
+ $this->assertContains( "Heading one", $headings );
+ $this->assertContains( "heading two", $headings );
+ }
+
+ public function testHeadingsNone() {
+ $text = "This text is completely devoid of headings.";
+ $struct = $this->getStructure( $text );
+ $headings = $struct->headings();
+ $this->assertArrayEquals( [], $headings );
+ }
+
+ public function testTexts() {
+ $text = <<<END
+Opening text is opening.
+== Then comes header ==
+Then we got more<br>text
+=== And more headers ===
+{| class="wikitable"
+|-
+! Header table
+|-
+| row in table
+|-
+| another row in table
+|}
+END;
+ $struct = $this->getStructure( $text );
+ $this->assertEquals( "Opening text is opening.", $struct->getOpeningText() );
+ $this->assertEquals( "Opening text is opening. Then we got more text",
+ $struct->getMainText() );
+ $this->assertEquals( [ "Header table row in table another row in table" ],
+ $struct->getAuxiliaryText() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/context/RequestContextTest.php b/www/wiki/tests/phpunit/includes/context/RequestContextTest.php
new file mode 100644
index 00000000..32e71e05
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/context/RequestContextTest.php
@@ -0,0 +1,117 @@
+<?php
+
+/**
+ * @group Database
+ * @group RequestContext
+ */
+class RequestContextTest extends MediaWikiTestCase {
+
+ /**
+ * Test the relationship between title and wikipage in RequestContext
+ * @covers RequestContext::getWikiPage
+ * @covers RequestContext::getTitle
+ */
+ public function testWikiPageTitle() {
+ $context = new RequestContext();
+
+ $curTitle = Title::newFromText( "A" );
+ $context->setTitle( $curTitle );
+ $this->assertTrue( $curTitle->equals( $context->getWikiPage()->getTitle() ),
+ "When a title is first set WikiPage should be created on-demand for that title." );
+
+ $curTitle = Title::newFromText( "B" );
+ $context->setWikiPage( WikiPage::factory( $curTitle ) );
+ $this->assertTrue( $curTitle->equals( $context->getTitle() ),
+ "Title must be updated when a new WikiPage is provided." );
+
+ $curTitle = Title::newFromText( "C" );
+ $context->setTitle( $curTitle );
+ $this->assertTrue(
+ $curTitle->equals( $context->getWikiPage()->getTitle() ),
+ "When a title is updated the WikiPage should be purged "
+ . "and recreated on-demand with the new title."
+ );
+ }
+
+ /**
+ * @covers RequestContext::importScopedSession
+ */
+ public function testImportScopedSession() {
+ // Make sure session handling is started
+ if ( !MediaWiki\Session\PHPSessionHandler::isInstalled() ) {
+ MediaWiki\Session\PHPSessionHandler::install(
+ MediaWiki\Session\SessionManager::singleton()
+ );
+ }
+ $oldSessionId = session_id();
+
+ $context = RequestContext::getMain();
+
+ $oInfo = $context->exportSession();
+ $this->assertEquals( '127.0.0.1', $oInfo['ip'], "Correct initial IP address." );
+ $this->assertEquals( 0, $oInfo['userId'], "Correct initial user ID." );
+ $this->assertFalse( MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent(),
+ 'Global session isn\'t persistent to start' );
+
+ $user = User::newFromName( 'UnitTestContextUser' );
+ $user->addToDatabase();
+
+ $sinfo = [
+ 'sessionId' => 'd612ee607c87e749ef14da4983a702cd',
+ 'userId' => $user->getId(),
+ 'ip' => '192.0.2.0',
+ 'headers' => [
+ 'USER-AGENT' => 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:18.0) Gecko/20100101 Firefox/18.0'
+ ]
+ ];
+ // importScopedSession() sets these variables
+ $this->setMwGlobals( [
+ 'wgUser' => new User,
+ 'wgRequest' => new FauxRequest,
+ ] );
+ $sc = RequestContext::importScopedSession( $sinfo ); // load new context
+
+ $info = $context->exportSession();
+ $this->assertEquals( $sinfo['ip'], $info['ip'], "Correct IP address." );
+ $this->assertEquals( $sinfo['headers'], $info['headers'], "Correct headers." );
+ $this->assertEquals( $sinfo['sessionId'], $info['sessionId'], "Correct session ID." );
+ $this->assertEquals( $sinfo['userId'], $info['userId'], "Correct user ID." );
+ $this->assertEquals(
+ $sinfo['ip'],
+ $context->getRequest()->getIP(),
+ "Correct context IP address."
+ );
+ $this->assertEquals(
+ $sinfo['headers'],
+ $context->getRequest()->getAllHeaders(),
+ "Correct context headers."
+ );
+ $this->assertEquals(
+ $sinfo['sessionId'],
+ MediaWiki\Session\SessionManager::getGlobalSession()->getId(),
+ "Correct context session ID."
+ );
+ if ( \MediaWiki\Session\PHPSessionHandler::isEnabled() ) {
+ $this->assertEquals( $sinfo['sessionId'], session_id(), "Correct context session ID." );
+ } else {
+ $this->assertEquals( $oldSessionId, session_id(), "Unchanged PHP session ID." );
+ }
+ $this->assertEquals( true, $context->getUser()->isLoggedIn(), "Correct context user." );
+ $this->assertEquals( $sinfo['userId'], $context->getUser()->getId(), "Correct context user ID." );
+ $this->assertEquals(
+ 'UnitTestContextUser',
+ $context->getUser()->getName(),
+ "Correct context user name."
+ );
+
+ unset( $sc ); // restore previous context
+
+ $info = $context->exportSession();
+ $this->assertEquals( $oInfo['ip'], $info['ip'], "Correct restored IP address." );
+ $this->assertEquals( $oInfo['headers'], $info['headers'], "Correct restored headers." );
+ $this->assertEquals( $oInfo['sessionId'], $info['sessionId'], "Correct restored session ID." );
+ $this->assertEquals( $oInfo['userId'], $info['userId'], "Correct restored user ID." );
+ $this->assertFalse( MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent(),
+ 'Global session isn\'t persistent after restoring the context' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/db/DatabaseOracleTest.php b/www/wiki/tests/phpunit/includes/db/DatabaseOracleTest.php
new file mode 100644
index 00000000..061e121a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/db/DatabaseOracleTest.php
@@ -0,0 +1,52 @@
+<?php
+
+class DatabaseOracleTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|DatabaseOracle
+ */
+ private function getMockDb() {
+ return $this->getMockBuilder( DatabaseOracle::class )
+ ->disableOriginalConstructor()
+ ->setMethods( null )
+ ->getMock();
+ }
+
+ public function provideBuildSubstring() {
+ yield [ 'someField', 1, 2, 'SUBSTR(someField,1,2)' ];
+ yield [ 'someField', 1, null, 'SUBSTR(someField,1)' ];
+ }
+
+ /**
+ * @covers DatabaseOracle::buildSubstring
+ * @dataProvider provideBuildSubstring
+ */
+ public function testBuildSubstring( $input, $start, $length, $expected ) {
+ $mockDb = $this->getMockDb();
+ $output = $mockDb->buildSubstring( $input, $start, $length );
+ $this->assertSame( $expected, $output );
+ }
+
+ public function provideBuildSubstring_invalidParams() {
+ yield [ -1, 1 ];
+ yield [ 1, -1 ];
+ yield [ 1, 'foo' ];
+ yield [ 'foo', 1 ];
+ yield [ null, 1 ];
+ yield [ 0, 1 ];
+ }
+
+ /**
+ * @covers DatabaseOracle::buildSubstring
+ * @dataProvider provideBuildSubstring_invalidParams
+ */
+ public function testBuildSubstring_invalidParams( $start, $length ) {
+ $mockDb = $this->getMockDb();
+ $this->setExpectedException( InvalidArgumentException::class );
+ $mockDb->buildSubstring( 'foo', $start, $length );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/db/DatabasePostgresTest.php b/www/wiki/tests/phpunit/includes/db/DatabasePostgresTest.php
new file mode 100644
index 00000000..5c2aa2bb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/db/DatabasePostgresTest.php
@@ -0,0 +1,177 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DatabasePostgres;
+use Wikimedia\ScopedCallback;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Database
+ */
+class DatabasePostgresTest extends MediaWikiTestCase {
+
+ private function doTestInsertIgnore() {
+ $reset = new ScopedCallback( function () {
+ if ( $this->db->explicitTrxActive() ) {
+ $this->db->rollback( __METHOD__ );
+ }
+ $this->db->query( 'DROP TABLE IF EXISTS ' . $this->db->tableName( 'foo' ) );
+ } );
+
+ $this->db->query(
+ "CREATE TEMPORARY TABLE {$this->db->tableName( 'foo' )} (i INTEGER NOT NULL PRIMARY KEY)"
+ );
+ $this->db->insert( 'foo', [ [ 'i' => 1 ], [ 'i' => 2 ] ], __METHOD__ );
+
+ // Normal INSERT IGNORE
+ $this->db->begin( __METHOD__ );
+ $this->db->insert(
+ 'foo', [ [ 'i' => 3 ], [ 'i' => 2 ], [ 'i' => 5 ] ], __METHOD__, [ 'IGNORE' ]
+ );
+ $this->assertSame( 2, $this->db->affectedRows() );
+ $this->assertSame(
+ [ '1', '2', '3', '5' ],
+ $this->db->selectFieldValues( 'foo', 'i', [], __METHOD__, [ 'ORDER BY' => 'i' ] )
+ );
+ $this->db->rollback( __METHOD__ );
+
+ // INSERT IGNORE doesn't ignore stuff like NOT NULL violations
+ $this->db->begin( __METHOD__ );
+ $this->db->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ try {
+ $this->db->insert(
+ 'foo', [ [ 'i' => 7 ], [ 'i' => null ] ], __METHOD__, [ 'IGNORE' ]
+ );
+ $this->db->endAtomic( __METHOD__ );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBQueryError $e ) {
+ $this->assertSame( 0, $this->db->affectedRows() );
+ $this->db->cancelAtomic( __METHOD__ );
+ }
+ $this->assertSame(
+ [ '1', '2' ],
+ $this->db->selectFieldValues( 'foo', 'i', [], __METHOD__, [ 'ORDER BY' => 'i' ] )
+ );
+ $this->db->rollback( __METHOD__ );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\DatabasePostgres::insert
+ */
+ public function testInsertIgnoreOld() {
+ if ( !$this->db instanceof DatabasePostgres ) {
+ $this->markTestSkipped( 'Not PostgreSQL' );
+ }
+ if ( $this->db->getServerVersion() < 9.5 ) {
+ $this->doTestInsertIgnore();
+ } else {
+ // Hack version to make it take the old code path
+ $w = TestingAccessWrapper::newFromObject( $this->db );
+ $oldVer = $w->numericVersion;
+ $w->numericVersion = 9.4;
+ try {
+ $this->doTestInsertIgnore();
+ } finally {
+ $w->numericVersion = $oldVer;
+ }
+ }
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\DatabasePostgres::insert
+ */
+ public function testInsertIgnoreNew() {
+ if ( !$this->db instanceof DatabasePostgres ) {
+ $this->markTestSkipped( 'Not PostgreSQL' );
+ }
+ if ( $this->db->getServerVersion() < 9.5 ) {
+ $this->markTestSkipped( 'PostgreSQL version is ' . $this->db->getServerVersion() );
+ }
+
+ $this->doTestInsertIgnore();
+ }
+
+ private function doTestInsertSelectIgnore() {
+ $reset = new ScopedCallback( function () {
+ if ( $this->db->explicitTrxActive() ) {
+ $this->db->rollback( __METHOD__ );
+ }
+ $this->db->query( 'DROP TABLE IF EXISTS ' . $this->db->tableName( 'foo' ) );
+ $this->db->query( 'DROP TABLE IF EXISTS ' . $this->db->tableName( 'bar' ) );
+ } );
+
+ $this->db->query(
+ "CREATE TEMPORARY TABLE {$this->db->tableName( 'foo' )} (i INTEGER)"
+ );
+ $this->db->query(
+ "CREATE TEMPORARY TABLE {$this->db->tableName( 'bar' )} (i INTEGER NOT NULL PRIMARY KEY)"
+ );
+ $this->db->insert( 'bar', [ [ 'i' => 1 ], [ 'i' => 2 ] ], __METHOD__ );
+
+ // Normal INSERT IGNORE
+ $this->db->begin( __METHOD__ );
+ $this->db->insert( 'foo', [ [ 'i' => 3 ], [ 'i' => 2 ], [ 'i' => 5 ] ], __METHOD__ );
+ $this->db->insertSelect( 'bar', 'foo', [ 'i' => 'i' ], [], __METHOD__, [ 'IGNORE' ] );
+ $this->assertSame( 2, $this->db->affectedRows() );
+ $this->assertSame(
+ [ '1', '2', '3', '5' ],
+ $this->db->selectFieldValues( 'bar', 'i', [], __METHOD__, [ 'ORDER BY' => 'i' ] )
+ );
+ $this->db->rollback( __METHOD__ );
+
+ // INSERT IGNORE doesn't ignore stuff like NOT NULL violations
+ $this->db->begin( __METHOD__ );
+ $this->db->insert( 'foo', [ [ 'i' => 7 ], [ 'i' => null ] ], __METHOD__ );
+ $this->db->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ try {
+ $this->db->insertSelect( 'bar', 'foo', [ 'i' => 'i' ], [], __METHOD__, [ 'IGNORE' ] );
+ $this->db->endAtomic( __METHOD__ );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBQueryError $e ) {
+ $this->assertSame( 0, $this->db->affectedRows() );
+ $this->db->cancelAtomic( __METHOD__ );
+ }
+ $this->assertSame(
+ [ '1', '2' ],
+ $this->db->selectFieldValues( 'bar', 'i', [], __METHOD__, [ 'ORDER BY' => 'i' ] )
+ );
+ $this->db->rollback( __METHOD__ );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\DatabasePostgres::nativeInsertSelect
+ */
+ public function testInsertSelectIgnoreOld() {
+ if ( !$this->db instanceof DatabasePostgres ) {
+ $this->markTestSkipped( 'Not PostgreSQL' );
+ }
+ if ( $this->db->getServerVersion() < 9.5 ) {
+ $this->doTestInsertSelectIgnore();
+ } else {
+ // Hack version to make it take the old code path
+ $w = TestingAccessWrapper::newFromObject( $this->db );
+ $oldVer = $w->numericVersion;
+ $w->numericVersion = 9.4;
+ try {
+ $this->doTestInsertSelectIgnore();
+ } finally {
+ $w->numericVersion = $oldVer;
+ }
+ }
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\DatabasePostgres::nativeInsertSelect
+ */
+ public function testInsertSelectIgnoreNew() {
+ if ( !$this->db instanceof DatabasePostgres ) {
+ $this->markTestSkipped( 'Not PostgreSQL' );
+ }
+ if ( $this->db->getServerVersion() < 9.5 ) {
+ $this->markTestSkipped( 'PostgreSQL version is ' . $this->db->getServerVersion() );
+ }
+
+ $this->doTestInsertSelectIgnore();
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/db/DatabaseSqliteTest.php b/www/wiki/tests/phpunit/includes/db/DatabaseSqliteTest.php
new file mode 100644
index 00000000..729b58c7
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/db/DatabaseSqliteTest.php
@@ -0,0 +1,519 @@
+<?php
+
+use Wikimedia\Rdbms\Blob;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DatabaseSqlite;
+use Wikimedia\Rdbms\ResultWrapper;
+
+class DatabaseSqliteMock extends DatabaseSqlite {
+ public static function newInstance( array $p = [] ) {
+ $p['dbFilePath'] = ':memory:';
+ $p['schema'] = false;
+
+ return Database::factory( 'SqliteMock', $p );
+ }
+
+ function query( $sql, $fname = '', $tempIgnore = false ) {
+ return true;
+ }
+
+ /**
+ * Override parent visibility to public
+ */
+ public function replaceVars( $s ) {
+ return parent::replaceVars( $s );
+ }
+}
+
+/**
+ * @group sqlite
+ * @group Database
+ * @group medium
+ */
+class DatabaseSqliteTest extends MediaWikiTestCase {
+ /** @var DatabaseSqliteMock */
+ protected $db;
+
+ protected function setUp() {
+ parent::setUp();
+
+ if ( !Sqlite::isPresent() ) {
+ $this->markTestSkipped( 'No SQLite support detected' );
+ }
+ $this->db = DatabaseSqliteMock::newInstance();
+ if ( version_compare( $this->db->getServerVersion(), '3.6.0', '<' ) ) {
+ $this->markTestSkipped( "SQLite at least 3.6 required, {$this->db->getServerVersion()} found" );
+ }
+ }
+
+ private function replaceVars( $sql ) {
+ // normalize spacing to hide implementation details
+ return preg_replace( '/\s+/', ' ', $this->db->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( 'foobar', $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" );
+ }
+
+ 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;
+ }
+
+ 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 ', $toString );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\DatabaseSqlite::getAttributes()
+ */
+ public function testsAttributes() {
+ $attributes = Database::attributesFromType( 'sqlite' );
+ $this->assertTrue( $attributes[Database::ATTR_DB_LEVEL_LOCKING] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/db/DatabaseTestHelper.php b/www/wiki/tests/phpunit/includes/db/DatabaseTestHelper.php
new file mode 100644
index 00000000..e9fc34fa
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/db/DatabaseTestHelper.php
@@ -0,0 +1,267 @@
+<?php
+
+use Wikimedia\Rdbms\TransactionProfiler;
+use Wikimedia\Rdbms\DatabaseDomain;
+use Wikimedia\Rdbms\Database;
+
+/**
+ * Helper for testing the methods from the Database class
+ * @since 1.22
+ */
+class DatabaseTestHelper extends Database {
+
+ /**
+ * __CLASS__ of the test suite,
+ * used to determine, if the function name is passed every time to query()
+ */
+ protected $testName = [];
+
+ /**
+ * Array of lastSqls passed to query(),
+ * This is an array since some methods in Database can do more than one
+ * query. Cleared when calling getLastSqls().
+ */
+ protected $lastSqls = [];
+
+ /** @var array List of row arrays */
+ protected $nextResult = [];
+
+ /** @var array|null */
+ protected $nextError = null;
+ /** @var array|null */
+ protected $lastError = null;
+
+ /**
+ * Array of tables to be considered as existing by tableExist()
+ * Use setExistingTables() to alter.
+ */
+ protected $tablesExists;
+
+ /**
+ * Value to return from unionSupportsOrderAndLimit()
+ */
+ protected $unionSupportsOrderAndLimit = true;
+
+ public function __construct( $testName, array $opts = [] ) {
+ $this->testName = $testName;
+
+ $this->profiler = new ProfilerStub( [] );
+ $this->trxProfiler = new TransactionProfiler();
+ $this->cliMode = isset( $opts['cliMode'] ) ? $opts['cliMode'] : true;
+ $this->connLogger = new \Psr\Log\NullLogger();
+ $this->queryLogger = new \Psr\Log\NullLogger();
+ $this->errorLogger = function ( Exception $e ) {
+ wfWarn( get_class( $e ) . ": {$e->getMessage()}" );
+ };
+ $this->deprecationLogger = function ( $msg ) {
+ wfWarn( $msg );
+ };
+ $this->currentDomain = DatabaseDomain::newUnspecified();
+ $this->open( 'localhost', 'testuser', 'password', 'testdb' );
+ }
+
+ /**
+ * Returns SQL queries grouped by '; '
+ * Clear the list of queries that have been done so far.
+ * @return string
+ */
+ public function getLastSqls() {
+ $lastSqls = implode( '; ', $this->lastSqls );
+ $this->lastSqls = [];
+
+ return $lastSqls;
+ }
+
+ public function setExistingTables( $tablesExists ) {
+ $this->tablesExists = (array)$tablesExists;
+ }
+
+ /**
+ * @param mixed $res Use an array of row arrays to set row result
+ */
+ public function forceNextResult( $res ) {
+ $this->nextResult = $res;
+ }
+
+ /**
+ * @param int $errno Error number
+ * @param string $error Error text
+ * @param array $options
+ * - wasKnownStatementRollbackError: Return value for wasKnownStatementRollbackError()
+ */
+ public function forceNextQueryError( $errno, $error, $options = [] ) {
+ $this->nextError = [ 'errno' => $errno, 'error' => $error ] + $options;
+ }
+
+ protected function addSql( $sql ) {
+ // clean up spaces before and after some words and the whole string
+ $this->lastSqls[] = trim( preg_replace(
+ '/\s{2,}(?=FROM|WHERE|GROUP BY|ORDER BY|LIMIT)|(?<=SELECT|INSERT|UPDATE)\s{2,}/',
+ ' ', $sql
+ ) );
+ }
+
+ protected function checkFunctionName( $fname ) {
+ if ( $fname === 'Wikimedia\\Rdbms\\Database::close' ) {
+ return; // no $fname parameter
+ }
+
+ // Handle some internal calls from the Database class
+ $check = $fname;
+ if ( preg_match( '/^Wikimedia\\\\Rdbms\\\\Database::query \((.+)\)$/', $fname, $m ) ) {
+ $check = $m[1];
+ }
+
+ if ( substr( $check, 0, strlen( $this->testName ) ) !== $this->testName ) {
+ throw new MWException( 'function name does not start with test class. ' .
+ $fname . ' vs. ' . $this->testName . '. ' .
+ 'Please provide __METHOD__ to database methods.' );
+ }
+ }
+
+ function strencode( $s ) {
+ // Choose apos to avoid handling of escaping double quotes in quoted text
+ return str_replace( "'", "\'", $s );
+ }
+
+ public function addIdentifierQuotes( $s ) {
+ // no escaping to avoid handling of double quotes in quoted text
+ return $s;
+ }
+
+ public function query( $sql, $fname = '', $tempIgnore = false ) {
+ $this->checkFunctionName( $fname );
+
+ return parent::query( $sql, $fname, $tempIgnore );
+ }
+
+ public function tableExists( $table, $fname = __METHOD__ ) {
+ $tableRaw = $this->tableName( $table, 'raw' );
+ if ( isset( $this->sessionTempTables[$tableRaw] ) ) {
+ return true; // already known to exist
+ }
+
+ $this->checkFunctionName( $fname );
+
+ return in_array( $table, (array)$this->tablesExists );
+ }
+
+ // Redeclare parent method to make it public
+ public function nativeReplace( $table, $rows, $fname ) {
+ return parent::nativeReplace( $table, $rows, $fname );
+ }
+
+ function getType() {
+ return 'test';
+ }
+
+ function open( $server, $user, $password, $dbName ) {
+ $this->conn = (object)[ 'test' ];
+
+ return true;
+ }
+
+ function fetchObject( $res ) {
+ return false;
+ }
+
+ function fetchRow( $res ) {
+ return false;
+ }
+
+ function numRows( $res ) {
+ return -1;
+ }
+
+ function numFields( $res ) {
+ return -1;
+ }
+
+ function fieldName( $res, $n ) {
+ return 'test';
+ }
+
+ function insertId() {
+ return -1;
+ }
+
+ function dataSeek( $res, $row ) {
+ /* nop */
+ }
+
+ function lastErrno() {
+ return $this->lastError ? $this->lastError['errno'] : -1;
+ }
+
+ function lastError() {
+ return $this->lastError ? $this->lastError['error'] : 'test';
+ }
+
+ protected function wasKnownStatementRollbackError() {
+ return isset( $this->lastError['wasKnownStatementRollbackError'] )
+ ? $this->lastError['wasKnownStatementRollbackError']
+ : false;
+ }
+
+ function fieldInfo( $table, $field ) {
+ return false;
+ }
+
+ function indexInfo( $table, $index, $fname = 'Database::indexInfo' ) {
+ return false;
+ }
+
+ function fetchAffectedRowCount() {
+ return -1;
+ }
+
+ function getSoftwareLink() {
+ return 'test';
+ }
+
+ function getServerVersion() {
+ return 'test';
+ }
+
+ function getServerInfo() {
+ return 'test';
+ }
+
+ function isOpen() {
+ return $this->conn ? true : false;
+ }
+
+ function ping( &$rtt = null ) {
+ $rtt = 0.0;
+ return true;
+ }
+
+ protected function closeConnection() {
+ return true;
+ }
+
+ protected function doQuery( $sql ) {
+ $sql = preg_replace( '< /\* .+? \*/>', '', $sql );
+ $this->addSql( $sql );
+
+ if ( $this->nextError ) {
+ $this->lastError = $this->nextError;
+ $this->nextError = null;
+ return false;
+ }
+
+ $res = $this->nextResult;
+ $this->nextResult = [];
+ $this->lastError = null;
+
+ return new FakeResultWrapper( $res );
+ }
+
+ public function unionSupportsOrderAndLimit() {
+ return $this->unionSupportsOrderAndLimit;
+ }
+
+ public function setUnionSupportsOrderAndLimit( $v ) {
+ $this->unionSupportsOrderAndLimit = (bool)$v;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/db/LBFactoryTest.php b/www/wiki/tests/phpunit/includes/db/LBFactoryTest.php
new file mode 100644
index 00000000..ed4c977f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/db/LBFactoryTest.php
@@ -0,0 +1,530 @@
+<?php
+/**
+ * Holds tests for LBFactory abstract MediaWiki class.
+ *
+ * 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
+ * @author Antoine Musso
+ * @copyright © 2013 Antoine Musso
+ * @copyright © 2013 Wikimedia Foundation Inc.
+ */
+
+use Wikimedia\Rdbms\LBFactorySimple;
+use Wikimedia\Rdbms\LBFactoryMulti;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\ChronologyProtector;
+use Wikimedia\Rdbms\DatabaseMysqli;
+use Wikimedia\Rdbms\MySQLMasterPos;
+use Wikimedia\Rdbms\DatabaseDomain;
+
+/**
+ * @group Database
+ * @covers \Wikimedia\Rdbms\LBFactorySimple
+ * @covers \Wikimedia\Rdbms\LBFactoryMulti
+ */
+class LBFactoryTest extends MediaWikiTestCase {
+
+ /**
+ * @covers MWLBFactory::getLBFactoryClass
+ * @dataProvider getLBFactoryClassProvider
+ */
+ public function testGetLBFactoryClass( $expected, $deprecated ) {
+ $mockDB = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $config = [
+ 'class' => $deprecated,
+ 'connection' => $mockDB,
+ # Various other parameters required:
+ 'sectionsByDB' => [],
+ 'sectionLoads' => [],
+ 'serverTemplate' => [],
+ ];
+
+ $this->hideDeprecated( '$wgLBFactoryConf must be updated. See RELEASE-NOTES for details' );
+ $result = MWLBFactory::getLBFactoryClass( $config );
+
+ $this->assertEquals( $expected, $result );
+ }
+
+ public function getLBFactoryClassProvider() {
+ return [
+ # Format: new class, old class
+ [ Wikimedia\Rdbms\LBFactorySimple::class, 'LBFactory_Simple' ],
+ [ Wikimedia\Rdbms\LBFactorySingle::class, 'LBFactory_Single' ],
+ [ Wikimedia\Rdbms\LBFactoryMulti::class, 'LBFactory_Multi' ],
+ [ Wikimedia\Rdbms\LBFactorySimple::class, 'LBFactorySimple' ],
+ [ Wikimedia\Rdbms\LBFactorySingle::class, 'LBFactorySingle' ],
+ [ Wikimedia\Rdbms\LBFactoryMulti::class, 'LBFactoryMulti' ],
+ ];
+ }
+
+ public function testLBFactorySimpleServer() {
+ global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir;
+
+ $servers = [
+ [
+ 'host' => $wgDBserver,
+ 'dbname' => $wgDBname,
+ 'user' => $wgDBuser,
+ 'password' => $wgDBpassword,
+ 'type' => $wgDBtype,
+ 'dbDirectory' => $wgSQLiteDataDir,
+ 'load' => 0,
+ 'flags' => DBO_TRX // REPEATABLE-READ for consistency
+ ],
+ ];
+
+ $factory = new LBFactorySimple( [ 'servers' => $servers ] );
+ $lb = $factory->getMainLB();
+
+ $dbw = $lb->getConnection( DB_MASTER );
+ $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' );
+
+ $dbr = $lb->getConnection( DB_REPLICA );
+ $this->assertTrue( $dbr->getLBInfo( 'master' ), 'DB_REPLICA also gets the master' );
+
+ $factory->shutdown();
+ $lb->closeAll();
+ }
+
+ public function testLBFactorySimpleServers() {
+ global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir;
+
+ $servers = [
+ [ // master
+ 'host' => $wgDBserver,
+ 'dbname' => $wgDBname,
+ 'user' => $wgDBuser,
+ 'password' => $wgDBpassword,
+ 'type' => $wgDBtype,
+ 'dbDirectory' => $wgSQLiteDataDir,
+ 'load' => 0,
+ 'flags' => DBO_TRX // REPEATABLE-READ for consistency
+ ],
+ [ // emulated slave
+ 'host' => $wgDBserver,
+ 'dbname' => $wgDBname,
+ 'user' => $wgDBuser,
+ 'password' => $wgDBpassword,
+ 'type' => $wgDBtype,
+ 'dbDirectory' => $wgSQLiteDataDir,
+ 'load' => 100,
+ 'flags' => DBO_TRX // REPEATABLE-READ for consistency
+ ]
+ ];
+
+ $factory = new LBFactorySimple( [
+ 'servers' => $servers,
+ 'loadMonitorClass' => LoadMonitorNull::class
+ ] );
+ $lb = $factory->getMainLB();
+
+ $dbw = $lb->getConnection( DB_MASTER );
+ $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' );
+ $this->assertEquals(
+ ( $wgDBserver != '' ) ? $wgDBserver : 'localhost',
+ $dbw->getLBInfo( 'clusterMasterHost' ),
+ 'cluster master set' );
+
+ $dbr = $lb->getConnection( DB_REPLICA );
+ $this->assertTrue( $dbr->getLBInfo( 'replica' ), 'slave shows as slave' );
+ $this->assertEquals(
+ ( $wgDBserver != '' ) ? $wgDBserver : 'localhost',
+ $dbr->getLBInfo( 'clusterMasterHost' ),
+ 'cluster master set' );
+
+ $factory->shutdown();
+ $lb->closeAll();
+ }
+
+ public function testLBFactoryMulti() {
+ global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir;
+
+ $factory = new LBFactoryMulti( [
+ 'sectionsByDB' => [],
+ 'sectionLoads' => [
+ 'DEFAULT' => [
+ 'test-db1' => 0,
+ 'test-db2' => 100,
+ ],
+ ],
+ 'serverTemplate' => [
+ 'dbname' => $wgDBname,
+ 'user' => $wgDBuser,
+ 'password' => $wgDBpassword,
+ 'type' => $wgDBtype,
+ 'dbDirectory' => $wgSQLiteDataDir,
+ 'flags' => DBO_DEFAULT
+ ],
+ 'hostsByName' => [
+ 'test-db1' => $wgDBserver,
+ 'test-db2' => $wgDBserver
+ ],
+ 'loadMonitorClass' => LoadMonitorNull::class
+ ] );
+ $lb = $factory->getMainLB();
+
+ $dbw = $lb->getConnection( DB_MASTER );
+ $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' );
+
+ $dbr = $lb->getConnection( DB_REPLICA );
+ $this->assertTrue( $dbr->getLBInfo( 'replica' ), 'slave shows as slave' );
+
+ $factory->shutdown();
+ $lb->closeAll();
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\ChronologyProtector
+ */
+ public function testChronologyProtector() {
+ $now = microtime( true );
+
+ // (a) First HTTP request
+ $m1Pos = new MySQLMasterPos( 'db1034-bin.000976/843431247', $now );
+ $m2Pos = new MySQLMasterPos( 'db1064-bin.002400/794074907', $now );
+
+ // Master DB 1
+ $mockDB1 = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mockDB1->method( 'writesOrCallbacksPending' )->willReturn( true );
+ $mockDB1->method( 'lastDoneWrites' )->willReturn( $now );
+ $mockDB1->method( 'getMasterPos' )->willReturn( $m1Pos );
+ // Load balancer for master DB 1
+ $lb1 = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $lb1->method( 'getConnection' )->willReturn( $mockDB1 );
+ $lb1->method( 'getServerCount' )->willReturn( 2 );
+ $lb1->method( 'getAnyOpenConnection' )->willReturn( $mockDB1 );
+ $lb1->method( 'hasOrMadeRecentMasterChanges' )->will( $this->returnCallback(
+ function () use ( $mockDB1 ) {
+ $p = 0;
+ $p |= call_user_func( [ $mockDB1, 'writesOrCallbacksPending' ] );
+ $p |= call_user_func( [ $mockDB1, 'lastDoneWrites' ] );
+
+ return (bool)$p;
+ }
+ ) );
+ $lb1->method( 'getMasterPos' )->willReturn( $m1Pos );
+ $lb1->method( 'getServerName' )->with( 0 )->willReturn( 'master1' );
+ // Master DB 2
+ $mockDB2 = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mockDB2->method( 'writesOrCallbacksPending' )->willReturn( true );
+ $mockDB2->method( 'lastDoneWrites' )->willReturn( $now );
+ $mockDB2->method( 'getMasterPos' )->willReturn( $m2Pos );
+ // Load balancer for master DB 2
+ $lb2 = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $lb2->method( 'getConnection' )->willReturn( $mockDB2 );
+ $lb2->method( 'getServerCount' )->willReturn( 2 );
+ $lb2->method( 'getAnyOpenConnection' )->willReturn( $mockDB2 );
+ $lb2->method( 'hasOrMadeRecentMasterChanges' )->will( $this->returnCallback(
+ function () use ( $mockDB2 ) {
+ $p = 0;
+ $p |= call_user_func( [ $mockDB2, 'writesOrCallbacksPending' ] );
+ $p |= call_user_func( [ $mockDB2, 'lastDoneWrites' ] );
+
+ return (bool)$p;
+ }
+ ) );
+ $lb2->method( 'getMasterPos' )->willReturn( $m2Pos );
+ $lb2->method( 'getServerName' )->with( 0 )->willReturn( 'master2' );
+
+ $bag = new HashBagOStuff();
+ $cp = new ChronologyProtector(
+ $bag,
+ [
+ 'ip' => '127.0.0.1',
+ 'agent' => "Totally-Not-FireFox"
+ ]
+ );
+
+ $mockDB1->expects( $this->exactly( 1 ) )->method( 'writesOrCallbacksPending' );
+ $mockDB1->expects( $this->exactly( 1 ) )->method( 'lastDoneWrites' );
+ $mockDB2->expects( $this->exactly( 1 ) )->method( 'writesOrCallbacksPending' );
+ $mockDB2->expects( $this->exactly( 1 ) )->method( 'lastDoneWrites' );
+
+ // Nothing to wait for on first HTTP request start
+ $cp->initLB( $lb1 );
+ $cp->initLB( $lb2 );
+ // Record positions in stash on first HTTP request end
+ $cp->shutdownLB( $lb1 );
+ $cp->shutdownLB( $lb2 );
+ $cpIndex = null;
+ $cp->shutdown( null, 'sync', $cpIndex );
+
+ $this->assertEquals( 1, $cpIndex, "CP write index set" );
+
+ // (b) Second HTTP request
+
+ // Load balancer for master DB 1
+ $lb1 = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $lb1->method( 'getServerCount' )->willReturn( 2 );
+ $lb1->method( 'getServerName' )->with( 0 )->willReturn( 'master1' );
+ $lb1->expects( $this->once() )
+ ->method( 'waitFor' )->with( $this->equalTo( $m1Pos ) );
+ // Load balancer for master DB 2
+ $lb2 = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $lb2->method( 'getServerCount' )->willReturn( 2 );
+ $lb2->method( 'getServerName' )->with( 0 )->willReturn( 'master2' );
+ $lb2->expects( $this->once() )
+ ->method( 'waitFor' )->with( $this->equalTo( $m2Pos ) );
+
+ $cp = new ChronologyProtector(
+ $bag,
+ [
+ 'ip' => '127.0.0.1',
+ 'agent' => "Totally-Not-FireFox"
+ ],
+ $cpIndex
+ );
+
+ // Wait for last positions to be reached on second HTTP request start
+ $cp->initLB( $lb1 );
+ $cp->initLB( $lb2 );
+ // Shutdown (nothing to record)
+ $cp->shutdownLB( $lb1 );
+ $cp->shutdownLB( $lb2 );
+ $cpIndex = null;
+ $cp->shutdown( null, 'sync', $cpIndex );
+
+ $this->assertEquals( null, $cpIndex, "CP write index retained" );
+ }
+
+ private function newLBFactoryMulti( array $baseOverride = [], array $serverOverride = [] ) {
+ global $wgDBserver, $wgDBuser, $wgDBpassword, $wgDBname, $wgDBprefix, $wgDBtype;
+ global $wgSQLiteDataDir;
+
+ return new LBFactoryMulti( $baseOverride + [
+ 'sectionsByDB' => [],
+ 'sectionLoads' => [
+ 'DEFAULT' => [
+ 'test-db1' => 1,
+ ],
+ ],
+ 'serverTemplate' => $serverOverride + [
+ 'dbname' => $wgDBname,
+ 'tablePrefix' => $wgDBprefix,
+ 'user' => $wgDBuser,
+ 'password' => $wgDBpassword,
+ 'type' => $wgDBtype,
+ 'dbDirectory' => $wgSQLiteDataDir,
+ 'flags' => DBO_DEFAULT
+ ],
+ 'hostsByName' => [
+ 'test-db1' => $wgDBserver,
+ ],
+ 'loadMonitorClass' => LoadMonitorNull::class,
+ 'localDomain' => new DatabaseDomain( $wgDBname, null, $wgDBprefix )
+ ] );
+ }
+
+ public function testNiceDomains() {
+ global $wgDBname;
+
+ if ( wfGetDB( DB_MASTER )->databasesAreIndependent() ) {
+ self::markTestSkipped( "Skipping tests about selecting DBs: not applicable" );
+ return;
+ }
+
+ $factory = $this->newLBFactoryMulti(
+ [],
+ []
+ );
+ $lb = $factory->getMainLB();
+
+ $db = $lb->getConnectionRef( DB_MASTER );
+ $this->assertEquals(
+ wfWikiID(),
+ $db->getDomainID()
+ );
+ unset( $db );
+
+ /** @var Database $db */
+ $db = $lb->getConnection( DB_MASTER, [], '' );
+
+ $this->assertEquals(
+ '',
+ $db->getDomainId(),
+ 'Null domain ID handle used'
+ );
+ $this->assertEquals(
+ '',
+ $db->getDBname(),
+ 'Null domain ID handle used'
+ );
+ $this->assertEquals(
+ '',
+ $db->tablePrefix(),
+ 'Main domain ID handle used; prefix is empty though'
+ );
+ $this->assertEquals(
+ $this->quoteTable( $db, 'page' ),
+ $db->tableName( 'page' ),
+ "Correct full table name"
+ );
+ $this->assertEquals(
+ $this->quoteTable( $db, $wgDBname ) . '.' . $this->quoteTable( $db, 'page' ),
+ $db->tableName( "$wgDBname.page" ),
+ "Correct full table name"
+ );
+ $this->assertEquals(
+ $this->quoteTable( $db, 'nice_db' ) . '.' . $this->quoteTable( $db, 'page' ),
+ $db->tableName( 'nice_db.page' ),
+ "Correct full table name"
+ );
+
+ $lb->reuseConnection( $db ); // don't care
+
+ $db = $lb->getConnection( DB_MASTER ); // local domain connection
+ $factory->setDomainPrefix( 'my_' );
+
+ $this->assertEquals( $wgDBname, $db->getDBname() );
+ $this->assertEquals(
+ "$wgDBname-my_",
+ $db->getDomainID()
+ );
+ $this->assertEquals(
+ $this->quoteTable( $db, 'my_page' ),
+ $db->tableName( 'page' ),
+ "Correct full table name"
+ );
+ $this->assertEquals(
+ $this->quoteTable( $db, 'other_nice_db' ) . '.' . $this->quoteTable( $db, 'page' ),
+ $db->tableName( 'other_nice_db.page' ),
+ "Correct full table name"
+ );
+
+ $factory->closeAll();
+ $factory->destroy();
+ }
+
+ public function testTrickyDomain() {
+ global $wgDBname;
+
+ if ( wfGetDB( DB_MASTER )->databasesAreIndependent() ) {
+ self::markTestSkipped( "Skipping tests about selecting DBs: not applicable" );
+ return;
+ }
+
+ $dbname = 'unittest-domain'; // explodes if DB is selected
+ $factory = $this->newLBFactoryMulti(
+ [ 'localDomain' => ( new DatabaseDomain( $dbname, null, '' ) )->getId() ],
+ [
+ 'dbName' => 'do_not_select_me' // explodes if DB is selected
+ ]
+ );
+ $lb = $factory->getMainLB();
+ /** @var Database $db */
+ $db = $lb->getConnection( DB_MASTER, [], '' );
+
+ $this->assertEquals( '', $db->getDomainID(), "Null domain used" );
+
+ $this->assertEquals(
+ $this->quoteTable( $db, 'page' ),
+ $db->tableName( 'page' ),
+ "Correct full table name"
+ );
+
+ $this->assertEquals(
+ $this->quoteTable( $db, $dbname ) . '.' . $this->quoteTable( $db, 'page' ),
+ $db->tableName( "$dbname.page" ),
+ "Correct full table name"
+ );
+
+ $this->assertEquals(
+ $this->quoteTable( $db, 'nice_db' ) . '.' . $this->quoteTable( $db, 'page' ),
+ $db->tableName( 'nice_db.page' ),
+ "Correct full table name"
+ );
+
+ $lb->reuseConnection( $db ); // don't care
+
+ $factory->setDomainPrefix( 'my_' );
+ $db = $lb->getConnection( DB_MASTER, [], "$wgDBname-my_" );
+
+ $this->assertEquals(
+ $this->quoteTable( $db, 'my_page' ),
+ $db->tableName( 'page' ),
+ "Correct full table name"
+ );
+ $this->assertEquals(
+ $this->quoteTable( $db, 'other_nice_db' ) . '.' . $this->quoteTable( $db, 'page' ),
+ $db->tableName( 'other_nice_db.page' ),
+ "Correct full table name"
+ );
+ $this->assertEquals(
+ $this->quoteTable( $db, 'garbage-db' ) . '.' . $this->quoteTable( $db, 'page' ),
+ $db->tableName( 'garbage-db.page' ),
+ "Correct full table name"
+ );
+
+ $lb->reuseConnection( $db ); // don't care
+
+ $factory->closeAll();
+ $factory->destroy();
+ }
+
+ public function testInvalidSelectDB() {
+ $dbname = 'unittest-domain'; // explodes if DB is selected
+ $factory = $this->newLBFactoryMulti(
+ [ 'localDomain' => ( new DatabaseDomain( $dbname, null, '' ) )->getId() ],
+ [
+ 'dbName' => 'do_not_select_me' // explodes if DB is selected
+ ]
+ );
+ $lb = $factory->getMainLB();
+ /** @var Database $db */
+ $db = $lb->getConnection( DB_MASTER, [], '' );
+
+ if ( $db->getType() === 'sqlite' ) {
+ $this->assertFalse( $db->selectDB( 'garbage-db' ) );
+ } elseif ( $db->databasesAreIndependent() ) {
+ try {
+ $e = null;
+ $db->selectDB( 'garbage-db' );
+ } catch ( \Wikimedia\Rdbms\DBConnectionError $e ) {
+ // expected
+ }
+ $this->assertInstanceOf( \Wikimedia\Rdbms\DBConnectionError::class, $e );
+ $this->assertFalse( $db->isOpen() );
+ } else {
+ \Wikimedia\suppressWarnings();
+ $this->assertFalse( $db->selectDB( 'garbage-db' ) );
+ \Wikimedia\restoreWarnings();
+ }
+ }
+
+ private function quoteTable( Database $db, $table ) {
+ if ( $db->getType() === 'sqlite' ) {
+ return $table;
+ } else {
+ return $db->addIdentifierQuotes( $table );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/db/LoadBalancerTest.php b/www/wiki/tests/phpunit/includes/db/LoadBalancerTest.php
new file mode 100644
index 00000000..e054569d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/db/LoadBalancerTest.php
@@ -0,0 +1,305 @@
+<?php
+
+/**
+ * Holds tests for LoadBalancer MediaWiki class.
+ *
+ * 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
+ */
+
+use Wikimedia\Rdbms\DBError;
+use Wikimedia\Rdbms\DatabaseDomain;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\LoadMonitorNull;
+
+/**
+ * @group Database
+ * @covers \Wikimedia\Rdbms\LoadBalancer
+ */
+class LoadBalancerTest extends MediaWikiTestCase {
+ private function makeServerConfig() {
+ global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir;
+
+ return [
+ 'host' => $wgDBserver,
+ 'dbname' => $wgDBname,
+ 'tablePrefix' => $this->dbPrefix(),
+ 'user' => $wgDBuser,
+ 'password' => $wgDBpassword,
+ 'type' => $wgDBtype,
+ 'dbDirectory' => $wgSQLiteDataDir,
+ 'load' => 0,
+ 'flags' => DBO_TRX // REPEATABLE-READ for consistency
+ ];
+ }
+
+ public function testWithoutReplica() {
+ global $wgDBname;
+
+ $called = false;
+ $lb = new LoadBalancer( [
+ 'servers' => [ $this->makeServerConfig() ],
+ 'queryLogger' => MediaWiki\Logger\LoggerFactory::getInstance( 'DBQuery' ),
+ 'localDomain' => new DatabaseDomain( $wgDBname, null, $this->dbPrefix() ),
+ 'chronologyCallback' => function () use ( &$called ) {
+ $called = true;
+ }
+ ] );
+
+ $ld = DatabaseDomain::newFromId( $lb->getLocalDomainID() );
+ $this->assertEquals( $wgDBname, $ld->getDatabase(), 'local domain DB set' );
+ $this->assertEquals( $this->dbPrefix(), $ld->getTablePrefix(), 'local domain prefix set' );
+
+ $this->assertFalse( $called );
+ $dbw = $lb->getConnection( DB_MASTER );
+ $this->assertTrue( $called );
+ $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' );
+ $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on master" );
+ $this->assertWriteAllowed( $dbw );
+
+ $dbr = $lb->getConnection( DB_REPLICA );
+ $this->assertTrue( $dbr->getLBInfo( 'master' ), 'DB_REPLICA also gets the master' );
+ $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on replica" );
+
+ if ( !$lb->getServerAttributes( $lb->getWriterIndex() )[$dbw::ATTR_DB_LEVEL_LOCKING] ) {
+ $dbwAuto = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+ $this->assertFalse(
+ $dbwAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" );
+ $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on master" );
+ $this->assertNotEquals(
+ $dbw, $dbwAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" );
+
+ $dbrAuto = $lb->getConnection( DB_REPLICA, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+ $this->assertFalse(
+ $dbrAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" );
+ $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on replica" );
+ $this->assertNotEquals(
+ $dbr, $dbrAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" );
+
+ $dbwAuto2 = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+ $this->assertEquals( $dbwAuto2, $dbwAuto, "CONN_TRX_AUTOCOMMIT reuses connections" );
+ }
+
+ $lb->closeAll();
+ }
+
+ public function testWithReplica() {
+ global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir;
+
+ $servers = [
+ [ // master
+ 'host' => $wgDBserver,
+ 'dbname' => $wgDBname,
+ 'tablePrefix' => $this->dbPrefix(),
+ 'user' => $wgDBuser,
+ 'password' => $wgDBpassword,
+ 'type' => $wgDBtype,
+ 'dbDirectory' => $wgSQLiteDataDir,
+ 'load' => 0,
+ 'flags' => DBO_TRX // REPEATABLE-READ for consistency
+ ],
+ [ // emulated replica
+ 'host' => $wgDBserver,
+ 'dbname' => $wgDBname,
+ 'tablePrefix' => $this->dbPrefix(),
+ 'user' => $wgDBuser,
+ 'password' => $wgDBpassword,
+ 'type' => $wgDBtype,
+ 'dbDirectory' => $wgSQLiteDataDir,
+ 'load' => 100,
+ 'flags' => DBO_TRX // REPEATABLE-READ for consistency
+ ]
+ ];
+
+ $lb = new LoadBalancer( [
+ 'servers' => $servers,
+ 'localDomain' => new DatabaseDomain( $wgDBname, null, $this->dbPrefix() ),
+ 'queryLogger' => MediaWiki\Logger\LoggerFactory::getInstance( 'DBQuery' ),
+ 'loadMonitorClass' => LoadMonitorNull::class
+ ] );
+
+ $dbw = $lb->getConnection( DB_MASTER );
+ $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' );
+ $this->assertEquals(
+ ( $wgDBserver != '' ) ? $wgDBserver : 'localhost',
+ $dbw->getLBInfo( 'clusterMasterHost' ),
+ 'cluster master set' );
+ $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on master" );
+ $this->assertWriteAllowed( $dbw );
+
+ $dbr = $lb->getConnection( DB_REPLICA );
+ $this->assertTrue( $dbr->getLBInfo( 'replica' ), 'replica shows as replica' );
+ $this->assertEquals(
+ ( $wgDBserver != '' ) ? $wgDBserver : 'localhost',
+ $dbr->getLBInfo( 'clusterMasterHost' ),
+ 'cluster master set' );
+ $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on replica" );
+ $this->assertWriteForbidden( $dbr );
+
+ if ( !$lb->getServerAttributes( $lb->getWriterIndex() )[$dbw::ATTR_DB_LEVEL_LOCKING] ) {
+ $dbwAuto = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+ $this->assertFalse(
+ $dbwAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" );
+ $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on master" );
+ $this->assertNotEquals(
+ $dbw, $dbwAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" );
+
+ $dbrAuto = $lb->getConnection( DB_REPLICA, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+ $this->assertFalse(
+ $dbrAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" );
+ $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on replica" );
+ $this->assertNotEquals(
+ $dbr, $dbrAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" );
+
+ $dbwAuto2 = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+ $this->assertEquals( $dbwAuto2, $dbwAuto, "CONN_TRX_AUTOCOMMIT reuses connections" );
+ }
+
+ $lb->closeAll();
+ }
+
+ private function assertWriteForbidden( Database $db ) {
+ try {
+ $db->delete( 'some_table', [ 'id' => 57634126 ], __METHOD__ );
+ $this->fail( 'Write operation should have failed!' );
+ } catch ( DBError $ex ) {
+ // check that the exception message contains "Write operation"
+ $constraint = new PHPUnit_Framework_Constraint_StringContains( 'Write operation' );
+
+ if ( !$constraint->evaluate( $ex->getMessage(), '', true ) ) {
+ // re-throw original error, to preserve stack trace
+ throw $ex;
+ }
+ }
+ }
+
+ private function assertWriteAllowed( Database $db ) {
+ $table = $db->tableName( 'some_table' );
+ try {
+ $db->dropTable( 'some_table' ); // clear for sanity
+
+ // Trigger DBO_TRX to create a transaction so the flush below will
+ // roll everything here back in sqlite. But don't actually do the
+ // code below inside an atomic section becaue MySQL and Oracle
+ // auto-commit transactions for DDL statements like CREATE TABLE.
+ $db->startAtomic( __METHOD__ );
+ $db->endAtomic( __METHOD__ );
+
+ // Use only basic SQL and trivial types for these queries for compatibility
+ $this->assertNotSame(
+ false,
+ $db->query( "CREATE TABLE $table (id INT, time INT)", __METHOD__ ),
+ "table created"
+ );
+ $this->assertNotSame(
+ false,
+ $db->query( "DELETE FROM $table WHERE id=57634126", __METHOD__ ),
+ "delete query"
+ );
+ } finally {
+ // Drop the table to clean up, ignoring any error.
+ $db->query( "DROP TABLE $table", __METHOD__, true );
+ // Rollback the DBO_TRX transaction for sqlite's benefit.
+ $db->rollback( __METHOD__, 'flush' );
+ }
+ }
+
+ public function testServerAttributes() {
+ $servers = [
+ [ // master
+ 'dbname' => 'my_unittest_wiki',
+ 'tablePrefix' => 'unittest_',
+ 'type' => 'sqlite',
+ 'dbDirectory' => "some_directory",
+ 'load' => 0
+ ]
+ ];
+
+ $lb = new LoadBalancer( [
+ 'servers' => $servers,
+ 'localDomain' => new DatabaseDomain( 'my_unittest_wiki', null, 'unittest_' ),
+ 'loadMonitorClass' => LoadMonitorNull::class
+ ] );
+
+ $this->assertTrue( $lb->getServerAttributes( 0 )[Database::ATTR_DB_LEVEL_LOCKING] );
+
+ $servers = [
+ [ // master
+ 'host' => 'db1001',
+ 'user' => 'wikiuser',
+ 'password' => 'none',
+ 'dbname' => 'my_unittest_wiki',
+ 'tablePrefix' => 'unittest_',
+ 'type' => 'mysql',
+ 'load' => 100
+ ],
+ [ // emulated replica
+ 'host' => 'db1002',
+ 'user' => 'wikiuser',
+ 'password' => 'none',
+ 'dbname' => 'my_unittest_wiki',
+ 'tablePrefix' => 'unittest_',
+ 'type' => 'mysql',
+ 'load' => 100
+ ]
+ ];
+
+ $lb = new LoadBalancer( [
+ 'servers' => $servers,
+ 'localDomain' => new DatabaseDomain( 'my_unittest_wiki', null, 'unittest_' ),
+ 'loadMonitorClass' => LoadMonitorNull::class
+ ] );
+
+ $this->assertFalse( $lb->getServerAttributes( 1 )[Database::ATTR_DB_LEVEL_LOCKING] );
+ }
+
+ /**
+ * @covers LoadBalancer::openConnection()
+ * @covers LoadBalancer::getAnyOpenConnection()
+ */
+ function testOpenConnection() {
+ global $wgDBname;
+
+ $lb = new LoadBalancer( [
+ 'servers' => [ $this->makeServerConfig() ],
+ 'localDomain' => new DatabaseDomain( $wgDBname, null, $this->dbPrefix() )
+ ] );
+
+ $i = $lb->getWriterIndex();
+ $this->assertEquals( null, $lb->getAnyOpenConnection( $i ) );
+ $conn1 = $lb->getConnection( $i );
+ $this->assertNotEquals( null, $conn1 );
+ $this->assertEquals( $conn1, $lb->getAnyOpenConnection( $i ) );
+ $conn2 = $lb->getConnection( $i, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+ $this->assertNotEquals( null, $conn2 );
+ if ( $lb->getServerAttributes( $i )[Database::ATTR_DB_LEVEL_LOCKING] ) {
+ $this->assertEquals( null,
+ $lb->getAnyOpenConnection( $i, $lb::CONN_TRX_AUTOCOMMIT ) );
+ $this->assertEquals( $conn1,
+ $lb->getConnection(
+ $i, [], false, $lb::CONN_TRX_AUTOCOMMIT ), $lb::CONN_TRX_AUTOCOMMIT );
+ } else {
+ $this->assertEquals( $conn2,
+ $lb->getAnyOpenConnection( $i, $lb::CONN_TRX_AUTOCOMMIT ) );
+ $this->assertEquals( $conn2,
+ $lb->getConnection( $i, [], false, $lb::CONN_TRX_AUTOCOMMIT ) );
+ }
+
+ $lb->closeAll();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/debug/MWDebugTest.php b/www/wiki/tests/phpunit/includes/debug/MWDebugTest.php
new file mode 100644
index 00000000..6f0b1db9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/debug/MWDebugTest.php
@@ -0,0 +1,140 @@
+<?php
+
+class MWDebugTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ /** Clear log before each test */
+ MWDebug::clearLog();
+ }
+
+ public static function setUpBeforeClass() {
+ parent::setUpBeforeClass();
+ MWDebug::init();
+ Wikimedia\suppressWarnings();
+ }
+
+ public static function tearDownAfterClass() {
+ parent::tearDownAfterClass();
+ MWDebug::deinit();
+ Wikimedia\restoreWarnings();
+ }
+
+ /**
+ * @covers MWDebug::log
+ */
+ public function testAddLog() {
+ MWDebug::log( 'logging a string' );
+ $this->assertEquals(
+ [ [
+ 'msg' => 'logging a string',
+ 'type' => 'log',
+ 'caller' => 'MWDebugTest->testAddLog',
+ ] ],
+ MWDebug::getLog()
+ );
+ }
+
+ /**
+ * @covers MWDebug::warning
+ */
+ public function testAddWarning() {
+ MWDebug::warning( 'Warning message' );
+ $this->assertEquals(
+ [ [
+ 'msg' => 'Warning message',
+ 'type' => 'warn',
+ 'caller' => 'MWDebugTest::testAddWarning',
+ ] ],
+ MWDebug::getLog()
+ );
+ }
+
+ /**
+ * @covers MWDebug::deprecated
+ */
+ public function testAvoidDuplicateDeprecations() {
+ MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' );
+ MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' );
+
+ // assertCount() not available on WMF integration server
+ $this->assertEquals( 1,
+ count( MWDebug::getLog() ),
+ "Only one deprecated warning per function should be kept"
+ );
+ }
+
+ /**
+ * @covers MWDebug::deprecated
+ */
+ public function testAvoidNonConsecutivesDuplicateDeprecations() {
+ MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' );
+ MWDebug::warning( 'some warning' );
+ MWDebug::log( 'we could have logged something too' );
+ // Another deprecation
+ MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' );
+
+ // assertCount() not available on WMF integration server
+ $this->assertEquals( 3,
+ count( MWDebug::getLog() ),
+ "Only one deprecated warning per function should be kept"
+ );
+ }
+
+ /**
+ * @covers MWDebug::appendDebugInfoToApiResult
+ */
+ public function testAppendDebugInfoToApiResultXmlFormat() {
+ $request = $this->newApiRequest(
+ [ 'action' => 'help', 'format' => 'xml' ],
+ '/api.php?action=help&format=xml'
+ );
+
+ $context = new RequestContext();
+ $context->setRequest( $request );
+
+ $apiMain = new ApiMain( $context );
+
+ $result = new ApiResult( $apiMain );
+
+ MWDebug::appendDebugInfoToApiResult( $context, $result );
+
+ $this->assertInstanceOf( ApiResult::class, $result );
+ $data = $result->getResultData();
+
+ $expectedKeys = [ 'mwVersion', 'phpEngine', 'phpVersion', 'gitRevision', 'gitBranch',
+ 'gitViewUrl', 'time', 'log', 'debugLog', 'queries', 'request', 'memory',
+ 'memoryPeak', 'includes', '_element' ];
+
+ foreach ( $expectedKeys as $expectedKey ) {
+ $this->assertArrayHasKey( $expectedKey, $data['debuginfo'], "debuginfo has $expectedKey" );
+ }
+
+ $xml = ApiFormatXml::recXmlPrint( 'help', $data, null );
+
+ // exception not thrown
+ $this->assertInternalType( 'string', $xml );
+ }
+
+ /**
+ * @param string[] $params
+ * @param string $requestUrl
+ *
+ * @return FauxRequest
+ */
+ private function newApiRequest( array $params, $requestUrl ) {
+ $request = $this->getMockBuilder( FauxRequest::class )
+ ->setMethods( [ 'getRequestURL' ] )
+ ->setConstructorArgs( [
+ $params
+ ] )
+ ->getMock();
+
+ $request->expects( $this->any() )
+ ->method( 'getRequestURL' )
+ ->will( $this->returnValue( $requestUrl ) );
+
+ return $request;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/debug/logger/LegacyLoggerTest.php b/www/wiki/tests/phpunit/includes/debug/logger/LegacyLoggerTest.php
new file mode 100644
index 00000000..37a28c36
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/debug/logger/LegacyLoggerTest.php
@@ -0,0 +1,175 @@
+<?php
+/**
+ * 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
+ */
+
+namespace MediaWiki\Logger;
+
+use MediaWikiTestCase;
+use Psr\Log\LogLevel;
+
+class LegacyLoggerTest extends MediaWikiTestCase {
+
+ /**
+ * @covers MediaWiki\Logger\LegacyLogger::interpolate
+ * @dataProvider provideInterpolate
+ */
+ public function testInterpolate( $message, $context, $expect ) {
+ $this->assertEquals(
+ $expect, LegacyLogger::interpolate( $message, $context ) );
+ }
+
+ public function provideInterpolate() {
+ $e = new \Exception( 'boom!' );
+ $d = new \DateTime();
+ return [
+ [
+ 'no-op',
+ [],
+ 'no-op',
+ ],
+ [
+ 'Hello {world}!',
+ [
+ 'world' => 'World',
+ ],
+ 'Hello World!',
+ ],
+ [
+ '{greeting} {user}',
+ [
+ 'greeting' => 'Goodnight',
+ 'user' => 'Moon',
+ ],
+ 'Goodnight Moon',
+ ],
+ [
+ 'Oops {key_not_set}',
+ [],
+ 'Oops {key_not_set}',
+ ],
+ [
+ '{ not interpolated }',
+ [
+ 'not interpolated' => 'This should NOT show up in the message',
+ ],
+ '{ not interpolated }',
+ ],
+ [
+ '{null}',
+ [
+ 'null' => null,
+ ],
+ '[Null]',
+ ],
+ [
+ '{bool}',
+ [
+ 'bool' => true,
+ ],
+ 'true',
+ ],
+ [
+ '{float}',
+ [
+ 'float' => 1.23,
+ ],
+ '1.23',
+ ],
+ [
+ '{array}',
+ [
+ 'array' => [ 1, 2, 3 ],
+ ],
+ '[Array(3)]',
+ ],
+ [
+ '{exception}',
+ [
+ 'exception' => $e,
+ ],
+ '[Exception ' . get_class( $e ) . '( ' .
+ $e->getFile() . ':' . $e->getLine() . ') ' .
+ $e->getMessage() . ']',
+ ],
+ [
+ '{datetime}',
+ [
+ 'datetime' => $d,
+ ],
+ $d->format( 'c' ),
+ ],
+ [
+ '{object}',
+ [
+ 'object' => new \stdClass,
+ ],
+ '[Object stdClass]',
+ ],
+ ];
+ }
+
+ /**
+ * @covers MediaWiki\Logger\LegacyLogger::shouldEmit
+ * @dataProvider provideShouldEmit
+ */
+ public function testShouldEmit( $level, $config, $expected ) {
+ $this->setMwGlobals( 'wgDebugLogGroups', [ 'fakechannel' => $config ] );
+ $this->assertEquals(
+ $expected,
+ LegacyLogger::shouldEmit( 'fakechannel', 'some message', $level, [] )
+ );
+ }
+
+ public static function provideShouldEmit() {
+ $dest = [ 'destination' => 'foobar' ];
+ $tests = [
+ [
+ LogLevel::DEBUG,
+ $dest,
+ true
+ ],
+ [
+ LogLevel::WARNING,
+ $dest + [ 'level' => LogLevel::INFO ],
+ true,
+ ],
+ [
+ LogLevel::INFO,
+ $dest + [ 'level' => LogLevel::CRITICAL ],
+ false,
+ ],
+ ];
+
+ if ( class_exists( '\Monolog\Logger' ) ) {
+ $tests[] = [
+ \Monolog\Logger::INFO,
+ $dest + [ 'level' => LogLevel::INFO ],
+ true,
+ ];
+ $tests[] = [
+ \Monolog\Logger::WARNING,
+ $dest + [ 'level' => LogLevel::EMERGENCY ],
+ false,
+ ];
+ }
+
+ return $tests;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/debug/logger/MonologSpiTest.php b/www/wiki/tests/phpunit/includes/debug/logger/MonologSpiTest.php
new file mode 100644
index 00000000..fda3ac61
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/debug/logger/MonologSpiTest.php
@@ -0,0 +1,136 @@
+<?php
+/**
+ * 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
+ */
+
+namespace MediaWiki\Logger;
+
+use MediaWikiTestCase;
+use Wikimedia\TestingAccessWrapper;
+
+class MonologSpiTest extends MediaWikiTestCase {
+
+ /**
+ * @covers MediaWiki\Logger\MonologSpi::mergeConfig
+ */
+ public function testMergeConfig() {
+ $base = [
+ 'loggers' => [
+ '@default' => [
+ 'processors' => [ 'constructor' ],
+ 'handlers' => [ 'constructor' ],
+ ],
+ ],
+ 'processors' => [
+ 'constructor' => [
+ 'class' => 'constructor',
+ ],
+ ],
+ 'handlers' => [
+ 'constructor' => [
+ 'class' => 'constructor',
+ 'formatter' => 'constructor',
+ ],
+ ],
+ 'formatters' => [
+ 'constructor' => [
+ 'class' => 'constructor',
+ ],
+ ],
+ ];
+
+ $fixture = new MonologSpi( $base );
+ $this->assertSame(
+ $base,
+ TestingAccessWrapper::newFromObject( $fixture )->config
+ );
+
+ $fixture->mergeConfig( [
+ 'loggers' => [
+ 'merged' => [
+ 'processors' => [ 'merged' ],
+ 'handlers' => [ 'merged' ],
+ ],
+ ],
+ 'processors' => [
+ 'merged' => [
+ 'class' => 'merged',
+ ],
+ ],
+ 'magic' => [
+ 'idkfa' => [ 'xyzzy' ],
+ ],
+ 'handlers' => [
+ 'merged' => [
+ 'class' => 'merged',
+ 'formatter' => 'merged',
+ ],
+ ],
+ 'formatters' => [
+ 'merged' => [
+ 'class' => 'merged',
+ ],
+ ],
+ ] );
+ $this->assertSame(
+ [
+ 'loggers' => [
+ '@default' => [
+ 'processors' => [ 'constructor' ],
+ 'handlers' => [ 'constructor' ],
+ ],
+ 'merged' => [
+ 'processors' => [ 'merged' ],
+ 'handlers' => [ 'merged' ],
+ ],
+ ],
+ 'processors' => [
+ 'constructor' => [
+ 'class' => 'constructor',
+ ],
+ 'merged' => [
+ 'class' => 'merged',
+ ],
+ ],
+ 'handlers' => [
+ 'constructor' => [
+ 'class' => 'constructor',
+ 'formatter' => 'constructor',
+ ],
+ 'merged' => [
+ 'class' => 'merged',
+ 'formatter' => 'merged',
+ ],
+ ],
+ 'formatters' => [
+ 'constructor' => [
+ 'class' => 'constructor',
+ ],
+ 'merged' => [
+ 'class' => 'merged',
+ ],
+ ],
+ 'magic' => [
+ 'idkfa' => [ 'xyzzy' ],
+ ],
+ ],
+ TestingAccessWrapper::newFromObject( $fixture )->config
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php b/www/wiki/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php
new file mode 100644
index 00000000..baa4df73
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * 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
+ */
+
+namespace MediaWiki\Logger\Monolog;
+
+use MediaWikiTestCase;
+use PHPUnit_Framework_Error_Notice;
+
+/**
+ * @covers \MediaWiki\Logger\Monolog\AvroFormatter
+ */
+class AvroFormatterTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ if ( !class_exists( 'AvroStringIO' ) ) {
+ $this->markTestSkipped( 'Avro is required for the AvroFormatterTest' );
+ }
+ parent::setUp();
+ }
+
+ public function testSchemaNotAvailable() {
+ $formatter = new AvroFormatter( [] );
+ $this->setExpectedException(
+ 'PHPUnit_Framework_Error_Notice',
+ "The schema for channel 'marty' is not available"
+ );
+ $formatter->format( [ 'channel' => 'marty' ] );
+ }
+
+ public function testSchemaNotAvailableReturnValue() {
+ $formatter = new AvroFormatter( [] );
+ $noticeEnabled = PHPUnit_Framework_Error_Notice::$enabled;
+ // disable conversion of notices
+ PHPUnit_Framework_Error_Notice::$enabled = false;
+ // have to keep the user notice from being output
+ \Wikimedia\suppressWarnings();
+ $res = $formatter->format( [ 'channel' => 'marty' ] );
+ \Wikimedia\restoreWarnings();
+ PHPUnit_Framework_Error_Notice::$enabled = $noticeEnabled;
+ $this->assertNull( $res );
+ }
+
+ public function testDoesSomethingWhenSchemaAvailable() {
+ $formatter = new AvroFormatter( [
+ 'string' => [
+ 'schema' => [ 'type' => 'string' ],
+ 'revision' => 1010101,
+ ]
+ ] );
+ $res = $formatter->format( [
+ 'channel' => 'string',
+ 'context' => 'better to be',
+ ] );
+ $this->assertNotNull( $res );
+ // basically just tell us if avro changes its string encoding, or if
+ // we completely fail to generate a log message.
+ $this->assertEquals( 'AAAAAAAAD2m1GGJldHRlciB0byBiZQ==', base64_encode( $res ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php b/www/wiki/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php
new file mode 100644
index 00000000..4c0ca04f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php
@@ -0,0 +1,227 @@
+<?php
+/**
+ * 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
+ */
+
+namespace MediaWiki\Logger\Monolog;
+
+use MediaWikiTestCase;
+use Monolog\Logger;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers \MediaWiki\Logger\Monolog\KafkaHandler
+ */
+class KafkaHandlerTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ if ( !class_exists( 'Monolog\Handler\AbstractProcessingHandler' )
+ || !class_exists( 'Kafka\Produce' )
+ ) {
+ $this->markTestSkipped( 'Monolog and Kafka are required for the KafkaHandlerTest' );
+ }
+
+ parent::setUp();
+ }
+
+ public function topicNamingProvider() {
+ return [
+ [ [], 'monolog_foo' ],
+ [ [ 'alias' => [ 'foo' => 'bar' ] ], 'bar' ]
+ ];
+ }
+
+ /**
+ * @dataProvider topicNamingProvider
+ */
+ public function testTopicNaming( $options, $expect ) {
+ $produce = $this->getMockBuilder( 'Kafka\Produce' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $produce->expects( $this->any() )
+ ->method( 'getAvailablePartitions' )
+ ->will( $this->returnValue( [ 'A' ] ) );
+ $produce->expects( $this->once() )
+ ->method( 'setMessages' )
+ ->with( $expect, $this->anything(), $this->anything() );
+ $produce->expects( $this->any() )
+ ->method( 'send' )
+ ->will( $this->returnValue( true ) );
+
+ $handler = new KafkaHandler( $produce, $options );
+ $handler->handle( [
+ 'channel' => 'foo',
+ 'level' => Logger::EMERGENCY,
+ 'extra' => [],
+ 'context' => [],
+ ] );
+ }
+
+ public function swallowsExceptionsWhenRequested() {
+ return [
+ // defaults to false
+ [ [], true ],
+ // also try false explicitly
+ [ [ 'swallowExceptions' => false ], true ],
+ // turn it on
+ [ [ 'swallowExceptions' => true ], false ],
+ ];
+ }
+
+ /**
+ * @dataProvider swallowsExceptionsWhenRequested
+ */
+ public function testGetAvailablePartitionsException( $options, $expectException ) {
+ $produce = $this->getMockBuilder( 'Kafka\Produce' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $produce->expects( $this->any() )
+ ->method( 'getAvailablePartitions' )
+ ->will( $this->throwException( new \Kafka\Exception ) );
+ $produce->expects( $this->any() )
+ ->method( 'send' )
+ ->will( $this->returnValue( true ) );
+
+ if ( $expectException ) {
+ $this->setExpectedException( 'Kafka\Exception' );
+ }
+
+ $handler = new KafkaHandler( $produce, $options );
+ $handler->handle( [
+ 'channel' => 'foo',
+ 'level' => Logger::EMERGENCY,
+ 'extra' => [],
+ 'context' => [],
+ ] );
+
+ if ( !$expectException ) {
+ $this->assertTrue( true, 'no exception was thrown' );
+ }
+ }
+
+ /**
+ * @dataProvider swallowsExceptionsWhenRequested
+ */
+ public function testSendException( $options, $expectException ) {
+ $produce = $this->getMockBuilder( 'Kafka\Produce' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $produce->expects( $this->any() )
+ ->method( 'getAvailablePartitions' )
+ ->will( $this->returnValue( [ 'A' ] ) );
+ $produce->expects( $this->any() )
+ ->method( 'send' )
+ ->will( $this->throwException( new \Kafka\Exception ) );
+
+ if ( $expectException ) {
+ $this->setExpectedException( 'Kafka\Exception' );
+ }
+
+ $handler = new KafkaHandler( $produce, $options );
+ $handler->handle( [
+ 'channel' => 'foo',
+ 'level' => Logger::EMERGENCY,
+ 'extra' => [],
+ 'context' => [],
+ ] );
+
+ if ( !$expectException ) {
+ $this->assertTrue( true, 'no exception was thrown' );
+ }
+ }
+
+ public function testHandlesNullFormatterResult() {
+ $produce = $this->getMockBuilder( 'Kafka\Produce' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $produce->expects( $this->any() )
+ ->method( 'getAvailablePartitions' )
+ ->will( $this->returnValue( [ 'A' ] ) );
+ $mockMethod = $produce->expects( $this->exactly( 2 ) )
+ ->method( 'setMessages' );
+ $produce->expects( $this->any() )
+ ->method( 'send' )
+ ->will( $this->returnValue( true ) );
+ // evil hax
+ $matcher = TestingAccessWrapper::newFromObject( $mockMethod )->matcher;
+ TestingAccessWrapper::newFromObject( $matcher )->parametersMatcher =
+ new \PHPUnit_Framework_MockObject_Matcher_ConsecutiveParameters( [
+ [ $this->anything(), $this->anything(), [ 'words' ] ],
+ [ $this->anything(), $this->anything(), [ 'lines' ] ]
+ ] );
+
+ $formatter = $this->createMock( \Monolog\Formatter\FormatterInterface::class );
+ $formatter->expects( $this->any() )
+ ->method( 'format' )
+ ->will( $this->onConsecutiveCalls( 'words', null, 'lines' ) );
+
+ $handler = new KafkaHandler( $produce, [] );
+ $handler->setFormatter( $formatter );
+ for ( $i = 0; $i < 3; ++$i ) {
+ $handler->handle( [
+ 'channel' => 'foo',
+ 'level' => Logger::EMERGENCY,
+ 'extra' => [],
+ 'context' => [],
+ ] );
+ }
+ }
+
+ public function testBatchHandlesNullFormatterResult() {
+ $produce = $this->getMockBuilder( 'Kafka\Produce' )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $produce->expects( $this->any() )
+ ->method( 'getAvailablePartitions' )
+ ->will( $this->returnValue( [ 'A' ] ) );
+ $produce->expects( $this->once() )
+ ->method( 'setMessages' )
+ ->with( $this->anything(), $this->anything(), [ 'words', 'lines' ] );
+ $produce->expects( $this->any() )
+ ->method( 'send' )
+ ->will( $this->returnValue( true ) );
+
+ $formatter = $this->createMock( \Monolog\Formatter\FormatterInterface::class );
+ $formatter->expects( $this->any() )
+ ->method( 'format' )
+ ->will( $this->onConsecutiveCalls( 'words', null, 'lines' ) );
+
+ $handler = new KafkaHandler( $produce, [] );
+ $handler->setFormatter( $formatter );
+ $handler->handleBatch( [
+ [
+ 'channel' => 'foo',
+ 'level' => Logger::EMERGENCY,
+ 'extra' => [],
+ 'context' => [],
+ ],
+ [
+ 'channel' => 'foo',
+ 'level' => Logger::EMERGENCY,
+ 'extra' => [],
+ 'context' => [],
+ ],
+ [
+ 'channel' => 'foo',
+ 'level' => Logger::EMERGENCY,
+ 'extra' => [],
+ 'context' => [],
+ ],
+ ] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php b/www/wiki/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php
new file mode 100644
index 00000000..2768d329
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php
@@ -0,0 +1,75 @@
+<?php
+/**
+ * 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
+ */
+
+namespace MediaWiki\Logger\Monolog;
+
+use InvalidArgumentException;
+use LengthException;
+use LogicException;
+use MediaWikiTestCase;
+use Wikimedia\TestingAccessWrapper;
+
+class LineFormatterTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ if ( !class_exists( 'Monolog\Formatter\LineFormatter' ) ) {
+ $this->markTestSkipped( 'This test requires monolog to be installed' );
+ }
+ parent::setUp();
+ }
+
+ /**
+ * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException
+ */
+ public function testNormalizeExceptionNoTrace() {
+ $fixture = new LineFormatter();
+ $fixture->includeStacktraces( false );
+ $fixture = TestingAccessWrapper::newFromObject( $fixture );
+ $boom = new InvalidArgumentException( 'boom', 0,
+ new LengthException( 'too long', 0,
+ new LogicException( 'Spock wuz here' )
+ )
+ );
+ $out = $fixture->normalizeException( $boom );
+ $this->assertContains( "\n[Exception InvalidArgumentException]", $out );
+ $this->assertContains( "\nCaused by: [Exception LengthException]", $out );
+ $this->assertContains( "\nCaused by: [Exception LogicException]", $out );
+ $this->assertNotContains( "\n #0", $out );
+ }
+
+ /**
+ * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException
+ */
+ public function testNormalizeExceptionTrace() {
+ $fixture = new LineFormatter();
+ $fixture->includeStacktraces( true );
+ $fixture = TestingAccessWrapper::newFromObject( $fixture );
+ $boom = new InvalidArgumentException( 'boom', 0,
+ new LengthException( 'too long', 0,
+ new LogicException( 'Spock wuz here' )
+ )
+ );
+ $out = $fixture->normalizeException( $boom );
+ $this->assertContains( "\n[Exception InvalidArgumentException]", $out );
+ $this->assertContains( "\nCaused by: [Exception LengthException]", $out );
+ $this->assertContains( "\nCaused by: [Exception LogicException]", $out );
+ $this->assertContains( "\n #0", $out );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php b/www/wiki/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php
new file mode 100644
index 00000000..1ee188e7
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace MediaWiki\Logger\Monolog;
+
+class LogstashFormatterTest extends \PHPUnit\Framework\TestCase {
+ /**
+ * @dataProvider provideV1
+ * @param array $record The input record.
+ * @param array $expected Associative array of expected keys and their values.
+ * @param array $notExpected List of keys that should not exist.
+ */
+ public function testV1( array $record, array $expected, array $notExpected ) {
+ $formatter = new LogstashFormatter( 'app', 'system', null, null, LogstashFormatter::V1 );
+ $formatted = json_decode( $formatter->format( $record ), true );
+ foreach ( $expected as $key => $value ) {
+ $this->assertArrayHasKey( $key, $formatted );
+ $this->assertSame( $value, $formatted[$key] );
+ }
+ foreach ( $notExpected as $key ) {
+ $this->assertArrayNotHasKey( $key, $formatted );
+ }
+ }
+
+ public function provideV1() {
+ return [
+ [
+ [ 'extra' => [ 'foo' => 1 ], 'context' => [ 'bar' => 2 ] ],
+ [ 'foo' => 1, 'bar' => 2 ],
+ [ 'logstash_formatter_key_conflict' ],
+ ],
+ [
+ [ 'extra' => [ 'url' => 1 ], 'context' => [ 'url' => 2 ] ],
+ [ 'url' => 1, 'c_url' => 2, 'logstash_formatter_key_conflict' => [ 'url' ] ],
+ [],
+ ],
+ [
+ [ 'channel' => 'x', 'context' => [ 'channel' => 'y' ] ],
+ [ 'channel' => 'x', 'c_channel' => 'y',
+ 'logstash_formatter_key_conflict' => [ 'channel' ] ],
+ [],
+ ],
+ ];
+ }
+
+ public function testV1WithPrefix() {
+ $formatter = new LogstashFormatter( 'app', 'system', null, 'ctx_', LogstashFormatter::V1 );
+ $record = [ 'extra' => [ 'url' => 1 ], 'context' => [ 'url' => 2 ] ];
+ $formatted = json_decode( $formatter->format( $record ), true );
+ $this->assertArrayHasKey( 'url', $formatted );
+ $this->assertSame( 1, $formatted['url'] );
+ $this->assertArrayHasKey( 'ctx_url', $formatted );
+ $this->assertSame( 2, $formatted['ctx_url'] );
+ $this->assertArrayNotHasKey( 'c_url', $formatted );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/deferred/CdnCacheUpdateTest.php b/www/wiki/tests/phpunit/includes/deferred/CdnCacheUpdateTest.php
new file mode 100644
index 00000000..f3c949d3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/deferred/CdnCacheUpdateTest.php
@@ -0,0 +1,31 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+class CdnCacheUpdateTest extends MediaWikiTestCase {
+
+ /**
+ * @covers CdnCacheUpdate::merge
+ */
+ public function testPurgeMergeWeb() {
+ $this->setMwGlobals( 'wgCommandLineMode', false );
+
+ $urls1 = [];
+ $title = Title::newMainPage();
+ $urls1[] = $title->getCanonicalURL( '?x=1' );
+ $urls1[] = $title->getCanonicalURL( '?x=2' );
+ $urls1[] = $title->getCanonicalURL( '?x=3' );
+ $update1 = new CdnCacheUpdate( $urls1 );
+ DeferredUpdates::addUpdate( $update1 );
+
+ $urls2 = [];
+ $urls2[] = $title->getCanonicalURL( '?x=2' );
+ $urls2[] = $title->getCanonicalURL( '?x=3' );
+ $urls2[] = $title->getCanonicalURL( '?x=4' );
+ $update2 = new CdnCacheUpdate( $urls2 );
+ DeferredUpdates::addUpdate( $update2 );
+
+ $wrapper = TestingAccessWrapper::newFromObject( $update1 );
+ $this->assertEquals( array_merge( $urls1, $urls2 ), $wrapper->urls );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/deferred/DeferredUpdatesTest.php b/www/wiki/tests/phpunit/includes/deferred/DeferredUpdatesTest.php
new file mode 100644
index 00000000..6b417073
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/deferred/DeferredUpdatesTest.php
@@ -0,0 +1,338 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+class DeferredUpdatesTest extends MediaWikiTestCase {
+
+ /**
+ * @covers DeferredUpdates::addUpdate
+ * @covers DeferredUpdates::push
+ * @covers DeferredUpdates::doUpdates
+ * @covers DeferredUpdates::execute
+ * @covers DeferredUpdates::runUpdate
+ */
+ public function testAddAndRun() {
+ $update = $this->getMockBuilder( DeferrableUpdate::class )
+ ->setMethods( [ 'doUpdate' ] )->getMock();
+ $update->expects( $this->once() )->method( 'doUpdate' );
+
+ DeferredUpdates::addUpdate( $update );
+ DeferredUpdates::doUpdates();
+ }
+
+ /**
+ * @covers DeferredUpdates::addUpdate
+ * @covers DeferredUpdates::push
+ */
+ public function testAddMergeable() {
+ $this->setMwGlobals( 'wgCommandLineMode', false );
+
+ $update1 = $this->getMockBuilder( MergeableUpdate::class )
+ ->setMethods( [ 'merge', 'doUpdate' ] )->getMock();
+ $update1->expects( $this->once() )->method( 'merge' );
+ $update1->expects( $this->never() )->method( 'doUpdate' );
+
+ $update2 = $this->getMockBuilder( MergeableUpdate::class )
+ ->setMethods( [ 'merge', 'doUpdate' ] )->getMock();
+ $update2->expects( $this->never() )->method( 'merge' );
+ $update2->expects( $this->never() )->method( 'doUpdate' );
+
+ DeferredUpdates::addUpdate( $update1 );
+ DeferredUpdates::addUpdate( $update2 );
+ }
+
+ /**
+ * @covers DeferredUpdates::addCallableUpdate
+ * @covers MWCallableUpdate::getOrigin
+ */
+ public function testAddCallableUpdate() {
+ $this->setMwGlobals( 'wgCommandLineMode', true );
+
+ $ran = 0;
+ DeferredUpdates::addCallableUpdate( function () use ( &$ran ) {
+ $ran++;
+ } );
+ DeferredUpdates::doUpdates();
+
+ $this->assertSame( 1, $ran, 'Update ran' );
+ }
+
+ /**
+ * @covers DeferredUpdates::getPendingUpdates
+ * @covers DeferredUpdates::clearPendingUpdates
+ */
+ public function testGetPendingUpdates() {
+ // Prevent updates from running
+ $this->setMwGlobals( 'wgCommandLineMode', false );
+
+ $pre = DeferredUpdates::PRESEND;
+ $post = DeferredUpdates::POSTSEND;
+ $all = DeferredUpdates::ALL;
+
+ $update = $this->getMock( DeferrableUpdate::class );
+ $update->expects( $this->never() )
+ ->method( 'doUpdate' );
+
+ DeferredUpdates::addUpdate( $update, $pre );
+ $this->assertCount( 1, DeferredUpdates::getPendingUpdates( $pre ) );
+ $this->assertCount( 0, DeferredUpdates::getPendingUpdates( $post ) );
+ $this->assertCount( 1, DeferredUpdates::getPendingUpdates( $all ) );
+ $this->assertCount( 1, DeferredUpdates::getPendingUpdates() );
+ DeferredUpdates::clearPendingUpdates();
+ $this->assertCount( 0, DeferredUpdates::getPendingUpdates() );
+
+ DeferredUpdates::addUpdate( $update, $post );
+ $this->assertCount( 0, DeferredUpdates::getPendingUpdates( $pre ) );
+ $this->assertCount( 1, DeferredUpdates::getPendingUpdates( $post ) );
+ $this->assertCount( 1, DeferredUpdates::getPendingUpdates( $all ) );
+ $this->assertCount( 1, DeferredUpdates::getPendingUpdates() );
+ DeferredUpdates::clearPendingUpdates();
+ $this->assertCount( 0, DeferredUpdates::getPendingUpdates() );
+ }
+
+ /**
+ * @covers DeferredUpdates::doUpdates
+ * @covers DeferredUpdates::execute
+ * @covers DeferredUpdates::addUpdate
+ */
+ public function testDoUpdatesWeb() {
+ $this->setMwGlobals( 'wgCommandLineMode', false );
+
+ $updates = [
+ '1' => "deferred update 1;\n",
+ '2' => "deferred update 2;\n",
+ '2-1' => "deferred update 1 within deferred update 2;\n",
+ '2-2' => "deferred update 2 within deferred update 2;\n",
+ '3' => "deferred update 3;\n",
+ '3-1' => "deferred update 1 within deferred update 3;\n",
+ '3-2' => "deferred update 2 within deferred update 3;\n",
+ '3-1-1' => "deferred update 1 within deferred update 1 within deferred update 3;\n",
+ '3-2-1' => "deferred update 1 within deferred update 2 with deferred update 3;\n",
+ ];
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['1'];
+ }
+ );
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['2'];
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['2-1'];
+ }
+ );
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['2-2'];
+ }
+ );
+ }
+ );
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['3'];
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['3-1'];
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['3-1-1'];
+ }
+ );
+ }
+ );
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['3-2'];
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['3-2-1'];
+ }
+ );
+ }
+ );
+ }
+ );
+
+ $this->assertEquals( 3, DeferredUpdates::pendingUpdatesCount() );
+
+ $this->expectOutputString( implode( '', $updates ) );
+
+ DeferredUpdates::doUpdates();
+
+ $x = null;
+ $y = null;
+ DeferredUpdates::addCallableUpdate(
+ function () use ( &$x ) {
+ $x = 'Sherity';
+ },
+ DeferredUpdates::PRESEND
+ );
+ DeferredUpdates::addCallableUpdate(
+ function () use ( &$y ) {
+ $y = 'Marychu';
+ },
+ DeferredUpdates::POSTSEND
+ );
+
+ $this->assertNull( $x, "Update not run yet" );
+ $this->assertNull( $y, "Update not run yet" );
+
+ DeferredUpdates::doUpdates( 'run', DeferredUpdates::PRESEND );
+ $this->assertEquals( "Sherity", $x, "PRESEND update ran" );
+ $this->assertNull( $y, "POSTSEND update not run yet" );
+
+ DeferredUpdates::doUpdates( 'run', DeferredUpdates::POSTSEND );
+ $this->assertEquals( "Marychu", $y, "POSTSEND update ran" );
+ }
+
+ /**
+ * @covers DeferredUpdates::doUpdates
+ * @covers DeferredUpdates::execute
+ * @covers DeferredUpdates::addUpdate
+ */
+ public function testDoUpdatesCLI() {
+ $this->setMwGlobals( 'wgCommandLineMode', true );
+ $updates = [
+ '1' => "deferred update 1;\n",
+ '2' => "deferred update 2;\n",
+ '2-1' => "deferred update 1 within deferred update 2;\n",
+ '2-2' => "deferred update 2 within deferred update 2;\n",
+ '3' => "deferred update 3;\n",
+ '3-1' => "deferred update 1 within deferred update 3;\n",
+ '3-2' => "deferred update 2 within deferred update 3;\n",
+ '3-1-1' => "deferred update 1 within deferred update 1 within deferred update 3;\n",
+ '3-2-1' => "deferred update 1 within deferred update 2 with deferred update 3;\n",
+ ];
+
+ // clear anything
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lbFactory->commitMasterChanges( __METHOD__ );
+
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['1'];
+ }
+ );
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['2'];
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['2-1'];
+ }
+ );
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['2-2'];
+ }
+ );
+ }
+ );
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['3'];
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['3-1'];
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['3-1-1'];
+ }
+ );
+ }
+ );
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['3-2'];
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $updates ) {
+ echo $updates['3-2-1'];
+ }
+ );
+ }
+ );
+ }
+ );
+
+ $this->expectOutputString( implode( '', $updates ) );
+
+ DeferredUpdates::doUpdates();
+ }
+
+ /**
+ * @covers DeferredUpdates::doUpdates
+ * @covers DeferredUpdates::execute
+ * @covers DeferredUpdates::addUpdate
+ */
+ public function testPresendAddOnPostsendRun() {
+ $this->setMwGlobals( 'wgCommandLineMode', true );
+
+ $x = false;
+ $y = false;
+ // clear anything
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lbFactory->commitMasterChanges( __METHOD__ );
+
+ DeferredUpdates::addCallableUpdate(
+ function () use ( &$x, &$y ) {
+ $x = true;
+ DeferredUpdates::addCallableUpdate(
+ function () use ( &$y ) {
+ $y = true;
+ },
+ DeferredUpdates::PRESEND
+ );
+ },
+ DeferredUpdates::POSTSEND
+ );
+
+ DeferredUpdates::doUpdates();
+
+ $this->assertTrue( $x, "Outer POSTSEND update ran" );
+ $this->assertTrue( $y, "Nested PRESEND update ran" );
+ }
+
+ /**
+ * @covers DeferredUpdates::runUpdate
+ */
+ public function testRunUpdateTransactionScope() {
+ $this->setMwGlobals( 'wgCommandLineMode', false );
+
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $this->assertFalse( $lbFactory->hasTransactionRound(), 'Initial state' );
+
+ $ran = 0;
+ DeferredUpdates::addCallableUpdate( function () use ( &$ran, $lbFactory ) {
+ $ran++;
+ $this->assertTrue( $lbFactory->hasTransactionRound(), 'Has transaction' );
+ } );
+ DeferredUpdates::doUpdates();
+
+ $this->assertSame( 1, $ran, 'Update ran' );
+ $this->assertFalse( $lbFactory->hasTransactionRound(), 'Final state' );
+ }
+
+ /**
+ * @covers DeferredUpdates::runUpdate
+ * @covers TransactionRoundDefiningUpdate::getOrigin
+ */
+ public function testRunOuterScopeUpdate() {
+ $this->setMwGlobals( 'wgCommandLineMode', false );
+
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $this->assertFalse( $lbFactory->hasTransactionRound(), 'Initial state' );
+
+ $ran = 0;
+ DeferredUpdates::addUpdate( new TransactionRoundDefiningUpdate(
+ function () use ( &$ran, $lbFactory ) {
+ $ran++;
+ $this->assertFalse( $lbFactory->hasTransactionRound(), 'No transaction' );
+ } )
+ );
+ DeferredUpdates::doUpdates();
+
+ $this->assertSame( 1, $ran, 'Update ran' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/deferred/LinksUpdateTest.php b/www/wiki/tests/phpunit/includes/deferred/LinksUpdateTest.php
new file mode 100644
index 00000000..ddc0798f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/deferred/LinksUpdateTest.php
@@ -0,0 +1,422 @@
+<?php
+
+/**
+ * @covers LinksUpdate
+ * @group LinksUpdate
+ * @group Database
+ * ^--- make sure temporary tables are used.
+ */
+class LinksUpdateTest extends MediaWikiLangTestCase {
+ protected static $testingPageId;
+
+ function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+
+ $this->tablesUsed = array_merge( $this->tablesUsed,
+ [
+ 'interwiki',
+ 'page_props',
+ 'pagelinks',
+ 'categorylinks',
+ 'langlinks',
+ 'externallinks',
+ 'imagelinks',
+ 'templatelinks',
+ 'iwlinks',
+ 'recentchanges',
+ ]
+ );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->replace(
+ 'interwiki',
+ [ 'iw_prefix' ],
+ [
+ 'iw_prefix' => 'linksupdatetest',
+ 'iw_url' => 'http://testing.com/wiki/$1',
+ 'iw_api' => 'http://testing.com/w/api.php',
+ 'iw_local' => 0,
+ 'iw_trans' => 0,
+ 'iw_wikiid' => 'linksupdatetest',
+ ]
+ );
+ $this->setMwGlobals( 'wgRCWatchCategoryMembership', true );
+ }
+
+ public function addDBDataOnce() {
+ $res = $this->insertPage( 'Testing' );
+ self::$testingPageId = $res['id'];
+ $this->insertPage( 'Some_other_page' );
+ $this->insertPage( 'Template:TestingTemplate' );
+ }
+
+ protected function makeTitleAndParserOutput( $name, $id ) {
+ $t = Title::newFromText( $name );
+ $t->mArticleID = $id; # XXX: this is fugly
+
+ $po = new ParserOutput();
+ $po->setTitleText( $t->getPrefixedText() );
+
+ return [ $t, $po ];
+ }
+
+ /**
+ * @covers ParserOutput::addLink
+ */
+ public function testUpdate_pagelinks() {
+ /** @var Title $t */
+ /** @var ParserOutput $po */
+ list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
+
+ $po->addLink( Title::newFromText( "Foo" ) );
+ $po->addLink( Title::newFromText( "Special:Foo" ) ); // special namespace should be ignored
+ $po->addLink( Title::newFromText( "linksupdatetest:Foo" ) ); // interwiki link should be ignored
+ $po->addLink( Title::newFromText( "#Foo" ) ); // hash link should be ignored
+
+ $update = $this->assertLinksUpdate(
+ $t,
+ $po,
+ 'pagelinks',
+ 'pl_namespace,
+ pl_title',
+ 'pl_from = ' . self::$testingPageId,
+ [ [ NS_MAIN, 'Foo' ] ]
+ );
+ $this->assertArrayEquals( [
+ Title::makeTitle( NS_MAIN, 'Foo' ), // newFromText doesn't yield the same internal state....
+ ], $update->getAddedLinks() );
+
+ $po = new ParserOutput();
+ $po->setTitleText( $t->getPrefixedText() );
+
+ $po->addLink( Title::newFromText( "Bar" ) );
+ $po->addLink( Title::newFromText( "Talk:Bar" ) );
+
+ $update = $this->assertLinksUpdate(
+ $t,
+ $po,
+ 'pagelinks',
+ 'pl_namespace,
+ pl_title',
+ 'pl_from = ' . self::$testingPageId,
+ [
+ [ NS_MAIN, 'Bar' ],
+ [ NS_TALK, 'Bar' ],
+ ]
+ );
+ $this->assertArrayEquals( [
+ Title::makeTitle( NS_MAIN, 'Bar' ),
+ Title::makeTitle( NS_TALK, 'Bar' ),
+ ], $update->getAddedLinks() );
+ $this->assertArrayEquals( [
+ Title::makeTitle( NS_MAIN, 'Foo' ),
+ ], $update->getRemovedLinks() );
+ }
+
+ /**
+ * @covers ParserOutput::addExternalLink
+ */
+ public function testUpdate_externallinks() {
+ /** @var ParserOutput $po */
+ list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
+
+ $po->addExternalLink( "http://testing.com/wiki/Foo" );
+
+ $this->assertLinksUpdate(
+ $t,
+ $po,
+ 'externallinks',
+ 'el_to, el_index',
+ 'el_from = ' . self::$testingPageId,
+ [
+ [ 'http://testing.com/wiki/Foo', 'http://com.testing./wiki/Foo' ],
+ ]
+ );
+ }
+
+ /**
+ * @covers ParserOutput::addCategory
+ */
+ public function testUpdate_categorylinks() {
+ /** @var ParserOutput $po */
+ $this->setMwGlobals( 'wgCategoryCollation', 'uppercase' );
+
+ list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
+
+ $po->addCategory( "Foo", "FOO" );
+
+ $this->assertLinksUpdate(
+ $t,
+ $po,
+ 'categorylinks',
+ 'cl_to, cl_sortkey',
+ 'cl_from = ' . self::$testingPageId,
+ [ [ 'Foo', "FOO\nTESTING" ] ]
+ );
+ }
+
+ public function testOnAddingAndRemovingCategory_recentChangesRowIsAdded() {
+ $this->setMwGlobals( 'wgCategoryCollation', 'uppercase' );
+
+ $title = Title::newFromText( 'Testing' );
+ $wikiPage = new WikiPage( $title );
+ $wikiPage->doEditContent( new WikitextContent( '[[Category:Foo]]' ), 'added category' );
+ $this->runAllRelatedJobs();
+
+ $this->assertRecentChangeByCategorization(
+ $title,
+ $wikiPage->getParserOutput( ParserOptions::newCanonical() ),
+ Title::newFromText( 'Category:Foo' ),
+ [ [ 'Foo', '[[:Testing]] added to category' ] ]
+ );
+
+ $wikiPage->doEditContent( new WikitextContent( '[[Category:Bar]]' ), 'replaced category' );
+ $this->runAllRelatedJobs();
+
+ $this->assertRecentChangeByCategorization(
+ $title,
+ $wikiPage->getParserOutput( ParserOptions::newCanonical() ),
+ Title::newFromText( 'Category:Foo' ),
+ [
+ [ 'Foo', '[[:Testing]] added to category' ],
+ [ 'Foo', '[[:Testing]] removed from category' ],
+ ]
+ );
+
+ $this->assertRecentChangeByCategorization(
+ $title,
+ $wikiPage->getParserOutput( ParserOptions::newCanonical() ),
+ Title::newFromText( 'Category:Bar' ),
+ [
+ [ 'Bar', '[[:Testing]] added to category' ],
+ ]
+ );
+ }
+
+ public function testOnAddingAndRemovingCategoryToTemplates_embeddingPagesAreIgnored() {
+ $this->setMwGlobals( 'wgCategoryCollation', 'uppercase' );
+
+ $templateTitle = Title::newFromText( 'Template:TestingTemplate' );
+ $templatePage = new WikiPage( $templateTitle );
+
+ $wikiPage = new WikiPage( Title::newFromText( 'Testing' ) );
+ $wikiPage->doEditContent( new WikitextContent( '{{TestingTemplate}}' ), 'added template' );
+ $this->runAllRelatedJobs();
+
+ $otherWikiPage = new WikiPage( Title::newFromText( 'Some_other_page' ) );
+ $otherWikiPage->doEditContent( new WikitextContent( '{{TestingTemplate}}' ), 'added template' );
+ $this->runAllRelatedJobs();
+
+ $this->assertRecentChangeByCategorization(
+ $templateTitle,
+ $templatePage->getParserOutput( ParserOptions::newCanonical() ),
+ Title::newFromText( 'Baz' ),
+ []
+ );
+
+ $templatePage->doEditContent( new WikitextContent( '[[Category:Baz]]' ), 'added category' );
+ $this->runAllRelatedJobs();
+
+ $this->assertRecentChangeByCategorization(
+ $templateTitle,
+ $templatePage->getParserOutput( ParserOptions::newCanonical() ),
+ Title::newFromText( 'Baz' ),
+ [ [
+ 'Baz',
+ '[[:Template:TestingTemplate]] added to category, ' .
+ '[[Special:WhatLinksHere/Template:TestingTemplate|this page is included within other pages]]'
+ ] ]
+ );
+ }
+
+ /**
+ * @covers ParserOutput::addInterwikiLink
+ */
+ public function testUpdate_iwlinks() {
+ /** @var ParserOutput $po */
+ list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
+
+ $target = Title::makeTitleSafe( NS_MAIN, "Foo", '', 'linksupdatetest' );
+ $po->addInterwikiLink( $target );
+
+ $this->assertLinksUpdate(
+ $t,
+ $po,
+ 'iwlinks',
+ 'iwl_prefix, iwl_title',
+ 'iwl_from = ' . self::$testingPageId,
+ [ [ 'linksupdatetest', 'Foo' ] ]
+ );
+ }
+
+ /**
+ * @covers ParserOutput::addTemplate
+ */
+ public function testUpdate_templatelinks() {
+ /** @var ParserOutput $po */
+ list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
+
+ $po->addTemplate( Title::newFromText( "Template:Foo" ), 23, 42 );
+
+ $this->assertLinksUpdate(
+ $t,
+ $po,
+ 'templatelinks',
+ 'tl_namespace,
+ tl_title',
+ 'tl_from = ' . self::$testingPageId,
+ [ [ NS_TEMPLATE, 'Foo' ] ]
+ );
+ }
+
+ /**
+ * @covers ParserOutput::addImage
+ */
+ public function testUpdate_imagelinks() {
+ /** @var ParserOutput $po */
+ list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
+
+ $po->addImage( "Foo.png" );
+
+ $this->assertLinksUpdate(
+ $t,
+ $po,
+ 'imagelinks',
+ 'il_to',
+ 'il_from = ' . self::$testingPageId,
+ [ [ 'Foo.png' ] ]
+ );
+ }
+
+ /**
+ * @covers ParserOutput::addLanguageLink
+ */
+ public function testUpdate_langlinks() {
+ $this->setMwGlobals( [
+ 'wgCapitalLinks' => true,
+ ] );
+
+ /** @var ParserOutput $po */
+ list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
+
+ $po->addLanguageLink( Title::newFromText( "en:Foo" )->getFullText() );
+
+ $this->assertLinksUpdate(
+ $t,
+ $po,
+ 'langlinks',
+ 'll_lang, ll_title',
+ 'll_from = ' . self::$testingPageId,
+ [ [ 'En', 'Foo' ] ]
+ );
+ }
+
+ /**
+ * @covers ParserOutput::setProperty
+ */
+ public function testUpdate_page_props() {
+ global $wgPagePropsHaveSortkey;
+
+ /** @var ParserOutput $po */
+ list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
+
+ $fields = [ 'pp_propname', 'pp_value' ];
+ $expected = [];
+
+ $po->setProperty( "bool", true );
+ $expected[] = [ "bool", true ];
+
+ $po->setProperty( "float", 4.0 + 1.0 / 4.0 );
+ $expected[] = [ "float", 4.0 + 1.0 / 4.0 ];
+
+ $po->setProperty( "int", -7 );
+ $expected[] = [ "int", -7 ];
+
+ $po->setProperty( "string", "33 bar" );
+ $expected[] = [ "string", "33 bar" ];
+
+ // compute expected sortkey values
+ if ( $wgPagePropsHaveSortkey ) {
+ $fields[] = 'pp_sortkey';
+
+ foreach ( $expected as &$row ) {
+ $value = $row[1];
+
+ if ( is_int( $value ) || is_float( $value ) || is_bool( $value ) ) {
+ $row[] = floatval( $value );
+ } else {
+ $row[] = null;
+ }
+ }
+ }
+
+ $this->assertLinksUpdate(
+ $t, $po, 'page_props', $fields, 'pp_page = ' . self::$testingPageId, $expected );
+ }
+
+ public function testUpdate_page_props_without_sortkey() {
+ $this->setMwGlobals( 'wgPagePropsHaveSortkey', false );
+
+ $this->testUpdate_page_props();
+ }
+
+ // @todo test recursive, too!
+
+ protected function assertLinksUpdate( Title $title, ParserOutput $parserOutput,
+ $table, $fields, $condition, array $expectedRows
+ ) {
+ $update = new LinksUpdate( $title, $parserOutput );
+
+ $update->doUpdate();
+
+ $this->assertSelect( $table, $fields, $condition, $expectedRows );
+ return $update;
+ }
+
+ protected function assertRecentChangeByCategorization(
+ Title $pageTitle, ParserOutput $parserOutput, Title $categoryTitle, $expectedRows
+ ) {
+ global $wgCommentTableSchemaMigrationStage;
+
+ if ( $wgCommentTableSchemaMigrationStage <= MIGRATION_WRITE_BOTH ) {
+ $this->assertSelect(
+ 'recentchanges',
+ 'rc_title, rc_comment',
+ [
+ 'rc_type' => RC_CATEGORIZE,
+ 'rc_namespace' => NS_CATEGORY,
+ 'rc_title' => $categoryTitle->getDBkey()
+ ],
+ $expectedRows
+ );
+ }
+ if ( $wgCommentTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ $this->assertSelect(
+ [ 'recentchanges', 'comment' ],
+ 'rc_title, comment_text',
+ [
+ 'rc_type' => RC_CATEGORIZE,
+ 'rc_namespace' => NS_CATEGORY,
+ 'rc_title' => $categoryTitle->getDBkey(),
+ 'comment_id = rc_comment_id',
+ ],
+ $expectedRows
+ );
+ }
+ }
+
+ private function runAllRelatedJobs() {
+ $queueGroup = JobQueueGroup::singleton();
+ while ( $job = $queueGroup->pop( 'refreshLinksPrioritized' ) ) {
+ $job->run();
+ $queueGroup->ack( $job );
+ }
+ while ( $job = $queueGroup->pop( 'categoryMembershipChange' ) ) {
+ $job->run();
+ $queueGroup->ack( $job );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/deferred/MWCallableUpdateTest.php b/www/wiki/tests/phpunit/includes/deferred/MWCallableUpdateTest.php
new file mode 100644
index 00000000..3ab9b565
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/deferred/MWCallableUpdateTest.php
@@ -0,0 +1,82 @@
+<?php
+
+/**
+ * @covers MWCallableUpdate
+ */
+class MWCallableUpdateTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function testDoUpdate() {
+ $ran = 0;
+ $update = new MWCallableUpdate( function () use ( &$ran ) {
+ $ran++;
+ } );
+ $this->assertSame( 0, $ran );
+ $update->doUpdate();
+ $this->assertSame( 1, $ran );
+ }
+
+ public function testCancel() {
+ // Prepare update and DB
+ $db = new DatabaseTestHelper( __METHOD__ );
+ $db->begin( __METHOD__ );
+ $ran = 0;
+ $update = new MWCallableUpdate( function () use ( &$ran ) {
+ $ran++;
+ }, __METHOD__, $db );
+
+ // Emulate rollback
+ $db->rollback( __METHOD__ );
+
+ $update->doUpdate();
+
+ // Ensure it was cancelled
+ $this->assertSame( 0, $ran );
+ }
+
+ public function testCancelSome() {
+ // Prepare update and DB
+ $db1 = new DatabaseTestHelper( __METHOD__ );
+ $db1->begin( __METHOD__ );
+ $db2 = new DatabaseTestHelper( __METHOD__ );
+ $db2->begin( __METHOD__ );
+ $ran = 0;
+ $update = new MWCallableUpdate( function () use ( &$ran ) {
+ $ran++;
+ }, __METHOD__, [ $db1, $db2 ] );
+
+ // Emulate rollback
+ $db1->rollback( __METHOD__ );
+
+ $update->doUpdate();
+
+ // Prevents: "Notice: DB transaction writes or callbacks still pending"
+ $db2->rollback( __METHOD__ );
+
+ // Ensure it was cancelled
+ $this->assertSame( 0, $ran );
+ }
+
+ public function testCancelAll() {
+ // Prepare update and DB
+ $db1 = new DatabaseTestHelper( __METHOD__ );
+ $db1->begin( __METHOD__ );
+ $db2 = new DatabaseTestHelper( __METHOD__ );
+ $db2->begin( __METHOD__ );
+ $ran = 0;
+ $update = new MWCallableUpdate( function () use ( &$ran ) {
+ $ran++;
+ }, __METHOD__, [ $db1, $db2 ] );
+
+ // Emulate rollbacks
+ $db1->rollback( __METHOD__ );
+ $db2->rollback( __METHOD__ );
+
+ $update->doUpdate();
+
+ // Ensure it was cancelled
+ $this->assertSame( 0, $ran );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/deferred/SearchUpdateTest.php b/www/wiki/tests/phpunit/includes/deferred/SearchUpdateTest.php
new file mode 100644
index 00000000..9e4dbea2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/deferred/SearchUpdateTest.php
@@ -0,0 +1,87 @@
+<?php
+
+class MockSearch extends SearchEngine {
+ public static $id;
+ public static $title;
+ public static $text;
+
+ public function __construct( $db ) {
+ }
+
+ public function update( $id, $title, $text ) {
+ self::$id = $id;
+ self::$title = $title;
+ self::$text = $text;
+ }
+}
+
+/**
+ * @group Search
+ */
+class SearchUpdateTest extends MediaWikiTestCase {
+
+ /**
+ * @var SearchUpdate
+ */
+ private $su;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( 'wgSearchType', 'MockSearch' );
+ $this->su = new SearchUpdate( 0, "" );
+ }
+
+ public function updateText( $text ) {
+ return trim( $this->su->updateText( $text ) );
+ }
+
+ /**
+ * @covers SearchUpdate::updateText
+ */
+ public function testUpdateText() {
+ $this->assertEquals(
+ 'test',
+ $this->updateText( '<div>TeSt</div>' ),
+ 'HTML stripped, text lowercased'
+ );
+
+ $this->assertEquals(
+ 'foo bar boz quux',
+ $this->updateText( <<<EOT
+<table style="color:red; font-size:100px">
+ <tr class="scary"><td><div>foo</div></td><tr>bar</td></tr>
+ <tr><td>boz</td><tr>quux</td></tr>
+</table>
+EOT
+ ), 'Stripping HTML tables' );
+
+ $this->assertEquals(
+ 'a b',
+ $this->updateText( 'a > b' ),
+ 'Handle unclosed tags'
+ );
+
+ $text = str_pad( "foo <barbarbar \n", 10000, 'x' );
+
+ $this->assertNotEquals(
+ '',
+ $this->updateText( $text ),
+ 'T20609'
+ );
+ }
+
+ /**
+ * @covers SearchUpdate::updateText
+ * Test T34712
+ * Test if unicode quotes in article links make its search index empty
+ */
+ public function testUnicodeLinkSearchIndexError() {
+ $text = "text „http://example.com“ text";
+ $result = $this->updateText( $text );
+ $processed = preg_replace( '/Q/u', 'Q', $result );
+ $this->assertTrue(
+ $processed != '',
+ 'Link surrounded by unicode quotes should not fail UTF-8 validation'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/deferred/SiteStatsUpdateTest.php b/www/wiki/tests/phpunit/includes/deferred/SiteStatsUpdateTest.php
new file mode 100644
index 00000000..83e9a47c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/deferred/SiteStatsUpdateTest.php
@@ -0,0 +1,77 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Database
+ */
+class SiteStatsUpdateTest extends MediaWikiTestCase {
+ /**
+ * @covers SiteStatsUpdate::factory
+ * @covers SiteStatsUpdate::merge
+ */
+ public function testFactoryAndMerge() {
+ $update1 = SiteStatsUpdate::factory( [ 'pages' => 1, 'users' => 2 ] );
+ $update2 = SiteStatsUpdate::factory( [ 'users' => 1, 'images' => 1 ] );
+
+ $update1->merge( $update2 );
+ $wrapped = TestingAccessWrapper::newFromObject( $update1 );
+
+ $this->assertEquals( 1, $wrapped->pages );
+ $this->assertEquals( 3, $wrapped->users );
+ $this->assertEquals( 1, $wrapped->images );
+ $this->assertEquals( 0, $wrapped->edits );
+ $this->assertEquals( 0, $wrapped->articles );
+ }
+
+ /**
+ * @covers SiteStatsUpdate::doUpdate()
+ * @covers SiteStatsInit::refresh()
+ */
+ public function testDoUpdate() {
+ $this->setMwGlobals( 'wgSiteStatsAsyncFactor', false );
+ $this->setMwGlobals( 'wgCommandLineMode', false ); // disable opportunistic updates
+
+ $dbw = wfGetDB( DB_MASTER );
+ $statsInit = new SiteStatsInit( $dbw );
+ $statsInit->refresh();
+
+ $ei = SiteStats::edits(); // trigger load
+ $pi = SiteStats::pages();
+ $ui = SiteStats::users();
+ $fi = SiteStats::images();
+ $ai = SiteStats::articles();
+
+ $dbw->begin( __METHOD__ ); // block opportunistic updates
+
+ $update = SiteStatsUpdate::factory( [ 'pages' => 2, 'images' => 1, 'edits' => 2 ] );
+ $this->assertEquals( 0, DeferredUpdates::pendingUpdatesCount() );
+ $update->doUpdate();
+ $this->assertEquals( 1, DeferredUpdates::pendingUpdatesCount() );
+
+ // Still the same
+ SiteStats::unload();
+ $this->assertEquals( $pi, SiteStats::pages(), 'page count' );
+ $this->assertEquals( $ei, SiteStats::edits(), 'edit count' );
+ $this->assertEquals( $ui, SiteStats::users(), 'user count' );
+ $this->assertEquals( $fi, SiteStats::images(), 'file count' );
+ $this->assertEquals( $ai, SiteStats::articles(), 'article count' );
+ $this->assertEquals( 1, DeferredUpdates::pendingUpdatesCount() );
+
+ $dbw->commit( __METHOD__ );
+
+ $this->assertEquals( 1, DeferredUpdates::pendingUpdatesCount() );
+ DeferredUpdates::doUpdates();
+ $this->assertEquals( 0, DeferredUpdates::pendingUpdatesCount() );
+
+ SiteStats::unload();
+ $this->assertEquals( $pi + 2, SiteStats::pages(), 'page count' );
+ $this->assertEquals( $ei + 2, SiteStats::edits(), 'edit count' );
+ $this->assertEquals( $ui, SiteStats::users(), 'user count' );
+ $this->assertEquals( $fi + 1, SiteStats::images(), 'file count' );
+ $this->assertEquals( $ai, SiteStats::articles(), 'article count' );
+
+ $statsInit = new SiteStatsInit();
+ $statsInit->refresh();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/deferred/TransactionRoundDefiningUpdateTest.php b/www/wiki/tests/phpunit/includes/deferred/TransactionRoundDefiningUpdateTest.php
new file mode 100644
index 00000000..693897e6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/deferred/TransactionRoundDefiningUpdateTest.php
@@ -0,0 +1,19 @@
+<?php
+
+/**
+ * @covers TransactionRoundDefiningUpdate
+ */
+class TransactionRoundDefiningUpdateTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function testDoUpdate() {
+ $ran = 0;
+ $update = new TransactionRoundDefiningUpdate( function () use ( &$ran ) {
+ $ran++;
+ } );
+ $this->assertSame( 0, $ran );
+ $update->doUpdate();
+ $this->assertSame( 1, $ran );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php b/www/wiki/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php
new file mode 100644
index 00000000..8d94404c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php
@@ -0,0 +1,134 @@
+<?php
+
+/**
+ * @author Addshore
+ *
+ * @group Diff
+ */
+class ArrayDiffFormatterTest extends MediaWikiTestCase {
+
+ /**
+ * @param Diff $input
+ * @param array $expectedOutput
+ * @dataProvider provideTestFormat
+ * @covers ArrayDiffFormatter::format
+ */
+ public function testFormat( $input, $expectedOutput ) {
+ $instance = new ArrayDiffFormatter();
+ $output = $instance->format( $input );
+ $this->assertEquals( $expectedOutput, $output );
+ }
+
+ private function getMockDiff( $edits ) {
+ $diff = $this->getMockBuilder( Diff::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $diff->expects( $this->any() )
+ ->method( 'getEdits' )
+ ->will( $this->returnValue( $edits ) );
+ return $diff;
+ }
+
+ private function getMockDiffOp( $type = null, $orig = [], $closing = [] ) {
+ $diffOp = $this->getMockBuilder( DiffOp::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $diffOp->expects( $this->any() )
+ ->method( 'getType' )
+ ->will( $this->returnValue( $type ) );
+ $diffOp->expects( $this->any() )
+ ->method( 'getOrig' )
+ ->will( $this->returnValue( $orig ) );
+ if ( $type === 'change' ) {
+ $diffOp->expects( $this->any() )
+ ->method( 'getClosing' )
+ ->with( $this->isType( 'integer' ) )
+ ->will( $this->returnCallback( function () {
+ return 'mockLine';
+ } ) );
+ } else {
+ $diffOp->expects( $this->any() )
+ ->method( 'getClosing' )
+ ->will( $this->returnValue( $closing ) );
+ }
+ return $diffOp;
+ }
+
+ public function provideTestFormat() {
+ $emptyArrayTestCases = [
+ $this->getMockDiff( [] ),
+ $this->getMockDiff( [ $this->getMockDiffOp( 'add' ) ] ),
+ $this->getMockDiff( [ $this->getMockDiffOp( 'delete' ) ] ),
+ $this->getMockDiff( [ $this->getMockDiffOp( 'change' ) ] ),
+ $this->getMockDiff( [ $this->getMockDiffOp( 'copy' ) ] ),
+ $this->getMockDiff( [ $this->getMockDiffOp( 'FOOBARBAZ' ) ] ),
+ $this->getMockDiff( [ $this->getMockDiffOp( 'add', 'line' ) ] ),
+ $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [], [ 'line' ] ) ] ),
+ $this->getMockDiff( [ $this->getMockDiffOp( 'copy', [], [ 'line' ] ) ] ),
+ ];
+
+ $otherTestCases = [];
+ $otherTestCases[] = [
+ $this->getMockDiff( [ $this->getMockDiffOp( 'add', [], [ 'a1' ] ) ] ),
+ [ [ 'action' => 'add', 'new' => 'a1', 'newline' => 1 ] ],
+ ];
+ $otherTestCases[] = [
+ $this->getMockDiff( [ $this->getMockDiffOp( 'add', [], [ 'a1', 'a2' ] ) ] ),
+ [
+ [ 'action' => 'add', 'new' => 'a1', 'newline' => 1 ],
+ [ 'action' => 'add', 'new' => 'a2', 'newline' => 2 ],
+ ],
+ ];
+ $otherTestCases[] = [
+ $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [ 'd1' ] ) ] ),
+ [ [ 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ] ],
+ ];
+ $otherTestCases[] = [
+ $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [ 'd1', 'd2' ] ) ] ),
+ [
+ [ 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ],
+ [ 'action' => 'delete', 'old' => 'd2', 'oldline' => 2 ],
+ ],
+ ];
+ $otherTestCases[] = [
+ $this->getMockDiff( [ $this->getMockDiffOp( 'change', [ 'd1' ], [ 'a1' ] ) ] ),
+ [ [
+ 'action' => 'change',
+ 'old' => 'd1',
+ 'new' => 'mockLine',
+ 'newline' => 1, 'oldline' => 1
+ ] ],
+ ];
+ $otherTestCases[] = [
+ $this->getMockDiff( [ $this->getMockDiffOp(
+ 'change',
+ [ 'd1', 'd2' ],
+ [ 'a1', 'a2' ]
+ ) ] ),
+ [
+ [
+ 'action' => 'change',
+ 'old' => 'd1',
+ 'new' => 'mockLine',
+ 'newline' => 1, 'oldline' => 1
+ ],
+ [
+ 'action' => 'change',
+ 'old' => 'd2',
+ 'new' => 'mockLine',
+ 'newline' => 2, 'oldline' => 2
+ ],
+ ],
+ ];
+
+ $testCases = [];
+ foreach ( $emptyArrayTestCases as $testCase ) {
+ $testCases[] = [ $testCase, [] ];
+ }
+ foreach ( $otherTestCases as $testCase ) {
+ $testCases[] = [ $testCase[0], $testCase[1] ];
+ }
+ return $testCases;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/diff/DiffOpTest.php b/www/wiki/tests/phpunit/includes/diff/DiffOpTest.php
new file mode 100644
index 00000000..3026fad6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/diff/DiffOpTest.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * @author Addshore
+ *
+ * @group Diff
+ */
+class DiffOpTest extends MediaWikiTestCase {
+
+ /**
+ * @covers DiffOp::getType
+ */
+ public function testGetType() {
+ $obj = new FakeDiffOp();
+ $obj->type = 'foo';
+ $this->assertEquals( 'foo', $obj->getType() );
+ }
+
+ /**
+ * @covers DiffOp::getOrig
+ */
+ public function testGetOrig() {
+ $obj = new FakeDiffOp();
+ $obj->orig = [ 'foo' ];
+ $this->assertEquals( [ 'foo' ], $obj->getOrig() );
+ }
+
+ /**
+ * @covers DiffOp::getClosing
+ */
+ public function testGetClosing() {
+ $obj = new FakeDiffOp();
+ $obj->closing = [ 'foo' ];
+ $this->assertEquals( [ 'foo' ], $obj->getClosing() );
+ }
+
+ /**
+ * @covers DiffOp::getClosing
+ */
+ public function testGetClosingWithParameter() {
+ $obj = new FakeDiffOp();
+ $obj->closing = [ 'foo', 'bar', 'baz' ];
+ $this->assertEquals( 'foo', $obj->getClosing( 0 ) );
+ $this->assertEquals( 'bar', $obj->getClosing( 1 ) );
+ $this->assertEquals( 'baz', $obj->getClosing( 2 ) );
+ $this->assertEquals( null, $obj->getClosing( 3 ) );
+ }
+
+ /**
+ * @covers DiffOp::norig
+ */
+ public function testNorig() {
+ $obj = new FakeDiffOp();
+ $this->assertEquals( 0, $obj->norig() );
+ $obj->orig = [ 'foo' ];
+ $this->assertEquals( 1, $obj->norig() );
+ }
+
+ /**
+ * @covers DiffOp::nclosing
+ */
+ public function testNclosing() {
+ $obj = new FakeDiffOp();
+ $this->assertEquals( 0, $obj->nclosing() );
+ $obj->closing = [ 'foo' ];
+ $this->assertEquals( 1, $obj->nclosing() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/diff/DiffTest.php b/www/wiki/tests/phpunit/includes/diff/DiffTest.php
new file mode 100644
index 00000000..da6d7d95
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/diff/DiffTest.php
@@ -0,0 +1,19 @@
+<?php
+
+/**
+ * @author Addshore
+ *
+ * @group Diff
+ */
+class DiffTest extends MediaWikiTestCase {
+
+ /**
+ * @covers Diff::getEdits
+ */
+ public function testGetEdits() {
+ $obj = new Diff( [], [] );
+ $obj->edits = 'FooBarBaz';
+ $this->assertEquals( 'FooBarBaz', $obj->getEdits() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/diff/DifferenceEngineTest.php b/www/wiki/tests/phpunit/includes/diff/DifferenceEngineTest.php
new file mode 100644
index 00000000..57aeb200
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/diff/DifferenceEngineTest.php
@@ -0,0 +1,148 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers DifferenceEngine
+ *
+ * @todo tests for the rest of DifferenceEngine!
+ *
+ * @group Database
+ * @group Diff
+ *
+ * @author Katie Filbert < aude.wiki@gmail.com >
+ */
+class DifferenceEngineTest extends MediaWikiTestCase {
+
+ protected $context;
+
+ private static $revisions;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $title = $this->getTitle();
+
+ $this->context = new RequestContext();
+ $this->context->setTitle( $title );
+
+ if ( !self::$revisions ) {
+ self::$revisions = $this->doEdits();
+ }
+ }
+
+ /**
+ * @return Title
+ */
+ protected function getTitle() {
+ $namespace = $this->getDefaultWikitextNS();
+ return Title::newFromText( 'Kitten', $namespace );
+ }
+
+ /**
+ * @return int[] Revision ids
+ */
+ protected function doEdits() {
+ $title = $this->getTitle();
+ $page = WikiPage::factory( $title );
+
+ $strings = [ "it is a kitten", "two kittens", "three kittens", "four kittens" ];
+ $revisions = [];
+
+ foreach ( $strings as $string ) {
+ $content = ContentHandler::makeContent( $string, $title );
+ $page->doEditContent( $content, 'edit page' );
+ $revisions[] = $page->getLatest();
+ }
+
+ return $revisions;
+ }
+
+ public function testMapDiffPrevNext() {
+ $cases = $this->getMapDiffPrevNextCases();
+
+ foreach ( $cases as $case ) {
+ list( $expected, $old, $new, $message ) = $case;
+
+ $diffEngine = new DifferenceEngine( $this->context, $old, $new, 2, true, false );
+ $diffMap = $diffEngine->mapDiffPrevNext( $old, $new );
+ $this->assertEquals( $expected, $diffMap, $message );
+ }
+ }
+
+ private function getMapDiffPrevNextCases() {
+ $revs = self::$revisions;
+
+ return [
+ [ [ $revs[1], $revs[2] ], $revs[2], 'prev', 'diff=prev' ],
+ [ [ $revs[2], $revs[3] ], $revs[2], 'next', 'diff=next' ],
+ [ [ $revs[1], $revs[3] ], $revs[1], $revs[3], 'diff=' . $revs[3] ]
+ ];
+ }
+
+ public function testLoadRevisionData() {
+ $cases = $this->getLoadRevisionDataCases();
+
+ foreach ( $cases as $case ) {
+ list( $expectedOld, $expectedNew, $old, $new, $message ) = $case;
+
+ $diffEngine = new DifferenceEngine( $this->context, $old, $new, 2, true, false );
+ $diffEngine->loadRevisionData();
+
+ $this->assertEquals( $diffEngine->getOldid(), $expectedOld, $message );
+ $this->assertEquals( $diffEngine->getNewid(), $expectedNew, $message );
+ }
+ }
+
+ private function getLoadRevisionDataCases() {
+ $revs = self::$revisions;
+
+ return [
+ [ $revs[2], $revs[3], $revs[3], 'prev', 'diff=prev' ],
+ [ $revs[2], $revs[3], $revs[2], 'next', 'diff=next' ],
+ [ $revs[1], $revs[3], $revs[1], $revs[3], 'diff=' . $revs[3] ],
+ [ $revs[1], $revs[3], $revs[1], 0, 'diff=0' ]
+ ];
+ }
+
+ public function testGetOldid() {
+ $revs = self::$revisions;
+
+ $diffEngine = new DifferenceEngine( $this->context, $revs[1], $revs[2], 2, true, false );
+ $this->assertEquals( $revs[1], $diffEngine->getOldid(), 'diff get old id' );
+ }
+
+ public function testGetNewid() {
+ $revs = self::$revisions;
+
+ $diffEngine = new DifferenceEngine( $this->context, $revs[1], $revs[2], 2, true, false );
+ $this->assertEquals( $revs[2], $diffEngine->getNewid(), 'diff get new id' );
+ }
+
+ public function provideLocaliseTitleTooltipsTestData() {
+ return [
+ 'moved paragraph left shoud get new location title' => [
+ '<a class="mw-diff-movedpara-left">⚫</a>',
+ '<a class="mw-diff-movedpara-left" title="(diff-paragraph-moved-tonew)">⚫</a>',
+ ],
+ 'moved paragraph right shoud get old location title' => [
+ '<a class="mw-diff-movedpara-right">⚫</a>',
+ '<a class="mw-diff-movedpara-right" title="(diff-paragraph-moved-toold)">⚫</a>',
+ ],
+ 'nothing changed when key not hit' => [
+ '<a class="mw-diff-movedpara-rightis">⚫</a>',
+ '<a class="mw-diff-movedpara-rightis">⚫</a>',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideLocaliseTitleTooltipsTestData
+ */
+ public function testAddLocalisedTitleTooltips( $input, $expected ) {
+ $this->setContentLang( 'qqx' );
+ $diffEngine = TestingAccessWrapper::newFromObject( new DifferenceEngine() );
+ $this->assertEquals( $expected, $diffEngine->addLocalisedTitleTooltips( $input ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/diff/FakeDiffOp.php b/www/wiki/tests/phpunit/includes/diff/FakeDiffOp.php
new file mode 100644
index 00000000..70c8f64a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/diff/FakeDiffOp.php
@@ -0,0 +1,11 @@
+<?php
+
+/**
+ * Class FakeDiffOp used to test abstract class DiffOp
+ */
+class FakeDiffOp extends DiffOp {
+
+ public function reverse() {
+ return null;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/editpage/TextboxBuilderTest.php b/www/wiki/tests/phpunit/includes/editpage/TextboxBuilderTest.php
new file mode 100644
index 00000000..4195f968
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/editpage/TextboxBuilderTest.php
@@ -0,0 +1,210 @@
+<?php
+/**
+ * Copyright (C) 2017 Kunal Mehta <legoktm@member.fsf.org>
+ *
+ * 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.
+ *
+ */
+
+namespace MediaWiki\Tests\EditPage;
+
+use Language;
+use MediaWiki\EditPage\TextboxBuilder;
+use MediaWikiTestCase;
+use Title;
+use User;
+
+/**
+ * @covers \MediaWiki\EditPage\TextboxBuilder
+ */
+class TextboxBuilderTest extends MediaWikiTestCase {
+
+ public function provideAddNewLineAtEnd() {
+ return [
+ [ '', '' ],
+ [ 'foo', "foo\n" ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideAddNewLineAtEnd
+ */
+ public function testAddNewLineAtEnd( $input, $expected ) {
+ $builder = new TextboxBuilder();
+ $this->assertSame( $expected, $builder->addNewLineAtEnd( $input ) );
+ }
+
+ public function testBuildTextboxAttribs() {
+ $user = new User();
+ $user->setOption( 'editfont', 'monospace' );
+
+ $title = $this->getMockBuilder( Title::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $title->expects( $this->any() )
+ ->method( 'getPageLanguage' )
+ ->will( $this->returnValue( Language::factory( 'en' ) ) );
+
+ $builder = new TextboxBuilder();
+ $attribs = $builder->buildTextboxAttribs(
+ 'mw-textbox1',
+ [ 'class' => 'foo bar', 'data-foo' => '123', 'rows' => 30 ],
+ $user,
+ $title
+ );
+
+ $this->assertInternalType( 'array', $attribs );
+ // custom attrib showed up
+ $this->assertArrayHasKey( 'data-foo', $attribs );
+ // classes merged properly (string)
+ $this->assertSame( 'foo bar mw-editfont-monospace', $attribs['class'] );
+ // overrides in custom attrib worked
+ $this->assertSame( 30, $attribs['rows'] );
+ $this->assertSame( 'en', $attribs['lang'] );
+
+ $attribs2 = $builder->buildTextboxAttribs(
+ 'mw-textbox2', [ 'class' => [ 'foo', 'bar' ] ], $user, $title
+ );
+ // classes merged properly (array)
+ $this->assertSame( [ 'foo', 'bar', 'mw-editfont-monospace' ], $attribs2['class'] );
+
+ $attribs3 = $builder->buildTextboxAttribs(
+ 'mw-textbox3', [], $user, $title
+ );
+ // classes ok when nothing to be merged
+ $this->assertSame( 'mw-editfont-monospace', $attribs3['class'] );
+ }
+
+ public function provideMergeClassesIntoAttributes() {
+ return [
+ [
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'mw-new-classname' ],
+ [],
+ [ 'class' => 'mw-new-classname' ],
+ ],
+ [
+ [],
+ [ 'title' => 'My Title' ],
+ [ 'title' => 'My Title' ],
+ ],
+ [
+ [ 'mw-new-classname' ],
+ [ 'title' => 'My Title' ],
+ [ 'title' => 'My Title', 'class' => 'mw-new-classname' ],
+ ],
+ [
+ [ 'mw-new-classname' ],
+ [ 'class' => 'mw-existing-classname' ],
+ [ 'class' => 'mw-existing-classname mw-new-classname' ],
+ ],
+ [
+ [ 'mw-new-classname', 'mw-existing-classname' ],
+ [ 'class' => 'mw-existing-classname' ],
+ [ 'class' => 'mw-existing-classname mw-new-classname' ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideMergeClassesIntoAttributes
+ */
+ public function testMergeClassesIntoAttributes( $inputClasses, $inputAttributes, $expected ) {
+ $builder = new TextboxBuilder();
+ $this->assertSame(
+ $expected,
+ $builder->mergeClassesIntoAttributes( $inputClasses, $inputAttributes )
+ );
+ }
+
+ public function provideGetTextboxProtectionCSSClasses() {
+ return [
+ [
+ [ '' ],
+ [ 'isProtected' ],
+ [],
+ ],
+ [
+ true,
+ [],
+ [],
+ ],
+ [
+ true,
+ [ 'isProtected' ],
+ [ 'mw-textarea-protected' ]
+ ],
+ [
+ true,
+ [ 'isProtected', 'isSemiProtected' ],
+ [ 'mw-textarea-sprotected' ],
+ ],
+ [
+ true,
+ [ 'isProtected', 'isCascadeProtected' ],
+ [ 'mw-textarea-protected', 'mw-textarea-cprotected' ],
+ ],
+ [
+ true,
+ [ 'isProtected', 'isCascadeProtected', 'isSemiProtected' ],
+ [ 'mw-textarea-sprotected', 'mw-textarea-cprotected' ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetTextboxProtectionCSSClasses
+ */
+ public function testGetTextboxProtectionCSSClasses(
+ $restrictionLevels,
+ $protectionModes,
+ $expected
+ ) {
+ $this->setMwGlobals( [
+ // set to trick MWNamespace::getRestrictionLevels
+ 'wgRestrictionLevels' => $restrictionLevels
+ ] );
+
+ $builder = new TextboxBuilder();
+ $this->assertSame( $expected, $builder->getTextboxProtectionCSSClasses(
+ $this->mockProtectedTitle( $protectionModes )
+ ) );
+ }
+
+ /**
+ * @return Title
+ */
+ private function mockProtectedTitle( $methodsToReturnTrue ) {
+ $title = $this->getMockBuilder( Title::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $title->expects( $this->any() )
+ ->method( 'getNamespace' )
+ ->will( $this->returnValue( 1 ) );
+
+ foreach ( $methodsToReturnTrue as $method ) {
+ $title->expects( $this->any() )
+ ->method( $method )
+ ->will( $this->returnValue( true ) );
+ }
+
+ return $title;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/exception/BadTitleErrorTest.php b/www/wiki/tests/phpunit/includes/exception/BadTitleErrorTest.php
new file mode 100644
index 00000000..b706face
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/exception/BadTitleErrorTest.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * @covers BadTitleError
+ * @author Addshore
+ */
+class BadTitleErrorTest extends MediaWikiTestCase {
+
+ public function testExceptionSetsStatusCode() {
+ $this->setMwGlobals( 'wgOut', $this->getMockWgOut() );
+ try {
+ throw new BadTitleError();
+ } catch ( BadTitleError $e ) {
+ ob_start();
+ $e->report();
+ $text = ob_get_clean();
+ $this->assertContains( $e->getText(), $text );
+ }
+ }
+
+ private function getMockWgOut() {
+ $mock = $this->getMockBuilder( OutputPage::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->once() )
+ ->method( 'setStatusCode' )
+ ->with( 400 );
+ return $mock;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/exception/ErrorPageErrorTest.php b/www/wiki/tests/phpunit/includes/exception/ErrorPageErrorTest.php
new file mode 100644
index 00000000..49d454e8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/exception/ErrorPageErrorTest.php
@@ -0,0 +1,52 @@
+<?php
+
+/**
+ * @covers ErrorPageError
+ * @author Addshore
+ */
+class ErrorPageErrorTest extends MediaWikiTestCase {
+
+ private function getMockMessage() {
+ $mockMessage = $this->getMockBuilder( Message::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mockMessage->expects( $this->once() )
+ ->method( 'inLanguage' )
+ ->will( $this->returnValue( $mockMessage ) );
+ $mockMessage->expects( $this->once() )
+ ->method( 'useDatabase' )
+ ->will( $this->returnValue( $mockMessage ) );
+ return $mockMessage;
+ }
+
+ public function testConstruction() {
+ $mockMessage = $this->getMockMessage();
+ $title = 'Foo';
+ $params = [ 'Baz' ];
+ $e = new ErrorPageError( $title, $mockMessage, $params );
+ $this->assertEquals( $title, $e->title );
+ $this->assertEquals( $mockMessage, $e->msg );
+ $this->assertEquals( $params, $e->params );
+ }
+
+ public function testReport() {
+ $mockMessage = $this->getMockMessage();
+ $title = 'Foo';
+ $params = [ 'Baz' ];
+
+ $mock = $this->getMockBuilder( OutputPage::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->once() )
+ ->method( 'showErrorPage' )
+ ->with( $title, $mockMessage, $params );
+ $mock->expects( $this->once() )
+ ->method( 'output' );
+ $this->setMwGlobals( 'wgOut', $mock );
+ $this->setMwGlobals( 'wgCommandLineMode', false );
+
+ $e = new ErrorPageError( $title, $mockMessage, $params );
+ $e->report();
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/exception/HttpErrorTest.php b/www/wiki/tests/phpunit/includes/exception/HttpErrorTest.php
new file mode 100644
index 00000000..90ccd1e5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/exception/HttpErrorTest.php
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * @todo tests for HttpError::report
+ *
+ * @covers HttpError
+ */
+class HttpErrorTest extends MediaWikiTestCase {
+
+ public function testIsLoggable() {
+ $httpError = new HttpError( 500, 'server error!' );
+ $this->assertFalse( $httpError->isLoggable(), 'http error is not loggable' );
+ }
+
+ public function testGetStatusCode() {
+ $httpError = new HttpError( 500, 'server error!' );
+ $this->assertEquals( 500, $httpError->getStatusCode() );
+ }
+
+ /**
+ * @dataProvider getHtmlProvider
+ */
+ public function testGetHtml( array $expected, $content, $header ) {
+ $httpError = new HttpError( 500, $content, $header );
+ $errorHtml = $httpError->getHTML();
+
+ foreach ( $expected as $key => $html ) {
+ $this->assertContains( $html, $errorHtml, $key );
+ }
+ }
+
+ public function getHtmlProvider() {
+ return [
+ [
+ [
+ 'head html' => '<head><title>Server Error 123</title></head>',
+ 'body html' => '<body><h1>Server Error 123</h1>'
+ . '<p>a server error!</p></body>'
+ ],
+ 'a server error!',
+ 'Server Error 123'
+ ],
+ [
+ [
+ 'head html' => '<head><title>loginerror</title></head>',
+ 'body html' => '<body><h1>loginerror</h1>'
+ . '<p>suspicious-userlogout</p></body>'
+ ],
+ new RawMessage( 'suspicious-userlogout' ),
+ new RawMessage( 'loginerror' )
+ ],
+ [
+ [
+ 'head html' => '<html><head><title>Internal Server Error</title></head>',
+ 'body html' => '<body><h1>Internal Server Error</h1>'
+ . '<p>a server error!</p></body></html>'
+ ],
+ 'a server error!',
+ null
+ ]
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/exception/MWExceptionHandlerTest.php b/www/wiki/tests/phpunit/includes/exception/MWExceptionHandlerTest.php
new file mode 100644
index 00000000..66060656
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/exception/MWExceptionHandlerTest.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * @author Antoine Musso
+ * @copyright Copyright © 2013, Antoine Musso
+ * @copyright Copyright © 2013, Wikimedia Foundation Inc.
+ * @file
+ */
+
+class MWExceptionHandlerTest extends MediaWikiTestCase {
+
+ /**
+ * @covers MWExceptionHandler::getRedactedTrace
+ */
+ public function testGetRedactedTrace() {
+ $refvar = 'value';
+ try {
+ $array = [ 'a', 'b' ];
+ $object = new stdClass();
+ self::helperThrowAnException( $array, $object, $refvar );
+ } catch ( Exception $e ) {
+ }
+
+ # Make sure our stack trace contains an array and an object passed to
+ # some function in the stacktrace. Else, we can not assert the trace
+ # redaction achieved its job.
+ $trace = $e->getTrace();
+ $hasObject = false;
+ $hasArray = false;
+ foreach ( $trace as $frame ) {
+ if ( !isset( $frame['args'] ) ) {
+ continue;
+ }
+ foreach ( $frame['args'] as $arg ) {
+ $hasObject = $hasObject || is_object( $arg );
+ $hasArray = $hasArray || is_array( $arg );
+ }
+
+ if ( $hasObject && $hasArray ) {
+ break;
+ }
+ }
+ $this->assertTrue( $hasObject,
+ "The stacktrace must have a function having an object has parameter" );
+ $this->assertTrue( $hasArray,
+ "The stacktrace must have a function having an array has parameter" );
+
+ # Now we redact the trace.. and make sure no function arguments are
+ # arrays or objects.
+ $redacted = MWExceptionHandler::getRedactedTrace( $e );
+
+ foreach ( $redacted as $frame ) {
+ if ( !isset( $frame['args'] ) ) {
+ continue;
+ }
+ foreach ( $frame['args'] as $arg ) {
+ $this->assertNotInternalType( 'array', $arg );
+ $this->assertNotInternalType( 'object', $arg );
+ }
+ }
+
+ $this->assertEquals( 'value', $refvar, 'Ensuring reference variable wasn\'t changed' );
+ }
+
+ /**
+ * Helper function for testExpandArgumentsInCall
+ *
+ * Pass it an object and an array, and something by reference :-)
+ *
+ * @throws Exception
+ */
+ protected static function helperThrowAnException( $a, $b, &$c ) {
+ throw new Exception();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/exception/MWExceptionTest.php b/www/wiki/tests/phpunit/includes/exception/MWExceptionTest.php
new file mode 100644
index 00000000..b1605549
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/exception/MWExceptionTest.php
@@ -0,0 +1,193 @@
+<?php
+/**
+ * @author Antoine Musso
+ * @copyright Copyright © 2013, Antoine Musso
+ * @copyright Copyright © 2013, Wikimedia Foundation Inc.
+ * @file
+ */
+
+class MWExceptionTest extends MediaWikiTestCase {
+
+ /**
+ * @expectedException MWException
+ * @covers MWException
+ */
+ public function testMwexceptionThrowing() {
+ throw new MWException();
+ }
+
+ /**
+ * @dataProvider provideTextUseOutputPage
+ * @covers MWException::useOutputPage
+ */
+ public function testUseOutputPage( $expected, $langObj, $wgFullyInitialised, $wgOut ) {
+ $this->setMwGlobals( [
+ 'wgLang' => $langObj,
+ 'wgFullyInitialised' => $wgFullyInitialised,
+ 'wgOut' => $wgOut,
+ ] );
+
+ $e = new MWException();
+ $this->assertEquals( $expected, $e->useOutputPage() );
+ }
+
+ public function provideTextUseOutputPage() {
+ return [
+ // expected, langObj, wgFullyInitialised, wgOut
+ [ false, null, null, null ],
+ [ false, $this->getMockLanguage(), null, null ],
+ [ false, $this->getMockLanguage(), true, null ],
+ [ false, null, true, null ],
+ [ false, null, null, true ],
+ [ true, $this->getMockLanguage(), true, true ],
+ ];
+ }
+
+ private function getMockLanguage() {
+ return $this->getMockBuilder( Language::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ /**
+ * @dataProvider provideUseMessageCache
+ * @covers MWException::useMessageCache
+ */
+ public function testUseMessageCache( $expected, $langObj ) {
+ $this->setMwGlobals( [
+ 'wgLang' => $langObj,
+ ] );
+ $e = new MWException();
+ $this->assertEquals( $expected, $e->useMessageCache() );
+ }
+
+ public function provideUseMessageCache() {
+ return [
+ [ false, null ],
+ [ true, $this->getMockLanguage() ],
+ ];
+ }
+
+ /**
+ * @covers MWException::isLoggable
+ */
+ public function testIsLogable() {
+ $e = new MWException();
+ $this->assertTrue( $e->isLoggable() );
+ }
+
+ /**
+ * @dataProvider provideIsCommandLine
+ * @covers MWException::isCommandLine
+ */
+ public function testisCommandLine( $expected, $wgCommandLineMode ) {
+ $this->setMwGlobals( [
+ 'wgCommandLineMode' => $wgCommandLineMode,
+ ] );
+ $e = new MWException();
+ $this->assertEquals( $expected, $e->isCommandLine() );
+ }
+
+ public static function provideIsCommandLine() {
+ return [
+ [ false, null ],
+ [ true, true ],
+ ];
+ }
+
+ /**
+ * Verify the exception classes are JSON serializabe.
+ *
+ * @covers MWExceptionHandler::jsonSerializeException
+ * @dataProvider provideExceptionClasses
+ */
+ public function testJsonSerializeExceptions( $exception_class ) {
+ $json = MWExceptionHandler::jsonSerializeException(
+ new $exception_class()
+ );
+ $this->assertNotEquals( false, $json,
+ "The $exception_class exception should be JSON serializable, got false." );
+ }
+
+ public static function provideExceptionClasses() {
+ return [
+ [ Exception::class ],
+ [ MWException::class ],
+ ];
+ }
+
+ /**
+ * Lame JSON schema validation.
+ *
+ * @covers MWExceptionHandler::jsonSerializeException
+ *
+ * @param string $expectedKeyType Type expected as returned by gettype()
+ * @param string $exClass An exception class (ie: Exception, MWException)
+ * @param string $key Name of the key to validate in the serialized JSON
+ * @dataProvider provideJsonSerializedKeys
+ */
+ public function testJsonserializeexceptionKeys( $expectedKeyType, $exClass, $key ) {
+ # Make sure we log a backtrace:
+ $this->setMwGlobals( [ 'wgLogExceptionBacktrace' => true ] );
+
+ $json = json_decode(
+ MWExceptionHandler::jsonSerializeException( new $exClass() )
+ );
+ $this->assertObjectHasAttribute( $key, $json,
+ "JSON serialized exception is missing key '$key'"
+ );
+ $this->assertInternalType( $expectedKeyType, $json->$key,
+ "JSON serialized key '$key' has type " . gettype( $json->$key )
+ . " (expected: $expectedKeyType)."
+ );
+ }
+
+ /**
+ * Returns test cases: exception class, key name, gettype()
+ */
+ public static function provideJsonSerializedKeys() {
+ $testCases = [];
+ foreach ( [ Exception::class, MWException::class ] as $exClass ) {
+ $exTests = [
+ [ 'string', $exClass, 'id' ],
+ [ 'string', $exClass, 'file' ],
+ [ 'integer', $exClass, 'line' ],
+ [ 'string', $exClass, 'message' ],
+ [ 'null', $exClass, 'url' ],
+ # Backtrace only enabled with wgLogExceptionBacktrace = true
+ [ 'array', $exClass, 'backtrace' ],
+ ];
+ $testCases = array_merge( $testCases, $exTests );
+ }
+ return $testCases;
+ }
+
+ /**
+ * Given wgLogExceptionBacktrace is true
+ * then serialized exception SHOULD have a backtrace
+ *
+ * @covers MWExceptionHandler::jsonSerializeException
+ */
+ public function testJsonserializeexceptionBacktracingEnabled() {
+ $this->setMwGlobals( [ 'wgLogExceptionBacktrace' => true ] );
+ $json = json_decode(
+ MWExceptionHandler::jsonSerializeException( new Exception() )
+ );
+ $this->assertObjectHasAttribute( 'backtrace', $json );
+ }
+
+ /**
+ * Given wgLogExceptionBacktrace is false
+ * then serialized exception SHOULD NOT have a backtrace
+ *
+ * @covers MWExceptionHandler::jsonSerializeException
+ */
+ public function testJsonserializeexceptionBacktracingDisabled() {
+ $this->setMwGlobals( [ 'wgLogExceptionBacktrace' => false ] );
+ $json = json_decode(
+ MWExceptionHandler::jsonSerializeException( new Exception() )
+ );
+ $this->assertObjectNotHasAttribute( 'backtrace', $json );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/exception/ReadOnlyErrorTest.php b/www/wiki/tests/phpunit/includes/exception/ReadOnlyErrorTest.php
new file mode 100644
index 00000000..ee5becff
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/exception/ReadOnlyErrorTest.php
@@ -0,0 +1,16 @@
+<?php
+
+/**
+ * @covers ReadOnlyError
+ * @author Addshore
+ */
+class ReadOnlyErrorTest extends MediaWikiTestCase {
+
+ public function testConstruction() {
+ $e = new ReadOnlyError();
+ $this->assertEquals( 'readonly', $e->title );
+ $this->assertEquals( 'readonlytext', $e->msg );
+ $this->assertEquals( wfReadOnlyReason() ?: [], $e->params );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/exception/ThrottledErrorTest.php b/www/wiki/tests/phpunit/includes/exception/ThrottledErrorTest.php
new file mode 100644
index 00000000..5214b6d4
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/exception/ThrottledErrorTest.php
@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * @covers ThrottledError
+ * @author Addshore
+ */
+class ThrottledErrorTest extends MediaWikiTestCase {
+
+ public function testExceptionSetsStatusCode() {
+ $this->setMwGlobals( 'wgOut', $this->getMockWgOut() );
+ try {
+ throw new ThrottledError();
+ } catch ( ThrottledError $e ) {
+ ob_start();
+ $e->report();
+ $text = ob_get_clean();
+ $this->assertContains( $e->getText(), $text );
+ }
+ }
+
+ private function getMockWgOut() {
+ $mock = $this->getMockBuilder( OutputPage::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->once() )
+ ->method( 'setStatusCode' )
+ ->with( 429 );
+ return $mock;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/exception/UserNotLoggedInTest.php b/www/wiki/tests/phpunit/includes/exception/UserNotLoggedInTest.php
new file mode 100644
index 00000000..55ec45a0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/exception/UserNotLoggedInTest.php
@@ -0,0 +1,16 @@
+<?php
+
+/**
+ * @covers UserNotLoggedIn
+ * @author Addshore
+ */
+class UserNotLoggedInTest extends MediaWikiTestCase {
+
+ public function testConstruction() {
+ $e = new UserNotLoggedIn();
+ $this->assertEquals( 'exception-nologin', $e->title );
+ $this->assertEquals( 'exception-nologin-text', $e->msg );
+ $this->assertEquals( [], $e->params );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php b/www/wiki/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php
new file mode 100644
index 00000000..f7626938
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php
@@ -0,0 +1,41 @@
+<?php
+
+/**
+ * @covers ExternalStoreFactory
+ */
+class ExternalStoreFactoryTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function testExternalStoreFactory_noStores() {
+ $factory = new ExternalStoreFactory( [] );
+ $this->assertFalse( $factory->getStoreObject( 'ForTesting' ) );
+ $this->assertFalse( $factory->getStoreObject( 'foo' ) );
+ }
+
+ public function provideStoreNames() {
+ yield 'Same case as construction' => [ 'ForTesting' ];
+ yield 'All lower case' => [ 'fortesting' ];
+ yield 'All upper case' => [ 'FORTESTING' ];
+ yield 'Mix of cases' => [ 'FOrTEsTInG' ];
+ }
+
+ /**
+ * @dataProvider provideStoreNames
+ */
+ public function testExternalStoreFactory_someStore_protoMatch( $proto ) {
+ $factory = new ExternalStoreFactory( [ 'ForTesting' ] );
+ $store = $factory->getStoreObject( $proto );
+ $this->assertInstanceOf( ExternalStoreForTesting::class, $store );
+ }
+
+ /**
+ * @dataProvider provideStoreNames
+ */
+ public function testExternalStoreFactory_someStore_noProtoMatch( $proto ) {
+ $factory = new ExternalStoreFactory( [ 'SomeOtherClassName' ] );
+ $store = $factory->getStoreObject( $proto );
+ $this->assertFalse( $store );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/externalstore/ExternalStoreForTesting.php b/www/wiki/tests/phpunit/includes/externalstore/ExternalStoreForTesting.php
new file mode 100644
index 00000000..50f1e523
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/externalstore/ExternalStoreForTesting.php
@@ -0,0 +1,46 @@
+<?php
+
+class ExternalStoreForTesting {
+
+ protected $data = [
+ 'cluster1' => [
+ '200' => 'Hello',
+ '300' => [
+ 'Hello', 'World',
+ ],
+ // gzip string below generated with gzdeflate( 'AAAABBAAA' )
+ '12345' => "sttttr\002\022\000",
+ ],
+ ];
+
+ /**
+ * Fetch data from given URL
+ * @param string $url An url of the form FOO://cluster/id or FOO://cluster/id/itemid.
+ * @return mixed
+ */
+ public function fetchFromURL( $url ) {
+ // Based on ExternalStoreDB
+ $path = explode( '/', $url );
+ $cluster = $path[2];
+ $id = $path[3];
+ if ( isset( $path[4] ) ) {
+ $itemID = $path[4];
+ } else {
+ $itemID = false;
+ }
+
+ if ( !isset( $this->data[$cluster][$id] ) ) {
+ return null;
+ }
+
+ if ( $itemID !== false
+ && is_array( $this->data[$cluster][$id] )
+ && isset( $this->data[$cluster][$id][$itemID] )
+ ) {
+ return $this->data[$cluster][$id][$itemID];
+ }
+
+ return $this->data[$cluster][$id];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/externalstore/ExternalStoreTest.php b/www/wiki/tests/phpunit/includes/externalstore/ExternalStoreTest.php
new file mode 100644
index 00000000..7ca38749
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/externalstore/ExternalStoreTest.php
@@ -0,0 +1,53 @@
+<?php
+
+class ExternalStoreTest extends MediaWikiTestCase {
+
+ /**
+ * @covers ExternalStore::fetchFromURL
+ */
+ public function testExternalFetchFromURL_noExternalStores() {
+ $this->setService(
+ 'ExternalStoreFactory',
+ new ExternalStoreFactory( [] )
+ );
+
+ $this->assertFalse(
+ ExternalStore::fetchFromURL( 'ForTesting://cluster1/200' ),
+ 'Deny if wgExternalStores is not set to a non-empty array'
+ );
+ }
+
+ /**
+ * @covers ExternalStore::fetchFromURL
+ */
+ public function testExternalFetchFromURL_someExternalStore() {
+ $this->setService(
+ 'ExternalStoreFactory',
+ new ExternalStoreFactory( [ 'ForTesting' ] )
+ );
+
+ $this->assertEquals(
+ 'Hello',
+ ExternalStore::fetchFromURL( 'ForTesting://cluster1/200' ),
+ 'Allow FOO://cluster1/200'
+ );
+ $this->assertEquals(
+ 'Hello',
+ ExternalStore::fetchFromURL( 'ForTesting://cluster1/300/0' ),
+ 'Allow FOO://cluster1/300/0'
+ );
+ # Assertions for r68900
+ $this->assertFalse(
+ ExternalStore::fetchFromURL( 'ftp.example.org' ),
+ 'Deny domain ftp.example.org'
+ );
+ $this->assertFalse(
+ ExternalStore::fetchFromURL( '/example.txt' ),
+ 'Deny path /example.txt'
+ );
+ $this->assertFalse(
+ ExternalStore::fetchFromURL( 'http://' ),
+ 'Deny protocol http://'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/filebackend/FileBackendTest.php b/www/wiki/tests/phpunit/includes/filebackend/FileBackendTest.php
new file mode 100644
index 00000000..2cd4ba6d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/filebackend/FileBackendTest.php
@@ -0,0 +1,2641 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group FileRepo
+ * @group FileBackend
+ * @group medium
+ *
+ * @covers FileBackend
+ *
+ * @covers CopyFileOp
+ * @covers CreateFileOp
+ * @covers DeleteFileOp
+ * @covers DescribeFileOp
+ * @covers FSFile
+ * @covers FSFileBackend
+ * @covers FSFileBackendDirList
+ * @covers FSFileBackendFileList
+ * @covers FSFileBackendList
+ * @covers FSFileOpHandle
+ * @covers FileBackendDBRepoWrapper
+ * @covers FileBackendError
+ * @covers FileBackendGroup
+ * @covers FileBackendMultiWrite
+ * @covers FileBackendStore
+ * @covers FileBackendStoreOpHandle
+ * @covers FileBackendStoreShardDirIterator
+ * @covers FileBackendStoreShardFileIterator
+ * @covers FileBackendStoreShardListIterator
+ * @covers FileJournal
+ * @covers FileOp
+ * @covers FileOpBatch
+ * @covers HTTPFileStreamer
+ * @covers LockManagerGroup
+ * @covers MemoryFileBackend
+ * @covers MoveFileOp
+ * @covers MySqlLockManager
+ * @covers NullFileJournal
+ * @covers NullFileOp
+ * @covers StoreFileOp
+ * @covers TempFSFile
+ *
+ * @covers FSLockManager
+ * @covers LockManager
+ * @covers NullLockManager
+ */
+class FileBackendTest extends MediaWikiTestCase {
+
+ /** @var FileBackend */
+ private $backend;
+ /** @var FileBackendMultiWrite */
+ private $multiBackend;
+ /** @var FSFileBackend */
+ public $singleBackend;
+ private static $backendToUse;
+
+ protected function setUp() {
+ global $wgFileBackends;
+ parent::setUp();
+ $tmpDir = $this->getNewTempDirectory();
+ if ( $this->getCliArg( 'use-filebackend' ) ) {
+ if ( self::$backendToUse ) {
+ $this->singleBackend = self::$backendToUse;
+ } else {
+ $name = $this->getCliArg( 'use-filebackend' );
+ $useConfig = [];
+ foreach ( $wgFileBackends as $conf ) {
+ if ( $conf['name'] == $name ) {
+ $useConfig = $conf;
+ break;
+ }
+ }
+ $useConfig['name'] = 'localtesting'; // swap name
+ $useConfig['shardViaHashLevels'] = [ // test sharding
+ 'unittest-cont1' => [ 'levels' => 1, 'base' => 16, 'repeat' => 1 ]
+ ];
+ if ( isset( $useConfig['fileJournal'] ) ) {
+ $useConfig['fileJournal'] = FileJournal::factory( $useConfig['fileJournal'], $name );
+ }
+ $useConfig['lockManager'] = LockManagerGroup::singleton()->get( $useConfig['lockManager'] );
+ $class = $useConfig['class'];
+ self::$backendToUse = new $class( $useConfig );
+ $this->singleBackend = self::$backendToUse;
+ }
+ } else {
+ $this->singleBackend = new FSFileBackend( [
+ 'name' => 'localtesting',
+ 'lockManager' => LockManagerGroup::singleton()->get( 'fsLockManager' ),
+ 'wikiId' => wfWikiID(),
+ 'containerPaths' => [
+ 'unittest-cont1' => "{$tmpDir}/localtesting-cont1",
+ 'unittest-cont2' => "{$tmpDir}/localtesting-cont2" ]
+ ] );
+ }
+ $this->multiBackend = new FileBackendMultiWrite( [
+ 'name' => 'localtesting',
+ 'lockManager' => LockManagerGroup::singleton()->get( 'fsLockManager' ),
+ 'parallelize' => 'implicit',
+ 'wikiId' => wfWikiID() . wfRandomString(),
+ 'backends' => [
+ [
+ 'name' => 'localmultitesting1',
+ 'class' => FSFileBackend::class,
+ 'containerPaths' => [
+ 'unittest-cont1' => "{$tmpDir}/localtestingmulti1-cont1",
+ 'unittest-cont2' => "{$tmpDir}/localtestingmulti1-cont2" ],
+ 'isMultiMaster' => false
+ ],
+ [
+ 'name' => 'localmultitesting2',
+ 'class' => FSFileBackend::class,
+ 'containerPaths' => [
+ 'unittest-cont1' => "{$tmpDir}/localtestingmulti2-cont1",
+ 'unittest-cont2' => "{$tmpDir}/localtestingmulti2-cont2" ],
+ 'isMultiMaster' => true
+ ]
+ ]
+ ] );
+ }
+
+ private static function baseStorePath() {
+ return 'mwstore://localtesting';
+ }
+
+ private function backendClass() {
+ return get_class( $this->backend );
+ }
+
+ /**
+ * @dataProvider provider_testIsStoragePath
+ */
+ public function testIsStoragePath( $path, $isStorePath ) {
+ $this->assertEquals( $isStorePath, FileBackend::isStoragePath( $path ),
+ "FileBackend::isStoragePath on path '$path'" );
+ }
+
+ public static function provider_testIsStoragePath() {
+ return [
+ [ 'mwstore://', true ],
+ [ 'mwstore://backend', true ],
+ [ 'mwstore://backend/container', true ],
+ [ 'mwstore://backend/container/', true ],
+ [ 'mwstore://backend/container/path', true ],
+ [ 'mwstore://backend//container/', true ],
+ [ 'mwstore://backend//container//', true ],
+ [ 'mwstore://backend//container//path', true ],
+ [ 'mwstore:///', true ],
+ [ 'mwstore:/', false ],
+ [ 'mwstore:', false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provider_testSplitStoragePath
+ */
+ public function testSplitStoragePath( $path, $res ) {
+ $this->assertEquals( $res, FileBackend::splitStoragePath( $path ),
+ "FileBackend::splitStoragePath on path '$path'" );
+ }
+
+ public static function provider_testSplitStoragePath() {
+ return [
+ [ 'mwstore://backend/container', [ 'backend', 'container', '' ] ],
+ [ 'mwstore://backend/container/', [ 'backend', 'container', '' ] ],
+ [ 'mwstore://backend/container/path', [ 'backend', 'container', 'path' ] ],
+ [ 'mwstore://backend/container//path', [ 'backend', 'container', '/path' ] ],
+ [ 'mwstore://backend//container/path', [ null, null, null ] ],
+ [ 'mwstore://backend//container//path', [ null, null, null ] ],
+ [ 'mwstore://', [ null, null, null ] ],
+ [ 'mwstore://backend', [ null, null, null ] ],
+ [ 'mwstore:///', [ null, null, null ] ],
+ [ 'mwstore:/', [ null, null, null ] ],
+ [ 'mwstore:', [ null, null, null ] ]
+ ];
+ }
+
+ /**
+ * @dataProvider provider_normalizeStoragePath
+ */
+ public function testNormalizeStoragePath( $path, $res ) {
+ $this->assertEquals( $res, FileBackend::normalizeStoragePath( $path ),
+ "FileBackend::normalizeStoragePath on path '$path'" );
+ }
+
+ public static function provider_normalizeStoragePath() {
+ return [
+ [ 'mwstore://backend/container', 'mwstore://backend/container' ],
+ [ 'mwstore://backend/container/', 'mwstore://backend/container' ],
+ [ 'mwstore://backend/container/path', 'mwstore://backend/container/path' ],
+ [ 'mwstore://backend/container//path', 'mwstore://backend/container/path' ],
+ [ 'mwstore://backend/container///path', 'mwstore://backend/container/path' ],
+ [
+ 'mwstore://backend/container///path//to///obj',
+ 'mwstore://backend/container/path/to/obj'
+ ],
+ [ 'mwstore://', null ],
+ [ 'mwstore://backend', null ],
+ [ 'mwstore://backend//container/path', null ],
+ [ 'mwstore://backend//container//path', null ],
+ [ 'mwstore:///', null ],
+ [ 'mwstore:/', null ],
+ [ 'mwstore:', null ],
+ ];
+ }
+
+ /**
+ * @dataProvider provider_testParentStoragePath
+ */
+ public function testParentStoragePath( $path, $res ) {
+ $this->assertEquals( $res, FileBackend::parentStoragePath( $path ),
+ "FileBackend::parentStoragePath on path '$path'" );
+ }
+
+ public static function provider_testParentStoragePath() {
+ return [
+ [ 'mwstore://backend/container/path/to/obj', 'mwstore://backend/container/path/to' ],
+ [ 'mwstore://backend/container/path/to', 'mwstore://backend/container/path' ],
+ [ 'mwstore://backend/container/path', 'mwstore://backend/container' ],
+ [ 'mwstore://backend/container', null ],
+ [ 'mwstore://backend/container/path/to/obj/', 'mwstore://backend/container/path/to' ],
+ [ 'mwstore://backend/container/path/to/', 'mwstore://backend/container/path' ],
+ [ 'mwstore://backend/container/path/', 'mwstore://backend/container' ],
+ [ 'mwstore://backend/container/', null ],
+ ];
+ }
+
+ /**
+ * @dataProvider provider_testExtensionFromPath
+ */
+ public function testExtensionFromPath( $path, $res ) {
+ $this->assertEquals( $res, FileBackend::extensionFromPath( $path ),
+ "FileBackend::extensionFromPath on path '$path'" );
+ }
+
+ public static function provider_testExtensionFromPath() {
+ return [
+ [ 'mwstore://backend/container/path.txt', 'txt' ],
+ [ 'mwstore://backend/container/path.svg.png', 'png' ],
+ [ 'mwstore://backend/container/path', '' ],
+ [ 'mwstore://backend/container/path.', '' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provider_testStore
+ */
+ public function testStore( $op ) {
+ $this->addTmpFiles( $op['src'] );
+
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestStore( $op );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestStore( $op );
+ $this->tearDownFiles();
+ }
+
+ private function doTestStore( $op ) {
+ $backendName = $this->backendClass();
+
+ $source = $op['src'];
+ $dest = $op['dst'];
+ $this->prepare( [ 'dir' => dirname( $dest ) ] );
+
+ file_put_contents( $source, "Unit test file" );
+
+ if ( isset( $op['overwrite'] ) || isset( $op['overwriteSame'] ) ) {
+ $this->backend->store( $op );
+ }
+
+ $status = $this->backend->doOperation( $op );
+
+ $this->assertGoodStatus( $status,
+ "Store from $source to $dest succeeded without warnings ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Store from $source to $dest succeeded ($backendName)." );
+ $this->assertEquals( [ 0 => true ], $status->success,
+ "Store from $source to $dest has proper 'success' field in Status ($backendName)." );
+ $this->assertEquals( true, file_exists( $source ),
+ "Source file $source still exists ($backendName)." );
+ $this->assertEquals( true, $this->backend->fileExists( [ 'src' => $dest ] ),
+ "Destination file $dest exists ($backendName)." );
+
+ $this->assertEquals( filesize( $source ),
+ $this->backend->getFileSize( [ 'src' => $dest ] ),
+ "Destination file $dest has correct size ($backendName)." );
+
+ $props1 = FSFile::getPropsFromPath( $source );
+ $props2 = $this->backend->getFileProps( [ 'src' => $dest ] );
+ $this->assertEquals( $props1, $props2,
+ "Source and destination have the same props ($backendName)." );
+
+ $this->assertBackendPathsConsistent( [ $dest ] );
+ }
+
+ public static function provider_testStore() {
+ $cases = [];
+
+ $tmpName = TempFSFile::factory( "unittests_", 'txt', wfTempDir() )->getPath();
+ $toPath = self::baseStorePath() . '/unittest-cont1/e/fun/obj1.txt';
+ $op = [ 'op' => 'store', 'src' => $tmpName, 'dst' => $toPath ];
+ $cases[] = [ $op ];
+
+ $op2 = $op;
+ $op2['overwrite'] = true;
+ $cases[] = [ $op2 ];
+
+ $op3 = $op;
+ $op3['overwriteSame'] = true;
+ $cases[] = [ $op3 ];
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider provider_testCopy
+ */
+ public function testCopy( $op ) {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestCopy( $op );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestCopy( $op );
+ $this->tearDownFiles();
+ }
+
+ private function doTestCopy( $op ) {
+ $backendName = $this->backendClass();
+
+ $source = $op['src'];
+ $dest = $op['dst'];
+ $this->prepare( [ 'dir' => dirname( $source ) ] );
+ $this->prepare( [ 'dir' => dirname( $dest ) ] );
+
+ if ( isset( $op['ignoreMissingSource'] ) ) {
+ $status = $this->backend->doOperation( $op );
+ $this->assertGoodStatus( $status,
+ "Move from $source to $dest succeeded without warnings ($backendName)." );
+ $this->assertEquals( [ 0 => true ], $status->success,
+ "Move from $source to $dest has proper 'success' field in Status ($backendName)." );
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $source ] ),
+ "Source file $source does not exist ($backendName)." );
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $dest ] ),
+ "Destination file $dest does not exist ($backendName)." );
+
+ return; // done
+ }
+
+ $status = $this->backend->doOperation(
+ [ 'op' => 'create', 'content' => 'blahblah', 'dst' => $source ] );
+ $this->assertGoodStatus( $status,
+ "Creation of file at $source succeeded ($backendName)." );
+
+ if ( isset( $op['overwrite'] ) || isset( $op['overwriteSame'] ) ) {
+ $this->backend->copy( $op );
+ }
+
+ $status = $this->backend->doOperation( $op );
+
+ $this->assertGoodStatus( $status,
+ "Copy from $source to $dest succeeded without warnings ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Copy from $source to $dest succeeded ($backendName)." );
+ $this->assertEquals( [ 0 => true ], $status->success,
+ "Copy from $source to $dest has proper 'success' field in Status ($backendName)." );
+ $this->assertEquals( true, $this->backend->fileExists( [ 'src' => $source ] ),
+ "Source file $source still exists ($backendName)." );
+ $this->assertEquals( true, $this->backend->fileExists( [ 'src' => $dest ] ),
+ "Destination file $dest exists after copy ($backendName)." );
+
+ $this->assertEquals(
+ $this->backend->getFileSize( [ 'src' => $source ] ),
+ $this->backend->getFileSize( [ 'src' => $dest ] ),
+ "Destination file $dest has correct size ($backendName)." );
+
+ $props1 = $this->backend->getFileProps( [ 'src' => $source ] );
+ $props2 = $this->backend->getFileProps( [ 'src' => $dest ] );
+ $this->assertEquals( $props1, $props2,
+ "Source and destination have the same props ($backendName)." );
+
+ $this->assertBackendPathsConsistent( [ $source, $dest ] );
+ }
+
+ public static function provider_testCopy() {
+ $cases = [];
+
+ $source = self::baseStorePath() . '/unittest-cont1/e/file.txt';
+ $dest = self::baseStorePath() . '/unittest-cont2/a/fileMoved.txt';
+
+ $op = [ 'op' => 'copy', 'src' => $source, 'dst' => $dest ];
+ $cases[] = [
+ $op, // operation
+ $source, // source
+ $dest, // dest
+ ];
+
+ $op2 = $op;
+ $op2['overwrite'] = true;
+ $cases[] = [
+ $op2, // operation
+ $source, // source
+ $dest, // dest
+ ];
+
+ $op2 = $op;
+ $op2['overwriteSame'] = true;
+ $cases[] = [
+ $op2, // operation
+ $source, // source
+ $dest, // dest
+ ];
+
+ $op2 = $op;
+ $op2['ignoreMissingSource'] = true;
+ $cases[] = [
+ $op2, // operation
+ $source, // source
+ $dest, // dest
+ ];
+
+ $op2 = $op;
+ $op2['ignoreMissingSource'] = true;
+ $cases[] = [
+ $op2, // operation
+ self::baseStorePath() . '/unittest-cont-bad/e/file.txt', // source
+ $dest, // dest
+ ];
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider provider_testMove
+ */
+ public function testMove( $op ) {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestMove( $op );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestMove( $op );
+ $this->tearDownFiles();
+ }
+
+ private function doTestMove( $op ) {
+ $backendName = $this->backendClass();
+
+ $source = $op['src'];
+ $dest = $op['dst'];
+ $this->prepare( [ 'dir' => dirname( $source ) ] );
+ $this->prepare( [ 'dir' => dirname( $dest ) ] );
+
+ if ( isset( $op['ignoreMissingSource'] ) ) {
+ $status = $this->backend->doOperation( $op );
+ $this->assertGoodStatus( $status,
+ "Move from $source to $dest succeeded without warnings ($backendName)." );
+ $this->assertEquals( [ 0 => true ], $status->success,
+ "Move from $source to $dest has proper 'success' field in Status ($backendName)." );
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $source ] ),
+ "Source file $source does not exist ($backendName)." );
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $dest ] ),
+ "Destination file $dest does not exist ($backendName)." );
+
+ return; // done
+ }
+
+ $status = $this->backend->doOperation(
+ [ 'op' => 'create', 'content' => 'blahblah', 'dst' => $source ] );
+ $this->assertGoodStatus( $status,
+ "Creation of file at $source succeeded ($backendName)." );
+
+ if ( isset( $op['overwrite'] ) || isset( $op['overwriteSame'] ) ) {
+ $this->backend->copy( $op );
+ }
+
+ $status = $this->backend->doOperation( $op );
+ $this->assertGoodStatus( $status,
+ "Move from $source to $dest succeeded without warnings ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Move from $source to $dest succeeded ($backendName)." );
+ $this->assertEquals( [ 0 => true ], $status->success,
+ "Move from $source to $dest has proper 'success' field in Status ($backendName)." );
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $source ] ),
+ "Source file $source does not still exists ($backendName)." );
+ $this->assertEquals( true, $this->backend->fileExists( [ 'src' => $dest ] ),
+ "Destination file $dest exists after move ($backendName)." );
+
+ $this->assertNotEquals(
+ $this->backend->getFileSize( [ 'src' => $source ] ),
+ $this->backend->getFileSize( [ 'src' => $dest ] ),
+ "Destination file $dest has correct size ($backendName)." );
+
+ $props1 = $this->backend->getFileProps( [ 'src' => $source ] );
+ $props2 = $this->backend->getFileProps( [ 'src' => $dest ] );
+ $this->assertEquals( false, $props1['fileExists'],
+ "Source file does not exist accourding to props ($backendName)." );
+ $this->assertEquals( true, $props2['fileExists'],
+ "Destination file exists accourding to props ($backendName)." );
+
+ $this->assertBackendPathsConsistent( [ $source, $dest ] );
+ }
+
+ public static function provider_testMove() {
+ $cases = [];
+
+ $source = self::baseStorePath() . '/unittest-cont1/e/file.txt';
+ $dest = self::baseStorePath() . '/unittest-cont2/a/fileMoved.txt';
+
+ $op = [ 'op' => 'move', 'src' => $source, 'dst' => $dest ];
+ $cases[] = [
+ $op, // operation
+ $source, // source
+ $dest, // dest
+ ];
+
+ $op2 = $op;
+ $op2['overwrite'] = true;
+ $cases[] = [
+ $op2, // operation
+ $source, // source
+ $dest, // dest
+ ];
+
+ $op2 = $op;
+ $op2['overwriteSame'] = true;
+ $cases[] = [
+ $op2, // operation
+ $source, // source
+ $dest, // dest
+ ];
+
+ $op2 = $op;
+ $op2['ignoreMissingSource'] = true;
+ $cases[] = [
+ $op2, // operation
+ $source, // source
+ $dest, // dest
+ ];
+
+ $op2 = $op;
+ $op2['ignoreMissingSource'] = true;
+ $cases[] = [
+ $op2, // operation
+ self::baseStorePath() . '/unittest-cont-bad/e/file.txt', // source
+ $dest, // dest
+ ];
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider provider_testDelete
+ */
+ public function testDelete( $op, $withSource, $okStatus ) {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestDelete( $op, $withSource, $okStatus );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestDelete( $op, $withSource, $okStatus );
+ $this->tearDownFiles();
+ }
+
+ private function doTestDelete( $op, $withSource, $okStatus ) {
+ $backendName = $this->backendClass();
+
+ $source = $op['src'];
+ $this->prepare( [ 'dir' => dirname( $source ) ] );
+
+ if ( $withSource ) {
+ $status = $this->backend->doOperation(
+ [ 'op' => 'create', 'content' => 'blahblah', 'dst' => $source ] );
+ $this->assertGoodStatus( $status,
+ "Creation of file at $source succeeded ($backendName)." );
+ }
+
+ $status = $this->backend->doOperation( $op );
+ if ( $okStatus ) {
+ $this->assertGoodStatus( $status,
+ "Deletion of file at $source succeeded without warnings ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Deletion of file at $source succeeded ($backendName)." );
+ $this->assertEquals( [ 0 => true ], $status->success,
+ "Deletion of file at $source has proper 'success' field in Status ($backendName)." );
+ } else {
+ $this->assertEquals( false, $status->isOK(),
+ "Deletion of file at $source failed ($backendName)." );
+ }
+
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $source ] ),
+ "Source file $source does not exist after move ($backendName)." );
+
+ $this->assertFalse(
+ $this->backend->getFileSize( [ 'src' => $source ] ),
+ "Source file $source has correct size (false) ($backendName)." );
+
+ $props1 = $this->backend->getFileProps( [ 'src' => $source ] );
+ $this->assertFalse( $props1['fileExists'],
+ "Source file $source does not exist according to props ($backendName)." );
+
+ $this->assertBackendPathsConsistent( [ $source ] );
+ }
+
+ public static function provider_testDelete() {
+ $cases = [];
+
+ $source = self::baseStorePath() . '/unittest-cont1/e/myfacefile.txt';
+
+ $op = [ 'op' => 'delete', 'src' => $source ];
+ $cases[] = [
+ $op, // operation
+ true, // with source
+ true // succeeds
+ ];
+
+ $cases[] = [
+ $op, // operation
+ false, // without source
+ false // fails
+ ];
+
+ $op['ignoreMissingSource'] = true;
+ $cases[] = [
+ $op, // operation
+ false, // without source
+ true // succeeds
+ ];
+
+ $op['ignoreMissingSource'] = true;
+ $op['src'] = self::baseStorePath() . '/unittest-cont-bad/e/file.txt';
+ $cases[] = [
+ $op, // operation
+ false, // without source
+ true // succeeds
+ ];
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider provider_testDescribe
+ */
+ public function testDescribe( $op, $withSource, $okStatus ) {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestDescribe( $op, $withSource, $okStatus );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestDescribe( $op, $withSource, $okStatus );
+ $this->tearDownFiles();
+ }
+
+ private function doTestDescribe( $op, $withSource, $okStatus ) {
+ $backendName = $this->backendClass();
+
+ $source = $op['src'];
+ $this->prepare( [ 'dir' => dirname( $source ) ] );
+
+ if ( $withSource ) {
+ $status = $this->backend->doOperation(
+ [ 'op' => 'create', 'content' => 'blahblah', 'dst' => $source,
+ 'headers' => [ 'Content-Disposition' => 'xxx' ] ] );
+ $this->assertGoodStatus( $status,
+ "Creation of file at $source succeeded ($backendName)." );
+ if ( $this->backend->hasFeatures( FileBackend::ATTR_HEADERS ) ) {
+ $attr = $this->backend->getFileXAttributes( [ 'src' => $source ] );
+ $this->assertHasHeaders( [ 'Content-Disposition' => 'xxx' ], $attr );
+ }
+
+ $status = $this->backend->describe( [ 'src' => $source,
+ 'headers' => [ 'Content-Disposition' => '' ] ] ); // remove
+ $this->assertGoodStatus( $status,
+ "Removal of header for $source succeeded ($backendName)." );
+
+ if ( $this->backend->hasFeatures( FileBackend::ATTR_HEADERS ) ) {
+ $attr = $this->backend->getFileXAttributes( [ 'src' => $source ] );
+ $this->assertFalse( isset( $attr['headers']['content-disposition'] ),
+ "File 'Content-Disposition' header removed." );
+ }
+ }
+
+ $status = $this->backend->doOperation( $op );
+ if ( $okStatus ) {
+ $this->assertGoodStatus( $status,
+ "Describe of file at $source succeeded without warnings ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Describe of file at $source succeeded ($backendName)." );
+ $this->assertEquals( [ 0 => true ], $status->success,
+ "Describe of file at $source has proper 'success' field in Status ($backendName)." );
+ if ( $this->backend->hasFeatures( FileBackend::ATTR_HEADERS ) ) {
+ $attr = $this->backend->getFileXAttributes( [ 'src' => $source ] );
+ $this->assertHasHeaders( $op['headers'], $attr );
+ }
+ } else {
+ $this->assertEquals( false, $status->isOK(),
+ "Describe of file at $source failed ($backendName)." );
+ }
+
+ $this->assertBackendPathsConsistent( [ $source ] );
+ }
+
+ private function assertHasHeaders( array $headers, array $attr ) {
+ foreach ( $headers as $n => $v ) {
+ if ( $n !== '' ) {
+ $this->assertTrue( isset( $attr['headers'][strtolower( $n )] ),
+ "File has '$n' header." );
+ $this->assertEquals( $v, $attr['headers'][strtolower( $n )],
+ "File has '$n' header value." );
+ } else {
+ $this->assertFalse( isset( $attr['headers'][strtolower( $n )] ),
+ "File does not have '$n' header." );
+ }
+ }
+ }
+
+ public static function provider_testDescribe() {
+ $cases = [];
+
+ $source = self::baseStorePath() . '/unittest-cont1/e/myfacefile.txt';
+
+ $op = [ 'op' => 'describe', 'src' => $source,
+ 'headers' => [ 'Content-Disposition' => 'inline' ], ];
+ $cases[] = [
+ $op, // operation
+ true, // with source
+ true // succeeds
+ ];
+
+ $cases[] = [
+ $op, // operation
+ false, // without source
+ false // fails
+ ];
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider provider_testCreate
+ */
+ public function testCreate( $op, $alreadyExists, $okStatus, $newSize ) {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestCreate( $op, $alreadyExists, $okStatus, $newSize );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestCreate( $op, $alreadyExists, $okStatus, $newSize );
+ $this->tearDownFiles();
+ }
+
+ private function doTestCreate( $op, $alreadyExists, $okStatus, $newSize ) {
+ $backendName = $this->backendClass();
+
+ $dest = $op['dst'];
+ $this->prepare( [ 'dir' => dirname( $dest ) ] );
+
+ $oldText = 'blah...blah...waahwaah';
+ if ( $alreadyExists ) {
+ $status = $this->backend->doOperation(
+ [ 'op' => 'create', 'content' => $oldText, 'dst' => $dest ] );
+ $this->assertGoodStatus( $status,
+ "Creation of file at $dest succeeded ($backendName)." );
+ }
+
+ $status = $this->backend->doOperation( $op );
+ if ( $okStatus ) {
+ $this->assertGoodStatus( $status,
+ "Creation of file at $dest succeeded without warnings ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Creation of file at $dest succeeded ($backendName)." );
+ $this->assertEquals( [ 0 => true ], $status->success,
+ "Creation of file at $dest has proper 'success' field in Status ($backendName)." );
+ } else {
+ $this->assertEquals( false, $status->isOK(),
+ "Creation of file at $dest failed ($backendName)." );
+ }
+
+ $this->assertEquals( true, $this->backend->fileExists( [ 'src' => $dest ] ),
+ "Destination file $dest exists after creation ($backendName)." );
+
+ $props1 = $this->backend->getFileProps( [ 'src' => $dest ] );
+ $this->assertEquals( true, $props1['fileExists'],
+ "Destination file $dest exists according to props ($backendName)." );
+ if ( $okStatus ) { // file content is what we saved
+ $this->assertEquals( $newSize, $props1['size'],
+ "Destination file $dest has expected size according to props ($backendName)." );
+ $this->assertEquals( $newSize,
+ $this->backend->getFileSize( [ 'src' => $dest ] ),
+ "Destination file $dest has correct size ($backendName)." );
+ } else { // file content is some other previous text
+ $this->assertEquals( strlen( $oldText ), $props1['size'],
+ "Destination file $dest has original size according to props ($backendName)." );
+ $this->assertEquals( strlen( $oldText ),
+ $this->backend->getFileSize( [ 'src' => $dest ] ),
+ "Destination file $dest has original size according to props ($backendName)." );
+ }
+
+ $this->assertBackendPathsConsistent( [ $dest ] );
+ }
+
+ /**
+ * @dataProvider provider_testCreate
+ */
+ public static function provider_testCreate() {
+ $cases = [];
+
+ $dest = self::baseStorePath() . '/unittest-cont2/a/myspacefile.txt';
+
+ $op = [ 'op' => 'create', 'content' => 'test test testing', 'dst' => $dest ];
+ $cases[] = [
+ $op, // operation
+ false, // no dest already exists
+ true, // succeeds
+ strlen( $op['content'] )
+ ];
+
+ $op2 = $op;
+ $op2['content'] = "\n";
+ $cases[] = [
+ $op2, // operation
+ false, // no dest already exists
+ true, // succeeds
+ strlen( $op2['content'] )
+ ];
+
+ $op2 = $op;
+ $op2['content'] = "fsf\n waf 3kt";
+ $cases[] = [
+ $op2, // operation
+ true, // dest already exists
+ false, // fails
+ strlen( $op2['content'] )
+ ];
+
+ $op2 = $op;
+ $op2['content'] = "egm'g gkpe gpqg eqwgwqg";
+ $op2['overwrite'] = true;
+ $cases[] = [
+ $op2, // operation
+ true, // dest already exists
+ true, // succeeds
+ strlen( $op2['content'] )
+ ];
+
+ $op2 = $op;
+ $op2['content'] = "39qjmg3-qg";
+ $op2['overwriteSame'] = true;
+ $cases[] = [
+ $op2, // operation
+ true, // dest already exists
+ false, // succeeds
+ strlen( $op2['content'] )
+ ];
+
+ return $cases;
+ }
+
+ public function testDoQuickOperations() {
+ $this->backend = $this->singleBackend;
+ $this->doTestDoQuickOperations();
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->doTestDoQuickOperations();
+ $this->tearDownFiles();
+ }
+
+ private function doTestDoQuickOperations() {
+ $backendName = $this->backendClass();
+
+ $base = self::baseStorePath();
+ $files = [
+ "$base/unittest-cont1/e/fileA.a",
+ "$base/unittest-cont1/e/fileB.a",
+ "$base/unittest-cont1/e/fileC.a"
+ ];
+ $createOps = [];
+ $purgeOps = [];
+ foreach ( $files as $path ) {
+ $status = $this->prepare( [ 'dir' => dirname( $path ) ] );
+ $this->assertGoodStatus( $status,
+ "Preparing $path succeeded without warnings ($backendName)." );
+ $createOps[] = [ 'op' => 'create', 'dst' => $path, 'content' => mt_rand( 0, 50000 ) ];
+ $copyOps[] = [ 'op' => 'copy', 'src' => $path, 'dst' => "$path-2" ];
+ $moveOps[] = [ 'op' => 'move', 'src' => "$path-2", 'dst' => "$path-3" ];
+ $purgeOps[] = [ 'op' => 'delete', 'src' => $path ];
+ $purgeOps[] = [ 'op' => 'delete', 'src' => "$path-3" ];
+ }
+ $purgeOps[] = [ 'op' => 'null' ];
+
+ $this->assertGoodStatus(
+ $this->backend->doQuickOperations( $createOps ),
+ "Creation of source files succeeded ($backendName)." );
+ foreach ( $files as $file ) {
+ $this->assertTrue( $this->backend->fileExists( [ 'src' => $file ] ),
+ "File $file exists." );
+ }
+
+ $this->assertGoodStatus(
+ $this->backend->doQuickOperations( $copyOps ),
+ "Quick copy of source files succeeded ($backendName)." );
+ foreach ( $files as $file ) {
+ $this->assertTrue( $this->backend->fileExists( [ 'src' => "$file-2" ] ),
+ "File $file-2 exists." );
+ }
+
+ $this->assertGoodStatus(
+ $this->backend->doQuickOperations( $moveOps ),
+ "Quick move of source files succeeded ($backendName)." );
+ foreach ( $files as $file ) {
+ $this->assertTrue( $this->backend->fileExists( [ 'src' => "$file-3" ] ),
+ "File $file-3 move in." );
+ $this->assertFalse( $this->backend->fileExists( [ 'src' => "$file-2" ] ),
+ "File $file-2 moved away." );
+ }
+
+ $this->assertGoodStatus(
+ $this->backend->quickCopy( [ 'src' => $files[0], 'dst' => $files[0] ] ),
+ "Copy of file {$files[0]} over itself succeeded ($backendName)." );
+ $this->assertTrue( $this->backend->fileExists( [ 'src' => $files[0] ] ),
+ "File {$files[0]} still exists." );
+
+ $this->assertGoodStatus(
+ $this->backend->quickMove( [ 'src' => $files[0], 'dst' => $files[0] ] ),
+ "Move of file {$files[0]} over itself succeeded ($backendName)." );
+ $this->assertTrue( $this->backend->fileExists( [ 'src' => $files[0] ] ),
+ "File {$files[0]} still exists." );
+
+ $this->assertGoodStatus(
+ $this->backend->doQuickOperations( $purgeOps ),
+ "Quick deletion of source files succeeded ($backendName)." );
+ foreach ( $files as $file ) {
+ $this->assertFalse( $this->backend->fileExists( [ 'src' => $file ] ),
+ "File $file purged." );
+ $this->assertFalse( $this->backend->fileExists( [ 'src' => "$file-3" ] ),
+ "File $file-3 purged." );
+ }
+ }
+
+ /**
+ * @dataProvider provider_testConcatenate
+ */
+ public function testConcatenate( $op, $srcs, $srcsContent, $alreadyExists, $okStatus ) {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestConcatenate( $op, $srcs, $srcsContent, $alreadyExists, $okStatus );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestConcatenate( $op, $srcs, $srcsContent, $alreadyExists, $okStatus );
+ $this->tearDownFiles();
+ }
+
+ private function doTestConcatenate( $params, $srcs, $srcsContent, $alreadyExists, $okStatus ) {
+ $backendName = $this->backendClass();
+
+ $expContent = '';
+ // Create sources
+ $ops = [];
+ foreach ( $srcs as $i => $source ) {
+ $this->prepare( [ 'dir' => dirname( $source ) ] );
+ $ops[] = [
+ 'op' => 'create', // operation
+ 'dst' => $source, // source
+ 'content' => $srcsContent[$i]
+ ];
+ $expContent .= $srcsContent[$i];
+ }
+ $status = $this->backend->doOperations( $ops );
+
+ $this->assertGoodStatus( $status,
+ "Creation of source files succeeded ($backendName)." );
+
+ $dest = $params['dst'] = $this->getNewTempFile();
+ if ( $alreadyExists ) {
+ $ok = file_put_contents( $dest, 'blah...blah...waahwaah' ) !== false;
+ $this->assertEquals( true, $ok,
+ "Creation of file at $dest succeeded ($backendName)." );
+ } else {
+ $ok = file_put_contents( $dest, '' ) !== false;
+ $this->assertEquals( true, $ok,
+ "Creation of 0-byte file at $dest succeeded ($backendName)." );
+ }
+
+ // Combine the files into one
+ $status = $this->backend->concatenate( $params );
+ if ( $okStatus ) {
+ $this->assertGoodStatus( $status,
+ "Creation of concat file at $dest succeeded without warnings ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Creation of concat file at $dest succeeded ($backendName)." );
+ } else {
+ $this->assertEquals( false, $status->isOK(),
+ "Creation of concat file at $dest failed ($backendName)." );
+ }
+
+ if ( $okStatus ) {
+ $this->assertEquals( true, is_file( $dest ),
+ "Dest concat file $dest exists after creation ($backendName)." );
+ } else {
+ $this->assertEquals( true, is_file( $dest ),
+ "Dest concat file $dest exists after failed creation ($backendName)." );
+ }
+
+ $contents = file_get_contents( $dest );
+ $this->assertNotEquals( false, $contents, "File at $dest exists ($backendName)." );
+
+ if ( $okStatus ) {
+ $this->assertEquals( $expContent, $contents,
+ "Concat file at $dest has correct contents ($backendName)." );
+ } else {
+ $this->assertNotEquals( $expContent, $contents,
+ "Concat file at $dest has correct contents ($backendName)." );
+ }
+ }
+
+ public static function provider_testConcatenate() {
+ $cases = [];
+
+ $srcs = [
+ self::baseStorePath() . '/unittest-cont1/e/file1.txt',
+ self::baseStorePath() . '/unittest-cont1/e/file2.txt',
+ self::baseStorePath() . '/unittest-cont1/e/file3.txt',
+ self::baseStorePath() . '/unittest-cont1/e/file4.txt',
+ self::baseStorePath() . '/unittest-cont1/e/file5.txt',
+ self::baseStorePath() . '/unittest-cont1/e/file6.txt',
+ self::baseStorePath() . '/unittest-cont1/e/file7.txt',
+ self::baseStorePath() . '/unittest-cont1/e/file8.txt',
+ self::baseStorePath() . '/unittest-cont1/e/file9.txt',
+ self::baseStorePath() . '/unittest-cont1/e/file10.txt'
+ ];
+ $content = [
+ 'egfage',
+ 'ageageag',
+ 'rhokohlr',
+ 'shgmslkg',
+ 'kenga',
+ 'owagmal',
+ 'kgmae',
+ 'g eak;g',
+ 'lkaem;a',
+ 'legma'
+ ];
+ $params = [ 'srcs' => $srcs ];
+
+ $cases[] = [
+ $params, // operation
+ $srcs, // sources
+ $content, // content for each source
+ false, // no dest already exists
+ true, // succeeds
+ ];
+
+ $cases[] = [
+ $params, // operation
+ $srcs, // sources
+ $content, // content for each source
+ true, // dest already exists
+ false, // succeeds
+ ];
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider provider_testGetFileStat
+ */
+ public function testGetFileStat( $path, $content, $alreadyExists ) {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestGetFileStat( $path, $content, $alreadyExists );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestGetFileStat( $path, $content, $alreadyExists );
+ $this->tearDownFiles();
+ }
+
+ private function doTestGetFileStat( $path, $content, $alreadyExists ) {
+ $backendName = $this->backendClass();
+
+ if ( $alreadyExists ) {
+ $this->prepare( [ 'dir' => dirname( $path ) ] );
+ $status = $this->create( [ 'dst' => $path, 'content' => $content ] );
+ $this->assertGoodStatus( $status,
+ "Creation of file at $path succeeded ($backendName)." );
+
+ $size = $this->backend->getFileSize( [ 'src' => $path ] );
+ $time = $this->backend->getFileTimestamp( [ 'src' => $path ] );
+ $stat = $this->backend->getFileStat( [ 'src' => $path ] );
+
+ $this->assertEquals( strlen( $content ), $size,
+ "Correct file size of '$path'" );
+ $this->assertTrue( abs( time() - wfTimestamp( TS_UNIX, $time ) ) < 10,
+ "Correct file timestamp of '$path'" );
+
+ $size = $stat['size'];
+ $time = $stat['mtime'];
+ $this->assertEquals( strlen( $content ), $size,
+ "Correct file size of '$path'" );
+ $this->assertTrue( abs( time() - wfTimestamp( TS_UNIX, $time ) ) < 10,
+ "Correct file timestamp of '$path'" );
+
+ $this->backend->clearCache( [ $path ] );
+
+ $size = $this->backend->getFileSize( [ 'src' => $path ] );
+
+ $this->assertEquals( strlen( $content ), $size,
+ "Correct file size of '$path'" );
+
+ $this->backend->preloadCache( [ $path ] );
+
+ $size = $this->backend->getFileSize( [ 'src' => $path ] );
+
+ $this->assertEquals( strlen( $content ), $size,
+ "Correct file size of '$path'" );
+ } else {
+ $size = $this->backend->getFileSize( [ 'src' => $path ] );
+ $time = $this->backend->getFileTimestamp( [ 'src' => $path ] );
+ $stat = $this->backend->getFileStat( [ 'src' => $path ] );
+
+ $this->assertFalse( $size, "Correct file size of '$path'" );
+ $this->assertFalse( $time, "Correct file timestamp of '$path'" );
+ $this->assertFalse( $stat, "Correct file stat of '$path'" );
+ }
+ }
+
+ public static function provider_testGetFileStat() {
+ $cases = [];
+
+ $base = self::baseStorePath();
+ $cases[] = [ "$base/unittest-cont1/e/b/z/some_file.txt", "some file contents", true ];
+ $cases[] = [ "$base/unittest-cont1/e/b/some-other_file.txt", "", true ];
+ $cases[] = [ "$base/unittest-cont1/e/b/some-diff_file.txt", null, false ];
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider provider_testGetFileStat
+ */
+ public function testStreamFile( $path, $content, $alreadyExists ) {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestStreamFile( $path, $content, $alreadyExists );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestStreamFile( $path, $content, $alreadyExists );
+ $this->tearDownFiles();
+ }
+
+ private function doTestStreamFile( $path, $content ) {
+ $backendName = $this->backendClass();
+
+ if ( $content !== null ) {
+ $this->prepare( [ 'dir' => dirname( $path ) ] );
+ $status = $this->create( [ 'dst' => $path, 'content' => $content ] );
+ $this->assertGoodStatus( $status,
+ "Creation of file at $path succeeded ($backendName)." );
+
+ ob_start();
+ $this->backend->streamFile( [ 'src' => $path, 'headless' => 1, 'allowOB' => 1 ] );
+ $data = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals( $content, $data, "Correct content streamed from '$path'" );
+ } else { // 404 case
+ ob_start();
+ $this->backend->streamFile( [ 'src' => $path, 'headless' => 1, 'allowOB' => 1 ] );
+ $data = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertRegExp( '#<h1>File not found</h1>#', $data,
+ "Correct content streamed from '$path' ($backendName)" );
+ }
+ }
+
+ public static function provider_testStreamFile() {
+ $cases = [];
+
+ $base = self::baseStorePath();
+ $cases[] = [ "$base/unittest-cont1/e/b/z/some_file.txt", "some file contents" ];
+ $cases[] = [ "$base/unittest-cont1/e/b/some-other_file.txt", null ];
+
+ return $cases;
+ }
+
+ public function testStreamFileRange() {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestStreamFileRange();
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestStreamFileRange();
+ $this->tearDownFiles();
+ }
+
+ private function doTestStreamFileRange() {
+ $backendName = $this->backendClass();
+
+ $base = self::baseStorePath();
+ $path = "$base/unittest-cont1/e/b/z/range_file.txt";
+ $content = "0123456789ABCDEF";
+
+ $this->prepare( [ 'dir' => dirname( $path ) ] );
+ $status = $this->create( [ 'dst' => $path, 'content' => $content ] );
+ $this->assertGoodStatus( $status,
+ "Creation of file at $path succeeded ($backendName)." );
+
+ static $ranges = [
+ 'bytes=0-0' => '0',
+ 'bytes=0-3' => '0123',
+ 'bytes=4-8' => '45678',
+ 'bytes=15-15' => 'F',
+ 'bytes=14-15' => 'EF',
+ 'bytes=-5' => 'BCDEF',
+ 'bytes=-1' => 'F',
+ 'bytes=10-16' => 'ABCDEF',
+ 'bytes=10-99' => 'ABCDEF',
+ ];
+
+ foreach ( $ranges as $range => $chunk ) {
+ ob_start();
+ $this->backend->streamFile( [ 'src' => $path, 'headless' => 1, 'allowOB' => 1,
+ 'options' => [ 'range' => $range ] ] );
+ $data = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals( $chunk, $data, "Correct chunk streamed from '$path' for '$range'" );
+ }
+ }
+
+ /**
+ * @dataProvider provider_testGetFileContents
+ */
+ public function testGetFileContents( $source, $content ) {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestGetFileContents( $source, $content );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestGetFileContents( $source, $content );
+ $this->tearDownFiles();
+ }
+
+ private function doTestGetFileContents( $source, $content ) {
+ $backendName = $this->backendClass();
+
+ $srcs = (array)$source;
+ $content = (array)$content;
+ foreach ( $srcs as $i => $src ) {
+ $this->prepare( [ 'dir' => dirname( $src ) ] );
+ $status = $this->backend->doOperation(
+ [ 'op' => 'create', 'content' => $content[$i], 'dst' => $src ] );
+ $this->assertGoodStatus( $status,
+ "Creation of file at $src succeeded ($backendName)." );
+ }
+
+ if ( is_array( $source ) ) {
+ $contents = $this->backend->getFileContentsMulti( [ 'srcs' => $source ] );
+ foreach ( $contents as $path => $data ) {
+ $this->assertNotEquals( false, $data, "Contents of $path exists ($backendName)." );
+ $this->assertEquals(
+ current( $content ),
+ $data,
+ "Contents of $path is correct ($backendName)."
+ );
+ next( $content );
+ }
+ $this->assertEquals(
+ $source,
+ array_keys( $contents ),
+ "Contents in right order ($backendName)."
+ );
+ $this->assertEquals(
+ count( $source ),
+ count( $contents ),
+ "Contents array size correct ($backendName)."
+ );
+ } else {
+ $data = $this->backend->getFileContents( [ 'src' => $source ] );
+ $this->assertNotEquals( false, $data, "Contents of $source exists ($backendName)." );
+ $this->assertEquals( $content[0], $data, "Contents of $source is correct ($backendName)." );
+ }
+ }
+
+ public static function provider_testGetFileContents() {
+ $cases = [];
+
+ $base = self::baseStorePath();
+ $cases[] = [ "$base/unittest-cont1/e/b/z/some_file.txt", "some file contents" ];
+ $cases[] = [ "$base/unittest-cont1/e/b/some-other_file.txt", "more file contents" ];
+ $cases[] = [
+ [ "$base/unittest-cont1/e/a/x.txt", "$base/unittest-cont1/e/a/y.txt",
+ "$base/unittest-cont1/e/a/z.txt" ],
+ [ "contents xx", "contents xy", "contents xz" ]
+ ];
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider provider_testGetLocalCopy
+ */
+ public function testGetLocalCopy( $source, $content ) {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestGetLocalCopy( $source, $content );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestGetLocalCopy( $source, $content );
+ $this->tearDownFiles();
+ }
+
+ private function doTestGetLocalCopy( $source, $content ) {
+ $backendName = $this->backendClass();
+
+ $srcs = (array)$source;
+ $content = (array)$content;
+ foreach ( $srcs as $i => $src ) {
+ $this->prepare( [ 'dir' => dirname( $src ) ] );
+ $status = $this->backend->doOperation(
+ [ 'op' => 'create', 'content' => $content[$i], 'dst' => $src ] );
+ $this->assertGoodStatus( $status,
+ "Creation of file at $src succeeded ($backendName)." );
+ }
+
+ if ( is_array( $source ) ) {
+ $tmpFiles = $this->backend->getLocalCopyMulti( [ 'srcs' => $source ] );
+ foreach ( $tmpFiles as $path => $tmpFile ) {
+ $this->assertNotNull( $tmpFile,
+ "Creation of local copy of $path succeeded ($backendName)." );
+ $contents = file_get_contents( $tmpFile->getPath() );
+ $this->assertNotEquals( false, $contents, "Local copy of $path exists ($backendName)." );
+ $this->assertEquals(
+ current( $content ),
+ $contents,
+ "Local copy of $path is correct ($backendName)."
+ );
+ next( $content );
+ }
+ $this->assertEquals(
+ $source,
+ array_keys( $tmpFiles ),
+ "Local copies in right order ($backendName)."
+ );
+ $this->assertEquals(
+ count( $source ),
+ count( $tmpFiles ),
+ "Local copies array size correct ($backendName)."
+ );
+ } else {
+ $tmpFile = $this->backend->getLocalCopy( [ 'src' => $source ] );
+ $this->assertNotNull( $tmpFile,
+ "Creation of local copy of $source succeeded ($backendName)." );
+ $contents = file_get_contents( $tmpFile->getPath() );
+ $this->assertNotEquals( false, $contents, "Local copy of $source exists ($backendName)." );
+ $this->assertEquals(
+ $content[0],
+ $contents,
+ "Local copy of $source is correct ($backendName)."
+ );
+ }
+
+ $obj = new stdClass();
+ $tmpFile->bind( $obj );
+ }
+
+ public static function provider_testGetLocalCopy() {
+ $cases = [];
+
+ $base = self::baseStorePath();
+ $cases[] = [ "$base/unittest-cont1/e/a/z/some_file.txt", "some file contents" ];
+ $cases[] = [ "$base/unittest-cont1/e/a/some-other_file.txt", "more file contents" ];
+ $cases[] = [ "$base/unittest-cont1/e/a/\$odd&.txt", "test file contents" ];
+ $cases[] = [
+ [ "$base/unittest-cont1/e/a/x.txt", "$base/unittest-cont1/e/a/y.txt",
+ "$base/unittest-cont1/e/a/z.txt" ],
+ [ "contents xx $", "contents xy 111", "contents xz" ]
+ ];
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider provider_testGetLocalReference
+ */
+ public function testGetLocalReference( $source, $content ) {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestGetLocalReference( $source, $content );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestGetLocalReference( $source, $content );
+ $this->tearDownFiles();
+ }
+
+ private function doTestGetLocalReference( $source, $content ) {
+ $backendName = $this->backendClass();
+
+ $srcs = (array)$source;
+ $content = (array)$content;
+ foreach ( $srcs as $i => $src ) {
+ $this->prepare( [ 'dir' => dirname( $src ) ] );
+ $status = $this->backend->doOperation(
+ [ 'op' => 'create', 'content' => $content[$i], 'dst' => $src ] );
+ $this->assertGoodStatus( $status,
+ "Creation of file at $src succeeded ($backendName)." );
+ }
+
+ if ( is_array( $source ) ) {
+ $tmpFiles = $this->backend->getLocalReferenceMulti( [ 'srcs' => $source ] );
+ foreach ( $tmpFiles as $path => $tmpFile ) {
+ $this->assertNotNull( $tmpFile,
+ "Creation of local copy of $path succeeded ($backendName)." );
+ $contents = file_get_contents( $tmpFile->getPath() );
+ $this->assertNotEquals( false, $contents, "Local ref of $path exists ($backendName)." );
+ $this->assertEquals(
+ current( $content ),
+ $contents,
+ "Local ref of $path is correct ($backendName)."
+ );
+ next( $content );
+ }
+ $this->assertEquals(
+ $source,
+ array_keys( $tmpFiles ),
+ "Local refs in right order ($backendName)."
+ );
+ $this->assertEquals(
+ count( $source ),
+ count( $tmpFiles ),
+ "Local refs array size correct ($backendName)."
+ );
+ } else {
+ $tmpFile = $this->backend->getLocalReference( [ 'src' => $source ] );
+ $this->assertNotNull( $tmpFile,
+ "Creation of local copy of $source succeeded ($backendName)." );
+ $contents = file_get_contents( $tmpFile->getPath() );
+ $this->assertNotEquals( false, $contents, "Local ref of $source exists ($backendName)." );
+ $this->assertEquals( $content[0], $contents, "Local ref of $source is correct ($backendName)." );
+ }
+ }
+
+ public static function provider_testGetLocalReference() {
+ $cases = [];
+
+ $base = self::baseStorePath();
+ $cases[] = [ "$base/unittest-cont1/e/a/z/some_file.txt", "some file contents" ];
+ $cases[] = [ "$base/unittest-cont1/e/a/some-other_file.txt", "more file contents" ];
+ $cases[] = [ "$base/unittest-cont1/e/a/\$odd&.txt", "test file contents" ];
+ $cases[] = [
+ [ "$base/unittest-cont1/e/a/x.txt", "$base/unittest-cont1/e/a/y.txt",
+ "$base/unittest-cont1/e/a/z.txt" ],
+ [ "contents xx 1111", "contents xy %", "contents xz $" ]
+ ];
+
+ return $cases;
+ }
+
+ public function testGetLocalCopyAndReference404() {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestGetLocalCopyAndReference404();
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestGetLocalCopyAndReference404();
+ $this->tearDownFiles();
+ }
+
+ public function doTestGetLocalCopyAndReference404() {
+ $backendName = $this->backendClass();
+
+ $base = self::baseStorePath();
+
+ $tmpFile = $this->backend->getLocalCopy( [
+ 'src' => "$base/unittest-cont1/not-there" ] );
+ $this->assertEquals( null, $tmpFile, "Local copy of not existing file is null ($backendName)." );
+
+ $tmpFile = $this->backend->getLocalReference( [
+ 'src' => "$base/unittest-cont1/not-there" ] );
+ $this->assertEquals( null, $tmpFile, "Local ref of not existing file is null ($backendName)." );
+ }
+
+ /**
+ * @dataProvider provider_testGetFileHttpUrl
+ */
+ public function testGetFileHttpUrl( $source, $content ) {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestGetFileHttpUrl( $source, $content );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestGetFileHttpUrl( $source, $content );
+ $this->tearDownFiles();
+ }
+
+ private function doTestGetFileHttpUrl( $source, $content ) {
+ $backendName = $this->backendClass();
+
+ $this->prepare( [ 'dir' => dirname( $source ) ] );
+ $status = $this->backend->doOperation(
+ [ 'op' => 'create', 'content' => $content, 'dst' => $source ] );
+ $this->assertGoodStatus( $status,
+ "Creation of file at $source succeeded ($backendName)." );
+
+ $url = $this->backend->getFileHttpUrl( [ 'src' => $source ] );
+
+ if ( $url !== null ) { // supported
+ $data = Http::request( "GET", $url, [], __METHOD__ );
+ $this->assertEquals( $content, $data,
+ "HTTP GET of URL has right contents ($backendName)." );
+ }
+ }
+
+ public static function provider_testGetFileHttpUrl() {
+ $cases = [];
+
+ $base = self::baseStorePath();
+ $cases[] = [ "$base/unittest-cont1/e/a/z/some_file.txt", "some file contents" ];
+ $cases[] = [ "$base/unittest-cont1/e/a/some-other_file.txt", "more file contents" ];
+ $cases[] = [ "$base/unittest-cont1/e/a/\$odd&.txt", "test file contents" ];
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider provider_testPrepareAndClean
+ */
+ public function testPrepareAndClean( $path, $isOK ) {
+ $this->backend = $this->singleBackend;
+ $this->doTestPrepareAndClean( $path, $isOK );
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->doTestPrepareAndClean( $path, $isOK );
+ $this->tearDownFiles();
+ }
+
+ public static function provider_testPrepareAndClean() {
+ $base = self::baseStorePath();
+
+ return [
+ [ "$base/unittest-cont1/e/a/z/some_file1.txt", true ],
+ [ "$base/unittest-cont2/a/z/some_file2.txt", true ],
+ # Specific to FS backend with no basePath field set
+ # [ "$base/unittest-cont3/a/z/some_file3.txt", false ],
+ ];
+ }
+
+ private function doTestPrepareAndClean( $path, $isOK ) {
+ $backendName = $this->backendClass();
+
+ $status = $this->prepare( [ 'dir' => dirname( $path ) ] );
+ if ( $isOK ) {
+ $this->assertGoodStatus( $status,
+ "Preparing dir $path succeeded without warnings ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Preparing dir $path succeeded ($backendName)." );
+ } else {
+ $this->assertEquals( false, $status->isOK(),
+ "Preparing dir $path failed ($backendName)." );
+ }
+
+ $status = $this->backend->secure( [ 'dir' => dirname( $path ) ] );
+ if ( $isOK ) {
+ $this->assertGoodStatus( $status,
+ "Securing dir $path succeeded without warnings ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Securing dir $path succeeded ($backendName)." );
+ } else {
+ $this->assertEquals( false, $status->isOK(),
+ "Securing dir $path failed ($backendName)." );
+ }
+
+ $status = $this->backend->publish( [ 'dir' => dirname( $path ) ] );
+ if ( $isOK ) {
+ $this->assertGoodStatus( $status,
+ "Publishing dir $path succeeded without warnings ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Publishing dir $path succeeded ($backendName)." );
+ } else {
+ $this->assertEquals( false, $status->isOK(),
+ "Publishing dir $path failed ($backendName)." );
+ }
+
+ $status = $this->backend->clean( [ 'dir' => dirname( $path ) ] );
+ if ( $isOK ) {
+ $this->assertGoodStatus( $status,
+ "Cleaning dir $path succeeded without warnings ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Cleaning dir $path succeeded ($backendName)." );
+ } else {
+ $this->assertEquals( false, $status->isOK(),
+ "Cleaning dir $path failed ($backendName)." );
+ }
+ }
+
+ public function testRecursiveClean() {
+ $this->backend = $this->singleBackend;
+ $this->doTestRecursiveClean();
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->doTestRecursiveClean();
+ $this->tearDownFiles();
+ }
+
+ private function doTestRecursiveClean() {
+ $backendName = $this->backendClass();
+
+ $base = self::baseStorePath();
+ $dirs = [
+ "$base/unittest-cont1",
+ "$base/unittest-cont1/e",
+ "$base/unittest-cont1/e/a",
+ "$base/unittest-cont1/e/a/b",
+ "$base/unittest-cont1/e/a/b/c",
+ "$base/unittest-cont1/e/a/b/c/d0",
+ "$base/unittest-cont1/e/a/b/c/d1",
+ "$base/unittest-cont1/e/a/b/c/d2",
+ "$base/unittest-cont1/e/a/b/c/d0/1",
+ "$base/unittest-cont1/e/a/b/c/d0/2",
+ "$base/unittest-cont1/e/a/b/c/d1/3",
+ "$base/unittest-cont1/e/a/b/c/d1/4",
+ "$base/unittest-cont1/e/a/b/c/d2/5",
+ "$base/unittest-cont1/e/a/b/c/d2/6"
+ ];
+ foreach ( $dirs as $dir ) {
+ $status = $this->prepare( [ 'dir' => $dir ] );
+ $this->assertGoodStatus( $status,
+ "Preparing dir $dir succeeded without warnings ($backendName)." );
+ }
+
+ if ( $this->backend instanceof FSFileBackend ) {
+ foreach ( $dirs as $dir ) {
+ $this->assertEquals( true, $this->backend->directoryExists( [ 'dir' => $dir ] ),
+ "Dir $dir exists ($backendName)." );
+ }
+ }
+
+ $status = $this->backend->clean(
+ [ 'dir' => "$base/unittest-cont1", 'recursive' => 1 ] );
+ $this->assertGoodStatus( $status,
+ "Recursive cleaning of dir $dir succeeded without warnings ($backendName)." );
+
+ foreach ( $dirs as $dir ) {
+ $this->assertEquals( false, $this->backend->directoryExists( [ 'dir' => $dir ] ),
+ "Dir $dir no longer exists ($backendName)." );
+ }
+ }
+
+ public function testDoOperations() {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestDoOperations();
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestDoOperations();
+ $this->tearDownFiles();
+ }
+
+ private function doTestDoOperations() {
+ $base = self::baseStorePath();
+
+ $fileA = "$base/unittest-cont1/e/a/b/fileA.txt";
+ $fileAContents = '3tqtmoeatmn4wg4qe-mg3qt3 tq';
+ $fileB = "$base/unittest-cont1/e/a/b/fileB.txt";
+ $fileBContents = 'g-jmq3gpqgt3qtg q3GT ';
+ $fileC = "$base/unittest-cont1/e/a/b/fileC.txt";
+ $fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag';
+ $fileD = "$base/unittest-cont1/e/a/b/fileD.txt";
+
+ $this->prepare( [ 'dir' => dirname( $fileA ) ] );
+ $this->create( [ 'dst' => $fileA, 'content' => $fileAContents ] );
+ $this->prepare( [ 'dir' => dirname( $fileB ) ] );
+ $this->create( [ 'dst' => $fileB, 'content' => $fileBContents ] );
+ $this->prepare( [ 'dir' => dirname( $fileC ) ] );
+ $this->create( [ 'dst' => $fileC, 'content' => $fileCContents ] );
+ $this->prepare( [ 'dir' => dirname( $fileD ) ] );
+
+ $status = $this->backend->doOperations( [
+ [ 'op' => 'describe', 'src' => $fileA,
+ 'headers' => [ 'X-Content-Length' => '91.3' ], 'disposition' => 'inline' ],
+ [ 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC, 'overwrite' => 1 ],
+ // Now: A:<A>, B:<B>, C:<A>, D:<empty> (file:<orginal contents>)
+ [ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileA, 'overwriteSame' => 1 ],
+ // Now: A:<A>, B:<B>, C:<A>, D:<empty>
+ [ 'op' => 'move', 'src' => $fileC, 'dst' => $fileD, 'overwrite' => 1 ],
+ // Now: A:<A>, B:<B>, C:<empty>, D:<A>
+ [ 'op' => 'move', 'src' => $fileB, 'dst' => $fileC ],
+ // Now: A:<A>, B:<empty>, C:<B>, D:<A>
+ [ 'op' => 'move', 'src' => $fileD, 'dst' => $fileA, 'overwriteSame' => 1 ],
+ // Now: A:<A>, B:<empty>, C:<B>, D:<empty>
+ [ 'op' => 'move', 'src' => $fileC, 'dst' => $fileA, 'overwrite' => 1 ],
+ // Now: A:<B>, B:<empty>, C:<empty>, D:<empty>
+ [ 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC ],
+ // Now: A:<B>, B:<empty>, C:<B>, D:<empty>
+ [ 'op' => 'move', 'src' => $fileA, 'dst' => $fileC, 'overwriteSame' => 1 ],
+ // Now: A:<empty>, B:<empty>, C:<B>, D:<empty>
+ [ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ],
+ // Does nothing
+ [ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ],
+ // Does nothing
+ [ 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ],
+ // Does nothing
+ [ 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ],
+ // Does nothing
+ [ 'op' => 'null' ],
+ // Does nothing
+ ] );
+
+ $this->assertGoodStatus( $status, "Operation batch succeeded" );
+ $this->assertEquals( true, $status->isOK(), "Operation batch succeeded" );
+ $this->assertEquals( 14, count( $status->success ),
+ "Operation batch has correct success array" );
+
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $fileA ] ),
+ "File does not exist at $fileA" );
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $fileB ] ),
+ "File does not exist at $fileB" );
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $fileD ] ),
+ "File does not exist at $fileD" );
+
+ $this->assertEquals( true, $this->backend->fileExists( [ 'src' => $fileC ] ),
+ "File exists at $fileC" );
+ $this->assertEquals( $fileBContents,
+ $this->backend->getFileContents( [ 'src' => $fileC ] ),
+ "Correct file contents of $fileC" );
+ $this->assertEquals( strlen( $fileBContents ),
+ $this->backend->getFileSize( [ 'src' => $fileC ] ),
+ "Correct file size of $fileC" );
+ $this->assertEquals( Wikimedia\base_convert( sha1( $fileBContents ), 16, 36, 31 ),
+ $this->backend->getFileSha1Base36( [ 'src' => $fileC ] ),
+ "Correct file SHA-1 of $fileC" );
+ }
+
+ public function testDoOperationsPipeline() {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestDoOperationsPipeline();
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestDoOperationsPipeline();
+ $this->tearDownFiles();
+ }
+
+ // concurrency orientated
+ private function doTestDoOperationsPipeline() {
+ $base = self::baseStorePath();
+
+ $fileAContents = '3tqtmoeatmn4wg4qe-mg3qt3 tq';
+ $fileBContents = 'g-jmq3gpqgt3qtg q3GT ';
+ $fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag';
+
+ $tmpNameA = TempFSFile::factory( "unittests_", 'txt', wfTempDir() )->getPath();
+ $tmpNameB = TempFSFile::factory( "unittests_", 'txt', wfTempDir() )->getPath();
+ $tmpNameC = TempFSFile::factory( "unittests_", 'txt', wfTempDir() )->getPath();
+ $this->addTmpFiles( [ $tmpNameA, $tmpNameB, $tmpNameC ] );
+ file_put_contents( $tmpNameA, $fileAContents );
+ file_put_contents( $tmpNameB, $fileBContents );
+ file_put_contents( $tmpNameC, $fileCContents );
+
+ $fileA = "$base/unittest-cont1/e/a/b/fileA.txt";
+ $fileB = "$base/unittest-cont1/e/a/b/fileB.txt";
+ $fileC = "$base/unittest-cont1/e/a/b/fileC.txt";
+ $fileD = "$base/unittest-cont1/e/a/b/fileD.txt";
+
+ $this->prepare( [ 'dir' => dirname( $fileA ) ] );
+ $this->create( [ 'dst' => $fileA, 'content' => $fileAContents ] );
+ $this->prepare( [ 'dir' => dirname( $fileB ) ] );
+ $this->prepare( [ 'dir' => dirname( $fileC ) ] );
+ $this->prepare( [ 'dir' => dirname( $fileD ) ] );
+
+ $status = $this->backend->doOperations( [
+ [ 'op' => 'store', 'src' => $tmpNameA, 'dst' => $fileA, 'overwriteSame' => 1 ],
+ [ 'op' => 'store', 'src' => $tmpNameB, 'dst' => $fileB, 'overwrite' => 1 ],
+ [ 'op' => 'store', 'src' => $tmpNameC, 'dst' => $fileC, 'overwrite' => 1 ],
+ [ 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC, 'overwrite' => 1 ],
+ // Now: A:<A>, B:<B>, C:<A>, D:<empty> (file:<orginal contents>)
+ [ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileA, 'overwriteSame' => 1 ],
+ // Now: A:<A>, B:<B>, C:<A>, D:<empty>
+ [ 'op' => 'move', 'src' => $fileC, 'dst' => $fileD, 'overwrite' => 1 ],
+ // Now: A:<A>, B:<B>, C:<empty>, D:<A>
+ [ 'op' => 'move', 'src' => $fileB, 'dst' => $fileC ],
+ // Now: A:<A>, B:<empty>, C:<B>, D:<A>
+ [ 'op' => 'move', 'src' => $fileD, 'dst' => $fileA, 'overwriteSame' => 1 ],
+ // Now: A:<A>, B:<empty>, C:<B>, D:<empty>
+ [ 'op' => 'move', 'src' => $fileC, 'dst' => $fileA, 'overwrite' => 1 ],
+ // Now: A:<B>, B:<empty>, C:<empty>, D:<empty>
+ [ 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC ],
+ // Now: A:<B>, B:<empty>, C:<B>, D:<empty>
+ [ 'op' => 'move', 'src' => $fileA, 'dst' => $fileC, 'overwriteSame' => 1 ],
+ // Now: A:<empty>, B:<empty>, C:<B>, D:<empty>
+ [ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ],
+ // Does nothing
+ [ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ],
+ // Does nothing
+ [ 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ],
+ // Does nothing
+ [ 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ],
+ // Does nothing
+ [ 'op' => 'null' ],
+ // Does nothing
+ ] );
+
+ $this->assertGoodStatus( $status, "Operation batch succeeded" );
+ $this->assertEquals( true, $status->isOK(), "Operation batch succeeded" );
+ $this->assertEquals( 16, count( $status->success ),
+ "Operation batch has correct success array" );
+
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $fileA ] ),
+ "File does not exist at $fileA" );
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $fileB ] ),
+ "File does not exist at $fileB" );
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $fileD ] ),
+ "File does not exist at $fileD" );
+
+ $this->assertEquals( true, $this->backend->fileExists( [ 'src' => $fileC ] ),
+ "File exists at $fileC" );
+ $this->assertEquals( $fileBContents,
+ $this->backend->getFileContents( [ 'src' => $fileC ] ),
+ "Correct file contents of $fileC" );
+ $this->assertEquals( strlen( $fileBContents ),
+ $this->backend->getFileSize( [ 'src' => $fileC ] ),
+ "Correct file size of $fileC" );
+ $this->assertEquals( Wikimedia\base_convert( sha1( $fileBContents ), 16, 36, 31 ),
+ $this->backend->getFileSha1Base36( [ 'src' => $fileC ] ),
+ "Correct file SHA-1 of $fileC" );
+ }
+
+ public function testDoOperationsFailing() {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestDoOperationsFailing();
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestDoOperationsFailing();
+ $this->tearDownFiles();
+ }
+
+ private function doTestDoOperationsFailing() {
+ $base = self::baseStorePath();
+
+ $fileA = "$base/unittest-cont2/a/b/fileA.txt";
+ $fileAContents = '3tqtmoeatmn4wg4qe-mg3qt3 tq';
+ $fileB = "$base/unittest-cont2/a/b/fileB.txt";
+ $fileBContents = 'g-jmq3gpqgt3qtg q3GT ';
+ $fileC = "$base/unittest-cont2/a/b/fileC.txt";
+ $fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag';
+ $fileD = "$base/unittest-cont2/a/b/fileD.txt";
+
+ $this->prepare( [ 'dir' => dirname( $fileA ) ] );
+ $this->create( [ 'dst' => $fileA, 'content' => $fileAContents ] );
+ $this->prepare( [ 'dir' => dirname( $fileB ) ] );
+ $this->create( [ 'dst' => $fileB, 'content' => $fileBContents ] );
+ $this->prepare( [ 'dir' => dirname( $fileC ) ] );
+ $this->create( [ 'dst' => $fileC, 'content' => $fileCContents ] );
+
+ $status = $this->backend->doOperations( [
+ [ 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC, 'overwrite' => 1 ],
+ // Now: A:<A>, B:<B>, C:<A>, D:<empty> (file:<orginal contents>)
+ [ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileA, 'overwriteSame' => 1 ],
+ // Now: A:<A>, B:<B>, C:<A>, D:<empty>
+ [ 'op' => 'copy', 'src' => $fileB, 'dst' => $fileD, 'overwrite' => 1 ],
+ // Now: A:<A>, B:<B>, C:<A>, D:<B>
+ [ 'op' => 'move', 'src' => $fileC, 'dst' => $fileD ],
+ // Now: A:<A>, B:<B>, C:<A>, D:<empty> (failed)
+ [ 'op' => 'move', 'src' => $fileB, 'dst' => $fileC, 'overwriteSame' => 1 ],
+ // Now: A:<A>, B:<B>, C:<A>, D:<empty> (failed)
+ [ 'op' => 'move', 'src' => $fileB, 'dst' => $fileA, 'overwrite' => 1 ],
+ // Now: A:<B>, B:<empty>, C:<A>, D:<empty>
+ [ 'op' => 'delete', 'src' => $fileD ],
+ // Now: A:<B>, B:<empty>, C:<A>, D:<empty>
+ [ 'op' => 'null' ],
+ // Does nothing
+ ], [ 'force' => 1 ] );
+
+ $this->assertNotEquals( [], $status->getErrors(), "Operation had warnings" );
+ $this->assertEquals( true, $status->isOK(), "Operation batch succeeded" );
+ $this->assertEquals( 8, count( $status->success ),
+ "Operation batch has correct success array" );
+
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $fileB ] ),
+ "File does not exist at $fileB" );
+ $this->assertEquals( false, $this->backend->fileExists( [ 'src' => $fileD ] ),
+ "File does not exist at $fileD" );
+
+ $this->assertEquals( true, $this->backend->fileExists( [ 'src' => $fileA ] ),
+ "File does not exist at $fileA" );
+ $this->assertEquals( true, $this->backend->fileExists( [ 'src' => $fileC ] ),
+ "File exists at $fileC" );
+ $this->assertEquals( $fileBContents,
+ $this->backend->getFileContents( [ 'src' => $fileA ] ),
+ "Correct file contents of $fileA" );
+ $this->assertEquals( strlen( $fileBContents ),
+ $this->backend->getFileSize( [ 'src' => $fileA ] ),
+ "Correct file size of $fileA" );
+ $this->assertEquals( Wikimedia\base_convert( sha1( $fileBContents ), 16, 36, 31 ),
+ $this->backend->getFileSha1Base36( [ 'src' => $fileA ] ),
+ "Correct file SHA-1 of $fileA" );
+ }
+
+ public function testGetFileList() {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestGetFileList();
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestGetFileList();
+ $this->tearDownFiles();
+ }
+
+ private function doTestGetFileList() {
+ $backendName = $this->backendClass();
+ $base = self::baseStorePath();
+
+ // Should have no errors
+ $iter = $this->backend->getFileList( [ 'dir' => "$base/unittest-cont-notexists" ] );
+
+ $files = [
+ "$base/unittest-cont1/e/test1.txt",
+ "$base/unittest-cont1/e/test2.txt",
+ "$base/unittest-cont1/e/test3.txt",
+ "$base/unittest-cont1/e/subdir1/test1.txt",
+ "$base/unittest-cont1/e/subdir1/test2.txt",
+ "$base/unittest-cont1/e/subdir2/test3.txt",
+ "$base/unittest-cont1/e/subdir2/test4.txt",
+ "$base/unittest-cont1/e/subdir2/subdir/test1.txt",
+ "$base/unittest-cont1/e/subdir2/subdir/test2.txt",
+ "$base/unittest-cont1/e/subdir2/subdir/test3.txt",
+ "$base/unittest-cont1/e/subdir2/subdir/test4.txt",
+ "$base/unittest-cont1/e/subdir2/subdir/test5.txt",
+ "$base/unittest-cont1/e/subdir2/subdir/sub/test0.txt",
+ "$base/unittest-cont1/e/subdir2/subdir/sub/120-px-file.txt",
+ ];
+
+ // Add the files
+ $ops = [];
+ foreach ( $files as $file ) {
+ $this->prepare( [ 'dir' => dirname( $file ) ] );
+ $ops[] = [ 'op' => 'create', 'content' => 'xxy', 'dst' => $file ];
+ }
+ $status = $this->backend->doQuickOperations( $ops );
+ $this->assertGoodStatus( $status,
+ "Creation of files succeeded ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Creation of files succeeded with OK status ($backendName)." );
+
+ // Expected listing at root
+ $expected = [
+ "e/test1.txt",
+ "e/test2.txt",
+ "e/test3.txt",
+ "e/subdir1/test1.txt",
+ "e/subdir1/test2.txt",
+ "e/subdir2/test3.txt",
+ "e/subdir2/test4.txt",
+ "e/subdir2/subdir/test1.txt",
+ "e/subdir2/subdir/test2.txt",
+ "e/subdir2/subdir/test3.txt",
+ "e/subdir2/subdir/test4.txt",
+ "e/subdir2/subdir/test5.txt",
+ "e/subdir2/subdir/sub/test0.txt",
+ "e/subdir2/subdir/sub/120-px-file.txt",
+ ];
+ sort( $expected );
+
+ // Actual listing (no trailing slash) at root
+ $iter = $this->backend->getFileList( [ 'dir' => "$base/unittest-cont1" ] );
+ $list = $this->listToArray( $iter );
+ sort( $list );
+ $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." );
+
+ // Actual listing (no trailing slash) at root with advise
+ $iter = $this->backend->getFileList( [
+ 'dir' => "$base/unittest-cont1",
+ 'adviseStat' => 1
+ ] );
+ $list = $this->listToArray( $iter );
+ sort( $list );
+ $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." );
+
+ // Actual listing (with trailing slash) at root
+ $list = [];
+ $iter = $this->backend->getFileList( [ 'dir' => "$base/unittest-cont1/" ] );
+ foreach ( $iter as $file ) {
+ $list[] = $file;
+ }
+ sort( $list );
+ $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." );
+
+ // Expected listing at subdir
+ $expected = [
+ "test1.txt",
+ "test2.txt",
+ "test3.txt",
+ "test4.txt",
+ "test5.txt",
+ "sub/test0.txt",
+ "sub/120-px-file.txt",
+ ];
+ sort( $expected );
+
+ // Actual listing (no trailing slash) at subdir
+ $iter = $this->backend->getFileList( [ 'dir' => "$base/unittest-cont1/e/subdir2/subdir" ] );
+ $list = $this->listToArray( $iter );
+ sort( $list );
+ $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." );
+
+ // Actual listing (no trailing slash) at subdir with advise
+ $iter = $this->backend->getFileList( [
+ 'dir' => "$base/unittest-cont1/e/subdir2/subdir",
+ 'adviseStat' => 1
+ ] );
+ $list = $this->listToArray( $iter );
+ sort( $list );
+ $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." );
+
+ // Actual listing (with trailing slash) at subdir
+ $list = [];
+ $iter = $this->backend->getFileList( [ 'dir' => "$base/unittest-cont1/e/subdir2/subdir/" ] );
+ foreach ( $iter as $file ) {
+ $list[] = $file;
+ }
+ sort( $list );
+ $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." );
+
+ // Actual listing (using iterator second time)
+ $list = $this->listToArray( $iter );
+ sort( $list );
+ $this->assertEquals( $expected, $list, "Correct file listing ($backendName), second iteration." );
+
+ // Actual listing (top files only) at root
+ $iter = $this->backend->getTopFileList( [ 'dir' => "$base/unittest-cont1" ] );
+ $list = $this->listToArray( $iter );
+ sort( $list );
+ $this->assertEquals( [], $list, "Correct top file listing ($backendName)." );
+
+ // Expected listing (top files only) at subdir
+ $expected = [
+ "test1.txt",
+ "test2.txt",
+ "test3.txt",
+ "test4.txt",
+ "test5.txt"
+ ];
+ sort( $expected );
+
+ // Actual listing (top files only) at subdir
+ $iter = $this->backend->getTopFileList(
+ [ 'dir' => "$base/unittest-cont1/e/subdir2/subdir" ]
+ );
+ $list = $this->listToArray( $iter );
+ sort( $list );
+ $this->assertEquals( $expected, $list, "Correct top file listing ($backendName)." );
+
+ // Actual listing (top files only) at subdir with advise
+ $iter = $this->backend->getTopFileList( [
+ 'dir' => "$base/unittest-cont1/e/subdir2/subdir",
+ 'adviseStat' => 1
+ ] );
+ $list = $this->listToArray( $iter );
+ sort( $list );
+ $this->assertEquals( $expected, $list, "Correct top file listing ($backendName)." );
+
+ foreach ( $files as $file ) { // clean up
+ $this->backend->doOperation( [ 'op' => 'delete', 'src' => $file ] );
+ }
+
+ $iter = $this->backend->getFileList( [ 'dir' => "$base/unittest-cont1/not/exists" ] );
+ foreach ( $iter as $iter ) {
+ // no errors
+ }
+ }
+
+ public function testGetDirectoryList() {
+ $this->backend = $this->singleBackend;
+ $this->tearDownFiles();
+ $this->doTestGetDirectoryList();
+ $this->tearDownFiles();
+
+ $this->backend = $this->multiBackend;
+ $this->tearDownFiles();
+ $this->doTestGetDirectoryList();
+ $this->tearDownFiles();
+ }
+
+ private function doTestGetDirectoryList() {
+ $backendName = $this->backendClass();
+
+ $base = self::baseStorePath();
+ $files = [
+ "$base/unittest-cont1/e/test1.txt",
+ "$base/unittest-cont1/e/test2.txt",
+ "$base/unittest-cont1/e/test3.txt",
+ "$base/unittest-cont1/e/subdir1/test1.txt",
+ "$base/unittest-cont1/e/subdir1/test2.txt",
+ "$base/unittest-cont1/e/subdir2/test3.txt",
+ "$base/unittest-cont1/e/subdir2/test4.txt",
+ "$base/unittest-cont1/e/subdir2/subdir/test1.txt",
+ "$base/unittest-cont1/e/subdir3/subdir/test2.txt",
+ "$base/unittest-cont1/e/subdir4/subdir/test3.txt",
+ "$base/unittest-cont1/e/subdir4/subdir/test4.txt",
+ "$base/unittest-cont1/e/subdir4/subdir/test5.txt",
+ "$base/unittest-cont1/e/subdir4/subdir/sub/test0.txt",
+ "$base/unittest-cont1/e/subdir4/subdir/sub/120-px-file.txt",
+ ];
+
+ // Add the files
+ $ops = [];
+ foreach ( $files as $file ) {
+ $this->prepare( [ 'dir' => dirname( $file ) ] );
+ $ops[] = [ 'op' => 'create', 'content' => 'xxy', 'dst' => $file ];
+ }
+ $status = $this->backend->doQuickOperations( $ops );
+ $this->assertGoodStatus( $status,
+ "Creation of files succeeded ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Creation of files succeeded with OK status ($backendName)." );
+
+ $this->assertEquals( true,
+ $this->backend->directoryExists( [ 'dir' => "$base/unittest-cont1/e/subdir1" ] ),
+ "Directory exists in ($backendName)." );
+ $this->assertEquals( true,
+ $this->backend->directoryExists( [ 'dir' => "$base/unittest-cont1/e/subdir2/subdir" ] ),
+ "Directory exists in ($backendName)." );
+ $this->assertEquals( false,
+ $this->backend->directoryExists( [ 'dir' => "$base/unittest-cont1/e/subdir2/test1.txt" ] ),
+ "Directory does not exists in ($backendName)." );
+
+ // Expected listing
+ $expected = [
+ "e",
+ ];
+ sort( $expected );
+
+ // Actual listing (no trailing slash)
+ $list = [];
+ $iter = $this->backend->getTopDirectoryList( [ 'dir' => "$base/unittest-cont1" ] );
+ foreach ( $iter as $file ) {
+ $list[] = $file;
+ }
+ sort( $list );
+
+ $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." );
+
+ // Expected listing
+ $expected = [
+ "subdir1",
+ "subdir2",
+ "subdir3",
+ "subdir4",
+ ];
+ sort( $expected );
+
+ // Actual listing (no trailing slash)
+ $list = [];
+ $iter = $this->backend->getTopDirectoryList( [ 'dir' => "$base/unittest-cont1/e" ] );
+ foreach ( $iter as $file ) {
+ $list[] = $file;
+ }
+ sort( $list );
+
+ $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." );
+
+ // Actual listing (with trailing slash)
+ $list = [];
+ $iter = $this->backend->getTopDirectoryList( [ 'dir' => "$base/unittest-cont1/e/" ] );
+ foreach ( $iter as $file ) {
+ $list[] = $file;
+ }
+ sort( $list );
+
+ $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." );
+
+ // Expected listing
+ $expected = [
+ "subdir",
+ ];
+ sort( $expected );
+
+ // Actual listing (no trailing slash)
+ $list = [];
+ $iter = $this->backend->getTopDirectoryList( [ 'dir' => "$base/unittest-cont1/e/subdir2" ] );
+ foreach ( $iter as $file ) {
+ $list[] = $file;
+ }
+ sort( $list );
+
+ $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." );
+
+ // Actual listing (with trailing slash)
+ $list = [];
+ $iter = $this->backend->getTopDirectoryList(
+ [ 'dir' => "$base/unittest-cont1/e/subdir2/" ]
+ );
+
+ foreach ( $iter as $file ) {
+ $list[] = $file;
+ }
+ sort( $list );
+
+ $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." );
+
+ // Actual listing (using iterator second time)
+ $list = [];
+ foreach ( $iter as $file ) {
+ $list[] = $file;
+ }
+ sort( $list );
+
+ $this->assertEquals(
+ $expected,
+ $list,
+ "Correct top dir listing ($backendName), second iteration."
+ );
+
+ // Expected listing (recursive)
+ $expected = [
+ "e",
+ "e/subdir1",
+ "e/subdir2",
+ "e/subdir3",
+ "e/subdir4",
+ "e/subdir2/subdir",
+ "e/subdir3/subdir",
+ "e/subdir4/subdir",
+ "e/subdir4/subdir/sub",
+ ];
+ sort( $expected );
+
+ // Actual listing (recursive)
+ $list = [];
+ $iter = $this->backend->getDirectoryList( [ 'dir' => "$base/unittest-cont1/" ] );
+ foreach ( $iter as $file ) {
+ $list[] = $file;
+ }
+ sort( $list );
+
+ $this->assertEquals( $expected, $list, "Correct dir listing ($backendName)." );
+
+ // Expected listing (recursive)
+ $expected = [
+ "subdir",
+ "subdir/sub",
+ ];
+ sort( $expected );
+
+ // Actual listing (recursive)
+ $list = [];
+ $iter = $this->backend->getDirectoryList( [ 'dir' => "$base/unittest-cont1/e/subdir4" ] );
+ foreach ( $iter as $file ) {
+ $list[] = $file;
+ }
+ sort( $list );
+
+ $this->assertEquals( $expected, $list, "Correct dir listing ($backendName)." );
+
+ // Actual listing (recursive, second time)
+ $list = [];
+ foreach ( $iter as $file ) {
+ $list[] = $file;
+ }
+ sort( $list );
+
+ $this->assertEquals( $expected, $list, "Correct dir listing ($backendName)." );
+
+ $iter = $this->backend->getDirectoryList( [ 'dir' => "$base/unittest-cont1/e/subdir1" ] );
+ $items = $this->listToArray( $iter );
+ $this->assertEquals( [], $items, "Directory listing is empty." );
+
+ foreach ( $files as $file ) { // clean up
+ $this->backend->doOperation( [ 'op' => 'delete', 'src' => $file ] );
+ }
+
+ $iter = $this->backend->getDirectoryList( [ 'dir' => "$base/unittest-cont1/not/exists" ] );
+ foreach ( $iter as $file ) {
+ // no errors
+ }
+
+ $items = $this->listToArray( $iter );
+ $this->assertEquals( [], $items, "Directory listing is empty." );
+
+ $iter = $this->backend->getDirectoryList( [ 'dir' => "$base/unittest-cont1/e/not/exists" ] );
+ $items = $this->listToArray( $iter );
+ $this->assertEquals( [], $items, "Directory listing is empty." );
+ }
+
+ public function testLockCalls() {
+ $this->backend = $this->singleBackend;
+ $this->doTestLockCalls();
+ }
+
+ private function doTestLockCalls() {
+ $backendName = $this->backendClass();
+
+ $paths = [
+ "test1.txt",
+ "test2.txt",
+ "test3.txt",
+ "subdir1",
+ "subdir1", // duplicate
+ "subdir1/test1.txt",
+ "subdir1/test2.txt",
+ "subdir2",
+ "subdir2", // duplicate
+ "subdir2/test3.txt",
+ "subdir2/test4.txt",
+ "subdir2/subdir",
+ "subdir2/subdir/test1.txt",
+ "subdir2/subdir/test2.txt",
+ "subdir2/subdir/test3.txt",
+ "subdir2/subdir/test4.txt",
+ "subdir2/subdir/test5.txt",
+ "subdir2/subdir/sub",
+ "subdir2/subdir/sub/test0.txt",
+ "subdir2/subdir/sub/120-px-file.txt",
+ ];
+
+ for ( $i = 0; $i < 25; $i++ ) {
+ $status = $this->backend->lockFiles( $paths, LockManager::LOCK_EX );
+ $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ),
+ "Locking of files succeeded ($backendName) ($i)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Locking of files succeeded with OK status ($backendName) ($i)." );
+
+ $status = $this->backend->lockFiles( $paths, LockManager::LOCK_SH );
+ $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ),
+ "Locking of files succeeded ($backendName) ($i)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Locking of files succeeded with OK status ($backendName) ($i)." );
+
+ $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_SH );
+ $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ),
+ "Locking of files succeeded ($backendName) ($i)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Locking of files succeeded with OK status ($backendName) ($i)." );
+
+ $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_EX );
+ $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ),
+ "Locking of files succeeded ($backendName). ($i)" );
+ $this->assertEquals( true, $status->isOK(),
+ "Locking of files succeeded with OK status ($backendName) ($i)." );
+
+ # # Flip the acquire/release ordering around ##
+
+ $status = $this->backend->lockFiles( $paths, LockManager::LOCK_SH );
+ $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ),
+ "Locking of files succeeded ($backendName) ($i)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Locking of files succeeded with OK status ($backendName) ($i)." );
+
+ $status = $this->backend->lockFiles( $paths, LockManager::LOCK_EX );
+ $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ),
+ "Locking of files succeeded ($backendName) ($i)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Locking of files succeeded with OK status ($backendName) ($i)." );
+
+ $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_EX );
+ $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ),
+ "Locking of files succeeded ($backendName). ($i)" );
+ $this->assertEquals( true, $status->isOK(),
+ "Locking of files succeeded with OK status ($backendName) ($i)." );
+
+ $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_SH );
+ $this->assertEquals( print_r( [], true ), print_r( $status->getErrors(), true ),
+ "Locking of files succeeded ($backendName) ($i)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Locking of files succeeded with OK status ($backendName) ($i)." );
+ }
+
+ $status = Status::newGood();
+ $sl = $this->backend->getScopedFileLocks( $paths, LockManager::LOCK_EX, $status );
+ $this->assertInstanceOf( ScopedLock::class, $sl,
+ "Scoped locking of files succeeded ($backendName)." );
+ $this->assertEquals( [], $status->getErrors(),
+ "Scoped locking of files succeeded ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Scoped locking of files succeeded with OK status ($backendName)." );
+
+ ScopedLock::release( $sl );
+ $this->assertEquals( null, $sl,
+ "Scoped unlocking of files succeeded ($backendName)." );
+ $this->assertEquals( [], $status->getErrors(),
+ "Scoped unlocking of files succeeded ($backendName)." );
+ $this->assertEquals( true, $status->isOK(),
+ "Scoped unlocking of files succeeded with OK status ($backendName)." );
+ }
+
+ /**
+ * @dataProvider provider_testGetContentType
+ */
+ public function testGetContentType( $mimeCallback, $mimeFromString ) {
+ global $IP;
+
+ $be = TestingAccessWrapper::newFromObject( new MemoryFileBackend(
+ [
+ 'name' => 'testing',
+ 'class' => MemoryFileBackend::class,
+ 'wikiId' => 'meow',
+ 'mimeCallback' => $mimeCallback
+ ]
+ ) );
+
+ $dst = 'mwstore://testing/container/path/to/file_no_ext';
+ $src = "$IP/tests/phpunit/data/media/srgb.jpg";
+ $this->assertEquals( 'image/jpeg', $be->getContentType( $dst, null, $src ) );
+ $this->assertEquals(
+ $mimeFromString ? 'image/jpeg' : 'unknown/unknown',
+ $be->getContentType( $dst, file_get_contents( $src ), null ) );
+
+ $src = "$IP/tests/phpunit/data/media/Png-native-test.png";
+ $this->assertEquals( 'image/png', $be->getContentType( $dst, null, $src ) );
+ $this->assertEquals(
+ $mimeFromString ? 'image/png' : 'unknown/unknown',
+ $be->getContentType( $dst, file_get_contents( $src ), null ) );
+ }
+
+ public static function provider_testGetContentType() {
+ return [
+ [ null, false ],
+ [ [ FileBackendGroup::singleton(), 'guessMimeInternal' ], true ]
+ ];
+ }
+
+ public function testReadAffinity() {
+ $be = TestingAccessWrapper::newFromObject(
+ new FileBackendMultiWrite( [
+ 'name' => 'localtesting',
+ 'wikiId' => wfWikiID() . mt_rand(),
+ 'backends' => [
+ [ // backend 0
+ 'name' => 'multitesting0',
+ 'class' => MemoryFileBackend::class,
+ 'isMultiMaster' => false,
+ 'readAffinity' => true
+ ],
+ [ // backend 1
+ 'name' => 'multitesting1',
+ 'class' => MemoryFileBackend::class,
+ 'isMultiMaster' => true
+ ]
+ ]
+ ] )
+ );
+
+ $this->assertEquals(
+ 1,
+ $be->getReadIndexFromParams( [ 'latest' => 1 ] ),
+ 'Reads with "latest" flag use backend 1'
+ );
+ $this->assertEquals(
+ 0,
+ $be->getReadIndexFromParams( [ 'latest' => 0 ] ),
+ 'Reads without "latest" flag use backend 0'
+ );
+
+ $p = 'container/test-cont/file.txt';
+ $be->backends[0]->quickCreate( [
+ 'dst' => "mwstore://multitesting0/$p", 'content' => 'cattitude' ] );
+ $be->backends[1]->quickCreate( [
+ 'dst' => "mwstore://multitesting1/$p", 'content' => 'princess of power' ] );
+
+ $this->assertEquals(
+ 'cattitude',
+ $be->getFileContents( [ 'src' => "mwstore://localtesting/$p" ] ),
+ "Non-latest read came from backend 0"
+ );
+ $this->assertEquals(
+ 'princess of power',
+ $be->getFileContents( [ 'src' => "mwstore://localtesting/$p", 'latest' => 1 ] ),
+ "Latest read came from backend1"
+ );
+ }
+
+ public function testAsyncWrites() {
+ $be = TestingAccessWrapper::newFromObject(
+ new FileBackendMultiWrite( [
+ 'name' => 'localtesting',
+ 'wikiId' => wfWikiID() . mt_rand(),
+ 'backends' => [
+ [ // backend 0
+ 'name' => 'multitesting0',
+ 'class' => MemoryFileBackend::class,
+ 'isMultiMaster' => false
+ ],
+ [ // backend 1
+ 'name' => 'multitesting1',
+ 'class' => MemoryFileBackend::class,
+ 'isMultiMaster' => true
+ ]
+ ],
+ 'replication' => 'async'
+ ] )
+ );
+
+ $this->setMwGlobals( 'wgCommandLineMode', false );
+
+ $p = 'container/test-cont/file.txt';
+ $be->quickCreate( [
+ 'dst' => "mwstore://localtesting/$p", 'content' => 'cattitude' ] );
+
+ $this->assertEquals(
+ false,
+ $be->backends[0]->getFileContents( [ 'src' => "mwstore://multitesting0/$p" ] ),
+ "File not yet written to backend 0"
+ );
+ $this->assertEquals(
+ 'cattitude',
+ $be->backends[1]->getFileContents( [ 'src' => "mwstore://multitesting1/$p" ] ),
+ "File already written to backend 1"
+ );
+
+ DeferredUpdates::doUpdates();
+
+ $this->assertEquals(
+ 'cattitude',
+ $be->backends[0]->getFileContents( [ 'src' => "mwstore://multitesting0/$p" ] ),
+ "File now written to backend 0"
+ );
+ }
+
+ public function testSanitizeOpHeaders() {
+ $be = TestingAccessWrapper::newFromObject( new MemoryFileBackend( [
+ 'name' => 'localtesting',
+ 'wikiId' => wfWikiID()
+ ] ) );
+
+ $name = wfRandomString( 300 );
+
+ $input = [
+ 'headers' => [
+ 'content-Disposition' => FileBackend::makeContentDisposition( 'inline', $name ),
+ 'Content-dUration' => 25.6,
+ 'X-LONG-VALUE' => str_pad( '0', 300 ),
+ 'CONTENT-LENGTH' => 855055,
+ ]
+ ];
+ $expected = [
+ 'headers' => [
+ 'content-disposition' => FileBackend::makeContentDisposition( 'inline', $name ),
+ 'content-duration' => 25.6,
+ 'content-length' => 855055
+ ]
+ ];
+
+ Wikimedia\suppressWarnings();
+ $actual = $be->sanitizeOpHeaders( $input );
+ Wikimedia\restoreWarnings();
+
+ $this->assertEquals( $expected, $actual, "Header sanitized properly" );
+ }
+
+ // helper function
+ private function listToArray( $iter ) {
+ return is_array( $iter ) ? $iter : iterator_to_array( $iter );
+ }
+
+ // test helper wrapper for backend prepare() function
+ private function prepare( array $params ) {
+ return $this->backend->prepare( $params );
+ }
+
+ // test helper wrapper for backend prepare() function
+ private function create( array $params ) {
+ $params['op'] = 'create';
+
+ return $this->backend->doQuickOperations( [ $params ] );
+ }
+
+ function tearDownFiles() {
+ $containers = [ 'unittest-cont1', 'unittest-cont2', 'unittest-cont-bad' ];
+ foreach ( $containers as $container ) {
+ $this->deleteFiles( $container );
+ }
+ }
+
+ private function deleteFiles( $container ) {
+ $base = self::baseStorePath();
+ $iter = $this->backend->getFileList( [ 'dir' => "$base/$container" ] );
+ if ( $iter ) {
+ foreach ( $iter as $file ) {
+ $this->backend->quickDelete( [ 'src' => "$base/$container/$file" ] );
+ }
+ // free the directory, to avoid Permission denied under windows on rmdir
+ unset( $iter );
+ }
+ $this->backend->clean( [ 'dir' => "$base/$container", 'recursive' => 1 ] );
+ }
+
+ function assertBackendPathsConsistent( array $paths ) {
+ if ( $this->backend instanceof FileBackendMultiWrite ) {
+ $status = $this->backend->consistencyCheck( $paths );
+ $this->assertGoodStatus( $status, "Files synced: " . implode( ',', $paths ) );
+ }
+ }
+
+ function assertGoodStatus( StatusValue $status, $msg ) {
+ $this->assertEquals( print_r( [], 1 ), print_r( $status->getErrors(), 1 ), $msg );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/filebackend/SwiftFileBackendTest.php b/www/wiki/tests/phpunit/includes/filebackend/SwiftFileBackendTest.php
new file mode 100644
index 00000000..35eca28f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/filebackend/SwiftFileBackendTest.php
@@ -0,0 +1,216 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group FileRepo
+ * @group FileBackend
+ * @group medium
+ *
+ * @covers SwiftFileBackend
+ * @covers SwiftFileBackendDirList
+ * @covers SwiftFileBackendFileList
+ * @covers SwiftFileBackendList
+ */
+class SwiftFileBackendTest extends MediaWikiTestCase {
+ /** @var TestingAccessWrapper Proxy to SwiftFileBackend */
+ private $backend;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->backend = TestingAccessWrapper::newFromObject(
+ new SwiftFileBackend( [
+ 'name' => 'local-swift-testing',
+ 'class' => SwiftFileBackend::class,
+ 'wikiId' => 'unit-testing',
+ 'lockManager' => LockManagerGroup::singleton()->get( 'fsLockManager' ),
+ 'swiftAuthUrl' => 'http://127.0.0.1:8080/auth', // unused
+ 'swiftUser' => 'test:tester',
+ 'swiftKey' => 'testing',
+ 'swiftTempUrlKey' => 'b3968d0207b54ece87cccc06515a89d4' // unused
+ ] )
+ );
+ }
+
+ /**
+ * @dataProvider provider_testSanitizeHdrsStrict
+ */
+ public function testSanitizeHdrsStrict( $raw, $sanitized ) {
+ $hdrs = $this->backend->sanitizeHdrsStrict( [ 'headers' => $raw ] );
+
+ $this->assertEquals( $hdrs, $sanitized, 'sanitizeHdrsStrict() has expected result' );
+ }
+
+ public static function provider_testSanitizeHdrsStrict() {
+ return [
+ [
+ [
+ 'content-length' => 345,
+ 'content-type' => 'image+bitmap/jpeg',
+ 'content-disposition' => 'inline',
+ 'content-duration' => 35.6363,
+ 'content-Custom' => 'hello',
+ 'x-content-custom' => 'hello'
+ ],
+ [
+ 'content-disposition' => 'inline',
+ 'content-duration' => 35.6363,
+ 'content-custom' => 'hello',
+ 'x-content-custom' => 'hello'
+ ]
+ ],
+ [
+ [
+ 'content-length' => 345,
+ 'content-type' => 'image+bitmap/jpeg',
+ 'content-Disposition' => 'inline; filename=xxx; ' . str_repeat( 'o', 1024 ),
+ 'content-duration' => 35.6363,
+ 'content-custom' => 'hello',
+ 'x-content-custom' => 'hello'
+ ],
+ [
+ 'content-disposition' => 'inline;filename=xxx',
+ 'content-duration' => 35.6363,
+ 'content-custom' => 'hello',
+ 'x-content-custom' => 'hello'
+ ]
+ ],
+ [
+ [
+ 'content-length' => 345,
+ 'content-type' => 'image+bitmap/jpeg',
+ 'content-disposition' => 'filename=' . str_repeat( 'o', 1024 ) . ';inline',
+ 'content-duration' => 35.6363,
+ 'content-custom' => 'hello',
+ 'x-content-custom' => 'hello'
+ ],
+ [
+ 'content-disposition' => '',
+ 'content-duration' => 35.6363,
+ 'content-custom' => 'hello',
+ 'x-content-custom' => 'hello'
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provider_testSanitizeHdrs
+ */
+ public function testSanitizeHdrs( $raw, $sanitized ) {
+ $hdrs = $this->backend->sanitizeHdrs( [ 'headers' => $raw ] );
+
+ $this->assertEquals( $hdrs, $sanitized, 'sanitizeHdrs() has expected result' );
+ }
+
+ public static function provider_testSanitizeHdrs() {
+ return [
+ [
+ [
+ 'content-length' => 345,
+ 'content-type' => 'image+bitmap/jpeg',
+ 'content-disposition' => 'inline',
+ 'content-duration' => 35.6363,
+ 'content-Custom' => 'hello',
+ 'x-content-custom' => 'hello'
+ ],
+ [
+ 'content-type' => 'image+bitmap/jpeg',
+ 'content-disposition' => 'inline',
+ 'content-duration' => 35.6363,
+ 'content-custom' => 'hello',
+ 'x-content-custom' => 'hello'
+ ]
+ ],
+ [
+ [
+ 'content-length' => 345,
+ 'content-type' => 'image+bitmap/jpeg',
+ 'content-Disposition' => 'inline; filename=xxx; ' . str_repeat( 'o', 1024 ),
+ 'content-duration' => 35.6363,
+ 'content-custom' => 'hello',
+ 'x-content-custom' => 'hello'
+ ],
+ [
+ 'content-type' => 'image+bitmap/jpeg',
+ 'content-disposition' => 'inline;filename=xxx',
+ 'content-duration' => 35.6363,
+ 'content-custom' => 'hello',
+ 'x-content-custom' => 'hello'
+ ]
+ ],
+ [
+ [
+ 'content-length' => 345,
+ 'content-type' => 'image+bitmap/jpeg',
+ 'content-disposition' => 'filename=' . str_repeat( 'o', 1024 ) . ';inline',
+ 'content-duration' => 35.6363,
+ 'content-custom' => 'hello',
+ 'x-content-custom' => 'hello'
+ ],
+ [
+ 'content-type' => 'image+bitmap/jpeg',
+ 'content-disposition' => '',
+ 'content-duration' => 35.6363,
+ 'content-custom' => 'hello',
+ 'x-content-custom' => 'hello'
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provider_testGetMetadataHeaders
+ */
+ public function testGetMetadataHeaders( $raw, $sanitized ) {
+ $hdrs = $this->backend->getMetadataHeaders( $raw );
+
+ $this->assertEquals( $hdrs, $sanitized, 'getMetadataHeaders() has expected result' );
+ }
+
+ public static function provider_testGetMetadataHeaders() {
+ return [
+ [
+ [
+ 'content-length' => 345,
+ 'content-custom' => 'hello',
+ 'x-content-custom' => 'hello',
+ 'x-object-meta-custom' => 5,
+ 'x-object-meta-sha1Base36' => 'a3deadfg...',
+ ],
+ [
+ 'x-object-meta-custom' => 5,
+ 'x-object-meta-sha1base36' => 'a3deadfg...',
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provider_testGetMetadata
+ */
+ public function testGetMetadata( $raw, $sanitized ) {
+ $hdrs = $this->backend->getMetadata( $raw );
+
+ $this->assertEquals( $hdrs, $sanitized, 'getMetadata() has expected result' );
+ }
+
+ public static function provider_testGetMetadata() {
+ return [
+ [
+ [
+ 'content-length' => 345,
+ 'content-custom' => 'hello',
+ 'x-content-custom' => 'hello',
+ 'x-object-meta-custom' => 5,
+ 'x-object-meta-sha1Base36' => 'a3deadfg...',
+ ],
+ [
+ 'custom' => 5,
+ 'sha1base36' => 'a3deadfg...',
+ ]
+ ]
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/filerepo/FileBackendDBRepoWrapperTest.php b/www/wiki/tests/phpunit/includes/filerepo/FileBackendDBRepoWrapperTest.php
new file mode 100644
index 00000000..4c9855b0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/filerepo/FileBackendDBRepoWrapperTest.php
@@ -0,0 +1,140 @@
+<?php
+
+class FileBackendDBRepoWrapperTest extends MediaWikiTestCase {
+ protected $backendName = 'foo-backend';
+ protected $repoName = 'pureTestRepo';
+
+ /**
+ * @dataProvider getBackendPathsProvider
+ * @covers FileBackendDBRepoWrapper::getBackendPaths
+ */
+ public function testGetBackendPaths(
+ $mocks,
+ $latest,
+ $dbReadsExpected,
+ $dbReturnValue,
+ $originalPath,
+ $expectedBackendPath,
+ $message ) {
+ list( $dbMock, $backendMock, $wrapperMock ) = $mocks;
+
+ $dbMock->expects( $dbReadsExpected )
+ ->method( 'selectField' )
+ ->will( $this->returnValue( $dbReturnValue ) );
+
+ $newPaths = $wrapperMock->getBackendPaths( [ $originalPath ], $latest );
+
+ $this->assertEquals(
+ $expectedBackendPath,
+ $newPaths[0],
+ $message );
+ }
+
+ public function getBackendPathsProvider() {
+ $prefix = 'mwstore://' . $this->backendName . '/' . $this->repoName;
+ $mocksForCaching = $this->getMocks();
+
+ return [
+ [
+ $mocksForCaching,
+ false,
+ $this->once(),
+ '96246614d75ba1703bdfd5d7660bb57407aaf5d9',
+ $prefix . '-public/f/o/foobar.jpg',
+ $prefix . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9',
+ 'Public path translated correctly',
+ ],
+ [
+ $mocksForCaching,
+ false,
+ $this->never(),
+ '96246614d75ba1703bdfd5d7660bb57407aaf5d9',
+ $prefix . '-public/f/o/foobar.jpg',
+ $prefix . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9',
+ 'LRU cache leveraged',
+ ],
+ [
+ $this->getMocks(),
+ true,
+ $this->once(),
+ '96246614d75ba1703bdfd5d7660bb57407aaf5d9',
+ $prefix . '-public/f/o/foobar.jpg',
+ $prefix . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9',
+ 'Latest obtained',
+ ],
+ [
+ $this->getMocks(),
+ true,
+ $this->never(),
+ '96246614d75ba1703bdfd5d7660bb57407aaf5d9',
+ $prefix . '-deleted/f/o/foobar.jpg',
+ $prefix . '-original/f/o/o/foobar',
+ 'Deleted path translated correctly',
+ ],
+ [
+ $this->getMocks(),
+ true,
+ $this->once(),
+ null,
+ $prefix . '-public/b/a/baz.jpg',
+ $prefix . '-public/b/a/baz.jpg',
+ 'Path left untouched if no sha1 can be found',
+ ],
+ ];
+ }
+
+ /**
+ * @covers FileBackendDBRepoWrapper::getFileContentsMulti
+ */
+ public function testGetFileContentsMulti() {
+ list( $dbMock, $backendMock, $wrapperMock ) = $this->getMocks();
+
+ $sha1Path = 'mwstore://' . $this->backendName . '/' . $this->repoName
+ . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9';
+ $filenamePath = 'mwstore://' . $this->backendName . '/' . $this->repoName
+ . '-public/f/o/foobar.jpg';
+
+ $dbMock->expects( $this->once() )
+ ->method( 'selectField' )
+ ->will( $this->returnValue( '96246614d75ba1703bdfd5d7660bb57407aaf5d9' ) );
+
+ $backendMock->expects( $this->once() )
+ ->method( 'getFileContentsMulti' )
+ ->will( $this->returnValue( [ $sha1Path => 'foo' ] ) );
+
+ $result = $wrapperMock->getFileContentsMulti( [ 'srcs' => [ $filenamePath ] ] );
+
+ $this->assertEquals(
+ [ $filenamePath => 'foo' ],
+ $result,
+ 'File contents paths translated properly'
+ );
+ }
+
+ protected function getMocks() {
+ $dbMock = $this->getMockBuilder( Wikimedia\Rdbms\DatabaseMysqli::class )
+ ->disableOriginalClone()
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $backendMock = $this->getMockBuilder( FSFileBackend::class )
+ ->setConstructorArgs( [ [
+ 'name' => $this->backendName,
+ 'wikiId' => wfWikiID()
+ ] ] )
+ ->getMock();
+
+ $wrapperMock = $this->getMockBuilder( FileBackendDBRepoWrapper::class )
+ ->setMethods( [ 'getDB' ] )
+ ->setConstructorArgs( [ [
+ 'backend' => $backendMock,
+ 'repoName' => $this->repoName,
+ 'dbHandleFactory' => null
+ ] ] )
+ ->getMock();
+
+ $wrapperMock->expects( $this->any() )->method( 'getDB' )->will( $this->returnValue( $dbMock ) );
+
+ return [ $dbMock, $backendMock, $wrapperMock ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/filerepo/FileRepoTest.php b/www/wiki/tests/phpunit/includes/filerepo/FileRepoTest.php
new file mode 100644
index 00000000..0d3e679a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/filerepo/FileRepoTest.php
@@ -0,0 +1,55 @@
+<?php
+
+class FileRepoTest extends MediaWikiTestCase {
+
+ /**
+ * @expectedException MWException
+ * @covers FileRepo::__construct
+ */
+ public function testFileRepoConstructionOptionCanNotBeNull() {
+ new FileRepo();
+ }
+
+ /**
+ * @expectedException MWException
+ * @covers FileRepo::__construct
+ */
+ public function testFileRepoConstructionOptionCanNotBeAnEmptyArray() {
+ new FileRepo( [] );
+ }
+
+ /**
+ * @expectedException MWException
+ * @covers FileRepo::__construct
+ */
+ public function testFileRepoConstructionOptionNeedNameKey() {
+ new FileRepo( [
+ 'backend' => 'foobar'
+ ] );
+ }
+
+ /**
+ * @expectedException MWException
+ * @covers FileRepo::__construct
+ */
+ public function testFileRepoConstructionOptionNeedBackendKey() {
+ new FileRepo( [
+ 'name' => 'foobar'
+ ] );
+ }
+
+ /**
+ * @covers FileRepo::__construct
+ */
+ public function testFileRepoConstructionWithRequiredOptions() {
+ $f = new FileRepo( [
+ 'name' => 'FileRepoTestRepository',
+ 'backend' => new FSFileBackend( [
+ 'name' => 'local-testing',
+ 'wikiId' => 'test_wiki',
+ 'containerPaths' => []
+ ] )
+ ] );
+ $this->assertInstanceOf( FileRepo::class, $f );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/filerepo/MigrateFileRepoLayoutTest.php b/www/wiki/tests/phpunit/includes/filerepo/MigrateFileRepoLayoutTest.php
new file mode 100644
index 00000000..9beea5b6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/filerepo/MigrateFileRepoLayoutTest.php
@@ -0,0 +1,142 @@
+<?php
+
+/**
+ * @covers MigrateFileRepoLayout
+ */
+class MigrateFileRepoLayoutTest extends MediaWikiTestCase {
+ protected $tmpPrefix;
+ protected $migratorMock;
+ protected $tmpFilepath;
+ protected $text = 'testing';
+
+ protected function setUp() {
+ parent::setUp();
+
+ $filename = 'Foo.png';
+
+ $this->tmpPrefix = $this->getNewTempDirectory();
+
+ $backend = new FSFileBackend( [
+ 'name' => 'local-migratefilerepolayouttest',
+ 'wikiId' => wfWikiID(),
+ 'containerPaths' => [
+ 'migratefilerepolayouttest-original' => "{$this->tmpPrefix}-original",
+ 'migratefilerepolayouttest-public' => "{$this->tmpPrefix}-public",
+ 'migratefilerepolayouttest-thumb' => "{$this->tmpPrefix}-thumb",
+ 'migratefilerepolayouttest-temp' => "{$this->tmpPrefix}-temp",
+ 'migratefilerepolayouttest-deleted' => "{$this->tmpPrefix}-deleted",
+ ]
+ ] );
+
+ $dbMock = $this->getMockBuilder( Wikimedia\Rdbms\DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $imageRow = new stdClass;
+ $imageRow->img_name = $filename;
+ $imageRow->img_sha1 = sha1( $this->text );
+
+ $dbMock->expects( $this->any() )
+ ->method( 'select' )
+ ->will( $this->onConsecutiveCalls(
+ new FakeResultWrapper( [ $imageRow ] ), // image
+ new FakeResultWrapper( [] ), // image
+ new FakeResultWrapper( [] ) // filearchive
+ ) );
+
+ $repoMock = $this->getMockBuilder( LocalRepo::class )
+ ->setMethods( [ 'getMasterDB' ] )
+ ->setConstructorArgs( [ [
+ 'name' => 'migratefilerepolayouttest',
+ 'backend' => $backend
+ ] ] )
+ ->getMock();
+
+ $repoMock
+ ->expects( $this->any() )
+ ->method( 'getMasterDB' )
+ ->will( $this->returnValue( $dbMock ) );
+
+ $this->migratorMock = $this->getMockBuilder( MigrateFileRepoLayout::class )
+ ->setMethods( [ 'getRepo' ] )->getMock();
+ $this->migratorMock
+ ->expects( $this->any() )
+ ->method( 'getRepo' )
+ ->will( $this->returnValue( $repoMock ) );
+
+ $this->tmpFilepath = TempFSFile::factory(
+ 'migratefilelayout-test-', 'png', wfTempDir() )->getPath();
+
+ file_put_contents( $this->tmpFilepath, $this->text );
+
+ $hashPath = $repoMock->getHashPath( $filename );
+
+ $status = $repoMock->store(
+ $this->tmpFilepath,
+ 'public',
+ $hashPath . $filename,
+ FileRepo::OVERWRITE
+ );
+ }
+
+ protected function deleteFilesRecursively( $directory ) {
+ foreach ( glob( $directory . '/*' ) as $file ) {
+ if ( is_dir( $file ) ) {
+ $this->deleteFilesRecursively( $file );
+ } else {
+ unlink( $file );
+ }
+ }
+
+ rmdir( $directory );
+ }
+
+ protected function tearDown() {
+ foreach ( glob( $this->tmpPrefix . '*' ) as $directory ) {
+ $this->deleteFilesRecursively( $directory );
+ }
+
+ unlink( $this->tmpFilepath );
+
+ parent::tearDown();
+ }
+
+ public function testMigration() {
+ $this->migratorMock->loadParamsAndArgs(
+ null,
+ [ 'oldlayout' => 'name', 'newlayout' => 'sha1' ]
+ );
+
+ ob_start();
+
+ $this->migratorMock->execute();
+
+ ob_end_clean();
+
+ $sha1 = sha1( $this->text );
+
+ $expectedOriginalFilepath = $this->tmpPrefix
+ . '-original/'
+ . substr( $sha1, 0, 1 )
+ . '/'
+ . substr( $sha1, 1, 1 )
+ . '/'
+ . substr( $sha1, 2, 1 )
+ . '/'
+ . $sha1;
+
+ $this->assertEquals(
+ file_get_contents( $expectedOriginalFilepath ),
+ $this->text,
+ 'New sha1 file should be exist and have the right contents'
+ );
+
+ $expectedPublicFilepath = $this->tmpPrefix . '-public/f/f8/Foo.png';
+
+ $this->assertEquals(
+ file_get_contents( $expectedPublicFilepath ),
+ $this->text,
+ 'Existing name file should still and have the right contents'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/filerepo/RepoGroupTest.php b/www/wiki/tests/phpunit/includes/filerepo/RepoGroupTest.php
new file mode 100644
index 00000000..5a343f65
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/filerepo/RepoGroupTest.php
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * @covers RepoGroup
+ */
+class RepoGroupTest extends MediaWikiTestCase {
+
+ function testHasForeignRepoNegative() {
+ $this->setMwGlobals( 'wgForeignFileRepos', [] );
+ RepoGroup::destroySingleton();
+ FileBackendGroup::destroySingleton();
+ $this->assertFalse( RepoGroup::singleton()->hasForeignRepos() );
+ }
+
+ function testHasForeignRepoPositive() {
+ $this->setUpForeignRepo();
+ $this->assertTrue( RepoGroup::singleton()->hasForeignRepos() );
+ }
+
+ function testForEachForeignRepo() {
+ $this->setUpForeignRepo();
+ $fakeCallback = $this->createMock( RepoGroupTestHelper::class );
+ $fakeCallback->expects( $this->once() )->method( 'callback' );
+ RepoGroup::singleton()->forEachForeignRepo(
+ [ $fakeCallback, 'callback' ], [ [] ] );
+ }
+
+ function testForEachForeignRepoNone() {
+ $this->setMwGlobals( 'wgForeignFileRepos', [] );
+ RepoGroup::destroySingleton();
+ FileBackendGroup::destroySingleton();
+ $fakeCallback = $this->createMock( RepoGroupTestHelper::class );
+ $fakeCallback->expects( $this->never() )->method( 'callback' );
+ RepoGroup::singleton()->forEachForeignRepo(
+ [ $fakeCallback, 'callback' ], [ [] ] );
+ }
+
+ private function setUpForeignRepo() {
+ global $wgUploadDirectory;
+ $this->setMwGlobals( 'wgForeignFileRepos', [ [
+ 'class' => ForeignAPIRepo::class,
+ 'name' => 'wikimediacommons',
+ 'backend' => 'wikimediacommons-backend',
+ 'apibase' => 'https://commons.wikimedia.org/w/api.php',
+ 'hashLevels' => 2,
+ 'fetchDescription' => true,
+ 'descriptionCacheExpiry' => 43200,
+ 'apiThumbCacheExpiry' => 86400,
+ 'directory' => $wgUploadDirectory
+ ] ] );
+ RepoGroup::destroySingleton();
+ FileBackendGroup::destroySingleton();
+ }
+}
+
+/**
+ * Quick helper class to use as a mock callback for RepoGroup::singleton()->forEachForeignRepo.
+ */
+class RepoGroupTestHelper {
+ function callback( FileRepo $repo, array $foo ) {
+ return true;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/filerepo/StoreBatchTest.php b/www/wiki/tests/phpunit/includes/filerepo/StoreBatchTest.php
new file mode 100644
index 00000000..337c65c4
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/filerepo/StoreBatchTest.php
@@ -0,0 +1,141 @@
+<?php
+
+/**
+ * @group FileRepo
+ * @group medium
+ */
+class StoreBatchTest extends MediaWikiTestCase {
+
+ protected $createdFiles;
+ protected $date;
+ /** @var FileRepo */
+ protected $repo;
+
+ protected function setUp() {
+ global $wgFileBackends;
+ parent::setUp();
+
+ # Forge a FileRepo object to not have to rely on local wiki settings
+ $tmpPrefix = $this->getNewTempDirectory();
+ if ( $this->getCliArg( 'use-filebackend' ) ) {
+ $name = $this->getCliArg( 'use-filebackend' );
+ $useConfig = [];
+ foreach ( $wgFileBackends as $conf ) {
+ if ( $conf['name'] == $name ) {
+ $useConfig = $conf;
+ }
+ }
+ $useConfig['lockManager'] = LockManagerGroup::singleton()->get( $useConfig['lockManager'] );
+ unset( $useConfig['fileJournal'] );
+ $useConfig['name'] = 'local-testing'; // swap name
+ $class = $useConfig['class'];
+ $backend = new $class( $useConfig );
+ } else {
+ $backend = new FSFileBackend( [
+ 'name' => 'local-testing',
+ 'wikiId' => wfWikiID(),
+ 'containerPaths' => [
+ 'unittests-public' => "{$tmpPrefix}/public",
+ 'unittests-thumb' => "{$tmpPrefix}/thumb",
+ 'unittests-temp' => "{$tmpPrefix}/temp",
+ 'unittests-deleted' => "{$tmpPrefix}/deleted",
+ ]
+ ] );
+ }
+ $this->repo = new FileRepo( [
+ 'name' => 'unittests',
+ 'backend' => $backend
+ ] );
+
+ $this->date = gmdate( "YmdHis" );
+ $this->createdFiles = [];
+ }
+
+ protected function tearDown() {
+ // Delete files
+ $this->repo->cleanupBatch( $this->createdFiles );
+ parent::tearDown();
+ }
+
+ /**
+ * Store a file or virtual URL source into a media file name.
+ *
+ * @param string $originalName The title of the image
+ * @param string $srcPath The filepath or virtual URL
+ * @param int $flags Flags to pass into repo::store().
+ * @return Status
+ */
+ private function storeit( $originalName, $srcPath, $flags ) {
+ $hashPath = $this->repo->getHashPath( $originalName );
+ $dstRel = "$hashPath{$this->date}!$originalName";
+ $dstUrlRel = $hashPath . $this->date . '!' . rawurlencode( $originalName );
+
+ $result = $this->repo->store( $srcPath, 'temp', $dstRel, $flags );
+ $result->value = $this->repo->getVirtualUrl( 'temp' ) . '/' . $dstUrlRel;
+ $this->createdFiles[] = $result->value;
+
+ return $result;
+ }
+
+ /**
+ * Test storing a file using different flags.
+ *
+ * @param string $fn The title of the image
+ * @param string $infn The name of the file (in the filesystem)
+ * @param string $otherfn The name of the different file (in the filesystem)
+ * @param bool $fromrepo 'true' if we want to copy from a virtual URL out of the Repo.
+ */
+ private function storecohort( $fn, $infn, $otherfn, $fromrepo ) {
+ $f = $this->storeit( $fn, $infn, 0 );
+ $this->assertTrue( $f->isOK(), 'failed to store a new file' );
+ $this->assertEquals( $f->failCount, 0, "counts wrong {$f->successCount} {$f->failCount}" );
+ $this->assertEquals( $f->successCount, 1, "counts wrong {$f->successCount} {$f->failCount}" );
+ if ( $fromrepo ) {
+ $f = $this->storeit( "Other-$fn", $infn, FileRepo::OVERWRITE );
+ $infn = $f->value;
+ }
+ // This should work because we're allowed to overwrite
+ $f = $this->storeit( $fn, $infn, FileRepo::OVERWRITE );
+ $this->assertTrue( $f->isOK(), 'We should be allowed to overwrite' );
+ $this->assertEquals( $f->failCount, 0, "counts wrong {$f->successCount} {$f->failCount}" );
+ $this->assertEquals( $f->successCount, 1, "counts wrong {$f->successCount} {$f->failCount}" );
+ // This should fail because we're overwriting.
+ $f = $this->storeit( $fn, $infn, 0 );
+ $this->assertFalse( $f->isOK(), 'We should not be allowed to overwrite' );
+ $this->assertEquals( $f->failCount, 1, "counts wrong {$f->successCount} {$f->failCount}" );
+ $this->assertEquals( $f->successCount, 0, "counts wrong {$f->successCount} {$f->failCount}" );
+ // This should succeed because we're overwriting the same content.
+ $f = $this->storeit( $fn, $infn, FileRepo::OVERWRITE_SAME );
+ $this->assertTrue( $f->isOK(), 'We should be able to overwrite the same content' );
+ $this->assertEquals( $f->failCount, 0, "counts wrong {$f->successCount} {$f->failCount}" );
+ $this->assertEquals( $f->successCount, 1, "counts wrong {$f->successCount} {$f->failCount}" );
+ // This should fail because we're overwriting different content.
+ if ( $fromrepo ) {
+ $f = $this->storeit( "Other-$fn", $otherfn, FileRepo::OVERWRITE );
+ $otherfn = $f->value;
+ }
+ $f = $this->storeit( $fn, $otherfn, FileRepo::OVERWRITE_SAME );
+ $this->assertFalse( $f->isOK(), 'We should not be allowed to overwrite different content' );
+ $this->assertEquals( $f->failCount, 1, "counts wrong {$f->successCount} {$f->failCount}" );
+ $this->assertEquals( $f->successCount, 0, "counts wrong {$f->successCount} {$f->failCount}" );
+ }
+
+ /**
+ * @covers FileRepo::store
+ */
+ public function teststore() {
+ global $IP;
+ $this->storecohort(
+ "Test1.png",
+ "$IP/tests/phpunit/data/filerepo/wiki.png",
+ "$IP/tests/phpunit/data/filerepo/video.png",
+ false
+ );
+ $this->storecohort(
+ "Test2.png",
+ "$IP/tests/phpunit/data/filerepo/wiki.png",
+ "$IP/tests/phpunit/data/filerepo/video.png",
+ true
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/filerepo/file/FileTest.php b/www/wiki/tests/phpunit/includes/filerepo/file/FileTest.php
new file mode 100644
index 00000000..3f4e46b5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/filerepo/file/FileTest.php
@@ -0,0 +1,389 @@
+<?php
+
+class FileTest extends MediaWikiMediaTestCase {
+
+ /**
+ * @param string $filename
+ * @param bool $expected
+ * @dataProvider providerCanAnimate
+ * @covers File::canAnimateThumbIfAppropriate
+ */
+ function testCanAnimateThumbIfAppropriate( $filename, $expected ) {
+ $this->setMwGlobals( 'wgMaxAnimatedGifArea', 9000 );
+ $file = $this->dataFile( $filename );
+ $this->assertEquals( $file->canAnimateThumbIfAppropriate(), $expected );
+ }
+
+ function providerCanAnimate() {
+ return [
+ [ 'nonanimated.gif', true ],
+ [ 'jpeg-comment-utf.jpg', true ],
+ [ 'test.tiff', true ],
+ [ 'Animated_PNG_example_bouncing_beach_ball.png', false ],
+ [ 'greyscale-png.png', true ],
+ [ 'Toll_Texas_1.svg', true ],
+ [ 'LoremIpsum.djvu', true ],
+ [ '80x60-2layers.xcf', true ],
+ [ 'Soccer_ball_animated.svg', false ],
+ [ 'Bishzilla_blink.gif', false ],
+ [ 'animated.gif', true ],
+ ];
+ }
+
+ /**
+ * @dataProvider getThumbnailBucketProvider
+ * @covers File::getThumbnailBucket
+ */
+ public function testGetThumbnailBucket( $data ) {
+ $this->setMwGlobals( 'wgThumbnailBuckets', $data['buckets'] );
+ $this->setMwGlobals( 'wgThumbnailMinimumBucketDistance', $data['minimumBucketDistance'] );
+
+ $fileMock = $this->getMockBuilder( File::class )
+ ->setConstructorArgs( [ 'fileMock', false ] )
+ ->setMethods( [ 'getWidth' ] )
+ ->getMockForAbstractClass();
+
+ $fileMock->expects( $this->any() )
+ ->method( 'getWidth' )
+ ->will( $this->returnValue( $data['width'] ) );
+
+ $this->assertEquals(
+ $data['expectedBucket'],
+ $fileMock->getThumbnailBucket( $data['requestedWidth'] ),
+ $data['message'] );
+ }
+
+ public function getThumbnailBucketProvider() {
+ $defaultBuckets = [ 256, 512, 1024, 2048, 4096 ];
+
+ return [
+ [ [
+ 'buckets' => $defaultBuckets,
+ 'minimumBucketDistance' => 0,
+ 'width' => 3000,
+ 'requestedWidth' => 120,
+ 'expectedBucket' => 256,
+ 'message' => 'Picking bucket bigger than requested size'
+ ] ],
+ [ [
+ 'buckets' => $defaultBuckets,
+ 'minimumBucketDistance' => 0,
+ 'width' => 3000,
+ 'requestedWidth' => 300,
+ 'expectedBucket' => 512,
+ 'message' => 'Picking bucket bigger than requested size'
+ ] ],
+ [ [
+ 'buckets' => $defaultBuckets,
+ 'minimumBucketDistance' => 0,
+ 'width' => 3000,
+ 'requestedWidth' => 1024,
+ 'expectedBucket' => 2048,
+ 'message' => 'Picking bucket bigger than requested size'
+ ] ],
+ [ [
+ 'buckets' => $defaultBuckets,
+ 'minimumBucketDistance' => 0,
+ 'width' => 3000,
+ 'requestedWidth' => 2048,
+ 'expectedBucket' => false,
+ 'message' => 'Picking no bucket because none is bigger than the requested size'
+ ] ],
+ [ [
+ 'buckets' => $defaultBuckets,
+ 'minimumBucketDistance' => 0,
+ 'width' => 3000,
+ 'requestedWidth' => 3500,
+ 'expectedBucket' => false,
+ 'message' => 'Picking no bucket because requested size is bigger than original'
+ ] ],
+ [ [
+ 'buckets' => [ 1024 ],
+ 'minimumBucketDistance' => 0,
+ 'width' => 3000,
+ 'requestedWidth' => 1024,
+ 'expectedBucket' => false,
+ 'message' => 'Picking no bucket because requested size equals biggest bucket'
+ ] ],
+ [ [
+ 'buckets' => null,
+ 'minimumBucketDistance' => 0,
+ 'width' => 3000,
+ 'requestedWidth' => 1024,
+ 'expectedBucket' => false,
+ 'message' => 'Picking no bucket because no buckets have been specified'
+ ] ],
+ [ [
+ 'buckets' => [ 256, 512 ],
+ 'minimumBucketDistance' => 10,
+ 'width' => 3000,
+ 'requestedWidth' => 245,
+ 'expectedBucket' => 256,
+ 'message' => 'Requested width is distant enough from next bucket for it to be picked'
+ ] ],
+ [ [
+ 'buckets' => [ 256, 512 ],
+ 'minimumBucketDistance' => 10,
+ 'width' => 3000,
+ 'requestedWidth' => 246,
+ 'expectedBucket' => 512,
+ 'message' => 'Requested width is too close to next bucket, picking next one'
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider getThumbnailSourceProvider
+ * @covers File::getThumbnailSource
+ */
+ public function testGetThumbnailSource( $data ) {
+ $backendMock = $this->getMockBuilder( FSFileBackend::class )
+ ->setConstructorArgs( [ [ 'name' => 'backendMock', 'wikiId' => wfWikiID() ] ] )
+ ->getMock();
+
+ $repoMock = $this->getMockBuilder( FileRepo::class )
+ ->setConstructorArgs( [ [ 'name' => 'repoMock', 'backend' => $backendMock ] ] )
+ ->setMethods( [ 'fileExists', 'getLocalReference' ] )
+ ->getMock();
+
+ $fsFile = new FSFile( 'fsFilePath' );
+
+ $repoMock->expects( $this->any() )
+ ->method( 'fileExists' )
+ ->will( $this->returnValue( true ) );
+
+ $repoMock->expects( $this->any() )
+ ->method( 'getLocalReference' )
+ ->will( $this->returnValue( $fsFile ) );
+
+ $handlerMock = $this->getMockBuilder( BitmapHandler::class )
+ ->setMethods( [ 'supportsBucketing' ] )->getMock();
+ $handlerMock->expects( $this->any() )
+ ->method( 'supportsBucketing' )
+ ->will( $this->returnValue( $data['supportsBucketing'] ) );
+
+ $fileMock = $this->getMockBuilder( File::class )
+ ->setConstructorArgs( [ 'fileMock', $repoMock ] )
+ ->setMethods( [ 'getThumbnailBucket', 'getLocalRefPath', 'getHandler' ] )
+ ->getMockForAbstractClass();
+
+ $fileMock->expects( $this->any() )
+ ->method( 'getThumbnailBucket' )
+ ->will( $this->returnValue( $data['thumbnailBucket'] ) );
+
+ $fileMock->expects( $this->any() )
+ ->method( 'getLocalRefPath' )
+ ->will( $this->returnValue( 'localRefPath' ) );
+
+ $fileMock->expects( $this->any() )
+ ->method( 'getHandler' )
+ ->will( $this->returnValue( $handlerMock ) );
+
+ $reflection = new ReflectionClass( $fileMock );
+ $reflection_property = $reflection->getProperty( 'handler' );
+ $reflection_property->setAccessible( true );
+ $reflection_property->setValue( $fileMock, $handlerMock );
+
+ if ( !is_null( $data['tmpBucketedThumbCache'] ) ) {
+ $reflection_property = $reflection->getProperty( 'tmpBucketedThumbCache' );
+ $reflection_property->setAccessible( true );
+ $reflection_property->setValue( $fileMock, $data['tmpBucketedThumbCache'] );
+ }
+
+ $result = $fileMock->getThumbnailSource(
+ [ 'physicalWidth' => $data['physicalWidth'] ] );
+
+ $this->assertEquals( $data['expectedPath'], $result['path'], $data['message'] );
+ }
+
+ public function getThumbnailSourceProvider() {
+ return [
+ [ [
+ 'supportsBucketing' => true,
+ 'tmpBucketedThumbCache' => null,
+ 'thumbnailBucket' => 1024,
+ 'physicalWidth' => 2048,
+ 'expectedPath' => 'fsFilePath',
+ 'message' => 'Path downloaded from storage'
+ ] ],
+ [ [
+ 'supportsBucketing' => true,
+ 'tmpBucketedThumbCache' => [ 1024 => '/tmp/shouldnotexist' . rand() ],
+ 'thumbnailBucket' => 1024,
+ 'physicalWidth' => 2048,
+ 'expectedPath' => 'fsFilePath',
+ 'message' => 'Path downloaded from storage because temp file is missing'
+ ] ],
+ [ [
+ 'supportsBucketing' => true,
+ 'tmpBucketedThumbCache' => [ 1024 => '/tmp' ],
+ 'thumbnailBucket' => 1024,
+ 'physicalWidth' => 2048,
+ 'expectedPath' => '/tmp',
+ 'message' => 'Temporary path because temp file was found'
+ ] ],
+ [ [
+ 'supportsBucketing' => false,
+ 'tmpBucketedThumbCache' => null,
+ 'thumbnailBucket' => 1024,
+ 'physicalWidth' => 2048,
+ 'expectedPath' => 'localRefPath',
+ 'message' => 'Original file path because bucketing is unsupported by handler'
+ ] ],
+ [ [
+ 'supportsBucketing' => true,
+ 'tmpBucketedThumbCache' => null,
+ 'thumbnailBucket' => false,
+ 'physicalWidth' => 2048,
+ 'expectedPath' => 'localRefPath',
+ 'message' => 'Original file path because no width provided'
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider generateBucketsIfNeededProvider
+ * @covers File::generateBucketsIfNeeded
+ */
+ public function testGenerateBucketsIfNeeded( $data ) {
+ $this->setMwGlobals( 'wgThumbnailBuckets', $data['buckets'] );
+
+ $backendMock = $this->getMockBuilder( FSFileBackend::class )
+ ->setConstructorArgs( [ [ 'name' => 'backendMock', 'wikiId' => wfWikiID() ] ] )
+ ->getMock();
+
+ $repoMock = $this->getMockBuilder( FileRepo::class )
+ ->setConstructorArgs( [ [ 'name' => 'repoMock', 'backend' => $backendMock ] ] )
+ ->setMethods( [ 'fileExists', 'getLocalReference' ] )
+ ->getMock();
+
+ $fileMock = $this->getMockBuilder( File::class )
+ ->setConstructorArgs( [ 'fileMock', $repoMock ] )
+ ->setMethods( [ 'getWidth', 'getBucketThumbPath', 'makeTransformTmpFile',
+ 'generateAndSaveThumb', 'getHandler' ] )
+ ->getMockForAbstractClass();
+
+ $handlerMock = $this->getMockBuilder( JpegHandler::class )
+ ->setMethods( [ 'supportsBucketing' ] )->getMock();
+ $handlerMock->expects( $this->any() )
+ ->method( 'supportsBucketing' )
+ ->will( $this->returnValue( true ) );
+
+ $fileMock->expects( $this->any() )
+ ->method( 'getHandler' )
+ ->will( $this->returnValue( $handlerMock ) );
+
+ $reflectionMethod = new ReflectionMethod( File::class, 'generateBucketsIfNeeded' );
+ $reflectionMethod->setAccessible( true );
+
+ $fileMock->expects( $this->any() )
+ ->method( 'getWidth' )
+ ->will( $this->returnValue( $data['width'] ) );
+
+ $fileMock->expects( $data['expectedGetBucketThumbPathCalls'] )
+ ->method( 'getBucketThumbPath' );
+
+ $repoMock->expects( $data['expectedFileExistsCalls'] )
+ ->method( 'fileExists' )
+ ->will( $this->returnValue( $data['fileExistsReturn'] ) );
+
+ $fileMock->expects( $data['expectedMakeTransformTmpFile'] )
+ ->method( 'makeTransformTmpFile' )
+ ->will( $this->returnValue( $data['makeTransformTmpFileReturn'] ) );
+
+ $fileMock->expects( $data['expectedGenerateAndSaveThumb'] )
+ ->method( 'generateAndSaveThumb' )
+ ->will( $this->returnValue( $data['generateAndSaveThumbReturn'] ) );
+
+ $this->assertEquals( $data['expectedResult'],
+ $reflectionMethod->invoke(
+ $fileMock,
+ [
+ 'physicalWidth' => $data['physicalWidth'],
+ 'physicalHeight' => $data['physicalHeight'] ]
+ ),
+ $data['message'] );
+ }
+
+ public function generateBucketsIfNeededProvider() {
+ $defaultBuckets = [ 256, 512, 1024, 2048, 4096 ];
+
+ return [
+ [ [
+ 'buckets' => $defaultBuckets,
+ 'width' => 256,
+ 'physicalWidth' => 256,
+ 'physicalHeight' => 100,
+ 'expectedGetBucketThumbPathCalls' => $this->never(),
+ 'expectedFileExistsCalls' => $this->never(),
+ 'fileExistsReturn' => null,
+ 'expectedMakeTransformTmpFile' => $this->never(),
+ 'makeTransformTmpFileReturn' => false,
+ 'expectedGenerateAndSaveThumb' => $this->never(),
+ 'generateAndSaveThumbReturn' => false,
+ 'expectedResult' => false,
+ 'message' => 'No bucket found, nothing to generate'
+ ] ],
+ [ [
+ 'buckets' => $defaultBuckets,
+ 'width' => 5000,
+ 'physicalWidth' => 300,
+ 'physicalHeight' => 200,
+ 'expectedGetBucketThumbPathCalls' => $this->once(),
+ 'expectedFileExistsCalls' => $this->once(),
+ 'fileExistsReturn' => true,
+ 'expectedMakeTransformTmpFile' => $this->never(),
+ 'makeTransformTmpFileReturn' => false,
+ 'expectedGenerateAndSaveThumb' => $this->never(),
+ 'generateAndSaveThumbReturn' => false,
+ 'expectedResult' => false,
+ 'message' => 'File already exists, no reason to generate buckets'
+ ] ],
+ [ [
+ 'buckets' => $defaultBuckets,
+ 'width' => 5000,
+ 'physicalWidth' => 300,
+ 'physicalHeight' => 200,
+ 'expectedGetBucketThumbPathCalls' => $this->once(),
+ 'expectedFileExistsCalls' => $this->once(),
+ 'fileExistsReturn' => false,
+ 'expectedMakeTransformTmpFile' => $this->once(),
+ 'makeTransformTmpFileReturn' => false,
+ 'expectedGenerateAndSaveThumb' => $this->never(),
+ 'generateAndSaveThumbReturn' => false,
+ 'expectedResult' => false,
+ 'message' => 'Cannot generate temp file for bucket'
+ ] ],
+ [ [
+ 'buckets' => $defaultBuckets,
+ 'width' => 5000,
+ 'physicalWidth' => 300,
+ 'physicalHeight' => 200,
+ 'expectedGetBucketThumbPathCalls' => $this->once(),
+ 'expectedFileExistsCalls' => $this->once(),
+ 'fileExistsReturn' => false,
+ 'expectedMakeTransformTmpFile' => $this->once(),
+ 'makeTransformTmpFileReturn' => new TempFSFile( '/tmp/foo' ),
+ 'expectedGenerateAndSaveThumb' => $this->once(),
+ 'generateAndSaveThumbReturn' => false,
+ 'expectedResult' => false,
+ 'message' => 'Bucket image could not be generated'
+ ] ],
+ [ [
+ 'buckets' => $defaultBuckets,
+ 'width' => 5000,
+ 'physicalWidth' => 300,
+ 'physicalHeight' => 200,
+ 'expectedGetBucketThumbPathCalls' => $this->once(),
+ 'expectedFileExistsCalls' => $this->once(),
+ 'fileExistsReturn' => false,
+ 'expectedMakeTransformTmpFile' => $this->once(),
+ 'makeTransformTmpFileReturn' => new TempFSFile( '/tmp/foo' ),
+ 'expectedGenerateAndSaveThumb' => $this->once(),
+ 'generateAndSaveThumbReturn' => new ThumbnailImage( false, 'bar', false, false ),
+ 'expectedResult' => true,
+ 'message' => 'Bucket image could not be generated'
+ ] ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/filerepo/file/LocalFileTest.php b/www/wiki/tests/phpunit/includes/filerepo/file/LocalFileTest.php
new file mode 100644
index 00000000..e25e6064
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/filerepo/file/LocalFileTest.php
@@ -0,0 +1,183 @@
+<?php
+
+/**
+ * These tests should work regardless of $wgCapitalLinks
+ * @todo Split tests into providers and test methods
+ */
+
+class LocalFileTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( 'wgCapitalLinks', true );
+
+ $info = [
+ 'name' => 'test',
+ 'directory' => '/testdir',
+ 'url' => '/testurl',
+ 'hashLevels' => 2,
+ 'transformVia404' => false,
+ 'backend' => new FSFileBackend( [
+ 'name' => 'local-backend',
+ 'wikiId' => wfWikiID(),
+ 'containerPaths' => [
+ 'cont1' => "/testdir/local-backend/tempimages/cont1",
+ 'cont2' => "/testdir/local-backend/tempimages/cont2"
+ ]
+ ] )
+ ];
+ $this->repo_hl0 = new LocalRepo( [ 'hashLevels' => 0 ] + $info );
+ $this->repo_hl2 = new LocalRepo( [ 'hashLevels' => 2 ] + $info );
+ $this->repo_lc = new LocalRepo( [ 'initialCapital' => false ] + $info );
+ $this->file_hl0 = $this->repo_hl0->newFile( 'test!' );
+ $this->file_hl2 = $this->repo_hl2->newFile( 'test!' );
+ $this->file_lc = $this->repo_lc->newFile( 'test!' );
+ }
+
+ /**
+ * @covers File::getHashPath
+ */
+ public function testGetHashPath() {
+ $this->assertEquals( '', $this->file_hl0->getHashPath() );
+ $this->assertEquals( 'a/a2/', $this->file_hl2->getHashPath() );
+ $this->assertEquals( 'c/c4/', $this->file_lc->getHashPath() );
+ }
+
+ /**
+ * @covers File::getRel
+ */
+ public function testGetRel() {
+ $this->assertEquals( 'Test!', $this->file_hl0->getRel() );
+ $this->assertEquals( 'a/a2/Test!', $this->file_hl2->getRel() );
+ $this->assertEquals( 'c/c4/test!', $this->file_lc->getRel() );
+ }
+
+ /**
+ * @covers File::getUrlRel
+ */
+ public function testGetUrlRel() {
+ $this->assertEquals( 'Test%21', $this->file_hl0->getUrlRel() );
+ $this->assertEquals( 'a/a2/Test%21', $this->file_hl2->getUrlRel() );
+ $this->assertEquals( 'c/c4/test%21', $this->file_lc->getUrlRel() );
+ }
+
+ /**
+ * @covers File::getArchivePath
+ */
+ public function testGetArchivePath() {
+ $this->assertEquals(
+ 'mwstore://local-backend/test-public/archive',
+ $this->file_hl0->getArchivePath()
+ );
+ $this->assertEquals(
+ 'mwstore://local-backend/test-public/archive/a/a2',
+ $this->file_hl2->getArchivePath()
+ );
+ $this->assertEquals(
+ 'mwstore://local-backend/test-public/archive/!',
+ $this->file_hl0->getArchivePath( '!' )
+ );
+ $this->assertEquals(
+ 'mwstore://local-backend/test-public/archive/a/a2/!',
+ $this->file_hl2->getArchivePath( '!' )
+ );
+ }
+
+ /**
+ * @covers File::getThumbPath
+ */
+ public function testGetThumbPath() {
+ $this->assertEquals(
+ 'mwstore://local-backend/test-thumb/Test!',
+ $this->file_hl0->getThumbPath()
+ );
+ $this->assertEquals(
+ 'mwstore://local-backend/test-thumb/a/a2/Test!',
+ $this->file_hl2->getThumbPath()
+ );
+ $this->assertEquals(
+ 'mwstore://local-backend/test-thumb/Test!/x',
+ $this->file_hl0->getThumbPath( 'x' )
+ );
+ $this->assertEquals(
+ 'mwstore://local-backend/test-thumb/a/a2/Test!/x',
+ $this->file_hl2->getThumbPath( 'x' )
+ );
+ }
+
+ /**
+ * @covers File::getArchiveUrl
+ */
+ public function testGetArchiveUrl() {
+ $this->assertEquals( '/testurl/archive', $this->file_hl0->getArchiveUrl() );
+ $this->assertEquals( '/testurl/archive/a/a2', $this->file_hl2->getArchiveUrl() );
+ $this->assertEquals( '/testurl/archive/%21', $this->file_hl0->getArchiveUrl( '!' ) );
+ $this->assertEquals( '/testurl/archive/a/a2/%21', $this->file_hl2->getArchiveUrl( '!' ) );
+ }
+
+ /**
+ * @covers File::getThumbUrl
+ */
+ public function testGetThumbUrl() {
+ $this->assertEquals( '/testurl/thumb/Test%21', $this->file_hl0->getThumbUrl() );
+ $this->assertEquals( '/testurl/thumb/a/a2/Test%21', $this->file_hl2->getThumbUrl() );
+ $this->assertEquals( '/testurl/thumb/Test%21/x', $this->file_hl0->getThumbUrl( 'x' ) );
+ $this->assertEquals( '/testurl/thumb/a/a2/Test%21/x', $this->file_hl2->getThumbUrl( 'x' ) );
+ }
+
+ /**
+ * @covers File::getArchiveVirtualUrl
+ */
+ public function testGetArchiveVirtualUrl() {
+ $this->assertEquals( 'mwrepo://test/public/archive', $this->file_hl0->getArchiveVirtualUrl() );
+ $this->assertEquals(
+ 'mwrepo://test/public/archive/a/a2',
+ $this->file_hl2->getArchiveVirtualUrl()
+ );
+ $this->assertEquals(
+ 'mwrepo://test/public/archive/%21',
+ $this->file_hl0->getArchiveVirtualUrl( '!' )
+ );
+ $this->assertEquals(
+ 'mwrepo://test/public/archive/a/a2/%21',
+ $this->file_hl2->getArchiveVirtualUrl( '!' )
+ );
+ }
+
+ /**
+ * @covers File::getThumbVirtualUrl
+ */
+ public function testGetThumbVirtualUrl() {
+ $this->assertEquals( 'mwrepo://test/thumb/Test%21', $this->file_hl0->getThumbVirtualUrl() );
+ $this->assertEquals( 'mwrepo://test/thumb/a/a2/Test%21', $this->file_hl2->getThumbVirtualUrl() );
+ $this->assertEquals(
+ 'mwrepo://test/thumb/Test%21/%21',
+ $this->file_hl0->getThumbVirtualUrl( '!' )
+ );
+ $this->assertEquals(
+ 'mwrepo://test/thumb/a/a2/Test%21/%21',
+ $this->file_hl2->getThumbVirtualUrl( '!' )
+ );
+ }
+
+ /**
+ * @covers File::getUrl
+ */
+ public function testGetUrl() {
+ $this->assertEquals( '/testurl/Test%21', $this->file_hl0->getUrl() );
+ $this->assertEquals( '/testurl/a/a2/Test%21', $this->file_hl2->getUrl() );
+ }
+
+ /**
+ * @covers ::wfLocalFile
+ */
+ public function testWfLocalFile() {
+ $file = wfLocalFile( "File:Some_file_that_probably_doesn't exist.png" );
+ $this->assertThat(
+ $file,
+ $this->isInstanceOf( LocalFile::class ),
+ 'wfLocalFile() returns LocalFile for valid Titles'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php b/www/wiki/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php
new file mode 100644
index 00000000..99bea68d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * Unit tests for HTMLAutoCompleteSelectField
+ *
+ * @covers HTMLAutoCompleteSelectField
+ */
+class HTMLAutoCompleteSelectFieldTest extends MediaWikiTestCase {
+
+ public $options = [
+ 'Bulgaria' => 'BGR',
+ 'Burkina Faso' => 'BFA',
+ 'Burundi' => 'BDI',
+ ];
+
+ /**
+ * Verify that attempting to instantiate an HTMLAutoCompleteSelectField
+ * without providing any autocomplete options causes an exception to be
+ * thrown.
+ *
+ * @expectedException MWException
+ * @expectedExceptionMessage called without any autocompletions
+ */
+ function testMissingAutocompletions() {
+ new HTMLAutoCompleteSelectField( [ 'fieldname' => 'Test' ] );
+ }
+
+ /**
+ * Verify that the autocomplete options are correctly encoded as
+ * the 'data-autocomplete' attribute of the field.
+ *
+ * @covers HTMLAutoCompleteSelectField::getAttributes
+ */
+ function testGetAttributes() {
+ $field = new HTMLAutoCompleteSelectField( [
+ 'fieldname' => 'Test',
+ 'autocomplete' => $this->options,
+ ] );
+
+ $attributes = $field->getAttributes( [] );
+ $this->assertEquals( array_keys( $this->options ),
+ FormatJson::decode( $attributes['data-autocomplete'] ),
+ "The 'data-autocomplete' attribute encodes autocomplete option keys as a JSON array."
+ );
+ }
+
+ /**
+ * Test that the optional select dropdown is included or excluded based on
+ * the presence or absence of the 'options' parameter.
+ */
+ function testOptionalSelectElement() {
+ $params = [
+ 'fieldname' => 'Test',
+ 'autocomplete-data' => $this->options,
+ 'options' => $this->options,
+ ];
+
+ $field = new HTMLAutoCompleteSelectField( $params );
+ $html = $field->getInputHTML( false );
+ $this->assertRegExp( '/select/', $html,
+ "When the 'options' parameter is set, the HTML includes a <select>" );
+
+ unset( $params['options'] );
+ $field = new HTMLAutoCompleteSelectField( $params );
+ $html = $field->getInputHTML( false );
+ $this->assertNotRegExp( '/select/', $html,
+ "When the 'options' parameter is not set, the HTML does not include a <select>" );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php b/www/wiki/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php
new file mode 100644
index 00000000..e7922fd2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php
@@ -0,0 +1,105 @@
+<?php
+
+/**
+ * Unit tests for the HTMLCheckMatrix
+ * @covers HTMLCheckMatrix
+ */
+class HTMLCheckMatrixTest extends MediaWikiTestCase {
+ static private $defaultOptions = [
+ 'rows' => [ 'r1', 'r2' ],
+ 'columns' => [ 'c1', 'c2' ],
+ 'fieldname' => 'test',
+ ];
+
+ public function testPlainInstantiation() {
+ try {
+ new HTMLCheckMatrix( [] );
+ } catch ( MWException $e ) {
+ $this->assertInstanceOf( HTMLFormFieldRequiredOptionsException::class, $e );
+ return;
+ }
+
+ $this->fail( 'Expected MWException indicating missing parameters but none was thrown.' );
+ }
+
+ public function testInstantiationWithMinimumRequiredParameters() {
+ new HTMLCheckMatrix( self::$defaultOptions );
+ $this->assertTrue( true ); // form instantiation must throw exception on failure
+ }
+
+ public function testValidateCallsUserDefinedValidationCallback() {
+ $called = false;
+ $field = new HTMLCheckMatrix( self::$defaultOptions + [
+ 'validation-callback' => function () use ( &$called ) {
+ $called = true;
+
+ return false;
+ },
+ ] );
+ $this->assertEquals( false, $this->validate( $field, [] ) );
+ $this->assertTrue( $called );
+ }
+
+ public function testValidateRequiresArrayInput() {
+ $field = new HTMLCheckMatrix( self::$defaultOptions );
+ $this->assertEquals( false, $this->validate( $field, null ) );
+ $this->assertEquals( false, $this->validate( $field, true ) );
+ $this->assertEquals( false, $this->validate( $field, 'abc' ) );
+ $this->assertEquals( false, $this->validate( $field, new stdClass ) );
+ $this->assertEquals( true, $this->validate( $field, [] ) );
+ }
+
+ public function testValidateAllowsOnlyKnownTags() {
+ $field = new HTMLCheckMatrix( self::$defaultOptions );
+ $this->assertInstanceOf( Message::class, $this->validate( $field, [ 'foo' ] ) );
+ }
+
+ public function testValidateAcceptsPartialTagList() {
+ $field = new HTMLCheckMatrix( self::$defaultOptions );
+ $this->assertTrue( $this->validate( $field, [] ) );
+ $this->assertTrue( $this->validate( $field, [ 'c1-r1' ] ) );
+ $this->assertTrue( $this->validate( $field, [ 'c1-r1', 'c1-r2', 'c2-r1', 'c2-r2' ] ) );
+ }
+
+ /**
+ * This form object actually has no visibility into what happens later on, but essentially
+ * if the data submitted by the user passes validate the following is run:
+ * foreach ( $field->filterDataForSubmit( $data ) as $k => $v ) {
+ * $user->setOption( $k, $v );
+ * }
+ */
+ public function testValuesForcedOnRemainOn() {
+ $field = new HTMLCheckMatrix( self::$defaultOptions + [
+ 'force-options-on' => [ 'c2-r1' ],
+ ] );
+ $expected = [
+ 'c1-r1' => false,
+ 'c1-r2' => false,
+ 'c2-r1' => true,
+ 'c2-r2' => false,
+ ];
+ $this->assertEquals( $expected, $field->filterDataForSubmit( [] ) );
+ }
+
+ public function testValuesForcedOffRemainOff() {
+ $field = new HTMLCheckMatrix( self::$defaultOptions + [
+ 'force-options-off' => [ 'c1-r2', 'c2-r2' ],
+ ] );
+ $expected = [
+ 'c1-r1' => true,
+ 'c1-r2' => false,
+ 'c2-r1' => true,
+ 'c2-r2' => false,
+ ];
+ // array_keys on the result simulates submitting all fields checked
+ $this->assertEquals( $expected, $field->filterDataForSubmit( array_keys( $expected ) ) );
+ }
+
+ protected function validate( HTMLFormField $field, $submitted ) {
+ return $field->validate(
+ $submitted,
+ [ self::$defaultOptions['fieldname'] => $submitted ]
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/htmlform/HTMLFormTest.php b/www/wiki/tests/phpunit/includes/htmlform/HTMLFormTest.php
new file mode 100644
index 00000000..e20cf942
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/htmlform/HTMLFormTest.php
@@ -0,0 +1,57 @@
+<?php
+
+/**
+ * @covers HTMLForm
+ *
+ * @license GNU GPL v2+
+ * @author Gergő Tisza
+ * @author Thiemo Mättig
+ */
+class HTMLFormTest extends MediaWikiTestCase {
+
+ private function newInstance() {
+ $form = new HTMLForm( [] );
+ $form->setTitle( Title::newFromText( 'Foo' ) );
+ return $form;
+ }
+
+ public function testGetHTML_empty() {
+ $form = $this->newInstance();
+ $form->prepareForm();
+ $html = $form->getHTML( false );
+ $this->assertStringStartsWith( '<form ', $html );
+ }
+
+ /**
+ * @expectedException LogicException
+ */
+ public function testGetHTML_noPrepare() {
+ $form = $this->newInstance();
+ $form->getHTML( false );
+ }
+
+ public function testAutocompleteDefaultsToNull() {
+ $form = $this->newInstance();
+ $this->assertNotContains( 'autocomplete', $form->wrapForm( '' ) );
+ }
+
+ public function testAutocompleteWhenSetToNull() {
+ $form = $this->newInstance();
+ $form->setAutocomplete( null );
+ $this->assertNotContains( 'autocomplete', $form->wrapForm( '' ) );
+ }
+
+ public function testAutocompleteWhenSetToFalse() {
+ $form = $this->newInstance();
+ // Previously false was used instead of null to indicate the attribute should not be set
+ $form->setAutocomplete( false );
+ $this->assertNotContains( 'autocomplete', $form->wrapForm( '' ) );
+ }
+
+ public function testAutocompleteWhenSetToOff() {
+ $form = $this->newInstance();
+ $form->setAutocomplete( 'off' );
+ $this->assertContains( ' autocomplete="off"', $form->wrapForm( '' ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/htmlform/HTMLRestrictionsFieldTest.php b/www/wiki/tests/phpunit/includes/htmlform/HTMLRestrictionsFieldTest.php
new file mode 100644
index 00000000..c4290e1e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/htmlform/HTMLRestrictionsFieldTest.php
@@ -0,0 +1,71 @@
+<?php
+
+/**
+ * @covers HTMLRestrictionsField
+ */
+class HTMLRestrictionsFieldTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function testConstruct() {
+ $field = new HTMLRestrictionsField( [ 'fieldname' => 'restrictions' ] );
+ $this->assertNotEmpty( $field->getLabel(), 'has a default label' );
+ $this->assertNotEmpty( $field->getHelpText(), 'has a default help text' );
+ $this->assertEquals( MWRestrictions::newDefault(), $field->getDefault(),
+ 'defaults to the default MWRestrictions object' );
+
+ $field = new HTMLRestrictionsField( [
+ 'fieldname' => 'restrictions',
+ 'label' => 'foo',
+ 'help' => 'bar',
+ 'default' => 'baz',
+ ] );
+ $this->assertEquals( 'foo', $field->getLabel(), 'label can be customized' );
+ $this->assertEquals( 'bar', $field->getHelpText(), 'help text can be customized' );
+ $this->assertEquals( 'baz', $field->getDefault(), 'default can be customized' );
+ }
+
+ /**
+ * @dataProvider provideValidate
+ */
+ public function testForm( $text, $value ) {
+ $form = HTMLForm::factory( 'ooui', [
+ 'restrictions' => [ 'class' => HTMLRestrictionsField::class ],
+ ] );
+ $request = new FauxRequest( [ 'wprestrictions' => $text ], true );
+ $context = new DerivativeContext( RequestContext::getMain() );
+ $context->setRequest( $request );
+ $form->setContext( $context );
+ $form->setTitle( Title::newFromText( 'Main Page' ) )->setSubmitCallback( function () {
+ return true;
+ } )->prepareForm();
+ $status = $form->trySubmit();
+
+ if ( $status instanceof StatusValue ) {
+ $this->assertEquals( $value !== false, $status->isGood() );
+ } elseif ( $value === false ) {
+ $this->assertNotSame( true, $status );
+ } else {
+ $this->assertSame( true, $status );
+ }
+
+ if ( $value !== false ) {
+ $restrictions = $form->mFieldData['restrictions'];
+ $this->assertInstanceOf( MWRestrictions::class, $restrictions );
+ $this->assertEquals( $value, $restrictions->toArray()['IPAddresses'] );
+ }
+
+ // sanity
+ $form->getHTML( $status );
+ }
+
+ public function provideValidate() {
+ return [
+ // submitted text, value of 'IPAddresses' key or false for validation error
+ [ null, [ '0.0.0.0/0', '::/0' ] ],
+ [ '', [] ],
+ [ "1.2.3.4\n::/0", [ '1.2.3.4', '::/0' ] ],
+ [ "1.2.3.4\n::/x", false ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/http/HttpTest.php b/www/wiki/tests/phpunit/includes/http/HttpTest.php
new file mode 100644
index 00000000..f80d18c6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/http/HttpTest.php
@@ -0,0 +1,548 @@
+<?php
+
+/**
+ * @group Http
+ * @group small
+ */
+class HttpTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider cookieDomains
+ * @covers Cookie::validateCookieDomain
+ */
+ public function testValidateCookieDomain( $expected, $domain, $origin = null ) {
+ if ( $origin ) {
+ $ok = Cookie::validateCookieDomain( $domain, $origin );
+ $msg = "$domain against origin $origin";
+ } else {
+ $ok = Cookie::validateCookieDomain( $domain );
+ $msg = "$domain";
+ }
+ $this->assertEquals( $expected, $ok, $msg );
+ }
+
+ public static function cookieDomains() {
+ return [
+ [ false, "org" ],
+ [ false, ".org" ],
+ [ true, "wikipedia.org" ],
+ [ true, ".wikipedia.org" ],
+ [ false, "co.uk" ],
+ [ false, ".co.uk" ],
+ [ false, "gov.uk" ],
+ [ false, ".gov.uk" ],
+ [ true, "supermarket.uk" ],
+ [ false, "uk" ],
+ [ false, ".uk" ],
+ [ false, "127.0.0." ],
+ [ false, "127." ],
+ [ false, "127.0.0.1." ],
+ [ true, "127.0.0.1" ],
+ [ false, "333.0.0.1" ],
+ [ true, "example.com" ],
+ [ false, "example.com." ],
+ [ true, ".example.com" ],
+
+ [ true, ".example.com", "www.example.com" ],
+ [ false, "example.com", "www.example.com" ],
+ [ true, "127.0.0.1", "127.0.0.1" ],
+ [ false, "127.0.0.1", "localhost" ],
+ ];
+ }
+
+ /**
+ * Test Http::isValidURI()
+ * T29854 : Http::isValidURI is too lax
+ * @dataProvider provideURI
+ * @covers Http::isValidURI
+ */
+ public function testIsValidUri( $expect, $URI, $message = '' ) {
+ $this->assertEquals(
+ $expect,
+ (bool)Http::isValidURI( $URI ),
+ $message
+ );
+ }
+
+ /**
+ * @covers Http::getProxy
+ */
+ public function testGetProxy() {
+ $this->setMwGlobals( 'wgHTTPProxy', false );
+ $this->assertEquals(
+ '',
+ Http::getProxy(),
+ 'default setting'
+ );
+
+ $this->setMwGlobals( 'wgHTTPProxy', 'proxy.domain.tld' );
+ $this->assertEquals(
+ 'proxy.domain.tld',
+ Http::getProxy()
+ );
+ }
+
+ /**
+ * Feeds URI to test a long regular expression in Http::isValidURI
+ */
+ public static function provideURI() {
+ /** Format: 'boolean expectation', 'URI to test', 'Optional message' */
+ return [
+ [ false, '¿non sens before!! http://a', 'Allow anything before URI' ],
+
+ # (http|https) - only two schemes allowed
+ [ true, 'http://www.example.org/' ],
+ [ true, 'https://www.example.org/' ],
+ [ true, 'http://www.example.org', 'URI without directory' ],
+ [ true, 'http://a', 'Short name' ],
+ [ true, 'http://étoile', 'Allow UTF-8 in hostname' ], # 'étoile' is french for 'star'
+ [ false, '\\host\directory', 'CIFS share' ],
+ [ false, 'gopher://host/dir', 'Reject gopher scheme' ],
+ [ false, 'telnet://host', 'Reject telnet scheme' ],
+
+ # :\/\/ - double slashes
+ [ false, 'http//example.org', 'Reject missing colon in protocol' ],
+ [ false, 'http:/example.org', 'Reject missing slash in protocol' ],
+ [ false, 'http:example.org', 'Must have two slashes' ],
+ # Following fail since hostname can be made of anything
+ [ false, 'http:///example.org', 'Must have exactly two slashes, not three' ],
+
+ # (\w+:{0,1}\w*@)? - optional user:pass
+ [ true, 'http://user@host', 'Username provided' ],
+ [ true, 'http://user:@host', 'Username provided, no password' ],
+ [ true, 'http://user:pass@host', 'Username and password provided' ],
+
+ # (\S+) - host part is made of anything not whitespaces
+ // commented these out in order to remove @group Broken
+ // @todo are these valid tests? if so, fix Http::isValidURI so it can handle them
+ // [ false, 'http://!"èèè¿¿¿~~\'', 'hostname is made of any non whitespace' ],
+ // [ false, 'http://exam:ple.org/', 'hostname can not use colons!' ],
+
+ # (:[0-9]+)? - port number
+ [ true, 'http://example.org:80/' ],
+ [ true, 'https://example.org:80/' ],
+ [ true, 'http://example.org:443/' ],
+ [ true, 'https://example.org:443/' ],
+
+ # Part after the hostname is / or / with something else
+ [ true, 'http://example/#' ],
+ [ true, 'http://example/!' ],
+ [ true, 'http://example/:' ],
+ [ true, 'http://example/.' ],
+ [ true, 'http://example/?' ],
+ [ true, 'http://example/+' ],
+ [ true, 'http://example/=' ],
+ [ true, 'http://example/&' ],
+ [ true, 'http://example/%' ],
+ [ true, 'http://example/@' ],
+ [ true, 'http://example/-' ],
+ [ true, 'http://example//' ],
+ [ true, 'http://example/&' ],
+
+ # Fragment
+ [ true, 'http://exam#ple.org', ], # This one is valid, really!
+ [ true, 'http://example.org:80#anchor' ],
+ [ true, 'http://example.org/?id#anchor' ],
+ [ true, 'http://example.org/?#anchor' ],
+
+ [ false, 'http://a ¿non !!sens after', 'Allow anything after URI' ],
+ ];
+ }
+
+ public static function provideRelativeRedirects() {
+ return [
+ [
+ 'location' => [ 'http://newsite/file.ext', '/newfile.ext' ],
+ 'final' => 'http://newsite/newfile.ext',
+ 'Relative file path Location: interpreted as full URL'
+ ],
+ [
+ 'location' => [ 'https://oldsite/file.ext' ],
+ 'final' => 'https://oldsite/file.ext',
+ 'Location to the HTTPS version of the site'
+ ],
+ [
+ 'location' => [
+ '/anotherfile.ext',
+ 'http://anotherfile/hoster.ext',
+ 'https://anotherfile/hoster.ext'
+ ],
+ 'final' => 'https://anotherfile/hoster.ext',
+ 'Relative file path Location: should keep the latest host and scheme!'
+ ],
+ [
+ 'location' => [ '/anotherfile.ext' ],
+ 'final' => 'http://oldsite/anotherfile.ext',
+ 'Relative Location without domain '
+ ],
+ [
+ 'location' => null,
+ 'final' => 'http://oldsite/file.ext',
+ 'No Location (no redirect) '
+ ],
+ ];
+ }
+
+ /**
+ * Warning:
+ *
+ * These tests are for code that makes use of an artifact of how CURL
+ * handles header reporting on redirect pages, and will need to be
+ * rewritten when T31232 is taken care of (high-level handling of HTTP redirects).
+ *
+ * @dataProvider provideRelativeRedirects
+ * @covers MWHttpRequest::getFinalUrl
+ */
+ public function testRelativeRedirections( $location, $final, $message = null ) {
+ $h = MWHttpRequestTester::factory( 'http://oldsite/file.ext', [], __METHOD__ );
+ // Forge a Location header
+ $h->setRespHeaders( 'location', $location );
+ // Verify it correctly fixes the Location
+ $this->assertEquals( $final, $h->getFinalUrl(), $message );
+ }
+
+ /**
+ * Constant values are from PHP 5.3.28 using cURL 7.24.0
+ * @see https://secure.php.net/manual/en/curl.constants.php
+ *
+ * All constant values are present so that developers don’t need to remember
+ * to add them if added at a later date. The commented out constants were
+ * not found anywhere in the MediaWiki core code.
+ *
+ * Commented out constants that were not available in:
+ * HipHop VM 3.3.0 (rel)
+ * Compiler: heads/master-0-g08810d920dfff59e0774cf2d651f92f13a637175
+ * Repo schema: 3214fc2c684a4520485f715ee45f33f2182324b1
+ * Extension API: 20140829
+ *
+ * Commented out constants that were removed in PHP 5.6.0
+ */
+ public function provideCurlConstants() {
+ return [
+ [ 'CURLAUTH_ANY' ],
+ [ 'CURLAUTH_ANYSAFE' ],
+ [ 'CURLAUTH_BASIC' ],
+ [ 'CURLAUTH_DIGEST' ],
+ [ 'CURLAUTH_GSSNEGOTIATE' ],
+ [ 'CURLAUTH_NTLM' ],
+ // [ 'CURLCLOSEPOLICY_CALLBACK' ], // removed in PHP 5.6.0
+ // [ 'CURLCLOSEPOLICY_LEAST_RECENTLY_USED' ], // removed in PHP 5.6.0
+ // [ 'CURLCLOSEPOLICY_LEAST_TRAFFIC' ], // removed in PHP 5.6.0
+ // [ 'CURLCLOSEPOLICY_OLDEST' ], // removed in PHP 5.6.0
+ // [ 'CURLCLOSEPOLICY_SLOWEST' ], // removed in PHP 5.6.0
+ [ 'CURLE_ABORTED_BY_CALLBACK' ],
+ [ 'CURLE_BAD_CALLING_ORDER' ],
+ [ 'CURLE_BAD_CONTENT_ENCODING' ],
+ [ 'CURLE_BAD_FUNCTION_ARGUMENT' ],
+ [ 'CURLE_BAD_PASSWORD_ENTERED' ],
+ [ 'CURLE_COULDNT_CONNECT' ],
+ [ 'CURLE_COULDNT_RESOLVE_HOST' ],
+ [ 'CURLE_COULDNT_RESOLVE_PROXY' ],
+ [ 'CURLE_FAILED_INIT' ],
+ [ 'CURLE_FILESIZE_EXCEEDED' ],
+ [ 'CURLE_FILE_COULDNT_READ_FILE' ],
+ [ 'CURLE_FTP_ACCESS_DENIED' ],
+ [ 'CURLE_FTP_BAD_DOWNLOAD_RESUME' ],
+ [ 'CURLE_FTP_CANT_GET_HOST' ],
+ [ 'CURLE_FTP_CANT_RECONNECT' ],
+ [ 'CURLE_FTP_COULDNT_GET_SIZE' ],
+ [ 'CURLE_FTP_COULDNT_RETR_FILE' ],
+ [ 'CURLE_FTP_COULDNT_SET_ASCII' ],
+ [ 'CURLE_FTP_COULDNT_SET_BINARY' ],
+ [ 'CURLE_FTP_COULDNT_STOR_FILE' ],
+ [ 'CURLE_FTP_COULDNT_USE_REST' ],
+ [ 'CURLE_FTP_PORT_FAILED' ],
+ [ 'CURLE_FTP_QUOTE_ERROR' ],
+ [ 'CURLE_FTP_SSL_FAILED' ],
+ [ 'CURLE_FTP_USER_PASSWORD_INCORRECT' ],
+ [ 'CURLE_FTP_WEIRD_227_FORMAT' ],
+ [ 'CURLE_FTP_WEIRD_PASS_REPLY' ],
+ [ 'CURLE_FTP_WEIRD_PASV_REPLY' ],
+ [ 'CURLE_FTP_WEIRD_SERVER_REPLY' ],
+ [ 'CURLE_FTP_WEIRD_USER_REPLY' ],
+ [ 'CURLE_FTP_WRITE_ERROR' ],
+ [ 'CURLE_FUNCTION_NOT_FOUND' ],
+ [ 'CURLE_GOT_NOTHING' ],
+ [ 'CURLE_HTTP_NOT_FOUND' ],
+ [ 'CURLE_HTTP_PORT_FAILED' ],
+ [ 'CURLE_HTTP_POST_ERROR' ],
+ [ 'CURLE_HTTP_RANGE_ERROR' ],
+ [ 'CURLE_LDAP_CANNOT_BIND' ],
+ [ 'CURLE_LDAP_INVALID_URL' ],
+ [ 'CURLE_LDAP_SEARCH_FAILED' ],
+ [ 'CURLE_LIBRARY_NOT_FOUND' ],
+ [ 'CURLE_MALFORMAT_USER' ],
+ [ 'CURLE_OBSOLETE' ],
+ [ 'CURLE_OK' ],
+ [ 'CURLE_OPERATION_TIMEOUTED' ],
+ [ 'CURLE_OUT_OF_MEMORY' ],
+ [ 'CURLE_PARTIAL_FILE' ],
+ [ 'CURLE_READ_ERROR' ],
+ [ 'CURLE_RECV_ERROR' ],
+ [ 'CURLE_SEND_ERROR' ],
+ [ 'CURLE_SHARE_IN_USE' ],
+ // [ 'CURLE_SSH' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLE_SSL_CACERT' ],
+ [ 'CURLE_SSL_CERTPROBLEM' ],
+ [ 'CURLE_SSL_CIPHER' ],
+ [ 'CURLE_SSL_CONNECT_ERROR' ],
+ [ 'CURLE_SSL_ENGINE_NOTFOUND' ],
+ [ 'CURLE_SSL_ENGINE_SETFAILED' ],
+ [ 'CURLE_SSL_PEER_CERTIFICATE' ],
+ [ 'CURLE_TELNET_OPTION_SYNTAX' ],
+ [ 'CURLE_TOO_MANY_REDIRECTS' ],
+ [ 'CURLE_UNKNOWN_TELNET_OPTION' ],
+ [ 'CURLE_UNSUPPORTED_PROTOCOL' ],
+ [ 'CURLE_URL_MALFORMAT' ],
+ [ 'CURLE_URL_MALFORMAT_USER' ],
+ [ 'CURLE_WRITE_ERROR' ],
+ [ 'CURLFTPAUTH_DEFAULT' ],
+ [ 'CURLFTPAUTH_SSL' ],
+ [ 'CURLFTPAUTH_TLS' ],
+ // [ 'CURLFTPMETHOD_MULTICWD' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLFTPMETHOD_NOCWD' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLFTPMETHOD_SINGLECWD' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLFTPSSL_ALL' ],
+ [ 'CURLFTPSSL_CONTROL' ],
+ [ 'CURLFTPSSL_NONE' ],
+ [ 'CURLFTPSSL_TRY' ],
+ // [ 'CURLINFO_CERTINFO' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLINFO_CONNECT_TIME' ],
+ [ 'CURLINFO_CONTENT_LENGTH_DOWNLOAD' ],
+ [ 'CURLINFO_CONTENT_LENGTH_UPLOAD' ],
+ [ 'CURLINFO_CONTENT_TYPE' ],
+ [ 'CURLINFO_EFFECTIVE_URL' ],
+ [ 'CURLINFO_FILETIME' ],
+ [ 'CURLINFO_HEADER_OUT' ],
+ [ 'CURLINFO_HEADER_SIZE' ],
+ [ 'CURLINFO_HTTP_CODE' ],
+ [ 'CURLINFO_NAMELOOKUP_TIME' ],
+ [ 'CURLINFO_PRETRANSFER_TIME' ],
+ [ 'CURLINFO_PRIVATE' ],
+ [ 'CURLINFO_REDIRECT_COUNT' ],
+ [ 'CURLINFO_REDIRECT_TIME' ],
+ // [ 'CURLINFO_REDIRECT_URL' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLINFO_REQUEST_SIZE' ],
+ [ 'CURLINFO_SIZE_DOWNLOAD' ],
+ [ 'CURLINFO_SIZE_UPLOAD' ],
+ [ 'CURLINFO_SPEED_DOWNLOAD' ],
+ [ 'CURLINFO_SPEED_UPLOAD' ],
+ [ 'CURLINFO_SSL_VERIFYRESULT' ],
+ [ 'CURLINFO_STARTTRANSFER_TIME' ],
+ [ 'CURLINFO_TOTAL_TIME' ],
+ [ 'CURLMSG_DONE' ],
+ [ 'CURLM_BAD_EASY_HANDLE' ],
+ [ 'CURLM_BAD_HANDLE' ],
+ [ 'CURLM_CALL_MULTI_PERFORM' ],
+ [ 'CURLM_INTERNAL_ERROR' ],
+ [ 'CURLM_OK' ],
+ [ 'CURLM_OUT_OF_MEMORY' ],
+ [ 'CURLOPT_AUTOREFERER' ],
+ [ 'CURLOPT_BINARYTRANSFER' ],
+ [ 'CURLOPT_BUFFERSIZE' ],
+ [ 'CURLOPT_CAINFO' ],
+ [ 'CURLOPT_CAPATH' ],
+ // [ 'CURLOPT_CERTINFO' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLOPT_CLOSEPOLICY' ], // removed in PHP 5.6.0
+ [ 'CURLOPT_CONNECTTIMEOUT' ],
+ [ 'CURLOPT_CONNECTTIMEOUT_MS' ],
+ [ 'CURLOPT_COOKIE' ],
+ [ 'CURLOPT_COOKIEFILE' ],
+ [ 'CURLOPT_COOKIEJAR' ],
+ [ 'CURLOPT_COOKIESESSION' ],
+ [ 'CURLOPT_CRLF' ],
+ [ 'CURLOPT_CUSTOMREQUEST' ],
+ [ 'CURLOPT_DNS_CACHE_TIMEOUT' ],
+ [ 'CURLOPT_DNS_USE_GLOBAL_CACHE' ],
+ [ 'CURLOPT_EGDSOCKET' ],
+ [ 'CURLOPT_ENCODING' ],
+ [ 'CURLOPT_FAILONERROR' ],
+ [ 'CURLOPT_FILE' ],
+ [ 'CURLOPT_FILETIME' ],
+ [ 'CURLOPT_FOLLOWLOCATION' ],
+ [ 'CURLOPT_FORBID_REUSE' ],
+ [ 'CURLOPT_FRESH_CONNECT' ],
+ [ 'CURLOPT_FTPAPPEND' ],
+ [ 'CURLOPT_FTPLISTONLY' ],
+ [ 'CURLOPT_FTPPORT' ],
+ [ 'CURLOPT_FTPSSLAUTH' ],
+ [ 'CURLOPT_FTP_CREATE_MISSING_DIRS' ],
+ // [ 'CURLOPT_FTP_FILEMETHOD' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLOPT_FTP_SKIP_PASV_IP' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLOPT_FTP_SSL' ],
+ [ 'CURLOPT_FTP_USE_EPRT' ],
+ [ 'CURLOPT_FTP_USE_EPSV' ],
+ [ 'CURLOPT_HEADER' ],
+ [ 'CURLOPT_HEADERFUNCTION' ],
+ [ 'CURLOPT_HTTP200ALIASES' ],
+ [ 'CURLOPT_HTTPAUTH' ],
+ [ 'CURLOPT_HTTPGET' ],
+ [ 'CURLOPT_HTTPHEADER' ],
+ [ 'CURLOPT_HTTPPROXYTUNNEL' ],
+ [ 'CURLOPT_HTTP_VERSION' ],
+ [ 'CURLOPT_INFILE' ],
+ [ 'CURLOPT_INFILESIZE' ],
+ [ 'CURLOPT_INTERFACE' ],
+ [ 'CURLOPT_IPRESOLVE' ],
+ // [ 'CURLOPT_KEYPASSWD' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLOPT_KRB4LEVEL' ],
+ [ 'CURLOPT_LOW_SPEED_LIMIT' ],
+ [ 'CURLOPT_LOW_SPEED_TIME' ],
+ [ 'CURLOPT_MAXCONNECTS' ],
+ [ 'CURLOPT_MAXREDIRS' ],
+ // [ 'CURLOPT_MAX_RECV_SPEED_LARGE' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLOPT_MAX_SEND_SPEED_LARGE' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLOPT_NETRC' ],
+ [ 'CURLOPT_NOBODY' ],
+ [ 'CURLOPT_NOPROGRESS' ],
+ [ 'CURLOPT_NOSIGNAL' ],
+ [ 'CURLOPT_PORT' ],
+ [ 'CURLOPT_POST' ],
+ [ 'CURLOPT_POSTFIELDS' ],
+ [ 'CURLOPT_POSTQUOTE' ],
+ [ 'CURLOPT_POSTREDIR' ],
+ [ 'CURLOPT_PRIVATE' ],
+ [ 'CURLOPT_PROGRESSFUNCTION' ],
+ // [ 'CURLOPT_PROTOCOLS' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLOPT_PROXY' ],
+ [ 'CURLOPT_PROXYAUTH' ],
+ [ 'CURLOPT_PROXYPORT' ],
+ [ 'CURLOPT_PROXYTYPE' ],
+ [ 'CURLOPT_PROXYUSERPWD' ],
+ [ 'CURLOPT_PUT' ],
+ [ 'CURLOPT_QUOTE' ],
+ [ 'CURLOPT_RANDOM_FILE' ],
+ [ 'CURLOPT_RANGE' ],
+ [ 'CURLOPT_READDATA' ],
+ [ 'CURLOPT_READFUNCTION' ],
+ // [ 'CURLOPT_REDIR_PROTOCOLS' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLOPT_REFERER' ],
+ [ 'CURLOPT_RESUME_FROM' ],
+ [ 'CURLOPT_RETURNTRANSFER' ],
+ // [ 'CURLOPT_SSH_AUTH_TYPES' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLOPT_SSH_HOST_PUBLIC_KEY_MD5' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLOPT_SSH_PRIVATE_KEYFILE' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLOPT_SSH_PUBLIC_KEYFILE' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLOPT_SSLCERT' ],
+ [ 'CURLOPT_SSLCERTPASSWD' ],
+ [ 'CURLOPT_SSLCERTTYPE' ],
+ [ 'CURLOPT_SSLENGINE' ],
+ [ 'CURLOPT_SSLENGINE_DEFAULT' ],
+ [ 'CURLOPT_SSLKEY' ],
+ [ 'CURLOPT_SSLKEYPASSWD' ],
+ [ 'CURLOPT_SSLKEYTYPE' ],
+ [ 'CURLOPT_SSLVERSION' ],
+ [ 'CURLOPT_SSL_CIPHER_LIST' ],
+ [ 'CURLOPT_SSL_VERIFYHOST' ],
+ [ 'CURLOPT_SSL_VERIFYPEER' ],
+ [ 'CURLOPT_STDERR' ],
+ [ 'CURLOPT_TCP_NODELAY' ],
+ [ 'CURLOPT_TIMECONDITION' ],
+ [ 'CURLOPT_TIMEOUT' ],
+ [ 'CURLOPT_TIMEOUT_MS' ],
+ [ 'CURLOPT_TIMEVALUE' ],
+ [ 'CURLOPT_TRANSFERTEXT' ],
+ [ 'CURLOPT_UNRESTRICTED_AUTH' ],
+ [ 'CURLOPT_UPLOAD' ],
+ [ 'CURLOPT_URL' ],
+ [ 'CURLOPT_USERAGENT' ],
+ [ 'CURLOPT_USERPWD' ],
+ [ 'CURLOPT_VERBOSE' ],
+ [ 'CURLOPT_WRITEFUNCTION' ],
+ [ 'CURLOPT_WRITEHEADER' ],
+ // [ 'CURLPROTO_ALL' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLPROTO_DICT' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLPROTO_FILE' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLPROTO_FTP' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLPROTO_FTPS' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLPROTO_HTTP' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLPROTO_HTTPS' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLPROTO_LDAP' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLPROTO_LDAPS' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLPROTO_SCP' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLPROTO_SFTP' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLPROTO_TELNET' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLPROTO_TFTP' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLPROXY_HTTP' ],
+ // [ 'CURLPROXY_SOCKS4' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLPROXY_SOCKS5' ],
+ // [ 'CURLSSH_AUTH_DEFAULT' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLSSH_AUTH_HOST' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLSSH_AUTH_KEYBOARD' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLSSH_AUTH_NONE' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLSSH_AUTH_PASSWORD' ], // not present in HHVM 3.3.0-dev
+ // [ 'CURLSSH_AUTH_PUBLICKEY' ], // not present in HHVM 3.3.0-dev
+ [ 'CURLVERSION_NOW' ],
+ [ 'CURL_HTTP_VERSION_1_0' ],
+ [ 'CURL_HTTP_VERSION_1_1' ],
+ [ 'CURL_HTTP_VERSION_NONE' ],
+ [ 'CURL_IPRESOLVE_V4' ],
+ [ 'CURL_IPRESOLVE_V6' ],
+ [ 'CURL_IPRESOLVE_WHATEVER' ],
+ [ 'CURL_NETRC_IGNORED' ],
+ [ 'CURL_NETRC_OPTIONAL' ],
+ [ 'CURL_NETRC_REQUIRED' ],
+ [ 'CURL_TIMECOND_IFMODSINCE' ],
+ [ 'CURL_TIMECOND_IFUNMODSINCE' ],
+ [ 'CURL_TIMECOND_LASTMOD' ],
+ [ 'CURL_VERSION_IPV6' ],
+ [ 'CURL_VERSION_KERBEROS4' ],
+ [ 'CURL_VERSION_LIBZ' ],
+ [ 'CURL_VERSION_SSL' ],
+ ];
+ }
+
+ /**
+ * Added this test based on an issue experienced with HHVM 3.3.0-dev
+ * where it did not define a cURL constant. T72570
+ *
+ * @dataProvider provideCurlConstants
+ * @coversNothing
+ */
+ public function testCurlConstants( $value ) {
+ $this->checkPHPExtension( 'curl' );
+
+ $this->assertTrue( defined( $value ), $value . ' not defined' );
+ }
+}
+
+/**
+ * Class to let us overwrite MWHttpRequest respHeaders variable
+ */
+class MWHttpRequestTester extends MWHttpRequest {
+ // function derived from the MWHttpRequest factory function but
+ // returns appropriate tester class here
+ public static function factory( $url, array $options = null, $caller = __METHOD__ ) {
+ if ( !Http::$httpEngine ) {
+ Http::$httpEngine = function_exists( 'curl_init' ) ? 'curl' : 'php';
+ } elseif ( Http::$httpEngine == 'curl' && !function_exists( 'curl_init' ) ) {
+ throw new DomainException( __METHOD__ . ': curl (http://php.net/curl) is not installed, but' .
+ 'Http::$httpEngine is set to "curl"' );
+ }
+
+ switch ( Http::$httpEngine ) {
+ case 'curl':
+ return new CurlHttpRequestTester( $url, $options, $caller );
+ case 'php':
+ if ( !wfIniGetBool( 'allow_url_fopen' ) ) {
+ throw new DomainException( __METHOD__ .
+ ': allow_url_fopen needs to be enabled for pure PHP HTTP requests to work. '
+ . 'If possible, curl should be used instead. See http://php.net/curl.' );
+ }
+
+ return new PhpHttpRequestTester( $url, $options, $caller );
+ default:
+ }
+ }
+}
+
+class CurlHttpRequestTester extends CurlHttpRequest {
+ function setRespHeaders( $name, $value ) {
+ $this->respHeaders[$name] = $value;
+ }
+}
+
+class PhpHttpRequestTester extends PhpHttpRequest {
+ function setRespHeaders( $name, $value ) {
+ $this->respHeaders[$name] = $value;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php b/www/wiki/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php
new file mode 100644
index 00000000..1db2215e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php
@@ -0,0 +1,104 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Integration test that checks import success and
+ * LinkCache integration.
+ *
+ * @group large
+ * @group Database
+ * @covers ImportStreamSource
+ * @covers ImportReporter
+ *
+ * @author mwjames
+ */
+class ImportLinkCacheIntegrationTest extends MediaWikiTestCase {
+
+ private $importStreamSource;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $file = dirname( __DIR__ ) . '/../data/import/ImportLinkCacheIntegrationTest.xml';
+
+ $this->importStreamSource = ImportStreamSource::newFromFile( $file );
+
+ if ( !$this->importStreamSource->isGood() ) {
+ throw new Exception( "Import source for {$file} failed" );
+ }
+ }
+
+ public function testImportForImportSource() {
+ $this->doImport( $this->importStreamSource );
+
+ // Imported title
+ $loremIpsum = Title::newFromText( 'Lorem ipsum' );
+
+ $this->assertSame(
+ $loremIpsum->getArticleID(),
+ $loremIpsum->getArticleID( Title::GAID_FOR_UPDATE )
+ );
+
+ $categoryLoremIpsum = Title::newFromText( 'Category:Lorem ipsum' );
+
+ $this->assertSame(
+ $categoryLoremIpsum->getArticleID(),
+ $categoryLoremIpsum->getArticleID( Title::GAID_FOR_UPDATE )
+ );
+
+ $page = new WikiPage( $loremIpsum );
+ $page->doDeleteArticle( 'import test: delete page' );
+
+ $page = new WikiPage( $categoryLoremIpsum );
+ $page->doDeleteArticle( 'import test: delete page' );
+ }
+
+ /**
+ * @depends testImportForImportSource
+ */
+ public function testReImportForImportSource() {
+ $this->doImport( $this->importStreamSource );
+
+ // ReImported title
+ $loremIpsum = Title::newFromText( 'Lorem ipsum' );
+
+ $this->assertSame(
+ $loremIpsum->getArticleID(),
+ $loremIpsum->getArticleID( Title::GAID_FOR_UPDATE )
+ );
+
+ $categoryLoremIpsum = Title::newFromText( 'Category:Lorem ipsum' );
+
+ $this->assertSame(
+ $categoryLoremIpsum->getArticleID(),
+ $categoryLoremIpsum->getArticleID( Title::GAID_FOR_UPDATE )
+ );
+ }
+
+ private function doImport( $importStreamSource ) {
+ $importer = new WikiImporter(
+ $importStreamSource->value,
+ MediaWikiServices::getInstance()->getMainConfig()
+ );
+ $importer->setDebug( true );
+
+ $reporter = new ImportReporter(
+ $importer,
+ false,
+ '',
+ false
+ );
+
+ $reporter->setContext( new RequestContext() );
+ $reporter->open();
+
+ $importer->doImport();
+
+ $result = $reporter->close();
+
+ $this->assertTrue(
+ $result->isGood()
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/import/ImportTest.php b/www/wiki/tests/phpunit/includes/import/ImportTest.php
new file mode 100644
index 00000000..3b91f5b3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/import/ImportTest.php
@@ -0,0 +1,329 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Test class for Import methods.
+ *
+ * @group Database
+ *
+ * @author Sebastian Brückner < sebastian.brueckner@student.hpi.uni-potsdam.de >
+ */
+class ImportTest extends MediaWikiLangTestCase {
+
+ private function getDataSource( $xml ) {
+ return new ImportStringSource( $xml );
+ }
+
+ /**
+ * @covers WikiImporter
+ * @dataProvider getUnknownTagsXML
+ * @param string $xml
+ * @param string $text
+ * @param string $title
+ */
+ public function testUnknownXMLTags( $xml, $text, $title ) {
+ $source = $this->getDataSource( $xml );
+
+ $importer = new WikiImporter(
+ $source,
+ MediaWikiServices::getInstance()->getMainConfig()
+ );
+
+ $importer->doImport();
+ $title = Title::newFromText( $title );
+ $this->assertTrue( $title->exists() );
+
+ $this->assertEquals( WikiPage::factory( $title )->getContent()->getNativeData(), $text );
+ }
+
+ public function getUnknownTagsXML() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ <<< EOF
+<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.10/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.10/ http://www.mediawiki.org/xml/export-0.10.xsd" version="0.10" xml:lang="en">
+ <page unknown="123" dontknow="533">
+ <title>TestImportPage</title>
+ <unknowntag>Should be ignored</unknowntag>
+ <ns>0</ns>
+ <id unknown="123" dontknow="533">14</id>
+ <revision>
+ <id unknown="123" dontknow="533">15</id>
+ <unknowntag>Should be ignored</unknowntag>
+ <timestamp>2016-01-03T11:18:43Z</timestamp>
+ <contributor>
+ <unknowntag>Should be ignored</unknowntag>
+ <username unknown="123" dontknow="533">Admin</username>
+ <id>1</id>
+ </contributor>
+ <model>wikitext</model>
+ <format>text/x-wiki</format>
+ <text xml:space="preserve" bytes="0">noitazinagro tseb eht si ikiWaideM</text>
+ <sha1>phoiac9h4m842xq45sp7s6u21eteeq1</sha1>
+ <unknowntag>Should be ignored</unknowntag>
+ </revision>
+ </page>
+ <unknowntag>Should be ignored</unknowntag>
+</mediawiki>
+EOF
+ ,
+ 'noitazinagro tseb eht si ikiWaideM',
+ 'TestImportPage'
+ ]
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @covers WikiImporter::handlePage
+ * @dataProvider getRedirectXML
+ * @param string $xml
+ * @param string|null $redirectTitle
+ */
+ public function testHandlePageContainsRedirect( $xml, $redirectTitle ) {
+ $source = $this->getDataSource( $xml );
+
+ $redirect = null;
+ $callback = function ( Title $title, ForeignTitle $foreignTitle, $revCount,
+ $sRevCount, $pageInfo ) use ( &$redirect ) {
+ if ( array_key_exists( 'redirect', $pageInfo ) ) {
+ $redirect = $pageInfo['redirect'];
+ }
+ };
+
+ $importer = new WikiImporter(
+ $source,
+ MediaWikiServices::getInstance()->getMainConfig()
+ );
+ $importer->setPageOutCallback( $callback );
+ $importer->doImport();
+
+ $this->assertEquals( $redirectTitle, $redirect );
+ }
+
+ public function getRedirectXML() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ <<< EOF
+<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.10/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.10/ http://www.mediawiki.org/xml/export-0.10.xsd" version="0.10" xml:lang="en">
+ <page>
+ <title>Test</title>
+ <ns>0</ns>
+ <id>21</id>
+ <redirect title="Test22"/>
+ <revision>
+ <id>20</id>
+ <timestamp>2014-05-27T10:00:00Z</timestamp>
+ <contributor>
+ <username>Admin</username>
+ <id>10</id>
+ </contributor>
+ <comment>Admin moved page [[Test]] to [[Test22]]</comment>
+ <model>wikitext</model>
+ <format>text/x-wiki</format>
+ <text xml:space="preserve" bytes="20">#REDIRECT [[Test22]]</text>
+ <sha1>tq456o9x3abm7r9ozi6km8yrbbc56o6</sha1>
+ </revision>
+ </page>
+</mediawiki>
+EOF
+ ,
+ 'Test22'
+ ],
+ [
+ <<< EOF
+<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.9/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.9/ http://www.mediawiki.org/xml/export-0.9.xsd" version="0.9" xml:lang="en">
+ <page>
+ <title>Test</title>
+ <ns>0</ns>
+ <id>42</id>
+ <revision>
+ <id>421</id>
+ <timestamp>2014-05-27T11:00:00Z</timestamp>
+ <contributor>
+ <username>Admin</username>
+ <id>10</id>
+ </contributor>
+ <text xml:space="preserve" bytes="4">Abcd</text>
+ <sha1>n7uomjq96szt60fy5w3x7ahf7q8m8rh</sha1>
+ <model>wikitext</model>
+ <format>text/x-wiki</format>
+ </revision>
+ </page>
+</mediawiki>
+EOF
+ ,
+ null
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @covers WikiImporter::handleSiteInfo
+ * @dataProvider getSiteInfoXML
+ * @param string $xml
+ * @param array|null $namespaces
+ */
+ public function testSiteInfoContainsNamespaces( $xml, $namespaces ) {
+ $source = $this->getDataSource( $xml );
+
+ $importNamespaces = null;
+ $callback = function ( array $siteinfo, $innerImporter ) use ( &$importNamespaces ) {
+ $importNamespaces = $siteinfo['_namespaces'];
+ };
+
+ $importer = new WikiImporter(
+ $source,
+ MediaWikiServices::getInstance()->getMainConfig()
+ );
+ $importer->setSiteInfoCallback( $callback );
+ $importer->doImport();
+
+ $this->assertEquals( $importNamespaces, $namespaces );
+ }
+
+ public function getSiteInfoXML() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ <<< EOF
+<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.10/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.10/ http://www.mediawiki.org/xml/export-0.10.xsd" version="0.10" xml:lang="en">
+ <siteinfo>
+ <namespaces>
+ <namespace key="-2" case="first-letter">Media</namespace>
+ <namespace key="-1" case="first-letter">Special</namespace>
+ <namespace key="0" case="first-letter" />
+ <namespace key="1" case="first-letter">Talk</namespace>
+ <namespace key="2" case="first-letter">User</namespace>
+ <namespace key="3" case="first-letter">User talk</namespace>
+ <namespace key="100" case="first-letter">Portal</namespace>
+ <namespace key="101" case="first-letter">Portal talk</namespace>
+ </namespaces>
+ </siteinfo>
+</mediawiki>
+EOF
+ ,
+ [
+ '-2' => 'Media',
+ '-1' => 'Special',
+ '0' => '',
+ '1' => 'Talk',
+ '2' => 'User',
+ '3' => 'User talk',
+ '100' => 'Portal',
+ '101' => 'Portal talk',
+ ]
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @dataProvider provideUnknownUserHandling
+ * @param bool $assign
+ * @param bool $create
+ */
+ public function testUnknownUserHandling( $assign, $create ) {
+ $hookId = -99;
+ $this->setMwGlobals( 'wgHooks', [
+ 'ImportHandleUnknownUser' => [ function ( $name ) use ( $assign, $create, &$hookId ) {
+ if ( !$assign ) {
+ $this->fail( 'ImportHandleUnknownUser was called unexpectedly' );
+ }
+
+ $this->assertEquals( 'UserDoesNotExist', $name );
+ if ( $create ) {
+ $user = User::createNew( $name );
+ $this->assertNotNull( $user );
+ $hookId = $user->getId();
+ return false;
+ }
+ return true;
+ } ]
+ ] );
+
+ $user = $this->getTestUser()->getUser();
+
+ $n = ( $assign ? 1 : 0 ) + ( $create ? 2 : 0 );
+
+ // phpcs:disable Generic.Files.LineLength
+ $source = $this->getDataSource( <<<EOF
+<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.10/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.10/ http://www.mediawiki.org/xml/export-0.10.xsd" version="0.10" xml:lang="en">
+ <page>
+ <title>TestImportPage</title>
+ <ns>0</ns>
+ <id>14</id>
+ <revision>
+ <id>15</id>
+ <timestamp>2016-01-01T0$n:00:00Z</timestamp>
+ <contributor>
+ <username>UserDoesNotExist</username>
+ <id>1</id>
+ </contributor>
+ <model>wikitext</model>
+ <format>text/x-wiki</format>
+ <text xml:space="preserve" bytes="3">foo</text>
+ <sha1>1e6gpc3ehk0mu2jqu8cg42g009s796b</sha1>
+ </revision>
+ <revision>
+ <id>16</id>
+ <timestamp>2016-01-01T0$n:00:01Z</timestamp>
+ <contributor>
+ <username>{$user->getName()}</username>
+ <id>{$user->getId()}</id>
+ </contributor>
+ <model>wikitext</model>
+ <format>text/x-wiki</format>
+ <text xml:space="preserve" bytes="3">bar</text>
+ <sha1>bjhlo6dxh5wivnszm93u4b78fheiy4t</sha1>
+ </revision>
+ </page>
+</mediawiki>
+EOF
+ );
+ // phpcs:enable
+
+ $importer = new WikiImporter( $source, MediaWikiServices::getInstance()->getMainConfig() );
+ $importer->setUsernamePrefix( 'Xxx', $assign );
+ $importer->doImport();
+
+ $db = wfGetDB( DB_MASTER );
+ $revQuery = Revision::getQueryInfo();
+
+ $row = $db->selectRow(
+ $revQuery['tables'],
+ $revQuery['fields'],
+ [ 'rev_timestamp' => $db->timestamp( "201601010{$n}0000" ) ],
+ __METHOD__,
+ [],
+ $revQuery['joins']
+ );
+ $this->assertSame(
+ $assign && $create ? 'UserDoesNotExist' : 'Xxx>UserDoesNotExist',
+ $row->rev_user_text
+ );
+ $this->assertSame( $assign && $create ? $hookId : 0, (int)$row->rev_user );
+
+ $row = $db->selectRow(
+ $revQuery['tables'],
+ $revQuery['fields'],
+ [ 'rev_timestamp' => $db->timestamp( "201601010{$n}0001" ) ],
+ __METHOD__,
+ [],
+ $revQuery['joins']
+ );
+ $this->assertSame( ( $assign ? '' : 'Xxx>' ) . $user->getName(), $row->rev_user_text );
+ $this->assertSame( $assign ? $user->getId() : 0, (int)$row->rev_user );
+ }
+
+ public static function provideUnknownUserHandling() {
+ return [
+ 'no assign' => [ false, false ],
+ 'assign, no create' => [ true, false ],
+ 'assign, create' => [ true, true ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/installer/InstallDocFormatterTest.php b/www/wiki/tests/phpunit/includes/installer/InstallDocFormatterTest.php
new file mode 100644
index 00000000..9584d4b8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/installer/InstallDocFormatterTest.php
@@ -0,0 +1,83 @@
+<?php
+
+class InstallDocFormatterTest extends MediaWikiTestCase {
+ /**
+ * @covers InstallDocFormatter
+ * @dataProvider provideDocFormattingTests
+ */
+ public function testFormat( $expected, $unformattedText, $message = '' ) {
+ $this->assertEquals(
+ $expected,
+ InstallDocFormatter::format( $unformattedText ),
+ $message
+ );
+ }
+
+ /**
+ * Provider for testFormat()
+ */
+ public static function provideDocFormattingTests() {
+ # Format: (expected string, unformattedText string, optional message)
+ return [
+ # Escape some wikitext
+ [ 'Install &lt;tag>', 'Install <tag>', 'Escaping <' ],
+ [ 'Install &#123;&#123;template}}', 'Install {{template}}', 'Escaping [[' ],
+ [ 'Install &#91;&#91;page]]', 'Install [[page]]', 'Escaping {{' ],
+ [ 'Install &#95;&#95;TOC&#95;&#95;', 'Install __TOC__', 'Escaping __' ],
+ [ 'Install ', "Install \r", 'Removing \r' ],
+
+ # Transform \t{1,2} into :{1,2}
+ [ ':One indentation', "\tOne indentation", 'Replacing a single \t' ],
+ [ '::Two indentations', "\t\tTwo indentations", 'Replacing 2 x \t' ],
+
+ # Transform 'T123' links
+ [
+ '<span class="config-plainlink">[https://phabricator.wikimedia.org/T123 T123]</span>',
+ 'T123', 'Testing T123 links' ],
+ [
+ 'bug <span class="config-plainlink">[https://phabricator.wikimedia.org/T123 T123]</span>',
+ 'bug T123', 'Testing bug T123 links' ],
+ [
+ '(<span class="config-plainlink">[https://phabricator.wikimedia.org/T987654 T987654]</span>)',
+ '(T987654)', 'Testing (T987654) links' ],
+
+ # "Tabc" shouldn't work
+ [ 'Tfoobar', 'Tfoobar', "Don't match T followed by non-digits" ],
+ [ 'T!!fakefake!!', 'T!!fakefake!!', "Don't match T followed by non-digits" ],
+
+ # Transform 'bug 123' links
+ [
+ '<span class="config-plainlink">[https://bugzilla.wikimedia.org/123 bug 123]</span>',
+ 'bug 123', 'Testing bug 123 links' ],
+ [
+ '(<span class="config-plainlink">[https://bugzilla.wikimedia.org/987654 bug 987654]</span>)',
+ '(bug 987654)', 'Testing (bug 987654) links' ],
+
+ # "bug abc" shouldn't work
+ [ 'bug foobar', 'bug foobar', "Don't match bug followed by non-digits" ],
+ [ 'bug !!fakefake!!', 'bug !!fakefake!!', "Don't match bug followed by non-digits" ],
+
+ # Transform '$wgFooBar' links
+ [
+ '<span class="config-plainlink">'
+ . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar $wgFooBar]</span>',
+ '$wgFooBar', 'Testing basic $wgFooBar' ],
+ [
+ '<span class="config-plainlink">'
+ . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar45 $wgFooBar45]</span>',
+ '$wgFooBar45', 'Testing $wgFooBar45 (with numbers)' ],
+ [
+ '<span class="config-plainlink">'
+ . '[https://www.mediawiki.org/wiki/Manual:$wgFoo_Bar $wgFoo_Bar]</span>',
+ '$wgFoo_Bar', 'Testing $wgFoo_Bar (with underscore)' ],
+
+ # Icky variables that shouldn't link
+ [
+ '$myAwesomeVariable',
+ '$myAwesomeVariable',
+ 'Testing $myAwesomeVariable (not starting with $wg)'
+ ],
+ [ '$()not!a&Var', '$()not!a&Var', 'Testing $()not!a&Var (obviously not a variable)' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/installer/OracleInstallerTest.php b/www/wiki/tests/phpunit/includes/installer/OracleInstallerTest.php
new file mode 100644
index 00000000..2811a9cf
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/installer/OracleInstallerTest.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * Tests for OracleInstaller
+ *
+ * @group Database
+ * @group Installer
+ */
+class OracleInstallerTest extends MediaWikiTestCase {
+
+ /**
+ * @dataProvider provideOracleConnectStrings
+ * @covers OracleInstaller::checkConnectStringFormat
+ */
+ public function testCheckConnectStringFormat( $expected, $connectString, $msg = '' ) {
+ $validity = $expected ? 'should be valid' : 'should NOT be valid';
+ $msg = "'$connectString' ($msg) $validity.";
+ $this->assertEquals( $expected,
+ OracleInstaller::checkConnectStringFormat( $connectString ),
+ $msg
+ );
+ }
+
+ /**
+ * Provider to test OracleInstaller::checkConnectStringFormat()
+ */
+ function provideOracleConnectStrings() {
+ // expected result, connectString[, message]
+ return [
+ [ true, 'simple_01', 'Simple TNS name' ],
+ [ true, 'simple_01.world', 'TNS name with domain' ],
+ [ true, 'simple_01.domain.net', 'TNS name with domain' ],
+ [ true, 'host123', 'Host only' ],
+ [ true, 'host123.domain.net', 'FQDN only' ],
+ [ true, '//host123.domain.net', 'FQDN URL only' ],
+ [ true, '123.223.213.132', 'Host IP only' ],
+ [ true, 'host:1521', 'Host and port' ],
+ [ true, 'host:1521/service', 'Host, port and service' ],
+ [ true, 'host:1521/service:shared', 'Host, port, service and shared server type' ],
+ [ true, 'host:1521/service:dedicated', 'Host, port, service and dedicated server type' ],
+ [ true, 'host:1521/service:pooled', 'Host, port, service and pooled server type' ],
+ [
+ true,
+ 'host:1521/service:shared/instance1',
+ 'Host, port, service, server type and instance'
+ ],
+ [ true, 'host:1521//instance1', 'Host, port and instance' ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/interwiki/ClassicInterwikiLookupTest.php b/www/wiki/tests/phpunit/includes/interwiki/ClassicInterwikiLookupTest.php
new file mode 100644
index 00000000..7fb2cd49
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/interwiki/ClassicInterwikiLookupTest.php
@@ -0,0 +1,273 @@
+<?php
+/**
+ * @covers MediaWiki\Interwiki\ClassicInterwikiLookup
+ *
+ * @group MediaWiki
+ * @group Database
+ */
+class ClassicInterwikiLookupTest extends MediaWikiTestCase {
+
+ private function populateDB( $iwrows ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete( 'interwiki', '*', __METHOD__ );
+ $dbw->insert( 'interwiki', array_values( $iwrows ), __METHOD__ );
+ $this->tablesUsed[] = 'interwiki';
+ }
+
+ public function testDatabaseStorage() {
+ // NOTE: database setup is expensive, so we only do
+ // it once and run all the tests in one go.
+ $dewiki = [
+ 'iw_prefix' => 'de',
+ 'iw_url' => 'http://de.wikipedia.org/wiki/',
+ 'iw_api' => 'http://de.wikipedia.org/w/api.php',
+ 'iw_wikiid' => 'dewiki',
+ 'iw_local' => 1,
+ 'iw_trans' => 0
+ ];
+
+ $zzwiki = [
+ 'iw_prefix' => 'zz',
+ 'iw_url' => 'http://zzwiki.org/wiki/',
+ 'iw_api' => 'http://zzwiki.org/w/api.php',
+ 'iw_wikiid' => 'zzwiki',
+ 'iw_local' => 0,
+ 'iw_trans' => 0
+ ];
+
+ $this->populateDB( [ $dewiki, $zzwiki ] );
+ $lookup = new \MediaWiki\Interwiki\ClassicInterwikiLookup(
+ Language::factory( 'en' ),
+ WANObjectCache::newEmpty(),
+ 60 * 60,
+ false,
+ 3,
+ 'en'
+ );
+
+ $this->assertEquals(
+ [ $dewiki, $zzwiki ],
+ $lookup->getAllPrefixes(),
+ 'getAllPrefixes()'
+ );
+ $this->assertEquals(
+ [ $dewiki ],
+ $lookup->getAllPrefixes( true ),
+ 'getAllPrefixes()'
+ );
+ $this->assertEquals(
+ [ $zzwiki ],
+ $lookup->getAllPrefixes( false ),
+ 'getAllPrefixes()'
+ );
+
+ $this->assertTrue( $lookup->isValidInterwiki( 'de' ), 'known prefix is valid' );
+ $this->assertFalse( $lookup->isValidInterwiki( 'xyz' ), 'unknown prefix is valid' );
+
+ $this->assertNull( $lookup->fetch( null ), 'no prefix' );
+ $this->assertFalse( $lookup->fetch( 'xyz' ), 'unknown prefix' );
+
+ $interwiki = $lookup->fetch( 'de' );
+ $this->assertInstanceOf( Interwiki::class, $interwiki );
+ $this->assertSame( $interwiki, $lookup->fetch( 'de' ), 'in-process caching' );
+
+ $this->assertSame( 'http://de.wikipedia.org/wiki/', $interwiki->getURL(), 'getURL' );
+ $this->assertSame( 'http://de.wikipedia.org/w/api.php', $interwiki->getAPI(), 'getAPI' );
+ $this->assertSame( 'dewiki', $interwiki->getWikiID(), 'getWikiID' );
+ $this->assertSame( true, $interwiki->isLocal(), 'isLocal' );
+ $this->assertSame( false, $interwiki->isTranscludable(), 'isTranscludable' );
+
+ $lookup->invalidateCache( 'de' );
+ $this->assertNotSame( $interwiki, $lookup->fetch( 'de' ), 'invalidate cache' );
+ }
+
+ /**
+ * @param string $thisSite
+ * @param string[] $local
+ * @param string[] $global
+ *
+ * @return string[]
+ */
+ private function populateHash( $thisSite, $local, $global ) {
+ $hash = [];
+ $hash[ '__sites:' . wfWikiID() ] = $thisSite;
+
+ $globals = [];
+ $locals = [];
+
+ foreach ( $local as $row ) {
+ $prefix = $row['iw_prefix'];
+ $data = $row['iw_local'] . ' ' . $row['iw_url'];
+ $locals[] = $prefix;
+ $hash[ "_{$thisSite}:{$prefix}" ] = $data;
+ }
+
+ foreach ( $global as $row ) {
+ $prefix = $row['iw_prefix'];
+ $data = $row['iw_local'] . ' ' . $row['iw_url'];
+ $globals[] = $prefix;
+ $hash[ "__global:{$prefix}" ] = $data;
+ }
+
+ $hash[ '__list:__global' ] = implode( ' ', $globals );
+ $hash[ '__list:_' . $thisSite ] = implode( ' ', $locals );
+
+ return $hash;
+ }
+
+ private function populateCDB( $thisSite, $local, $global ) {
+ $cdbFile = tempnam( wfTempDir(), 'MW-ClassicInterwikiLookupTest-' ) . '.cdb';
+ $cdb = \Cdb\Writer::open( $cdbFile );
+
+ $hash = $this->populateHash( $thisSite, $local, $global );
+
+ foreach ( $hash as $key => $value ) {
+ $cdb->set( $key, $value );
+ }
+
+ $cdb->close();
+ return $cdbFile;
+ }
+
+ public function testCDBStorage() {
+ // NOTE: CDB setup is expensive, so we only do
+ // it once and run all the tests in one go.
+
+ $zzwiki = [
+ 'iw_prefix' => 'zz',
+ 'iw_url' => 'http://zzwiki.org/wiki/',
+ 'iw_local' => 0
+ ];
+
+ $dewiki = [
+ 'iw_prefix' => 'de',
+ 'iw_url' => 'http://de.wikipedia.org/wiki/',
+ 'iw_local' => 1
+ ];
+
+ $cdbFile = $this->populateCDB(
+ 'en',
+ [ $dewiki ],
+ [ $zzwiki ]
+ );
+ $lookup = new \MediaWiki\Interwiki\ClassicInterwikiLookup(
+ Language::factory( 'en' ),
+ WANObjectCache::newEmpty(),
+ 60 * 60,
+ $cdbFile,
+ 3,
+ 'en'
+ );
+
+ $this->assertEquals(
+ [ $zzwiki, $dewiki ],
+ $lookup->getAllPrefixes(),
+ 'getAllPrefixes()'
+ );
+
+ $this->assertTrue( $lookup->isValidInterwiki( 'de' ), 'known prefix is valid' );
+ $this->assertTrue( $lookup->isValidInterwiki( 'zz' ), 'known prefix is valid' );
+
+ $interwiki = $lookup->fetch( 'de' );
+ $this->assertInstanceOf( Interwiki::class, $interwiki );
+
+ $this->assertSame( 'http://de.wikipedia.org/wiki/', $interwiki->getURL(), 'getURL' );
+ $this->assertSame( true, $interwiki->isLocal(), 'isLocal' );
+
+ $interwiki = $lookup->fetch( 'zz' );
+ $this->assertInstanceOf( Interwiki::class, $interwiki );
+
+ $this->assertSame( 'http://zzwiki.org/wiki/', $interwiki->getURL(), 'getURL' );
+ $this->assertSame( false, $interwiki->isLocal(), 'isLocal' );
+
+ // cleanup temp file
+ unlink( $cdbFile );
+ }
+
+ public function testArrayStorage() {
+ $zzwiki = [
+ 'iw_prefix' => 'zz',
+ 'iw_url' => 'http://zzwiki.org/wiki/',
+ 'iw_local' => 0
+ ];
+ $dewiki = [
+ 'iw_prefix' => 'de',
+ 'iw_url' => 'http://de.wikipedia.org/wiki/',
+ 'iw_local' => 1
+ ];
+
+ $hash = $this->populateHash(
+ 'en',
+ [ $dewiki ],
+ [ $zzwiki ]
+ );
+ $lookup = new \MediaWiki\Interwiki\ClassicInterwikiLookup(
+ Language::factory( 'en' ),
+ WANObjectCache::newEmpty(),
+ 60 * 60,
+ $hash,
+ 3,
+ 'en'
+ );
+
+ $this->assertEquals(
+ [ $zzwiki, $dewiki ],
+ $lookup->getAllPrefixes(),
+ 'getAllPrefixes()'
+ );
+
+ $this->assertTrue( $lookup->isValidInterwiki( 'de' ), 'known prefix is valid' );
+ $this->assertTrue( $lookup->isValidInterwiki( 'zz' ), 'known prefix is valid' );
+
+ $interwiki = $lookup->fetch( 'de' );
+ $this->assertInstanceOf( Interwiki::class, $interwiki );
+
+ $this->assertSame( 'http://de.wikipedia.org/wiki/', $interwiki->getURL(), 'getURL' );
+ $this->assertSame( true, $interwiki->isLocal(), 'isLocal' );
+
+ $interwiki = $lookup->fetch( 'zz' );
+ $this->assertInstanceOf( Interwiki::class, $interwiki );
+
+ $this->assertSame( 'http://zzwiki.org/wiki/', $interwiki->getURL(), 'getURL' );
+ $this->assertSame( false, $interwiki->isLocal(), 'isLocal' );
+ }
+
+ public function testGetAllPrefixes() {
+ $zz = [
+ 'iw_prefix' => 'zz',
+ 'iw_url' => 'https://azz.example.org/',
+ 'iw_local' => 1
+ ];
+ $de = [
+ 'iw_prefix' => 'de',
+ 'iw_url' => 'https://de.example.org/',
+ 'iw_local' => 1
+ ];
+ $azz = [
+ 'iw_prefix' => 'azz',
+ 'iw_url' => 'https://azz.example.org/',
+ 'iw_local' => 1
+ ];
+
+ $hash = $this->populateHash(
+ 'en',
+ [],
+ [ $zz, $de, $azz ]
+ );
+ $lookup = new \MediaWiki\Interwiki\ClassicInterwikiLookup(
+ Language::factory( 'en' ),
+ WANObjectCache::newEmpty(),
+ 60 * 60,
+ $hash,
+ 3,
+ 'en'
+ );
+
+ $this->assertEquals(
+ [ $zz, $de, $azz ],
+ $lookup->getAllPrefixes(),
+ 'getAllPrefixes() - preserves order'
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php b/www/wiki/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php
new file mode 100644
index 00000000..0a13de1d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php
@@ -0,0 +1,133 @@
+<?php
+
+use MediaWiki\Interwiki\InterwikiLookupAdapter;
+
+/**
+ * @covers MediaWiki\Interwiki\InterwikiLookupAdapter
+ *
+ * @group MediaWiki
+ * @group Interwiki
+ */
+class InterwikiLookupAdapterTest extends MediaWikiTestCase {
+
+ /**
+ * @var InterwikiLookupAdapter
+ */
+ private $interwikiLookup;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->interwikiLookup = new InterwikiLookupAdapter(
+ $this->getSiteLookup( $this->getSites() )
+ );
+ }
+
+ public function testIsValidInterwiki() {
+ $this->assertTrue(
+ $this->interwikiLookup->isValidInterwiki( 'enwt' ),
+ 'enwt known prefix is valid'
+ );
+ $this->assertTrue(
+ $this->interwikiLookup->isValidInterwiki( 'foo' ),
+ 'foo site known prefix is valid'
+ );
+ $this->assertFalse(
+ $this->interwikiLookup->isValidInterwiki( 'xyz' ),
+ 'unknown prefix is not valid'
+ );
+ }
+
+ public function testFetch() {
+ $interwiki = $this->interwikiLookup->fetch( '' );
+ $this->assertNull( $interwiki );
+
+ $interwiki = $this->interwikiLookup->fetch( 'xyz' );
+ $this->assertFalse( $interwiki );
+
+ $interwiki = $this->interwikiLookup->fetch( 'foo' );
+ $this->assertInstanceOf( Interwiki::class, $interwiki );
+ $this->assertSame( 'foobar', $interwiki->getWikiID() );
+
+ $interwiki = $this->interwikiLookup->fetch( 'enwt' );
+ $this->assertInstanceOf( Interwiki::class, $interwiki );
+
+ $this->assertSame( 'https://en.wiktionary.org/wiki/$1', $interwiki->getURL(), 'getURL' );
+ $this->assertSame( 'https://en.wiktionary.org/w/api.php', $interwiki->getAPI(), 'getAPI' );
+ $this->assertSame( 'enwiktionary', $interwiki->getWikiID(), 'getWikiID' );
+ $this->assertTrue( $interwiki->isLocal(), 'isLocal' );
+ }
+
+ public function testGetAllPrefixes() {
+ $foo = [
+ 'iw_prefix' => 'foo',
+ 'iw_url' => '',
+ 'iw_api' => '',
+ 'iw_wikiid' => 'foobar',
+ 'iw_local' => false,
+ 'iw_trans' => false,
+ ];
+ $enwt = [
+ 'iw_prefix' => 'enwt',
+ 'iw_url' => 'https://en.wiktionary.org/wiki/$1',
+ 'iw_api' => 'https://en.wiktionary.org/w/api.php',
+ 'iw_wikiid' => 'enwiktionary',
+ 'iw_local' => true,
+ 'iw_trans' => false,
+ ];
+
+ $this->assertEquals(
+ [ $foo, $enwt ],
+ $this->interwikiLookup->getAllPrefixes(),
+ 'getAllPrefixes()'
+ );
+
+ $this->assertEquals(
+ [ $foo ],
+ $this->interwikiLookup->getAllPrefixes( false ),
+ 'get external prefixes'
+ );
+
+ $this->assertEquals(
+ [ $enwt ],
+ $this->interwikiLookup->getAllPrefixes( true ),
+ 'get local prefixes'
+ );
+ }
+
+ private function getSiteLookup( SiteList $sites ) {
+ $siteLookup = $this->getMockBuilder( SiteLookup::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $siteLookup->expects( $this->any() )
+ ->method( 'getSites' )
+ ->will( $this->returnValue( $sites ) );
+
+ return $siteLookup;
+ }
+
+ private function getSites() {
+ $sites = [];
+
+ $site = new Site();
+ $site->setGlobalId( 'foobar' );
+ $site->addInterwikiId( 'foo' );
+ $site->setSource( 'external' );
+ $sites[] = $site;
+
+ $site = new MediaWikiSite();
+ $site->setGlobalId( 'enwiktionary' );
+ $site->setGroup( 'wiktionary' );
+ $site->setLanguageCode( 'en' );
+ $site->addNavigationId( 'enwiktionary' );
+ $site->addInterwikiId( 'enwt' );
+ $site->setSource( 'local' );
+ $site->setPath( MediaWikiSite::PATH_PAGE, "https://en.wiktionary.org/wiki/$1" );
+ $site->setPath( MediaWikiSite::PATH_FILE, "https://en.wiktionary.org/w/$1" );
+ $sites[] = $site;
+
+ return new SiteList( $sites );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/interwiki/InterwikiTest.php b/www/wiki/tests/phpunit/includes/interwiki/InterwikiTest.php
new file mode 100644
index 00000000..0d41c520
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/interwiki/InterwikiTest.php
@@ -0,0 +1,122 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @covers Interwiki
+ *
+ * @group MediaWiki
+ * @group Database
+ */
+class InterwikiTest extends MediaWikiTestCase {
+
+ public function testConstructor() {
+ $interwiki = new Interwiki(
+ 'xyz',
+ 'http://xyz.acme.test/wiki/$1',
+ 'http://xyz.acme.test/w/api.php',
+ 'xyzwiki',
+ 1,
+ 0
+ );
+
+ $this->setContentLang( 'qqx' );
+
+ $this->assertSame( '(interwiki-name-xyz)', $interwiki->getName() );
+ $this->assertSame( '(interwiki-desc-xyz)', $interwiki->getDescription() );
+ $this->assertSame( 'http://xyz.acme.test/w/api.php', $interwiki->getAPI() );
+ $this->assertSame( 'http://xyz.acme.test/wiki/$1', $interwiki->getURL() );
+ $this->assertSame( 'xyzwiki', $interwiki->getWikiID() );
+ $this->assertTrue( $interwiki->isLocal() );
+ $this->assertFalse( $interwiki->isTranscludable() );
+ }
+
+ public function testGetUrl() {
+ $interwiki = new Interwiki(
+ 'xyz',
+ 'http://xyz.acme.test/wiki/$1'
+ );
+
+ $this->assertSame( 'http://xyz.acme.test/wiki/$1', $interwiki->getURL() );
+ $this->assertSame( 'http://xyz.acme.test/wiki/Foo%26Bar', $interwiki->getURL( 'Foo&Bar' ) );
+ }
+
+ //// tests for static data access methods below ///////////////////////////////////////////////
+
+ private function populateDB( $iwrows ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete( 'interwiki', '*', __METHOD__ );
+ $dbw->insert( 'interwiki', array_values( $iwrows ), __METHOD__ );
+ $this->tablesUsed[] = 'interwiki';
+ }
+
+ private function setWgInterwikiCache( $interwikiCache ) {
+ $this->overrideMwServices();
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'InterwikiLookup' );
+ $this->setMwGlobals( 'wgInterwikiCache', $interwikiCache );
+ }
+
+ public function testDatabaseStorage() {
+ $this->markTestSkipped( 'Needs I37b8e8018b3 <https://gerrit.wikimedia.org/r/#/c/270555/>' );
+
+ // NOTE: database setup is expensive, so we only do
+ // it once and run all the tests in one go.
+ $dewiki = [
+ 'iw_prefix' => 'de',
+ 'iw_url' => 'http://de.wikipedia.org/wiki/',
+ 'iw_api' => 'http://de.wikipedia.org/w/api.php',
+ 'iw_wikiid' => 'dewiki',
+ 'iw_local' => 1,
+ 'iw_trans' => 0
+ ];
+
+ $zzwiki = [
+ 'iw_prefix' => 'zz',
+ 'iw_url' => 'http://zzwiki.org/wiki/',
+ 'iw_api' => 'http://zzwiki.org/w/api.php',
+ 'iw_wikiid' => 'zzwiki',
+ 'iw_local' => 0,
+ 'iw_trans' => 0
+ ];
+
+ $this->populateDB( [ $dewiki, $zzwiki ] );
+
+ $this->setWgInterwikiCache( false );
+
+ $interwikiLookup = MediaWikiServices::getInstance()->getInterwikiLookup();
+ $this->assertEquals(
+ [ $dewiki, $zzwiki ],
+ $interwikiLookup->getAllPrefixes(),
+ 'getAllPrefixes()'
+ );
+ $this->assertEquals(
+ [ $dewiki ],
+ $interwikiLookup->getAllPrefixes( true ),
+ 'getAllPrefixes()'
+ );
+ $this->assertEquals(
+ [ $zzwiki ],
+ $interwikiLookup->getAllPrefixes( false ),
+ 'getAllPrefixes()'
+ );
+
+ $this->assertTrue( $interwikiLookup->isValidInterwiki( 'de' ), 'known prefix is valid' );
+ $this->assertFalse( $interwikiLookup->isValidInterwiki( 'xyz' ), 'unknown prefix is valid' );
+
+ $this->assertNull( $interwikiLookup->fetch( null ), 'no prefix' );
+ $this->assertFalse( $interwikiLookup->fetch( 'xyz' ), 'unknown prefix' );
+
+ $interwiki = $interwikiLookup->fetch( 'de' );
+ $this->assertInstanceOf( Interwiki::class, $interwiki );
+ $this->assertSame( $interwiki, $interwikiLookup->fetch( 'de' ), 'in-process caching' );
+
+ $this->assertSame( 'http://de.wikipedia.org/wiki/', $interwiki->getURL(), 'getURL' );
+ $this->assertSame( 'http://de.wikipedia.org/w/api.php', $interwiki->getAPI(), 'getAPI' );
+ $this->assertSame( 'dewiki', $interwiki->getWikiID(), 'getWikiID' );
+ $this->assertSame( true, $interwiki->isLocal(), 'isLocal' );
+ $this->assertSame( false, $interwiki->isTranscludable(), 'isTranscludable' );
+
+ Interwiki::invalidateCache( 'de' );
+ $this->assertNotSame( $interwiki, $interwikiLookup->fetch( 'de' ), 'invalidate cache' );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/jobqueue/JobQueueMemoryTest.php b/www/wiki/tests/phpunit/includes/jobqueue/JobQueueMemoryTest.php
new file mode 100644
index 00000000..bf8603dd
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/jobqueue/JobQueueMemoryTest.php
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * @covers JobQueueMemory
+ *
+ * @group JobQueue
+ *
+ * @license GNU GPL v2+
+ * @author Thiemo Kreuz
+ */
+class JobQueueMemoryTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @return JobQueueMemory
+ */
+ private function newJobQueue() {
+ return JobQueue::factory( [
+ 'class' => JobQueueMemory::class,
+ 'wiki' => wfWikiID(),
+ 'type' => 'null',
+ ] );
+ }
+
+ private function newJobSpecification() {
+ return new JobSpecification(
+ 'null',
+ [ 'customParameter' => null ],
+ [],
+ Title::newFromText( 'Custom title' )
+ );
+ }
+
+ public function testGetAllQueuedJobs() {
+ $queue = $this->newJobQueue();
+ $this->assertCount( 0, $queue->getAllQueuedJobs() );
+
+ $queue->push( $this->newJobSpecification() );
+ $this->assertCount( 1, $queue->getAllQueuedJobs() );
+ }
+
+ public function testGetAllAcquiredJobs() {
+ $queue = $this->newJobQueue();
+ $this->assertCount( 0, $queue->getAllAcquiredJobs() );
+
+ $queue->push( $this->newJobSpecification() );
+ $this->assertCount( 0, $queue->getAllAcquiredJobs() );
+
+ $queue->pop();
+ $this->assertCount( 1, $queue->getAllAcquiredJobs() );
+ }
+
+ public function testJobFromSpecInternal() {
+ $queue = $this->newJobQueue();
+ $job = $queue->jobFromSpecInternal( $this->newJobSpecification() );
+ $this->assertInstanceOf( Job::class, $job );
+ $this->assertSame( 'null', $job->getType() );
+ $this->assertArrayHasKey( 'customParameter', $job->getParams() );
+ $this->assertSame( 'Custom title', $job->getTitle()->getText() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/jobqueue/JobQueueTest.php b/www/wiki/tests/phpunit/includes/jobqueue/JobQueueTest.php
new file mode 100644
index 00000000..64dde778
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/jobqueue/JobQueueTest.php
@@ -0,0 +1,393 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group JobQueue
+ * @group medium
+ * @group Database
+ */
+class JobQueueTest extends MediaWikiTestCase {
+ protected $key;
+ protected $queueRand, $queueRandTTL, $queueFifo, $queueFifoTTL;
+
+ function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+
+ $this->tablesUsed[] = 'job';
+ }
+
+ protected function setUp() {
+ global $wgJobTypeConf;
+ parent::setUp();
+
+ if ( $this->getCliArg( 'use-jobqueue' ) ) {
+ $name = $this->getCliArg( 'use-jobqueue' );
+ if ( !isset( $wgJobTypeConf[$name] ) ) {
+ throw new MWException( "No \$wgJobTypeConf entry for '$name'." );
+ }
+ $baseConfig = $wgJobTypeConf[$name];
+ } else {
+ $baseConfig = [ 'class' => JobQueueDBSingle::class ];
+ }
+ $baseConfig['type'] = 'null';
+ $baseConfig['wiki'] = wfWikiID();
+ $variants = [
+ 'queueRand' => [ 'order' => 'random', 'claimTTL' => 0 ],
+ 'queueRandTTL' => [ 'order' => 'random', 'claimTTL' => 10 ],
+ 'queueTimestamp' => [ 'order' => 'timestamp', 'claimTTL' => 0 ],
+ 'queueTimestampTTL' => [ 'order' => 'timestamp', 'claimTTL' => 10 ],
+ 'queueFifo' => [ 'order' => 'fifo', 'claimTTL' => 0 ],
+ 'queueFifoTTL' => [ 'order' => 'fifo', 'claimTTL' => 10 ],
+ ];
+ foreach ( $variants as $q => $settings ) {
+ try {
+ $this->$q = JobQueue::factory( $settings + $baseConfig );
+ } catch ( MWException $e ) {
+ // unsupported?
+ // @todo What if it was another error?
+ };
+ }
+ }
+
+ protected function tearDown() {
+ parent::tearDown();
+ foreach (
+ [
+ 'queueRand', 'queueRandTTL', 'queueTimestamp', 'queueTimestampTTL',
+ 'queueFifo', 'queueFifoTTL'
+ ] as $q
+ ) {
+ if ( $this->$q ) {
+ $this->$q->delete();
+ }
+ $this->$q = null;
+ }
+ }
+
+ /**
+ * @dataProvider provider_queueLists
+ * @covers JobQueue::getWiki
+ */
+ public function testGetWiki( $queue, $recycles, $desc ) {
+ $queue = $this->$queue;
+ if ( !$queue ) {
+ $this->markTestSkipped( $desc );
+ }
+ $this->assertEquals( wfWikiID(), $queue->getWiki(), "Proper wiki ID ($desc)" );
+ }
+
+ /**
+ * @dataProvider provider_queueLists
+ * @covers JobQueue::getType
+ */
+ public function testGetType( $queue, $recycles, $desc ) {
+ $queue = $this->$queue;
+ if ( !$queue ) {
+ $this->markTestSkipped( $desc );
+ }
+ $this->assertEquals( 'null', $queue->getType(), "Proper job type ($desc)" );
+ }
+
+ /**
+ * @dataProvider provider_queueLists
+ * @covers JobQueue
+ */
+ public function testBasicOperations( $queue, $recycles, $desc ) {
+ $queue = $this->$queue;
+ if ( !$queue ) {
+ $this->markTestSkipped( $desc );
+ }
+
+ $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );
+
+ $queue->flushCaches();
+ $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" );
+ $this->assertEquals( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" );
+
+ $this->assertNull( $queue->push( $this->newJob() ), "Push worked ($desc)" );
+ $this->assertNull( $queue->batchPush( [ $this->newJob() ] ), "Push worked ($desc)" );
+
+ $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" );
+
+ $queue->flushCaches();
+ $this->assertEquals( 2, $queue->getSize(), "Queue size is correct ($desc)" );
+ $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" );
+ $jobs = iterator_to_array( $queue->getAllQueuedJobs() );
+ $this->assertEquals( 2, count( $jobs ), "Queue iterator size is correct ($desc)" );
+
+ $job1 = $queue->pop();
+ $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" );
+
+ $queue->flushCaches();
+ $this->assertEquals( 1, $queue->getSize(), "Queue size is correct ($desc)" );
+
+ $queue->flushCaches();
+ if ( $recycles ) {
+ $this->assertEquals( 1, $queue->getAcquiredCount(), "Active job count ($desc)" );
+ }
+
+ $job2 = $queue->pop();
+ $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );
+ $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" );
+
+ $queue->flushCaches();
+ if ( $recycles ) {
+ $this->assertEquals( 2, $queue->getAcquiredCount(), "Active job count ($desc)" );
+ }
+
+ $queue->ack( $job1 );
+
+ $queue->flushCaches();
+ if ( $recycles ) {
+ $this->assertEquals( 1, $queue->getAcquiredCount(), "Active job count ($desc)" );
+ }
+
+ $queue->ack( $job2 );
+
+ $queue->flushCaches();
+ $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" );
+
+ $this->assertNull( $queue->batchPush( [ $this->newJob(), $this->newJob() ] ),
+ "Push worked ($desc)" );
+ $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" );
+
+ $queue->delete();
+ $queue->flushCaches();
+ $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );
+ $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" );
+ }
+
+ /**
+ * @dataProvider provider_queueLists
+ * @covers JobQueue
+ */
+ public function testBasicDeduplication( $queue, $recycles, $desc ) {
+ $queue = $this->$queue;
+ if ( !$queue ) {
+ $this->markTestSkipped( $desc );
+ }
+
+ $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );
+
+ $queue->flushCaches();
+ $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" );
+ $this->assertEquals( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" );
+
+ $this->assertNull(
+ $queue->batchPush(
+ [ $this->newDedupedJob(), $this->newDedupedJob(), $this->newDedupedJob() ]
+ ),
+ "Push worked ($desc)" );
+
+ $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" );
+
+ $queue->flushCaches();
+ $this->assertEquals( 1, $queue->getSize(), "Queue size is correct ($desc)" );
+ $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" );
+
+ $this->assertNull(
+ $queue->batchPush(
+ [ $this->newDedupedJob(), $this->newDedupedJob(), $this->newDedupedJob() ]
+ ),
+ "Push worked ($desc)"
+ );
+
+ $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" );
+
+ $queue->flushCaches();
+ $this->assertEquals( 1, $queue->getSize(), "Queue size is correct ($desc)" );
+ $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" );
+
+ $job1 = $queue->pop();
+ $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );
+
+ $queue->flushCaches();
+ $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" );
+ if ( $recycles ) {
+ $this->assertEquals( 1, $queue->getAcquiredCount(), "Active job count ($desc)" );
+ }
+
+ $queue->ack( $job1 );
+
+ $queue->flushCaches();
+ $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" );
+ }
+
+ /**
+ * @dataProvider provider_queueLists
+ * @covers JobQueue
+ */
+ public function testDeduplicationWhileClaimed( $queue, $recycles, $desc ) {
+ $queue = $this->$queue;
+ if ( !$queue ) {
+ $this->markTestSkipped( $desc );
+ }
+
+ $job = $this->newDedupedJob();
+ $queue->push( $job );
+
+ // De-duplication does not apply to already-claimed jobs
+ $j = $queue->pop();
+ $queue->push( $job );
+ $queue->ack( $j );
+
+ $j = $queue->pop();
+ // Make sure ack() of the twin did not delete the sibling data
+ $this->assertType( NullJob::class, $j );
+ }
+
+ /**
+ * @dataProvider provider_queueLists
+ * @covers JobQueue
+ */
+ public function testRootDeduplication( $queue, $recycles, $desc ) {
+ $queue = $this->$queue;
+ if ( !$queue ) {
+ $this->markTestSkipped( $desc );
+ }
+
+ $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );
+
+ $queue->flushCaches();
+ $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" );
+ $this->assertEquals( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" );
+
+ $id = wfRandomString( 32 );
+ $root1 = Job::newRootJobParams( "nulljobspam:$id" ); // task ID/timestamp
+ for ( $i = 0; $i < 5; ++$i ) {
+ $this->assertNull( $queue->push( $this->newJob( 0, $root1 ) ), "Push worked ($desc)" );
+ }
+ $queue->deduplicateRootJob( $this->newJob( 0, $root1 ) );
+
+ $root2 = $root1;
+ # Add a second to UNIX epoch and format back to TS_MW
+ $root2_ts = strtotime( $root2['rootJobTimestamp'] );
+ $root2_ts++;
+ $root2['rootJobTimestamp'] = wfTimestamp( TS_MW, $root2_ts );
+
+ $this->assertNotEquals( $root1['rootJobTimestamp'], $root2['rootJobTimestamp'],
+ "Root job signatures have different timestamps." );
+ for ( $i = 0; $i < 5; ++$i ) {
+ $this->assertNull( $queue->push( $this->newJob( 0, $root2 ) ), "Push worked ($desc)" );
+ }
+ $queue->deduplicateRootJob( $this->newJob( 0, $root2 ) );
+
+ $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" );
+
+ $queue->flushCaches();
+ $this->assertEquals( 10, $queue->getSize(), "Queue size is correct ($desc)" );
+ $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" );
+
+ $dupcount = 0;
+ $jobs = [];
+ do {
+ $job = $queue->pop();
+ if ( $job ) {
+ $jobs[] = $job;
+ $queue->ack( $job );
+ }
+ if ( $job instanceof DuplicateJob ) {
+ ++$dupcount;
+ }
+ } while ( $job );
+
+ $this->assertEquals( 10, count( $jobs ), "Correct number of jobs popped ($desc)" );
+ $this->assertEquals( 5, $dupcount, "Correct number of duplicate jobs popped ($desc)" );
+ }
+
+ /**
+ * @dataProvider provider_fifoQueueLists
+ * @covers JobQueue
+ */
+ public function testJobOrder( $queue, $recycles, $desc ) {
+ $queue = $this->$queue;
+ if ( !$queue ) {
+ $this->markTestSkipped( $desc );
+ }
+
+ $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );
+
+ $queue->flushCaches();
+ $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" );
+ $this->assertEquals( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" );
+
+ for ( $i = 0; $i < 10; ++$i ) {
+ $this->assertNull( $queue->push( $this->newJob( $i ) ), "Push worked ($desc)" );
+ }
+
+ for ( $i = 0; $i < 10; ++$i ) {
+ $job = $queue->pop();
+ $this->assertTrue( $job instanceof Job, "Jobs popped from queue ($desc)" );
+ $params = $job->getParams();
+ $this->assertEquals( $i, $params['i'], "Job popped from queue is FIFO ($desc)" );
+ $queue->ack( $job );
+ }
+
+ $this->assertFalse( $queue->pop(), "Queue is not empty ($desc)" );
+
+ $queue->flushCaches();
+ $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" );
+ $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" );
+ }
+
+ /**
+ * @covers JobQueue
+ */
+ public function testQueueAggregateTable() {
+ $queue = $this->queueFifo;
+ if ( !$queue || !method_exists( $queue, 'getServerQueuesWithJobs' ) ) {
+ $this->markTestSkipped();
+ }
+
+ $this->assertNotContains(
+ [ $queue->getType(), $queue->getWiki() ],
+ $queue->getServerQueuesWithJobs(),
+ "Null queue not in listing"
+ );
+
+ $queue->push( $this->newJob( 0 ) );
+
+ $this->assertContains(
+ [ $queue->getType(), $queue->getWiki() ],
+ $queue->getServerQueuesWithJobs(),
+ "Null queue in listing"
+ );
+ }
+
+ public static function provider_queueLists() {
+ return [
+ [ 'queueRand', false, 'Random queue without ack()' ],
+ [ 'queueRandTTL', true, 'Random queue with ack()' ],
+ [ 'queueTimestamp', false, 'Time ordered queue without ack()' ],
+ [ 'queueTimestampTTL', true, 'Time ordered queue with ack()' ],
+ [ 'queueFifo', false, 'FIFO ordered queue without ack()' ],
+ [ 'queueFifoTTL', true, 'FIFO ordered queue with ack()' ]
+ ];
+ }
+
+ public static function provider_fifoQueueLists() {
+ return [
+ [ 'queueFifo', false, 'Ordered queue without ack()' ],
+ [ 'queueFifoTTL', true, 'Ordered queue with ack()' ]
+ ];
+ }
+
+ function newJob( $i = 0, $rootJob = [] ) {
+ return new NullJob( Title::newMainPage(),
+ [ 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 0, 'i' => $i ] + $rootJob );
+ }
+
+ function newDedupedJob( $i = 0, $rootJob = [] ) {
+ return new NullJob( Title::newMainPage(),
+ [ 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 1, 'i' => $i ] + $rootJob );
+ }
+}
+
+class JobQueueDBSingle extends JobQueueDB {
+ protected function getDB( $index ) {
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ // Override to not use CONN_TRX_AUTOCOMMIT so that we see the same temporary `job` table
+ return $lb->getConnection( $index, [], $this->wiki );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/jobqueue/JobTest.php b/www/wiki/tests/phpunit/includes/jobqueue/JobTest.php
new file mode 100644
index 00000000..0cab7024
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/jobqueue/JobTest.php
@@ -0,0 +1,133 @@
+<?php
+
+/**
+ * @author Addshore
+ */
+class JobTest extends MediaWikiTestCase {
+
+ /**
+ * @dataProvider provideTestToString
+ *
+ * @param Job $job
+ * @param string $expected
+ *
+ * @covers Job::toString
+ */
+ public function testToString( $job, $expected ) {
+ $this->assertEquals( $expected, $job->toString() );
+ }
+
+ public function provideTestToString() {
+ $mockToStringObj = $this->getMockBuilder( stdClass::class )
+ ->setMethods( [ '__toString' ] )->getMock();
+ $mockToStringObj->expects( $this->any() )
+ ->method( '__toString' )
+ ->will( $this->returnValue( '{STRING_OBJ_VAL}' ) );
+
+ $requestId = 'requestId=' . WebRequest::getRequestId();
+
+ return [
+ [
+ $this->getMockJob( false ),
+ 'someCommand ' . $requestId
+ ],
+ [
+ $this->getMockJob( [ 'key' => 'val' ] ),
+ 'someCommand key=val ' . $requestId
+ ],
+ [
+ $this->getMockJob( [ 'key' => [ 'inkey' => 'inval' ] ] ),
+ 'someCommand key={"inkey":"inval"} ' . $requestId
+ ],
+ [
+ $this->getMockJob( [ 'val1' ] ),
+ 'someCommand 0=val1 ' . $requestId
+ ],
+ [
+ $this->getMockJob( [ 'val1', 'val2' ] ),
+ 'someCommand 0=val1 1=val2 ' . $requestId
+ ],
+ [
+ $this->getMockJob( [ new stdClass() ] ),
+ 'someCommand 0=object(stdClass) ' . $requestId
+ ],
+ [
+ $this->getMockJob( [ $mockToStringObj ] ),
+ 'someCommand 0={STRING_OBJ_VAL} ' . $requestId
+ ],
+ [
+ $this->getMockJob( [
+ "pages" => [
+ "932737" => [
+ 0,
+ "Robert_James_Waller"
+ ]
+ ],
+ "rootJobSignature" => "45868e99bba89064e4483743ebb9b682ef95c1a7",
+ "rootJobTimestamp" => "20160309110158",
+ "masterPos" => [
+ "file" => "db1023-bin.001288",
+ "pos" => "308257743",
+ "asOfTime" => 1457521464.3814
+ ],
+ "triggeredRecursive" => true
+ ] ),
+ 'someCommand pages={"932737":[0,"Robert_James_Waller"]} ' .
+ 'rootJobSignature=45868e99bba89064e4483743ebb9b682ef95c1a7 ' .
+ 'rootJobTimestamp=20160309110158 masterPos=' .
+ '{"file":"db1023-bin.001288","pos":"308257743","asOfTime":' .
+ // Embed dynamically because TestSetup sets serialize_precision=17
+ // which, in PHP 7.1 and 7.2, produces 1457521464.3814001 instead
+ json_encode( 1457521464.3814 ) . '} ' . 'triggeredRecursive=1 ' .
+ $requestId
+ ],
+ ];
+ }
+
+ public function getMockJob( $params ) {
+ $mock = $this->getMockForAbstractClass(
+ Job::class,
+ [ 'someCommand', new Title(), $params ],
+ 'SomeJob'
+ );
+ return $mock;
+ }
+
+ /**
+ * @dataProvider provideTestJobFactory
+ *
+ * @param mixed $handler
+ *
+ * @covers Job::factory
+ */
+ public function testJobFactory( $handler ) {
+ $this->mergeMwGlobalArrayValue( 'wgJobClasses', [ 'testdummy' => $handler ] );
+
+ $job = Job::factory( 'testdummy', Title::newMainPage(), [] );
+ $this->assertInstanceOf( NullJob::class, $job );
+
+ $job2 = Job::factory( 'testdummy', Title::newMainPage(), [] );
+ $this->assertInstanceOf( NullJob::class, $job2 );
+ $this->assertNotSame( $job, $job2, 'should not reuse instance' );
+ }
+
+ public function provideTestJobFactory() {
+ return [
+ 'class name' => [ 'NullJob' ],
+ 'closure' => [ function ( Title $title, array $params ) {
+ return new NullJob( $title, $params );
+ } ],
+ 'function' => [ [ $this, 'newNullJob' ] ],
+ 'static function' => [ self::class . '::staticNullJob' ]
+ ];
+ }
+
+ public function newNullJob( Title $title, array $params ) {
+ return new NullJob( $title, $params );
+ }
+
+ public static function staticNullJob( Title $title, array $params ) {
+ return new NullJob( $title, $params );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/jobqueue/RefreshLinksPartitionTest.php b/www/wiki/tests/phpunit/includes/jobqueue/RefreshLinksPartitionTest.php
new file mode 100644
index 00000000..f874f6de
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/jobqueue/RefreshLinksPartitionTest.php
@@ -0,0 +1,113 @@
+<?php
+
+/**
+ * @group JobQueue
+ * @group medium
+ * @group Database
+ */
+class RefreshLinksPartitionTest extends MediaWikiTestCase {
+ public function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+
+ $this->tablesUsed[] = 'page';
+ $this->tablesUsed[] = 'revision';
+ $this->tablesUsed[] = 'pagelinks';
+ }
+
+ /**
+ * @dataProvider provider_backlinks
+ * @covers BacklinkJobUtils::partitionBacklinkJob
+ */
+ public function testRefreshLinks( $ns, $dbKey, $pages ) {
+ $title = Title::makeTitle( $ns, $dbKey );
+
+ foreach ( $pages as $page ) {
+ list( $bns, $bdbkey ) = $page;
+ $bpage = WikiPage::factory( Title::makeTitle( $bns, $bdbkey ) );
+ $content = ContentHandler::makeContent( "[[{$title->getPrefixedText()}]]", $bpage->getTitle() );
+ $bpage->doEditContent( $content, "test" );
+ }
+
+ $title->getBacklinkCache()->clear();
+ $this->assertEquals(
+ 20,
+ $title->getBacklinkCache()->getNumLinks( 'pagelinks' ),
+ 'Correct number of backlinks'
+ );
+
+ $job = new RefreshLinksJob( $title, [ 'recursive' => true, 'table' => 'pagelinks' ]
+ + Job::newRootJobParams( "refreshlinks:pagelinks:{$title->getPrefixedText()}" ) );
+ $extraParams = $job->getRootJobParams();
+ $jobs = BacklinkJobUtils::partitionBacklinkJob( $job, 9, 1, [ 'params' => $extraParams ] );
+
+ $this->assertEquals( 10, count( $jobs ), 'Correct number of sub-jobs' );
+ $this->assertEquals( $pages[0], current( $jobs[0]->params['pages'] ),
+ 'First job is leaf job with proper title' );
+ $this->assertEquals( $pages[8], current( $jobs[8]->params['pages'] ),
+ 'Last leaf job is leaf job with proper title' );
+ $this->assertEquals( true, isset( $jobs[9]->params['recursive'] ),
+ 'Last job is recursive sub-job' );
+ $this->assertEquals( true, $jobs[9]->params['recursive'],
+ 'Last job is recursive sub-job' );
+ $this->assertEquals( true, is_array( $jobs[9]->params['range'] ),
+ 'Last job is recursive sub-job' );
+ $this->assertEquals( $title->getPrefixedText(), $jobs[0]->getTitle()->getPrefixedText(),
+ 'Base job title retainend in leaf job' );
+ $this->assertEquals( $title->getPrefixedText(), $jobs[9]->getTitle()->getPrefixedText(),
+ 'Base job title retainend recursive sub-job' );
+ $this->assertEquals( $extraParams['rootJobSignature'], $jobs[0]->params['rootJobSignature'],
+ 'Leaf job has root params' );
+ $this->assertEquals( $extraParams['rootJobSignature'], $jobs[9]->params['rootJobSignature'],
+ 'Recursive sub-job has root params' );
+
+ $jobs2 = BacklinkJobUtils::partitionBacklinkJob(
+ $jobs[9],
+ 9,
+ 1,
+ [ 'params' => $extraParams ]
+ );
+
+ $this->assertEquals( 10, count( $jobs2 ), 'Correct number of sub-jobs' );
+ $this->assertEquals( $pages[9], current( $jobs2[0]->params['pages'] ),
+ 'First job is leaf job with proper title' );
+ $this->assertEquals( $pages[17], current( $jobs2[8]->params['pages'] ),
+ 'Last leaf job is leaf job with proper title' );
+ $this->assertEquals( true, isset( $jobs2[9]->params['recursive'] ),
+ 'Last job is recursive sub-job' );
+ $this->assertEquals( true, $jobs2[9]->params['recursive'],
+ 'Last job is recursive sub-job' );
+ $this->assertEquals( true, is_array( $jobs2[9]->params['range'] ),
+ 'Last job is recursive sub-job' );
+ $this->assertEquals( $extraParams['rootJobSignature'], $jobs2[0]->params['rootJobSignature'],
+ 'Leaf job has root params' );
+ $this->assertEquals( $extraParams['rootJobSignature'], $jobs2[9]->params['rootJobSignature'],
+ 'Recursive sub-job has root params' );
+
+ $jobs3 = BacklinkJobUtils::partitionBacklinkJob(
+ $jobs2[9],
+ 9,
+ 1,
+ [ 'params' => $extraParams ]
+ );
+
+ $this->assertEquals( 2, count( $jobs3 ), 'Correct number of sub-jobs' );
+ $this->assertEquals( $pages[18], current( $jobs3[0]->params['pages'] ),
+ 'First job is leaf job with proper title' );
+ $this->assertEquals( $extraParams['rootJobSignature'], $jobs3[0]->params['rootJobSignature'],
+ 'Leaf job has root params' );
+ $this->assertEquals( $pages[19], current( $jobs3[1]->params['pages'] ),
+ 'Last job is leaf job with proper title' );
+ $this->assertEquals( $extraParams['rootJobSignature'], $jobs3[1]->params['rootJobSignature'],
+ 'Last leaf job has root params' );
+ }
+
+ public static function provider_backlinks() {
+ $pages = [];
+ for ( $i = 0; $i < 20; ++$i ) {
+ $pages[] = [ 0, "Page-$i" ];
+ }
+ return [
+ [ 10, 'Bang', $pages ]
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/jobqueue/jobs/CategoryMembershipChangeJobTest.php b/www/wiki/tests/phpunit/includes/jobqueue/jobs/CategoryMembershipChangeJobTest.php
new file mode 100644
index 00000000..5960a16b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/jobqueue/jobs/CategoryMembershipChangeJobTest.php
@@ -0,0 +1,87 @@
+<?php
+
+/**
+ * @covers CategoryMembershipChangeJob
+ *
+ * @group JobQueue
+ * @group Database
+ *
+ * @license GNU GPL v2+
+ * @author Addshore
+ */
+class CategoryMembershipChangeJobTest extends MediaWikiTestCase {
+
+ const TITLE_STRING = 'UTCatChangeJobPage';
+
+ /**
+ * @var Title
+ */
+ private $title;
+
+ public function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( 'wgRCWatchCategoryMembership', true );
+ $this->setContentLang( 'qqx' );
+ }
+
+ public function addDBDataOnce() {
+ parent::addDBDataOnce();
+ $insertResult = $this->insertPage( self::TITLE_STRING, 'UT Content' );
+ $this->title = $insertResult['title'];
+ }
+
+ private function runJobs() {
+ JobQueueGroup::destroySingletons();
+ $jobs = new RunJobs;
+ $jobs->loadParamsAndArgs( null, [ 'quiet' => true ], null );
+ $jobs->execute();
+ }
+
+ /**
+ * @param string $text new page text
+ *
+ * @return int|null
+ */
+ private function editPageText( $text ) {
+ $page = WikiPage::factory( $this->title );
+ $editResult = $page->doEditContent(
+ ContentHandler::makeContent( $text, $this->title ),
+ __METHOD__
+ );
+ /** @var Revision $revision */
+ $revision = $editResult->value['revision'];
+ $this->runJobs();
+
+ return $revision->getId();
+ }
+
+ /**
+ * @param int $revId
+ *
+ * @return RecentChange|null
+ */
+ private function getCategorizeRecentChangeForRevId( $revId ) {
+ return RecentChange::newFromConds(
+ [
+ 'rc_type' => RC_CATEGORIZE,
+ 'rc_this_oldid' => $revId,
+ ],
+ __METHOD__
+ );
+ }
+
+ public function testRun_normalCategoryAddedAndRemoved() {
+ $addedRevId = $this->editPageText( '[[Category:Normal]]' );
+ $removedRevId = $this->editPageText( 'Blank' );
+
+ $this->assertEquals(
+ '(recentchanges-page-added-to-category: ' . self::TITLE_STRING . ')',
+ $this->getCategorizeRecentChangeForRevId( $addedRevId )->getAttribute( 'rc_comment' )
+ );
+ $this->assertEquals(
+ '(recentchanges-page-removed-from-category: ' . self::TITLE_STRING . ')',
+ $this->getCategorizeRecentChangeForRevId( $removedRevId )->getAttribute( 'rc_comment' )
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/jobqueue/jobs/ClearUserWatchlistJobTest.php b/www/wiki/tests/phpunit/includes/jobqueue/jobs/ClearUserWatchlistJobTest.php
new file mode 100644
index 00000000..6ae7d605
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/jobqueue/jobs/ClearUserWatchlistJobTest.php
@@ -0,0 +1,79 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @covers ClearUserWatchlistJob
+ *
+ * @group JobQueue
+ * @group Database
+ *
+ * @license GNU GPL v2+
+ * @author Addshore
+ */
+class ClearUserWatchlistJobTest extends MediaWikiTestCase {
+
+ public function setUp() {
+ parent::setUp();
+ self::$users['ClearUserWatchlistJobTestUser']
+ = new TestUser( 'ClearUserWatchlistJobTestUser' );
+ $this->runJobs();
+ JobQueueGroup::destroySingletons();
+ }
+
+ private function getUser() {
+ return self::$users['ClearUserWatchlistJobTestUser']->getUser();
+ }
+
+ private function runJobs( $jobLimit = 9999 ) {
+ $runJobs = new RunJobs;
+ $runJobs->loadParamsAndArgs( null, [ 'quiet' => true, 'maxjobs' => $jobLimit ] );
+ $runJobs->execute();
+ }
+
+ private function getWatchedItemStore() {
+ return MediaWikiServices::getInstance()->getWatchedItemStore();
+ }
+
+ public function testRun() {
+ $user = $this->getUser();
+ $watchedItemStore = $this->getWatchedItemStore();
+
+ $watchedItemStore->addWatch( $user, new TitleValue( 0, 'A' ) );
+ $watchedItemStore->addWatch( $user, new TitleValue( 1, 'A' ) );
+ $watchedItemStore->addWatch( $user, new TitleValue( 0, 'B' ) );
+ $watchedItemStore->addWatch( $user, new TitleValue( 1, 'B' ) );
+
+ $maxId = $watchedItemStore->getMaxId();
+
+ $watchedItemStore->addWatch( $user, new TitleValue( 0, 'C' ) );
+ $watchedItemStore->addWatch( $user, new TitleValue( 1, 'C' ) );
+
+ $this->setMwGlobals( 'wgUpdateRowsPerQuery', 2 );
+
+ JobQueueGroup::singleton()->push(
+ new ClearUserWatchlistJob(
+ null,
+ [
+ 'userId' => $user->getId(),
+ 'maxWatchlistId' => $maxId,
+ ]
+ )
+ );
+
+ $this->assertEquals( 1, JobQueueGroup::singleton()->getQueueSizes()['clearUserWatchlist'] );
+ $this->assertEquals( 6, $watchedItemStore->countWatchedItems( $user ) );
+ $this->runJobs( 1 );
+ $this->assertEquals( 1, JobQueueGroup::singleton()->getQueueSizes()['clearUserWatchlist'] );
+ $this->assertEquals( 4, $watchedItemStore->countWatchedItems( $user ) );
+ $this->runJobs( 1 );
+ $this->assertEquals( 1, JobQueueGroup::singleton()->getQueueSizes()['clearUserWatchlist'] );
+ $this->assertEquals( 2, $watchedItemStore->countWatchedItems( $user ) );
+ $this->runJobs( 1 );
+ $this->assertEquals( 0, JobQueueGroup::singleton()->getQueueSizes()['clearUserWatchlist'] );
+ $this->assertEquals( 2, $watchedItemStore->countWatchedItems( $user ) );
+
+ $this->assertTrue( $watchedItemStore->isWatched( $user, new TitleValue( 0, 'C' ) ) );
+ $this->assertTrue( $watchedItemStore->isWatched( $user, new TitleValue( 1, 'C' ) ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/json/FormatJsonTest.php b/www/wiki/tests/phpunit/includes/json/FormatJsonTest.php
new file mode 100644
index 00000000..a4ab879f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/json/FormatJsonTest.php
@@ -0,0 +1,375 @@
+<?php
+
+/**
+ * @covers FormatJson
+ */
+class FormatJsonTest extends MediaWikiTestCase {
+
+ public static function provideEncoderPrettyPrinting() {
+ return [
+ // Four spaces
+ [ true, ' ' ],
+ [ ' ', ' ' ],
+ // Two spaces
+ [ ' ', ' ' ],
+ // One tab
+ [ "\t", "\t" ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideEncoderPrettyPrinting
+ */
+ public function testEncoderPrettyPrinting( $pretty, $expectedIndent ) {
+ $obj = [
+ 'emptyObject' => new stdClass,
+ 'emptyArray' => [],
+ 'string' => 'foobar\\',
+ 'filledArray' => [
+ [
+ 123,
+ 456,
+ ],
+ // Nested json works without problems
+ '"7":["8",{"9":"10"}]',
+ // Whitespace clean up doesn't touch strings that look alike
+ "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}",
+ ],
+ ];
+
+ // No trailing whitespace, no trailing linefeed
+ $json = '{
+ "emptyObject": {},
+ "emptyArray": [],
+ "string": "foobar\\\\",
+ "filledArray": [
+ [
+ 123,
+ 456
+ ],
+ "\"7\":[\"8\",{\"9\":\"10\"}]",
+ "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}"
+ ]
+}';
+
+ $json = str_replace( "\r", '', $json ); // Windows compat
+ $json = str_replace( "\t", $expectedIndent, $json );
+ $this->assertSame( $json, FormatJson::encode( $obj, $pretty ) );
+ }
+
+ public static function provideEncodeDefault() {
+ return self::getEncodeTestCases( [] );
+ }
+
+ /**
+ * @dataProvider provideEncodeDefault
+ */
+ public function testEncodeDefault( $from, $to ) {
+ $this->assertSame( $to, FormatJson::encode( $from ) );
+ }
+
+ public static function provideEncodeUtf8() {
+ return self::getEncodeTestCases( [ 'unicode' ] );
+ }
+
+ /**
+ * @dataProvider provideEncodeUtf8
+ */
+ public function testEncodeUtf8( $from, $to ) {
+ $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::UTF8_OK ) );
+ }
+
+ public static function provideEncodeXmlMeta() {
+ return self::getEncodeTestCases( [ 'xmlmeta' ] );
+ }
+
+ /**
+ * @dataProvider provideEncodeXmlMeta
+ */
+ public function testEncodeXmlMeta( $from, $to ) {
+ $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::XMLMETA_OK ) );
+ }
+
+ public static function provideEncodeAllOk() {
+ return self::getEncodeTestCases( [ 'unicode', 'xmlmeta' ] );
+ }
+
+ /**
+ * @dataProvider provideEncodeAllOk
+ */
+ public function testEncodeAllOk( $from, $to ) {
+ $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::ALL_OK ) );
+ }
+
+ public function testEncodePhpBug46944() {
+ $this->assertNotEquals(
+ '\ud840\udc00',
+ strtolower( FormatJson::encode( "\xf0\xa0\x80\x80" ) ),
+ 'Test encoding an broken json_encode character (U+20000)'
+ );
+ }
+
+ public function testDecodeReturnType() {
+ $this->assertInternalType(
+ 'object',
+ FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}' ),
+ 'Default to object'
+ );
+
+ $this->assertInternalType(
+ 'array',
+ FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}', true ),
+ 'Optional array'
+ );
+ }
+
+ public static function provideParse() {
+ return [
+ [ null ],
+ [ true ],
+ [ false ],
+ [ 0 ],
+ [ 1 ],
+ [ 1.2 ],
+ [ '' ],
+ [ 'str' ],
+ [ [ 0, 1, 2 ] ],
+ [ [ 'a' => 'b' ] ],
+ [ [ 'a' => 'b' ] ],
+ [ [ 'a' => 'b', 'x' => [ 'c' => 'd' ] ] ],
+ ];
+ }
+
+ /**
+ * Recursively convert arrays into stdClass
+ * @param array|string|bool|int|float|null $value
+ * @return stdClass|string|bool|int|float|null
+ */
+ public static function toObject( $value ) {
+ return !is_array( $value ) ? $value : (object)array_map( __METHOD__, $value );
+ }
+
+ /**
+ * @dataProvider provideParse
+ * @param mixed $value
+ */
+ public function testParse( $value ) {
+ $expected = self::toObject( $value );
+ $json = FormatJson::encode( $expected, false, FormatJson::ALL_OK );
+ $this->assertJson( $json );
+
+ $st = FormatJson::parse( $json );
+ $this->assertInstanceOf( Status::class, $st );
+ $this->assertTrue( $st->isGood() );
+ $this->assertEquals( $expected, $st->getValue() );
+
+ $st = FormatJson::parse( $json, FormatJson::FORCE_ASSOC );
+ $this->assertInstanceOf( Status::class, $st );
+ $this->assertTrue( $st->isGood() );
+ $this->assertEquals( $value, $st->getValue() );
+ }
+
+ /**
+ * Test data for testParseTryFixing.
+ *
+ * Some PHP interpreters use json-c rather than the JSON.org cannonical
+ * parser to avoid being encumbered by the "shall be used for Good, not
+ * Evil" clause of the JSON.org parser's license. By default, json-c
+ * parses in a non-strict mode which allows trailing commas for array and
+ * object delarations among other things, so our JSON_ERROR_SYNTAX rescue
+ * block is not always triggered. It however isn't lenient in exactly the
+ * same ways as our TRY_FIXING mode, so the assertions in this test are
+ * a bit more complicated than they ideally would be:
+ *
+ * Optional third argument: true if json-c parses the value without
+ * intervention, false otherwise. Defaults to true.
+ *
+ * Optional fourth argument: expected cannonical JSON serialization of
+ * json-c parsed result. Defaults to the second argument's value.
+ */
+ public static function provideParseTryFixing() {
+ return [
+ [ "[,]", '[]', false ],
+ [ "[ , ]", '[]', false ],
+ [ "[ , }", false ],
+ [ '[1],', false, true, '[1]' ],
+ [ "[1,]", '[1]' ],
+ [ "[1\n,]", '[1]' ],
+ [ "[1,\n]", '[1]' ],
+ [ "[1,]\n", '[1]' ],
+ [ "[1\n,\n]\n", '[1]' ],
+ [ '["a,",]', '["a,"]' ],
+ [ "[[1,]\n,[2,\n],[3\n,]]", '[[1],[2],[3]]' ],
+ // I wish we could parse this, but would need quote parsing
+ [ '[[1,],[2,],[3,]]', false, true, '[[1],[2],[3]]' ],
+ [ '[1,,]', false, false, '[1]' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideParseTryFixing
+ * @param string $value
+ * @param string|bool $expected Expected result with strict parser
+ * @param bool $jsoncParses Will json-c parse this value without TRY_FIXING?
+ * @param string|bool $expectedJsonc Expected result with lenient parser
+ * if different from the strict expectation
+ */
+ public function testParseTryFixing(
+ $value, $expected,
+ $jsoncParses = true, $expectedJsonc = null
+ ) {
+ // PHP5 results are always expected to have isGood() === false
+ $expectedGoodStatus = false;
+
+ // Check to see if json parser allows trailing commas
+ if ( json_decode( '[1,]' ) !== null ) {
+ // Use json-c specific expected result if provided
+ $expected = ( $expectedJsonc === null ) ? $expected : $expectedJsonc;
+ // If json-c parses the value natively, expect isGood() === true
+ $expectedGoodStatus = $jsoncParses;
+ }
+
+ $st = FormatJson::parse( $value, FormatJson::TRY_FIXING );
+ $this->assertInstanceOf( Status::class, $st );
+ if ( $expected === false ) {
+ $this->assertFalse( $st->isOK(), 'Expected isOK() == false' );
+ } else {
+ $this->assertSame( $expectedGoodStatus, $st->isGood(),
+ 'Expected isGood() == ' . ( $expectedGoodStatus ? 'true' : 'false' )
+ );
+ $this->assertTrue( $st->isOK(), 'Expected isOK == true' );
+ $val = FormatJson::encode( $st->getValue(), false, FormatJson::ALL_OK );
+ $this->assertEquals( $expected, $val );
+ }
+ }
+
+ public static function provideParseErrors() {
+ return [
+ [ 'aaa' ],
+ [ '{"j": 1 ] }' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideParseErrors
+ * @param mixed $value
+ */
+ public function testParseErrors( $value ) {
+ $st = FormatJson::parse( $value );
+ $this->assertInstanceOf( Status::class, $st );
+ $this->assertFalse( $st->isOK() );
+ }
+
+ public function provideStripComments() {
+ return [
+ [ '{"a":"b"}', '{"a":"b"}' ],
+ [ "{\"a\":\"b\"}\n", "{\"a\":\"b\"}\n" ],
+ [ '/*c*/{"c":"b"}', '{"c":"b"}' ],
+ [ '{"a":"c"}/*c*/', '{"a":"c"}' ],
+ [ '/*c//d*/{"c":"b"}', '{"c":"b"}' ],
+ [ '{/*c*/"c":"b"}', '{"c":"b"}' ],
+ [ "/*\nc\r\n*/{\"c\":\"b\"}", '{"c":"b"}' ],
+ [ "//c\n{\"c\":\"b\"}", '{"c":"b"}' ],
+ [ "//c\r\n{\"c\":\"b\"}", '{"c":"b"}' ],
+ [ '{"a":"c"}//c', '{"a":"c"}' ],
+ [ "{\"a-c\"://c\n\"b\"}", '{"a-c":"b"}' ],
+ [ '{"/*a":"b"}', '{"/*a":"b"}' ],
+ [ '{"a":"//b"}', '{"a":"//b"}' ],
+ [ '{"a":"b/*c*/"}', '{"a":"b/*c*/"}' ],
+ [ "{\"\\\"/*a\":\"b\"}", "{\"\\\"/*a\":\"b\"}" ],
+ [ '', '' ],
+ [ '/*c', '' ],
+ [ '//c', '' ],
+ [ '"http://example.com"', '"http://example.com"' ],
+ [ "\0", "\0" ],
+ [ '"Blåbærsyltetøy"', '"Blåbærsyltetøy"' ],
+ ];
+ }
+
+ /**
+ * @covers FormatJson::stripComments
+ * @dataProvider provideStripComments
+ * @param string $json
+ * @param string $expect
+ */
+ public function testStripComments( $json, $expect ) {
+ $this->assertSame( $expect, FormatJson::stripComments( $json ) );
+ }
+
+ public function provideParseStripComments() {
+ return [
+ [ '/* blah */true', true ],
+ [ "// blah \ntrue", true ],
+ [ '[ "a" , /* blah */ "b" ]', [ 'a', 'b' ] ],
+ ];
+ }
+
+ /**
+ * @covers FormatJson::parse
+ * @covers FormatJson::stripComments
+ * @dataProvider provideParseStripComments
+ * @param string $json
+ * @param mixed $expect
+ */
+ public function testParseStripComments( $json, $expect ) {
+ $st = FormatJson::parse( $json, FormatJson::STRIP_COMMENTS );
+ $this->assertInstanceOf( Status::class, $st );
+ $this->assertTrue( $st->isGood() );
+ $this->assertEquals( $expect, $st->getValue() );
+ }
+
+ /**
+ * Generate a set of test cases for a particular combination of encoder options.
+ *
+ * @param array $unescapedGroups List of character groups to leave unescaped
+ * @return array Arrays of unencoded strings and corresponding encoded strings
+ */
+ private static function getEncodeTestCases( array $unescapedGroups ) {
+ $groups = [
+ 'always' => [
+ // Forward slash (always unescaped)
+ '/' => '/',
+
+ // Control characters
+ "\0" => '\u0000',
+ "\x08" => '\b',
+ "\t" => '\t',
+ "\n" => '\n',
+ "\r" => '\r',
+ "\f" => '\f',
+ "\x1f" => '\u001f', // representative example
+
+ // Double quotes
+ '"' => '\"',
+
+ // Backslashes
+ '\\' => '\\\\',
+ '\\\\' => '\\\\\\\\',
+ '\\u00e9' => '\\\u00e9', // security check for Unicode unescaping
+
+ // Line terminators
+ "\xe2\x80\xa8" => '\u2028',
+ "\xe2\x80\xa9" => '\u2029',
+ ],
+ 'unicode' => [
+ "\xc3\xa9" => '\u00e9',
+ "\xf0\x9d\x92\x9e" => '\ud835\udc9e', // U+1D49E, outside the BMP
+ ],
+ 'xmlmeta' => [
+ '<' => '\u003C', // JSON_HEX_TAG uses uppercase hex digits
+ '>' => '\u003E',
+ '&' => '\u0026',
+ ],
+ ];
+
+ $cases = [];
+ foreach ( $groups as $name => $rules ) {
+ $leaveUnescaped = in_array( $name, $unescapedGroups );
+ foreach ( $rules as $from => $to ) {
+ $cases[] = [ $from, '"' . ( $leaveUnescaped ? $from : $to ) . '"' ];
+ }
+ }
+
+ return $cases;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/ArrayUtilsTest.php b/www/wiki/tests/phpunit/includes/libs/ArrayUtilsTest.php
new file mode 100644
index 00000000..d5ac77bb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/ArrayUtilsTest.php
@@ -0,0 +1,310 @@
+<?php
+/**
+ * Test class for ArrayUtils class
+ *
+ * @group Database
+ */
+class ArrayUtilsTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers ArrayUtils::findLowerBound
+ * @dataProvider provideFindLowerBound
+ */
+ function testFindLowerBound(
+ $valueCallback, $valueCount, $comparisonCallback, $target, $expected
+ ) {
+ $this->assertSame(
+ ArrayUtils::findLowerBound(
+ $valueCallback, $valueCount, $comparisonCallback, $target
+ ), $expected
+ );
+ }
+
+ function provideFindLowerBound() {
+ $indexValueCallback = function ( $size ) {
+ return function ( $val ) use ( $size ) {
+ $this->assertTrue( $val >= 0 );
+ $this->assertTrue( $val < $size );
+ return $val;
+ };
+ };
+ $comparisonCallback = function ( $a, $b ) {
+ return $a - $b;
+ };
+
+ return [
+ [
+ $indexValueCallback( 0 ),
+ 0,
+ $comparisonCallback,
+ 1,
+ false,
+ ],
+ [
+ $indexValueCallback( 1 ),
+ 1,
+ $comparisonCallback,
+ -1,
+ false,
+ ],
+ [
+ $indexValueCallback( 1 ),
+ 1,
+ $comparisonCallback,
+ 0,
+ 0,
+ ],
+ [
+ $indexValueCallback( 1 ),
+ 1,
+ $comparisonCallback,
+ 1,
+ 0,
+ ],
+ [
+ $indexValueCallback( 2 ),
+ 2,
+ $comparisonCallback,
+ -1,
+ false,
+ ],
+ [
+ $indexValueCallback( 2 ),
+ 2,
+ $comparisonCallback,
+ 0,
+ 0,
+ ],
+ [
+ $indexValueCallback( 2 ),
+ 2,
+ $comparisonCallback,
+ 0.5,
+ 0,
+ ],
+ [
+ $indexValueCallback( 2 ),
+ 2,
+ $comparisonCallback,
+ 1,
+ 1,
+ ],
+ [
+ $indexValueCallback( 2 ),
+ 2,
+ $comparisonCallback,
+ 1.5,
+ 1,
+ ],
+ [
+ $indexValueCallback( 3 ),
+ 3,
+ $comparisonCallback,
+ 1,
+ 1,
+ ],
+ [
+ $indexValueCallback( 3 ),
+ 3,
+ $comparisonCallback,
+ 1.5,
+ 1,
+ ],
+ [
+ $indexValueCallback( 3 ),
+ 3,
+ $comparisonCallback,
+ 2,
+ 2,
+ ],
+ [
+ $indexValueCallback( 3 ),
+ 3,
+ $comparisonCallback,
+ 3,
+ 2,
+ ],
+ ];
+ }
+
+ /**
+ * @covers ArrayUtils::arrayDiffAssocRecursive
+ * @dataProvider provideArrayDiffAssocRecursive
+ */
+ function testArrayDiffAssocRecursive( $expected ) {
+ $args = func_get_args();
+ array_shift( $args );
+ $this->assertEquals( call_user_func_array(
+ 'ArrayUtils::arrayDiffAssocRecursive', $args
+ ), $expected );
+ }
+
+ function provideArrayDiffAssocRecursive() {
+ return [
+ [
+ [],
+ [],
+ [],
+ ],
+ [
+ [],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 1 ],
+ [ 1 ],
+ [],
+ ],
+ [
+ [ 1 ],
+ [ 1 ],
+ [],
+ [],
+ ],
+ [
+ [],
+ [],
+ [ 1 ],
+ ],
+ [
+ [],
+ [],
+ [ 1 ],
+ [ 2 ],
+ ],
+ [
+ [ '' => 1 ],
+ [ '' => 1 ],
+ [],
+ ],
+ [
+ [],
+ [],
+ [ '' => 1 ],
+ ],
+ [
+ [ 1 ],
+ [ 1 ],
+ [ 2 ],
+ ],
+ [
+ [],
+ [ 1 ],
+ [ 2 ],
+ [ 1 ],
+ ],
+ [
+ [],
+ [ 1 ],
+ [ 1, 2 ],
+ ],
+ [
+ [ 1 => 1 ],
+ [ 1 => 1 ],
+ [ 1 ],
+ ],
+ [
+ [],
+ [ 1 => 1 ],
+ [ 1 ],
+ [ 1 => 1 ],
+ ],
+ [
+ [],
+ [ 1 => 1 ],
+ [ 1, 1, 1 ],
+ ],
+ [
+ [],
+ [ [] ],
+ [],
+ ],
+ [
+ [],
+ [ [ [] ] ],
+ [],
+ ],
+ [
+ [ 1, [ 1 ] ],
+ [ 1, [ 1 ] ],
+ [],
+ ],
+ [
+ [ 1 ],
+ [ 1, [ 1 ] ],
+ [ 2, [ 1 ] ],
+ ],
+ [
+ [],
+ [ 1, [ 1 ] ],
+ [ 2, [ 1 ] ],
+ [ 1, [ 2 ] ],
+ ],
+ [
+ [ 1 ],
+ [ 1, [] ],
+ [ 2 ],
+ ],
+ [
+ [],
+ [ 1, [] ],
+ [ 2 ],
+ [ 1 ],
+ ],
+ [
+ [ 1, [ 1 => 2 ] ],
+ [ 1, [ 1, 2 ] ],
+ [ 2, [ 1 ] ],
+ ],
+ [
+ [ 1 ],
+ [ 1, [ 1, 2 ] ],
+ [ 2, [ 1 ] ],
+ [ 2, [ 1 => 2 ] ],
+ ],
+ [
+ [ 1 => [ 1, 2 ] ],
+ [ 1, [ 1, 2 ] ],
+ [ 1, [ 2 ] ],
+ ],
+ [
+ [ 1 => [ [ 2, 3 ], 2 ] ],
+ [ 1, [ [ 2, 3 ], 2 ] ],
+ [ 1, [ 2 ] ],
+ ],
+ [
+ [ 1 => [ [ 2 ], 2 ] ],
+ [ 1, [ [ 2, 3 ], 2 ] ],
+ [ 1, [ [ 1 => 3 ] ] ],
+ ],
+ [
+ [ 1 => [ 1 => 2 ] ],
+ [ 1, [ [ 2, 3 ], 2 ] ],
+ [ 1, [ [ 1 => 3, 0 => 2 ] ] ],
+ ],
+ [
+ [ 1 => [ 1 => 2 ] ],
+ [ 1, [ [ 2, 3 ], 2 ] ],
+ [ 1, [ [ 1 => 3 ] ] ],
+ [ 1 => [ [ 2 ] ] ],
+ ],
+ [
+ [],
+ [ 1, [ [ 2, 3 ], 2 ] ],
+ [ 1 => [ 1 => 2, 0 => [ 1 => 3, 0 => 2 ] ], 0 => 1 ],
+ ],
+ [
+ [],
+ [ 1, [ [ 2, 3 ], 2 ] ],
+ [ 1 => [ 1 => 2 ] ],
+ [ 1 => [ [ 1 => 3 ] ] ],
+ [ 1 => [ [ 2 ] ] ],
+ [ 1 ],
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/CSSMinTest.php b/www/wiki/tests/phpunit/includes/libs/CSSMinTest.php
new file mode 100644
index 00000000..46bf2c6c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/CSSMinTest.php
@@ -0,0 +1,640 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group ResourceLoader
+ * @group CSSMin
+ */
+class CSSMinTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ // For wfExpandUrl
+ $server = 'https://expand.example';
+ $this->setMwGlobals( [
+ 'wgServer' => $server,
+ 'wgCanonicalServer' => $server,
+ ] );
+ }
+
+ /**
+ * @dataProvider provideSerializeStringValue
+ * @covers CSSMin::serializeStringValue
+ */
+ public function testSerializeStringValue( $input, $expected ) {
+ $output = CSSMin::serializeStringValue( $input );
+ $this->assertEquals(
+ $expected,
+ $output,
+ 'Serialized output must be in the expected form.'
+ );
+ }
+
+ public static function provideSerializeStringValue() {
+ return [
+ [ 'Hello World!', '"Hello World!"' ],
+ [ "Null\0Null", "\"Null\\fffd Null\"" ],
+ [ '"', '"\\""' ],
+ [ "'", '"\'"' ],
+ [ "\\", '"\\\\"' ],
+ [ "Tab\tTab", '"Tab\\9 Tab"' ],
+ [ "Space tab \t space", '"Space tab \\9 space"' ],
+ [ "Line\nfeed", '"Line\\a feed"' ],
+ [ "Return\rreturn", '"Return\\d return"' ],
+ [ "Next\xc2\x85line", "\"Next\xc2\x85line\"" ],
+ [ "Del\x7fDel", '"Del\\7f Del"' ],
+ [ "nb\xc2\xa0sp", "\"nb\xc2\xa0sp\"" ],
+ [ "AMP&amp;AMP", "\"AMP&amp;AMP\"" ],
+ [ '!"#$%&\'()*+,-./0123456789:;<=>?', '"!\\"#$%&\'()*+,-./0123456789:;<=>?"' ],
+ [ '@[\\]^_`{|}~', '"@[\\\\]^_`{|}~"' ],
+ [ 'ä', '"ä"' ],
+ [ 'Ä', '"Ä"' ],
+ [ '€', '"€"' ],
+ [ '𝒞', '"𝒞"' ], // U+1D49E 'MATHEMATICAL SCRIPT CAPITAL C'
+ ];
+ }
+
+ /**
+ * @dataProvider provideMimeType
+ * @covers CSSMin::getMimeType
+ */
+ public function testGetMimeType( $fileContents, $fileExtension, $expected ) {
+ $fileName = wfTempDir() . DIRECTORY_SEPARATOR . uniqid( 'MW_PHPUnit_CSSMinTest_' ) . '.'
+ . $fileExtension;
+ $this->addTmpFiles( $fileName );
+ file_put_contents( $fileName, $fileContents );
+ $this->assertSame( $expected, CSSMin::getMimeType( $fileName ) );
+ }
+
+ public static function provideMimeType() {
+ return [
+ 'JPEG with short extension' => [
+ "\xFF\xD8\xFF",
+ 'jpg',
+ 'image/jpeg'
+ ],
+ 'JPEG with long extension' => [
+ "\xFF\xD8\xFF",
+ 'jpeg',
+ 'image/jpeg'
+ ],
+ 'PNG' => [
+ "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A",
+ 'png',
+ 'image/png'
+ ],
+
+ 'PNG extension but JPEG content' => [
+ "\xFF\xD8\xFF",
+ 'png',
+ 'image/png'
+ ],
+ 'JPEG extension but PNG content' => [
+ "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A",
+ 'jpg',
+ 'image/jpeg'
+ ],
+ 'PNG extension but SVG content' => [
+ '<?xml version="1.0"?><svg></svg>',
+ 'png',
+ 'image/png'
+ ],
+ 'SVG extension but PNG content' => [
+ "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A",
+ 'svg',
+ 'image/svg+xml'
+ ],
+
+ 'SVG with all headers' => [
+ '<?xml version="1.0"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" '
+ . '"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg></svg>',
+ 'svg',
+ 'image/svg+xml'
+ ],
+ 'SVG with XML header only' => [
+ '<?xml version="1.0"?><svg></svg>',
+ 'svg',
+ 'image/svg+xml'
+ ],
+ 'SVG with DOCTYPE only' => [
+ '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" '
+ . '"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg></svg>',
+ 'svg',
+ 'image/svg+xml'
+ ],
+ 'SVG without any header' => [
+ '<svg></svg>',
+ 'svg',
+ 'image/svg+xml'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideMinifyCases
+ * @covers CSSMin::minify
+ */
+ public function testMinify( $code, $expectedOutput ) {
+ $minified = CSSMin::minify( $code );
+
+ $this->assertEquals(
+ $expectedOutput,
+ $minified,
+ 'Minified output should be in the form expected.'
+ );
+ }
+
+ public static function provideMinifyCases() {
+ return [
+ // Whitespace
+ [ "\r\t\f \v\n\r", "" ],
+ [ "foo, bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ],
+
+ // Loose comments
+ [ "/* foo */", "" ],
+ [ "/*******\n foo\n *******/", "" ],
+ [ "/*!\n foo\n */", "" ],
+
+ // Inline comments in various different places
+ [ "/* comment */foo, bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ],
+ [ "foo/* comment */, bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ],
+ [ "foo,/* comment */ bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ],
+ [ "foo, bar/* comment */ {\n\tprop: value;\n}", "foo,bar{prop:value}" ],
+ [ "foo, bar {\n\t/* comment */prop: value;\n}", "foo,bar{prop:value}" ],
+ [ "foo, bar {\n\tprop: /* comment */value;\n}", "foo,bar{prop:value}" ],
+ [ "foo, bar {\n\tprop: value /* comment */;\n}", "foo,bar{prop:value }" ],
+ [ "foo, bar {\n\tprop: value; /* comment */\n}", "foo,bar{prop:value; }" ],
+
+ // Keep track of things that aren't as minified as much as they
+ // could be (T37493)
+ [ 'foo { prop: value ;}', 'foo{prop:value }' ],
+ [ 'foo { prop : value; }', 'foo{prop :value}' ],
+ [ 'foo { prop: value ; }', 'foo{prop:value }' ],
+ [ 'foo { font-family: "foo" , "bar"; }', 'foo{font-family:"foo" ,"bar"}' ],
+ [ "foo { src:\n\turl('foo') ,\n\turl('bar') ; }", "foo{src:url('foo') ,url('bar') }" ],
+
+ // Interesting cases with string values
+ // - Double quotes, single quotes
+ [ 'foo { content: ""; }', 'foo{content:""}' ],
+ [ "foo { content: ''; }", "foo{content:''}" ],
+ [ 'foo { content: "\'"; }', 'foo{content:"\'"}' ],
+ [ "foo { content: '\"'; }", "foo{content:'\"'}" ],
+ // - Whitespace in string values
+ [ 'foo { content: " "; }', 'foo{content:" "}' ],
+
+ // Whitespaces after opening and before closing parentheses and brackets
+ [ 'a:not( [ href ] ) { prop: url( foobar.png ); }', 'a:not([href]){prop:url(foobar.png)}' ],
+
+ // Ensure that the invalid "url (" will not become the valid "url(" by minification
+ [ 'foo { prop: url ( foobar.png ); }', 'foo{prop:url (foobar.png)}' ],
+ ];
+ }
+
+ public static function provideIsRemoteUrl() {
+ return [
+ [ true, 'http://localhost/w/red.gif?123' ],
+ [ true, 'https://example.org/x.png' ],
+ [ true, '//example.org/x.y.z/image.png' ],
+ [ true, '//localhost/styles.css?query=yes' ],
+ [ true, 'data:image/gif;base64,R0lGODlhAQABAIAAAP8AADAAACwAAAAAAQABAAACAkQBADs=' ],
+ [ false, 'x.gif' ],
+ [ false, '/x.gif' ],
+ [ false, './x.gif' ],
+ [ false, '../x.gif' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsRemoteUrl
+ * @covers CSSMin::isRemoteUrl
+ */
+ public function testIsRemoteUrl( $expect, $url ) {
+ $class = TestingAccessWrapper::newFromClass( CSSMin::class );
+ $this->assertEquals( $class->isRemoteUrl( $url ), $expect );
+ }
+
+ public static function provideIsLocalUrls() {
+ return [
+ [ false, 'x.gif' ],
+ [ true, '/x.gif' ],
+ [ false, './x.gif' ],
+ [ false, '../x.gif' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsLocalUrls
+ * @covers CSSMin::isLocalUrl
+ */
+ public function testIsLocalUrl( $expect, $url ) {
+ $class = TestingAccessWrapper::newFromClass( CSSMin::class );
+ $this->assertEquals( $class->isLocalUrl( $url ), $expect );
+ }
+
+ /**
+ * This test tests funky parameters to CSSMin::remap.
+ *
+ * @see testRemapRemapping for testing of the basic functionality
+ * @dataProvider provideRemapCases
+ * @covers CSSMin::remap
+ * @covers CSSMin::remapOne
+ */
+ public function testRemap( $message, $params, $expectedOutput ) {
+ $remapped = call_user_func_array( 'CSSMin::remap', $params );
+
+ $messageAdd = " Case: $message";
+ $this->assertEquals(
+ $expectedOutput,
+ $remapped,
+ 'CSSMin::remap should return the expected url form.' . $messageAdd
+ );
+ }
+
+ public static function provideRemapCases() {
+ // Parameter signature:
+ // CSSMin::remap( $code, $local, $remote, $embedData = true )
+ return [
+ [
+ 'Simple case',
+ [ 'foo { prop: url(bar.png); }', false, 'http://example.org', false ],
+ 'foo { prop: url(http://example.org/bar.png); }',
+ ],
+ [
+ 'Without trailing slash',
+ [ 'foo { prop: url(../bar.png); }', false, 'http://example.org/quux', false ],
+ 'foo { prop: url(http://example.org/bar.png); }',
+ ],
+ [
+ 'With trailing slash on remote (T29052)',
+ [ 'foo { prop: url(../bar.png); }', false, 'http://example.org/quux/', false ],
+ 'foo { prop: url(http://example.org/bar.png); }',
+ ],
+ [
+ 'Guard against stripping double slashes from query',
+ [ 'foo { prop: url(bar.png?corge=//grault); }', false, 'http://example.org/quux/', false ],
+ 'foo { prop: url(http://example.org/quux/bar.png?corge=//grault); }',
+ ],
+ [
+ 'Expand absolute paths',
+ [ 'foo { prop: url(/w/skin/images/bar.png); }', false, 'http://example.org/quux', false ],
+ 'foo { prop: url(https://expand.example/w/skin/images/bar.png); }',
+ ],
+ [
+ "Don't barf at behavior: url(#default#behaviorName) - T162973",
+ [ 'foo { behavior: url(#default#bar); }', false, '/w/', false ],
+ 'foo { behavior: url("#default#bar"); }',
+ ],
+ ];
+ }
+
+ /**
+ * Cases with empty url() for CSSMin::remap.
+ *
+ * Regression test for T191237.
+ *
+ * @dataProvider provideRemapEmptyUrl
+ * @covers CSSMin
+ */
+ public function testRemapEmptyUrl( $params, $expected ) {
+ $remapped = call_user_func_array( 'CSSMin::remap', $params );
+ $this->assertEquals( $expected, $remapped, 'Ignore empty url' );
+ }
+
+ public static function provideRemapEmptyUrl() {
+ return [
+ 'Empty' => [
+ [ "background-image: url();", false, '/example', false ],
+ "background-image: url();",
+ ],
+ 'Single quote' => [
+ [ "background-image: url('');", false, '/example', false ],
+ "background-image: url('');",
+ ],
+ 'Double quote' => [
+ [ 'background-image: url("");', false, '/example', false ],
+ 'background-image: url("");',
+ ],
+ ];
+ }
+
+ /**
+ * This tests the basic functionality of CSSMin::remap.
+ *
+ * @see testRemap for testing of funky parameters
+ * @dataProvider provideRemapRemappingCases
+ * @covers CSSMin
+ */
+ public function testRemapRemapping( $message, $input, $expectedOutput ) {
+ $localPath = __DIR__ . '/../../data/cssmin';
+ $remotePath = 'http://localhost/w';
+
+ $realOutput = CSSMin::remap( $input, $localPath, $remotePath );
+ $this->assertEquals( $expectedOutput, $realOutput, "CSSMin::remap: $message" );
+ }
+
+ public static function provideRemapRemappingCases() {
+ // red.gif and green.gif are one-pixel 35-byte GIFs.
+ // large.png is a 35K PNG that should be non-embeddable.
+ // Full paths start with http://localhost/w/.
+ // Timestamps in output are replaced with 'timestamp'.
+
+ // data: URIs for red.gif, green.gif, circle.svg
+ $red = 'data:image/gif;base64,R0lGODlhAQABAIAAAP8AADAAACwAAAAAAQABAAACAkQBADs=';
+ $green = 'data:image/gif;base64,R0lGODlhAQABAIAAAACAADAAACwAAAAAAQABAAACAkQBADs=';
+ $svg = 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%228'
+ . '%22 height=%228%22 viewBox=%220 0 8 8%22%3E %3Ccircle cx=%224%22 cy=%224%22 '
+ . 'r=%222%22/%3E %3Ca xmlns:xlink=%22http://www.w3.org/1999/xlink%22 xlink:title='
+ . '%22%3F%3E%22%3Etest%3C/a%3E %3C/svg%3E';
+
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ 'Regular file',
+ 'foo { background: url(red.gif); }',
+ 'foo { background: url(http://localhost/w/red.gif?34ac6); }',
+ ],
+ [
+ 'Regular file (missing)',
+ 'foo { background: url(theColorOfHerHair.gif); }',
+ 'foo { background: url(http://localhost/w/theColorOfHerHair.gif); }',
+ ],
+ [
+ 'Remote URL',
+ 'foo { background: url(http://example.org/w/foo.png); }',
+ 'foo { background: url(http://example.org/w/foo.png); }',
+ ],
+ [
+ 'Protocol-relative remote URL',
+ 'foo { background: url(//example.org/w/foo.png); }',
+ 'foo { background: url(//example.org/w/foo.png); }',
+ ],
+ [
+ 'Remote URL with query',
+ 'foo { background: url(http://example.org/w/foo.png?query=yes); }',
+ 'foo { background: url(http://example.org/w/foo.png?query=yes); }',
+ ],
+ [
+ 'Protocol-relative remote URL with query',
+ 'foo { background: url(//example.org/w/foo.png?query=yes); }',
+ 'foo { background: url(//example.org/w/foo.png?query=yes); }',
+ ],
+ [
+ 'Domain-relative URL',
+ 'foo { background: url(/static/foo.png); }',
+ 'foo { background: url(https://expand.example/static/foo.png); }',
+ ],
+ [
+ 'Domain-relative URL with query',
+ 'foo { background: url(/static/foo.png?query=yes); }',
+ 'foo { background: url(https://expand.example/static/foo.png?query=yes); }',
+ ],
+ [
+ 'Remote URL (unnecessary quotes not preserved)',
+ 'foo { background: url("http://example.org/w/unnecessary-quotes.png"); }',
+ 'foo { background: url(http://example.org/w/unnecessary-quotes.png); }',
+ ],
+ [
+ 'Embedded file',
+ 'foo { /* @embed */ background: url(red.gif); }',
+ "foo { background: url($red); background: url(http://localhost/w/red.gif?34ac6)!ie; }",
+ ],
+ [
+ 'Embedded file, other comments before the rule',
+ "foo { /* Bar. */ /* @embed */ background: url(red.gif); }",
+ "foo { /* Bar. */ background: url($red); /* Bar. */ background: url(http://localhost/w/red.gif?34ac6)!ie; }",
+ ],
+ [
+ 'Can not re-embed data: URIs',
+ "foo { /* @embed */ background: url($red); }",
+ "foo { background: url($red); }",
+ ],
+ [
+ 'Can not remap data: URIs',
+ "foo { background: url($red); }",
+ "foo { background: url($red); }",
+ ],
+ [
+ 'Can not embed remote URLs',
+ 'foo { /* @embed */ background: url(http://example.org/w/foo.png); }',
+ 'foo { background: url(http://example.org/w/foo.png); }',
+ ],
+ [
+ 'Embedded file (inline @embed)',
+ 'foo { background: /* @embed */ url(red.gif); }',
+ "foo { background: url($red); "
+ . "background: url(http://localhost/w/red.gif?34ac6)!ie; }",
+ ],
+ [
+ 'Can not embed large files',
+ 'foo { /* @embed */ background: url(large.png); }',
+ "foo { background: url(http://localhost/w/large.png?e3d1f); }",
+ ],
+ [
+ 'SVG files are embedded without base64 encoding and unnecessary IE 6 and 7 fallback',
+ 'foo { /* @embed */ background: url(circle.svg); }',
+ "foo { background: url(\"$svg\"); }",
+ ],
+ [
+ 'Two regular files in one rule',
+ 'foo { background: url(red.gif), url(green.gif); }',
+ 'foo { background: url(http://localhost/w/red.gif?34ac6), '
+ . 'url(http://localhost/w/green.gif?13651); }',
+ ],
+ [
+ 'Two embedded files in one rule',
+ 'foo { /* @embed */ background: url(red.gif), url(green.gif); }',
+ "foo { background: url($red), url($green); "
+ . "background: url(http://localhost/w/red.gif?34ac6), "
+ . "url(http://localhost/w/green.gif?13651)!ie; }",
+ ],
+ [
+ 'Two embedded files in one rule (inline @embed)',
+ 'foo { background: /* @embed */ url(red.gif), /* @embed */ url(green.gif); }',
+ "foo { background: url($red), url($green); "
+ . "background: url(http://localhost/w/red.gif?34ac6), "
+ . "url(http://localhost/w/green.gif?13651)!ie; }",
+ ],
+ [
+ 'Two embedded files in one rule (inline @embed), one too large',
+ 'foo { background: /* @embed */ url(red.gif), /* @embed */ url(large.png); }',
+ "foo { background: url($red), url(http://localhost/w/large.png?e3d1f); "
+ . "background: url(http://localhost/w/red.gif?34ac6), "
+ . "url(http://localhost/w/large.png?e3d1f)!ie; }",
+ ],
+ [
+ 'Practical example with some noise',
+ 'foo { /* @embed */ background: #f9f9f9 url(red.gif) 0 0 no-repeat; }',
+ "foo { background: #f9f9f9 url($red) 0 0 no-repeat; "
+ . "background: #f9f9f9 url(http://localhost/w/red.gif?34ac6) 0 0 no-repeat!ie; }",
+ ],
+ [
+ 'Does not mess with other properties',
+ 'foo { color: red; background: url(red.gif); font-size: small; }',
+ 'foo { color: red; background: url(http://localhost/w/red.gif?34ac6); font-size: small; }',
+ ],
+ [
+ 'Spacing and miscellanea not changed (1)',
+ 'foo { background: url(red.gif); }',
+ 'foo { background: url(http://localhost/w/red.gif?34ac6); }',
+ ],
+ [
+ 'Spacing and miscellanea not changed (2)',
+ 'foo {background:url(red.gif)}',
+ 'foo {background:url(http://localhost/w/red.gif?34ac6)}',
+ ],
+ [
+ 'Spaces within url() parentheses are ignored',
+ 'foo { background: url( red.gif ); }',
+ 'foo { background: url(http://localhost/w/red.gif?34ac6); }',
+ ],
+ [
+ '@import rule to local file (should we remap this?)',
+ '@import url(/styles.css)',
+ '@import url(https://expand.example/styles.css)',
+ ],
+ [
+ '@import rule to local file (should we remap this?)',
+ '@import url(/styles.css)',
+ '@import url(https://expand.example/styles.css)',
+ ],
+ [
+ '@import rule to URL',
+ '@import url(//localhost/styles.css?query=val)',
+ '@import url(//localhost/styles.css?query=val)',
+ ],
+ [
+ 'Background URL (double quotes)',
+ 'foo { background: url("//localhost/styles.css?quoted=double") }',
+ 'foo { background: url(//localhost/styles.css?quoted=double) }',
+ ],
+ [
+ 'Background URL (single quotes)',
+ 'foo { background: url(\'//localhost/styles.css?quoted=single\') }',
+ 'foo { background: url(//localhost/styles.css?quoted=single) }',
+ ],
+ [
+ 'Background URL (double quoted, containing parentheses; T60473)',
+ 'foo { background: url("//localhost/styles.css?query=(parens)") }',
+ 'foo { background: url("//localhost/styles.css?query=(parens)") }',
+ ],
+ [
+ 'Background URL (double quoted, containing single quotes; T60473)',
+ 'foo { background: url("//localhost/styles.css?quote=\'") }',
+ 'foo { background: url("//localhost/styles.css?quote=\'") }',
+ ],
+ [
+ 'Background URL (single quoted, containing double quotes; T60473)',
+ 'foo { background: url(\'//localhost/styles.css?quote="\') }',
+ 'foo { background: url("//localhost/styles.css?quote=\"") }',
+ ],
+ [
+ 'Background URL (double quoted with outer spacing)',
+ 'foo { background: url( "http://localhost/styles.css?quoted=double" ) }',
+ 'foo { background: url(http://localhost/styles.css?quoted=double) }',
+ ],
+ [
+ 'Simple case with comments before url',
+ 'foo { prop: /* some {funny;} comment */ url(bar.png); }',
+ 'foo { prop: /* some {funny;} comment */ url(http://localhost/w/bar.png); }',
+ ],
+ [
+ 'Simple case with comments after url',
+ 'foo { prop: url(red.gif)/* some {funny;} comment */ ; }',
+ 'foo { prop: url(http://localhost/w/red.gif?34ac6)/* some {funny;} comment */ ; }',
+ ],
+ [
+ 'Embedded file with comment before url',
+ 'foo { /* @embed */ background: /* some {funny;} comment */ url(red.gif); }',
+ "foo { background: /* some {funny;} comment */ url($red); background: /* some {funny;} comment */ url(http://localhost/w/red.gif?34ac6)!ie; }",
+ ],
+ [
+ 'Embedded file with comments inside and outside the rule',
+ 'foo { /* @embed */ background: url(red.gif) /* some {foo;} comment */; /* some {bar;} comment */ }',
+ "foo { background: url($red) /* some {foo;} comment */; background: url(http://localhost/w/red.gif?34ac6) /* some {foo;} comment */!ie; /* some {bar;} comment */ }",
+ ],
+ [
+ 'Embedded file with comment outside the rule',
+ 'foo { /* @embed */ background: url(red.gif); /* some {funny;} comment */ }',
+ "foo { background: url($red); background: url(http://localhost/w/red.gif?34ac6)!ie; /* some {funny;} comment */ }",
+ ],
+ [
+ 'Rule with two urls, each with comments',
+ '{ background: /*asd*/ url(something.png); background: /*jkl*/ url(something.png); }',
+ '{ background: /*asd*/ url(http://localhost/w/something.png); background: /*jkl*/ url(http://localhost/w/something.png); }',
+ ],
+ [
+ 'Sanity check for offending line from jquery.ui.theme.css (T62077)',
+ '.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3/*{borderColorDefault}*/; background: #e6e6e6/*{bgColorDefault}*/ url(images/ui-bg_glass_75_e6e6e6_1x400.png)/*{bgImgUrlDefault}*/ 50%/*{bgDefaultXPos}*/ 50%/*{bgDefaultYPos}*/ repeat-x/*{bgDefaultRepeat}*/; font-weight: normal/*{fwDefault}*/; color: #555555/*{fcDefault}*/; }',
+ '.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3/*{borderColorDefault}*/; background: #e6e6e6/*{bgColorDefault}*/ url(http://localhost/w/images/ui-bg_glass_75_e6e6e6_1x400.png)/*{bgImgUrlDefault}*/ 50%/*{bgDefaultXPos}*/ 50%/*{bgDefaultYPos}*/ repeat-x/*{bgDefaultRepeat}*/; font-weight: normal/*{fwDefault}*/; color: #555555/*{fcDefault}*/; }',
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * This tests basic functionality of CSSMin::buildUrlValue.
+ *
+ * @dataProvider provideBuildUrlValueCases
+ * @covers CSSMin::buildUrlValue
+ */
+ public function testBuildUrlValue( $message, $input, $expectedOutput ) {
+ $this->assertEquals(
+ $expectedOutput,
+ CSSMin::buildUrlValue( $input ),
+ "CSSMin::buildUrlValue: $message"
+ );
+ }
+
+ public static function provideBuildUrlValueCases() {
+ return [
+ [
+ 'Full URL',
+ 'scheme://user@domain:port/~user/fi%20le.png?query=yes&really=y+s',
+ 'url(scheme://user@domain:port/~user/fi%20le.png?query=yes&really=y+s)',
+ ],
+ [
+ 'data: URI',
+ 'data:image/png;base64,R0lGODlh/+==',
+ 'url(data:image/png;base64,R0lGODlh/+==)',
+ ],
+ [
+ 'URL with quotes',
+ "https://en.wikipedia.org/wiki/Wendy's",
+ "url(\"https://en.wikipedia.org/wiki/Wendy's\")",
+ ],
+ [
+ 'URL with parentheses',
+ 'https://en.wikipedia.org/wiki/Boston_(band)',
+ 'url("https://en.wikipedia.org/wiki/Boston_(band)")',
+ ],
+ ];
+ }
+
+ /**
+ * Seperated because they are currently broken (T37492)
+ *
+ * @group Broken
+ * @dataProvider provideStringCases
+ * @covers CSSMin::remap
+ */
+ public function testMinifyWithCSSStringValues( $code, $expectedOutput ) {
+ $this->testMinifyOutput( $code, $expectedOutput );
+ }
+
+ public static function provideStringCases() {
+ return [
+ // String values should be respected
+ // - More than one space in a string value
+ [ 'foo { content: " "; }', 'foo{content:" "}' ],
+ // - Using a tab in a string value (turns into a space)
+ [ "foo { content: '\t'; }", "foo{content:'\t'}" ],
+ // - Using css-like syntax in string values
+ [
+ 'foo::after { content: "{;}"; position: absolute; }',
+ 'foo::after{content:"{;}";position:absolute}'
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/DeferredStringifierTest.php b/www/wiki/tests/phpunit/includes/libs/DeferredStringifierTest.php
new file mode 100644
index 00000000..c9cdf583
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/DeferredStringifierTest.php
@@ -0,0 +1,54 @@
+<?php
+
+/**
+ * @covers DeferredStringifier
+ */
+class DeferredStringifierTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @dataProvider provideToString
+ */
+ public function testToString( $params, $expected ) {
+ $class = new ReflectionClass( DeferredStringifier::class );
+ $ds = $class->newInstanceArgs( $params );
+ $this->assertEquals( $expected, (string)$ds );
+ }
+
+ public static function provideToString() {
+ return [
+ // No args
+ [
+ [
+ function () {
+ return 'foo';
+ }
+ ],
+ 'foo'
+ ],
+ // Has args
+ [
+ [
+ function ( $i ) {
+ return $i;
+ },
+ 'bar'
+ ],
+ 'bar'
+ ],
+ ];
+ }
+
+ /**
+ * Verify that the callback is not called if
+ * it is never converted to a string
+ */
+ public function testCallbackNotCalled() {
+ $ds = new DeferredStringifier( function () {
+ throw new Exception( 'This should not be reached!' );
+ } );
+ // No exception was thrown
+ $this->assertTrue( true );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php b/www/wiki/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php
new file mode 100644
index 00000000..1b3397c1
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php
@@ -0,0 +1,144 @@
+<?php
+
+/**
+ * @covers DnsSrvDiscoverer
+ */
+class DnsSrvDiscovererTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @dataProvider provideRecords
+ */
+ public function testPickServer( $params, $expected ) {
+ $discoverer = new DnsSrvDiscoverer( 'etcd-tcp.example.net' );
+ $record = $discoverer->pickServer( $params );
+
+ $this->assertEquals( $expected, $record );
+ }
+
+ public static function provideRecords() {
+ return [
+ [
+ [ // record list
+ [
+ 'target' => 'conf03.example.net',
+ 'port' => 'SRV',
+ 'pri' => 0,
+ 'weight' => 1,
+ ],
+ [
+ 'target' => 'conf02.example.net',
+ 'port' => 'SRV',
+ 'pri' => 1,
+ 'weight' => 1,
+ ],
+ [
+ 'target' => 'conf01.example.net',
+ 'port' => 'SRV',
+ 'pri' => 2,
+ 'weight' => 1,
+ ],
+ ], // selected record
+ [
+ 'target' => 'conf03.example.net',
+ 'port' => 'SRV',
+ 'pri' => 0,
+ 'weight' => 1,
+ ]
+ ],
+ [
+ [ // record list
+ [
+ 'target' => 'conf03or2.example.net',
+ 'port' => 'SRV',
+ 'pri' => 0,
+ 'weight' => 1,
+ ],
+ [
+ 'target' => 'conf03or2.example.net',
+ 'port' => 'SRV',
+ 'pri' => 0,
+ 'weight' => 1,
+ ],
+ [
+ 'target' => 'conf01.example.net',
+ 'port' => 'SRV',
+ 'pri' => 2,
+ 'weight' => 1,
+ ],
+ [
+ 'target' => 'conf04.example.net',
+ 'port' => 'SRV',
+ 'pri' => 2,
+ 'weight' => 1,
+ ],
+ [
+ 'target' => 'conf05.example.net',
+ 'port' => 'SRV',
+ 'pri' => 3,
+ 'weight' => 1,
+ ],
+ ], // selected record
+ [
+ 'target' => 'conf03or2.example.net',
+ 'port' => 'SRV',
+ 'pri' => 0,
+ 'weight' => 1,
+ ]
+ ],
+ ];
+ }
+
+ public function testRemoveServer() {
+ $dsd = new DnsSrvDiscoverer( 'localhost' );
+
+ $servers = [
+ [
+ 'target' => 'conf01.example.net',
+ 'port' => 35,
+ 'pri' => 2,
+ 'weight' => 1,
+ ],
+ [
+ 'target' => 'conf04.example.net',
+ 'port' => 74,
+ 'pri' => 2,
+ 'weight' => 1,
+ ],
+ [
+ 'target' => 'conf05.example.net',
+ 'port' => 77,
+ 'pri' => 3,
+ 'weight' => 1,
+ ],
+ ];
+ $server = $servers[1];
+
+ $expected = [
+ [
+ 'target' => 'conf01.example.net',
+ 'port' => 35,
+ 'pri' => 2,
+ 'weight' => 1,
+ ],
+ [
+ 'target' => 'conf05.example.net',
+ 'port' => 77,
+ 'pri' => 3,
+ 'weight' => 1,
+ ],
+ ];
+
+ $this->assertEquals(
+ $expected,
+ $dsd->removeServer( $server, $servers ),
+ "Correct server removed"
+ );
+ $this->assertEquals(
+ $expected,
+ $dsd->removeServer( $server, $servers ),
+ "Nothing to remove"
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/GenericArrayObjectTest.php b/www/wiki/tests/phpunit/includes/libs/GenericArrayObjectTest.php
new file mode 100644
index 00000000..3be2b064
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/GenericArrayObjectTest.php
@@ -0,0 +1,279 @@
+<?php
+
+/**
+ * Tests for the GenericArrayObject and deriving classes.
+ *
+ * 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
+ * @since 1.20
+ *
+ * @ingroup Test
+ * @group GenericArrayObject
+ *
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+abstract class GenericArrayObjectTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * Returns objects that can serve as elements in the concrete
+ * GenericArrayObject deriving class being tested.
+ *
+ * @since 1.20
+ *
+ * @return array
+ */
+ abstract public function elementInstancesProvider();
+
+ /**
+ * Returns the name of the concrete class being tested.
+ *
+ * @since 1.20
+ *
+ * @return string
+ */
+ abstract public function getInstanceClass();
+
+ /**
+ * Provides instances of the concrete class being tested.
+ *
+ * @since 1.20
+ *
+ * @return array
+ */
+ public function instanceProvider() {
+ $instances = [];
+
+ foreach ( $this->elementInstancesProvider() as $elementInstances ) {
+ $instances[] = $this->getNew( $elementInstances[0] );
+ }
+
+ return $this->arrayWrap( $instances );
+ }
+
+ /**
+ * @since 1.20
+ *
+ * @param array $elements
+ *
+ * @return GenericArrayObject
+ */
+ protected function getNew( array $elements = [] ) {
+ $class = $this->getInstanceClass();
+
+ return new $class( $elements );
+ }
+
+ /**
+ * @dataProvider elementInstancesProvider
+ *
+ * @since 1.20
+ *
+ * @param array $elements
+ *
+ * @covers GenericArrayObject::__construct
+ */
+ public function testConstructor( array $elements ) {
+ $arrayObject = $this->getNew( $elements );
+
+ $this->assertEquals( count( $elements ), $arrayObject->count() );
+ }
+
+ /**
+ * @dataProvider elementInstancesProvider
+ *
+ * @since 1.20
+ *
+ * @param array $elements
+ *
+ * @covers GenericArrayObject::isEmpty
+ */
+ public function testIsEmpty( array $elements ) {
+ $arrayObject = $this->getNew( $elements );
+
+ $this->assertEquals( $elements === [], $arrayObject->isEmpty() );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ *
+ * @since 1.20
+ *
+ * @param GenericArrayObject $list
+ *
+ * @covers GenericArrayObject::offsetUnset
+ */
+ public function testUnset( GenericArrayObject $list ) {
+ if ( $list->isEmpty() ) {
+ $this->assertTrue( true ); // We cannot test unset if there are no elements
+ } else {
+ $offset = $list->getIterator()->key();
+ $count = $list->count();
+ $list->offsetUnset( $offset );
+ $this->assertEquals( $count - 1, $list->count() );
+ }
+
+ if ( !$list->isEmpty() ) {
+ $offset = $list->getIterator()->key();
+ $count = $list->count();
+ unset( $list[$offset] );
+ $this->assertEquals( $count - 1, $list->count() );
+ }
+ }
+
+ /**
+ * @dataProvider elementInstancesProvider
+ *
+ * @since 1.20
+ *
+ * @param array $elements
+ *
+ * @covers GenericArrayObject::append
+ */
+ public function testAppend( array $elements ) {
+ $list = $this->getNew();
+
+ $listSize = count( $elements );
+
+ foreach ( $elements as $element ) {
+ $list->append( $element );
+ }
+
+ $this->assertEquals( $listSize, $list->count() );
+
+ $list = $this->getNew();
+
+ foreach ( $elements as $element ) {
+ $list[] = $element;
+ }
+
+ $this->assertEquals( $listSize, $list->count() );
+
+ $this->checkTypeChecks( function ( GenericArrayObject $list, $element ) {
+ $list->append( $element );
+ } );
+ }
+
+ /**
+ * @since 1.20
+ *
+ * @param callable $function
+ */
+ protected function checkTypeChecks( $function ) {
+ $excption = null;
+ $list = $this->getNew();
+
+ $elementClass = $list->getObjectType();
+
+ foreach ( [ 42, 'foo', [], new stdClass(), 4.2 ] as $element ) {
+ $validValid = $element instanceof $elementClass;
+
+ try {
+ call_user_func( $function, $list, $element );
+ $valid = true;
+ } catch ( InvalidArgumentException $exception ) {
+ $valid = false;
+ }
+
+ $this->assertEquals(
+ $validValid,
+ $valid,
+ 'Object of invalid type got successfully added to a GenericArrayObject'
+ );
+ }
+ }
+
+ /**
+ * @dataProvider elementInstancesProvider
+ *
+ * @since 1.20
+ *
+ * @param array $elements
+ * @covers GenericArrayObject::getObjectType
+ * @covers GenericArrayObject::offsetSet
+ */
+ public function testOffsetSet( array $elements ) {
+ if ( $elements === [] ) {
+ $this->assertTrue( true );
+
+ return;
+ }
+
+ $list = $this->getNew();
+
+ $element = reset( $elements );
+ $list->offsetSet( 42, $element );
+ $this->assertEquals( $element, $list->offsetGet( 42 ) );
+
+ $list = $this->getNew();
+
+ $element = reset( $elements );
+ $list['oHai'] = $element;
+ $this->assertEquals( $element, $list['oHai'] );
+
+ $list = $this->getNew();
+
+ $element = reset( $elements );
+ $list->offsetSet( 9001, $element );
+ $this->assertEquals( $element, $list[9001] );
+
+ $list = $this->getNew();
+
+ $element = reset( $elements );
+ $list->offsetSet( null, $element );
+ $this->assertEquals( $element, $list[0] );
+
+ $list = $this->getNew();
+ $offset = 0;
+
+ foreach ( $elements as $element ) {
+ $list->offsetSet( null, $element );
+ $this->assertEquals( $element, $list[$offset++] );
+ }
+
+ $this->assertEquals( count( $elements ), $list->count() );
+
+ $this->checkTypeChecks( function ( GenericArrayObject $list, $element ) {
+ $list->offsetSet( mt_rand(), $element );
+ } );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ *
+ * @since 1.21
+ *
+ * @param GenericArrayObject $list
+ *
+ * @covers GenericArrayObject::getSerializationData
+ * @covers GenericArrayObject::serialize
+ * @covers GenericArrayObject::unserialize
+ */
+ public function testSerialization( GenericArrayObject $list ) {
+ $serialization = serialize( $list );
+ $copy = unserialize( $serialization );
+
+ $this->assertEquals( $serialization, serialize( $copy ) );
+ $this->assertEquals( count( $list ), count( $copy ) );
+
+ $list = $list->getArrayCopy();
+ $copy = $copy->getArrayCopy();
+
+ $this->assertArrayEquals( $list, $copy, true, true );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/HashRingTest.php b/www/wiki/tests/phpunit/includes/libs/HashRingTest.php
new file mode 100644
index 00000000..ba288281
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/HashRingTest.php
@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * @group HashRing
+ */
+class HashRingTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers HashRing
+ */
+ public function testHashRing() {
+ $ring = new HashRing( [ 's1' => 1, 's2' => 1, 's3' => 2, 's4' => 2, 's5' => 2, 's6' => 3 ] );
+
+ $locations = [];
+ for ( $i = 0; $i < 20; $i++ ) {
+ $locations[ "hello$i"] = $ring->getLocation( "hello$i" );
+ }
+ $expectedLocations = [
+ "hello0" => "s5",
+ "hello1" => "s6",
+ "hello2" => "s2",
+ "hello3" => "s5",
+ "hello4" => "s6",
+ "hello5" => "s4",
+ "hello6" => "s5",
+ "hello7" => "s4",
+ "hello8" => "s5",
+ "hello9" => "s5",
+ "hello10" => "s3",
+ "hello11" => "s6",
+ "hello12" => "s1",
+ "hello13" => "s3",
+ "hello14" => "s3",
+ "hello15" => "s5",
+ "hello16" => "s4",
+ "hello17" => "s6",
+ "hello18" => "s6",
+ "hello19" => "s3"
+ ];
+
+ $this->assertEquals( $expectedLocations, $locations, 'Items placed at proper locations' );
+
+ $locations = [];
+ for ( $i = 0; $i < 5; $i++ ) {
+ $locations[ "hello$i"] = $ring->getLocations( "hello$i", 2 );
+ }
+
+ $expectedLocations = [
+ "hello0" => [ "s5", "s6" ],
+ "hello1" => [ "s6", "s4" ],
+ "hello2" => [ "s2", "s1" ],
+ "hello3" => [ "s5", "s6" ],
+ "hello4" => [ "s6", "s4" ],
+ ];
+ $this->assertEquals( $expectedLocations, $locations, 'Items placed at proper locations' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/HtmlArmorTest.php b/www/wiki/tests/phpunit/includes/libs/HtmlArmorTest.php
new file mode 100644
index 00000000..c5e87e4e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/HtmlArmorTest.php
@@ -0,0 +1,55 @@
+<?php
+
+/**
+ * @covers HtmlArmor
+ */
+class HtmlArmorTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public static function provideConstructor() {
+ return [
+ [ 'test' ],
+ [ null ],
+ [ '<em>some html!</em>' ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructor
+ */
+ public function testConstructor( $value ) {
+ $this->assertInstanceOf( HtmlArmor::class, new HtmlArmor( $value ) );
+ }
+
+ public static function provideGetHtml() {
+ return [
+ [
+ 'foobar',
+ 'foobar',
+ ],
+ [
+ '<script>alert("evil!");</script>',
+ '&lt;script&gt;alert(&quot;evil!&quot;);&lt;/script&gt;',
+ ],
+ [
+ new HtmlArmor( '<script>alert("evil!");</script>' ),
+ '<script>alert("evil!");</script>',
+ ],
+ [
+ new HtmlArmor( null ),
+ null,
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetHtml
+ */
+ public function testGetHtml( $input, $expected ) {
+ $this->assertEquals(
+ $expected,
+ HtmlArmor::getHtml( $input )
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/IEUrlExtensionTest.php b/www/wiki/tests/phpunit/includes/libs/IEUrlExtensionTest.php
new file mode 100644
index 00000000..03c7b0c0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/IEUrlExtensionTest.php
@@ -0,0 +1,209 @@
+<?php
+
+/**
+ * Tests for IEUrlExtension::findIE6Extension
+ * @todo tests below for findIE6Extension should be split into...
+ * ...a dataprovider and test method.
+ */
+class IEUrlExtensionTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testSimple() {
+ $this->assertEquals(
+ 'y',
+ IEUrlExtension::findIE6Extension( 'x.y' ),
+ 'Simple extension'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testSimpleNoExt() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( 'x' ),
+ 'No extension'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testEmpty() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( '' ),
+ 'Empty string'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testQuestionMark() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( '?' ),
+ 'Question mark only'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testExtQuestionMark() {
+ $this->assertEquals(
+ 'x',
+ IEUrlExtension::findIE6Extension( '.x?' ),
+ 'Extension then question mark'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testQuestionMarkExt() {
+ $this->assertEquals(
+ 'x',
+ IEUrlExtension::findIE6Extension( '?.x' ),
+ 'Question mark then extension'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testInvalidChar() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( '.x*' ),
+ 'Extension with invalid character'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testInvalidCharThenExtension() {
+ $this->assertEquals(
+ 'x',
+ IEUrlExtension::findIE6Extension( '*.x' ),
+ 'Invalid character followed by an extension'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testMultipleQuestionMarks() {
+ $this->assertEquals(
+ 'c',
+ IEUrlExtension::findIE6Extension( 'a?b?.c?.d?e?f' ),
+ 'Multiple question marks'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testExeException() {
+ $this->assertEquals(
+ 'd',
+ IEUrlExtension::findIE6Extension( 'a?b?.exe?.d?.e' ),
+ '.exe exception'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testExeException2() {
+ $this->assertEquals(
+ 'exe',
+ IEUrlExtension::findIE6Extension( 'a?b?.exe' ),
+ '.exe exception 2'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testHash() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( 'a#b.c' ),
+ 'Hash character preceding extension'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testHash2() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( 'a?#b.c' ),
+ 'Hash character preceding extension 2'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testDotAtEnd() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( '.' ),
+ 'Dot at end of string'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testTwoDots() {
+ $this->assertEquals(
+ 'z',
+ IEUrlExtension::findIE6Extension( 'x.y.z' ),
+ 'Two dots'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testScriptQuery() {
+ $this->assertEquals(
+ 'php',
+ IEUrlExtension::findIE6Extension( 'example.php?foo=a&bar=b' ),
+ 'Script with query'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testEscapedScriptQuery() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( 'example%2Ephp?foo=a&bar=b' ),
+ 'Script with urlencoded dot and query'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testEscapedScriptQueryDot() {
+ $this->assertEquals(
+ 'y',
+ IEUrlExtension::findIE6Extension( 'example%2Ephp?foo=a.x&bar=b.y' ),
+ 'Script with urlencoded dot and query with dot'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/IPTest.php b/www/wiki/tests/phpunit/includes/libs/IPTest.php
new file mode 100644
index 00000000..9702c82c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/IPTest.php
@@ -0,0 +1,672 @@
+<?php
+/**
+ * Tests for IP validity functions.
+ *
+ * Ported from /t/inc/IP.t by avar.
+ *
+ * @group IP
+ * @todo Test methods in this call should be split into a method and a
+ * dataprovider.
+ */
+class IPTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers IP::isIPAddress
+ * @dataProvider provideInvalidIPs
+ */
+ public function testIsNotIPAddress( $val, $desc ) {
+ $this->assertFalse( IP::isIPAddress( $val ), $desc );
+ }
+
+ /**
+ * Provide a list of things that aren't IP addresses
+ */
+ public function provideInvalidIPs() {
+ return [
+ [ false, 'Boolean false is not an IP' ],
+ [ true, 'Boolean true is not an IP' ],
+ [ '', 'Empty string is not an IP' ],
+ [ 'abc', 'Garbage IP string' ],
+ [ ':', 'Single ":" is not an IP' ],
+ [ '2001:0DB8::A:1::1', 'IPv6 with a double :: occurrence' ],
+ [ '2001:0DB8::A:1::', 'IPv6 with a double :: occurrence, last at end' ],
+ [ '::2001:0DB8::5:1', 'IPv6 with a double :: occurrence, firt at beginning' ],
+ [ '124.24.52', 'IPv4 not enough quads' ],
+ [ '24.324.52.13', 'IPv4 out of range' ],
+ [ '.24.52.13', 'IPv4 starts with period' ],
+ [ 'fc:100:300', 'IPv6 with only 3 words' ],
+ ];
+ }
+
+ /**
+ * @covers IP::isIPAddress
+ */
+ public function testisIPAddress() {
+ $this->assertTrue( IP::isIPAddress( '::' ), 'RFC 4291 IPv6 Unspecified Address' );
+ $this->assertTrue( IP::isIPAddress( '::1' ), 'RFC 4291 IPv6 Loopback Address' );
+ $this->assertTrue( IP::isIPAddress( '74.24.52.13/20' ), 'IPv4 range' );
+ $this->assertTrue( IP::isIPAddress( 'fc:100:a:d:1:e:ac:0/24' ), 'IPv6 range' );
+ $this->assertTrue( IP::isIPAddress( 'fc::100:a:d:1:e:ac/96' ), 'IPv6 range with "::"' );
+
+ $validIPs = [ 'fc:100::', 'fc:100:a:d:1:e:ac::', 'fc::100', '::fc:100:a:d:1:e:ac',
+ '::fc', 'fc::100:a:d:1:e:ac', 'fc:100:a:d:1:e:ac:0', '124.24.52.13', '1.24.52.13' ];
+ foreach ( $validIPs as $ip ) {
+ $this->assertTrue( IP::isIPAddress( $ip ), "$ip is a valid IP address" );
+ }
+ }
+
+ /**
+ * @covers IP::isIPv6
+ */
+ public function testisIPv6() {
+ $this->assertFalse( IP::isIPv6( ':fc:100::' ), 'IPv6 starting with lone ":"' );
+ $this->assertFalse( IP::isIPv6( 'fc:100:::' ), 'IPv6 ending with a ":::"' );
+ $this->assertFalse( IP::isIPv6( 'fc:300' ), 'IPv6 with only 2 words' );
+ $this->assertFalse( IP::isIPv6( 'fc:100:300' ), 'IPv6 with only 3 words' );
+
+ $this->assertTrue( IP::isIPv6( 'fc:100::' ) );
+ $this->assertTrue( IP::isIPv6( 'fc:100:a::' ) );
+ $this->assertTrue( IP::isIPv6( 'fc:100:a:d::' ) );
+ $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1::' ) );
+ $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e::' ) );
+ $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e:ac::' ) );
+
+ $this->assertFalse( IP::isIPv6( 'fc:100:a:d:1:e:ac:0::' ), 'IPv6 with 8 words ending with "::"' );
+ $this->assertFalse(
+ IP::isIPv6( 'fc:100:a:d:1:e:ac:0:1::' ),
+ 'IPv6 with 9 words ending with "::"'
+ );
+
+ $this->assertFalse( IP::isIPv6( ':::' ) );
+ $this->assertFalse( IP::isIPv6( '::0:' ), 'IPv6 ending in a lone ":"' );
+
+ $this->assertTrue( IP::isIPv6( '::' ), 'IPv6 zero address' );
+ $this->assertTrue( IP::isIPv6( '::0' ) );
+ $this->assertTrue( IP::isIPv6( '::fc' ) );
+ $this->assertTrue( IP::isIPv6( '::fc:100' ) );
+ $this->assertTrue( IP::isIPv6( '::fc:100:a' ) );
+ $this->assertTrue( IP::isIPv6( '::fc:100:a:d' ) );
+ $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1' ) );
+ $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1:e' ) );
+ $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1:e:ac' ) );
+
+ $this->assertFalse( IP::isIPv6( '::fc:100:a:d:1:e:ac:0' ), 'IPv6 with "::" and 8 words' );
+ $this->assertFalse( IP::isIPv6( '::fc:100:a:d:1:e:ac:0:1' ), 'IPv6 with 9 words' );
+
+ $this->assertFalse( IP::isIPv6( ':fc::100' ), 'IPv6 starting with lone ":"' );
+ $this->assertFalse( IP::isIPv6( 'fc::100:' ), 'IPv6 ending with lone ":"' );
+ $this->assertFalse( IP::isIPv6( 'fc:::100' ), 'IPv6 with ":::" in the middle' );
+
+ $this->assertTrue( IP::isIPv6( 'fc::100' ), 'IPv6 with "::" and 2 words' );
+ $this->assertTrue( IP::isIPv6( 'fc::100:a' ), 'IPv6 with "::" and 3 words' );
+ $this->assertTrue( IP::isIPv6( 'fc::100:a:d' ), 'IPv6 with "::" and 4 words' );
+ $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1' ), 'IPv6 with "::" and 5 words' );
+ $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1:e' ), 'IPv6 with "::" and 6 words' );
+ $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1:e:ac' ), 'IPv6 with "::" and 7 words' );
+ $this->assertTrue( IP::isIPv6( '2001::df' ), 'IPv6 with "::" and 2 words' );
+ $this->assertTrue( IP::isIPv6( '2001:5c0:1400:a::df' ), 'IPv6 with "::" and 5 words' );
+ $this->assertTrue( IP::isIPv6( '2001:5c0:1400:a::df:2' ), 'IPv6 with "::" and 6 words' );
+
+ $this->assertFalse( IP::isIPv6( 'fc::100:a:d:1:e:ac:0' ), 'IPv6 with "::" and 8 words' );
+ $this->assertFalse( IP::isIPv6( 'fc::100:a:d:1:e:ac:0:1' ), 'IPv6 with 9 words' );
+
+ $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e:ac:0' ) );
+ }
+
+ /**
+ * @covers IP::isIPv4
+ * @dataProvider provideInvalidIPv4Addresses
+ */
+ public function testisNotIPv4( $bogusIP, $desc ) {
+ $this->assertFalse( IP::isIPv4( $bogusIP ), $desc );
+ }
+
+ public function provideInvalidIPv4Addresses() {
+ return [
+ [ false, 'Boolean false is not an IP' ],
+ [ true, 'Boolean true is not an IP' ],
+ [ '', 'Empty string is not an IP' ],
+ [ 'abc', 'Letters are not an IP' ],
+ [ ':', 'A colon is not an IP' ],
+ [ '124.24.52', 'IPv4 not enough quads' ],
+ [ '24.324.52.13', 'IPv4 out of range' ],
+ [ '.24.52.13', 'IPv4 starts with period' ],
+ ];
+ }
+
+ /**
+ * @covers IP::isIPv4
+ * @dataProvider provideValidIPv4Address
+ */
+ public function testIsIPv4( $ip, $desc ) {
+ $this->assertTrue( IP::isIPv4( $ip ), $desc );
+ }
+
+ /**
+ * Provide some IPv4 addresses and ranges
+ */
+ public function provideValidIPv4Address() {
+ return [
+ [ '124.24.52.13', 'Valid IPv4 address' ],
+ [ '1.24.52.13', 'Another valid IPv4 address' ],
+ [ '74.24.52.13/20', 'An IPv4 range' ],
+ ];
+ }
+
+ /**
+ * @covers IP::isValid
+ */
+ public function testValidIPs() {
+ foreach ( range( 0, 255 ) as $i ) {
+ $a = sprintf( "%03d", $i );
+ $b = sprintf( "%02d", $i );
+ $c = sprintf( "%01d", $i );
+ foreach ( array_unique( [ $a, $b, $c ] ) as $f ) {
+ $ip = "$f.$f.$f.$f";
+ $this->assertTrue( IP::isValid( $ip ), "$ip is a valid IPv4 address" );
+ }
+ }
+ foreach ( range( 0x0, 0xFFFF, 0xF ) as $i ) {
+ $a = sprintf( "%04x", $i );
+ $b = sprintf( "%03x", $i );
+ $c = sprintf( "%02x", $i );
+ foreach ( array_unique( [ $a, $b, $c ] ) as $f ) {
+ $ip = "$f:$f:$f:$f:$f:$f:$f:$f";
+ $this->assertTrue( IP::isValid( $ip ), "$ip is a valid IPv6 address" );
+ }
+ }
+ // test with some abbreviations
+ $this->assertFalse( IP::isValid( ':fc:100::' ), 'IPv6 starting with lone ":"' );
+ $this->assertFalse( IP::isValid( 'fc:100:::' ), 'IPv6 ending with a ":::"' );
+ $this->assertFalse( IP::isValid( 'fc:300' ), 'IPv6 with only 2 words' );
+ $this->assertFalse( IP::isValid( 'fc:100:300' ), 'IPv6 with only 3 words' );
+
+ $this->assertTrue( IP::isValid( 'fc:100::' ) );
+ $this->assertTrue( IP::isValid( 'fc:100:a:d:1:e::' ) );
+ $this->assertTrue( IP::isValid( 'fc:100:a:d:1:e:ac::' ) );
+
+ $this->assertTrue( IP::isValid( 'fc::100' ), 'IPv6 with "::" and 2 words' );
+ $this->assertTrue( IP::isValid( 'fc::100:a' ), 'IPv6 with "::" and 3 words' );
+ $this->assertTrue( IP::isValid( '2001::df' ), 'IPv6 with "::" and 2 words' );
+ $this->assertTrue( IP::isValid( '2001:5c0:1400:a::df' ), 'IPv6 with "::" and 5 words' );
+ $this->assertTrue( IP::isValid( '2001:5c0:1400:a::df:2' ), 'IPv6 with "::" and 6 words' );
+ $this->assertTrue( IP::isValid( 'fc::100:a:d:1' ), 'IPv6 with "::" and 5 words' );
+ $this->assertTrue( IP::isValid( 'fc::100:a:d:1:e:ac' ), 'IPv6 with "::" and 7 words' );
+
+ $this->assertFalse(
+ IP::isValid( 'fc:100:a:d:1:e:ac:0::' ),
+ 'IPv6 with 8 words ending with "::"'
+ );
+ $this->assertFalse(
+ IP::isValid( 'fc:100:a:d:1:e:ac:0:1::' ),
+ 'IPv6 with 9 words ending with "::"'
+ );
+ }
+
+ /**
+ * @covers IP::isValid
+ */
+ public function testInvalidIPs() {
+ // Out of range...
+ foreach ( range( 256, 999 ) as $i ) {
+ $a = sprintf( "%03d", $i );
+ $b = sprintf( "%02d", $i );
+ $c = sprintf( "%01d", $i );
+ foreach ( array_unique( [ $a, $b, $c ] ) as $f ) {
+ $ip = "$f.$f.$f.$f";
+ $this->assertFalse( IP::isValid( $ip ), "$ip is not a valid IPv4 address" );
+ }
+ }
+ foreach ( range( 'g', 'z' ) as $i ) {
+ $a = sprintf( "%04s", $i );
+ $b = sprintf( "%03s", $i );
+ $c = sprintf( "%02s", $i );
+ foreach ( array_unique( [ $a, $b, $c ] ) as $f ) {
+ $ip = "$f:$f:$f:$f:$f:$f:$f:$f";
+ $this->assertFalse( IP::isValid( $ip ), "$ip is not a valid IPv6 address" );
+ }
+ }
+ // Have CIDR
+ $ipCIDRs = [
+ '212.35.31.121/32',
+ '212.35.31.121/18',
+ '212.35.31.121/24',
+ '::ff:d:321:5/96',
+ 'ff::d3:321:5/116',
+ 'c:ff:12:1:ea:d:321:5/120',
+ ];
+ foreach ( $ipCIDRs as $i ) {
+ $this->assertFalse( IP::isValid( $i ),
+ "$i is an invalid IP address because it is a range" );
+ }
+ // Incomplete/garbage
+ $invalid = [
+ 'www.xn--var-xla.net',
+ '216.17.184.G',
+ '216.17.184.1.',
+ '216.17.184',
+ '216.17.184.',
+ '256.17.184.1'
+ ];
+ foreach ( $invalid as $i ) {
+ $this->assertFalse( IP::isValid( $i ), "$i is an invalid IP address" );
+ }
+ }
+
+ /**
+ * Provide some valid IP ranges
+ */
+ public function provideValidRanges() {
+ return [
+ [ '116.17.184.5/32' ],
+ [ '0.17.184.5/30' ],
+ [ '16.17.184.1/24' ],
+ [ '30.242.52.14/1' ],
+ [ '10.232.52.13/8' ],
+ [ '30.242.52.14/0' ],
+ [ '::e:f:2001/96' ],
+ [ '::c:f:2001/128' ],
+ [ '::10:f:2001/70' ],
+ [ '::fe:f:2001/1' ],
+ [ '::6d:f:2001/8' ],
+ [ '::fe:f:2001/0' ],
+ ];
+ }
+
+ /**
+ * @covers IP::isValidRange
+ * @dataProvider provideValidRanges
+ */
+ public function testValidRanges( $range ) {
+ $this->assertTrue( IP::isValidRange( $range ), "$range is a valid IP range" );
+ }
+
+ /**
+ * @covers IP::isValidRange
+ * @dataProvider provideInvalidRanges
+ */
+ public function testInvalidRanges( $invalid ) {
+ $this->assertFalse( IP::isValidRange( $invalid ), "$invalid is not a valid IP range" );
+ }
+
+ public function provideInvalidRanges() {
+ return [
+ [ '116.17.184.5/33' ],
+ [ '0.17.184.5/130' ],
+ [ '16.17.184.1/-1' ],
+ [ '10.232.52.13/*' ],
+ [ '7.232.52.13/ab' ],
+ [ '11.232.52.13/' ],
+ [ '::e:f:2001/129' ],
+ [ '::c:f:2001/228' ],
+ [ '::10:f:2001/-1' ],
+ [ '::6d:f:2001/*' ],
+ [ '::86:f:2001/ab' ],
+ [ '::23:f:2001/' ],
+ ];
+ }
+
+ /**
+ * @covers IP::sanitizeIP
+ * @dataProvider provideSanitizeIP
+ */
+ public function testSanitizeIP( $expected, $input ) {
+ $result = IP::sanitizeIP( $input );
+ $this->assertEquals( $expected, $result );
+ }
+
+ /**
+ * Provider for IP::testSanitizeIP()
+ */
+ public static function provideSanitizeIP() {
+ return [
+ [ '0.0.0.0', '0.0.0.0' ],
+ [ '0.0.0.0', '00.00.00.00' ],
+ [ '0.0.0.0', '000.000.000.000' ],
+ [ '141.0.11.253', '141.000.011.253' ],
+ [ '1.2.4.5', '1.2.4.5' ],
+ [ '1.2.4.5', '01.02.04.05' ],
+ [ '1.2.4.5', '001.002.004.005' ],
+ [ '10.0.0.1', '010.0.000.1' ],
+ [ '80.72.250.4', '080.072.250.04' ],
+ [ 'Foo.1000.00', 'Foo.1000.00' ],
+ [ 'Bar.01', 'Bar.01' ],
+ [ 'Bar.010', 'Bar.010' ],
+ [ null, '' ],
+ [ null, ' ' ]
+ ];
+ }
+
+ /**
+ * @covers IP::toHex
+ * @dataProvider provideToHex
+ */
+ public function testToHex( $expected, $input ) {
+ $result = IP::toHex( $input );
+ $this->assertTrue( $result === false || is_string( $result ) );
+ $this->assertEquals( $expected, $result );
+ }
+
+ /**
+ * Provider for IP::testToHex()
+ */
+ public static function provideToHex() {
+ return [
+ [ '00000001', '0.0.0.1' ],
+ [ '01020304', '1.2.3.4' ],
+ [ '7F000001', '127.0.0.1' ],
+ [ '80000000', '128.0.0.0' ],
+ [ 'DEADCAFE', '222.173.202.254' ],
+ [ 'FFFFFFFF', '255.255.255.255' ],
+ [ '8D000BFD', '141.000.11.253' ],
+ [ false, 'IN.VA.LI.D' ],
+ [ 'v6-00000000000000000000000000000001', '::1' ],
+ [ 'v6-20010DB885A3000000008A2E03707334', '2001:0db8:85a3:0000:0000:8a2e:0370:7334' ],
+ [ 'v6-20010DB885A3000000008A2E03707334', '2001:db8:85a3::8a2e:0370:7334' ],
+ [ false, 'IN:VA::LI:D' ],
+ [ false, ':::1' ]
+ ];
+ }
+
+ /**
+ * @covers IP::isPublic
+ * @dataProvider provideIsPublic
+ */
+ public function testIsPublic( $expected, $input ) {
+ $result = IP::isPublic( $input );
+ $this->assertEquals( $expected, $result );
+ }
+
+ /**
+ * Provider for IP::testIsPublic()
+ */
+ public static function provideIsPublic() {
+ return [
+ [ false, 'fc00::3' ], # RFC 4193 (local)
+ [ false, 'fc00::ff' ], # RFC 4193 (local)
+ [ false, '127.1.2.3' ], # loopback
+ [ false, '::1' ], # loopback
+ [ false, 'fe80::1' ], # link-local
+ [ false, '169.254.1.1' ], # link-local
+ [ false, '10.0.0.1' ], # RFC 1918 (private)
+ [ false, '172.16.0.1' ], # RFC 1918 (private)
+ [ false, '192.168.0.1' ], # RFC 1918 (private)
+ [ true, '2001:5c0:1000:a::133' ], # public
+ [ true, 'fc::3' ], # public
+ [ true, '00FC::' ] # public
+ ];
+ }
+
+ // Private wrapper used to test CIDR Parsing.
+ private function assertFalseCIDR( $CIDR, $msg = '' ) {
+ $ff = [ false, false ];
+ $this->assertEquals( $ff, IP::parseCIDR( $CIDR ), $msg );
+ }
+
+ // Private wrapper to test network shifting using only dot notation
+ private function assertNet( $expected, $CIDR ) {
+ $parse = IP::parseCIDR( $CIDR );
+ $this->assertEquals( $expected, long2ip( $parse[0] ), "network shifting $CIDR" );
+ }
+
+ /**
+ * @covers IP::hexToQuad
+ * @dataProvider provideIPsAndHexes
+ */
+ public function testHexToQuad( $ip, $hex ) {
+ $this->assertEquals( $ip, IP::hexToQuad( $hex ) );
+ }
+
+ /**
+ * Provide some IP addresses and their equivalent hex representations
+ */
+ public function provideIPsandHexes() {
+ return [
+ [ '0.0.0.1', '00000001' ],
+ [ '255.0.0.0', 'FF000000' ],
+ [ '255.255.255.255', 'FFFFFFFF' ],
+ [ '10.188.222.255', '0ABCDEFF' ],
+ // hex not left-padded...
+ [ '0.0.0.0', '0' ],
+ [ '0.0.0.1', '1' ],
+ [ '0.0.0.255', 'FF' ],
+ [ '0.0.255.0', 'FF00' ],
+ ];
+ }
+
+ /**
+ * @covers IP::hexToOctet
+ * @dataProvider provideOctetsAndHexes
+ */
+ public function testHexToOctet( $octet, $hex ) {
+ $this->assertEquals( $octet, IP::hexToOctet( $hex ) );
+ }
+
+ /**
+ * Provide some hex and octet representations of the same IPs
+ */
+ public function provideOctetsAndHexes() {
+ return [
+ [ '0:0:0:0:0:0:0:1', '00000000000000000000000000000001' ],
+ [ '0:0:0:0:0:0:FF:3', '00000000000000000000000000FF0003' ],
+ [ '0:0:0:0:0:0:FF00:6', '000000000000000000000000FF000006' ],
+ [ '0:0:0:0:0:0:FCCF:FAFF', '000000000000000000000000FCCFFAFF' ],
+ [ 'FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' ],
+ // hex not left-padded...
+ [ '0:0:0:0:0:0:0:0', '0' ],
+ [ '0:0:0:0:0:0:0:1', '1' ],
+ [ '0:0:0:0:0:0:0:FF', 'FF' ],
+ [ '0:0:0:0:0:0:0:FFD0', 'FFD0' ],
+ [ '0:0:0:0:0:0:FA00:0', 'FA000000' ],
+ [ '0:0:0:0:0:0:FCCF:FAFF', 'FCCFFAFF' ],
+ ];
+ }
+
+ /**
+ * IP::parseCIDR() returns an array containing a signed IP address
+ * representing the network mask and the bit mask.
+ * @covers IP::parseCIDR
+ */
+ public function testCIDRParsing() {
+ $this->assertFalseCIDR( '192.0.2.0', "missing mask" );
+ $this->assertFalseCIDR( '192.0.2.0/', "missing bitmask" );
+
+ // Verify if statement
+ $this->assertFalseCIDR( '256.0.0.0/32', "invalid net" );
+ $this->assertFalseCIDR( '192.0.2.0/AA', "mask not numeric" );
+ $this->assertFalseCIDR( '192.0.2.0/-1', "mask < 0" );
+ $this->assertFalseCIDR( '192.0.2.0/33', "mask > 32" );
+
+ // Check internal logic
+ # 0 mask always result in array(0,0)
+ $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '192.0.0.2/0' ) );
+ $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '0.0.0.0/0' ) );
+ $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '255.255.255.255/0' ) );
+
+ // @todo FIXME: Add more tests.
+
+ # This part test network shifting
+ $this->assertNet( '192.0.0.0', '192.0.0.2/24' );
+ $this->assertNet( '192.168.5.0', '192.168.5.13/24' );
+ $this->assertNet( '10.0.0.160', '10.0.0.161/28' );
+ $this->assertNet( '10.0.0.0', '10.0.0.3/28' );
+ $this->assertNet( '10.0.0.0', '10.0.0.3/30' );
+ $this->assertNet( '10.0.0.4', '10.0.0.4/30' );
+ $this->assertNet( '172.17.32.0', '172.17.35.48/21' );
+ $this->assertNet( '10.128.0.0', '10.135.0.0/9' );
+ $this->assertNet( '134.0.0.0', '134.0.5.1/8' );
+ }
+
+ /**
+ * @covers IP::canonicalize
+ */
+ public function testIPCanonicalizeOnValidIp() {
+ $this->assertEquals( '192.0.2.152', IP::canonicalize( '192.0.2.152' ),
+ 'Canonicalization of a valid IP returns it unchanged' );
+ }
+
+ /**
+ * @covers IP::canonicalize
+ */
+ public function testIPCanonicalizeMappedAddress() {
+ $this->assertEquals(
+ '192.0.2.152',
+ IP::canonicalize( '::ffff:192.0.2.152' )
+ );
+ $this->assertEquals(
+ '192.0.2.152',
+ IP::canonicalize( '::192.0.2.152' )
+ );
+ }
+
+ /**
+ * Issues there are most probably from IP::toHex() or IP::parseRange()
+ * @covers IP::isInRange
+ * @dataProvider provideIPsAndRanges
+ */
+ public function testIPIsInRange( $expected, $addr, $range, $message = '' ) {
+ $this->assertEquals(
+ $expected,
+ IP::isInRange( $addr, $range ),
+ $message
+ );
+ }
+
+ /** Provider for testIPIsInRange() */
+ public static function provideIPsAndRanges() {
+ # Format: (expected boolean, address, range, optional message)
+ return [
+ # IPv4
+ [ true, '192.0.2.0', '192.0.2.0/24', 'Network address' ],
+ [ true, '192.0.2.77', '192.0.2.0/24', 'Simple address' ],
+ [ true, '192.0.2.255', '192.0.2.0/24', 'Broadcast address' ],
+
+ [ false, '0.0.0.0', '192.0.2.0/24' ],
+ [ false, '255.255.255', '192.0.2.0/24' ],
+
+ # IPv6
+ [ false, '::1', '2001:DB8::/32' ],
+ [ false, '::', '2001:DB8::/32' ],
+ [ false, 'FE80::1', '2001:DB8::/32' ],
+
+ [ true, '2001:DB8::', '2001:DB8::/32' ],
+ [ true, '2001:0DB8::', '2001:DB8::/32' ],
+ [ true, '2001:DB8::1', '2001:DB8::/32' ],
+ [ true, '2001:0DB8::1', '2001:DB8::/32' ],
+ [ true, '2001:0DB8:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF',
+ '2001:DB8::/32' ],
+
+ [ false, '2001:0DB8:F::', '2001:DB8::/96' ],
+ ];
+ }
+
+ /**
+ * @covers IP::splitHostAndPort()
+ * @dataProvider provideSplitHostAndPort
+ */
+ public function testSplitHostAndPort( $expected, $input, $description ) {
+ $this->assertEquals( $expected, IP::splitHostAndPort( $input ), $description );
+ }
+
+ /**
+ * Provider for IP::splitHostAndPort()
+ */
+ public static function provideSplitHostAndPort() {
+ return [
+ [ false, '[', 'Unclosed square bracket' ],
+ [ false, '[::', 'Unclosed square bracket 2' ],
+ [ [ '::', false ], '::', 'Bare IPv6 0' ],
+ [ [ '::1', false ], '::1', 'Bare IPv6 1' ],
+ [ [ '::', false ], '[::]', 'Bracketed IPv6 0' ],
+ [ [ '::1', false ], '[::1]', 'Bracketed IPv6 1' ],
+ [ [ '::1', 80 ], '[::1]:80', 'Bracketed IPv6 with port' ],
+ [ false, '::x', 'Double colon but no IPv6' ],
+ [ [ 'x', 80 ], 'x:80', 'Hostname and port' ],
+ [ false, 'x:x', 'Hostname and invalid port' ],
+ [ [ 'x', false ], 'x', 'Plain hostname' ]
+ ];
+ }
+
+ /**
+ * @covers IP::combineHostAndPort()
+ * @dataProvider provideCombineHostAndPort
+ */
+ public function testCombineHostAndPort( $expected, $input, $description ) {
+ list( $host, $port, $defaultPort ) = $input;
+ $this->assertEquals(
+ $expected,
+ IP::combineHostAndPort( $host, $port, $defaultPort ),
+ $description );
+ }
+
+ /**
+ * Provider for IP::combineHostAndPort()
+ */
+ public static function provideCombineHostAndPort() {
+ return [
+ [ '[::1]', [ '::1', 2, 2 ], 'IPv6 default port' ],
+ [ '[::1]:2', [ '::1', 2, 3 ], 'IPv6 non-default port' ],
+ [ 'x', [ 'x', 2, 2 ], 'Normal default port' ],
+ [ 'x:2', [ 'x', 2, 3 ], 'Normal non-default port' ],
+ ];
+ }
+
+ /**
+ * @covers IP::sanitizeRange()
+ * @dataProvider provideIPCIDRs
+ */
+ public function testSanitizeRange( $input, $expected, $description ) {
+ $this->assertEquals( $expected, IP::sanitizeRange( $input ), $description );
+ }
+
+ /**
+ * Provider for IP::testSanitizeRange()
+ */
+ public static function provideIPCIDRs() {
+ return [
+ [ '35.56.31.252/16', '35.56.0.0/16', 'IPv4 range' ],
+ [ '135.16.21.252/24', '135.16.21.0/24', 'IPv4 range' ],
+ [ '5.36.71.252/32', '5.36.71.252/32', 'IPv4 silly range' ],
+ [ '5.36.71.252', '5.36.71.252', 'IPv4 non-range' ],
+ [ '0:1:2:3:4:c5:f6:7/96', '0:1:2:3:4:C5:0:0/96', 'IPv6 range' ],
+ [ '0:1:2:3:4:5:6:7/120', '0:1:2:3:4:5:6:0/120', 'IPv6 range' ],
+ [ '0:e1:2:3:4:5:e6:7/128', '0:E1:2:3:4:5:E6:7/128', 'IPv6 silly range' ],
+ [ '0:c1:A2:3:4:5:c6:7', '0:C1:A2:3:4:5:C6:7', 'IPv6 non range' ],
+ ];
+ }
+
+ /**
+ * @covers IP::prettifyIP()
+ * @dataProvider provideIPsToPrettify
+ */
+ public function testPrettifyIP( $ip, $prettified ) {
+ $this->assertEquals( $prettified, IP::prettifyIP( $ip ), "Prettify of $ip" );
+ }
+
+ /**
+ * Provider for IP::testPrettifyIP()
+ */
+ public static function provideIPsToPrettify() {
+ return [
+ [ '0:0:0:0:0:0:0:0', '::' ],
+ [ '0:0:0::0:0:0', '::' ],
+ [ '0:0:0:1:0:0:0:0', '0:0:0:1::' ],
+ [ '0:0::f', '::f' ],
+ [ '0::0:0:0:33:fef:b', '::33:fef:b' ],
+ [ '3f:535:0:0:0:0:e:fbb', '3f:535::e:fbb' ],
+ [ '0:0:fef:0:0:0:e:fbb', '0:0:fef::e:fbb' ],
+ [ 'abbc:2004::0:0:0:0', 'abbc:2004::' ],
+ [ 'cebc:2004:f:0:0:0:0:0', 'cebc:2004:f::' ],
+ [ '0:0:0:0:0:0:0:0/16', '::/16' ],
+ [ '0:0:0::0:0:0/64', '::/64' ],
+ [ '0:0::f/52', '::f/52' ],
+ [ '::0:0:33:fef:b/52', '::33:fef:b/52' ],
+ [ '3f:535:0:0:0:0:e:fbb/48', '3f:535::e:fbb/48' ],
+ [ '0:0:fef:0:0:0:e:fbb/96', '0:0:fef::e:fbb/96' ],
+ [ 'abbc:2004:0:0::0:0/40', 'abbc:2004::/40' ],
+ [ 'aebc:2004:f:0:0:0:0:0/80', 'aebc:2004:f::/80' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/JavaScriptMinifierTest.php b/www/wiki/tests/phpunit/includes/libs/JavaScriptMinifierTest.php
new file mode 100644
index 00000000..61056784
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/JavaScriptMinifierTest.php
@@ -0,0 +1,242 @@
+<?php
+
+class JavaScriptMinifierTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public static function provideCases() {
+ return [
+
+ // Basic whitespace and comments that should be stripped entirely
+ [ "\r\t\f \v\n\r", "" ],
+ [ "/* Foo *\n*bar\n*/", "" ],
+
+ /**
+ * Slashes used inside block comments (T28931).
+ * At some point there was a bug that caused this comment to be ended at '* /',
+ * causing /M... to be left as the beginning of a regex.
+ */
+ [
+ "/**\n * Foo\n * {\n * 'bar' : {\n * "
+ . "//Multiple rules with configurable operators\n * 'baz' : false\n * }\n */",
+ "" ],
+
+ /**
+ * ' Foo \' bar \
+ * baz \' quox ' .
+ */
+ [
+ "' Foo \\' bar \\\n baz \\' quox ' .length",
+ "' Foo \\' bar \\\n baz \\' quox '.length"
+ ],
+ [
+ "\" Foo \\\" bar \\\n baz \\\" quox \" .length",
+ "\" Foo \\\" bar \\\n baz \\\" quox \".length"
+ ],
+ [ "// Foo b/ar baz", "" ],
+ [
+ "/ Foo \\/ bar [ / \\] / ] baz / .length",
+ "/ Foo \\/ bar [ / \\] / ] baz /.length"
+ ],
+
+ // HTML comments
+ [ "<!-- Foo bar", "" ],
+ [ "<!-- Foo --> bar", "" ],
+ [ "--> Foo", "" ],
+ [ "x --> y", "x-->y" ],
+
+ // Semicolon insertion
+ [ "(function(){return\nx;})", "(function(){return\nx;})" ],
+ [ "throw\nx;", "throw\nx;" ],
+ [ "while(p){continue\nx;}", "while(p){continue\nx;}" ],
+ [ "while(p){break\nx;}", "while(p){break\nx;}" ],
+ [ "var\nx;", "var x;" ],
+ [ "x\ny;", "x\ny;" ],
+ [ "x\n++y;", "x\n++y;" ],
+ [ "x\n!y;", "x\n!y;" ],
+ [ "x\n{y}", "x\n{y}" ],
+ [ "x\n+y;", "x+y;" ],
+ [ "x\n(y);", "x(y);" ],
+ [ "5.\nx;", "5.\nx;" ],
+ [ "0xFF.\nx;", "0xFF.x;" ],
+ [ "5.3.\nx;", "5.3.x;" ],
+
+ // Cover failure case for incomplete hex literal
+ [ "0x;", false, false ],
+
+ // Cover failure case for number with no digits after E
+ [ "1.4E", false, false ],
+
+ // Cover failure case for number with several E
+ [ "1.4EE2", false, false ],
+ [ "1.4EE", false, false ],
+
+ // Cover failure case for number with several E (nonconsecutive)
+ // FIXME: This is invalid, but currently tolerated
+ [ "1.4E2E3", "1.4E2 E3", false ],
+
+ // Semicolon insertion between an expression having an inline
+ // comment after it, and a statement on the next line (T29046).
+ [
+ "var a = this //foo bar \n for ( b = 0; c < d; b++ ) {}",
+ "var a=this\nfor(b=0;c<d;b++){}"
+ ],
+
+ // Cover failure case of incomplete regexp at end of file (T75556)
+ // FIXME: This is invalid, but currently tolerated
+ [ "*/", "*/", false ],
+
+ // Cover failure case of incomplete char class in regexp (T75556)
+ // FIXME: This is invalid, but currently tolerated
+ [ "/a[b/.test", "/a[b/.test", false ],
+
+ // Cover failure case of incomplete string at end of file (T75556)
+ // FIXME: This is invalid, but currently tolerated
+ [ "'a", "'a", false ],
+
+ // Token separation
+ [ "x in y", "x in y" ],
+ [ "/x/g in y", "/x/g in y" ],
+ [ "x in 30", "x in 30" ],
+ [ "x + ++ y", "x+ ++y" ],
+ [ "x ++ + y", "x++ +y" ],
+ [ "x / /y/.exec(z)", "x/ /y/.exec(z)" ],
+
+ // State machine
+ [ "/ x/g", "/ x/g" ],
+ [ "(function(){return/ x/g})", "(function(){return/ x/g})" ],
+ [ "+/ x/g", "+/ x/g" ],
+ [ "++/ x/g", "++/ x/g" ],
+ [ "x/ x/g", "x/x/g" ],
+ [ "(/ x/g)", "(/ x/g)" ],
+ [ "if(/ x/g);", "if(/ x/g);" ],
+ [ "(x/ x/g)", "(x/x/g)" ],
+ [ "([/ x/g])", "([/ x/g])" ],
+ [ "+x/ x/g", "+x/x/g" ],
+ [ "{}/ x/g", "{}/ x/g" ],
+ [ "+{}/ x/g", "+{}/x/g" ],
+ [ "(x)/ x/g", "(x)/x/g" ],
+ [ "if(x)/ x/g", "if(x)/ x/g" ],
+ [ "for(x;x;{}/ x/g);", "for(x;x;{}/x/g);" ],
+ [ "x;x;{}/ x/g", "x;x;{}/ x/g" ],
+ [ "x:{}/ x/g", "x:{}/ x/g" ],
+ [ "switch(x){case y?z:{}/ x/g:{}/ x/g;}", "switch(x){case y?z:{}/x/g:{}/ x/g;}" ],
+ [ "function x(){}/ x/g", "function x(){}/ x/g" ],
+ [ "+function x(){}/ x/g", "+function x(){}/x/g" ],
+
+ // Multiline quoted string
+ [ "var foo=\"\\\nblah\\\n\";", "var foo=\"\\\nblah\\\n\";" ],
+
+ // Multiline quoted string followed by string with spaces
+ [
+ "var foo=\"\\\nblah\\\n\";\nvar baz = \" foo \";\n",
+ "var foo=\"\\\nblah\\\n\";var baz=\" foo \";"
+ ],
+
+ // URL in quoted string ( // is not a comment)
+ [
+ "aNode.setAttribute('href','http://foo.bar.org/baz');",
+ "aNode.setAttribute('href','http://foo.bar.org/baz');"
+ ],
+
+ // URL in quoted string after multiline quoted string
+ [
+ "var foo=\"\\\nblah\\\n\";\naNode.setAttribute('href','http://foo.bar.org/baz');",
+ "var foo=\"\\\nblah\\\n\";aNode.setAttribute('href','http://foo.bar.org/baz');"
+ ],
+
+ // Division vs. regex nastiness
+ [
+ "alert( (10+10) / '/'.charCodeAt( 0 ) + '//' );",
+ "alert((10+10)/'/'.charCodeAt(0)+'//');"
+ ],
+ [ "if(1)/a /g.exec('Pa ss');", "if(1)/a /g.exec('Pa ss');" ],
+
+ // newline insertion after 1000 chars: break after the "++", not before
+ [ str_repeat( ';', 996 ) . "if(x++);", str_repeat( ';', 996 ) . "if(x++\n);" ],
+
+ // Unicode letter characters should pass through ok in identifiers (T33187)
+ [ "var KaŝSkatolVal = {}", 'var KaŝSkatolVal={}' ],
+
+ // Per spec unicode char escape values should work in identifiers,
+ // as long as it's a valid char. In future it might get normalized.
+ [ "var Ka\\u015dSkatolVal = {}", 'var Ka\\u015dSkatolVal={}' ],
+
+ // Some structures that might look invalid at first sight
+ [ "var a = 5.;", "var a=5.;" ],
+ [ "5.0.toString();", "5.0.toString();" ],
+ [ "5..toString();", "5..toString();" ],
+ // Cover failure case for too many decimal points
+ [ "5...toString();", false ],
+ [ "5.\n.toString();", '5..toString();' ],
+
+ // Boolean minification (!0 / !1)
+ [ "var a = { b: true };", "var a={b:!0};" ],
+ [ "var a = { true: 12 };", "var a={true:12};", false ],
+ [ "a.true = 12;", "a.true=12;", false ],
+ [ "a.foo = true;", "a.foo=!0;" ],
+ [ "a.foo = false;", "a.foo=!1;" ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCases
+ * @covers JavaScriptMinifier::minify
+ * @covers JavaScriptMinifier::parseError
+ */
+ public function testMinifyOutput( $code, $expectedOutput, $expectedValid = true ) {
+ $minified = JavaScriptMinifier::minify( $code );
+
+ // JSMin+'s parser will throw an exception if output is not valid JS.
+ // suppression of warnings needed for stupid crap
+ if ( $expectedValid ) {
+ Wikimedia\suppressWarnings();
+ $parser = new JSParser();
+ Wikimedia\restoreWarnings();
+ $parser->parse( $minified, 'minify-test.js', 1 );
+ }
+
+ $this->assertEquals(
+ $expectedOutput,
+ $minified,
+ "Minified output should be in the form expected."
+ );
+ }
+
+ public static function provideExponentLineBreaking() {
+ return [
+ [
+ // This one gets interpreted all together by the prior code;
+ // no break at the 'E' happens.
+ '1.23456789E55',
+ ],
+ [
+ // This one breaks under the bad code; splits between 'E' and '+'
+ '1.23456789E+5',
+ ],
+ [
+ // This one breaks under the bad code; splits between 'E' and '-'
+ '1.23456789E-5',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideExponentLineBreaking
+ * @covers JavaScriptMinifier::minify
+ */
+ public function testExponentLineBreaking( $num ) {
+ // Long line breaking was being incorrectly done between the base and
+ // exponent part of a number, causing a syntax error. The line should
+ // instead break at the start of the number. (T34548)
+ $prefix = 'var longVarName' . str_repeat( '_', 973 ) . '=';
+ $suffix = ',shortVarName=0;';
+
+ $input = $prefix . $num . $suffix;
+ $expected = $prefix . "\n" . $num . $suffix;
+
+ $minified = JavaScriptMinifier::minify( $input );
+
+ $this->assertEquals( $expected, $minified, "Line breaks must not occur in middle of exponent" );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/MWMessagePackTest.php b/www/wiki/tests/phpunit/includes/libs/MWMessagePackTest.php
new file mode 100644
index 00000000..695a7341
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/MWMessagePackTest.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * PHP Unit tests for MWMessagePack
+ * @covers MWMessagePack
+ */
+class MWMessagePackTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * Provides test cases for MWMessagePackTest::testMessagePack
+ *
+ * Returns an array of test cases. Each case is an array of (type, value,
+ * expected encoding as hex string). The expected values were generated
+ * using <https://github.com/msgpack/msgpack-php>, which includes a
+ * serialization function.
+ */
+ public static function providePacks() {
+ $tests = [
+ [ 'nil', null, 'c0' ],
+ [ 'bool', true, 'c3' ],
+ [ 'bool', false, 'c2' ],
+ [ 'positive fixnum', 0, '00' ],
+ [ 'positive fixnum', 1, '01' ],
+ [ 'positive fixnum', 5, '05' ],
+ [ 'positive fixnum', 35, '23' ],
+ [ 'uint 8', 128, 'cc80' ],
+ [ 'uint 16', 1000, 'cd03e8' ],
+ [ 'uint 32', 100000, 'ce000186a0' ],
+ [ 'negative fixnum', -1, 'ff' ],
+ [ 'negative fixnum', -2, 'fe' ],
+ [ 'int 8', -128, 'd080' ],
+ [ 'int 8', -35, 'd0dd' ],
+ [ 'int 16', -1000, 'd1fc18' ],
+ [ 'int 32', -100000, 'd2fffe7960' ],
+ [ 'double', 0.1, 'cb3fb999999999999a' ],
+ [ 'double', 1.1, 'cb3ff199999999999a' ],
+ [ 'double', 123.456, 'cb405edd2f1a9fbe77' ],
+ [ 'fix raw', '', 'a0' ],
+ [ 'fix raw', 'foobar', 'a6666f6f626172' ],
+ [
+ 'raw 16',
+ 'Lorem ipsum dolor sit amet amet.',
+ 'da00204c6f72656d20697073756d20646f6c6f722073697420616d657420616d65742e'
+ ],
+ [
+ 'fix array',
+ [ 'abc', 'def', 'ghi' ],
+ '93a3616263a3646566a3676869'
+ ],
+ [
+ 'fix map',
+ [ 'one' => 1, 'two' => 2 ],
+ '82a36f6e6501a374776f02'
+ ],
+ ];
+
+ if ( PHP_INT_SIZE > 4 ) {
+ $tests[] = [ 'uint 64', 10000000000, 'cf00000002540be400' ];
+ $tests[] = [ 'int 64', -10000000000, 'd3fffffffdabf41c00' ];
+ $tests[] = [ 'int 64', -223372036854775807, 'd3fce66c50e2840001' ];
+ $tests[] = [ 'int 64', -9223372036854775807, 'd38000000000000001' ];
+ }
+
+ return $tests;
+ }
+
+ /**
+ * Verify that values are serialized correctly.
+ * @covers MWMessagePack::pack
+ * @dataProvider providePacks
+ */
+ public function testPack( $type, $value, $expected ) {
+ $actual = bin2hex( MWMessagePack::pack( $value ) );
+ $this->assertEquals( $expected, $actual, $type );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/MapCacheLRUTest.php b/www/wiki/tests/phpunit/includes/libs/MapCacheLRUTest.php
new file mode 100644
index 00000000..2a962b79
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/MapCacheLRUTest.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * @group Cache
+ */
+class MapCacheLRUTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers MapCacheLRU::newFromArray()
+ * @covers MapCacheLRU::toArray()
+ * @covers MapCacheLRU::getAllKeys()
+ * @covers MapCacheLRU::clear()
+ */
+ function testArrayConversion() {
+ $raw = [ 'd' => 4, 'c' => 3, 'b' => 2, 'a' => 1 ];
+ $cache = MapCacheLRU::newFromArray( $raw, 3 );
+
+ $this->assertSame( true, $cache->has( 'a' ) );
+ $this->assertSame( true, $cache->has( 'b' ) );
+ $this->assertSame( true, $cache->has( 'c' ) );
+ $this->assertSame( 1, $cache->get( 'a' ) );
+ $this->assertSame( 2, $cache->get( 'b' ) );
+ $this->assertSame( 3, $cache->get( 'c' ) );
+
+ $this->assertSame(
+ [ 'a' => 1, 'b' => 2, 'c' => 3 ],
+ $cache->toArray()
+ );
+ $this->assertSame(
+ [ 'a', 'b', 'c' ],
+ $cache->getAllKeys()
+ );
+
+ $cache->clear( 'a' );
+ $this->assertSame(
+ [ 'b' => 2, 'c' => 3 ],
+ $cache->toArray()
+ );
+
+ $cache->clear();
+ $this->assertSame(
+ [],
+ $cache->toArray()
+ );
+ }
+
+ /**
+ * @covers MapCacheLRU::has()
+ * @covers MapCacheLRU::get()
+ * @covers MapCacheLRU::set()
+ */
+ function testLRU() {
+ $raw = [ 'a' => 1, 'b' => 2, 'c' => 3 ];
+ $cache = MapCacheLRU::newFromArray( $raw, 3 );
+
+ $this->assertSame( true, $cache->has( 'c' ) );
+ $this->assertSame(
+ [ 'a' => 1, 'b' => 2, 'c' => 3 ],
+ $cache->toArray()
+ );
+
+ $this->assertSame( 3, $cache->get( 'c' ) );
+ $this->assertSame(
+ [ 'a' => 1, 'b' => 2, 'c' => 3 ],
+ $cache->toArray()
+ );
+
+ $this->assertSame( 1, $cache->get( 'a' ) );
+ $this->assertSame(
+ [ 'b' => 2, 'c' => 3, 'a' => 1 ],
+ $cache->toArray()
+ );
+
+ $cache->set( 'a', 1 );
+ $this->assertSame(
+ [ 'b' => 2, 'c' => 3, 'a' => 1 ],
+ $cache->toArray()
+ );
+
+ $cache->set( 'b', 22 );
+ $this->assertSame(
+ [ 'c' => 3, 'a' => 1, 'b' => 22 ],
+ $cache->toArray()
+ );
+
+ $cache->set( 'd', 4 );
+ $this->assertSame(
+ [ 'a' => 1, 'b' => 22, 'd' => 4 ],
+ $cache->toArray()
+ );
+
+ $cache->set( 'e', 5, 0.33 );
+ $this->assertSame(
+ [ 'e' => 5, 'b' => 22, 'd' => 4 ],
+ $cache->toArray()
+ );
+
+ $cache->set( 'f', 6, 0.66 );
+ $this->assertSame(
+ [ 'b' => 22, 'f' => 6, 'd' => 4 ],
+ $cache->toArray()
+ );
+
+ $cache->set( 'g', 7, 0.90 );
+ $this->assertSame(
+ [ 'f' => 6, 'g' => 7, 'd' => 4 ],
+ $cache->toArray()
+ );
+
+ $cache->set( 'g', 7, 1.0 );
+ $this->assertSame(
+ [ 'f' => 6, 'd' => 4, 'g' => 7 ],
+ $cache->toArray()
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/MemoizedCallableTest.php b/www/wiki/tests/phpunit/includes/libs/MemoizedCallableTest.php
new file mode 100644
index 00000000..9127a30f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/MemoizedCallableTest.php
@@ -0,0 +1,142 @@
+<?php
+/**
+ * A MemoizedCallable subclass that stores function return values
+ * in an instance property rather than APC or APCu.
+ */
+class ArrayBackedMemoizedCallable extends MemoizedCallable {
+ private $cache = [];
+
+ protected function fetchResult( $key, &$success ) {
+ if ( array_key_exists( $key, $this->cache ) ) {
+ $success = true;
+ return $this->cache[$key];
+ }
+ $success = false;
+ return false;
+ }
+
+ protected function storeResult( $key, $result ) {
+ $this->cache[$key] = $result;
+ }
+}
+
+/**
+ * PHP Unit tests for MemoizedCallable class.
+ * @covers MemoizedCallable
+ */
+class MemoizedCallableTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * The memoized callable should relate inputs to outputs in the same
+ * way as the original underlying callable.
+ */
+ public function testReturnValuePassedThrough() {
+ $mock = $this->getMockBuilder( stdClass::class )
+ ->setMethods( [ 'reverse' ] )->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'reverse' )
+ ->will( $this->returnCallback( 'strrev' ) );
+
+ $memoized = new MemoizedCallable( [ $mock, 'reverse' ] );
+ $this->assertEquals( 'flow', $memoized->invoke( 'wolf' ) );
+ }
+
+ /**
+ * Consecutive calls to the memoized callable with the same arguments
+ * should result in just one invocation of the underlying callable.
+ *
+ * @requires extension apcu
+ */
+ public function testCallableMemoized() {
+ $observer = $this->getMockBuilder( stdClass::class )
+ ->setMethods( [ 'computeSomething' ] )->getMock();
+ $observer->expects( $this->once() )
+ ->method( 'computeSomething' )
+ ->will( $this->returnValue( 'ok' ) );
+
+ $memoized = new ArrayBackedMemoizedCallable( [ $observer, 'computeSomething' ] );
+
+ // First invocation -- delegates to $observer->computeSomething()
+ $this->assertEquals( 'ok', $memoized->invoke() );
+
+ // Second invocation -- returns memoized result
+ $this->assertEquals( 'ok', $memoized->invoke() );
+ }
+
+ /**
+ * @covers MemoizedCallable::invoke
+ */
+ public function testInvokeVariadic() {
+ $memoized = new MemoizedCallable( 'sprintf' );
+ $this->assertEquals(
+ $memoized->invokeArgs( [ 'this is %s', 'correct' ] ),
+ $memoized->invoke( 'this is %s', 'correct' )
+ );
+ }
+
+ /**
+ * @covers MemoizedCallable::call
+ */
+ public function testShortcutMethod() {
+ $this->assertEquals(
+ 'this is correct',
+ MemoizedCallable::call( 'sprintf', [ 'this is %s', 'correct' ] )
+ );
+ }
+
+ /**
+ * Outlier TTL values should be coerced to range 1 - 86400.
+ */
+ public function testTTLMaxMin() {
+ $memoized = new MemoizedCallable( 'abs', 100000 );
+ $this->assertEquals( 86400, $this->readAttribute( $memoized, 'ttl' ) );
+
+ $memoized = new MemoizedCallable( 'abs', -10 );
+ $this->assertEquals( 1, $this->readAttribute( $memoized, 'ttl' ) );
+ }
+
+ /**
+ * Closure names should be distinct.
+ */
+ public function testMemoizedClosure() {
+ $a = new MemoizedCallable( function () {
+ return 'a';
+ } );
+
+ $b = new MemoizedCallable( function () {
+ return 'b';
+ } );
+
+ $this->assertEquals( $a->invokeArgs(), 'a' );
+ $this->assertEquals( $b->invokeArgs(), 'b' );
+
+ $this->assertNotEquals(
+ $this->readAttribute( $a, 'callableName' ),
+ $this->readAttribute( $b, 'callableName' )
+ );
+
+ $c = new ArrayBackedMemoizedCallable( function () {
+ return rand();
+ } );
+ $this->assertEquals( $c->invokeArgs(), $c->invokeArgs(), 'memoized random' );
+ }
+
+ /**
+ * @expectedExceptionMessage non-scalar argument
+ * @expectedException InvalidArgumentException
+ */
+ public function testNonScalarArguments() {
+ $memoized = new MemoizedCallable( 'gettype' );
+ $memoized->invoke( new stdClass() );
+ }
+
+ /**
+ * @expectedExceptionMessage must be an instance of callable
+ * @expectedException InvalidArgumentException
+ */
+ public function testNotCallable() {
+ $memoized = new MemoizedCallable( 14 );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/ProcessCacheLRUTest.php b/www/wiki/tests/phpunit/includes/libs/ProcessCacheLRUTest.php
new file mode 100644
index 00000000..c8940e5f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/ProcessCacheLRUTest.php
@@ -0,0 +1,268 @@
+<?php
+
+/**
+ * Test for ProcessCacheLRU class.
+ *
+ * Note that it uses the ProcessCacheLRUTestable class which extends some
+ * properties and methods visibility. That class is defined at the end of the
+ * file containing this class.
+ *
+ * @group Cache
+ */
+class ProcessCacheLRUTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * Helper to verify emptiness of a cache object.
+ * Compare against an array so we get the cache content difference.
+ */
+ protected function assertCacheEmpty( $cache, $msg = 'Cache should be empty' ) {
+ $this->assertAttributeEquals( [], 'cache', $cache, $msg );
+ }
+
+ /**
+ * Helper to fill a cache object passed by reference
+ */
+ protected function fillCache( &$cache, $numEntries ) {
+ // Fill cache with three values
+ for ( $i = 1; $i <= $numEntries; $i++ ) {
+ $cache->set( "cache-key-$i", "prop-$i", "value-$i" );
+ }
+ }
+
+ /**
+ * Generates an array of what would be expected in cache for a given cache
+ * size and a number of entries filled in sequentially
+ */
+ protected function getExpectedCache( $cacheMaxEntries, $entryToFill ) {
+ $expected = [];
+
+ if ( $entryToFill === 0 ) {
+ // The cache is empty!
+ return [];
+ } elseif ( $entryToFill <= $cacheMaxEntries ) {
+ // Cache is not fully filled
+ $firstKey = 1;
+ } else {
+ // Cache overflowed
+ $firstKey = 1 + $entryToFill - $cacheMaxEntries;
+ }
+
+ $lastKey = $entryToFill;
+
+ for ( $i = $firstKey; $i <= $lastKey; $i++ ) {
+ $expected["cache-key-$i"] = [ "prop-$i" => "value-$i" ];
+ }
+
+ return $expected;
+ }
+
+ /**
+ * Highlight diff between assertEquals and assertNotSame
+ * @coversNothing
+ */
+ public function testPhpUnitArrayEquality() {
+ $one = [ 'A' => 1, 'B' => 2 ];
+ $two = [ 'B' => 2, 'A' => 1 ];
+ // ==
+ $this->assertEquals( $one, $two );
+ // ===
+ $this->assertNotSame( $one, $two );
+ }
+
+ /**
+ * @dataProvider provideInvalidConstructorArg
+ * @expectedException Wikimedia\Assert\ParameterAssertionException
+ * @covers ProcessCacheLRU::__construct
+ */
+ public function testConstructorGivenInvalidValue( $maxSize ) {
+ new ProcessCacheLRUTestable( $maxSize );
+ }
+
+ /**
+ * Value which are forbidden by the constructor
+ */
+ public static function provideInvalidConstructorArg() {
+ return [
+ [ null ],
+ [ [] ],
+ [ new stdClass() ],
+ [ 0 ],
+ [ '5' ],
+ [ -1 ],
+ ];
+ }
+
+ /**
+ * @covers ProcessCacheLRU::get
+ * @covers ProcessCacheLRU::set
+ * @covers ProcessCacheLRU::has
+ */
+ public function testAddAndGetAKey() {
+ $oneCache = new ProcessCacheLRUTestable( 1 );
+ $this->assertCacheEmpty( $oneCache );
+
+ // First set just one value
+ $oneCache->set( 'cache-key', 'prop1', 'value1' );
+ $this->assertEquals( 1, $oneCache->getEntriesCount() );
+ $this->assertTrue( $oneCache->has( 'cache-key', 'prop1' ) );
+ $this->assertEquals( 'value1', $oneCache->get( 'cache-key', 'prop1' ) );
+ }
+
+ /**
+ * @covers ProcessCacheLRU::set
+ * @covers ProcessCacheLRU::get
+ */
+ public function testDeleteOldKey() {
+ $oneCache = new ProcessCacheLRUTestable( 1 );
+ $this->assertCacheEmpty( $oneCache );
+
+ $oneCache->set( 'cache-key', 'prop1', 'value1' );
+ $oneCache->set( 'cache-key', 'prop1', 'value2' );
+ $this->assertEquals( 'value2', $oneCache->get( 'cache-key', 'prop1' ) );
+ }
+
+ /**
+ * This test that we properly overflow when filling a cache with
+ * a sequence of always different cache-keys. Meant to verify we correclty
+ * delete the older key.
+ *
+ * @covers ProcessCacheLRU::set
+ * @dataProvider provideCacheFilling
+ * @param int $cacheMaxEntries Maximum entry the created cache will hold
+ * @param int $entryToFill Number of entries to insert in the created cache.
+ */
+ public function testFillingCache( $cacheMaxEntries, $entryToFill, $msg = '' ) {
+ $cache = new ProcessCacheLRUTestable( $cacheMaxEntries );
+ $this->fillCache( $cache, $entryToFill );
+
+ $this->assertSame(
+ $this->getExpectedCache( $cacheMaxEntries, $entryToFill ),
+ $cache->getCache(),
+ "Filling a $cacheMaxEntries entries cache with $entryToFill entries"
+ );
+ }
+
+ /**
+ * Provider for testFillingCache
+ */
+ public static function provideCacheFilling() {
+ // ($cacheMaxEntries, $entryToFill, $msg='')
+ return [
+ [ 1, 0 ],
+ [ 1, 1 ],
+ // overflow
+ [ 1, 2 ],
+ // overflow
+ [ 5, 33 ],
+ ];
+ }
+
+ /**
+ * Create a cache with only one remaining entry then update
+ * the first inserted entry. Should bump it to the top.
+ *
+ * @covers ProcessCacheLRU::set
+ */
+ public function testReplaceExistingKeyShouldBumpEntryToTop() {
+ $maxEntries = 3;
+
+ $cache = new ProcessCacheLRUTestable( $maxEntries );
+ // Fill cache leaving just one remaining slot
+ $this->fillCache( $cache, $maxEntries - 1 );
+
+ // Set an existing cache key
+ $cache->set( "cache-key-1", "prop-1", "new-value-for-1" );
+
+ $this->assertSame(
+ [
+ 'cache-key-2' => [ 'prop-2' => 'value-2' ],
+ 'cache-key-1' => [ 'prop-1' => 'new-value-for-1' ],
+ ],
+ $cache->getCache()
+ );
+ }
+
+ /**
+ * @covers ProcessCacheLRU::get
+ * @covers ProcessCacheLRU::set
+ * @covers ProcessCacheLRU::has
+ */
+ public function testRecentlyAccessedKeyStickIn() {
+ $cache = new ProcessCacheLRUTestable( 2 );
+ $cache->set( 'first', 'prop1', 'value1' );
+ $cache->set( 'second', 'prop2', 'value2' );
+
+ // Get first
+ $cache->get( 'first', 'prop1' );
+ // Cache a third value, should invalidate the least used one
+ $cache->set( 'third', 'prop3', 'value3' );
+
+ $this->assertFalse( $cache->has( 'second', 'prop2' ) );
+ }
+
+ /**
+ * This first create a full cache then update the value for the 2nd
+ * filled entry.
+ * Given a cache having 1,2,3 as key, updating 2 should bump 2 to
+ * the top of the queue with the new value: 1,3,2* (* = updated).
+ *
+ * @covers ProcessCacheLRU::set
+ * @covers ProcessCacheLRU::get
+ */
+ public function testReplaceExistingKeyInAFullCacheShouldBumpToTop() {
+ $maxEntries = 3;
+
+ $cache = new ProcessCacheLRUTestable( $maxEntries );
+ $this->fillCache( $cache, $maxEntries );
+
+ // Set an existing cache key
+ $cache->set( "cache-key-2", "prop-2", "new-value-for-2" );
+ $this->assertSame(
+ [
+ 'cache-key-1' => [ 'prop-1' => 'value-1' ],
+ 'cache-key-3' => [ 'prop-3' => 'value-3' ],
+ 'cache-key-2' => [ 'prop-2' => 'new-value-for-2' ],
+ ],
+ $cache->getCache()
+ );
+ $this->assertEquals( 'new-value-for-2',
+ $cache->get( 'cache-key-2', 'prop-2' )
+ );
+ }
+
+ /**
+ * @covers ProcessCacheLRU::set
+ */
+ public function testBumpExistingKeyToTop() {
+ $cache = new ProcessCacheLRUTestable( 3 );
+ $this->fillCache( $cache, 3 );
+
+ // Set the very first cache key to a new value
+ $cache->set( "cache-key-1", "prop-1", "new value for 1" );
+ $this->assertEquals(
+ [
+ 'cache-key-2' => [ 'prop-2' => 'value-2' ],
+ 'cache-key-3' => [ 'prop-3' => 'value-3' ],
+ 'cache-key-1' => [ 'prop-1' => 'new value for 1' ],
+ ],
+ $cache->getCache()
+ );
+ }
+}
+
+/**
+ * Overrides some ProcessCacheLRU methods and properties accessibility.
+ */
+class ProcessCacheLRUTestable extends ProcessCacheLRU {
+ public $cache = [];
+
+ public function getCache() {
+ return $this->cache;
+ }
+
+ public function getEntriesCount() {
+ return count( $this->cache );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/SamplingStatsdClientTest.php b/www/wiki/tests/phpunit/includes/libs/SamplingStatsdClientTest.php
new file mode 100644
index 00000000..7bd16115
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/SamplingStatsdClientTest.php
@@ -0,0 +1,77 @@
+<?php
+
+use Liuggio\StatsdClient\Entity\StatsdData;
+use Liuggio\StatsdClient\Sender\SenderInterface;
+
+/**
+ * @covers SamplingStatsdClient
+ */
+class SamplingStatsdClientTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @dataProvider samplingDataProvider
+ */
+ public function testSampling( $data, $sampleRate, $seed, $expectWrite ) {
+ $sender = $this->getMockBuilder( SenderInterface::class )->getMock();
+ $sender->expects( $this->any() )->method( 'open' )->will( $this->returnValue( true ) );
+ if ( $expectWrite ) {
+ $sender->expects( $this->once() )->method( 'write' )
+ ->with( $this->anything(), $this->equalTo( $data ) );
+ } else {
+ $sender->expects( $this->never() )->method( 'write' );
+ }
+ if ( defined( 'MT_RAND_PHP' ) ) {
+ mt_srand( $seed, MT_RAND_PHP );
+ } else {
+ mt_srand( $seed );
+ }
+ $client = new SamplingStatsdClient( $sender );
+ $client->send( $data, $sampleRate );
+ }
+
+ public function samplingDataProvider() {
+ $unsampled = new StatsdData();
+ $unsampled->setKey( 'foo' );
+ $unsampled->setValue( 1 );
+
+ $sampled = new StatsdData();
+ $sampled->setKey( 'foo' );
+ $sampled->setValue( 1 );
+ $sampled->setSampleRate( '0.1' );
+
+ return [
+ // $data, $sampleRate, $seed, $expectWrite
+ [ $unsampled, 1, 0 /*0.44*/, true ],
+ [ $sampled, 1, 0 /*0.44*/, false ],
+ [ $sampled, 1, 4 /*0.03*/, true ],
+ [ $unsampled, 0.1, 0 /*0.44*/, false ],
+ [ $sampled, 0.5, 0 /*0.44*/, false ],
+ [ $sampled, 0.5, 4 /*0.03*/, false ],
+ ];
+ }
+
+ public function testSetSamplingRates() {
+ $matching = new StatsdData();
+ $matching->setKey( 'foo.bar' );
+ $matching->setValue( 1 );
+
+ $nonMatching = new StatsdData();
+ $nonMatching->setKey( 'oof.bar' );
+ $nonMatching->setValue( 1 );
+
+ $sender = $this->getMockBuilder( SenderInterface::class )->getMock();
+ $sender->expects( $this->any() )->method( 'open' )->will( $this->returnValue( true ) );
+ $sender->expects( $this->once() )->method( 'write' )->with( $this->anything(),
+ $this->equalTo( $nonMatching ) );
+
+ $client = new SamplingStatsdClient( $sender );
+ $client->setSamplingRates( [ 'foo.*' => 0.2 ] );
+
+ mt_srand( 0 ); // next random is 0.44
+ $client->send( $matching );
+ mt_srand( 0 );
+ $client->send( $nonMatching );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/StringUtilsTest.php b/www/wiki/tests/phpunit/includes/libs/StringUtilsTest.php
new file mode 100644
index 00000000..fcfa53e2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/StringUtilsTest.php
@@ -0,0 +1,128 @@
+<?php
+
+class StringUtilsTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers StringUtils::isUtf8
+ * @dataProvider provideStringsForIsUtf8Check
+ */
+ public function testIsUtf8( $expected, $string ) {
+ $this->assertEquals( $expected, StringUtils::isUtf8( $string ),
+ 'Testing string "' . $this->escaped( $string ) . '"' );
+ }
+
+ /**
+ * Print high range characters as a hexadecimal
+ * @param string $string
+ * @return string
+ */
+ function escaped( $string ) {
+ $escaped = '';
+ $length = strlen( $string );
+ for ( $i = 0; $i < $length; $i++ ) {
+ $char = $string[$i];
+ $val = ord( $char );
+ if ( $val > 127 ) {
+ $escaped .= '\x' . dechex( $val );
+ } else {
+ $escaped .= $char;
+ }
+ }
+
+ return $escaped;
+ }
+
+ /**
+ * See also "UTF-8 decoder capability and stress test" by
+ * Markus Kuhn:
+ * http://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-test.txt
+ */
+ public static function provideStringsForIsUtf8Check() {
+ // Expected return values for StringUtils::isUtf8()
+ $PASS = true;
+ $FAIL = false;
+
+ return [
+ 'some ASCII' => [ $PASS, 'Some ASCII' ],
+ 'euro sign' => [ $PASS, "Euro sign €" ],
+
+ 'first possible sequence 1 byte' => [ $PASS, "\x00" ],
+ 'first possible sequence 2 bytes' => [ $PASS, "\xc2\x80" ],
+ 'first possible sequence 3 bytes' => [ $PASS, "\xe0\xa0\x80" ],
+ 'first possible sequence 4 bytes' => [ $PASS, "\xf0\x90\x80\x80" ],
+ 'first possible sequence 5 bytes' => [ $FAIL, "\xf8\x88\x80\x80\x80" ],
+ 'first possible sequence 6 bytes' => [ $FAIL, "\xfc\x84\x80\x80\x80\x80" ],
+
+ 'last possible sequence 1 byte' => [ $PASS, "\x7f" ],
+ 'last possible sequence 2 bytes' => [ $PASS, "\xdf\xbf" ],
+ 'last possible sequence 3 bytes' => [ $PASS, "\xef\xbf\xbf" ],
+ 'last possible sequence 4 bytes (U+1FFFFF)' => [ $FAIL, "\xf7\xbf\xbf\xbf" ],
+ 'last possible sequence 5 bytes' => [ $FAIL, "\xfb\xbf\xbf\xbf\xbf" ],
+ 'last possible sequence 6 bytes' => [ $FAIL, "\xfd\xbf\xbf\xbf\xbf\xbf" ],
+
+ 'boundary 1' => [ $PASS, "\xed\x9f\xbf" ],
+ 'boundary 2' => [ $PASS, "\xee\x80\x80" ],
+ 'boundary 3' => [ $PASS, "\xef\xbf\xbd" ],
+ 'boundary 4' => [ $PASS, "\xf2\x80\x80\x80" ],
+ 'boundary 5 (U+FFFFF)' => [ $PASS, "\xf3\xbf\xbf\xbf" ],
+ 'boundary 6 (U+100000)' => [ $PASS, "\xf4\x80\x80\x80" ],
+ 'boundary 7 (U+10FFFF)' => [ $PASS, "\xf4\x8f\xbf\xbf" ],
+ 'boundary 8 (U+110000)' => [ $FAIL, "\xf4\x90\x80\x80" ],
+
+ 'malformed 1' => [ $FAIL, "\x80" ],
+ 'malformed 2' => [ $FAIL, "\xbf" ],
+ 'malformed 3' => [ $FAIL, "\x80\xbf" ],
+ 'malformed 4' => [ $FAIL, "\x80\xbf\x80" ],
+ 'malformed 5' => [ $FAIL, "\x80\xbf\x80\xbf" ],
+ 'malformed 6' => [ $FAIL, "\x80\xbf\x80\xbf\x80" ],
+ 'malformed 7' => [ $FAIL, "\x80\xbf\x80\xbf\x80\xbf" ],
+ 'malformed 8' => [ $FAIL, "\x80\xbf\x80\xbf\x80\xbf\x80" ],
+
+ 'last byte missing 1' => [ $FAIL, "\xc0" ],
+ 'last byte missing 2' => [ $FAIL, "\xe0\x80" ],
+ 'last byte missing 3' => [ $FAIL, "\xf0\x80\x80" ],
+ 'last byte missing 4' => [ $FAIL, "\xf8\x80\x80\x80" ],
+ 'last byte missing 5' => [ $FAIL, "\xfc\x80\x80\x80\x80" ],
+ 'last byte missing 6' => [ $FAIL, "\xdf" ],
+ 'last byte missing 7' => [ $FAIL, "\xef\xbf" ],
+ 'last byte missing 8' => [ $FAIL, "\xf7\xbf\xbf" ],
+ 'last byte missing 9' => [ $FAIL, "\xfb\xbf\xbf\xbf" ],
+ 'last byte missing 10' => [ $FAIL, "\xfd\xbf\xbf\xbf\xbf" ],
+
+ 'extra continuation byte 1' => [ $FAIL, "e\xaf" ],
+ 'extra continuation byte 2' => [ $FAIL, "\xc3\x89\xaf" ],
+ 'extra continuation byte 3' => [ $FAIL, "\xef\xbc\xa5\xaf" ],
+ 'extra continuation byte 4' => [ $FAIL, "\xf0\x9d\x99\xb4\xaf" ],
+
+ 'impossible bytes 1' => [ $FAIL, "\xfe" ],
+ 'impossible bytes 2' => [ $FAIL, "\xff" ],
+ 'impossible bytes 3' => [ $FAIL, "\xfe\xfe\xff\xff" ],
+
+ 'overlong sequences 1' => [ $FAIL, "\xc0\xaf" ],
+ 'overlong sequences 2' => [ $FAIL, "\xc1\xaf" ],
+ 'overlong sequences 3' => [ $FAIL, "\xe0\x80\xaf" ],
+ 'overlong sequences 4' => [ $FAIL, "\xf0\x80\x80\xaf" ],
+ 'overlong sequences 5' => [ $FAIL, "\xf8\x80\x80\x80\xaf" ],
+ 'overlong sequences 6' => [ $FAIL, "\xfc\x80\x80\x80\x80\xaf" ],
+
+ 'maximum overlong sequences 1' => [ $FAIL, "\xc1\xbf" ],
+ 'maximum overlong sequences 2' => [ $FAIL, "\xe0\x9f\xbf" ],
+ 'maximum overlong sequences 3' => [ $FAIL, "\xf0\x8f\xbf\xbf" ],
+ 'maximum overlong sequences 4' => [ $FAIL, "\xf8\x87\xbf\xbf" ],
+ 'maximum overlong sequences 5' => [ $FAIL, "\xfc\x83\xbf\xbf\xbf\xbf" ],
+
+ 'surrogates 1 (U+D799)' => [ $PASS, "\xed\x9f\xbf" ],
+ 'surrogates 2 (U+E000)' => [ $PASS, "\xee\x80\x80" ],
+ 'surrogates 3 (U+D800)' => [ $FAIL, "\xed\xa0\x80" ],
+ 'surrogates 4 (U+DBFF)' => [ $FAIL, "\xed\xaf\xbf" ],
+ 'surrogates 5 (U+DC00)' => [ $FAIL, "\xed\xb0\x80" ],
+ 'surrogates 6 (U+DFFF)' => [ $FAIL, "\xed\xbf\xbf" ],
+ 'surrogates 7 (U+D800 U+DC00)' => [ $FAIL, "\xed\xa0\x80\xed\xb0\x80" ],
+
+ 'noncharacters 1' => [ $PASS, "\xef\xbf\xbe" ],
+ 'noncharacters 2' => [ $PASS, "\xef\xbf\xbf" ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/TimingTest.php b/www/wiki/tests/phpunit/includes/libs/TimingTest.php
new file mode 100644
index 00000000..581a5186
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/TimingTest.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * 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
+ * @author Ori Livneh <ori@wikimedia.org>
+ */
+
+class TimingTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers Timing::clearMarks
+ * @covers Timing::getEntries
+ */
+ public function testClearMarks() {
+ $timing = new Timing;
+ $this->assertCount( 1, $timing->getEntries() );
+
+ $timing->mark( 'a' );
+ $timing->mark( 'b' );
+ $this->assertCount( 3, $timing->getEntries() );
+
+ $timing->clearMarks( 'a' );
+ $this->assertNull( $timing->getEntryByName( 'a' ) );
+ $this->assertNotNull( $timing->getEntryByName( 'b' ) );
+
+ $timing->clearMarks();
+ $this->assertCount( 1, $timing->getEntries() );
+ }
+
+ /**
+ * @covers Timing::mark
+ * @covers Timing::getEntryByName
+ */
+ public function testMark() {
+ $timing = new Timing;
+ $timing->mark( 'a' );
+
+ $entry = $timing->getEntryByName( 'a' );
+ $this->assertEquals( 'a', $entry['name'] );
+ $this->assertEquals( 'mark', $entry['entryType'] );
+ $this->assertArrayHasKey( 'startTime', $entry );
+ $this->assertEquals( 0, $entry['duration'] );
+
+ usleep( 100 );
+ $timing->mark( 'a' );
+ $newEntry = $timing->getEntryByName( 'a' );
+ $this->assertGreaterThan( $entry['startTime'], $newEntry['startTime'] );
+ }
+
+ /**
+ * @covers Timing::measure
+ */
+ public function testMeasure() {
+ $timing = new Timing;
+
+ $timing->mark( 'a' );
+ usleep( 100 );
+ $timing->mark( 'b' );
+
+ $a = $timing->getEntryByName( 'a' );
+ $b = $timing->getEntryByName( 'b' );
+
+ $timing->measure( 'a_to_b', 'a', 'b' );
+
+ $entry = $timing->getEntryByName( 'a_to_b' );
+ $this->assertEquals( 'a_to_b', $entry['name'] );
+ $this->assertEquals( 'measure', $entry['entryType'] );
+ $this->assertEquals( $a['startTime'], $entry['startTime'] );
+ $this->assertEquals( $b['startTime'] - $a['startTime'], $entry['duration'] );
+ }
+
+ /**
+ * @covers Timing::getEntriesByType
+ */
+ public function testGetEntriesByType() {
+ $timing = new Timing;
+
+ $timing->mark( 'mark_a' );
+ usleep( 100 );
+ $timing->mark( 'mark_b' );
+ usleep( 100 );
+ $timing->mark( 'mark_c' );
+
+ $timing->measure( 'measure_a', 'mark_a', 'mark_b' );
+ $timing->measure( 'measure_b', 'mark_b', 'mark_c' );
+
+ $marks = array_map( function ( $entry ) {
+ return $entry['name'];
+ }, $timing->getEntriesByType( 'mark' ) );
+
+ $this->assertEquals( [ 'requestStart', 'mark_a', 'mark_b', 'mark_c' ], $marks );
+
+ $measures = array_map( function ( $entry ) {
+ return $entry['name'];
+ }, $timing->getEntriesByType( 'measure' ) );
+
+ $this->assertEquals( [ 'measure_a', 'measure_b' ], $measures );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/XhprofDataTest.php b/www/wiki/tests/phpunit/includes/libs/XhprofDataTest.php
new file mode 100644
index 00000000..1cbd86f1
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/XhprofDataTest.php
@@ -0,0 +1,278 @@
+<?php
+/**
+ * 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
+ */
+
+/**
+ * @uses XhprofData
+ * @uses AutoLoader
+ * @copyright © 2014 Wikimedia Foundation and contributors
+ * @since 1.25
+ */
+class XhprofDataTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers XhprofData::splitKey
+ * @dataProvider provideSplitKey
+ */
+ public function testSplitKey( $key, $expect ) {
+ $this->assertSame( $expect, XhprofData::splitKey( $key ) );
+ }
+
+ public function provideSplitKey() {
+ return [
+ [ 'main()', [ null, 'main()' ] ],
+ [ 'foo==>bar', [ 'foo', 'bar' ] ],
+ [ 'bar@1==>bar@2', [ 'bar@1', 'bar@2' ] ],
+ [ 'foo==>bar==>baz', [ 'foo', 'bar==>baz' ] ],
+ [ '==>bar', [ '', 'bar' ] ],
+ [ '', [ null, '' ] ],
+ ];
+ }
+
+ /**
+ * @covers XhprofData::pruneData
+ */
+ public function testInclude() {
+ $xhprofData = $this->getXhprofDataFixture( [
+ 'include' => [ 'main()' ],
+ ] );
+ $raw = $xhprofData->getRawData();
+ $this->assertArrayHasKey( 'main()', $raw );
+ $this->assertArrayHasKey( 'main()==>foo', $raw );
+ $this->assertArrayHasKey( 'main()==>xhprof_disable', $raw );
+ $this->assertSame( 3, count( $raw ) );
+ }
+
+ /**
+ * Validate the structure of data returned by
+ * Xhprof::getInclusiveMetrics(). This acts as a guard against unexpected
+ * structural changes to the returned data in lieu of using a more heavy
+ * weight typed response object.
+ *
+ * @covers XhprofData::getInclusiveMetrics
+ */
+ public function testInclusiveMetricsStructure() {
+ $metricStruct = [
+ 'ct' => 'int',
+ 'wt' => 'array',
+ 'cpu' => 'array',
+ 'mu' => 'array',
+ 'pmu' => 'array',
+ ];
+ $statStruct = [
+ 'total' => 'numeric',
+ 'min' => 'numeric',
+ 'mean' => 'numeric',
+ 'max' => 'numeric',
+ 'variance' => 'numeric',
+ 'percent' => 'numeric',
+ ];
+
+ $xhprofData = $this->getXhprofDataFixture();
+ $metrics = $xhprofData->getInclusiveMetrics();
+
+ foreach ( $metrics as $name => $metric ) {
+ $this->assertArrayStructure( $metricStruct, $metric );
+
+ foreach ( $metricStruct as $key => $type ) {
+ if ( $type === 'array' ) {
+ $this->assertArrayStructure( $statStruct, $metric[$key] );
+ if ( $name === 'main()' ) {
+ $this->assertEquals( 100, $metric[$key]['percent'] );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Validate the structure of data returned by
+ * Xhprof::getCompleteMetrics(). This acts as a guard against unexpected
+ * structural changes to the returned data in lieu of using a more heavy
+ * weight typed response object.
+ *
+ * @covers XhprofData::getCompleteMetrics
+ */
+ public function testCompleteMetricsStructure() {
+ $metricStruct = [
+ 'ct' => 'int',
+ 'wt' => 'array',
+ 'cpu' => 'array',
+ 'mu' => 'array',
+ 'pmu' => 'array',
+ 'calls' => 'array',
+ 'subcalls' => 'array',
+ ];
+ $statsMetrics = [ 'wt', 'cpu', 'mu', 'pmu' ];
+ $statStruct = [
+ 'total' => 'numeric',
+ 'min' => 'numeric',
+ 'mean' => 'numeric',
+ 'max' => 'numeric',
+ 'variance' => 'numeric',
+ 'percent' => 'numeric',
+ 'exclusive' => 'numeric',
+ ];
+
+ $xhprofData = $this->getXhprofDataFixture();
+ $metrics = $xhprofData->getCompleteMetrics();
+
+ foreach ( $metrics as $name => $metric ) {
+ $this->assertArrayStructure( $metricStruct, $metric, $name );
+
+ foreach ( $metricStruct as $key => $type ) {
+ if ( in_array( $key, $statsMetrics ) ) {
+ $this->assertArrayStructure(
+ $statStruct, $metric[$key], $key
+ );
+ $this->assertLessThanOrEqual(
+ $metric[$key]['total'], $metric[$key]['exclusive']
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * @covers XhprofData::getCallers
+ * @covers XhprofData::getCallees
+ * @uses XhprofData
+ */
+ public function testEdges() {
+ $xhprofData = $this->getXhprofDataFixture();
+ $this->assertSame( [], $xhprofData->getCallers( 'main()' ) );
+ $this->assertSame( [ 'foo', 'xhprof_disable' ],
+ $xhprofData->getCallees( 'main()' )
+ );
+ $this->assertSame( [ 'main()' ],
+ $xhprofData->getCallers( 'foo' )
+ );
+ $this->assertSame( [], $xhprofData->getCallees( 'strlen' ) );
+ }
+
+ /**
+ * @covers XhprofData::getCriticalPath
+ * @uses XhprofData
+ */
+ public function testCriticalPath() {
+ $xhprofData = $this->getXhprofDataFixture();
+ $path = $xhprofData->getCriticalPath();
+
+ $last = null;
+ foreach ( $path as $key => $value ) {
+ list( $func, $call ) = XhprofData::splitKey( $key );
+ $this->assertSame( $last, $func );
+ $last = $call;
+ }
+ $this->assertSame( $last, 'bar@1' );
+ }
+
+ /**
+ * Get an Xhprof instance that has been primed with a set of known testing
+ * data. Tests for the Xhprof class should laregly be concerned with
+ * evaluating the manipulations of the data collected by xhprof rather
+ * than the data collection process itself.
+ *
+ * The returned Xhprof instance primed will be with a data set created by
+ * running this trivial program using the PECL xhprof implementation:
+ * @code
+ * function bar( $x ) {
+ * if ( $x > 0 ) {
+ * bar($x - 1);
+ * }
+ * }
+ * function foo() {
+ * for ( $idx = 0; $idx < 2; $idx++ ) {
+ * bar( $idx );
+ * $x = strlen( 'abc' );
+ * }
+ * }
+ * xhprof_enable( XHPROF_FLAGS_CPU | XHPROF_FLAGS_MEMORY );
+ * foo();
+ * $x = xhprof_disable();
+ * var_export( $x );
+ * @endcode
+ *
+ * @return Xhprof
+ */
+ protected function getXhprofDataFixture( array $opts = [] ) {
+ return new XhprofData( [
+ 'foo==>bar' => [
+ 'ct' => 2,
+ 'wt' => 57,
+ 'cpu' => 92,
+ 'mu' => 1896,
+ 'pmu' => 0,
+ ],
+ 'foo==>strlen' => [
+ 'ct' => 2,
+ 'wt' => 21,
+ 'cpu' => 141,
+ 'mu' => 752,
+ 'pmu' => 0,
+ ],
+ 'bar==>bar@1' => [
+ 'ct' => 1,
+ 'wt' => 18,
+ 'cpu' => 19,
+ 'mu' => 752,
+ 'pmu' => 0,
+ ],
+ 'main()==>foo' => [
+ 'ct' => 1,
+ 'wt' => 304,
+ 'cpu' => 307,
+ 'mu' => 4008,
+ 'pmu' => 0,
+ ],
+ 'main()==>xhprof_disable' => [
+ 'ct' => 1,
+ 'wt' => 8,
+ 'cpu' => 10,
+ 'mu' => 768,
+ 'pmu' => 392,
+ ],
+ 'main()' => [
+ 'ct' => 1,
+ 'wt' => 353,
+ 'cpu' => 351,
+ 'mu' => 6112,
+ 'pmu' => 1424,
+ ],
+ ], $opts );
+ }
+
+ /**
+ * Assert that the given array has the described structure.
+ *
+ * @param array $struct Array of key => type mappings
+ * @param array $actual Array to check
+ * @param string $label
+ */
+ protected function assertArrayStructure( $struct, $actual, $label = null ) {
+ $this->assertInternalType( 'array', $actual, $label );
+ $this->assertCount( count( $struct ), $actual, $label );
+ foreach ( $struct as $key => $type ) {
+ $this->assertArrayHasKey( $key, $actual );
+ $this->assertInternalType( $type, $actual[$key] );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/XhprofTest.php b/www/wiki/tests/phpunit/includes/libs/XhprofTest.php
new file mode 100644
index 00000000..0ea13289
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/XhprofTest.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * 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
+ */
+
+class XhprofTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * Trying to enable Xhprof when it is already enabled causes an exception
+ * to be thrown.
+ *
+ * @expectedException Exception
+ * @expectedExceptionMessage already enabled
+ * @covers Xhprof::enable
+ */
+ public function testEnable() {
+ $xhprof = new ReflectionClass( Xhprof::class );
+ $enabled = $xhprof->getProperty( 'enabled' );
+ $enabled->setAccessible( true );
+ $enabled->setValue( true );
+ $xhprof->getMethod( 'enable' )->invoke( null );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/XmlTypeCheckTest.php b/www/wiki/tests/phpunit/includes/libs/XmlTypeCheckTest.php
new file mode 100644
index 00000000..8616b419
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/XmlTypeCheckTest.php
@@ -0,0 +1,79 @@
+<?php
+/**
+ * PHPUnit tests for XMLTypeCheck.
+ * @author physikerwelt
+ * @group Xml
+ * @covers XMLTypeCheck
+ */
+class XmlTypeCheckTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ const WELL_FORMED_XML = "<root><child /></root>";
+ const MAL_FORMED_XML = "<root><child /></error>";
+ // phpcs:ignore Generic.Files.LineLength
+ const XML_WITH_PIH = '<?xml version="1.0"?><?xml-stylesheet type="text/xsl" href="/w/index.php"?><svg><child /></svg>';
+
+ /**
+ * @covers XMLTypeCheck::newFromString
+ * @covers XMLTypeCheck::getRootElement
+ */
+ public function testWellFormedXML() {
+ $testXML = XmlTypeCheck::newFromString( self::WELL_FORMED_XML );
+ $this->assertTrue( $testXML->wellFormed );
+ $this->assertEquals( 'root', $testXML->getRootElement() );
+ }
+
+ /**
+ * @covers XMLTypeCheck::newFromString
+ */
+ public function testMalFormedXML() {
+ $testXML = XmlTypeCheck::newFromString( self::MAL_FORMED_XML );
+ $this->assertFalse( $testXML->wellFormed );
+ }
+
+ /**
+ * Verify we check for recursive entity DOS
+ *
+ * (If the DOS isn't properly handled, the test runner will probably go OOM...)
+ */
+ public function testRecursiveEntity() {
+ $xml = <<<'XML'
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE foo [
+ <!ENTITY test "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">
+ <!ENTITY a "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">
+ <!ENTITY b "&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;">
+ <!ENTITY c "&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;">
+ <!ENTITY d "&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;">
+ <!ENTITY e "&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;">
+ <!ENTITY f "&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;">
+ <!ENTITY g "-00000000000000000000000000000000000000000000000000000000000000000000000-">
+]>
+<foo>
+<bar>&test;</bar>
+</foo>
+XML;
+ $check = XmlTypeCheck::newFromString( $xml );
+ $this->assertFalse( $check->wellFormed );
+ }
+
+ /**
+ * @covers XMLTypeCheck::processingInstructionHandler
+ */
+ public function testProcessingInstructionHandler() {
+ $called = false;
+ $testXML = new XmlTypeCheck(
+ self::XML_WITH_PIH,
+ null,
+ false,
+ [
+ 'processing_instruction_handler' => function () use ( &$called ) {
+ $called = true;
+ }
+ ]
+ );
+ $this->assertTrue( $called );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php b/www/wiki/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php
new file mode 100644
index 00000000..05ae2a37
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php
@@ -0,0 +1,499 @@
+<?php
+
+class ComposerInstalledTest extends MediaWikiTestCase {
+
+ private $installed;
+
+ public function setUp() {
+ parent::setUp();
+ global $IP;
+ $this->installed = "$IP/tests/phpunit/data/composer/installed.json";
+ }
+
+ /**
+ * @covers ComposerInstalled::__construct
+ * @covers ComposerInstalled::getInstalledDependencies
+ */
+ public function testGetInstalledDependencies() {
+ $installed = new ComposerInstalled( $this->installed );
+ $this->assertArrayEquals( [
+ 'leafo/lessphp' => [
+ 'version' => '0.5.0',
+ 'type' => 'library',
+ 'licenses' => [ 'MIT', 'GPL-3.0-only' ],
+ 'authors' => [
+ [
+ 'name' => 'Leaf Corcoran',
+ 'email' => 'leafot@gmail.com',
+ 'homepage' => 'http://leafo.net',
+ ],
+ ],
+ 'description' => 'lessphp is a compiler for LESS written in PHP.',
+ ],
+ 'psr/log' => [
+ 'version' => '1.0.0',
+ 'type' => 'library',
+ 'licenses' => [ 'MIT' ],
+ 'authors' => [
+ [
+ 'name' => 'PHP-FIG',
+ 'homepage' => 'http://www.php-fig.org/',
+ ],
+ ],
+ 'description' => 'Common interface for logging libraries',
+ ],
+ 'cssjanus/cssjanus' => [
+ 'version' => '1.1.1',
+ 'type' => 'library',
+ 'licenses' => [ 'Apache-2.0' ],
+ 'authors' => [
+ ],
+ 'description' => 'Convert CSS stylesheets between left-to-right ' .
+ 'and right-to-left.',
+ ],
+ 'cdb/cdb' => [
+ 'version' => '1.0.0',
+ 'type' => 'library',
+ 'licenses' => [ 'GPLv2' ],
+ 'authors' => [
+ [
+ 'name' => 'Tim Starling',
+ 'email' => 'tstarling@wikimedia.org',
+ ],
+ [
+ 'name' => 'Chad Horohoe',
+ 'email' => 'chad@wikimedia.org',
+ ],
+ ],
+ 'description' => 'Constant Database (CDB) wrapper library for PHP. ' .
+ 'Provides pure-PHP fallback when dba_* functions are absent.',
+ ],
+ 'sebastian/version' => [
+ 'version' => '2.0.1',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ 'role' => 'lead',
+ ],
+ ],
+ 'description' => 'Library that helps with managing the version ' .
+ 'number of Git-hosted PHP projects',
+ ],
+ 'sebastian/resource-operations' => [
+ 'version' => '1.0.0',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ ],
+ ],
+ 'description' => 'Provides a list of PHP built-in functions that ' .
+ 'operate on resources',
+ ],
+ 'sebastian/recursion-context' => [
+ 'version' => '3.0.0',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Jeff Welch',
+ 'email' => 'whatthejeff@gmail.com',
+ ],
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ ],
+ [
+ 'name' => 'Adam Harvey',
+ 'email' => 'aharvey@php.net',
+ ],
+ ],
+ 'description' => 'Provides functionality to recursively process PHP ' .
+ 'variables',
+ ],
+ 'sebastian/object-reflector' => [
+ 'version' => '1.1.1',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ ],
+ ],
+ 'description' => 'Allows reflection of object attributes, including ' .
+ 'inherited and non-public ones',
+ ],
+ 'sebastian/object-enumerator' => [
+ 'version' => '3.0.3',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ ],
+ ],
+ 'description' => 'Traverses array structures and object graphs ' .
+ 'to enumerate all referenced objects',
+ ],
+ 'sebastian/global-state' => [
+ 'version' => '2.0.0',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ ],
+ ],
+ 'description' => 'Snapshotting of global state',
+ ],
+ 'sebastian/exporter' => [
+ 'version' => '3.1.0',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Jeff Welch',
+ 'email' => 'whatthejeff@gmail.com',
+ ],
+ [
+ 'name' => 'Volker Dusch',
+ 'email' => 'github@wallbash.com',
+ ],
+ [
+ 'name' => 'Bernhard Schussek',
+ 'email' => 'bschussek@2bepublished.at',
+ ],
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ ],
+ [
+ 'name' => 'Adam Harvey',
+ 'email' => 'aharvey@php.net',
+ ],
+ ],
+ 'description' => 'Provides the functionality to export PHP ' .
+ 'variables for visualization',
+ ],
+ 'sebastian/environment' => [
+ 'version' => '3.1.0',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ ],
+ ],
+ 'description' => 'Provides functionality to handle HHVM/PHP ' .
+ 'environments',
+ ],
+ 'sebastian/diff' => [
+ 'version' => '2.0.1',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Kore Nordmann',
+ 'email' => 'mail@kore-nordmann.de',
+ ],
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ ],
+ ],
+ 'description' => 'Diff implementation',
+ ],
+ 'sebastian/comparator' => [
+ 'version' => '2.1.1',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Jeff Welch',
+ 'email' => 'whatthejeff@gmail.com',
+ ],
+ [
+ 'name' => 'Volker Dusch',
+ 'email' => 'github@wallbash.com',
+ ],
+ [
+ 'name' => 'Bernhard Schussek',
+ 'email' => 'bschussek@2bepublished.at',
+ ],
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ ],
+ ],
+ 'description' => 'Provides the functionality to compare PHP ' .
+ 'values for equality',
+ ],
+ 'doctrine/instantiator' => [
+ 'version' => '1.1.0',
+ 'type' => 'library',
+ 'licenses' => [ 'MIT' ],
+ 'authors' => [
+ [
+ 'name' => 'Marco Pivetta',
+ 'email' => 'ocramius@gmail.com',
+ 'homepage' => 'http://ocramius.github.com/',
+ ],
+ ],
+ 'description' => 'A small, lightweight utility to instantiate ' .
+ 'objects in PHP without invoking their constructors',
+ ],
+ 'phpunit/php-text-template' => [
+ 'version' => '1.2.1',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ 'role' => 'lead',
+ ],
+ ],
+ 'description' => 'Simple template engine.',
+ ],
+ 'phpunit/phpunit-mock-objects' => [
+ 'version' => '5.0.6',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ 'role' => 'lead',
+ ],
+ ],
+ 'description' => 'Mock Object library for PHPUnit',
+ ],
+ 'phpunit/php-timer' => [
+ 'version' => '1.0.9',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sb@sebastian-bergmann.de',
+ 'role' => 'lead',
+ ],
+ ],
+ 'description' => 'Utility class for timing',
+ ],
+ 'phpunit/php-file-iterator' => [
+ 'version' => '1.4.5',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sb@sebastian-bergmann.de',
+ 'role' => 'lead',
+ ],
+ ],
+ 'description' => 'FilterIterator implementation that filters ' .
+ 'files based on a list of suffixes.',
+ ],
+ 'theseer/tokenizer' => [
+ 'version' => '1.1.0',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Arne Blankerts',
+ 'email' => 'arne@blankerts.de',
+ 'role' => 'Developer',
+ ],
+ ],
+ 'description' => 'A small library for converting tokenized PHP ' .
+ 'source code into XML and potentially other formats',
+ ],
+ 'sebastian/code-unit-reverse-lookup' => [
+ 'version' => '1.0.1',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ ],
+ ],
+ 'description' => 'Looks up which function or method a line of ' .
+ 'code belongs to',
+ ],
+ 'phpunit/php-token-stream' => [
+ 'version' => '2.0.2',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ ],
+ ],
+ 'description' => 'Wrapper around PHP\'s tokenizer extension.',
+ ],
+ 'phpunit/php-code-coverage' => [
+ 'version' => '5.3.0',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ 'role' => 'lead',
+ ],
+ ],
+ 'description' => 'Library that provides collection, processing, ' .
+ 'and rendering functionality for PHP code coverage information.',
+ ],
+ 'webmozart/assert' => [
+ 'version' => '1.2.0',
+ 'type' => 'library',
+ 'licenses' => [ 'MIT' ],
+ 'authors' => [
+ [
+ 'name' => 'Bernhard Schussek',
+ 'email' => 'bschussek@gmail.com',
+ ],
+ ],
+ 'description' => 'Assertions to validate method input/output with ' .
+ 'nice error messages.',
+ ],
+ 'phpdocumentor/reflection-common' => [
+ 'version' => '1.0.1',
+ 'type' => 'library',
+ 'licenses' => [ 'MIT' ],
+ 'authors' => [
+ [
+ 'name' => 'Jaap van Otterdijk',
+ 'email' => 'opensource@ijaap.nl',
+ ],
+ ],
+ 'description' => 'Common reflection classes used by phpdocumentor to ' .
+ 'reflect the code structure',
+ ],
+ 'phpdocumentor/type-resolver' => [
+ 'version' => '0.4.0',
+ 'type' => 'library',
+ 'licenses' => [ 'MIT' ],
+ 'authors' => [
+ [
+ 'name' => 'Mike van Riel',
+ 'email' => 'me@mikevanriel.com',
+ ],
+ ],
+ 'description' => '',
+ ],
+ 'phpdocumentor/reflection-docblock' => [
+ 'version' => '4.2.0',
+ 'type' => 'library',
+ 'licenses' => [ 'MIT' ],
+ 'authors' => [
+ [
+ 'name' => 'Mike van Riel',
+ 'email' => 'me@mikevanriel.com',
+ ],
+ ],
+ 'description' => 'With this component, a library can provide support for ' .
+ 'annotations via DocBlocks or otherwise retrieve information that ' .
+ 'is embedded in a DocBlock.',
+ ],
+ 'phpspec/prophecy' => [
+ 'version' => '1.7.3',
+ 'type' => 'library',
+ 'licenses' => [ 'MIT' ],
+ 'authors' => [
+ [
+ 'name' => 'Konstantin Kudryashov',
+ 'email' => 'ever.zet@gmail.com',
+ 'homepage' => 'http://everzet.com',
+ ],
+ [
+ 'name' => 'Marcello Duarte',
+ 'email' => 'marcello.duarte@gmail.com',
+ ],
+ ],
+ 'description' => 'Highly opinionated mocking framework for PHP 5.3+',
+ ],
+ 'phar-io/version' => [
+ 'version' => '1.0.1',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Arne Blankerts',
+ 'email' => 'arne@blankerts.de',
+ 'role' => 'Developer',
+ ],
+ [
+ 'name' => 'Sebastian Heuer',
+ 'email' => 'sebastian@phpeople.de',
+ 'role' => 'Developer',
+ ],
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ 'role' => 'Developer',
+ ],
+ ],
+ 'description' => 'Library for handling version information and constraints',
+ ],
+ 'phar-io/manifest' => [
+ 'version' => '1.0.1',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Arne Blankerts',
+ 'email' => 'arne@blankerts.de',
+ 'role' => 'Developer',
+ ],
+ [
+ 'name' => 'Sebastian Heuer',
+ 'email' => 'sebastian@phpeople.de',
+ 'role' => 'Developer',
+ ],
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ 'role' => 'Developer',
+ ],
+ ],
+ 'description' => 'Component for reading phar.io manifest ' .
+ 'information from a PHP Archive (PHAR)',
+ ],
+ 'myclabs/deep-copy' => [
+ 'version' => '1.7.0',
+ 'type' => 'library',
+ 'licenses' => [ 'MIT' ],
+ 'authors' => [
+ ],
+ 'description' => 'Create deep copies (clones) of your objects',
+ ],
+ 'phpunit/phpunit' => [
+ 'version' => '6.5.5',
+ 'type' => 'library',
+ 'licenses' => [ 'BSD-3-Clause' ],
+ 'authors' => [
+ [
+ 'name' => 'Sebastian Bergmann',
+ 'email' => 'sebastian@phpunit.de',
+ 'role' => 'lead',
+ ],
+ ],
+ 'description' => 'The PHP Unit Testing framework.',
+ ],
+ ], $installed->getInstalledDependencies(), false, true );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/composer/ComposerJsonTest.php b/www/wiki/tests/phpunit/includes/libs/composer/ComposerJsonTest.php
new file mode 100644
index 00000000..ded5f8fe
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/composer/ComposerJsonTest.php
@@ -0,0 +1,42 @@
+<?php
+
+class ComposerJsonTest extends MediaWikiTestCase {
+
+ private $json, $json2;
+
+ public function setUp() {
+ parent::setUp();
+ global $IP;
+ $this->json = "$IP/tests/phpunit/data/composer/composer.json";
+ $this->json2 = "$IP/tests/phpunit/data/composer/new-composer.json";
+ }
+
+ /**
+ * @covers ComposerJson::__construct
+ * @covers ComposerJson::getRequiredDependencies
+ */
+ public function testGetRequiredDependencies() {
+ $json = new ComposerJson( $this->json );
+ $this->assertArrayEquals( [
+ 'cdb/cdb' => '1.0.0',
+ 'cssjanus/cssjanus' => '1.1.1',
+ 'leafo/lessphp' => '0.5.0',
+ 'psr/log' => '1.0.0',
+ ], $json->getRequiredDependencies(), false, true );
+ }
+
+ public static function provideNormalizeVersion() {
+ return [
+ [ 'v1.0.0', '1.0.0' ],
+ [ '0.0.5', '0.0.5' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideNormalizeVersion
+ * @covers ComposerJson::normalizeVersion
+ */
+ public function testNormalizeVersion( $input, $expected ) {
+ $this->assertEquals( $expected, ComposerJson::normalizeVersion( $input ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/composer/ComposerLockTest.php b/www/wiki/tests/phpunit/includes/libs/composer/ComposerLockTest.php
new file mode 100644
index 00000000..dc81e1d3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/composer/ComposerLockTest.php
@@ -0,0 +1,121 @@
+<?php
+
+class ComposerLockTest extends MediaWikiTestCase {
+
+ private $lock;
+
+ public function setUp() {
+ parent::setUp();
+ global $IP;
+ $this->lock = "$IP/tests/phpunit/data/composer/composer.lock";
+ }
+
+ /**
+ * @covers ComposerLock::__construct
+ * @covers ComposerLock::getInstalledDependencies
+ */
+ public function testGetInstalledDependencies() {
+ $lock = new ComposerLock( $this->lock );
+ $this->assertArrayEquals( [
+ 'wikimedia/cdb' => [
+ 'version' => '1.0.1',
+ 'type' => 'library',
+ 'licenses' => [ 'GPL-2.0-only' ],
+ 'authors' => [
+ [
+ 'name' => 'Tim Starling',
+ 'email' => 'tstarling@wikimedia.org',
+ ],
+ [
+ 'name' => 'Chad Horohoe',
+ 'email' => 'chad@wikimedia.org',
+ ],
+ ],
+ 'description' => 'Constant Database (CDB) wrapper library for PHP. '.
+ 'Provides pure-PHP fallback when dba_* functions are absent.',
+ ],
+ 'cssjanus/cssjanus' => [
+ 'version' => '1.1.1',
+ 'type' => 'library',
+ 'licenses' => [ 'Apache-2.0' ],
+ 'authors' => [],
+ 'description' => 'Convert CSS stylesheets between left-to-right and right-to-left.',
+ ],
+ 'leafo/lessphp' => [
+ 'version' => '0.5.0',
+ 'type' => 'library',
+ 'licenses' => [ 'MIT', 'GPL-3.0-only' ],
+ 'authors' => [
+ [
+ 'name' => 'Leaf Corcoran',
+ 'email' => 'leafot@gmail.com',
+ 'homepage' => 'http://leafo.net',
+ ],
+ ],
+ 'description' => 'lessphp is a compiler for LESS written in PHP.',
+ ],
+ 'psr/log' => [
+ 'version' => '1.0.0',
+ 'type' => 'library',
+ 'licenses' => [ 'MIT' ],
+ 'authors' => [
+ [
+ 'name' => 'PHP-FIG',
+ 'homepage' => 'http://www.php-fig.org/',
+ ],
+ ],
+ 'description' => 'Common interface for logging libraries',
+ ],
+ 'oojs/oojs-ui' => [
+ 'version' => '0.6.0',
+ 'type' => 'library',
+ 'licenses' => [ 'MIT' ],
+ 'authors' => [],
+ 'description' => '',
+ ],
+ 'composer/installers' => [
+ 'version' => '1.0.19',
+ 'type' => 'composer-installer',
+ 'licenses' => [ 'MIT' ],
+ 'authors' => [
+ [
+ 'name' => 'Kyle Robinson Young',
+ 'email' => 'kyle@dontkry.com',
+ 'homepage' => 'https://github.com/shama',
+ ],
+ ],
+ 'description' => 'A multi-framework Composer library installer',
+ ],
+ 'mediawiki/translate' => [
+ 'version' => '2014.12',
+ 'type' => 'mediawiki-extension',
+ 'licenses' => [ 'GPL-2.0-or-later' ],
+ 'authors' => [
+ [
+ 'name' => 'Niklas Laxström',
+ 'email' => 'niklas.laxstrom@gmail.com',
+ 'role' => 'Lead nitpicker',
+ ],
+ [
+ 'name' => 'Siebrand Mazeland',
+ 'email' => 's.mazeland@xs4all.nl',
+ 'role' => 'Developer',
+ ],
+ ],
+ 'description' => 'The only standard solution to translate any kind ' .
+ 'of text with an avant-garde web interface within MediaWiki, ' .
+ 'including your documentation and software',
+ ],
+ 'mediawiki/universal-language-selector' => [
+ 'version' => '2014.12',
+ 'type' => 'mediawiki-extension',
+ 'licenses' => [ 'GPL-2.0-or-later', 'MIT' ],
+ 'authors' => [],
+ 'description' => 'The primary aim is to allow users to select a language ' .
+ 'and configure its support in an easy way. ' .
+ 'Main features are language selection, input methods and web fonts.',
+ ],
+ ], $lock->getInstalledDependencies(), false, true );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php b/www/wiki/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php
new file mode 100644
index 00000000..02eac118
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php
@@ -0,0 +1,150 @@
+<?php
+
+use Wikimedia\Http\HttpAcceptNegotiator;
+
+/**
+ * @covers Wikimedia\Http\HttpAcceptNegotiator
+ *
+ * @author Daniel Kinzler
+ */
+class HttpAcceptNegotiatorTest extends \PHPUnit\Framework\TestCase {
+
+ public function provideGetFirstSupportedValue() {
+ return [
+ [ // #0: empty
+ [], // supported
+ [], // accepted
+ null, // default
+ null, // expected
+ ],
+ [ // #1: simple
+ [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported
+ [ 'text/xzy', 'text/bar' ], // accepted
+ null, // default
+ 'text/BAR', // expected
+ ],
+ [ // #2: default
+ [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported
+ [ 'text/xzy', 'text/xoo' ], // accepted
+ 'X', // default
+ 'X', // expected
+ ],
+ [ // #3: preference
+ [ 'text/foo', 'text/bar', 'application/zuul' ], // supported
+ [ 'text/xoo', 'text/BAR', 'text/foo' ], // accepted
+ null, // default
+ 'text/bar', // expected
+ ],
+ [ // #4: * wildcard
+ [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported
+ [ 'text/xoo', '*' ], // accepted
+ null, // default
+ 'text/foo', // expected
+ ],
+ [ // #5: */* wildcard
+ [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported
+ [ 'text/xoo', '*/*' ], // accepted
+ null, // default
+ 'text/foo', // expected
+ ],
+ [ // #6: text/* wildcard
+ [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported
+ [ 'application/*', 'text/foo' ], // accepted
+ null, // default
+ 'application/zuul', // expected
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetFirstSupportedValue
+ */
+ public function testGetFirstSupportedValue( $supported, $accepted, $default, $expected ) {
+ $negotiator = new HttpAcceptNegotiator( $supported );
+ $actual = $negotiator->getFirstSupportedValue( $accepted, $default );
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public function provideGetBestSupportedKey() {
+ return [
+ [ // #0: empty
+ [], // supported
+ [], // accepted
+ null, // default
+ null, // expected
+ ],
+ [ // #1: simple
+ [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported
+ [ 'text/xzy' => 1, 'text/bar' => 0.5 ], // accepted
+ null, // default
+ 'text/BAR', // expected
+ ],
+ [ // #2: default
+ [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported
+ [ 'text/xzy' => 1, 'text/xoo' => 0.5 ], // accepted
+ 'X', // default
+ 'X', // expected
+ ],
+ [ // #3: weighted
+ [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported
+ [ 'text/foo' => 0.3, 'text/BAR' => 0.8, 'application/zuul' => 0.5 ], // accepted
+ null, // default
+ 'text/BAR', // expected
+ ],
+ [ // #4: zero weight
+ [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported
+ [ 'text/foo' => 0, 'text/xoo' => 1 ], // accepted
+ null, // default
+ null, // expected
+ ],
+ [ // #5: * wildcard
+ [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported
+ [ 'text/xoo' => 0.5, '*' => 0.1 ], // accepted
+ null, // default
+ 'text/foo', // expected
+ ],
+ [ // #6: */* wildcard
+ [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported
+ [ 'text/xoo' => 0.5, '*/*' => 0.1 ], // accepted
+ null, // default
+ 'text/foo', // expected
+ ],
+ [ // #7: text/* wildcard
+ [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported
+ [ 'text/foo' => 0.3, 'application/*' => 0.8 ], // accepted
+ null, // default
+ 'application/zuul', // expected
+ ],
+ [ // #8: Test specific format preferred over wildcard (T133314)
+ [ 'application/rdf+xml', 'text/json', 'text/html' ], // supported
+ [ '*/*' => 1, 'text/html' => 1 ], // accepted
+ null, // default
+ 'text/html', // expected
+ ],
+ [ // #9: Test specific format preferred over range (T133314)
+ [ 'application/rdf+xml', 'text/json', 'text/html' ], // supported
+ [ 'text/*' => 1, 'text/html' => 1 ], // accepted
+ null, // default
+ 'text/html', // expected
+ ],
+ [ // #10: Test range preferred over wildcard (T133314)
+ [ 'application/rdf+xml', 'text/html' ], // supported
+ [ '*/*' => 1, 'text/*' => 1 ], // accepted
+ null, // default
+ 'text/html', // expected
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetBestSupportedKey
+ */
+ public function testGetBestSupportedKey( $supported, $accepted, $default, $expected ) {
+ $negotiator = new HttpAcceptNegotiator( $supported );
+ $actual = $negotiator->getBestSupportedKey( $accepted, $default );
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php b/www/wiki/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php
new file mode 100644
index 00000000..e4b47b46
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php
@@ -0,0 +1,56 @@
+<?php
+
+use Wikimedia\Http\HttpAcceptParser;
+
+/**
+ * @covers Wikimedia\Http\HttpAcceptParser
+ *
+ * @author Daniel Kinzler
+ */
+class HttpAcceptParserTest extends \PHPUnit\Framework\TestCase {
+
+ public function provideParseWeights() {
+ return [
+ [ // #0
+ '',
+ []
+ ],
+ [ // #1
+ 'Foo/Bar',
+ [ 'foo/bar' => 1 ]
+ ],
+ [ // #2
+ 'Accept: text/plain',
+ [ 'text/plain' => 1 ]
+ ],
+ [ // #3
+ 'Accept: application/vnd.php.serialized, application/rdf+xml',
+ [ 'application/vnd.php.serialized' => 1, 'application/rdf+xml' => 1 ]
+ ],
+ [ // #4
+ 'foo; q=0.2, xoo; q=0,text/n3',
+ [ 'text/n3' => 1, 'foo' => 0.2 ]
+ ],
+ [ // #5
+ '*; q=0.2, */*; q=0.1,text/*',
+ [ 'text/*' => 1, '*' => 0.2, '*/*' => 0.1 ]
+ ],
+ // TODO: nicely ignore additional type paramerters
+ //[ // #6
+ // 'Foo; q=0.2, Xoo; level=3, Bar; charset=xyz; q=0.4',
+ // [ 'xoo' => 1, 'bar' => 0.4, 'foo' => 0.1 ]
+ //],
+ ];
+ }
+
+ /**
+ * @dataProvider provideParseWeights
+ */
+ public function testParseWeights( $header, $expected ) {
+ $parser = new HttpAcceptParser();
+ $actual = $parser->parseWeights( $header );
+
+ $this->assertEquals( $expected, $actual ); // shouldn't be sensitive to order
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php b/www/wiki/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php
new file mode 100644
index 00000000..fbe5a2ba
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php
@@ -0,0 +1,131 @@
+<?php
+/**
+ * @group Media
+ * @covers MimeAnalyzer
+ */
+class MimeAnalyzerTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /** @var MimeAnalyzer */
+ private $mimeAnalyzer;
+
+ function setUp() {
+ global $IP;
+
+ $this->mimeAnalyzer = new MimeAnalyzer( [
+ 'infoFile' => $IP . "/includes/libs/mime/mime.info",
+ 'typeFile' => $IP . "/includes/libs/mime/mime.types",
+ 'xmlTypes' => [
+ 'http://www.w3.org/2000/svg:svg' => 'image/svg+xml',
+ 'svg' => 'image/svg+xml',
+ 'http://www.lysator.liu.se/~alla/dia/:diagram' => 'application/x-dia-diagram',
+ 'http://www.w3.org/1999/xhtml:html' => 'text/html', // application/xhtml+xml?
+ 'html' => 'text/html', // application/xhtml+xml?
+ ]
+ ] );
+ parent::setUp();
+ }
+
+ function doGuessMimeType( array $parameters = [] ) {
+ $class = new ReflectionClass( get_class( $this->mimeAnalyzer ) );
+ $method = $class->getMethod( 'doGuessMimeType' );
+ $method->setAccessible( true );
+ return $method->invokeArgs( $this->mimeAnalyzer, $parameters );
+ }
+
+ /**
+ * @dataProvider providerImproveTypeFromExtension
+ * @param string $ext File extension (no leading dot)
+ * @param string $oldMime Initially detected MIME
+ * @param string $expectedMime MIME type after taking extension into account
+ */
+ function testImproveTypeFromExtension( $ext, $oldMime, $expectedMime ) {
+ $actualMime = $this->mimeAnalyzer->improveTypeFromExtension( $oldMime, $ext );
+ $this->assertEquals( $expectedMime, $actualMime );
+ }
+
+ function providerImproveTypeFromExtension() {
+ return [
+ [ 'gif', 'image/gif', 'image/gif' ],
+ [ 'gif', 'unknown/unknown', 'unknown/unknown' ],
+ [ 'wrl', 'unknown/unknown', 'model/vrml' ],
+ [ 'txt', 'text/plain', 'text/plain' ],
+ [ 'csv', 'text/plain', 'text/csv' ],
+ [ 'tsv', 'text/plain', 'text/tab-separated-values' ],
+ [ 'js', 'text/javascript', 'application/javascript' ],
+ [ 'js', 'application/x-javascript', 'application/javascript' ],
+ [ 'json', 'text/plain', 'application/json' ],
+ [ 'foo', 'application/x-opc+zip', 'application/zip' ],
+ [ 'docx', 'application/x-opc+zip',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ],
+ [ 'djvu', 'image/x-djvu', 'image/vnd.djvu' ],
+ [ 'wav', 'audio/wav', 'audio/wav' ],
+ ];
+ }
+
+ /**
+ * Test to make sure that encoder=ffmpeg2theora doesn't trigger
+ * MEDIATYPE_VIDEO (T65584)
+ */
+ function testOggRecognize() {
+ $oggFile = __DIR__ . '/../../../data/media/say-test.ogg';
+ $actualType = $this->mimeAnalyzer->getMediaType( $oggFile, 'application/ogg' );
+ $this->assertEquals( MEDIATYPE_AUDIO, $actualType );
+ }
+
+ /**
+ * Test to make sure that Opus audio files don't trigger
+ * MEDIATYPE_MULTIMEDIA (bug T151352)
+ */
+ function testOpusRecognize() {
+ $oggFile = __DIR__ . '/../../../data/media/say-test.opus';
+ $actualType = $this->mimeAnalyzer->getMediaType( $oggFile, 'application/ogg' );
+ $this->assertEquals( MEDIATYPE_AUDIO, $actualType );
+ }
+
+ /**
+ * Test to make sure that mp3 files are detected as audio type
+ */
+ function testMP3AsAudio() {
+ $file = __DIR__ . '/../../../data/media/say-test-with-id3.mp3';
+ $actualType = $this->mimeAnalyzer->getMediaType( $file );
+ $this->assertEquals( MEDIATYPE_AUDIO, $actualType );
+ }
+
+ /**
+ * Test to make sure that MP3 with id3 tag is recognized
+ */
+ function testMP3WithID3Recognize() {
+ $file = __DIR__ . '/../../../data/media/say-test-with-id3.mp3';
+ $actualType = $this->doGuessMimeType( [ $file, 'mp3' ] );
+ $this->assertEquals( 'audio/mpeg', $actualType );
+ }
+
+ /**
+ * Test to make sure that MP3 without id3 tag is recognized (MPEG-1 sample rates)
+ */
+ function testMP3NoID3RecognizeMPEG1() {
+ $file = __DIR__ . '/../../../data/media/say-test-mpeg1.mp3';
+ $actualType = $this->doGuessMimeType( [ $file, 'mp3' ] );
+ $this->assertEquals( 'audio/mpeg', $actualType );
+ }
+
+ /**
+ * Test to make sure that MP3 without id3 tag is recognized (MPEG-2 sample rates)
+ */
+ function testMP3NoID3RecognizeMPEG2() {
+ $file = __DIR__ . '/../../../data/media/say-test-mpeg2.mp3';
+ $actualType = $this->doGuessMimeType( [ $file, 'mp3' ] );
+ $this->assertEquals( 'audio/mpeg', $actualType );
+ }
+
+ /**
+ * Test to make sure that MP3 without id3 tag is recognized (MPEG-2.5 sample rates)
+ */
+ function testMP3NoID3RecognizeMPEG2_5() {
+ $file = __DIR__ . '/../../../data/media/say-test-mpeg2.5.mp3';
+ $actualType = $this->doGuessMimeType( [ $file, 'mp3' ] );
+ $this->assertEquals( 'audio/mpeg', $actualType );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php b/www/wiki/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php
new file mode 100644
index 00000000..10fba835
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php
@@ -0,0 +1,300 @@
+<?php
+
+use Wikimedia\ScopedCallback;
+
+/**
+ * @author Matthias Mullie <mmullie@wikimedia.org>
+ * @group BagOStuff
+ */
+class BagOStuffTest extends MediaWikiTestCase {
+ /** @var BagOStuff */
+ private $cache;
+
+ protected function setUp() {
+ parent::setUp();
+
+ // type defined through parameter
+ if ( $this->getCliArg( 'use-bagostuff' ) ) {
+ $name = $this->getCliArg( 'use-bagostuff' );
+
+ $this->cache = ObjectCache::newFromId( $name );
+ } else {
+ // no type defined - use simple hash
+ $this->cache = new HashBagOStuff;
+ }
+
+ $this->cache->delete( wfMemcKey( 'test' ) );
+ }
+
+ /**
+ * @covers BagOStuff::makeGlobalKey
+ * @covers BagOStuff::makeKeyInternal
+ */
+ public function testMakeKey() {
+ $cache = ObjectCache::newFromId( 'hash' );
+
+ $localKey = $cache->makeKey( 'first', 'second', 'third' );
+ $globalKey = $cache->makeGlobalKey( 'first', 'second', 'third' );
+
+ $this->assertStringMatchesFormat(
+ '%Sfirst%Ssecond%Sthird%S',
+ $localKey,
+ 'Local key interpolates parameters'
+ );
+
+ $this->assertStringMatchesFormat(
+ 'global%Sfirst%Ssecond%Sthird%S',
+ $globalKey,
+ 'Global key interpolates parameters and contains global prefix'
+ );
+
+ $this->assertNotEquals(
+ $localKey,
+ $globalKey,
+ 'Local key and global key with same parameters should not be equal'
+ );
+
+ $this->assertNotEquals(
+ $cache->makeKeyInternal( 'prefix', [ 'a', 'bc:', 'de' ] ),
+ $cache->makeKeyInternal( 'prefix', [ 'a', 'bc', ':de' ] )
+ );
+ }
+
+ /**
+ * @covers BagOStuff::merge
+ * @covers BagOStuff::mergeViaLock
+ */
+ public function testMerge() {
+ $key = wfMemcKey( 'test' );
+
+ $usleep = 0;
+
+ /**
+ * Callback method: append "merged" to whatever is in cache.
+ *
+ * @param BagOStuff $cache
+ * @param string $key
+ * @param int $existingValue
+ * @use int $usleep
+ * @return int
+ */
+ $callback = function ( BagOStuff $cache, $key, $existingValue ) use ( &$usleep ) {
+ // let's pretend this is an expensive callback to test concurrent merge attempts
+ usleep( $usleep );
+
+ if ( $existingValue === false ) {
+ return 'merged';
+ }
+
+ return $existingValue . 'merged';
+ };
+
+ // merge on non-existing value
+ $merged = $this->cache->merge( $key, $callback, 0 );
+ $this->assertTrue( $merged );
+ $this->assertEquals( 'merged', $this->cache->get( $key ) );
+
+ // merge on existing value
+ $merged = $this->cache->merge( $key, $callback, 0 );
+ $this->assertTrue( $merged );
+ $this->assertEquals( 'mergedmerged', $this->cache->get( $key ) );
+
+ /*
+ * Test concurrent merges by forking this process, if:
+ * - not manually called with --use-bagostuff
+ * - pcntl_fork is supported by the system
+ * - cache type will correctly support calls over forks
+ */
+ $fork = (bool)$this->getCliArg( 'use-bagostuff' );
+ $fork &= function_exists( 'pcntl_fork' );
+ $fork &= !$this->cache instanceof HashBagOStuff;
+ $fork &= !$this->cache instanceof EmptyBagOStuff;
+ $fork &= !$this->cache instanceof MultiWriteBagOStuff;
+ if ( $fork ) {
+ // callback should take awhile now so that we can test concurrent merge attempts
+ $pid = pcntl_fork();
+ if ( $pid == -1 ) {
+ // can't fork, ignore this test...
+ } elseif ( $pid ) {
+ // wait a little, making sure that the child process is calling merge
+ usleep( 3000 );
+
+ // attempt a merge - this should fail
+ $merged = $this->cache->merge( $key, $callback, 0, 1 );
+
+ // merge has failed because child process was merging (and we only attempted once)
+ $this->assertFalse( $merged );
+
+ // make sure the child's merge is completed and verify
+ usleep( 3000 );
+ $this->assertEquals( $this->cache->get( $key ), 'mergedmergedmerged' );
+ } else {
+ $this->cache->merge( $key, $callback, 0, 1 );
+
+ // Note: I'm not even going to check if the merge worked, I'll
+ // compare values in the parent process to test if this merge worked.
+ // I'm just going to exit this child process, since I don't want the
+ // child to output any test results (would be rather confusing to
+ // have test output twice)
+ exit;
+ }
+ }
+ }
+
+ /**
+ * @covers BagOStuff::changeTTL
+ */
+ public function testChangeTTL() {
+ $key = wfMemcKey( 'test' );
+ $value = 'meow';
+
+ $this->cache->add( $key, $value );
+ $this->assertTrue( $this->cache->changeTTL( $key, 5 ) );
+ $this->assertEquals( $this->cache->get( $key ), $value );
+ $this->cache->delete( $key );
+ $this->assertFalse( $this->cache->changeTTL( $key, 5 ) );
+ }
+
+ /**
+ * @covers BagOStuff::add
+ */
+ public function testAdd() {
+ $key = wfMemcKey( 'test' );
+ $this->assertTrue( $this->cache->add( $key, 'test' ) );
+ }
+
+ /**
+ * @covers BagOStuff::get
+ */
+ public function testGet() {
+ $value = [ 'this' => 'is', 'a' => 'test' ];
+
+ $key = wfMemcKey( 'test' );
+ $this->cache->add( $key, $value );
+ $this->assertEquals( $this->cache->get( $key ), $value );
+ }
+
+ /**
+ * @covers BagOStuff::getWithSetCallback
+ */
+ public function testGetWithSetCallback() {
+ $key = wfMemcKey( 'test' );
+ $value = $this->cache->getWithSetCallback(
+ $key,
+ 30,
+ function () {
+ return 'hello kitty';
+ }
+ );
+
+ $this->assertEquals( 'hello kitty', $value );
+ $this->assertEquals( $value, $this->cache->get( $key ) );
+ }
+
+ /**
+ * @covers BagOStuff::incr
+ */
+ public function testIncr() {
+ $key = wfMemcKey( 'test' );
+ $this->cache->add( $key, 0 );
+ $this->cache->incr( $key );
+ $expectedValue = 1;
+ $actualValue = $this->cache->get( $key );
+ $this->assertEquals( $expectedValue, $actualValue, 'Value should be 1 after incrementing' );
+ }
+
+ /**
+ * @covers BagOStuff::incrWithInit
+ */
+ public function testIncrWithInit() {
+ $key = wfMemcKey( 'test' );
+ $val = $this->cache->incrWithInit( $key, 0, 1, 3 );
+ $this->assertEquals( 3, $val, "Correct init value" );
+
+ $val = $this->cache->incrWithInit( $key, 0, 1, 3 );
+ $this->assertEquals( 4, $val, "Correct init value" );
+ }
+
+ /**
+ * @covers BagOStuff::getMulti
+ */
+ public function testGetMulti() {
+ $value1 = [ 'this' => 'is', 'a' => 'test' ];
+ $value2 = [ 'this' => 'is', 'another' => 'test' ];
+ $value3 = [ 'testing a key that may be encoded when sent to cache backend' ];
+ $value4 = [ 'another test where chars in key will be encoded' ];
+
+ $key1 = wfMemcKey( 'test1' );
+ $key2 = wfMemcKey( 'test2' );
+ // internally, MemcachedBagOStuffs will encode to will-%25-encode
+ $key3 = wfMemcKey( 'will-%-encode' );
+ $key4 = wfMemcKey(
+ 'flowdb:flow_ref:wiki:by-source:v3:Parser\'s_"broken"_+_(page)_&_grill:testwiki:1:4.7'
+ );
+
+ $this->cache->add( $key1, $value1 );
+ $this->cache->add( $key2, $value2 );
+ $this->cache->add( $key3, $value3 );
+ $this->cache->add( $key4, $value4 );
+
+ $this->assertEquals(
+ [ $key1 => $value1, $key2 => $value2, $key3 => $value3, $key4 => $value4 ],
+ $this->cache->getMulti( [ $key1, $key2, $key3, $key4 ] )
+ );
+
+ // cleanup
+ $this->cache->delete( $key1 );
+ $this->cache->delete( $key2 );
+ $this->cache->delete( $key3 );
+ $this->cache->delete( $key4 );
+ }
+
+ /**
+ * @covers BagOStuff::getScopedLock
+ */
+ public function testGetScopedLock() {
+ $key = wfMemcKey( 'test' );
+ $value1 = $this->cache->getScopedLock( $key, 0 );
+ $value2 = $this->cache->getScopedLock( $key, 0 );
+
+ $this->assertType( ScopedCallback::class, $value1, 'First call returned lock' );
+ $this->assertNull( $value2, 'Duplicate call returned no lock' );
+
+ unset( $value1 );
+
+ $value3 = $this->cache->getScopedLock( $key, 0 );
+ $this->assertType( ScopedCallback::class, $value3, 'Lock returned callback after release' );
+ unset( $value3 );
+
+ $value1 = $this->cache->getScopedLock( $key, 0, 5, 'reentry' );
+ $value2 = $this->cache->getScopedLock( $key, 0, 5, 'reentry' );
+
+ $this->assertType( ScopedCallback::class, $value1, 'First reentrant call returned lock' );
+ $this->assertType( ScopedCallback::class, $value1, 'Second reentrant call returned lock' );
+ }
+
+ /**
+ * @covers BagOStuff::__construct
+ * @covers BagOStuff::trackDuplicateKeys
+ */
+ public function testReportDupes() {
+ $logger = $this->createMock( Psr\Log\NullLogger::class );
+ $logger->expects( $this->once() )
+ ->method( 'warning' )
+ ->with( 'Duplicate get(): "{key}" fetched {count} times', [
+ 'key' => 'foo',
+ 'count' => 2,
+ ] );
+
+ $cache = new HashBagOStuff( [
+ 'reportDupes' => true,
+ 'asyncHandler' => 'DeferredUpdates::addCallableUpdate',
+ 'logger' => $logger,
+ ] );
+ $cache->get( 'foo' );
+ $cache->get( 'bar' );
+ $cache->get( 'foo' );
+
+ DeferredUpdates::doUpdates();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php b/www/wiki/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php
new file mode 100644
index 00000000..d0360a99
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php
@@ -0,0 +1,158 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group BagOStuff
+ */
+class CachedBagOStuffTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers CachedBagOStuff::__construct
+ * @covers CachedBagOStuff::doGet
+ */
+ public function testGetFromBackend() {
+ $backend = new HashBagOStuff;
+ $cache = new CachedBagOStuff( $backend );
+
+ $backend->set( 'foo', 'bar' );
+ $this->assertEquals( 'bar', $cache->get( 'foo' ) );
+
+ $backend->set( 'foo', 'baz' );
+ $this->assertEquals( 'bar', $cache->get( 'foo' ), 'cached' );
+ }
+
+ /**
+ * @covers CachedBagOStuff::set
+ * @covers CachedBagOStuff::delete
+ */
+ public function testSetAndDelete() {
+ $backend = new HashBagOStuff;
+ $cache = new CachedBagOStuff( $backend );
+
+ for ( $i = 0; $i < 10; $i++ ) {
+ $cache->set( "key$i", 1 );
+ $this->assertEquals( 1, $cache->get( "key$i" ) );
+ $this->assertEquals( 1, $backend->get( "key$i" ) );
+ $cache->delete( "key$i" );
+ $this->assertEquals( false, $cache->get( "key$i" ) );
+ $this->assertEquals( false, $backend->get( "key$i" ) );
+ }
+ }
+
+ /**
+ * @covers CachedBagOStuff::set
+ * @covers CachedBagOStuff::delete
+ */
+ public function testWriteCacheOnly() {
+ $backend = new HashBagOStuff;
+ $cache = new CachedBagOStuff( $backend );
+
+ $cache->set( 'foo', 'bar', 0, CachedBagOStuff::WRITE_CACHE_ONLY );
+ $this->assertEquals( 'bar', $cache->get( 'foo' ) );
+ $this->assertFalse( $backend->get( 'foo' ) );
+
+ $cache->set( 'foo', 'old' );
+ $this->assertEquals( 'old', $cache->get( 'foo' ) );
+ $this->assertEquals( 'old', $backend->get( 'foo' ) );
+
+ $cache->set( 'foo', 'new', 0, CachedBagOStuff::WRITE_CACHE_ONLY );
+ $this->assertEquals( 'new', $cache->get( 'foo' ) );
+ $this->assertEquals( 'old', $backend->get( 'foo' ) );
+
+ $cache->delete( 'foo', CachedBagOStuff::WRITE_CACHE_ONLY );
+ $this->assertEquals( 'old', $cache->get( 'foo' ) ); // Reloaded from backend
+ }
+
+ /**
+ * @covers CachedBagOStuff::doGet
+ */
+ public function testCacheBackendMisses() {
+ $backend = new HashBagOStuff;
+ $cache = new CachedBagOStuff( $backend );
+
+ // First hit primes the cache with miss from the backend
+ $this->assertEquals( false, $cache->get( 'foo' ) );
+
+ // Change the value in the backend
+ $backend->set( 'foo', true );
+
+ // Second hit returns the cached miss
+ $this->assertEquals( false, $cache->get( 'foo' ) );
+
+ // But a fresh value is read from the backend
+ $backend->set( 'bar', true );
+ $this->assertEquals( true, $cache->get( 'bar' ) );
+ }
+
+ /**
+ * @covers CachedBagOStuff::setDebug
+ */
+ public function testSetDebug() {
+ $backend = new HashBagOStuff();
+ $cache = new CachedBagOStuff( $backend );
+ // Access private property 'debugMode'
+ $backend = TestingAccessWrapper::newFromObject( $backend );
+ $cache = TestingAccessWrapper::newFromObject( $cache );
+ $this->assertFalse( $backend->debugMode );
+ $this->assertFalse( $cache->debugMode );
+
+ $cache->setDebug( true );
+ // Should have set both
+ $this->assertTrue( $backend->debugMode, 'sets backend' );
+ $this->assertTrue( $cache->debugMode, 'sets self' );
+ }
+
+ /**
+ * @covers CachedBagOStuff::deleteObjectsExpiringBefore
+ */
+ public function testExpire() {
+ $backend = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'deleteObjectsExpiringBefore' ] )
+ ->getMock();
+ $backend->expects( $this->once() )
+ ->method( 'deleteObjectsExpiringBefore' )
+ ->willReturn( false );
+
+ $cache = new CachedBagOStuff( $backend );
+ $cache->deleteObjectsExpiringBefore( '20110401000000' );
+ }
+
+ /**
+ * @covers CachedBagOStuff::makeKey
+ */
+ public function testMakeKey() {
+ $backend = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'makeKey' ] )
+ ->getMock();
+ $backend->method( 'makeKey' )
+ ->willReturn( 'special/logic' );
+
+ // CachedBagOStuff wraps any backend with a process cache
+ // using HashBagOStuff. Hash has no special key limitations,
+ // but backends often do. Make sure it uses the backend's
+ // makeKey() logic, not the one inherited from HashBagOStuff
+ $cache = new CachedBagOStuff( $backend );
+
+ $this->assertEquals( 'special/logic', $backend->makeKey( 'special', 'logic' ) );
+ $this->assertEquals( 'special/logic', $cache->makeKey( 'special', 'logic' ) );
+ }
+
+ /**
+ * @covers CachedBagOStuff::makeGlobalKey
+ */
+ public function testMakeGlobalKey() {
+ $backend = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'makeGlobalKey' ] )
+ ->getMock();
+ $backend->method( 'makeGlobalKey' )
+ ->willReturn( 'special/logic' );
+
+ $cache = new CachedBagOStuff( $backend );
+
+ $this->assertEquals( 'special/logic', $backend->makeGlobalKey( 'special', 'logic' ) );
+ $this->assertEquals( 'special/logic', $cache->makeGlobalKey( 'special', 'logic' ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php b/www/wiki/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php
new file mode 100644
index 00000000..332e23b2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php
@@ -0,0 +1,163 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group BagOStuff
+ */
+class HashBagOStuffTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers HashBagOStuff::__construct
+ */
+ public function testConstruct() {
+ $this->assertInstanceOf(
+ HashBagOStuff::class,
+ new HashBagOStuff()
+ );
+ }
+
+ /**
+ * @covers HashBagOStuff::__construct
+ * @expectedException InvalidArgumentException
+ */
+ public function testConstructBadZero() {
+ $cache = new HashBagOStuff( [ 'maxKeys' => 0 ] );
+ }
+
+ /**
+ * @covers HashBagOStuff::__construct
+ * @expectedException InvalidArgumentException
+ */
+ public function testConstructBadNeg() {
+ $cache = new HashBagOStuff( [ 'maxKeys' => -1 ] );
+ }
+
+ /**
+ * @covers HashBagOStuff::__construct
+ * @expectedException InvalidArgumentException
+ */
+ public function testConstructBadType() {
+ $cache = new HashBagOStuff( [ 'maxKeys' => 'x' ] );
+ }
+
+ /**
+ * @covers HashBagOStuff::delete
+ */
+ public function testDelete() {
+ $cache = new HashBagOStuff();
+ for ( $i = 0; $i < 10; $i++ ) {
+ $cache->set( "key$i", 1 );
+ $this->assertEquals( 1, $cache->get( "key$i" ) );
+ $cache->delete( "key$i" );
+ $this->assertEquals( false, $cache->get( "key$i" ) );
+ }
+ }
+
+ /**
+ * @covers HashBagOStuff::clear
+ */
+ public function testClear() {
+ $cache = new HashBagOStuff();
+ for ( $i = 0; $i < 10; $i++ ) {
+ $cache->set( "key$i", 1 );
+ $this->assertEquals( 1, $cache->get( "key$i" ) );
+ }
+ $cache->clear();
+ for ( $i = 0; $i < 10; $i++ ) {
+ $this->assertEquals( false, $cache->get( "key$i" ) );
+ }
+ }
+
+ /**
+ * @covers HashBagOStuff::doGet
+ * @covers HashBagOStuff::expire
+ */
+ public function testExpire() {
+ $cache = new HashBagOStuff();
+ $cacheInternal = TestingAccessWrapper::newFromObject( $cache );
+ $cache->set( 'foo', 1 );
+ $cache->set( 'bar', 1, 10 );
+ $cache->set( 'baz', 1, -10 );
+
+ $this->assertEquals( 0, $cacheInternal->bag['foo'][$cache::KEY_EXP], 'Indefinite' );
+ // 2 seconds tolerance
+ $this->assertEquals( time() + 10, $cacheInternal->bag['bar'][$cache::KEY_EXP], 'Future', 2 );
+ $this->assertEquals( time() - 10, $cacheInternal->bag['baz'][$cache::KEY_EXP], 'Past', 2 );
+
+ $this->assertEquals( 1, $cache->get( 'bar' ), 'Key not expired' );
+ $this->assertEquals( false, $cache->get( 'baz' ), 'Key expired' );
+ }
+
+ /**
+ * Ensure maxKeys eviction prefers keeping new keys.
+ *
+ * @covers HashBagOStuff::set
+ */
+ public function testEvictionAdd() {
+ $cache = new HashBagOStuff( [ 'maxKeys' => 10 ] );
+ for ( $i = 0; $i < 10; $i++ ) {
+ $cache->set( "key$i", 1 );
+ $this->assertEquals( 1, $cache->get( "key$i" ) );
+ }
+ for ( $i = 10; $i < 20; $i++ ) {
+ $cache->set( "key$i", 1 );
+ $this->assertEquals( 1, $cache->get( "key$i" ) );
+ $this->assertEquals( false, $cache->get( "key" . ( $i - 10 ) ) );
+ }
+ }
+
+ /**
+ * Ensure maxKeys eviction prefers recently set keys
+ * even if the keys pre-exist.
+ *
+ * @covers HashBagOStuff::set
+ */
+ public function testEvictionSet() {
+ $cache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
+
+ foreach ( [ 'foo', 'bar', 'baz' ] as $key ) {
+ $cache->set( $key, 1 );
+ }
+
+ // Set existing key
+ $cache->set( 'foo', 1 );
+
+ // Add a 4th key (beyond the allowed maximum)
+ $cache->set( 'quux', 1 );
+
+ // Foo's life should have been extended over Bar
+ foreach ( [ 'foo', 'baz', 'quux' ] as $key ) {
+ $this->assertEquals( 1, $cache->get( $key ), "Kept $key" );
+ }
+ $this->assertEquals( false, $cache->get( 'bar' ), 'Evicted bar' );
+ }
+
+ /**
+ * Ensure maxKeys eviction prefers recently retrieved keys (LRU).
+ *
+ * @covers HashBagOStuff::doGet
+ * @covers HashBagOStuff::hasKey
+ */
+ public function testEvictionGet() {
+ $cache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
+
+ foreach ( [ 'foo', 'bar', 'baz' ] as $key ) {
+ $cache->set( $key, 1 );
+ }
+
+ // Get existing key
+ $cache->get( 'foo', 1 );
+
+ // Add a 4th key (beyond the allowed maximum)
+ $cache->set( 'quux', 1 );
+
+ // Foo's life should have been extended over Bar
+ foreach ( [ 'foo', 'baz', 'quux' ] as $key ) {
+ $this->assertEquals( 1, $cache->get( $key ), "Kept $key" );
+ }
+ $this->assertEquals( false, $cache->get( 'bar' ), 'Evicted bar' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php b/www/wiki/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php
new file mode 100644
index 00000000..4a9f6cc9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php
@@ -0,0 +1,140 @@
+<?php
+
+/**
+ * @group Database
+ */
+class MultiWriteBagOStuffTest extends MediaWikiTestCase {
+ /** @var HashBagOStuff */
+ private $cache1;
+ /** @var HashBagOStuff */
+ private $cache2;
+ /** @var MultiWriteBagOStuff */
+ private $cache;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->cache1 = new HashBagOStuff();
+ $this->cache2 = new HashBagOStuff();
+ $this->cache = new MultiWriteBagOStuff( [
+ 'caches' => [ $this->cache1, $this->cache2 ],
+ 'replication' => 'async',
+ 'asyncHandler' => 'DeferredUpdates::addCallableUpdate'
+ ] );
+ }
+
+ /**
+ * @covers MultiWriteBagOStuff::set
+ * @covers MultiWriteBagOStuff::doWrite
+ */
+ public function testSetImmediate() {
+ $key = wfRandomString();
+ $value = wfRandomString();
+ $this->cache->set( $key, $value );
+
+ // Set in tier 1
+ $this->assertEquals( $value, $this->cache1->get( $key ), 'Written to tier 1' );
+ // Set in tier 2
+ $this->assertEquals( $value, $this->cache2->get( $key ), 'Written to tier 2' );
+ }
+
+ /**
+ * @covers MultiWriteBagOStuff
+ */
+ public function testSyncMerge() {
+ $key = wfRandomString();
+ $value = wfRandomString();
+ $func = function () use ( $value ) {
+ return $value;
+ };
+
+ // XXX: DeferredUpdates bound to transactions in CLI mode
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->begin();
+ $this->cache->merge( $key, $func );
+
+ // Set in tier 1
+ $this->assertEquals( $value, $this->cache1->get( $key ), 'Written to tier 1' );
+ // Not yet set in tier 2
+ $this->assertEquals( false, $this->cache2->get( $key ), 'Not written to tier 2' );
+
+ $dbw->commit();
+
+ // Set in tier 2
+ $this->assertEquals( $value, $this->cache2->get( $key ), 'Written to tier 2' );
+
+ $key = wfRandomString();
+
+ $dbw->begin();
+ $this->cache->merge( $key, $func, 0, 1, BagOStuff::WRITE_SYNC );
+
+ // Set in tier 1
+ $this->assertEquals( $value, $this->cache1->get( $key ), 'Written to tier 1' );
+ // Also set in tier 2
+ $this->assertEquals( $value, $this->cache2->get( $key ), 'Written to tier 2' );
+
+ $dbw->commit();
+ }
+
+ /**
+ * @covers MultiWriteBagOStuff::set
+ */
+ public function testSetDelayed() {
+ $key = wfRandomString();
+ $value = (object)[ 'v' => wfRandomString() ];
+ $expectValue = clone $value;
+
+ // XXX: DeferredUpdates bound to transactions in CLI mode
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->begin();
+ $this->cache->set( $key, $value );
+
+ // Test that later changes to $value don't affect the saved value (e.g. T168040)
+ $value->v = 'bogus';
+
+ // Set in tier 1
+ $this->assertEquals( $expectValue, $this->cache1->get( $key ), 'Written to tier 1' );
+ // Not yet set in tier 2
+ $this->assertEquals( false, $this->cache2->get( $key ), 'Not written to tier 2' );
+
+ $dbw->commit();
+
+ // Set in tier 2
+ $this->assertEquals( $expectValue, $this->cache2->get( $key ), 'Written to tier 2' );
+ }
+
+ /**
+ * @covers MultiWriteBagOStuff::makeKey
+ */
+ public function testMakeKey() {
+ $cache1 = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'makeKey' ] )->getMock();
+ $cache1->expects( $this->once() )->method( 'makeKey' )
+ ->willReturn( 'special' );
+
+ $cache2 = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'makeKey' ] )->getMock();
+ $cache2->expects( $this->never() )->method( 'makeKey' );
+
+ $cache = new MultiWriteBagOStuff( [ 'caches' => [ $cache1, $cache2 ] ] );
+ $this->assertSame( 'special', $cache->makeKey( 'a', 'b' ) );
+ }
+
+ /**
+ * @covers MultiWriteBagOStuff::makeGlobalKey
+ */
+ public function testMakeGlobalKey() {
+ $cache1 = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'makeGlobalKey' ] )->getMock();
+ $cache1->expects( $this->once() )->method( 'makeGlobalKey' )
+ ->willReturn( 'special' );
+
+ $cache2 = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'makeGlobalKey' ] )->getMock();
+ $cache2->expects( $this->never() )->method( 'makeGlobalKey' );
+
+ $cache = new MultiWriteBagOStuff( [ 'caches' => [ $cache1, $cache2 ] ] );
+
+ $this->assertSame( 'special', $cache->makeGlobalKey( 'a', 'b' ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php b/www/wiki/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php
new file mode 100644
index 00000000..1b502ec1
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php
@@ -0,0 +1,62 @@
+<?php
+
+class ReplicatedBagOStuffTest extends MediaWikiTestCase {
+ /** @var HashBagOStuff */
+ private $writeCache;
+ /** @var HashBagOStuff */
+ private $readCache;
+ /** @var ReplicatedBagOStuff */
+ private $cache;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->writeCache = new HashBagOStuff();
+ $this->readCache = new HashBagOStuff();
+ $this->cache = new ReplicatedBagOStuff( [
+ 'writeFactory' => $this->writeCache,
+ 'readFactory' => $this->readCache,
+ ] );
+ }
+
+ /**
+ * @covers ReplicatedBagOStuff::set
+ */
+ public function testSet() {
+ $key = wfRandomString();
+ $value = wfRandomString();
+ $this->cache->set( $key, $value );
+
+ // Write to master.
+ $this->assertEquals( $this->writeCache->get( $key ), $value );
+ // Don't write to slave. Replication is deferred to backend.
+ $this->assertEquals( $this->readCache->get( $key ), false );
+ }
+
+ /**
+ * @covers ReplicatedBagOStuff::get
+ */
+ public function testGet() {
+ $key = wfRandomString();
+
+ $write = wfRandomString();
+ $this->writeCache->set( $key, $write );
+ $read = wfRandomString();
+ $this->readCache->set( $key, $read );
+
+ // Read from slave.
+ $this->assertEquals( $this->cache->get( $key ), $read );
+ }
+
+ /**
+ * @covers ReplicatedBagOStuff::get
+ */
+ public function testGetAbsent() {
+ $key = wfRandomString();
+ $value = wfRandomString();
+ $this->writeCache->set( $key, $value );
+
+ // Don't read from master. No failover if value is absent.
+ $this->assertEquals( $this->cache->get( $key ), false );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php b/www/wiki/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php
new file mode 100644
index 00000000..662bb961
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php
@@ -0,0 +1,1711 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers WANObjectCache::wrap
+ * @covers WANObjectCache::unwrap
+ * @covers WANObjectCache::worthRefreshExpiring
+ * @covers WANObjectCache::worthRefreshPopular
+ * @covers WANObjectCache::isValid
+ * @covers WANObjectCache::getWarmupKeyMisses
+ * @covers WANObjectCache::prefixCacheKeys
+ * @covers WANObjectCache::getProcessCache
+ * @covers WANObjectCache::getNonProcessCachedKeys
+ * @covers WANObjectCache::getRawKeysForWarmup
+ * @covers WANObjectCache::getInterimValue
+ * @covers WANObjectCache::setInterimValue
+ */
+class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ /** @var WANObjectCache */
+ private $cache;
+ /** @var BagOStuff */
+ private $internalCache;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->cache = new WANObjectCache( [
+ 'cache' => new HashBagOStuff(),
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] )
+ ] );
+
+ $wanCache = TestingAccessWrapper::newFromObject( $this->cache );
+ /** @noinspection PhpUndefinedFieldInspection */
+ $this->internalCache = $wanCache->cache;
+ }
+
+ /**
+ * @dataProvider provideSetAndGet
+ * @covers WANObjectCache::set()
+ * @covers WANObjectCache::get()
+ * @covers WANObjectCache::makeKey()
+ * @param mixed $value
+ * @param int $ttl
+ */
+ public function testSetAndGet( $value, $ttl ) {
+ $curTTL = null;
+ $asOf = null;
+ $key = $this->cache->makeKey( 'x', wfRandomString() );
+
+ $this->cache->get( $key, $curTTL, [], $asOf );
+ $this->assertNull( $curTTL, "Current TTL is null" );
+ $this->assertNull( $asOf, "Current as-of-time is infinite" );
+
+ $t = microtime( true );
+ $this->cache->set( $key, $value, $ttl );
+
+ $this->assertEquals( $value, $this->cache->get( $key, $curTTL, [], $asOf ) );
+ if ( is_infinite( $ttl ) || $ttl == 0 ) {
+ $this->assertTrue( is_infinite( $curTTL ), "Current TTL is infinite" );
+ } else {
+ $this->assertGreaterThan( 0, $curTTL, "Current TTL > 0" );
+ $this->assertLessThanOrEqual( $ttl, $curTTL, "Current TTL < nominal TTL" );
+ }
+ $this->assertGreaterThanOrEqual( $t - 1, $asOf, "As-of-time in range of set() time" );
+ $this->assertLessThanOrEqual( $t + 1, $asOf, "As-of-time in range of set() time" );
+ }
+
+ public static function provideSetAndGet() {
+ return [
+ [ 14141, 3 ],
+ [ 3535.666, 3 ],
+ [ [], 3 ],
+ [ null, 3 ],
+ [ '0', 3 ],
+ [ (object)[ 'meow' ], 3 ],
+ [ INF, 3 ],
+ [ '', 3 ],
+ [ 'pizzacat', INF ],
+ ];
+ }
+
+ /**
+ * @covers WANObjectCache::get()
+ * @covers WANObjectCache::makeGlobalKey()
+ */
+ public function testGetNotExists() {
+ $key = $this->cache->makeGlobalKey( 'y', wfRandomString(), 'p' );
+ $curTTL = null;
+ $value = $this->cache->get( $key, $curTTL );
+
+ $this->assertFalse( $value, "Non-existing key has false value" );
+ $this->assertNull( $curTTL, "Non-existing key has null current TTL" );
+ }
+
+ /**
+ * @covers WANObjectCache::set()
+ */
+ public function testSetOver() {
+ $key = wfRandomString();
+ for ( $i = 0; $i < 3; ++$i ) {
+ $value = wfRandomString();
+ $this->cache->set( $key, $value, 3 );
+
+ $this->assertEquals( $this->cache->get( $key ), $value );
+ }
+ }
+
+ /**
+ * @covers WANObjectCache::set()
+ */
+ public function testStaleSet() {
+ $key = wfRandomString();
+ $value = wfRandomString();
+ $this->cache->set( $key, $value, 3, [ 'since' => microtime( true ) - 30 ] );
+
+ $this->assertFalse( $this->cache->get( $key ), "Stale set() value ignored" );
+ }
+
+ public function testProcessCache() {
+ $hit = 0;
+ $callback = function () use ( &$hit ) {
+ ++$hit;
+ return 42;
+ };
+ $keys = [ wfRandomString(), wfRandomString(), wfRandomString() ];
+ $groups = [ 'thiscache:1', 'thatcache:1', 'somecache:1' ];
+
+ foreach ( $keys as $i => $key ) {
+ $this->cache->getWithSetCallback(
+ $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] );
+ }
+ $this->assertEquals( 3, $hit );
+
+ foreach ( $keys as $i => $key ) {
+ $this->cache->getWithSetCallback(
+ $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] );
+ }
+ $this->assertEquals( 3, $hit, "Values cached" );
+
+ foreach ( $keys as $i => $key ) {
+ $this->cache->getWithSetCallback(
+ "$key-2", 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] );
+ }
+ $this->assertEquals( 6, $hit );
+
+ foreach ( $keys as $i => $key ) {
+ $this->cache->getWithSetCallback(
+ "$key-2", 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] );
+ }
+ $this->assertEquals( 6, $hit, "New values cached" );
+
+ foreach ( $keys as $i => $key ) {
+ $this->cache->delete( $key );
+ $this->cache->getWithSetCallback(
+ $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] );
+ }
+ $this->assertEquals( 9, $hit, "Values evicted" );
+
+ $key = reset( $keys );
+ // Get into cache (default process cache group)
+ $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] );
+ $this->assertEquals( 10, $hit, "Value calculated" );
+ $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] );
+ $this->assertEquals( 10, $hit, "Value cached" );
+ $outerCallback = function () use ( &$callback, $key ) {
+ $v = $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] );
+
+ return 43 + $v;
+ };
+ // Outer key misses and refuses inner key process cache value
+ $this->cache->getWithSetCallback( "$key-miss-outer", 100, $outerCallback );
+ $this->assertEquals( 11, $hit, "Nested callback value process cache skipped" );
+ }
+
+ /**
+ * @dataProvider getWithSetCallback_provider
+ * @covers WANObjectCache::getWithSetCallback()
+ * @covers WANObjectCache::doGetWithSetCallback()
+ * @param array $extOpts
+ * @param bool $versioned
+ */
+ public function testGetWithSetCallback( array $extOpts, $versioned ) {
+ $cache = $this->cache;
+
+ $key = wfRandomString();
+ $value = wfRandomString();
+ $cKey1 = wfRandomString();
+ $cKey2 = wfRandomString();
+
+ $priorValue = null;
+ $priorAsOf = null;
+ $wasSet = 0;
+ $func = function ( $old, &$ttl, &$opts, $asOf )
+ use ( &$wasSet, &$priorValue, &$priorAsOf, $value )
+ {
+ ++$wasSet;
+ $priorValue = $old;
+ $priorAsOf = $asOf;
+ $ttl = 20; // override with another value
+ return $value;
+ };
+
+ $wasSet = 0;
+ $v = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] + $extOpts );
+ $this->assertEquals( $value, $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated" );
+ $this->assertFalse( $priorValue, "No prior value" );
+ $this->assertNull( $priorAsOf, "No prior value" );
+
+ $curTTL = null;
+ $cache->get( $key, $curTTL );
+ $this->assertLessThanOrEqual( 20, $curTTL, 'Current TTL between 19-20 (overriden)' );
+ $this->assertGreaterThanOrEqual( 19, $curTTL, 'Current TTL between 19-20 (overriden)' );
+
+ $wasSet = 0;
+ $v = $cache->getWithSetCallback(
+ $key, 30, $func, [ 'lowTTL' => 0, 'lockTSE' => 5 ] + $extOpts );
+ $this->assertEquals( $value, $v, "Value returned" );
+ $this->assertEquals( 0, $wasSet, "Value not regenerated" );
+
+ $mockWallClock = microtime( true );
+ $priorTime = $mockWallClock; // reference time
+ $cache->setMockTime( $mockWallClock );
+
+ $mockWallClock += 1;
+
+ $wasSet = 0;
+ $v = $cache->getWithSetCallback(
+ $key, 30, $func, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts
+ );
+ $this->assertEquals( $value, $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated due to check keys" );
+ $this->assertEquals( $value, $priorValue, "Has prior value" );
+ $this->assertInternalType( 'float', $priorAsOf, "Has prior value" );
+ $t1 = $cache->getCheckKeyTime( $cKey1 );
+ $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check keys generated on miss' );
+ $t2 = $cache->getCheckKeyTime( $cKey2 );
+ $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check keys generated on miss' );
+
+ $mockWallClock += 0.01;
+ $priorTime = $mockWallClock; // reference time
+ $wasSet = 0;
+ $v = $cache->getWithSetCallback(
+ $key, 30, $func, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts
+ );
+ $this->assertEquals( $value, $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated due to still-recent check keys" );
+ $t1 = $cache->getCheckKeyTime( $cKey1 );
+ $this->assertLessThanOrEqual( $priorTime, $t1, 'Check keys did not change again' );
+ $t2 = $cache->getCheckKeyTime( $cKey2 );
+ $this->assertLessThanOrEqual( $priorTime, $t2, 'Check keys did not change again' );
+
+ $curTTL = null;
+ $v = $cache->get( $key, $curTTL, [ $cKey1, $cKey2 ] );
+ if ( $versioned ) {
+ $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" );
+ } else {
+ $this->assertEquals( $value, $v, "Value returned" );
+ }
+ $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" );
+
+ $wasSet = 0;
+ $key = wfRandomString();
+ $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] + $extOpts );
+ $this->assertEquals( $value, $v, "Value returned" );
+ $cache->delete( $key );
+ $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] + $extOpts );
+ $this->assertEquals( $value, $v, "Value still returned after deleted" );
+ $this->assertEquals( 1, $wasSet, "Value process cached while deleted" );
+
+ $oldValReceived = -1;
+ $oldAsOfReceived = -1;
+ $checkFunc = function ( $oldVal, &$ttl, array $setOpts, $oldAsOf )
+ use ( &$oldValReceived, &$oldAsOfReceived, &$wasSet ) {
+ ++$wasSet;
+ $oldValReceived = $oldVal;
+ $oldAsOfReceived = $oldAsOf;
+
+ return 'xxx' . $wasSet;
+ };
+
+ $mockWallClock = microtime( true );
+ $priorTime = $mockWallClock; // reference time
+
+ $wasSet = 0;
+ $key = wfRandomString();
+ $v = $cache->getWithSetCallback(
+ $key, 30, $checkFunc, [ 'staleTTL' => 50 ] + $extOpts );
+ $this->assertEquals( 'xxx1', $v, "Value returned" );
+ $this->assertEquals( false, $oldValReceived, "Callback got no stale value" );
+ $this->assertEquals( null, $oldAsOfReceived, "Callback got no stale value" );
+
+ $mockWallClock += 40;
+ $v = $cache->getWithSetCallback(
+ $key, 30, $checkFunc, [ 'staleTTL' => 50 ] + $extOpts );
+ $this->assertEquals( 'xxx2', $v, "Value still returned after expired" );
+ $this->assertEquals( 2, $wasSet, "Value recalculated while expired" );
+ $this->assertEquals( 'xxx1', $oldValReceived, "Callback got stale value" );
+ $this->assertNotEquals( null, $oldAsOfReceived, "Callback got stale value" );
+
+ $mockWallClock += 260;
+ $v = $cache->getWithSetCallback(
+ $key, 30, $checkFunc, [ 'staleTTL' => 50 ] + $extOpts );
+ $this->assertEquals( 'xxx3', $v, "Value still returned after expired" );
+ $this->assertEquals( 3, $wasSet, "Value recalculated while expired" );
+ $this->assertEquals( false, $oldValReceived, "Callback got no stale value" );
+ $this->assertEquals( null, $oldAsOfReceived, "Callback got no stale value" );
+
+ $mockWallClock = ( $priorTime - $cache::HOLDOFF_TTL - 1 );
+ $wasSet = 0;
+ $key = wfRandomString();
+ $checkKey = $cache->makeKey( 'template', 'X' );
+ $cache->touchCheckKey( $checkKey ); // init check key
+ $mockWallClock = $priorTime;
+ $v = $cache->getWithSetCallback(
+ $key,
+ $cache::TTL_INDEFINITE,
+ $checkFunc,
+ [ 'graceTTL' => $cache::TTL_WEEK, 'checkKeys' => [ $checkKey ] ] + $extOpts
+ );
+ $this->assertEquals( 'xxx1', $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value computed" );
+ $this->assertEquals( false, $oldValReceived, "Callback got no stale value" );
+ $this->assertEquals( null, $oldAsOfReceived, "Callback got no stale value" );
+
+ $mockWallClock += $cache::TTL_HOUR; // some time passes
+ $v = $cache->getWithSetCallback(
+ $key,
+ $cache::TTL_INDEFINITE,
+ $checkFunc,
+ [ 'graceTTL' => $cache::TTL_WEEK, 'checkKeys' => [ $checkKey ] ] + $extOpts
+ );
+ $this->assertEquals( 'xxx1', $v, "Cached value returned" );
+ $this->assertEquals( 1, $wasSet, "Cached value returned" );
+
+ $cache->touchCheckKey( $checkKey ); // make key stale
+ $mockWallClock += 0.01; // ~1 week left of grace (barely stale to avoid refreshes)
+
+ $v = $cache->getWithSetCallback(
+ $key,
+ $cache::TTL_INDEFINITE,
+ $checkFunc,
+ [ 'graceTTL' => $cache::TTL_WEEK, 'checkKeys' => [ $checkKey ] ] + $extOpts
+ );
+ $this->assertEquals( 'xxx1', $v, "Value still returned after expired (in grace)" );
+ $this->assertEquals( 1, $wasSet, "Value still returned after expired (in grace)" );
+
+ // Change of refresh increase to unity as staleness approaches graceTTL
+ $mockWallClock += $cache::TTL_WEEK; // 8 days of being stale
+ $v = $cache->getWithSetCallback(
+ $key,
+ $cache::TTL_INDEFINITE,
+ $checkFunc,
+ [ 'graceTTL' => $cache::TTL_WEEK, 'checkKeys' => [ $checkKey ] ] + $extOpts
+ );
+ $this->assertEquals( 'xxx2', $v, "Value was recomputed (past grace)" );
+ $this->assertEquals( 2, $wasSet, "Value was recomputed (past grace)" );
+ $this->assertEquals( 'xxx1', $oldValReceived, "Callback got post-grace stale value" );
+ $this->assertNotEquals( null, $oldAsOfReceived, "Callback got post-grace stale value" );
+ }
+
+ public static function getWithSetCallback_provider() {
+ return [
+ [ [], false ],
+ [ [ 'version' => 1 ], true ]
+ ];
+ }
+
+ public function testPreemtiveRefresh() {
+ $value = 'KatCafe';
+ $wasSet = 0;
+ $func = function ( $old, &$ttl, &$opts, $asOf ) use ( &$wasSet, &$value )
+ {
+ ++$wasSet;
+ return $value;
+ };
+
+ $cache = new NearExpiringWANObjectCache( [
+ 'cache' => new HashBagOStuff(),
+ 'pool' => 'empty',
+ ] );
+
+ $wasSet = 0;
+ $key = wfRandomString();
+ $opts = [ 'lowTTL' => 30 ];
+ $v = $cache->getWithSetCallback( $key, 20, $func, $opts );
+ $this->assertEquals( $value, $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value calculated" );
+ $v = $cache->getWithSetCallback( $key, 20, $func, $opts );
+ $this->assertEquals( 2, $wasSet, "Value re-calculated" );
+
+ $wasSet = 0;
+ $key = wfRandomString();
+ $opts = [ 'lowTTL' => 1 ];
+ $v = $cache->getWithSetCallback( $key, 30, $func, $opts );
+ $this->assertEquals( $value, $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value calculated" );
+ $v = $cache->getWithSetCallback( $key, 30, $func, $opts );
+ $this->assertEquals( 1, $wasSet, "Value cached" );
+
+ $asycList = [];
+ $asyncHandler = function ( $callback ) use ( &$asycList ) {
+ $asycList[] = $callback;
+ };
+ $cache = new NearExpiringWANObjectCache( [
+ 'cache' => new HashBagOStuff(),
+ 'pool' => 'empty',
+ 'asyncHandler' => $asyncHandler
+ ] );
+
+ $mockWallClock = microtime( true );
+ $priorTime = $mockWallClock; // reference time
+ $cache->setMockTime( $mockWallClock );
+
+ $wasSet = 0;
+ $key = wfRandomString();
+ $opts = [ 'lowTTL' => 100 ];
+ $v = $cache->getWithSetCallback( $key, 300, $func, $opts );
+ $this->assertEquals( $value, $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value calculated" );
+ $v = $cache->getWithSetCallback( $key, 300, $func, $opts );
+ $this->assertEquals( 1, $wasSet, "Cached value used" );
+ $this->assertEquals( $v, $value, "Value cached" );
+
+ $mockWallClock += 250;
+ $v = $cache->getWithSetCallback( $key, 300, $func, $opts );
+ $this->assertEquals( $value, $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Stale value used" );
+ $this->assertEquals( 1, count( $asycList ), "Refresh deferred." );
+ $value = 'NewCatsInTown'; // change callback return value
+ $asycList[0](); // run the refresh callback
+ $asycList = [];
+ $this->assertEquals( 2, $wasSet, "Value calculated at later time" );
+ $this->assertEquals( 0, count( $asycList ), "No deferred refreshes added." );
+ $v = $cache->getWithSetCallback( $key, 300, $func, $opts );
+ $this->assertEquals( $value, $v, "New value stored" );
+
+ $cache = new PopularityRefreshingWANObjectCache( [
+ 'cache' => new HashBagOStuff(),
+ 'pool' => 'empty'
+ ] );
+
+ $mockWallClock = $priorTime;
+ $cache->setMockTime( $mockWallClock );
+
+ $wasSet = 0;
+ $key = wfRandomString();
+ $opts = [ 'hotTTR' => 900 ];
+ $v = $cache->getWithSetCallback( $key, 60, $func, $opts );
+ $this->assertEquals( $value, $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value calculated" );
+
+ $mockWallClock += 30;
+
+ $v = $cache->getWithSetCallback( $key, 60, $func, $opts );
+ $this->assertEquals( 1, $wasSet, "Value cached" );
+
+ $mockWallClock = $priorTime;
+ $wasSet = 0;
+ $key = wfRandomString();
+ $opts = [ 'hotTTR' => 10 ];
+ $v = $cache->getWithSetCallback( $key, 60, $func, $opts );
+ $this->assertEquals( $value, $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value calculated" );
+
+ $mockWallClock += 30;
+
+ $v = $cache->getWithSetCallback( $key, 60, $func, $opts );
+ $this->assertEquals( $value, $v, "Value returned" );
+ $this->assertEquals( 2, $wasSet, "Value re-calculated" );
+ }
+
+ /**
+ * @covers WANObjectCache::getWithSetCallback()
+ * @covers WANObjectCache::doGetWithSetCallback()
+ */
+ public function testGetWithSetCallback_invalidCallback() {
+ $this->setExpectedException( InvalidArgumentException::class );
+ $this->cache->getWithSetCallback( 'key', 30, 'invalid callback' );
+ }
+
+ /**
+ * @dataProvider getMultiWithSetCallback_provider
+ * @covers WANObjectCache::getMultiWithSetCallback
+ * @covers WANObjectCache::makeMultiKeys
+ * @covers WANObjectCache::getMulti
+ * @param array $extOpts
+ * @param bool $versioned
+ */
+ public function testGetMultiWithSetCallback( array $extOpts, $versioned ) {
+ $cache = $this->cache;
+
+ $keyA = wfRandomString();
+ $keyB = wfRandomString();
+ $keyC = wfRandomString();
+ $cKey1 = wfRandomString();
+ $cKey2 = wfRandomString();
+
+ $priorValue = null;
+ $priorAsOf = null;
+ $wasSet = 0;
+ $genFunc = function ( $id, $old, &$ttl, &$opts, $asOf ) use (
+ &$wasSet, &$priorValue, &$priorAsOf
+ ) {
+ ++$wasSet;
+ $priorValue = $old;
+ $priorAsOf = $asOf;
+ $ttl = 20; // override with another value
+ return "@$id$";
+ };
+
+ $wasSet = 0;
+ $keyedIds = new ArrayIterator( [ $keyA => 3353 ] );
+ $value = "@3353$";
+ $v = $cache->getMultiWithSetCallback(
+ $keyedIds, 30, $genFunc, [ 'lockTSE' => 5 ] + $extOpts );
+ $this->assertEquals( $value, $v[$keyA], "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated" );
+ $this->assertFalse( $priorValue, "No prior value" );
+ $this->assertNull( $priorAsOf, "No prior value" );
+
+ $curTTL = null;
+ $cache->get( $keyA, $curTTL );
+ $this->assertLessThanOrEqual( 20, $curTTL, 'Current TTL between 19-20 (overriden)' );
+ $this->assertGreaterThanOrEqual( 19, $curTTL, 'Current TTL between 19-20 (overriden)' );
+
+ $wasSet = 0;
+ $value = "@efef$";
+ $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] );
+ $v = $cache->getMultiWithSetCallback(
+ $keyedIds, 30, $genFunc, [ 'lowTTL' => 0, 'lockTSE' => 5, ] + $extOpts );
+ $this->assertEquals( $value, $v[$keyB], "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated" );
+ $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed yet in process cache" );
+ $v = $cache->getMultiWithSetCallback(
+ $keyedIds, 30, $genFunc, [ 'lowTTL' => 0, 'lockTSE' => 5, ] + $extOpts );
+ $this->assertEquals( $value, $v[$keyB], "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value not regenerated" );
+ $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed in process cache" );
+
+ $mockWallClock = microtime( true );
+ $priorTime = $mockWallClock; // reference time
+ $cache->setMockTime( $mockWallClock );
+
+ $mockWallClock += 1;
+
+ $wasSet = 0;
+ $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] );
+ $v = $cache->getMultiWithSetCallback(
+ $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts
+ );
+ $this->assertEquals( $value, $v[$keyB], "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated due to check keys" );
+ $this->assertEquals( $value, $priorValue, "Has prior value" );
+ $this->assertInternalType( 'float', $priorAsOf, "Has prior value" );
+ $t1 = $cache->getCheckKeyTime( $cKey1 );
+ $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check keys generated on miss' );
+ $t2 = $cache->getCheckKeyTime( $cKey2 );
+ $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check keys generated on miss' );
+
+ $mockWallClock += 0.01;
+ $priorTime = $mockWallClock;
+ $value = "@43636$";
+ $wasSet = 0;
+ $keyedIds = new ArrayIterator( [ $keyC => 43636 ] );
+ $v = $cache->getMultiWithSetCallback(
+ $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts
+ );
+ $this->assertEquals( $value, $v[$keyC], "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated due to still-recent check keys" );
+ $t1 = $cache->getCheckKeyTime( $cKey1 );
+ $this->assertLessThanOrEqual( $priorTime, $t1, 'Check keys did not change again' );
+ $t2 = $cache->getCheckKeyTime( $cKey2 );
+ $this->assertLessThanOrEqual( $priorTime, $t2, 'Check keys did not change again' );
+
+ $curTTL = null;
+ $v = $cache->get( $keyC, $curTTL, [ $cKey1, $cKey2 ] );
+ if ( $versioned ) {
+ $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" );
+ } else {
+ $this->assertEquals( $value, $v, "Value returned" );
+ }
+ $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" );
+
+ $wasSet = 0;
+ $key = wfRandomString();
+ $keyedIds = new ArrayIterator( [ $key => 242424 ] );
+ $v = $cache->getMultiWithSetCallback(
+ $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts );
+ $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value returned" );
+ $cache->delete( $key );
+ $keyedIds = new ArrayIterator( [ $key => 242424 ] );
+ $v = $cache->getMultiWithSetCallback(
+ $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts );
+ $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value still returned after deleted" );
+ $this->assertEquals( 1, $wasSet, "Value process cached while deleted" );
+
+ $calls = 0;
+ $ids = [ 1, 2, 3, 4, 5, 6 ];
+ $keyFunc = function ( $id, WANObjectCache $wanCache ) {
+ return $wanCache->makeKey( 'test', $id );
+ };
+ $keyedIds = $cache->makeMultiKeys( $ids, $keyFunc );
+ $genFunc = function ( $id, $oldValue, &$ttl, array &$setops ) use ( &$calls ) {
+ ++$calls;
+
+ return "val-{$id}";
+ };
+ $values = $cache->getMultiWithSetCallback( $keyedIds, 10, $genFunc );
+
+ $this->assertEquals(
+ [ "val-1", "val-2", "val-3", "val-4", "val-5", "val-6" ],
+ array_values( $values ),
+ "Correct values in correct order"
+ );
+ $this->assertEquals(
+ array_map( $keyFunc, $ids, array_fill( 0, count( $ids ), $this->cache ) ),
+ array_keys( $values ),
+ "Correct keys in correct order"
+ );
+ $this->assertEquals( count( $ids ), $calls );
+
+ $cache->getMultiWithSetCallback( $keyedIds, 10, $genFunc );
+ $this->assertEquals( count( $ids ), $calls, "Values cached" );
+
+ // Mock the BagOStuff to assure only one getMulti() call given process caching
+ $localBag = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'getMulti' ] )->getMock();
+ $localBag->expects( $this->exactly( 1 ) )->method( 'getMulti' )->willReturn( [
+ WANObjectCache::VALUE_KEY_PREFIX . 'k1' => 'val-id1',
+ WANObjectCache::VALUE_KEY_PREFIX . 'k2' => 'val-id2'
+ ] );
+ $wanCache = new WANObjectCache( [ 'cache' => $localBag, 'pool' => 'testcache-hash' ] );
+
+ // Warm the process cache
+ $keyedIds = new ArrayIterator( [ 'k1' => 'id1', 'k2' => 'id2' ] );
+ $this->assertEquals(
+ [ 'k1' => 'val-id1', 'k2' => 'val-id2' ],
+ $wanCache->getMultiWithSetCallback( $keyedIds, 10, $genFunc, [ 'pcTTL' => 5 ] )
+ );
+ // Use the process cache
+ $this->assertEquals(
+ [ 'k1' => 'val-id1', 'k2' => 'val-id2' ],
+ $wanCache->getMultiWithSetCallback( $keyedIds, 10, $genFunc, [ 'pcTTL' => 5 ] )
+ );
+ }
+
+ public static function getMultiWithSetCallback_provider() {
+ return [
+ [ [], false ],
+ [ [ 'version' => 1 ], true ]
+ ];
+ }
+
+ /**
+ * @dataProvider getMultiWithUnionSetCallback_provider
+ * @covers WANObjectCache::getMultiWithUnionSetCallback()
+ * @covers WANObjectCache::makeMultiKeys()
+ * @param array $extOpts
+ * @param bool $versioned
+ */
+ public function testGetMultiWithUnionSetCallback( array $extOpts, $versioned ) {
+ $cache = $this->cache;
+
+ $keyA = wfRandomString();
+ $keyB = wfRandomString();
+ $keyC = wfRandomString();
+ $cKey1 = wfRandomString();
+ $cKey2 = wfRandomString();
+
+ $wasSet = 0;
+ $genFunc = function ( array $ids, array &$ttls, array &$setOpts ) use (
+ &$wasSet, &$priorValue, &$priorAsOf
+ ) {
+ $newValues = [];
+ foreach ( $ids as $id ) {
+ ++$wasSet;
+ $newValues[$id] = "@$id$";
+ $ttls[$id] = 20; // override with another value
+ }
+
+ return $newValues;
+ };
+
+ $wasSet = 0;
+ $keyedIds = new ArrayIterator( [ $keyA => 3353 ] );
+ $value = "@3353$";
+ $v = $cache->getMultiWithUnionSetCallback(
+ $keyedIds, 30, $genFunc, $extOpts );
+ $this->assertEquals( $value, $v[$keyA], "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated" );
+
+ $curTTL = null;
+ $cache->get( $keyA, $curTTL );
+ $this->assertLessThanOrEqual( 20, $curTTL, 'Current TTL between 19-20 (overriden)' );
+ $this->assertGreaterThanOrEqual( 19, $curTTL, 'Current TTL between 19-20 (overriden)' );
+
+ $wasSet = 0;
+ $value = "@efef$";
+ $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] );
+ $v = $cache->getMultiWithUnionSetCallback(
+ $keyedIds, 30, $genFunc, [ 'lowTTL' => 0 ] + $extOpts );
+ $this->assertEquals( $value, $v[$keyB], "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated" );
+ $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed yet in process cache" );
+ $v = $cache->getMultiWithUnionSetCallback(
+ $keyedIds, 30, $genFunc, [ 'lowTTL' => 0 ] + $extOpts );
+ $this->assertEquals( $value, $v[$keyB], "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value not regenerated" );
+ $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed in process cache" );
+
+ $mockWallClock = microtime( true );
+ $priorTime = $mockWallClock; // reference time
+ $cache->setMockTime( $mockWallClock );
+
+ $mockWallClock += 1;
+
+ $wasSet = 0;
+ $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] );
+ $v = $cache->getMultiWithUnionSetCallback(
+ $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts
+ );
+ $this->assertEquals( $value, $v[$keyB], "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated due to check keys" );
+ $t1 = $cache->getCheckKeyTime( $cKey1 );
+ $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check keys generated on miss' );
+ $t2 = $cache->getCheckKeyTime( $cKey2 );
+ $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check keys generated on miss' );
+
+ $mockWallClock += 0.01;
+ $priorTime = $mockWallClock;
+ $value = "@43636$";
+ $wasSet = 0;
+ $keyedIds = new ArrayIterator( [ $keyC => 43636 ] );
+ $v = $cache->getMultiWithUnionSetCallback(
+ $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts
+ );
+ $this->assertEquals( $value, $v[$keyC], "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated due to still-recent check keys" );
+ $t1 = $cache->getCheckKeyTime( $cKey1 );
+ $this->assertLessThanOrEqual( $priorTime, $t1, 'Check keys did not change again' );
+ $t2 = $cache->getCheckKeyTime( $cKey2 );
+ $this->assertLessThanOrEqual( $priorTime, $t2, 'Check keys did not change again' );
+
+ $curTTL = null;
+ $v = $cache->get( $keyC, $curTTL, [ $cKey1, $cKey2 ] );
+ if ( $versioned ) {
+ $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" );
+ } else {
+ $this->assertEquals( $value, $v, "Value returned" );
+ }
+ $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" );
+
+ $wasSet = 0;
+ $key = wfRandomString();
+ $keyedIds = new ArrayIterator( [ $key => 242424 ] );
+ $v = $cache->getMultiWithUnionSetCallback(
+ $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts );
+ $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value returned" );
+ $cache->delete( $key );
+ $keyedIds = new ArrayIterator( [ $key => 242424 ] );
+ $v = $cache->getMultiWithUnionSetCallback(
+ $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts );
+ $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value still returned after deleted" );
+ $this->assertEquals( 1, $wasSet, "Value process cached while deleted" );
+
+ $calls = 0;
+ $ids = [ 1, 2, 3, 4, 5, 6 ];
+ $keyFunc = function ( $id, WANObjectCache $wanCache ) {
+ return $wanCache->makeKey( 'test', $id );
+ };
+ $keyedIds = $cache->makeMultiKeys( $ids, $keyFunc );
+ $genFunc = function ( array $ids, array &$ttls, array &$setOpts ) use ( &$calls ) {
+ $newValues = [];
+ foreach ( $ids as $id ) {
+ ++$calls;
+ $newValues[$id] = "val-{$id}";
+ }
+
+ return $newValues;
+ };
+ $values = $cache->getMultiWithUnionSetCallback( $keyedIds, 10, $genFunc );
+
+ $this->assertEquals(
+ [ "val-1", "val-2", "val-3", "val-4", "val-5", "val-6" ],
+ array_values( $values ),
+ "Correct values in correct order"
+ );
+ $this->assertEquals(
+ array_map( $keyFunc, $ids, array_fill( 0, count( $ids ), $this->cache ) ),
+ array_keys( $values ),
+ "Correct keys in correct order"
+ );
+ $this->assertEquals( count( $ids ), $calls );
+
+ $cache->getMultiWithUnionSetCallback( $keyedIds, 10, $genFunc );
+ $this->assertEquals( count( $ids ), $calls, "Values cached" );
+ }
+
+ public static function getMultiWithUnionSetCallback_provider() {
+ return [
+ [ [], false ],
+ [ [ 'version' => 1 ], true ]
+ ];
+ }
+
+ /**
+ * @covers WANObjectCache::getWithSetCallback()
+ * @covers WANObjectCache::doGetWithSetCallback()
+ */
+ public function testLockTSE() {
+ $cache = $this->cache;
+ $key = wfRandomString();
+ $value = wfRandomString();
+
+ $calls = 0;
+ $func = function () use ( &$calls, $value, $cache, $key ) {
+ ++$calls;
+ return $value;
+ };
+
+ $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] );
+ $this->assertEquals( $value, $ret );
+ $this->assertEquals( 1, $calls, 'Value was populated' );
+
+ // Acquire the mutex to verify that getWithSetCallback uses lockTSE properly
+ $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+
+ $checkKeys = [ wfRandomString() ]; // new check keys => force misses
+ $ret = $cache->getWithSetCallback( $key, 30, $func,
+ [ 'lockTSE' => 5, 'checkKeys' => $checkKeys ] );
+ $this->assertEquals( $value, $ret, 'Old value used' );
+ $this->assertEquals( 1, $calls, 'Callback was not used' );
+
+ $cache->delete( $key );
+ $ret = $cache->getWithSetCallback( $key, 30, $func,
+ [ 'lockTSE' => 5, 'checkKeys' => $checkKeys ] );
+ $this->assertEquals( $value, $ret, 'Callback was used; interim saved' );
+ $this->assertEquals( 2, $calls, 'Callback was used; interim saved' );
+
+ $ret = $cache->getWithSetCallback( $key, 30, $func,
+ [ 'lockTSE' => 5, 'checkKeys' => $checkKeys ] );
+ $this->assertEquals( $value, $ret, 'Callback was not used; used interim (mutex failed)' );
+ $this->assertEquals( 2, $calls, 'Callback was not used; used interim (mutex failed)' );
+ }
+
+ /**
+ * @covers WANObjectCache::getWithSetCallback()
+ * @covers WANObjectCache::doGetWithSetCallback()
+ * @covers WANObjectCache::set()
+ */
+ public function testLockTSESlow() {
+ $cache = $this->cache;
+ $key = wfRandomString();
+ $value = wfRandomString();
+
+ $calls = 0;
+ $func = function ( $oldValue, &$ttl, &$setOpts ) use ( &$calls, $value, $cache, $key ) {
+ ++$calls;
+ $setOpts['since'] = microtime( true ) - 10;
+ // Immediately kill any mutex rather than waiting a second
+ $cache->delete( $cache::MUTEX_KEY_PREFIX . $key );
+ return $value;
+ };
+
+ // Value should be marked as stale due to snapshot lag
+ $curTTL = null;
+ $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] );
+ $this->assertEquals( $value, $ret );
+ $this->assertEquals( $value, $cache->get( $key, $curTTL ), 'Value was populated' );
+ $this->assertLessThan( 0, $curTTL, 'Value has negative curTTL' );
+ $this->assertEquals( 1, $calls, 'Value was generated' );
+
+ // Acquire a lock to verify that getWithSetCallback uses lockTSE properly
+ $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+ $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] );
+ $this->assertEquals( $value, $ret );
+ $this->assertEquals( 1, $calls, 'Callback was not used' );
+ }
+
+ /**
+ * @covers WANObjectCache::getWithSetCallback()
+ * @covers WANObjectCache::doGetWithSetCallback()
+ */
+ public function testBusyValue() {
+ $cache = $this->cache;
+ $key = wfRandomString();
+ $value = wfRandomString();
+ $busyValue = wfRandomString();
+
+ $calls = 0;
+ $func = function () use ( &$calls, $value, $cache, $key ) {
+ ++$calls;
+ // Immediately kill any mutex rather than waiting a second
+ $cache->delete( $cache::MUTEX_KEY_PREFIX . $key );
+ return $value;
+ };
+
+ $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'busyValue' => $busyValue ] );
+ $this->assertEquals( $value, $ret );
+ $this->assertEquals( 1, $calls, 'Value was populated' );
+
+ // Acquire a lock to verify that getWithSetCallback uses busyValue properly
+ $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+
+ $checkKeys = [ wfRandomString() ]; // new check keys => force misses
+ $ret = $cache->getWithSetCallback( $key, 30, $func,
+ [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] );
+ $this->assertEquals( $value, $ret, 'Callback used' );
+ $this->assertEquals( 2, $calls, 'Callback used' );
+
+ $ret = $cache->getWithSetCallback( $key, 30, $func,
+ [ 'lockTSE' => 30, 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] );
+ $this->assertEquals( $value, $ret, 'Old value used' );
+ $this->assertEquals( 2, $calls, 'Callback was not used' );
+
+ $cache->delete( $key ); // no value at all anymore and still locked
+ $ret = $cache->getWithSetCallback( $key, 30, $func,
+ [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] );
+ $this->assertEquals( $busyValue, $ret, 'Callback was not used; used busy value' );
+ $this->assertEquals( 2, $calls, 'Callback was not used; used busy value' );
+
+ $this->internalCache->delete( $cache::MUTEX_KEY_PREFIX . $key );
+ $ret = $cache->getWithSetCallback( $key, 30, $func,
+ [ 'lockTSE' => 30, 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] );
+ $this->assertEquals( $value, $ret, 'Callback was used; saved interim' );
+ $this->assertEquals( 3, $calls, 'Callback was used; saved interim' );
+
+ $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+ $ret = $cache->getWithSetCallback( $key, 30, $func,
+ [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] );
+ $this->assertEquals( $value, $ret, 'Callback was not used; used interim' );
+ $this->assertEquals( 3, $calls, 'Callback was not used; used interim' );
+ }
+
+ /**
+ * @covers WANObjectCache::getMulti()
+ */
+ public function testGetMulti() {
+ $cache = $this->cache;
+
+ $value1 = [ 'this' => 'is', 'a' => 'test' ];
+ $value2 = [ 'this' => 'is', 'another' => 'test' ];
+
+ $key1 = wfRandomString();
+ $key2 = wfRandomString();
+ $key3 = wfRandomString();
+
+ $cache->set( $key1, $value1, 5 );
+ $cache->set( $key2, $value2, 10 );
+
+ $curTTLs = [];
+ $this->assertEquals(
+ [ $key1 => $value1, $key2 => $value2 ],
+ $cache->getMulti( [ $key1, $key2, $key3 ], $curTTLs ),
+ 'Result array populated'
+ );
+
+ $this->assertEquals( 2, count( $curTTLs ), "Two current TTLs in array" );
+ $this->assertGreaterThan( 0, $curTTLs[$key1], "Key 1 has current TTL > 0" );
+ $this->assertGreaterThan( 0, $curTTLs[$key2], "Key 2 has current TTL > 0" );
+
+ $cKey1 = wfRandomString();
+ $cKey2 = wfRandomString();
+
+ $mockWallClock = microtime( true );
+ $priorTime = $mockWallClock; // reference time
+ $cache->setMockTime( $mockWallClock );
+
+ $mockWallClock += 1;
+
+ $curTTLs = [];
+ $this->assertEquals(
+ [ $key1 => $value1, $key2 => $value2 ],
+ $cache->getMulti( [ $key1, $key2, $key3 ], $curTTLs, [ $cKey1, $cKey2 ] ),
+ "Result array populated even with new check keys"
+ );
+ $t1 = $cache->getCheckKeyTime( $cKey1 );
+ $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check key 1 generated on miss' );
+ $t2 = $cache->getCheckKeyTime( $cKey2 );
+ $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check key 2 generated on miss' );
+ $this->assertEquals( 2, count( $curTTLs ), "Current TTLs array set" );
+ $this->assertLessThanOrEqual( 0, $curTTLs[$key1], 'Key 1 has current TTL <= 0' );
+ $this->assertLessThanOrEqual( 0, $curTTLs[$key2], 'Key 2 has current TTL <= 0' );
+
+ $mockWallClock += 1;
+
+ $curTTLs = [];
+ $this->assertEquals(
+ [ $key1 => $value1, $key2 => $value2 ],
+ $cache->getMulti( [ $key1, $key2, $key3 ], $curTTLs, [ $cKey1, $cKey2 ] ),
+ "Result array still populated even with new check keys"
+ );
+ $this->assertEquals( 2, count( $curTTLs ), "Current TTLs still array set" );
+ $this->assertLessThan( 0, $curTTLs[$key1], 'Key 1 has negative current TTL' );
+ $this->assertLessThan( 0, $curTTLs[$key2], 'Key 2 has negative current TTL' );
+ }
+
+ /**
+ * @covers WANObjectCache::getMulti()
+ * @covers WANObjectCache::processCheckKeys()
+ */
+ public function testGetMultiCheckKeys() {
+ $cache = $this->cache;
+
+ $checkAll = wfRandomString();
+ $check1 = wfRandomString();
+ $check2 = wfRandomString();
+ $check3 = wfRandomString();
+ $value1 = wfRandomString();
+ $value2 = wfRandomString();
+
+ $mockWallClock = microtime( true );
+ $cache->setMockTime( $mockWallClock );
+
+ // Fake initial check key to be set in the past. Otherwise we'd have to sleep for
+ // several seconds during the test to assert the behaviour.
+ foreach ( [ $checkAll, $check1, $check2 ] as $checkKey ) {
+ $cache->touchCheckKey( $checkKey, WANObjectCache::HOLDOFF_NONE );
+ }
+
+ $mockWallClock += 0.100;
+
+ $cache->set( 'key1', $value1, 10 );
+ $cache->set( 'key2', $value2, 10 );
+
+ $curTTLs = [];
+ $result = $cache->getMulti( [ 'key1', 'key2', 'key3' ], $curTTLs, [
+ 'key1' => $check1,
+ $checkAll,
+ 'key2' => $check2,
+ 'key3' => $check3,
+ ] );
+ $this->assertEquals(
+ [ 'key1' => $value1, 'key2' => $value2 ],
+ $result,
+ 'Initial values'
+ );
+ $this->assertGreaterThanOrEqual( 9.5, $curTTLs['key1'], 'Initial ttls' );
+ $this->assertLessThanOrEqual( 10.5, $curTTLs['key1'], 'Initial ttls' );
+ $this->assertGreaterThanOrEqual( 9.5, $curTTLs['key2'], 'Initial ttls' );
+ $this->assertLessThanOrEqual( 10.5, $curTTLs['key2'], 'Initial ttls' );
+
+ $mockWallClock += 0.100;
+ $cache->touchCheckKey( $check1 );
+
+ $curTTLs = [];
+ $result = $cache->getMulti( [ 'key1', 'key2', 'key3' ], $curTTLs, [
+ 'key1' => $check1,
+ $checkAll,
+ 'key2' => $check2,
+ 'key3' => $check3,
+ ] );
+ $this->assertEquals(
+ [ 'key1' => $value1, 'key2' => $value2 ],
+ $result,
+ 'key1 expired by check1, but value still provided'
+ );
+ $this->assertLessThan( 0, $curTTLs['key1'], 'key1 TTL expired' );
+ $this->assertGreaterThan( 0, $curTTLs['key2'], 'key2 still valid' );
+
+ $cache->touchCheckKey( $checkAll );
+
+ $curTTLs = [];
+ $result = $cache->getMulti( [ 'key1', 'key2', 'key3' ], $curTTLs, [
+ 'key1' => $check1,
+ $checkAll,
+ 'key2' => $check2,
+ 'key3' => $check3,
+ ] );
+ $this->assertEquals(
+ [ 'key1' => $value1, 'key2' => $value2 ],
+ $result,
+ 'All keys expired by checkAll, but value still provided'
+ );
+ $this->assertLessThan( 0, $curTTLs['key1'], 'key1 expired by checkAll' );
+ $this->assertLessThan( 0, $curTTLs['key2'], 'key2 expired by checkAll' );
+ }
+
+ /**
+ * @covers WANObjectCache::get()
+ * @covers WANObjectCache::processCheckKeys()
+ */
+ public function testCheckKeyInitHoldoff() {
+ $cache = $this->cache;
+
+ for ( $i = 0; $i < 500; ++$i ) {
+ $key = wfRandomString();
+ $checkKey = wfRandomString();
+ // miss, set, hit
+ $cache->get( $key, $curTTL, [ $checkKey ] );
+ $cache->set( $key, 'val', 10 );
+ $curTTL = null;
+ $v = $cache->get( $key, $curTTL, [ $checkKey ] );
+
+ $this->assertEquals( 'val', $v );
+ $this->assertLessThan( 0, $curTTL, "Step $i: CTL < 0 (miss/set/hit)" );
+ }
+
+ for ( $i = 0; $i < 500; ++$i ) {
+ $key = wfRandomString();
+ $checkKey = wfRandomString();
+ // set, hit
+ $cache->set( $key, 'val', 10 );
+ $curTTL = null;
+ $v = $cache->get( $key, $curTTL, [ $checkKey ] );
+
+ $this->assertEquals( 'val', $v );
+ $this->assertLessThan( 0, $curTTL, "Step $i: CTL < 0 (set/hit)" );
+ }
+ }
+
+ /**
+ * @covers WANObjectCache::delete
+ * @covers WANObjectCache::relayDelete
+ * @covers WANObjectCache::relayPurge
+ */
+ public function testDelete() {
+ $key = wfRandomString();
+ $value = wfRandomString();
+ $this->cache->set( $key, $value );
+
+ $curTTL = null;
+ $v = $this->cache->get( $key, $curTTL );
+ $this->assertEquals( $value, $v, "Key was created with value" );
+ $this->assertGreaterThan( 0, $curTTL, "Existing key has current TTL > 0" );
+
+ $this->cache->delete( $key );
+
+ $curTTL = null;
+ $v = $this->cache->get( $key, $curTTL );
+ $this->assertFalse( $v, "Deleted key has false value" );
+ $this->assertLessThan( 0, $curTTL, "Deleted key has current TTL < 0" );
+
+ $this->cache->set( $key, $value . 'more' );
+ $v = $this->cache->get( $key, $curTTL );
+ $this->assertFalse( $v, "Deleted key is tombstoned and has false value" );
+ $this->assertLessThan( 0, $curTTL, "Deleted key is tombstoned and has current TTL < 0" );
+
+ $this->cache->set( $key, $value );
+ $this->cache->delete( $key, WANObjectCache::HOLDOFF_NONE );
+
+ $curTTL = null;
+ $v = $this->cache->get( $key, $curTTL );
+ $this->assertFalse( $v, "Deleted key has false value" );
+ $this->assertNull( $curTTL, "Deleted key has null current TTL" );
+
+ $this->cache->set( $key, $value );
+ $v = $this->cache->get( $key, $curTTL );
+ $this->assertEquals( $value, $v, "Key was created with value" );
+ $this->assertGreaterThan( 0, $curTTL, "Existing key has current TTL > 0" );
+ }
+
+ /**
+ * @dataProvider getWithSetCallback_versions_provider
+ * @covers WANObjectCache::getWithSetCallback()
+ * @covers WANObjectCache::doGetWithSetCallback()
+ * @param array $extOpts
+ * @param bool $versioned
+ */
+ public function testGetWithSetCallback_versions( array $extOpts, $versioned ) {
+ $cache = $this->cache;
+
+ $key = wfRandomString();
+ $valueV1 = wfRandomString();
+ $valueV2 = [ wfRandomString() ];
+
+ $wasSet = 0;
+ $funcV1 = function () use ( &$wasSet, $valueV1 ) {
+ ++$wasSet;
+
+ return $valueV1;
+ };
+
+ $priorValue = false;
+ $priorAsOf = null;
+ $funcV2 = function ( $oldValue, &$ttl, $setOpts, $oldAsOf )
+ use ( &$wasSet, $valueV2, &$priorValue, &$priorAsOf ) {
+ $priorValue = $oldValue;
+ $priorAsOf = $oldAsOf;
+ ++$wasSet;
+
+ return $valueV2; // new array format
+ };
+
+ // Set the main key (version N if versioned)
+ $wasSet = 0;
+ $v = $cache->getWithSetCallback( $key, 30, $funcV1, $extOpts );
+ $this->assertEquals( $valueV1, $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated" );
+ $cache->getWithSetCallback( $key, 30, $funcV1, $extOpts );
+ $this->assertEquals( 1, $wasSet, "Value not regenerated" );
+ $this->assertEquals( $valueV1, $v, "Value not regenerated" );
+
+ if ( $versioned ) {
+ // Set the key for version N+1 format
+ $verOpts = [ 'version' => $extOpts['version'] + 1 ];
+ } else {
+ // Start versioning now with the unversioned key still there
+ $verOpts = [ 'version' => 1 ];
+ }
+
+ // Value goes to secondary key since V1 already used $key
+ $wasSet = 0;
+ $v = $cache->getWithSetCallback( $key, 30, $funcV2, $verOpts + $extOpts );
+ $this->assertEquals( $valueV2, $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated" );
+ $this->assertEquals( false, $priorValue, "Old value not given due to old format" );
+ $this->assertEquals( null, $priorAsOf, "Old value not given due to old format" );
+
+ $wasSet = 0;
+ $v = $cache->getWithSetCallback( $key, 30, $funcV2, $verOpts + $extOpts );
+ $this->assertEquals( $valueV2, $v, "Value not regenerated (secondary key)" );
+ $this->assertEquals( 0, $wasSet, "Value not regenerated (secondary key)" );
+
+ // Clear out the older or unversioned key
+ $cache->delete( $key, 0 );
+
+ // Set the key for next/first versioned format
+ $wasSet = 0;
+ $v = $cache->getWithSetCallback( $key, 30, $funcV2, $verOpts + $extOpts );
+ $this->assertEquals( $valueV2, $v, "Value returned" );
+ $this->assertEquals( 1, $wasSet, "Value regenerated" );
+
+ $v = $cache->getWithSetCallback( $key, 30, $funcV2, $verOpts + $extOpts );
+ $this->assertEquals( $valueV2, $v, "Value not regenerated (main key)" );
+ $this->assertEquals( 1, $wasSet, "Value not regenerated (main key)" );
+ }
+
+ public static function getWithSetCallback_versions_provider() {
+ return [
+ [ [], false ],
+ [ [ 'version' => 1 ], true ]
+ ];
+ }
+
+ /**
+ * @covers WANObjectCache::useInterimHoldOffCaching
+ * @covers WANObjectCache::getInterimValue
+ */
+ public function testInterimHoldOffCaching() {
+ $cache = $this->cache;
+
+ $value = 'CRL-40-940';
+ $wasCalled = 0;
+ $func = function () use ( &$wasCalled, $value ) {
+ $wasCalled++;
+
+ return $value;
+ };
+
+ $cache->useInterimHoldOffCaching( true );
+
+ $key = wfRandomString( 32 );
+ $v = $cache->getWithSetCallback( $key, 60, $func );
+ $v = $cache->getWithSetCallback( $key, 60, $func );
+ $this->assertEquals( 1, $wasCalled, 'Value cached' );
+ $cache->delete( $key );
+ $v = $cache->getWithSetCallback( $key, 60, $func );
+ $this->assertEquals( 2, $wasCalled, 'Value regenerated (got mutex)' ); // sets interim
+ $v = $cache->getWithSetCallback( $key, 60, $func );
+ $this->assertEquals( 3, $wasCalled, 'Value regenerated (got mutex)' ); // sets interim
+ // Lock up the mutex so interim cache is used
+ $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+ $v = $cache->getWithSetCallback( $key, 60, $func );
+ $this->assertEquals( 3, $wasCalled, 'Value interim cached (failed mutex)' );
+ $this->internalCache->delete( $cache::MUTEX_KEY_PREFIX . $key );
+
+ $cache->useInterimHoldOffCaching( false );
+
+ $wasCalled = 0;
+ $key = wfRandomString( 32 );
+ $v = $cache->getWithSetCallback( $key, 60, $func );
+ $v = $cache->getWithSetCallback( $key, 60, $func );
+ $this->assertEquals( 1, $wasCalled, 'Value cached' );
+ $cache->delete( $key );
+ $v = $cache->getWithSetCallback( $key, 60, $func );
+ $this->assertEquals( 2, $wasCalled, 'Value regenerated (got mutex)' );
+ $v = $cache->getWithSetCallback( $key, 60, $func );
+ $this->assertEquals( 3, $wasCalled, 'Value still regenerated (got mutex)' );
+ $v = $cache->getWithSetCallback( $key, 60, $func );
+ $this->assertEquals( 4, $wasCalled, 'Value still regenerated (got mutex)' );
+ // Lock up the mutex so interim cache is used
+ $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+ $v = $cache->getWithSetCallback( $key, 60, $func );
+ $this->assertEquals( 5, $wasCalled, 'Value still regenerated (failed mutex)' );
+ }
+
+ /**
+ * @covers WANObjectCache::touchCheckKey
+ * @covers WANObjectCache::resetCheckKey
+ * @covers WANObjectCache::getCheckKeyTime
+ * @covers WANObjectCache::getMultiCheckKeyTime
+ * @covers WANObjectCache::makePurgeValue
+ * @covers WANObjectCache::parsePurgeValue
+ */
+ public function testTouchKeys() {
+ $cache = $this->cache;
+ $key = wfRandomString();
+
+ $mockWallClock = microtime( true );
+ $priorTime = $mockWallClock; // reference time
+ $cache->setMockTime( $mockWallClock );
+
+ $mockWallClock += 0.100;
+ $t0 = $cache->getCheckKeyTime( $key );
+ $this->assertGreaterThanOrEqual( $priorTime, $t0, 'Check key auto-created' );
+
+ $priorTime = $mockWallClock;
+ $mockWallClock += 0.100;
+ $cache->touchCheckKey( $key );
+ $t1 = $cache->getCheckKeyTime( $key );
+ $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check key created' );
+
+ $t2 = $cache->getCheckKeyTime( $key );
+ $this->assertEquals( $t1, $t2, 'Check key time did not change' );
+
+ $mockWallClock += 0.100;
+ $cache->touchCheckKey( $key );
+ $t3 = $cache->getCheckKeyTime( $key );
+ $this->assertGreaterThan( $t2, $t3, 'Check key time increased' );
+
+ $t4 = $cache->getCheckKeyTime( $key );
+ $this->assertEquals( $t3, $t4, 'Check key time did not change' );
+
+ $mockWallClock += 0.100;
+ $cache->resetCheckKey( $key );
+ $t5 = $cache->getCheckKeyTime( $key );
+ $this->assertGreaterThan( $t4, $t5, 'Check key time increased' );
+
+ $t6 = $cache->getCheckKeyTime( $key );
+ $this->assertEquals( $t5, $t6, 'Check key time did not change' );
+ }
+
+ /**
+ * @covers WANObjectCache::getMulti()
+ */
+ public function testGetWithSeveralCheckKeys() {
+ $key = wfRandomString();
+ $tKey1 = wfRandomString();
+ $tKey2 = wfRandomString();
+ $value = 'meow';
+
+ // Two check keys are newer (given hold-off) than $key, another is older
+ $this->internalCache->set(
+ WANObjectCache::TIME_KEY_PREFIX . $tKey2,
+ WANObjectCache::PURGE_VAL_PREFIX . ( microtime( true ) - 3 )
+ );
+ $this->internalCache->set(
+ WANObjectCache::TIME_KEY_PREFIX . $tKey2,
+ WANObjectCache::PURGE_VAL_PREFIX . ( microtime( true ) - 5 )
+ );
+ $this->internalCache->set(
+ WANObjectCache::TIME_KEY_PREFIX . $tKey1,
+ WANObjectCache::PURGE_VAL_PREFIX . ( microtime( true ) - 30 )
+ );
+ $this->cache->set( $key, $value, 30 );
+
+ $curTTL = null;
+ $v = $this->cache->get( $key, $curTTL, [ $tKey1, $tKey2 ] );
+ $this->assertEquals( $value, $v, "Value matches" );
+ $this->assertLessThan( -4.9, $curTTL, "Correct CTL" );
+ $this->assertGreaterThan( -5.1, $curTTL, "Correct CTL" );
+ }
+
+ /**
+ * @covers WANObjectCache::reap()
+ * @covers WANObjectCache::reapCheckKey()
+ */
+ public function testReap() {
+ $vKey1 = wfRandomString();
+ $vKey2 = wfRandomString();
+ $tKey1 = wfRandomString();
+ $tKey2 = wfRandomString();
+ $value = 'moo';
+
+ $knownPurge = time() - 60;
+ $goodTime = microtime( true ) - 5;
+ $badTime = microtime( true ) - 300;
+
+ $this->internalCache->set(
+ WANObjectCache::VALUE_KEY_PREFIX . $vKey1,
+ [
+ WANObjectCache::FLD_VERSION => WANObjectCache::VERSION,
+ WANObjectCache::FLD_VALUE => $value,
+ WANObjectCache::FLD_TTL => 3600,
+ WANObjectCache::FLD_TIME => $goodTime
+ ]
+ );
+ $this->internalCache->set(
+ WANObjectCache::VALUE_KEY_PREFIX . $vKey2,
+ [
+ WANObjectCache::FLD_VERSION => WANObjectCache::VERSION,
+ WANObjectCache::FLD_VALUE => $value,
+ WANObjectCache::FLD_TTL => 3600,
+ WANObjectCache::FLD_TIME => $badTime
+ ]
+ );
+ $this->internalCache->set(
+ WANObjectCache::TIME_KEY_PREFIX . $tKey1,
+ WANObjectCache::PURGE_VAL_PREFIX . $goodTime
+ );
+ $this->internalCache->set(
+ WANObjectCache::TIME_KEY_PREFIX . $tKey2,
+ WANObjectCache::PURGE_VAL_PREFIX . $badTime
+ );
+
+ $this->assertEquals( $value, $this->cache->get( $vKey1 ) );
+ $this->assertEquals( $value, $this->cache->get( $vKey2 ) );
+ $this->cache->reap( $vKey1, $knownPurge, $bad1 );
+ $this->cache->reap( $vKey2, $knownPurge, $bad2 );
+
+ $this->assertFalse( $bad1 );
+ $this->assertTrue( $bad2 );
+
+ $this->cache->reapCheckKey( $tKey1, $knownPurge, $tBad1 );
+ $this->cache->reapCheckKey( $tKey2, $knownPurge, $tBad2 );
+ $this->assertFalse( $tBad1 );
+ $this->assertTrue( $tBad2 );
+ }
+
+ /**
+ * @covers WANObjectCache::reap()
+ */
+ public function testReap_fail() {
+ $backend = $this->getMockBuilder( EmptyBagOStuff::class )
+ ->setMethods( [ 'get', 'changeTTL' ] )->getMock();
+ $backend->expects( $this->once() )->method( 'get' )
+ ->willReturn( [
+ WANObjectCache::FLD_VERSION => WANObjectCache::VERSION,
+ WANObjectCache::FLD_VALUE => 'value',
+ WANObjectCache::FLD_TTL => 3600,
+ WANObjectCache::FLD_TIME => 300,
+ ] );
+ $backend->expects( $this->once() )->method( 'changeTTL' )
+ ->willReturn( false );
+
+ $wanCache = new WANObjectCache( [
+ 'cache' => $backend,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] )
+ ] );
+
+ $isStale = null;
+ $ret = $wanCache->reap( 'key', 360, $isStale );
+ $this->assertTrue( $isStale, 'value was stale' );
+ $this->assertFalse( $ret, 'changeTTL failed' );
+ }
+
+ /**
+ * @covers WANObjectCache::set()
+ */
+ public function testSetWithLag() {
+ $value = 1;
+
+ $key = wfRandomString();
+ $opts = [ 'lag' => 300, 'since' => microtime( true ) ];
+ $this->cache->set( $key, $value, 30, $opts );
+ $this->assertEquals( $value, $this->cache->get( $key ), "Rep-lagged value written." );
+
+ $key = wfRandomString();
+ $opts = [ 'lag' => 0, 'since' => microtime( true ) - 300 ];
+ $this->cache->set( $key, $value, 30, $opts );
+ $this->assertEquals( false, $this->cache->get( $key ), "Trx-lagged value not written." );
+
+ $key = wfRandomString();
+ $opts = [ 'lag' => 5, 'since' => microtime( true ) - 5 ];
+ $this->cache->set( $key, $value, 30, $opts );
+ $this->assertEquals( false, $this->cache->get( $key ), "Lagged value not written." );
+ }
+
+ /**
+ * @covers WANObjectCache::set()
+ */
+ public function testWritePending() {
+ $value = 1;
+
+ $key = wfRandomString();
+ $opts = [ 'pending' => true ];
+ $this->cache->set( $key, $value, 30, $opts );
+ $this->assertEquals( false, $this->cache->get( $key ), "Pending value not written." );
+ }
+
+ public function testMcRouterSupport() {
+ $localBag = $this->getMockBuilder( EmptyBagOStuff::class )
+ ->setMethods( [ 'set', 'delete' ] )->getMock();
+ $localBag->expects( $this->never() )->method( 'set' );
+ $localBag->expects( $this->never() )->method( 'delete' );
+ $wanCache = new WANObjectCache( [
+ 'cache' => $localBag,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] ),
+ 'mcrouterAware' => true,
+ 'region' => 'pmtpa',
+ 'cluster' => 'mw-wan'
+ ] );
+ $valFunc = function () {
+ return 1;
+ };
+
+ // None of these should use broadcasting commands (e.g. SET, DELETE)
+ $wanCache->get( 'x' );
+ $wanCache->get( 'x', $ctl, [ 'check1' ] );
+ $wanCache->getMulti( [ 'x', 'y' ] );
+ $wanCache->getMulti( [ 'x', 'y' ], $ctls, [ 'check2' ] );
+ $wanCache->getWithSetCallback( 'p', 30, $valFunc );
+ $wanCache->getCheckKeyTime( 'zzz' );
+ $wanCache->reap( 'x', time() - 300 );
+ $wanCache->reap( 'zzz', time() - 300 );
+ }
+
+ public function testMcRouterSupportBroadcastDelete() {
+ $localBag = $this->getMockBuilder( EmptyBagOStuff::class )
+ ->setMethods( [ 'set' ] )->getMock();
+ $wanCache = new WANObjectCache( [
+ 'cache' => $localBag,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] ),
+ 'mcrouterAware' => true,
+ 'region' => 'pmtpa',
+ 'cluster' => 'mw-wan'
+ ] );
+
+ $localBag->expects( $this->once() )->method( 'set' )
+ ->with( "/*/mw-wan/" . $wanCache::VALUE_KEY_PREFIX . "test" );
+
+ $wanCache->delete( 'test' );
+ }
+
+ public function testMcRouterSupportBroadcastTouchCK() {
+ $localBag = $this->getMockBuilder( EmptyBagOStuff::class )
+ ->setMethods( [ 'set' ] )->getMock();
+ $wanCache = new WANObjectCache( [
+ 'cache' => $localBag,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] ),
+ 'mcrouterAware' => true,
+ 'region' => 'pmtpa',
+ 'cluster' => 'mw-wan'
+ ] );
+
+ $localBag->expects( $this->once() )->method( 'set' )
+ ->with( "/*/mw-wan/" . $wanCache::TIME_KEY_PREFIX . "test" );
+
+ $wanCache->touchCheckKey( 'test' );
+ }
+
+ public function testMcRouterSupportBroadcastResetCK() {
+ $localBag = $this->getMockBuilder( EmptyBagOStuff::class )
+ ->setMethods( [ 'delete' ] )->getMock();
+ $wanCache = new WANObjectCache( [
+ 'cache' => $localBag,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] ),
+ 'mcrouterAware' => true,
+ 'region' => 'pmtpa',
+ 'cluster' => 'mw-wan'
+ ] );
+
+ $localBag->expects( $this->once() )->method( 'delete' )
+ ->with( "/*/mw-wan/" . $wanCache::TIME_KEY_PREFIX . "test" );
+
+ $wanCache->resetCheckKey( 'test' );
+ }
+
+ /**
+ * @dataProvider provideAdaptiveTTL
+ * @covers WANObjectCache::adaptiveTTL()
+ * @param float|int $ago
+ * @param int $maxTTL
+ * @param int $minTTL
+ * @param float $factor
+ * @param int $adaptiveTTL
+ */
+ public function testAdaptiveTTL( $ago, $maxTTL, $minTTL, $factor, $adaptiveTTL ) {
+ $mtime = $ago ? time() - $ago : $ago;
+ $margin = 5;
+ $ttl = $this->cache->adaptiveTTL( $mtime, $maxTTL, $minTTL, $factor );
+
+ $this->assertGreaterThanOrEqual( $adaptiveTTL - $margin, $ttl );
+ $this->assertLessThanOrEqual( $adaptiveTTL + $margin, $ttl );
+
+ $ttl = $this->cache->adaptiveTTL( (string)$mtime, $maxTTL, $minTTL, $factor );
+
+ $this->assertGreaterThanOrEqual( $adaptiveTTL - $margin, $ttl );
+ $this->assertLessThanOrEqual( $adaptiveTTL + $margin, $ttl );
+ }
+
+ public static function provideAdaptiveTTL() {
+ return [
+ [ 3600, 900, 30, 0.2, 720 ],
+ [ 3600, 500, 30, 0.2, 500 ],
+ [ 3600, 86400, 800, 0.2, 800 ],
+ [ false, 86400, 800, 0.2, 800 ],
+ [ null, 86400, 800, 0.2, 800 ]
+ ];
+ }
+
+ /**
+ * @covers WANObjectCache::__construct
+ * @covers WANObjectCache::newEmpty
+ */
+ public function testNewEmpty() {
+ $this->assertInstanceOf(
+ WANObjectCache::class,
+ WANObjectCache::newEmpty()
+ );
+ }
+
+ /**
+ * @covers WANObjectCache::setLogger
+ */
+ public function testSetLogger() {
+ $this->assertSame( null, $this->cache->setLogger( new Psr\Log\NullLogger ) );
+ }
+
+ /**
+ * @covers WANObjectCache::getQoS
+ */
+ public function testGetQoS() {
+ $backend = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'getQoS' ] )->getMock();
+ $backend->expects( $this->once() )->method( 'getQoS' )
+ ->willReturn( BagOStuff::QOS_UNKNOWN );
+ $wanCache = new WANObjectCache( [ 'cache' => $backend ] );
+
+ $this->assertSame(
+ $wanCache::QOS_UNKNOWN,
+ $wanCache->getQoS( $wanCache::ATTR_EMULATION )
+ );
+ }
+
+ /**
+ * @covers WANObjectCache::makeKey
+ */
+ public function testMakeKey() {
+ $backend = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'makeKey' ] )->getMock();
+ $backend->expects( $this->once() )->method( 'makeKey' )
+ ->willReturn( 'special' );
+
+ $wanCache = new WANObjectCache( [
+ 'cache' => $backend,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] )
+ ] );
+
+ $this->assertSame( 'special', $wanCache->makeKey( 'a', 'b' ) );
+ }
+
+ /**
+ * @covers WANObjectCache::makeGlobalKey
+ */
+ public function testMakeGlobalKey() {
+ $backend = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'makeGlobalKey' ] )->getMock();
+ $backend->expects( $this->once() )->method( 'makeGlobalKey' )
+ ->willReturn( 'special' );
+
+ $wanCache = new WANObjectCache( [
+ 'cache' => $backend,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] )
+ ] );
+
+ $this->assertSame( 'special', $wanCache->makeGlobalKey( 'a', 'b' ) );
+ }
+
+ public static function statsKeyProvider() {
+ return [
+ [ 'domain:page:5', 'page' ],
+ [ 'domain:main-key', 'main-key' ],
+ [ 'domain:page:history', 'page' ],
+ [ 'missingdomainkey', 'missingdomainkey' ]
+ ];
+ }
+
+ /**
+ * @dataProvider statsKeyProvider
+ * @covers WANObjectCache::determineKeyClass
+ */
+ public function testStatsKeyClass( $key, $class ) {
+ $wanCache = TestingAccessWrapper::newFromObject( new WANObjectCache( [
+ 'cache' => new HashBagOStuff,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] )
+ ] ) );
+
+ $this->assertEquals( $class, $wanCache->determineKeyClass( $key ) );
+ }
+}
+
+class NearExpiringWANObjectCache extends WANObjectCache {
+ const CLOCK_SKEW = 1;
+
+ protected function worthRefreshExpiring( $curTTL, $lowTTL ) {
+ return ( $curTTL > 0 && ( $curTTL + self::CLOCK_SKEW ) < $lowTTL );
+ }
+}
+
+class PopularityRefreshingWANObjectCache extends WANObjectCache {
+ protected function worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now ) {
+ return ( ( $now - $asOf ) > $timeTillRefresh );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php
new file mode 100644
index 00000000..538d625c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php
@@ -0,0 +1,147 @@
+<?php
+
+use Wikimedia\Rdbms\TransactionProfiler;
+use Psr\Log\LoggerInterface;
+
+/**
+ * @covers \Wikimedia\Rdbms\TransactionProfiler
+ */
+class TransactionProfilerTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function testAffected() {
+ $logger = $this->getMockBuilder( LoggerInterface::class )->getMock();
+ $logger->expects( $this->exactly( 3 ) )->method( 'warning' );
+
+ $tp = new TransactionProfiler();
+ $tp->setLogger( $logger );
+ $tp->setExpectation( 'maxAffected', 100, __METHOD__ );
+
+ $tp->transactionWritingIn( 'srv1', 'db1', '123' );
+ $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 3, true, 200 );
+ $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 3, true, 200 );
+ $tp->transactionWritingOut( 'srv1', 'db1', '123', 1, 400 );
+ }
+
+ public function testReadTime() {
+ $logger = $this->getMockBuilder( LoggerInterface::class )->getMock();
+ // 1 per query
+ $logger->expects( $this->exactly( 2 ) )->method( 'warning' );
+
+ $tp = new TransactionProfiler();
+ $tp->setLogger( $logger );
+ $tp->setExpectation( 'readQueryTime', 5, __METHOD__ );
+
+ $tp->transactionWritingIn( 'srv1', 'db1', '123' );
+ $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 10, false, 1 );
+ $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 10, false, 1 );
+ $tp->transactionWritingOut( 'srv1', 'db1', '123', 0, 0 );
+ }
+
+ public function testWriteTime() {
+ $logger = $this->getMockBuilder( LoggerInterface::class )->getMock();
+ // 1 per query, 1 per trx, and one "sub-optimal trx" entry
+ $logger->expects( $this->exactly( 4 ) )->method( 'warning' );
+
+ $tp = new TransactionProfiler();
+ $tp->setLogger( $logger );
+ $tp->setExpectation( 'writeQueryTime', 5, __METHOD__ );
+
+ $tp->transactionWritingIn( 'srv1', 'db1', '123' );
+ $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 10, true, 1 );
+ $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 10, true, 1 );
+ $tp->transactionWritingOut( 'srv1', 'db1', '123', 20, 1 );
+ }
+
+ public function testAffectedTrx() {
+ $logger = $this->getMockBuilder( LoggerInterface::class )->getMock();
+ $logger->expects( $this->exactly( 1 ) )->method( 'warning' );
+
+ $tp = new TransactionProfiler();
+ $tp->setLogger( $logger );
+ $tp->setExpectation( 'maxAffected', 100, __METHOD__ );
+
+ $tp->transactionWritingIn( 'srv1', 'db1', '123' );
+ $tp->transactionWritingOut( 'srv1', 'db1', '123', 1, 200 );
+ }
+
+ public function testWriteTimeTrx() {
+ $logger = $this->getMockBuilder( LoggerInterface::class )->getMock();
+ // 1 per trx, and one "sub-optimal trx" entry
+ $logger->expects( $this->exactly( 2 ) )->method( 'warning' );
+
+ $tp = new TransactionProfiler();
+ $tp->setLogger( $logger );
+ $tp->setExpectation( 'writeQueryTime', 5, __METHOD__ );
+
+ $tp->transactionWritingIn( 'srv1', 'db1', '123' );
+ $tp->transactionWritingOut( 'srv1', 'db1', '123', 10, 1 );
+ }
+
+ public function testConns() {
+ $logger = $this->getMockBuilder( LoggerInterface::class )->getMock();
+ $logger->expects( $this->exactly( 2 ) )->method( 'warning' );
+
+ $tp = new TransactionProfiler();
+ $tp->setLogger( $logger );
+ $tp->setExpectation( 'conns', 2, __METHOD__ );
+
+ $tp->recordConnection( 'srv1', 'db1', false );
+ $tp->recordConnection( 'srv1', 'db2', false );
+ $tp->recordConnection( 'srv1', 'db3', false ); // warn
+ $tp->recordConnection( 'srv1', 'db4', false ); // warn
+ }
+
+ public function testMasterConns() {
+ $logger = $this->getMockBuilder( LoggerInterface::class )->getMock();
+ $logger->expects( $this->exactly( 2 ) )->method( 'warning' );
+
+ $tp = new TransactionProfiler();
+ $tp->setLogger( $logger );
+ $tp->setExpectation( 'masterConns', 2, __METHOD__ );
+
+ $tp->recordConnection( 'srv1', 'db1', false );
+ $tp->recordConnection( 'srv1', 'db2', false );
+
+ $tp->recordConnection( 'srv1', 'db1', true );
+ $tp->recordConnection( 'srv1', 'db2', true );
+ $tp->recordConnection( 'srv1', 'db3', true ); // warn
+ $tp->recordConnection( 'srv1', 'db4', true ); // warn
+ }
+
+ public function testReadQueryCount() {
+ $logger = $this->getMockBuilder( LoggerInterface::class )->getMock();
+ $logger->expects( $this->exactly( 2 ) )->method( 'warning' );
+
+ $tp = new TransactionProfiler();
+ $tp->setLogger( $logger );
+ $tp->setExpectation( 'queries', 2, __METHOD__ );
+
+ $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 0.01, false, 0 );
+ $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 0.01, false, 0 );
+ $tp->recordQueryCompletion( "SQL 3", microtime( true ) - 0.01, false, 0 ); // warn
+ $tp->recordQueryCompletion( "SQL 4", microtime( true ) - 0.01, false, 0 ); // warn
+ }
+
+ public function testWriteQueryCount() {
+ $logger = $this->getMockBuilder( LoggerInterface::class )->getMock();
+ $logger->expects( $this->exactly( 2 ) )->method( 'warning' );
+
+ $tp = new TransactionProfiler();
+ $tp->setLogger( $logger );
+ $tp->setExpectation( 'writes', 2, __METHOD__ );
+
+ $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 0.01, false, 0 );
+ $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 0.01, false, 0 );
+ $tp->recordQueryCompletion( "SQL 3", microtime( true ) - 0.01, false, 0 );
+ $tp->recordQueryCompletion( "SQL 4", microtime( true ) - 0.01, false, 0 );
+
+ $tp->transactionWritingIn( 'srv1', 'db1', '123' );
+ $tp->recordQueryCompletion( "SQL 1w", microtime( true ) - 0.01, true, 2 );
+ $tp->recordQueryCompletion( "SQL 2w", microtime( true ) - 0.01, true, 5 );
+ $tp->recordQueryCompletion( "SQL 3w", microtime( true ) - 0.01, true, 3 );
+ $tp->recordQueryCompletion( "SQL 4w", microtime( true ) - 0.01, true, 1 );
+ $tp->transactionWritingOut( 'srv1', 'db1', '123', 1, 1 );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php
new file mode 100644
index 00000000..dd86a73e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace Wikimedia\Tests\Rdbms;
+
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\LoadBalancer;
+use PHPUnit_Framework_MockObject_MockObject;
+use Wikimedia\Rdbms\ConnectionManager;
+
+/**
+ * @covers Wikimedia\Rdbms\ConnectionManager
+ *
+ * @author Daniel Kinzler
+ */
+class ConnectionManagerTest extends \PHPUnit\Framework\TestCase {
+
+ /**
+ * @return IDatabase|PHPUnit_Framework_MockObject_MockObject
+ */
+ private function getIDatabaseMock() {
+ return $this->getMockBuilder( IDatabase::class )
+ ->getMock();
+ }
+
+ /**
+ * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject
+ */
+ private function getLoadBalancerMock() {
+ $lb = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ return $lb;
+ }
+
+ public function testGetReadConnection_nullGroups() {
+ $database = $this->getIDatabaseMock();
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'getConnection' )
+ ->with( DB_REPLICA, [ 'group1' ], 'someDbName' )
+ ->will( $this->returnValue( $database ) );
+
+ $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] );
+ $actual = $manager->getReadConnection();
+
+ $this->assertSame( $database, $actual );
+ }
+
+ public function testGetReadConnection_withGroups() {
+ $database = $this->getIDatabaseMock();
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'getConnection' )
+ ->with( DB_REPLICA, [ 'group2' ], 'someDbName' )
+ ->will( $this->returnValue( $database ) );
+
+ $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] );
+ $actual = $manager->getReadConnection( [ 'group2' ] );
+
+ $this->assertSame( $database, $actual );
+ }
+
+ public function testGetWriteConnection() {
+ $database = $this->getIDatabaseMock();
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'getConnection' )
+ ->with( DB_MASTER, [ 'group1' ], 'someDbName' )
+ ->will( $this->returnValue( $database ) );
+
+ $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] );
+ $actual = $manager->getWriteConnection();
+
+ $this->assertSame( $database, $actual );
+ }
+
+ public function testReleaseConnection() {
+ $database = $this->getIDatabaseMock();
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'reuseConnection' )
+ ->with( $database )
+ ->will( $this->returnValue( null ) );
+
+ $manager = new ConnectionManager( $lb );
+ $manager->releaseConnection( $database );
+ }
+
+ public function testGetReadConnectionRef_nullGroups() {
+ $database = $this->getIDatabaseMock();
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'getConnectionRef' )
+ ->with( DB_REPLICA, [ 'group1' ], 'someDbName' )
+ ->will( $this->returnValue( $database ) );
+
+ $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] );
+ $actual = $manager->getReadConnectionRef();
+
+ $this->assertSame( $database, $actual );
+ }
+
+ public function testGetReadConnectionRef_withGroups() {
+ $database = $this->getIDatabaseMock();
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'getConnectionRef' )
+ ->with( DB_REPLICA, [ 'group2' ], 'someDbName' )
+ ->will( $this->returnValue( $database ) );
+
+ $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] );
+ $actual = $manager->getReadConnectionRef( [ 'group2' ] );
+
+ $this->assertSame( $database, $actual );
+ }
+
+ public function testGetWriteConnectionRef() {
+ $database = $this->getIDatabaseMock();
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'getConnectionRef' )
+ ->with( DB_MASTER, [ 'group1' ], 'someDbName' )
+ ->will( $this->returnValue( $database ) );
+
+ $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] );
+ $actual = $manager->getWriteConnectionRef();
+
+ $this->assertSame( $database, $actual );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php
new file mode 100644
index 00000000..8d7d104c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Wikimedia\Tests\Rdbms;
+
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\LoadBalancer;
+use PHPUnit_Framework_MockObject_MockObject;
+use Wikimedia\Rdbms\SessionConsistentConnectionManager;
+
+/**
+ * @covers Wikimedia\Rdbms\SessionConsistentConnectionManager
+ *
+ * @author Daniel Kinzler
+ */
+class SessionConsistentConnectionManagerTest extends \PHPUnit\Framework\TestCase {
+
+ /**
+ * @return IDatabase|PHPUnit_Framework_MockObject_MockObject
+ */
+ private function getIDatabaseMock() {
+ return $this->getMockBuilder( IDatabase::class )
+ ->getMock();
+ }
+
+ /**
+ * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject
+ */
+ private function getLoadBalancerMock() {
+ $lb = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ return $lb;
+ }
+
+ public function testGetReadConnection() {
+ $database = $this->getIDatabaseMock();
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'getConnection' )
+ ->with( DB_REPLICA )
+ ->will( $this->returnValue( $database ) );
+
+ $manager = new SessionConsistentConnectionManager( $lb );
+ $actual = $manager->getReadConnection();
+
+ $this->assertSame( $database, $actual );
+ }
+
+ public function testGetReadConnectionReturnsWriteDbOnForceMatser() {
+ $database = $this->getIDatabaseMock();
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'getConnection' )
+ ->with( DB_MASTER )
+ ->will( $this->returnValue( $database ) );
+
+ $manager = new SessionConsistentConnectionManager( $lb );
+ $manager->prepareForUpdates();
+ $actual = $manager->getReadConnection();
+
+ $this->assertSame( $database, $actual );
+ }
+
+ public function testGetWriteConnection() {
+ $database = $this->getIDatabaseMock();
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'getConnection' )
+ ->with( DB_MASTER )
+ ->will( $this->returnValue( $database ) );
+
+ $manager = new SessionConsistentConnectionManager( $lb );
+ $actual = $manager->getWriteConnection();
+
+ $this->assertSame( $database, $actual );
+ }
+
+ public function testForceMaster() {
+ $database = $this->getIDatabaseMock();
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'getConnection' )
+ ->with( DB_MASTER )
+ ->will( $this->returnValue( $database ) );
+
+ $manager = new SessionConsistentConnectionManager( $lb );
+ $manager->prepareForUpdates();
+ $manager->getReadConnection();
+ }
+
+ public function testReleaseConnection() {
+ $database = $this->getIDatabaseMock();
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'reuseConnection' )
+ ->with( $database )
+ ->will( $this->returnValue( null ) );
+
+ $manager = new SessionConsistentConnectionManager( $lb );
+ $manager->releaseConnection( $database );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php
new file mode 100644
index 00000000..c3cddc61
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php
@@ -0,0 +1,148 @@
+<?php
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DBConnRef;
+use Wikimedia\Rdbms\FakeResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\ILoadBalancer;
+use Wikimedia\Rdbms\ResultWrapper;
+
+/**
+ * @covers Wikimedia\Rdbms\DBConnRef
+ */
+class DBConnRefTest extends PHPUnit\Framework\TestCase {
+
+ use PHPUnit4And6Compat;
+
+ /**
+ * @return ILoadBalancer
+ */
+ private function getLoadBalancerMock() {
+ $lb = $this->getMock( ILoadBalancer::class );
+
+ $lb->method( 'getConnection' )->willReturnCallback(
+ function () {
+ return $this->getDatabaseMock();
+ }
+ );
+
+ $lb->method( 'getConnectionRef' )->willReturnCallback(
+ function () use ( $lb ) {
+ return $this->getDBConnRef( $lb );
+ }
+ );
+
+ return $lb;
+ }
+
+ /**
+ * @return IDatabase
+ */
+ private function getDatabaseMock() {
+ $db = $this->getMockBuilder( Database::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $db->method( 'select' )->willReturn( new FakeResultWrapper( [] ) );
+ $db->method( '__toString' )->willReturn( 'MOCK_DB' );
+
+ return $db;
+ }
+
+ /**
+ * @return IDatabase
+ */
+ private function getDBConnRef( ILoadBalancer $lb = null ) {
+ $lb = $lb ?: $this->getLoadBalancerMock();
+ return new DBConnRef( $lb, $this->getDatabaseMock() );
+ }
+
+ public function testConstruct() {
+ $lb = $this->getLoadBalancerMock();
+ $ref = new DBConnRef( $lb, $this->getDatabaseMock() );
+
+ $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) );
+ }
+
+ public function testConstruct_params() {
+ $lb = $this->getMock( ILoadBalancer::class );
+
+ $lb->expects( $this->once() )
+ ->method( 'getConnection' )
+ ->with( DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT )
+ ->willReturnCallback(
+ function () {
+ return $this->getDatabaseMock();
+ }
+ );
+
+ $ref = new DBConnRef(
+ $lb,
+ [ DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT ]
+ );
+
+ $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) );
+ }
+
+ public function testDestruct() {
+ $lb = $this->getLoadBalancerMock();
+
+ $lb->expects( $this->once() )
+ ->method( 'reuseConnection' );
+
+ $this->innerMethodForTestDestruct( $lb );
+ }
+
+ private function innerMethodForTestDestruct( ILoadBalancer $lb ) {
+ $ref = $lb->getConnectionRef( DB_REPLICA );
+
+ $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) );
+ }
+
+ public function testConstruct_failure() {
+ $this->setExpectedException( InvalidArgumentException::class, '' );
+
+ $lb = $this->getLoadBalancerMock();
+ new DBConnRef( $lb, 17 ); // bad constructor argument
+ }
+
+ public function testGetWikiID() {
+ $lb = $this->getMock( ILoadBalancer::class );
+
+ // getWikiID is optimized to not create a connection
+ $lb->expects( $this->never() )
+ ->method( 'getConnection' );
+
+ $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ] );
+
+ $this->assertSame( 'dummy', $ref->getWikiID() );
+ }
+
+ public function testGetDomainID() {
+ $lb = $this->getMock( ILoadBalancer::class );
+
+ // getDomainID is optimized to not create a connection
+ $lb->expects( $this->never() )
+ ->method( 'getConnection' );
+
+ $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ] );
+
+ $this->assertSame( 'dummy', $ref->getDomainID() );
+ }
+
+ public function testSelect() {
+ // select should get passed through normally
+ $ref = $this->getDBConnRef();
+ $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) );
+ }
+
+ public function testToString() {
+ $ref = $this->getDBConnRef();
+ $this->assertInternalType( 'string', $ref->__toString() );
+
+ $lb = $this->getLoadBalancerMock();
+ $ref = new DBConnRef( $lb, [ DB_MASTER, [], 'test', 0 ] );
+ $this->assertInternalType( 'string', $ref->__toString() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php
new file mode 100644
index 00000000..b2e71554
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php
@@ -0,0 +1,133 @@
+<?php
+
+use Wikimedia\Rdbms\DatabaseDomain;
+
+/**
+ * @covers Wikimedia\Rdbms\DatabaseDomain
+ */
+class DatabaseDomainTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ public static function provideConstruct() {
+ return [
+ 'All strings' =>
+ [ 'foo', 'bar', 'baz', 'foo-bar-baz' ],
+ 'Nothing' =>
+ [ null, null, '', '' ],
+ 'Invalid $database' =>
+ [ 0, 'bar', '', '', true ],
+ 'Invalid $schema' =>
+ [ 'foo', 0, '', '', true ],
+ 'Invalid $prefix' =>
+ [ 'foo', 'bar', 0, '', true ],
+ 'Dash' =>
+ [ 'foo-bar', 'baz', 'baa', 'foo?hbar-baz-baa' ],
+ 'Question mark' =>
+ [ 'foo?bar', 'baz', 'baa', 'foo??bar-baz-baa' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstruct
+ */
+ public function testConstruct( $db, $schema, $prefix, $id, $exception = false ) {
+ if ( $exception ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ new DatabaseDomain( $db, $schema, $prefix );
+ return;
+ }
+
+ $domain = new DatabaseDomain( $db, $schema, $prefix );
+ $this->assertInstanceOf( DatabaseDomain::class, $domain );
+ $this->assertEquals( $db, $domain->getDatabase() );
+ $this->assertEquals( $schema, $domain->getSchema() );
+ $this->assertEquals( $prefix, $domain->getTablePrefix() );
+ $this->assertEquals( $id, $domain->getId() );
+ $this->assertEquals( $id, strval( $domain ), 'toString' );
+ }
+
+ public static function provideNewFromId() {
+ return [
+ 'Basic' =>
+ [ 'foo', 'foo', null, '' ],
+ 'db+prefix' =>
+ [ 'foo-bar', 'foo', null, 'bar' ],
+ 'db+schema+prefix' =>
+ [ 'foo-bar-baz', 'foo', 'bar', 'baz' ],
+ '?h -> -' =>
+ [ 'foo?hbar-baz-baa', 'foo-bar', 'baz', 'baa' ],
+ '?? -> ?' =>
+ [ 'foo??bar-baz-baa', 'foo?bar', 'baz', 'baa' ],
+ '? is left alone' =>
+ [ 'foo?bar-baz-baa', 'foo?bar', 'baz', 'baa' ],
+ 'too many parts' =>
+ [ 'foo-bar-baz-baa', '', '', '', true ],
+ 'from instance' =>
+ [ DatabaseDomain::newUnspecified(), null, null, '' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideNewFromId
+ */
+ public function testNewFromId( $id, $db, $schema, $prefix, $exception = false ) {
+ if ( $exception ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ DatabaseDomain::newFromId( $id );
+ return;
+ }
+ $domain = DatabaseDomain::newFromId( $id );
+ $this->assertInstanceOf( DatabaseDomain::class, $domain );
+ $this->assertEquals( $db, $domain->getDatabase() );
+ $this->assertEquals( $schema, $domain->getSchema() );
+ $this->assertEquals( $prefix, $domain->getTablePrefix() );
+ }
+
+ public static function provideEquals() {
+ return [
+ 'Basic' =>
+ [ 'foo', 'foo', null, '' ],
+ 'db+prefix' =>
+ [ 'foo-bar', 'foo', null, 'bar' ],
+ 'db+schema+prefix' =>
+ [ 'foo-bar-baz', 'foo', 'bar', 'baz' ],
+ '?h -> -' =>
+ [ 'foo?hbar-baz-baa', 'foo-bar', 'baz', 'baa' ],
+ '?? -> ?' =>
+ [ 'foo??bar-baz-baa', 'foo?bar', 'baz', 'baa' ],
+ 'Nothing' =>
+ [ '', null, null, '' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideEquals
+ * @covers Wikimedia\Rdbms\DatabaseDomain::equals
+ */
+ public function testEquals( $id, $db, $schema, $prefix ) {
+ $fromId = DatabaseDomain::newFromId( $id );
+ $this->assertInstanceOf( DatabaseDomain::class, $fromId );
+
+ $constructed = new DatabaseDomain( $db, $schema, $prefix );
+
+ $this->assertTrue( $constructed->equals( $id ), 'constructed equals string' );
+ $this->assertTrue( $fromId->equals( $id ), 'fromId equals string' );
+
+ $this->assertTrue( $constructed->equals( $fromId ), 'compare constructed to newId' );
+ $this->assertTrue( $fromId->equals( $constructed ), 'compare newId to constructed' );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\DatabaseDomain::newUnspecified
+ */
+ public function testNewUnspecified() {
+ $domain = DatabaseDomain::newUnspecified();
+ $this->assertInstanceOf( DatabaseDomain::class, $domain );
+ $this->assertTrue( $domain->equals( '' ) );
+ $this->assertSame( null, $domain->getDatabase() );
+ $this->assertSame( null, $domain->getSchema() );
+ $this->assertSame( '', $domain->getTablePrefix() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php
new file mode 100644
index 00000000..b28a5b9e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php
@@ -0,0 +1,55 @@
+<?php
+
+use Wikimedia\Rdbms\DatabaseMssql;
+
+class DatabaseMssqlTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|DatabaseMssql
+ */
+ private function getMockDb() {
+ return $this->getMockBuilder( DatabaseMssql::class )
+ ->disableOriginalConstructor()
+ ->setMethods( null )
+ ->getMock();
+ }
+
+ public function provideBuildSubstring() {
+ yield [ 'someField', 1, 2, 'SUBSTRING(someField,1,2)' ];
+ yield [ 'someField', 1, null, 'SUBSTRING(someField,1,2147483647)' ];
+ yield [ 'someField', 1, 3333333333, 'SUBSTRING(someField,1,3333333333)' ];
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\DatabaseMssql::buildSubstring
+ * @dataProvider provideBuildSubstring
+ */
+ public function testBuildSubstring( $input, $start, $length, $expected ) {
+ $mockDb = $this->getMockDb();
+ $output = $mockDb->buildSubstring( $input, $start, $length );
+ $this->assertSame( $expected, $output );
+ }
+
+ public function provideBuildSubstring_invalidParams() {
+ yield [ -1, 1 ];
+ yield [ 1, -1 ];
+ yield [ 1, 'foo' ];
+ yield [ 'foo', 1 ];
+ yield [ null, 1 ];
+ yield [ 0, 1 ];
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\DatabaseMssql::buildSubstring
+ * @dataProvider provideBuildSubstring_invalidParams
+ */
+ public function testBuildSubstring_invalidParams( $start, $length ) {
+ $mockDb = $this->getMockDb();
+ $this->setExpectedException( InvalidArgumentException::class );
+ $mockDb->buildSubstring( 'foo', $start, $length );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php
new file mode 100644
index 00000000..93192d01
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php
@@ -0,0 +1,743 @@
+<?php
+/**
+ * Holds tests for DatabaseMysqlBase class.
+ *
+ * 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
+ * @author Antoine Musso
+ * @copyright © 2013 Antoine Musso
+ * @copyright © 2013 Wikimedia Foundation and contributors
+ */
+
+use Wikimedia\Rdbms\MySQLMasterPos;
+use Wikimedia\TestingAccessWrapper;
+
+class DatabaseMysqlBaseTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ /**
+ * @dataProvider provideDiapers
+ * @covers Wikimedia\Rdbms\DatabaseMysqlBase::addIdentifierQuotes
+ */
+ public function testAddIdentifierQuotes( $expected, $in ) {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( null )
+ ->getMock();
+
+ $quoted = $db->addIdentifierQuotes( $in );
+ $this->assertEquals( $expected, $quoted );
+ }
+
+ /**
+ * Feeds testAddIdentifierQuotes
+ *
+ * Named per T22281 convention.
+ */
+ public static function provideDiapers() {
+ return [
+ // Format: expected, input
+ [ '``', '' ],
+
+ // Yeah I really hate loosely typed PHP idiocies nowadays
+ [ '``', null ],
+
+ // Dear codereviewer, guess what addIdentifierQuotes()
+ // will return with thoses:
+ [ '``', false ],
+ [ '`1`', true ],
+
+ // We never know what could happen
+ [ '`0`', 0 ],
+ [ '`1`', 1 ],
+
+ // Whatchout! Should probably use something more meaningful
+ [ "`'`", "'" ], # single quote
+ [ '`"`', '"' ], # double quote
+ [ '````', '`' ], # backtick
+ [ '`’`', '’' ], # apostrophe (look at your encyclopedia)
+
+ // sneaky NUL bytes are lurking everywhere
+ [ '``', "\0" ],
+ [ '`xyzzy`', "\0x\0y\0z\0z\0y\0" ],
+
+ // unicode chars
+ [
+ self::createUnicodeString( '`\u0001a\uFFFFb`' ),
+ self::createUnicodeString( '\u0001a\uFFFFb' )
+ ],
+ [
+ self::createUnicodeString( '`\u0001\uFFFF`' ),
+ self::createUnicodeString( '\u0001\u0000\uFFFF\u0000' )
+ ],
+ [ '`☃`', '☃' ],
+ [ '`メインページ`', 'メインページ' ],
+ [ '`Басты_бет`', 'Басты_бет' ],
+
+ // Real world:
+ [ '`Alix`', 'Alix' ], # while( ! $recovered ) { sleep(); }
+ [ '`Backtick: ```', 'Backtick: `' ],
+ [ '`This is a test`', 'This is a test' ],
+ ];
+ }
+
+ private static function createUnicodeString( $str ) {
+ return json_decode( '"' . $str . '"' );
+ }
+
+ private function getMockForViews() {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'fetchRow', 'query' ] )
+ ->getMock();
+
+ $db->method( 'query' )
+ ->with( $this->anything() )
+ ->willReturn( new FakeResultWrapper( [
+ (object)[ 'Tables_in_' => 'view1' ],
+ (object)[ 'Tables_in_' => 'view2' ],
+ (object)[ 'Tables_in_' => 'myview' ]
+ ] ) );
+
+ return $db;
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\DatabaseMysqlBase::listViews
+ */
+ public function testListviews() {
+ $db = $this->getMockForViews();
+
+ $this->assertEquals( [ 'view1', 'view2', 'myview' ],
+ $db->listViews() );
+
+ // Prefix filtering
+ $this->assertEquals( [ 'view1', 'view2' ],
+ $db->listViews( 'view' ) );
+ $this->assertEquals( [ 'myview' ],
+ $db->listViews( 'my' ) );
+ $this->assertEquals( [],
+ $db->listViews( 'UNUSED_PREFIX' ) );
+ $this->assertEquals( [ 'view1', 'view2', 'myview' ],
+ $db->listViews( '' ) );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\MySQLMasterPos
+ */
+ public function testBinLogName() {
+ $pos = new MySQLMasterPos( "db1052.2424/4643", 1 );
+
+ $this->assertEquals( "db1052", $pos->getLogName() );
+ $this->assertEquals( "db1052.2424", $pos->getLogFile() );
+ $this->assertEquals( [ 2424, 4643 ], $pos->getLogPosition() );
+ }
+
+ /**
+ * @dataProvider provideComparePositions
+ * @covers Wikimedia\Rdbms\MySQLMasterPos
+ */
+ public function testHasReached(
+ MySQLMasterPos $lowerPos, MySQLMasterPos $higherPos, $match, $hetero
+ ) {
+ if ( $match ) {
+ $this->assertTrue( $lowerPos->channelsMatch( $higherPos ) );
+
+ if ( $hetero ) {
+ // Each position is has one channel higher than the other
+ $this->assertFalse( $higherPos->hasReached( $lowerPos ) );
+ } else {
+ $this->assertTrue( $higherPos->hasReached( $lowerPos ) );
+ }
+ $this->assertTrue( $lowerPos->hasReached( $lowerPos ) );
+ $this->assertTrue( $higherPos->hasReached( $higherPos ) );
+ $this->assertFalse( $lowerPos->hasReached( $higherPos ) );
+ } else { // channels don't match
+ $this->assertFalse( $lowerPos->channelsMatch( $higherPos ) );
+
+ $this->assertFalse( $higherPos->hasReached( $lowerPos ) );
+ $this->assertFalse( $lowerPos->hasReached( $higherPos ) );
+ }
+ }
+
+ public static function provideComparePositions() {
+ $now = microtime( true );
+
+ return [
+ // Binlog style
+ [
+ new MySQLMasterPos( 'db1034-bin.000976/843431247', $now ),
+ new MySQLMasterPos( 'db1034-bin.000976/843431248', $now ),
+ true,
+ false
+ ],
+ [
+ new MySQLMasterPos( 'db1034-bin.000976/999', $now ),
+ new MySQLMasterPos( 'db1034-bin.000976/1000', $now ),
+ true,
+ false
+ ],
+ [
+ new MySQLMasterPos( 'db1034-bin.000976/999', $now ),
+ new MySQLMasterPos( 'db1035-bin.000976/1000', $now ),
+ false,
+ false
+ ],
+ // MySQL GTID style
+ [
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-23', $now ),
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-24', $now ),
+ true,
+ false
+ ],
+ [
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-99', $now ),
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ),
+ true,
+ false
+ ],
+ [
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-99', $now ),
+ new MySQLMasterPos( '1E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ),
+ false,
+ false
+ ],
+ // MariaDB GTID style
+ [
+ new MySQLMasterPos( '255-11-23', $now ),
+ new MySQLMasterPos( '255-11-24', $now ),
+ true,
+ false
+ ],
+ [
+ new MySQLMasterPos( '255-11-99', $now ),
+ new MySQLMasterPos( '255-11-100', $now ),
+ true,
+ false
+ ],
+ [
+ new MySQLMasterPos( '255-11-999', $now ),
+ new MySQLMasterPos( '254-11-1000', $now ),
+ false,
+ false
+ ],
+ [
+ new MySQLMasterPos( '255-11-23,256-12-50', $now ),
+ new MySQLMasterPos( '255-11-24', $now ),
+ true,
+ false
+ ],
+ [
+ new MySQLMasterPos( '255-11-99,256-12-50,257-12-50', $now ),
+ new MySQLMasterPos( '255-11-1000', $now ),
+ true,
+ false
+ ],
+ [
+ new MySQLMasterPos( '255-11-23,256-12-50', $now ),
+ new MySQLMasterPos( '255-11-24,155-52-63', $now ),
+ true,
+ false
+ ],
+ [
+ new MySQLMasterPos( '255-11-99,256-12-50,257-12-50', $now ),
+ new MySQLMasterPos( '255-11-1000,256-12-51', $now ),
+ true,
+ false
+ ],
+ [
+ new MySQLMasterPos( '255-11-99,256-12-50', $now ),
+ new MySQLMasterPos( '255-13-1000,256-14-49', $now ),
+ true,
+ true
+ ],
+ [
+ new MySQLMasterPos( '253-11-999,255-11-999', $now ),
+ new MySQLMasterPos( '254-11-1000', $now ),
+ false,
+ false
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideChannelPositions
+ * @covers Wikimedia\Rdbms\MySQLMasterPos
+ */
+ public function testChannelsMatch( MySQLMasterPos $pos1, MySQLMasterPos $pos2, $matches ) {
+ $this->assertEquals( $matches, $pos1->channelsMatch( $pos2 ) );
+ $this->assertEquals( $matches, $pos2->channelsMatch( $pos1 ) );
+
+ $roundtripPos = new MySQLMasterPos( (string)$pos1, 1 );
+ $this->assertEquals( (string)$pos1, (string)$roundtripPos );
+ }
+
+ public static function provideChannelPositions() {
+ $now = microtime( true );
+
+ return [
+ [
+ new MySQLMasterPos( 'db1034-bin.000876/44', $now ),
+ new MySQLMasterPos( 'db1034-bin.000976/74', $now ),
+ true
+ ],
+ [
+ new MySQLMasterPos( 'db1052-bin.000976/999', $now ),
+ new MySQLMasterPos( 'db1052-bin.000976/1000', $now ),
+ true
+ ],
+ [
+ new MySQLMasterPos( 'db1066-bin.000976/9999', $now ),
+ new MySQLMasterPos( 'db1035-bin.000976/10000', $now ),
+ false
+ ],
+ [
+ new MySQLMasterPos( 'db1066-bin.000976/9999', $now ),
+ new MySQLMasterPos( 'trump2016.000976/10000', $now ),
+ false
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCommonDomainGTIDs
+ * @covers Wikimedia\Rdbms\MySQLMasterPos
+ */
+ public function testCommonGtidDomains( MySQLMasterPos $pos, MySQLMasterPos $ref, $gtids ) {
+ $this->assertEquals( $gtids, MySQLMasterPos::getCommonDomainGTIDs( $pos, $ref ) );
+ }
+
+ public static function provideCommonDomainGTIDs() {
+ return [
+ [
+ new MySQLMasterPos( '255-13-99,256-12-50,257-14-50', 1 ),
+ new MySQLMasterPos( '255-11-1000', 1 ),
+ [ '255-13-99' ]
+ ],
+ [
+ new MySQLMasterPos(
+ '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-5,' .
+ '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99,' .
+ '7E11FA47-71CA-11E1-9E33-C80AA9429562:1-30',
+ 1
+ ),
+ new MySQLMasterPos(
+ '1E11FA47-71CA-11E1-9E33-C80AA9429562:30-100,' .
+ '3E11FA47-71CA-11E1-9E33-C80AA9429562:30-66',
+ 1
+ ),
+ [ '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99' ]
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideLagAmounts
+ * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getLag
+ * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getLagFromPtHeartbeat
+ */
+ public function testPtHeartbeat( $lag ) {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [
+ 'getLagDetectionMethod', 'getHeartbeatData', 'getMasterServerInfo' ] )
+ ->getMock();
+
+ $db->method( 'getLagDetectionMethod' )
+ ->willReturn( 'pt-heartbeat' );
+
+ $db->method( 'getMasterServerInfo' )
+ ->willReturn( [ 'serverId' => 172, 'asOf' => time() ] );
+
+ // Fake the current time.
+ list( $nowSecFrac, $nowSec ) = explode( ' ', microtime() );
+ $now = (float)$nowSec + (float)$nowSecFrac;
+ // Fake the heartbeat time.
+ // Work arounds for weak DataTime microseconds support.
+ $ptTime = $now - $lag;
+ $ptSec = (int)$ptTime;
+ $ptSecFrac = ( $ptTime - $ptSec );
+ $ptDateTime = new DateTime( "@$ptSec" );
+ $ptTimeISO = $ptDateTime->format( 'Y-m-d\TH:i:s' );
+ $ptTimeISO .= ltrim( number_format( $ptSecFrac, 6 ), '0' );
+
+ $db->method( 'getHeartbeatData' )
+ ->with( [ 'server_id' => 172 ] )
+ ->willReturn( [ $ptTimeISO, $now ] );
+
+ $db->setLBInfo( 'clusterMasterHost', 'db1052' );
+ $lagEst = $db->getLag();
+
+ $this->assertGreaterThan( $lag - 0.010, $lagEst, "Correct heatbeat lag" );
+ $this->assertLessThan( $lag + 0.010, $lagEst, "Correct heatbeat lag" );
+ }
+
+ public static function provideLagAmounts() {
+ return [
+ [ 0 ],
+ [ 0.3 ],
+ [ 6.5 ],
+ [ 10.1 ],
+ [ 200.2 ],
+ [ 400.7 ],
+ [ 600.22 ],
+ [ 1000.77 ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGtidData
+ * @covers Wikimedia\Rdbms\MySQLMasterPos
+ * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getReplicaPos
+ * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getMasterPos
+ */
+ public function testServerGtidTable( $gtable, $rBLtable, $mBLtable, $rGTIDs, $mGTIDs ) {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [
+ 'useGTIDs',
+ 'getServerGTIDs',
+ 'getServerRoleStatus',
+ 'getServerId',
+ 'getServerUUID'
+ ] )
+ ->getMock();
+
+ $db->method( 'useGTIDs' )->willReturn( true );
+ $db->method( 'getServerGTIDs' )->willReturn( $gtable );
+ $db->method( 'getServerRoleStatus' )->willReturnCallback(
+ function ( $role ) use ( $rBLtable, $mBLtable ) {
+ if ( $role === 'SLAVE' ) {
+ return $rBLtable;
+ } elseif ( $role === 'MASTER' ) {
+ return $mBLtable;
+ }
+
+ return null;
+ }
+ );
+ $db->method( 'getServerId' )->willReturn( 1 );
+ $db->method( 'getServerUUID' )->willReturn( '2E11FA47-71CA-11E1-9E33-C80AA9429562' );
+
+ if ( is_array( $rGTIDs ) ) {
+ $this->assertEquals( $rGTIDs, $db->getReplicaPos()->getGTIDs() );
+ } else {
+ $this->assertEquals( false, $db->getReplicaPos() );
+ }
+ if ( is_array( $mGTIDs ) ) {
+ $this->assertEquals( $mGTIDs, $db->getMasterPos()->getGTIDs() );
+ } else {
+ $this->assertEquals( false, $db->getMasterPos() );
+ }
+ }
+
+ public static function provideGtidData() {
+ return [
+ // MariaDB
+ [
+ [
+ 'gtid_domain_id' => 100,
+ 'gtid_current_pos' => '100-13-77',
+ 'gtid_binlog_pos' => '100-13-77',
+ 'gtid_slave_pos' => null // master
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [
+ 'File' => 'host.1600',
+ 'Position' => '77'
+ ],
+ [],
+ [ '100' => '100-13-77' ]
+ ],
+ [
+ [
+ 'gtid_domain_id' => 100,
+ 'gtid_current_pos' => '100-13-77',
+ 'gtid_binlog_pos' => '100-13-77',
+ 'gtid_slave_pos' => '100-13-77' // replica
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [],
+ [ '100' => '100-13-77' ],
+ [ '100' => '100-13-77' ]
+ ],
+ [
+ [
+ 'gtid_current_pos' => '100-13-77',
+ 'gtid_binlog_pos' => '100-13-77',
+ 'gtid_slave_pos' => '100-13-77' // replica
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [],
+ [ '100' => '100-13-77' ],
+ [ '100' => '100-13-77' ]
+ ],
+ // MySQL
+ [
+ [
+ 'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77'
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [], // only a replica
+ [ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
+ => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ],
+ // replica/master use same var
+ [ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
+ => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ],
+ ],
+ [
+ [
+ 'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-49,' .
+ '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77'
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [], // only a replica
+ [ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
+ => '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ],
+ // replica/master use same var
+ [ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
+ => '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ],
+ ],
+ [
+ [
+ 'gtid_executed' => null, // not enabled?
+ 'gtid_binlog_pos' => null
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [], // only a replica
+ [], // binlog fallback
+ false
+ ],
+ [
+ [
+ 'gtid_executed' => null, // not enabled?
+ 'gtid_binlog_pos' => null
+ ],
+ [], // no replication
+ [], // no replication
+ false,
+ false
+ ]
+ ];
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\MySQLMasterPos
+ */
+ public function testSerialize() {
+ $pos = new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:99', 53636363 );
+ $roundtripPos = unserialize( serialize( $pos ) );
+
+ $this->assertEquals( $pos, $roundtripPos );
+
+ $pos = new MySQLMasterPos( '255-11-23', 53636363 );
+ $roundtripPos = unserialize( serialize( $pos ) );
+
+ $this->assertEquals( $pos, $roundtripPos );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\DatabaseMysqlBase::isInsertSelectSafe
+ * @dataProvider provideInsertSelectCases
+ */
+ public function testInsertSelectIsSafe( $insertOpts, $selectOpts, $row, $safe ) {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'getReplicationSafetyInfo' ] )
+ ->getMock();
+ $db->method( 'getReplicationSafetyInfo' )->willReturn( (object)$row );
+ $dbw = TestingAccessWrapper::newFromObject( $db );
+
+ $this->assertEquals( $safe, $dbw->isInsertSelectSafe( $insertOpts, $selectOpts ) );
+ }
+
+ public function provideInsertSelectCases() {
+ return [
+ [
+ [],
+ [],
+ [
+ 'innodb_autoinc_lock_mode' => '2',
+ 'binlog_format' => 'ROW',
+ ],
+ true
+ ],
+ [
+ [],
+ [ 'LIMIT' => 100 ],
+ [
+ 'innodb_autoinc_lock_mode' => '2',
+ 'binlog_format' => 'ROW',
+ ],
+ true
+ ],
+ [
+ [],
+ [ 'LIMIT' => 100 ],
+ [
+ 'innodb_autoinc_lock_mode' => '0',
+ 'binlog_format' => 'STATEMENT',
+ ],
+ false
+ ],
+ [
+ [],
+ [],
+ [
+ 'innodb_autoinc_lock_mode' => '2',
+ 'binlog_format' => 'STATEMENT',
+ ],
+ false
+ ],
+ [
+ [ 'NO_AUTO_COLUMNS' ],
+ [ 'LIMIT' => 100 ],
+ [
+ 'innodb_autoinc_lock_mode' => '0',
+ 'binlog_format' => 'STATEMENT',
+ ],
+ false
+ ],
+ [
+ [],
+ [],
+ [
+ 'innodb_autoinc_lock_mode' => 0,
+ 'binlog_format' => 'STATEMENT',
+ ],
+ true
+ ],
+ [
+ [ 'NO_AUTO_COLUMNS' ],
+ [],
+ [
+ 'innodb_autoinc_lock_mode' => 2,
+ 'binlog_format' => 'STATEMENT',
+ ],
+ true
+ ],
+ [
+ [ 'NO_AUTO_COLUMNS' ],
+ [],
+ [
+ 'innodb_autoinc_lock_mode' => 0,
+ 'binlog_format' => 'STATEMENT',
+ ],
+ true
+ ],
+
+ ];
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\DatabaseMysqlBase::buildIntegerCast
+ */
+ public function testBuildIntegerCast() {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( null )
+ ->getMock();
+ $output = $db->buildIntegerCast( 'fieldName' );
+ $this->assertSame( 'CAST( fieldName AS SIGNED )', $output );
+ }
+
+ /*
+ * @covers Wikimedia\Rdbms\Database::setIndexAliases
+ */
+ public function testIndexAliases() {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'mysqlRealEscapeString' ] )
+ ->getMock();
+ $db->method( 'mysqlRealEscapeString' )->willReturnCallback(
+ function ( $s ) {
+ return str_replace( "'", "\\'", $s );
+ }
+ );
+
+ $db->setIndexAliases( [ 'a_b_idx' => 'a_c_idx' ] );
+ $sql = $db->selectSQLText(
+ 'zend', 'field', [ 'a' => 'x' ], __METHOD__, [ 'USE INDEX' => 'a_b_idx' ] );
+
+ $this->assertEquals(
+ "SELECT field FROM `zend` FORCE INDEX (a_c_idx) WHERE a = 'x' ",
+ $sql
+ );
+
+ $db->setIndexAliases( [] );
+ $sql = $db->selectSQLText(
+ 'zend', 'field', [ 'a' => 'x' ], __METHOD__, [ 'USE INDEX' => 'a_b_idx' ] );
+
+ $this->assertEquals(
+ "SELECT field FROM `zend` FORCE INDEX (a_b_idx) WHERE a = 'x' ",
+ $sql
+ );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::setTableAliases
+ */
+ public function testTableAliases() {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'mysqlRealEscapeString' ] )
+ ->getMock();
+ $db->method( 'mysqlRealEscapeString' )->willReturnCallback(
+ function ( $s ) {
+ return str_replace( "'", "\\'", $s );
+ }
+ );
+
+ $db->setTableAliases( [
+ 'meow' => [ 'dbname' => 'feline', 'schema' => null, 'prefix' => 'cat_' ]
+ ] );
+ $sql = $db->selectSQLText( 'meow', 'field', [ 'a' => 'x' ], __METHOD__ );
+
+ $this->assertEquals(
+ "SELECT field FROM `feline`.`cat_meow` WHERE a = 'x' ",
+ $sql
+ );
+
+ $db->setTableAliases( [] );
+ $sql = $db->selectSQLText( 'meow', 'field', [ 'a' => 'x' ], __METHOD__ );
+
+ $this->assertEquals(
+ "SELECT field FROM `meow` WHERE a = 'x' ",
+ $sql
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php
new file mode 100644
index 00000000..ab2f11b5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php
@@ -0,0 +1,2067 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\LikeMatch;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\TestingAccessWrapper;
+use Wikimedia\Rdbms\DBTransactionStateError;
+use Wikimedia\Rdbms\DBUnexpectedError;
+use Wikimedia\Rdbms\DBTransactionError;
+
+/**
+ * Test the parts of the Database abstract class that deal
+ * with creating SQL text.
+ */
+class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ /** @var DatabaseTestHelper|Database */
+ private $database;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->database = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => true ] );
+ }
+
+ protected function assertLastSql( $sqlText ) {
+ $this->assertEquals(
+ $sqlText,
+ $this->database->getLastSqls()
+ );
+ }
+
+ protected function assertLastSqlDb( $sqlText, DatabaseTestHelper $db ) {
+ $this->assertEquals( $sqlText, $db->getLastSqls() );
+ }
+
+ /**
+ * @dataProvider provideSelect
+ * @covers Wikimedia\Rdbms\Database::select
+ * @covers Wikimedia\Rdbms\Database::selectSQLText
+ * @covers Wikimedia\Rdbms\Database::tableNamesWithIndexClauseOrJOIN
+ * @covers Wikimedia\Rdbms\Database::useIndexClause
+ * @covers Wikimedia\Rdbms\Database::ignoreIndexClause
+ * @covers Wikimedia\Rdbms\Database::makeSelectOptions
+ * @covers Wikimedia\Rdbms\Database::makeOrderBy
+ * @covers Wikimedia\Rdbms\Database::makeGroupByWithHaving
+ */
+ public function testSelect( $sql, $sqlText ) {
+ $this->database->select(
+ $sql['tables'],
+ $sql['fields'],
+ isset( $sql['conds'] ) ? $sql['conds'] : [],
+ __METHOD__,
+ isset( $sql['options'] ) ? $sql['options'] : [],
+ isset( $sql['join_conds'] ) ? $sql['join_conds'] : []
+ );
+ $this->assertLastSql( $sqlText );
+ }
+
+ public static function provideSelect() {
+ return [
+ [
+ [
+ 'tables' => 'table',
+ 'fields' => [ 'field', 'alias' => 'field2' ],
+ 'conds' => [ 'alias' => 'text' ],
+ ],
+ "SELECT field,field2 AS alias " .
+ "FROM table " .
+ "WHERE alias = 'text'"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'fields' => [ 'field', 'alias' => 'field2' ],
+ 'conds' => 'alias = \'text\'',
+ ],
+ "SELECT field,field2 AS alias " .
+ "FROM table " .
+ "WHERE alias = 'text'"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'fields' => [ 'field', 'alias' => 'field2' ],
+ 'conds' => [],
+ ],
+ "SELECT field,field2 AS alias " .
+ "FROM table"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'fields' => [ 'field', 'alias' => 'field2' ],
+ 'conds' => '',
+ ],
+ "SELECT field,field2 AS alias " .
+ "FROM table"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'fields' => [ 'field', 'alias' => 'field2' ],
+ 'conds' => '0', // T188314
+ ],
+ "SELECT field,field2 AS alias " .
+ "FROM table " .
+ "WHERE 0"
+ ],
+ [
+ [
+ // 'tables' with space prepended indicates pre-escaped table name
+ 'tables' => ' table LEFT JOIN table2',
+ 'fields' => [ 'field' ],
+ 'conds' => [ 'field' => 'text' ],
+ ],
+ "SELECT field FROM table LEFT JOIN table2 WHERE field = 'text'"
+ ],
+ [
+ [
+ // Empty 'tables' is allowed
+ 'tables' => '',
+ 'fields' => [ 'SPECIAL_QUERY()' ],
+ ],
+ "SELECT SPECIAL_QUERY()"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'fields' => [ 'field', 'alias' => 'field2' ],
+ 'conds' => [ 'alias' => 'text' ],
+ 'options' => [ 'LIMIT' => 1, 'ORDER BY' => 'field' ],
+ ],
+ "SELECT field,field2 AS alias " .
+ "FROM table " .
+ "WHERE alias = 'text' " .
+ "ORDER BY field " .
+ "LIMIT 1"
+ ],
+ [
+ [
+ 'tables' => [ 'table', 't2' => 'table2' ],
+ 'fields' => [ 'tid', 'field', 'alias' => 'field2', 't2.id' ],
+ 'conds' => [ 'alias' => 'text' ],
+ 'options' => [ 'LIMIT' => 1, 'ORDER BY' => 'field' ],
+ 'join_conds' => [ 't2' => [
+ 'LEFT JOIN', 'tid = t2.id'
+ ] ],
+ ],
+ "SELECT tid,field,field2 AS alias,t2.id " .
+ "FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " .
+ "WHERE alias = 'text' " .
+ "ORDER BY field " .
+ "LIMIT 1"
+ ],
+ [
+ [
+ 'tables' => [ 'table', 't2' => 'table2' ],
+ 'fields' => [ 'tid', 'field', 'alias' => 'field2', 't2.id' ],
+ 'conds' => [ 'alias' => 'text' ],
+ 'options' => [ 'LIMIT' => 1, 'GROUP BY' => 'field', 'HAVING' => 'COUNT(*) > 1' ],
+ 'join_conds' => [ 't2' => [
+ 'LEFT JOIN', 'tid = t2.id'
+ ] ],
+ ],
+ "SELECT tid,field,field2 AS alias,t2.id " .
+ "FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " .
+ "WHERE alias = 'text' " .
+ "GROUP BY field HAVING COUNT(*) > 1 " .
+ "LIMIT 1"
+ ],
+ [
+ [
+ 'tables' => [ 'table', 't2' => 'table2' ],
+ 'fields' => [ 'tid', 'field', 'alias' => 'field2', 't2.id' ],
+ 'conds' => [ 'alias' => 'text' ],
+ 'options' => [
+ 'LIMIT' => 1,
+ 'GROUP BY' => [ 'field', 'field2' ],
+ 'HAVING' => [ 'COUNT(*) > 1', 'field' => 1 ]
+ ],
+ 'join_conds' => [ 't2' => [
+ 'LEFT JOIN', 'tid = t2.id'
+ ] ],
+ ],
+ "SELECT tid,field,field2 AS alias,t2.id " .
+ "FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " .
+ "WHERE alias = 'text' " .
+ "GROUP BY field,field2 HAVING (COUNT(*) > 1) AND field = '1' " .
+ "LIMIT 1"
+ ],
+ [
+ [
+ 'tables' => [ 'table' ],
+ 'fields' => [ 'alias' => 'field' ],
+ 'conds' => [ 'alias' => [ 1, 2, 3, 4 ] ],
+ ],
+ "SELECT field AS alias " .
+ "FROM table " .
+ "WHERE alias IN ('1','2','3','4')"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'fields' => [ 'field' ],
+ 'options' => [ 'USE INDEX' => [ 'table' => 'X' ] ],
+ ],
+ // No-op by default
+ "SELECT field FROM table"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'fields' => [ 'field' ],
+ 'options' => [ 'IGNORE INDEX' => [ 'table' => 'X' ] ],
+ ],
+ // No-op by default
+ "SELECT field FROM table"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'fields' => [ 'field' ],
+ 'options' => [ 'DISTINCT', 'LOCK IN SHARE MODE' ],
+ ],
+ "SELECT DISTINCT field FROM table LOCK IN SHARE MODE"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'fields' => [ 'field' ],
+ 'options' => [ 'EXPLAIN' => true ],
+ ],
+ 'EXPLAIN SELECT field FROM table'
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'fields' => [ 'field' ],
+ 'options' => [ 'FOR UPDATE' ],
+ ],
+ "SELECT field FROM table FOR UPDATE"
+ ],
+ ];
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Subquery
+ * @dataProvider provideSelectRowCount
+ * @param $sql
+ * @param $sqlText
+ */
+ public function testSelectRowCount( $sql, $sqlText ) {
+ $this->database->selectRowCount(
+ $sql['tables'],
+ $sql['field'],
+ isset( $sql['conds'] ) ? $sql['conds'] : [],
+ __METHOD__,
+ isset( $sql['options'] ) ? $sql['options'] : [],
+ isset( $sql['join_conds'] ) ? $sql['join_conds'] : []
+ );
+ $this->assertLastSql( $sqlText );
+ }
+
+ public static function provideSelectRowCount() {
+ return [
+ [
+ [
+ 'tables' => 'table',
+ 'field' => [ '*' ],
+ 'conds' => [ 'field' => 'text' ],
+ ],
+ "SELECT COUNT(*) AS rowcount FROM " .
+ "(SELECT 1 FROM table WHERE field = 'text' ) tmp_count"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'field' => [ 'column' ],
+ 'conds' => [ 'field' => 'text' ],
+ ],
+ "SELECT COUNT(*) AS rowcount FROM " .
+ "(SELECT 1 FROM table WHERE field = 'text' AND (column IS NOT NULL) ) tmp_count"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'field' => [ 'alias' => 'column' ],
+ 'conds' => [ 'field' => 'text' ],
+ ],
+ "SELECT COUNT(*) AS rowcount FROM " .
+ "(SELECT 1 FROM table WHERE field = 'text' AND (column IS NOT NULL) ) tmp_count"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'field' => [ 'alias' => 'column' ],
+ 'conds' => '',
+ ],
+ "SELECT COUNT(*) AS rowcount FROM " .
+ "(SELECT 1 FROM table WHERE (column IS NOT NULL) ) tmp_count"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'field' => [ 'alias' => 'column' ],
+ 'conds' => false,
+ ],
+ "SELECT COUNT(*) AS rowcount FROM " .
+ "(SELECT 1 FROM table WHERE (column IS NOT NULL) ) tmp_count"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'field' => [ 'alias' => 'column' ],
+ 'conds' => null,
+ ],
+ "SELECT COUNT(*) AS rowcount FROM " .
+ "(SELECT 1 FROM table WHERE (column IS NOT NULL) ) tmp_count"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'field' => [ 'alias' => 'column' ],
+ 'conds' => '1',
+ ],
+ "SELECT COUNT(*) AS rowcount FROM " .
+ "(SELECT 1 FROM table WHERE (1) AND (column IS NOT NULL) ) tmp_count"
+ ],
+ [
+ [
+ 'tables' => 'table',
+ 'field' => [ 'alias' => 'column' ],
+ 'conds' => '0',
+ ],
+ "SELECT COUNT(*) AS rowcount FROM " .
+ "(SELECT 1 FROM table WHERE (0) AND (column IS NOT NULL) ) tmp_count"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideUpdate
+ * @covers Wikimedia\Rdbms\Database::update
+ * @covers Wikimedia\Rdbms\Database::makeUpdateOptions
+ * @covers Wikimedia\Rdbms\Database::makeUpdateOptionsArray
+ */
+ public function testUpdate( $sql, $sqlText ) {
+ $this->database->update(
+ $sql['table'],
+ $sql['values'],
+ $sql['conds'],
+ __METHOD__,
+ isset( $sql['options'] ) ? $sql['options'] : []
+ );
+ $this->assertLastSql( $sqlText );
+ }
+
+ public static function provideUpdate() {
+ return [
+ [
+ [
+ 'table' => 'table',
+ 'values' => [ 'field' => 'text', 'field2' => 'text2' ],
+ 'conds' => [ 'alias' => 'text' ],
+ ],
+ "UPDATE table " .
+ "SET field = 'text'" .
+ ",field2 = 'text2' " .
+ "WHERE alias = 'text'"
+ ],
+ [
+ [
+ 'table' => 'table',
+ 'values' => [ 'field = other', 'field2' => 'text2' ],
+ 'conds' => [ 'id' => '1' ],
+ ],
+ "UPDATE table " .
+ "SET field = other" .
+ ",field2 = 'text2' " .
+ "WHERE id = '1'"
+ ],
+ [
+ [
+ 'table' => 'table',
+ 'values' => [ 'field = other', 'field2' => 'text2' ],
+ 'conds' => '*',
+ ],
+ "UPDATE table " .
+ "SET field = other" .
+ ",field2 = 'text2'"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideDelete
+ * @covers Wikimedia\Rdbms\Database::delete
+ */
+ public function testDelete( $sql, $sqlText ) {
+ $this->database->delete(
+ $sql['table'],
+ $sql['conds'],
+ __METHOD__
+ );
+ $this->assertLastSql( $sqlText );
+ }
+
+ public static function provideDelete() {
+ return [
+ [
+ [
+ 'table' => 'table',
+ 'conds' => [ 'alias' => 'text' ],
+ ],
+ "DELETE FROM table " .
+ "WHERE alias = 'text'"
+ ],
+ [
+ [
+ 'table' => 'table',
+ 'conds' => '*',
+ ],
+ "DELETE FROM table"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideUpsert
+ * @covers Wikimedia\Rdbms\Database::upsert
+ */
+ public function testUpsert( $sql, $sqlText ) {
+ $this->database->upsert(
+ $sql['table'],
+ $sql['rows'],
+ $sql['uniqueIndexes'],
+ $sql['set'],
+ __METHOD__
+ );
+ $this->assertLastSql( $sqlText );
+ }
+
+ public static function provideUpsert() {
+ return [
+ [
+ [
+ 'table' => 'upsert_table',
+ 'rows' => [ 'field' => 'text', 'field2' => 'text2' ],
+ 'uniqueIndexes' => [ 'field' ],
+ 'set' => [ 'field' => 'set' ],
+ ],
+ "BEGIN; " .
+ "UPDATE upsert_table " .
+ "SET field = 'set' " .
+ "WHERE ((field = 'text')); " .
+ "INSERT IGNORE INTO upsert_table " .
+ "(field,field2) " .
+ "VALUES ('text','text2'); " .
+ "COMMIT"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideDeleteJoin
+ * @covers Wikimedia\Rdbms\Database::deleteJoin
+ */
+ public function testDeleteJoin( $sql, $sqlText ) {
+ $this->database->deleteJoin(
+ $sql['delTable'],
+ $sql['joinTable'],
+ $sql['delVar'],
+ $sql['joinVar'],
+ $sql['conds'],
+ __METHOD__
+ );
+ $this->assertLastSql( $sqlText );
+ }
+
+ public static function provideDeleteJoin() {
+ return [
+ [
+ [
+ 'delTable' => 'table',
+ 'joinTable' => 'table_join',
+ 'delVar' => 'field',
+ 'joinVar' => 'field_join',
+ 'conds' => [ 'alias' => 'text' ],
+ ],
+ "DELETE FROM table " .
+ "WHERE field IN (" .
+ "SELECT field_join FROM table_join WHERE alias = 'text'" .
+ ")"
+ ],
+ [
+ [
+ 'delTable' => 'table',
+ 'joinTable' => 'table_join',
+ 'delVar' => 'field',
+ 'joinVar' => 'field_join',
+ 'conds' => '*',
+ ],
+ "DELETE FROM table " .
+ "WHERE field IN (" .
+ "SELECT field_join FROM table_join " .
+ ")"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideInsert
+ * @covers Wikimedia\Rdbms\Database::insert
+ * @covers Wikimedia\Rdbms\Database::makeInsertOptions
+ */
+ public function testInsert( $sql, $sqlText ) {
+ $this->database->insert(
+ $sql['table'],
+ $sql['rows'],
+ __METHOD__,
+ isset( $sql['options'] ) ? $sql['options'] : []
+ );
+ $this->assertLastSql( $sqlText );
+ }
+
+ public static function provideInsert() {
+ return [
+ [
+ [
+ 'table' => 'table',
+ 'rows' => [ 'field' => 'text', 'field2' => 2 ],
+ ],
+ "INSERT INTO table " .
+ "(field,field2) " .
+ "VALUES ('text','2')"
+ ],
+ [
+ [
+ 'table' => 'table',
+ 'rows' => [ 'field' => 'text', 'field2' => 2 ],
+ 'options' => 'IGNORE',
+ ],
+ "INSERT IGNORE INTO table " .
+ "(field,field2) " .
+ "VALUES ('text','2')"
+ ],
+ [
+ [
+ 'table' => 'table',
+ 'rows' => [
+ [ 'field' => 'text', 'field2' => 2 ],
+ [ 'field' => 'multi', 'field2' => 3 ],
+ ],
+ 'options' => 'IGNORE',
+ ],
+ "INSERT IGNORE INTO table " .
+ "(field,field2) " .
+ "VALUES " .
+ "('text','2')," .
+ "('multi','3')"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideInsertSelect
+ * @covers Wikimedia\Rdbms\Database::insertSelect
+ * @covers Wikimedia\Rdbms\Database::nativeInsertSelect
+ */
+ public function testInsertSelect( $sql, $sqlTextNative, $sqlSelect, $sqlInsert ) {
+ $this->database->insertSelect(
+ $sql['destTable'],
+ $sql['srcTable'],
+ $sql['varMap'],
+ $sql['conds'],
+ __METHOD__,
+ isset( $sql['insertOptions'] ) ? $sql['insertOptions'] : [],
+ isset( $sql['selectOptions'] ) ? $sql['selectOptions'] : [],
+ isset( $sql['selectJoinConds'] ) ? $sql['selectJoinConds'] : []
+ );
+ $this->assertLastSql( $sqlTextNative );
+
+ $dbWeb = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => false ] );
+ $dbWeb->forceNextResult( [
+ array_flip( array_keys( $sql['varMap'] ) )
+ ] );
+ $dbWeb->insertSelect(
+ $sql['destTable'],
+ $sql['srcTable'],
+ $sql['varMap'],
+ $sql['conds'],
+ __METHOD__,
+ isset( $sql['insertOptions'] ) ? $sql['insertOptions'] : [],
+ isset( $sql['selectOptions'] ) ? $sql['selectOptions'] : [],
+ isset( $sql['selectJoinConds'] ) ? $sql['selectJoinConds'] : []
+ );
+ $this->assertLastSqlDb( implode( '; ', [ $sqlSelect, 'BEGIN', $sqlInsert, 'COMMIT' ] ), $dbWeb );
+ }
+
+ public static function provideInsertSelect() {
+ return [
+ [
+ [
+ 'destTable' => 'insert_table',
+ 'srcTable' => 'select_table',
+ 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ],
+ 'conds' => '*',
+ ],
+ "INSERT INTO insert_table " .
+ "(field_insert,field) " .
+ "SELECT field_select,field2 " .
+ "FROM select_table WHERE *",
+ "SELECT field_select AS field_insert,field2 AS field " .
+ "FROM select_table WHERE * FOR UPDATE",
+ "INSERT INTO insert_table (field_insert,field) VALUES ('0','1')"
+ ],
+ [
+ [
+ 'destTable' => 'insert_table',
+ 'srcTable' => 'select_table',
+ 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ],
+ 'conds' => [ 'field' => 2 ],
+ ],
+ "INSERT INTO insert_table " .
+ "(field_insert,field) " .
+ "SELECT field_select,field2 " .
+ "FROM select_table " .
+ "WHERE field = '2'",
+ "SELECT field_select AS field_insert,field2 AS field FROM " .
+ "select_table WHERE field = '2' FOR UPDATE",
+ "INSERT INTO insert_table (field_insert,field) VALUES ('0','1')"
+ ],
+ [
+ [
+ 'destTable' => 'insert_table',
+ 'srcTable' => 'select_table',
+ 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ],
+ 'conds' => [ 'field' => 2 ],
+ 'insertOptions' => 'IGNORE',
+ 'selectOptions' => [ 'ORDER BY' => 'field' ],
+ ],
+ "INSERT IGNORE INTO insert_table " .
+ "(field_insert,field) " .
+ "SELECT field_select,field2 " .
+ "FROM select_table " .
+ "WHERE field = '2' " .
+ "ORDER BY field",
+ "SELECT field_select AS field_insert,field2 AS field " .
+ "FROM select_table WHERE field = '2' ORDER BY field FOR UPDATE",
+ "INSERT IGNORE INTO insert_table (field_insert,field) VALUES ('0','1')"
+ ],
+ [
+ [
+ 'destTable' => 'insert_table',
+ 'srcTable' => [ 'select_table1', 'select_table2' ],
+ 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ],
+ 'conds' => [ 'field' => 2 ],
+ 'insertOptions' => [ 'NO_AUTO_COLUMNS' ],
+ 'selectOptions' => [ 'ORDER BY' => 'field', 'FORCE INDEX' => [ 'select_table1' => 'index1' ] ],
+ 'selectJoinConds' => [
+ 'select_table2' => [ 'LEFT JOIN', [ 'select_table1.foo = select_table2.bar' ] ],
+ ],
+ ],
+ "INSERT INTO insert_table " .
+ "(field_insert,field) " .
+ "SELECT field_select,field2 " .
+ "FROM select_table1 LEFT JOIN select_table2 ON ((select_table1.foo = select_table2.bar)) " .
+ "WHERE field = '2' " .
+ "ORDER BY field",
+ "SELECT field_select AS field_insert,field2 AS field " .
+ "FROM select_table1 LEFT JOIN select_table2 ON ((select_table1.foo = select_table2.bar)) " .
+ "WHERE field = '2' ORDER BY field FOR UPDATE",
+ "INSERT INTO insert_table (field_insert,field) VALUES ('0','1')"
+ ],
+ ];
+ }
+
+ public function testInsertSelectBatching() {
+ $dbWeb = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => false ] );
+ $rows = [];
+ for ( $i = 0; $i <= 25000; $i++ ) {
+ $rows[] = [ 'field' => $i ];
+ }
+ $dbWeb->forceNextResult( $rows );
+ $dbWeb->insertSelect(
+ 'insert_table',
+ 'select_table',
+ [ 'field' => 'field2' ],
+ '*',
+ __METHOD__
+ );
+ $this->assertLastSqlDb( implode( '; ', [
+ 'SELECT field2 AS field FROM select_table WHERE * FOR UPDATE',
+ 'BEGIN',
+ "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 0, 9999 ) ) . "')",
+ "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 10000, 19999 ) ) . "')",
+ "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 20000, 25000 ) ) . "')",
+ 'COMMIT'
+ ] ), $dbWeb );
+ }
+
+ /**
+ * @dataProvider provideReplace
+ * @covers Wikimedia\Rdbms\Database::replace
+ */
+ public function testReplace( $sql, $sqlText ) {
+ $this->database->replace(
+ $sql['table'],
+ $sql['uniqueIndexes'],
+ $sql['rows'],
+ __METHOD__
+ );
+ $this->assertLastSql( $sqlText );
+ }
+
+ public static function provideReplace() {
+ return [
+ [
+ [
+ 'table' => 'replace_table',
+ 'uniqueIndexes' => [ 'field' ],
+ 'rows' => [ 'field' => 'text', 'field2' => 'text2' ],
+ ],
+ "BEGIN; DELETE FROM replace_table " .
+ "WHERE (field = 'text'); " .
+ "INSERT INTO replace_table " .
+ "(field,field2) " .
+ "VALUES ('text','text2'); COMMIT"
+ ],
+ [
+ [
+ 'table' => 'module_deps',
+ 'uniqueIndexes' => [ [ 'md_module', 'md_skin' ] ],
+ 'rows' => [
+ 'md_module' => 'module',
+ 'md_skin' => 'skin',
+ 'md_deps' => 'deps',
+ ],
+ ],
+ "BEGIN; DELETE FROM module_deps " .
+ "WHERE (md_module = 'module' AND md_skin = 'skin'); " .
+ "INSERT INTO module_deps " .
+ "(md_module,md_skin,md_deps) " .
+ "VALUES ('module','skin','deps'); COMMIT"
+ ],
+ [
+ [
+ 'table' => 'module_deps',
+ 'uniqueIndexes' => [ [ 'md_module', 'md_skin' ] ],
+ 'rows' => [
+ [
+ 'md_module' => 'module',
+ 'md_skin' => 'skin',
+ 'md_deps' => 'deps',
+ ], [
+ 'md_module' => 'module2',
+ 'md_skin' => 'skin2',
+ 'md_deps' => 'deps2',
+ ],
+ ],
+ ],
+ "BEGIN; DELETE FROM module_deps " .
+ "WHERE (md_module = 'module' AND md_skin = 'skin'); " .
+ "INSERT INTO module_deps " .
+ "(md_module,md_skin,md_deps) " .
+ "VALUES ('module','skin','deps'); " .
+ "DELETE FROM module_deps " .
+ "WHERE (md_module = 'module2' AND md_skin = 'skin2'); " .
+ "INSERT INTO module_deps " .
+ "(md_module,md_skin,md_deps) " .
+ "VALUES ('module2','skin2','deps2'); COMMIT"
+ ],
+ [
+ [
+ 'table' => 'module_deps',
+ 'uniqueIndexes' => [ 'md_module', 'md_skin' ],
+ 'rows' => [
+ [
+ 'md_module' => 'module',
+ 'md_skin' => 'skin',
+ 'md_deps' => 'deps',
+ ], [
+ 'md_module' => 'module2',
+ 'md_skin' => 'skin2',
+ 'md_deps' => 'deps2',
+ ],
+ ],
+ ],
+ "BEGIN; DELETE FROM module_deps " .
+ "WHERE (md_module = 'module') OR (md_skin = 'skin'); " .
+ "INSERT INTO module_deps " .
+ "(md_module,md_skin,md_deps) " .
+ "VALUES ('module','skin','deps'); " .
+ "DELETE FROM module_deps " .
+ "WHERE (md_module = 'module2') OR (md_skin = 'skin2'); " .
+ "INSERT INTO module_deps " .
+ "(md_module,md_skin,md_deps) " .
+ "VALUES ('module2','skin2','deps2'); COMMIT"
+ ],
+ [
+ [
+ 'table' => 'module_deps',
+ 'uniqueIndexes' => [],
+ 'rows' => [
+ 'md_module' => 'module',
+ 'md_skin' => 'skin',
+ 'md_deps' => 'deps',
+ ],
+ ],
+ "BEGIN; INSERT INTO module_deps " .
+ "(md_module,md_skin,md_deps) " .
+ "VALUES ('module','skin','deps'); COMMIT"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideNativeReplace
+ * @covers Wikimedia\Rdbms\Database::nativeReplace
+ */
+ public function testNativeReplace( $sql, $sqlText ) {
+ $this->database->nativeReplace(
+ $sql['table'],
+ $sql['rows'],
+ __METHOD__
+ );
+ $this->assertLastSql( $sqlText );
+ }
+
+ public static function provideNativeReplace() {
+ return [
+ [
+ [
+ 'table' => 'replace_table',
+ 'rows' => [ 'field' => 'text', 'field2' => 'text2' ],
+ ],
+ "REPLACE INTO replace_table " .
+ "(field,field2) " .
+ "VALUES ('text','text2')"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideConditional
+ * @covers Wikimedia\Rdbms\Database::conditional
+ */
+ public function testConditional( $sql, $sqlText ) {
+ $this->assertEquals( trim( $this->database->conditional(
+ $sql['conds'],
+ $sql['true'],
+ $sql['false']
+ ) ), $sqlText );
+ }
+
+ public static function provideConditional() {
+ return [
+ [
+ [
+ 'conds' => [ 'field' => 'text' ],
+ 'true' => 1,
+ 'false' => 'NULL',
+ ],
+ "(CASE WHEN field = 'text' THEN 1 ELSE NULL END)"
+ ],
+ [
+ [
+ 'conds' => [ 'field' => 'text', 'field2' => 'anothertext' ],
+ 'true' => 1,
+ 'false' => 'NULL',
+ ],
+ "(CASE WHEN field = 'text' AND field2 = 'anothertext' THEN 1 ELSE NULL END)"
+ ],
+ [
+ [
+ 'conds' => 'field=1',
+ 'true' => 1,
+ 'false' => 'NULL',
+ ],
+ "(CASE WHEN field=1 THEN 1 ELSE NULL END)"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideBuildConcat
+ * @covers Wikimedia\Rdbms\Database::buildConcat
+ */
+ public function testBuildConcat( $stringList, $sqlText ) {
+ $this->assertEquals( trim( $this->database->buildConcat(
+ $stringList
+ ) ), $sqlText );
+ }
+
+ public static function provideBuildConcat() {
+ return [
+ [
+ [ 'field', 'field2' ],
+ "CONCAT(field,field2)"
+ ],
+ [
+ [ "'test'", 'field2' ],
+ "CONCAT('test',field2)"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideBuildLike
+ * @covers Wikimedia\Rdbms\Database::buildLike
+ * @covers Wikimedia\Rdbms\Database::escapeLikeInternal
+ */
+ public function testBuildLike( $array, $sqlText ) {
+ $this->assertEquals( trim( $this->database->buildLike(
+ $array
+ ) ), $sqlText );
+ }
+
+ public static function provideBuildLike() {
+ return [
+ [
+ 'text',
+ "LIKE 'text' ESCAPE '`'"
+ ],
+ [
+ [ 'text', new LikeMatch( '%' ) ],
+ "LIKE 'text%' ESCAPE '`'"
+ ],
+ [
+ [ 'text', new LikeMatch( '%' ), 'text2' ],
+ "LIKE 'text%text2' ESCAPE '`'"
+ ],
+ [
+ [ 'text', new LikeMatch( '_' ) ],
+ "LIKE 'text_' ESCAPE '`'"
+ ],
+ [
+ 'more_text',
+ "LIKE 'more`_text' ESCAPE '`'"
+ ],
+ [
+ [ 'C:\\Windows\\', new LikeMatch( '%' ) ],
+ "LIKE 'C:\\Windows\\%' ESCAPE '`'"
+ ],
+ [
+ [ 'accent`_test`', new LikeMatch( '%' ) ],
+ "LIKE 'accent```_test``%' ESCAPE '`'"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideUnionQueries
+ * @covers Wikimedia\Rdbms\Database::unionQueries
+ */
+ public function testUnionQueries( $sql, $sqlText ) {
+ $this->assertEquals( trim( $this->database->unionQueries(
+ $sql['sqls'],
+ $sql['all']
+ ) ), $sqlText );
+ }
+
+ public static function provideUnionQueries() {
+ return [
+ [
+ [
+ 'sqls' => [ 'RAW SQL', 'RAW2SQL' ],
+ 'all' => true,
+ ],
+ "(RAW SQL) UNION ALL (RAW2SQL)"
+ ],
+ [
+ [
+ 'sqls' => [ 'RAW SQL', 'RAW2SQL' ],
+ 'all' => false,
+ ],
+ "(RAW SQL) UNION (RAW2SQL)"
+ ],
+ [
+ [
+ 'sqls' => [ 'RAW SQL', 'RAW2SQL', 'RAW3SQL' ],
+ 'all' => false,
+ ],
+ "(RAW SQL) UNION (RAW2SQL) UNION (RAW3SQL)"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideUnionConditionPermutations
+ * @covers Wikimedia\Rdbms\Database::unionConditionPermutations
+ */
+ public function testUnionConditionPermutations( $params, $expect ) {
+ if ( isset( $params['unionSupportsOrderAndLimit'] ) ) {
+ $this->database->setUnionSupportsOrderAndLimit( $params['unionSupportsOrderAndLimit'] );
+ }
+
+ $sql = trim( $this->database->unionConditionPermutations(
+ $params['table'],
+ $params['vars'],
+ $params['permute_conds'],
+ isset( $params['extra_conds'] ) ? $params['extra_conds'] : '',
+ 'FNAME',
+ isset( $params['options'] ) ? $params['options'] : [],
+ isset( $params['join_conds'] ) ? $params['join_conds'] : []
+ ) );
+ $this->assertEquals( $expect, $sql );
+ }
+
+ public static function provideUnionConditionPermutations() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ [
+ 'table' => [ 'table1', 'table2' ],
+ 'vars' => [ 'field1', 'alias' => 'field2' ],
+ 'permute_conds' => [
+ 'field3' => [ 1, 2, 3 ],
+ 'duplicates' => [ 4, 5, 4 ],
+ 'empty' => [],
+ 'single' => [ 0 ],
+ ],
+ 'extra_conds' => 'table2.bar > 23',
+ 'options' => [
+ 'ORDER BY' => [ 'field1', 'alias' ],
+ 'INNER ORDER BY' => [ 'field1', 'field2' ],
+ 'LIMIT' => 100,
+ ],
+ 'join_conds' => [
+ 'table2' => [ 'JOIN', 'table1.foo_id = table2.foo_id' ],
+ ],
+ ],
+ "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '1' AND duplicates = '4' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " .
+ "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '1' AND duplicates = '5' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " .
+ "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '2' AND duplicates = '4' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " .
+ "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '2' AND duplicates = '5' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " .
+ "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '3' AND duplicates = '4' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " .
+ "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '3' AND duplicates = '5' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) " .
+ "ORDER BY field1,alias LIMIT 100"
+ ],
+ [
+ [
+ 'table' => 'foo',
+ 'vars' => [ 'foo_id' ],
+ 'permute_conds' => [
+ 'bar' => [ 1, 2, 3 ],
+ ],
+ 'extra_conds' => [ 'baz' => null ],
+ 'options' => [
+ 'NOTALL',
+ 'ORDER BY' => [ 'foo_id' ],
+ 'LIMIT' => 25,
+ ],
+ ],
+ "(SELECT foo_id FROM foo WHERE bar = '1' AND baz IS NULL ORDER BY foo_id LIMIT 25 ) UNION " .
+ "(SELECT foo_id FROM foo WHERE bar = '2' AND baz IS NULL ORDER BY foo_id LIMIT 25 ) UNION " .
+ "(SELECT foo_id FROM foo WHERE bar = '3' AND baz IS NULL ORDER BY foo_id LIMIT 25 ) " .
+ "ORDER BY foo_id LIMIT 25"
+ ],
+ [
+ [
+ 'table' => 'foo',
+ 'vars' => [ 'foo_id' ],
+ 'permute_conds' => [
+ 'bar' => [ 1, 2, 3 ],
+ ],
+ 'extra_conds' => [ 'baz' => null ],
+ 'options' => [
+ 'NOTALL' => true,
+ 'ORDER BY' => [ 'foo_id' ],
+ 'LIMIT' => 25,
+ ],
+ 'unionSupportsOrderAndLimit' => false,
+ ],
+ "(SELECT foo_id FROM foo WHERE bar = '1' AND baz IS NULL ) UNION " .
+ "(SELECT foo_id FROM foo WHERE bar = '2' AND baz IS NULL ) UNION " .
+ "(SELECT foo_id FROM foo WHERE bar = '3' AND baz IS NULL ) " .
+ "ORDER BY foo_id LIMIT 25"
+ ],
+ [
+ [
+ 'table' => 'foo',
+ 'vars' => [ 'foo_id' ],
+ 'permute_conds' => [],
+ 'extra_conds' => [ 'baz' => null ],
+ 'options' => [
+ 'ORDER BY' => [ 'foo_id' ],
+ 'LIMIT' => 25,
+ ],
+ ],
+ "SELECT foo_id FROM foo WHERE baz IS NULL ORDER BY foo_id LIMIT 25"
+ ],
+ [
+ [
+ 'table' => 'foo',
+ 'vars' => [ 'foo_id' ],
+ 'permute_conds' => [
+ 'bar' => [],
+ ],
+ 'extra_conds' => [ 'baz' => null ],
+ 'options' => [
+ 'ORDER BY' => [ 'foo_id' ],
+ 'LIMIT' => 25,
+ ],
+ ],
+ "SELECT foo_id FROM foo WHERE baz IS NULL ORDER BY foo_id LIMIT 25"
+ ],
+ [
+ [
+ 'table' => 'foo',
+ 'vars' => [ 'foo_id' ],
+ 'permute_conds' => [
+ 'bar' => [ 1 ],
+ ],
+ 'options' => [
+ 'ORDER BY' => [ 'foo_id' ],
+ 'LIMIT' => 25,
+ 'OFFSET' => 150,
+ ],
+ ],
+ "SELECT foo_id FROM foo WHERE bar = '1' ORDER BY foo_id LIMIT 150,25"
+ ],
+ [
+ [
+ 'table' => 'foo',
+ 'vars' => [ 'foo_id' ],
+ 'permute_conds' => [],
+ 'extra_conds' => [ 'baz' => null ],
+ 'options' => [
+ 'ORDER BY' => [ 'foo_id' ],
+ 'LIMIT' => 25,
+ 'OFFSET' => 150,
+ 'INNER ORDER BY' => [ 'bar_id' ],
+ ],
+ ],
+ "(SELECT foo_id FROM foo WHERE baz IS NULL ORDER BY bar_id LIMIT 175 ) ORDER BY foo_id LIMIT 150,25"
+ ],
+ [
+ [
+ 'table' => 'foo',
+ 'vars' => [ 'foo_id' ],
+ 'permute_conds' => [],
+ 'extra_conds' => [ 'baz' => null ],
+ 'options' => [
+ 'ORDER BY' => [ 'foo_id' ],
+ 'LIMIT' => 25,
+ 'OFFSET' => 150,
+ 'INNER ORDER BY' => [ 'bar_id' ],
+ ],
+ 'unionSupportsOrderAndLimit' => false,
+ ],
+ "SELECT foo_id FROM foo WHERE baz IS NULL ORDER BY foo_id LIMIT 150,25"
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::commit
+ * @covers Wikimedia\Rdbms\Database::doCommit
+ */
+ public function testTransactionCommit() {
+ $this->database->begin( __METHOD__ );
+ $this->database->commit( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; COMMIT' );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::rollback
+ * @covers Wikimedia\Rdbms\Database::doRollback
+ */
+ public function testTransactionRollback() {
+ $this->database->begin( __METHOD__ );
+ $this->database->rollback( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; ROLLBACK' );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::dropTable
+ */
+ public function testDropTable() {
+ $this->database->setExistingTables( [ 'table' ] );
+ $this->database->dropTable( 'table', __METHOD__ );
+ $this->assertLastSql( 'DROP TABLE table CASCADE' );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::dropTable
+ */
+ public function testDropNonExistingTable() {
+ $this->assertFalse(
+ $this->database->dropTable( 'non_existing', __METHOD__ )
+ );
+ }
+
+ /**
+ * @dataProvider provideMakeList
+ * @covers Wikimedia\Rdbms\Database::makeList
+ */
+ public function testMakeList( $list, $mode, $sqlText ) {
+ $this->assertEquals( trim( $this->database->makeList(
+ $list, $mode
+ ) ), $sqlText );
+ }
+
+ public static function provideMakeList() {
+ return [
+ [
+ [ 'value', 'value2' ],
+ LIST_COMMA,
+ "'value','value2'"
+ ],
+ [
+ [ 'field', 'field2' ],
+ LIST_NAMES,
+ "field,field2"
+ ],
+ [
+ [ 'field' => 'value', 'field2' => 'value2' ],
+ LIST_AND,
+ "field = 'value' AND field2 = 'value2'"
+ ],
+ [
+ [ 'field' => null, "field2 != 'value2'" ],
+ LIST_AND,
+ "field IS NULL AND (field2 != 'value2')"
+ ],
+ [
+ [ 'field' => [ 'value', null, 'value2' ], 'field2' => 'value2' ],
+ LIST_AND,
+ "(field IN ('value','value2') OR field IS NULL) AND field2 = 'value2'"
+ ],
+ [
+ [ 'field' => [ null ], 'field2' => null ],
+ LIST_AND,
+ "field IS NULL AND field2 IS NULL"
+ ],
+ [
+ [ 'field' => 'value', 'field2' => 'value2' ],
+ LIST_OR,
+ "field = 'value' OR field2 = 'value2'"
+ ],
+ [
+ [ 'field' => 'value', 'field2' => null ],
+ LIST_OR,
+ "field = 'value' OR field2 IS NULL"
+ ],
+ [
+ [ 'field' => [ 'value', 'value2' ], 'field2' => [ 'value' ] ],
+ LIST_OR,
+ "field IN ('value','value2') OR field2 = 'value'"
+ ],
+ [
+ [ 'field' => [ null, 'value', null, 'value2' ], "field2 != 'value2'" ],
+ LIST_OR,
+ "(field IN ('value','value2') OR field IS NULL) OR (field2 != 'value2')"
+ ],
+ [
+ [ 'field' => 'value', 'field2' => 'value2' ],
+ LIST_SET,
+ "field = 'value',field2 = 'value2'"
+ ],
+ [
+ [ 'field' => 'value', 'field2' => null ],
+ LIST_SET,
+ "field = 'value',field2 = NULL"
+ ],
+ [
+ [ 'field' => 'value', "field2 != 'value2'" ],
+ LIST_SET,
+ "field = 'value',field2 != 'value2'"
+ ],
+ ];
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::registerTempTableOperation
+ */
+ public function testSessionTempTables() {
+ $temp1 = $this->database->tableName( 'tmp_table_1' );
+ $temp2 = $this->database->tableName( 'tmp_table_2' );
+ $temp3 = $this->database->tableName( 'tmp_table_3' );
+
+ $this->database->query( "CREATE TEMPORARY TABLE $temp1 LIKE orig_tbl", __METHOD__ );
+ $this->database->query( "CREATE TEMPORARY TABLE $temp2 LIKE orig_tbl", __METHOD__ );
+ $this->database->query( "CREATE TEMPORARY TABLE $temp3 LIKE orig_tbl", __METHOD__ );
+
+ $this->assertTrue( $this->database->tableExists( "tmp_table_1", __METHOD__ ) );
+ $this->assertTrue( $this->database->tableExists( "tmp_table_2", __METHOD__ ) );
+ $this->assertTrue( $this->database->tableExists( "tmp_table_3", __METHOD__ ) );
+
+ $this->database->dropTable( 'tmp_table_1', __METHOD__ );
+ $this->database->dropTable( 'tmp_table_2', __METHOD__ );
+ $this->database->dropTable( 'tmp_table_3', __METHOD__ );
+
+ $this->assertFalse( $this->database->tableExists( "tmp_table_1", __METHOD__ ) );
+ $this->assertFalse( $this->database->tableExists( "tmp_table_2", __METHOD__ ) );
+ $this->assertFalse( $this->database->tableExists( "tmp_table_3", __METHOD__ ) );
+
+ $this->database->query( "CREATE TEMPORARY TABLE tmp_table_1 LIKE orig_tbl", __METHOD__ );
+ $this->database->query( "CREATE TEMPORARY TABLE 'tmp_table_2' LIKE orig_tbl", __METHOD__ );
+ $this->database->query( "CREATE TEMPORARY TABLE `tmp_table_3` LIKE orig_tbl", __METHOD__ );
+
+ $this->assertTrue( $this->database->tableExists( "tmp_table_1", __METHOD__ ) );
+ $this->assertTrue( $this->database->tableExists( "tmp_table_2", __METHOD__ ) );
+ $this->assertTrue( $this->database->tableExists( "tmp_table_3", __METHOD__ ) );
+
+ $this->database->query( "DROP TEMPORARY TABLE tmp_table_1 LIKE orig_tbl", __METHOD__ );
+ $this->database->query( "DROP TEMPORARY TABLE 'tmp_table_2' LIKE orig_tbl", __METHOD__ );
+ $this->database->query( "DROP TABLE `tmp_table_3` LIKE orig_tbl", __METHOD__ );
+
+ $this->assertFalse( $this->database->tableExists( "tmp_table_1", __METHOD__ ) );
+ $this->assertFalse( $this->database->tableExists( "tmp_table_2", __METHOD__ ) );
+ $this->assertFalse( $this->database->tableExists( "tmp_table_3", __METHOD__ ) );
+ }
+
+ public function provideBuildSubstring() {
+ yield [ 'someField', 1, 2, 'SUBSTRING(someField FROM 1 FOR 2)' ];
+ yield [ 'someField', 1, null, 'SUBSTRING(someField FROM 1)' ];
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::buildSubstring
+ * @dataProvider provideBuildSubstring
+ */
+ public function testBuildSubstring( $input, $start, $length, $expected ) {
+ $output = $this->database->buildSubstring( $input, $start, $length );
+ $this->assertSame( $expected, $output );
+ }
+
+ public function provideBuildSubstring_invalidParams() {
+ yield [ -1, 1 ];
+ yield [ 1, -1 ];
+ yield [ 1, 'foo' ];
+ yield [ 'foo', 1 ];
+ yield [ null, 1 ];
+ yield [ 0, 1 ];
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::buildSubstring
+ * @covers Wikimedia\Rdbms\Database::assertBuildSubstringParams
+ * @dataProvider provideBuildSubstring_invalidParams
+ */
+ public function testBuildSubstring_invalidParams( $start, $length ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ $this->database->buildSubstring( 'foo', $start, $length );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::buildIntegerCast
+ */
+ public function testBuildIntegerCast() {
+ $output = $this->database->buildIntegerCast( 'fieldName' );
+ $this->assertSame( 'CAST( fieldName AS INTEGER )', $output );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::doSavepoint
+ * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint
+ * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint
+ * @covers \Wikimedia\Rdbms\Database::startAtomic
+ * @covers \Wikimedia\Rdbms\Database::endAtomic
+ * @covers \Wikimedia\Rdbms\Database::cancelAtomic
+ * @covers \Wikimedia\Rdbms\Database::doAtomicSection
+ */
+ public function testAtomicSections() {
+ $this->database->startAtomic( __METHOD__ );
+ $this->database->endAtomic( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; COMMIT' );
+
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; ROLLBACK' );
+
+ $this->database->begin( __METHOD__ );
+ $this->database->startAtomic( __METHOD__ );
+ $this->database->endAtomic( __METHOD__ );
+ $this->database->commit( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; COMMIT' );
+
+ $this->database->begin( __METHOD__ );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->endAtomic( __METHOD__ );
+ $this->database->commit( __METHOD__ );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' );
+
+ $this->database->begin( __METHOD__ );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->commit( __METHOD__ );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' );
+
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->endAtomic( __METHOD__ );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' );
+
+ $noOpCallack = function () {
+ };
+
+ $this->database->doAtomicSection( __METHOD__, $noOpCallack, IDatabase::ATOMIC_CANCELABLE );
+ $this->assertLastSql( 'BEGIN; COMMIT' );
+
+ $this->database->doAtomicSection( __METHOD__, $noOpCallack );
+ $this->assertLastSql( 'BEGIN; COMMIT' );
+
+ $this->database->begin( __METHOD__ );
+ $this->database->doAtomicSection( __METHOD__, $noOpCallack, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->rollback( __METHOD__ );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK' );
+
+ $fname = __METHOD__;
+ $triggerMap = [
+ '-' => '-',
+ IDatabase::TRIGGER_COMMIT => 'tCommit',
+ IDatabase::TRIGGER_ROLLBACK => 'tRollback'
+ ];
+ $callback1 = function ( $trigger = '-' ) use ( $fname, $triggerMap ) {
+ $this->database->query( "SELECT 1, {$triggerMap[$trigger]} AS t", $fname );
+ };
+ $callback2 = function ( $trigger = '-' ) use ( $fname, $triggerMap ) {
+ $this->database->query( "SELECT 2, {$triggerMap[$trigger]} AS t", $fname );
+ };
+ $callback3 = function ( $trigger = '-' ) use ( $fname, $triggerMap ) {
+ $this->database->query( "SELECT 3, {$triggerMap[$trigger]} AS t", $fname );
+ };
+
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionPreCommitOrIdle( $callback1, __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; ROLLBACK' );
+
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionIdle( $callback1, __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; ROLLBACK' );
+
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionResolution( $callback1, __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; ROLLBACK; SELECT 1, tRollback AS t' );
+
+ $this->database->startAtomic( __METHOD__ . '_outer' );
+ $this->database->onTransactionPreCommitOrIdle( $callback1, __METHOD__ );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->onTransactionPreCommitOrIdle( $callback3, __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_outer' );
+ $this->assertLastSql( implode( "; ", [
+ 'BEGIN',
+ 'SAVEPOINT wikimedia_rdbms_atomic1',
+ 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1',
+ 'SELECT 1, - AS t',
+ 'SELECT 3, - AS t',
+ 'COMMIT'
+ ] ) );
+
+ $this->database->startAtomic( __METHOD__ . '_outer' );
+ $this->database->onTransactionIdle( $callback1, __METHOD__ );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionIdle( $callback2, __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->onTransactionIdle( $callback3, __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_outer' );
+ $this->assertLastSql( implode( "; ", [
+ 'BEGIN',
+ 'SAVEPOINT wikimedia_rdbms_atomic1',
+ 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1',
+ 'COMMIT',
+ 'SELECT 1, tCommit AS t',
+ 'SELECT 3, tCommit AS t'
+ ] ) );
+
+ $this->database->startAtomic( __METHOD__ . '_outer' );
+ $this->database->onTransactionResolution( $callback1, __METHOD__ );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionResolution( $callback2, __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->onTransactionResolution( $callback3, __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_outer' );
+ $this->assertLastSql( implode( "; ", [
+ 'BEGIN',
+ 'SAVEPOINT wikimedia_rdbms_atomic1',
+ 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1',
+ 'COMMIT',
+ 'SELECT 1, tCommit AS t',
+ 'SELECT 2, tRollback AS t',
+ 'SELECT 3, tCommit AS t'
+ ] ) );
+
+ $makeCallback = function ( $id ) use ( $fname, $triggerMap ) {
+ return function ( $trigger = '-' ) use ( $id, $fname, $triggerMap ) {
+ $this->database->query( "SELECT $id, {$triggerMap[$trigger]} AS t", $fname );
+ };
+ };
+
+ $this->database->startAtomic( __METHOD__ . '_outer' );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionResolution( $makeCallback( 1 ), __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_outer' );
+ $this->assertLastSql( implode( "; ", [
+ 'BEGIN',
+ 'SAVEPOINT wikimedia_rdbms_atomic1',
+ 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1',
+ 'COMMIT',
+ 'SELECT 1, tRollback AS t'
+ ] ) );
+
+ $this->database->startAtomic( __METHOD__ . '_level1', IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionResolution( $makeCallback( 1 ), __METHOD__ );
+ $this->database->startAtomic( __METHOD__ . '_level2' );
+ $this->database->startAtomic( __METHOD__ . '_level3', IDatabase::ATOMIC_CANCELABLE );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionResolution( $makeCallback( 2 ), __METHOD__ );
+ $this->database->endAtomic( __METHOD__ );
+ $this->database->onTransactionResolution( $makeCallback( 3 ), __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__ . '_level3' );
+ $this->database->endAtomic( __METHOD__ . '_level2' );
+ $this->database->onTransactionResolution( $makeCallback( 4 ), __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_level1' );
+ $this->assertLastSql( implode( "; ", [
+ 'BEGIN',
+ 'SAVEPOINT wikimedia_rdbms_atomic1',
+ 'SAVEPOINT wikimedia_rdbms_atomic2',
+ 'RELEASE SAVEPOINT wikimedia_rdbms_atomic2',
+ 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1',
+ 'COMMIT; SELECT 1, tCommit AS t',
+ 'SELECT 2, tRollback AS t',
+ 'SELECT 3, tRollback AS t',
+ 'SELECT 4, tCommit AS t'
+ ] ) );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::doSavepoint
+ * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint
+ * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint
+ * @covers \Wikimedia\Rdbms\Database::startAtomic
+ * @covers \Wikimedia\Rdbms\Database::endAtomic
+ * @covers \Wikimedia\Rdbms\Database::cancelAtomic
+ * @covers \Wikimedia\Rdbms\Database::doAtomicSection
+ */
+ public function testAtomicSectionsRecovery() {
+ $this->database->begin( __METHOD__ );
+ try {
+ $this->database->doAtomicSection(
+ __METHOD__,
+ function () {
+ $this->database->startAtomic( 'inner_func1' );
+ $this->database->startAtomic( 'inner_func2' );
+
+ throw new RuntimeException( 'Test exception' );
+ },
+ IDatabase::ATOMIC_CANCELABLE
+ );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame( 'Test exception', $ex->getMessage() );
+ }
+ $this->database->commit( __METHOD__ );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' );
+
+ $this->database->begin( __METHOD__ );
+ try {
+ $this->database->doAtomicSection(
+ __METHOD__,
+ function () {
+ throw new RuntimeException( 'Test exception' );
+ }
+ );
+ $this->fail( 'Test exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame( 'Test exception', $ex->getMessage() );
+ }
+ try {
+ $this->database->commit( __METHOD__ );
+ $this->fail( 'Test exception not thrown' );
+ } catch ( DBTransactionError $ex ) {
+ $this->assertSame(
+ 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.',
+ $ex->getMessage()
+ );
+ }
+ $this->database->rollback( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; ROLLBACK' );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::doSavepoint
+ * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint
+ * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint
+ * @covers \Wikimedia\Rdbms\Database::startAtomic
+ * @covers \Wikimedia\Rdbms\Database::endAtomic
+ * @covers \Wikimedia\Rdbms\Database::cancelAtomic
+ * @covers \Wikimedia\Rdbms\Database::doAtomicSection
+ */
+ public function testAtomicSectionsCallbackCancellation() {
+ $fname = __METHOD__;
+ $callback1Called = null;
+ $callback1 = function ( $trigger = '-' ) use ( $fname, &$callback1Called ) {
+ $callback1Called = $trigger;
+ $this->database->query( "SELECT 1", $fname );
+ };
+ $callback2Called = null;
+ $callback2 = function ( $trigger = '-' ) use ( $fname, &$callback2Called ) {
+ $callback2Called = $trigger;
+ $this->database->query( "SELECT 2", $fname );
+ };
+ $callback3Called = null;
+ $callback3 = function ( $trigger = '-' ) use ( $fname, &$callback3Called ) {
+ $callback3Called = $trigger;
+ $this->database->query( "SELECT 3", $fname );
+ };
+
+ $this->database->startAtomic( __METHOD__ . '_outer' );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->startAtomic( __METHOD__ . '_inner' );
+ $this->database->onTransactionIdle( $callback1, __METHOD__ );
+ $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
+ $this->database->onTransactionResolution( $callback3, __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_inner' );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_outer' );
+ $this->assertNull( $callback1Called );
+ $this->assertNull( $callback2Called );
+ $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' );
+
+ $callback1Called = null;
+ $callback2Called = null;
+ $callback3Called = null;
+ $this->database->startAtomic( __METHOD__ . '_outer' );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionIdle( $callback1, __METHOD__ );
+ $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
+ $this->database->onTransactionResolution( $callback3, __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_inner' );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_outer' );
+ $this->assertNull( $callback1Called );
+ $this->assertNull( $callback2Called );
+ $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' );
+
+ $callback1Called = null;
+ $callback2Called = null;
+ $callback3Called = null;
+ $this->database->startAtomic( __METHOD__ . '_outer' );
+ $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->startAtomic( __METHOD__ . '_inner' );
+ $this->database->onTransactionIdle( $callback1, __METHOD__ );
+ $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
+ $this->database->onTransactionResolution( $callback3, __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__, $atomicId );
+ $this->database->endAtomic( __METHOD__ . '_outer' );
+ $this->assertNull( $callback1Called );
+ $this->assertNull( $callback2Called );
+ $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called );
+
+ $callback1Called = null;
+ $callback2Called = null;
+ $callback3Called = null;
+ $this->database->startAtomic( __METHOD__ . '_outer' );
+ $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->startAtomic( __METHOD__ . '_inner' );
+ $this->database->onTransactionIdle( $callback1, __METHOD__ );
+ $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
+ $this->database->onTransactionResolution( $callback3, __METHOD__ );
+ try {
+ $this->database->cancelAtomic( __METHOD__ . '_X', $atomicId );
+ } catch ( DBUnexpectedError $e ) {
+ $m = __METHOD__;
+ $this->assertSame(
+ "Invalid atomic section ended (got {$m}_X but expected {$m}).",
+ $e->getMessage()
+ );
+ }
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_outer' );
+ $this->assertNull( $callback1Called );
+ $this->assertNull( $callback2Called );
+ $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called );
+
+ $this->database->startAtomic( __METHOD__ . '_outer' );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->startAtomic( __METHOD__ . '_inner' );
+ $this->database->onTransactionIdle( $callback1, __METHOD__ );
+ $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
+ $this->database->onTransactionResolution( $callback3, __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__ . '_inner' );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_outer' );
+ $this->assertNull( $callback1Called );
+ $this->assertNull( $callback2Called );
+ $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called );
+
+ $wrapper = TestingAccessWrapper::newFromObject( $this->database );
+ $callback1Called = null;
+ $callback2Called = null;
+ $callback3Called = null;
+ $this->database->startAtomic( __METHOD__ . '_outer' );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->startAtomic( __METHOD__ . '_inner' );
+ $this->database->onTransactionIdle( $callback1, __METHOD__ );
+ $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
+ $this->database->onTransactionResolution( $callback3, __METHOD__ );
+ $wrapper->trxStatus = Database::STATUS_TRX_ERROR;
+ $this->database->cancelAtomic( __METHOD__ . '_inner' );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_outer' );
+ $this->assertNull( $callback1Called );
+ $this->assertNull( $callback2Called );
+ $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::doSavepoint
+ * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint
+ * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint
+ * @covers \Wikimedia\Rdbms\Database::startAtomic
+ * @covers \Wikimedia\Rdbms\Database::endAtomic
+ * @covers \Wikimedia\Rdbms\Database::cancelAtomic
+ * @covers \Wikimedia\Rdbms\Database::doAtomicSection
+ */
+ public function testAtomicSectionsTrxRound() {
+ $this->database->setFlag( IDatabase::DBO_TRX );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->query( 'SELECT 1', __METHOD__ );
+ $this->database->endAtomic( __METHOD__ );
+ $this->database->commit( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SELECT 1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' );
+ }
+
+ public static function provideAtomicSectionMethodsForErrors() {
+ return [
+ [ 'endAtomic' ],
+ [ 'cancelAtomic' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideAtomicSectionMethodsForErrors
+ * @covers \Wikimedia\Rdbms\Database::endAtomic
+ * @covers \Wikimedia\Rdbms\Database::cancelAtomic
+ */
+ public function testNoAtomicSection( $method ) {
+ try {
+ $this->database->$method( __METHOD__ );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBUnexpectedError $ex ) {
+ $this->assertSame(
+ 'No atomic section is open (got ' . __METHOD__ . ').',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ /**
+ * @dataProvider provideAtomicSectionMethodsForErrors
+ * @covers \Wikimedia\Rdbms\Database::endAtomic
+ * @covers \Wikimedia\Rdbms\Database::cancelAtomic
+ */
+ public function testInvalidAtomicSectionEnded( $method ) {
+ $this->database->startAtomic( __METHOD__ . 'X' );
+ try {
+ $this->database->$method( __METHOD__ );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBUnexpectedError $ex ) {
+ $this->assertSame(
+ 'Invalid atomic section ended (got ' . __METHOD__ . ' but expected ' .
+ __METHOD__ . 'X' . ').',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::cancelAtomic
+ */
+ public function testUncancellableAtomicSection() {
+ $this->database->startAtomic( __METHOD__ );
+ try {
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->select( 'test', '1', [], __METHOD__ );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBTransactionError $ex ) {
+ $this->assertSame(
+ 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ /**
+ * @expectedException \Wikimedia\Rdbms\DBTransactionStateError
+ */
+ public function testTransactionErrorState1() {
+ $wrapper = TestingAccessWrapper::newFromObject( $this->database );
+
+ $this->database->begin( __METHOD__ );
+ $wrapper->trxStatus = Database::STATUS_TRX_ERROR;
+ $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ );
+ $this->database->commit( __METHOD__ );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::query
+ */
+ public function testTransactionErrorState2() {
+ $wrapper = TestingAccessWrapper::newFromObject( $this->database );
+
+ $this->database->startAtomic( __METHOD__ );
+ $wrapper->trxStatus = Database::STATUS_TRX_ERROR;
+ $this->database->rollback( __METHOD__ );
+ $this->assertEquals( 0, $this->database->trxLevel() );
+ $this->assertEquals( Database::STATUS_TRX_NONE, $wrapper->trxStatus() );
+ $this->assertLastSql( 'BEGIN; ROLLBACK' );
+
+ $this->database->startAtomic( __METHOD__ );
+ $this->assertEquals( Database::STATUS_TRX_OK, $wrapper->trxStatus() );
+ $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ );
+ $this->database->endAtomic( __METHOD__ );
+ $this->assertEquals( Database::STATUS_TRX_NONE, $wrapper->trxStatus() );
+ $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'1\'; COMMIT' );
+ $this->assertEquals( 0, $this->database->trxLevel(), 'Use after rollback()' );
+
+ $this->database->begin( __METHOD__ );
+ $this->database->startAtomic( __METHOD__, Database::ATOMIC_CANCELABLE );
+ $this->database->update( 'y', [ 'a' => 1 ], [ 'field' => 1 ], __METHOD__ );
+ $wrapper->trxStatus = Database::STATUS_TRX_ERROR;
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->assertEquals( Database::STATUS_TRX_OK, $wrapper->trxStatus() );
+ $this->database->startAtomic( __METHOD__ );
+ $this->database->delete( 'y', [ 'field' => 1 ], __METHOD__ );
+ $this->database->endAtomic( __METHOD__ );
+ $this->database->commit( __METHOD__ );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; UPDATE y SET a = \'1\' WHERE field = \'1\'; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; DELETE FROM y WHERE field = \'1\'; COMMIT' );
+ $this->assertEquals( 0, $this->database->trxLevel(), 'Use after rollback()' );
+
+ // Next transaction
+ $this->database->startAtomic( __METHOD__ );
+ $this->assertEquals( Database::STATUS_TRX_OK, $wrapper->trxStatus() );
+ $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ );
+ $this->database->endAtomic( __METHOD__ );
+ $this->assertEquals( Database::STATUS_TRX_NONE, $wrapper->trxStatus() );
+ $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; COMMIT' );
+ $this->assertEquals( 0, $this->database->trxLevel() );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::query
+ */
+ public function testImplicitTransactionRollback() {
+ $doError = function () {
+ $this->database->forceNextQueryError( 666, 'Evilness' );
+ try {
+ $this->database->delete( 'error', '1', __CLASS__ . '::SomeCaller' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBError $e ) {
+ $this->assertSame( 666, $e->errno );
+ }
+ };
+
+ $this->database->setFlag( Database::DBO_TRX );
+
+ // Implicit transaction gets silently rolled back
+ $this->database->begin( __METHOD__, Database::TRANSACTION_INTERNAL );
+ call_user_func( $doError );
+ $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ );
+ $this->database->commit( __METHOD__, Database::FLUSHING_INTERNAL );
+ // phpcs:ignore
+ $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; ROLLBACK; BEGIN; DELETE FROM x WHERE field = \'1\'; COMMIT' );
+
+ // ... unless there were prior writes
+ $this->database->begin( __METHOD__, Database::TRANSACTION_INTERNAL );
+ $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ );
+ call_user_func( $doError );
+ try {
+ $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBTransactionStateError $e ) {
+ }
+ $this->database->rollback( __METHOD__, Database::FLUSHING_INTERNAL );
+ // phpcs:ignore
+ $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'1\'; DELETE FROM error WHERE 1; ROLLBACK' );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::query
+ */
+ public function testTransactionStatementRollbackIgnoring() {
+ $wrapper = TestingAccessWrapper::newFromObject( $this->database );
+ $warning = [];
+ $wrapper->deprecationLogger = function ( $msg ) use ( &$warning ) {
+ $warning[] = $msg;
+ };
+
+ $doError = function () {
+ $this->database->forceNextQueryError( 666, 'Evilness', [
+ 'wasKnownStatementRollbackError' => true,
+ ] );
+ try {
+ $this->database->delete( 'error', '1', __CLASS__ . '::SomeCaller' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBError $e ) {
+ $this->assertSame( 666, $e->errno );
+ }
+ };
+ $expectWarning = 'Caller from ' . __METHOD__ .
+ ' ignored an error originally raised from ' . __CLASS__ . '::SomeCaller: [666] Evilness';
+
+ // Rollback doesn't raise a warning
+ $warning = [];
+ $this->database->startAtomic( __METHOD__ );
+ call_user_func( $doError );
+ $this->database->rollback( __METHOD__ );
+ $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ );
+ $this->assertSame( [], $warning );
+ // phpcs:ignore
+ $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; ROLLBACK; DELETE FROM x WHERE field = \'1\'' );
+
+ // cancelAtomic() doesn't raise a warning
+ $warning = [];
+ $this->database->begin( __METHOD__ );
+ $this->database->startAtomic( __METHOD__, Database::ATOMIC_CANCELABLE );
+ call_user_func( $doError );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ );
+ $this->database->commit( __METHOD__ );
+ $this->assertSame( [], $warning );
+ // phpcs:ignore
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; DELETE FROM error WHERE 1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; DELETE FROM x WHERE field = \'1\'; COMMIT' );
+
+ // Commit does raise a warning
+ $warning = [];
+ $this->database->begin( __METHOD__ );
+ call_user_func( $doError );
+ $this->database->commit( __METHOD__ );
+ $this->assertSame( [ $expectWarning ], $warning );
+ $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; COMMIT' );
+
+ // Deprecation only gets raised once
+ $warning = [];
+ $this->database->begin( __METHOD__ );
+ call_user_func( $doError );
+ $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ );
+ $this->database->commit( __METHOD__ );
+ $this->assertSame( [ $expectWarning ], $warning );
+ // phpcs:ignore
+ $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; DELETE FROM x WHERE field = \'1\'; COMMIT' );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::close
+ */
+ public function testPrematureClose1() {
+ $fname = __METHOD__;
+ $this->database->begin( __METHOD__ );
+ $this->database->onTransactionIdle( function () use ( $fname ) {
+ $this->database->query( 'SELECT 1', $fname );
+ } );
+ $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ );
+ $this->database->close();
+
+ $this->assertFalse( $this->database->isOpen() );
+ $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; COMMIT; SELECT 1' );
+ $this->assertEquals( 0, $this->database->trxLevel() );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::close
+ */
+ public function testPrematureClose2() {
+ try {
+ $fname = __METHOD__;
+ $this->database->startAtomic( __METHOD__ );
+ $this->database->onTransactionIdle( function () use ( $fname ) {
+ $this->database->query( 'SELECT 1', $fname );
+ } );
+ $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ );
+ $this->database->close();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBUnexpectedError $ex ) {
+ $this->assertSame(
+ 'Wikimedia\Rdbms\Database::close: atomic sections ' .
+ 'DatabaseSQLTest::testPrematureClose2 are still open.',
+ $ex->getMessage()
+ );
+ }
+
+ $this->assertFalse( $this->database->isOpen() );
+ $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' );
+ $this->assertEquals( 0, $this->database->trxLevel() );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::close
+ */
+ public function testPrematureClose3() {
+ try {
+ $this->database->setFlag( IDatabase::DBO_TRX );
+ $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ );
+ $this->assertEquals( 1, $this->database->trxLevel() );
+ $this->database->close();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBUnexpectedError $ex ) {
+ $this->assertSame(
+ 'Wikimedia\Rdbms\Database::close: ' .
+ 'mass commit/rollback of peer transaction required (DBO_TRX set).',
+ $ex->getMessage()
+ );
+ }
+
+ $this->assertFalse( $this->database->isOpen() );
+ $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' );
+ $this->assertEquals( 0, $this->database->trxLevel() );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::close
+ */
+ public function testPrematureClose4() {
+ $this->database->setFlag( IDatabase::DBO_TRX );
+ $this->database->query( 'SELECT 1', __METHOD__ );
+ $this->assertEquals( 1, $this->database->trxLevel() );
+ $this->database->close();
+ $this->database->clearFlag( IDatabase::DBO_TRX );
+
+ $this->assertFalse( $this->database->isOpen() );
+ $this->assertLastSql( 'BEGIN; SELECT 1; COMMIT' );
+ $this->assertEquals( 0, $this->database->trxLevel() );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::selectFieldValues()
+ */
+ public function testSelectFieldValues() {
+ $this->database->forceNextResult( [
+ (object)[ 'value' => 'row1' ],
+ (object)[ 'value' => 'row2' ],
+ (object)[ 'value' => 'row3' ],
+ ] );
+
+ $this->assertSame(
+ [ 'row1', 'row2', 'row3' ],
+ $this->database->selectFieldValues( 'table', 'table.field', 'conds', __METHOD__ )
+ );
+ $this->assertLastSql( 'SELECT table.field AS value FROM table WHERE conds' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php
new file mode 100644
index 00000000..a886d6bf
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php
@@ -0,0 +1,60 @@
+<?php
+
+use Wikimedia\Rdbms\DatabaseSqlite;
+
+/**
+ * DatabaseSqliteTest is already defined in mediawiki core hence the 'Rdbms' included in this
+ * class name.
+ * The test in core should have mediawiki specific stuff removed and the tests moved to this
+ * rdbms libs test.
+ */
+class DatabaseSqliteRdbmsTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|DatabaseSqlite
+ */
+ private function getMockDb() {
+ return $this->getMockBuilder( DatabaseSqlite::class )
+ ->disableOriginalConstructor()
+ ->setMethods( null )
+ ->getMock();
+ }
+
+ public function provideBuildSubstring() {
+ yield [ 'someField', 1, 2, 'SUBSTR(someField,1,2)' ];
+ yield [ 'someField', 1, null, 'SUBSTR(someField,1)' ];
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\DatabaseSqlite::buildSubstring
+ * @dataProvider provideBuildSubstring
+ */
+ public function testBuildSubstring( $input, $start, $length, $expected ) {
+ $dbMock = $this->getMockDb();
+ $output = $dbMock->buildSubstring( $input, $start, $length );
+ $this->assertSame( $expected, $output );
+ }
+
+ public function provideBuildSubstring_invalidParams() {
+ yield [ -1, 1 ];
+ yield [ 1, -1 ];
+ yield [ 1, 'foo' ];
+ yield [ 'foo', 1 ];
+ yield [ null, 1 ];
+ yield [ 0, 1 ];
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\DatabaseSqlite::buildSubstring
+ * @dataProvider provideBuildSubstring_invalidParams
+ */
+ public function testBuildSubstring_invalidParams( $start, $length ) {
+ $dbMock = $this->getMockDb();
+ $this->setExpectedException( InvalidArgumentException::class );
+ $dbMock->buildSubstring( 'foo', $start, $length );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php
new file mode 100644
index 00000000..444a946e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php
@@ -0,0 +1,613 @@
+<?php
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DatabaseMysqli;
+use Wikimedia\Rdbms\LBFactorySingle;
+use Wikimedia\Rdbms\TransactionProfiler;
+use Wikimedia\TestingAccessWrapper;
+use Wikimedia\Rdbms\DatabaseSqlite;
+use Wikimedia\Rdbms\DatabasePostgres;
+use Wikimedia\Rdbms\DatabaseMssql;
+
+class DatabaseTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected function setUp() {
+ $this->db = new DatabaseTestHelper( __CLASS__ . '::' . $this->getName() );
+ }
+
+ /**
+ * @dataProvider provideAddQuotes
+ * @covers Wikimedia\Rdbms\Database::factory
+ */
+ public function testFactory() {
+ $m = Database::NEW_UNCONNECTED; // no-connect mode
+ $p = [ 'host' => 'localhost', 'user' => 'me', 'password' => 'myself', 'dbname' => 'i' ];
+
+ $this->assertInstanceOf( DatabaseMysqli::class, Database::factory( 'mysqli', $p, $m ) );
+ $this->assertInstanceOf( DatabaseMysqli::class, Database::factory( 'MySqli', $p, $m ) );
+ $this->assertInstanceOf( DatabaseMysqli::class, Database::factory( 'MySQLi', $p, $m ) );
+ $this->assertInstanceOf( DatabasePostgres::class, Database::factory( 'postgres', $p, $m ) );
+ $this->assertInstanceOf( DatabasePostgres::class, Database::factory( 'Postgres', $p, $m ) );
+
+ $x = $p + [ 'port' => 10000, 'UseWindowsAuth' => false ];
+ $this->assertInstanceOf( DatabaseMssql::class, Database::factory( 'mssql', $x, $m ) );
+
+ $x = $p + [ 'dbFilePath' => 'some/file.sqlite' ];
+ $this->assertInstanceOf( DatabaseSqlite::class, Database::factory( 'sqlite', $x, $m ) );
+ $x = $p + [ 'dbDirectory' => 'some/file' ];
+ $this->assertInstanceOf( DatabaseSqlite::class, Database::factory( 'sqlite', $x, $m ) );
+ }
+
+ public static function provideAddQuotes() {
+ return [
+ [ null, 'NULL' ],
+ [ 1234, "'1234'" ],
+ [ 1234.5678, "'1234.5678'" ],
+ [ 'string', "'string'" ],
+ [ 'string\'s cause trouble', "'string\'s cause trouble'" ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideAddQuotes
+ * @covers Wikimedia\Rdbms\Database::addQuotes
+ */
+ public function testAddQuotes( $input, $expected ) {
+ $this->assertEquals( $expected, $this->db->addQuotes( $input ) );
+ }
+
+ public static function provideTableName() {
+ // Formatting is mostly ignored since addIdentifierQuotes is abstract.
+ // For testing of addIdentifierQuotes, see actual Database subclas tests.
+ return [
+ 'local' => [
+ 'tablename',
+ 'tablename',
+ 'quoted',
+ ],
+ 'local-raw' => [
+ 'tablename',
+ 'tablename',
+ 'raw',
+ ],
+ 'shared' => [
+ 'sharedb.tablename',
+ 'tablename',
+ 'quoted',
+ [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => '' ],
+ ],
+ 'shared-raw' => [
+ 'sharedb.tablename',
+ 'tablename',
+ 'raw',
+ [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => '' ],
+ ],
+ 'shared-prefix' => [
+ 'sharedb.sh_tablename',
+ 'tablename',
+ 'quoted',
+ [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => 'sh_' ],
+ ],
+ 'shared-prefix-raw' => [
+ 'sharedb.sh_tablename',
+ 'tablename',
+ 'raw',
+ [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => 'sh_' ],
+ ],
+ 'foreign' => [
+ 'databasename.tablename',
+ 'databasename.tablename',
+ 'quoted',
+ ],
+ 'foreign-raw' => [
+ 'databasename.tablename',
+ 'databasename.tablename',
+ 'raw',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideTableName
+ * @covers Wikimedia\Rdbms\Database::tableName
+ */
+ public function testTableName( $expected, $table, $format, array $alias = null ) {
+ if ( $alias ) {
+ $this->db->setTableAliases( [ $table => $alias ] );
+ }
+ $this->assertEquals(
+ $expected,
+ $this->db->tableName( $table, $format ?: 'quoted' )
+ );
+ }
+
+ public function provideTableNamesWithIndexClauseOrJOIN() {
+ return [
+ 'one-element array' => [
+ [ 'table' ], [], 'table '
+ ],
+ 'comma join' => [
+ [ 'table1', 'table2' ], [], 'table1,table2 '
+ ],
+ 'real join' => [
+ [ 'table1', 'table2' ],
+ [ 'table2' => [ 'LEFT JOIN', 't1_id = t2_id' ] ],
+ 'table1 LEFT JOIN table2 ON ((t1_id = t2_id))'
+ ],
+ 'real join with multiple conditionals' => [
+ [ 'table1', 'table2' ],
+ [ 'table2' => [ 'LEFT JOIN', [ 't1_id = t2_id', 't2_x = \'X\'' ] ] ],
+ 'table1 LEFT JOIN table2 ON ((t1_id = t2_id) AND (t2_x = \'X\'))'
+ ],
+ 'join with parenthesized group' => [
+ [ 'table1', 'n' => [ 'table2', 'table3' ] ],
+ [
+ 'table3' => [ 'JOIN', 't2_id = t3_id' ],
+ 'n' => [ 'LEFT JOIN', 't1_id = t2_id' ],
+ ],
+ 'table1 LEFT JOIN (table2 JOIN table3 ON ((t2_id = t3_id))) ON ((t1_id = t2_id))'
+ ],
+ 'join with degenerate parenthesized group' => [
+ [ 'table1', 'n' => [ 't2' => 'table2' ] ],
+ [
+ 'n' => [ 'LEFT JOIN', 't1_id = t2_id' ],
+ ],
+ 'table1 LEFT JOIN table2 t2 ON ((t1_id = t2_id))'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideTableNamesWithIndexClauseOrJOIN
+ * @covers Wikimedia\Rdbms\Database::tableNamesWithIndexClauseOrJOIN
+ */
+ public function testTableNamesWithIndexClauseOrJOIN( $tables, $join_conds, $expect ) {
+ $clause = TestingAccessWrapper::newFromObject( $this->db )
+ ->tableNamesWithIndexClauseOrJOIN( $tables, [], [], $join_conds );
+ $this->assertSame( $expect, $clause );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::onTransactionIdle
+ * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks
+ */
+ public function testTransactionIdle() {
+ $db = $this->db;
+
+ $db->clearFlag( DBO_TRX );
+ $called = false;
+ $flagSet = null;
+ $callback = function () use ( $db, &$flagSet, &$called ) {
+ $called = true;
+ $flagSet = $db->getFlag( DBO_TRX );
+ };
+
+ $db->onTransactionIdle( $callback, __METHOD__ );
+ $this->assertTrue( $called, 'Callback reached' );
+ $this->assertFalse( $flagSet, 'DBO_TRX off in callback' );
+ $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX still default' );
+
+ $flagSet = null;
+ $called = false;
+ $db->startAtomic( __METHOD__ );
+ $db->onTransactionIdle( $callback, __METHOD__ );
+ $this->assertFalse( $called, 'Callback not reached during TRX' );
+ $db->endAtomic( __METHOD__ );
+
+ $this->assertTrue( $called, 'Callback reached after COMMIT' );
+ $this->assertFalse( $flagSet, 'DBO_TRX off in callback' );
+ $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' );
+
+ $db->clearFlag( DBO_TRX );
+ $db->onTransactionIdle(
+ function () use ( $db ) {
+ $db->setFlag( DBO_TRX );
+ },
+ __METHOD__
+ );
+ $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::onTransactionIdle
+ * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks
+ */
+ public function testTransactionIdle_TRX() {
+ $db = $this->getMockDB( [ 'isOpen', 'ping' ] );
+ $db->method( 'isOpen' )->willReturn( true );
+ $db->method( 'ping' )->willReturn( true );
+ $db->setFlag( DBO_TRX );
+
+ $lbFactory = LBFactorySingle::newFromConnection( $db );
+ // Ask for the connection so that LB sets internal state
+ // about this connection being the master connection
+ $lb = $lbFactory->getMainLB();
+ $conn = $lb->openConnection( $lb->getWriterIndex() );
+ $this->assertSame( $db, $conn, 'Same DB instance' );
+ $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX is set' );
+
+ $called = false;
+ $flagSet = null;
+ $callback = function () use ( $db, &$flagSet, &$called ) {
+ $called = true;
+ $flagSet = $db->getFlag( DBO_TRX );
+ };
+
+ $db->onTransactionIdle( $callback, __METHOD__ );
+ $this->assertTrue( $called, 'Called when idle if DBO_TRX is set' );
+ $this->assertFalse( $flagSet, 'DBO_TRX off in callback' );
+ $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX still default' );
+
+ $called = false;
+ $lbFactory->beginMasterChanges( __METHOD__ );
+ $db->onTransactionIdle( $callback, __METHOD__ );
+ $this->assertFalse( $called, 'Not called when lb-transaction is active' );
+
+ $lbFactory->commitMasterChanges( __METHOD__ );
+ $this->assertTrue( $called, 'Called when lb-transaction is committed' );
+
+ $called = false;
+ $lbFactory->beginMasterChanges( __METHOD__ );
+ $db->onTransactionIdle( $callback, __METHOD__ );
+ $this->assertFalse( $called, 'Not called when lb-transaction is active' );
+
+ $lbFactory->rollbackMasterChanges( __METHOD__ );
+ $this->assertFalse( $called, 'Not called when lb-transaction is rolled back' );
+
+ $lbFactory->commitMasterChanges( __METHOD__ );
+ $this->assertFalse( $called, 'Not called in next round commit' );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::onTransactionPreCommitOrIdle
+ * @covers Wikimedia\Rdbms\Database::runOnTransactionPreCommitCallbacks
+ */
+ public function testTransactionPreCommitOrIdle() {
+ $db = $this->getMockDB( [ 'isOpen' ] );
+ $db->method( 'isOpen' )->willReturn( true );
+ $db->clearFlag( DBO_TRX );
+
+ $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX is not set' );
+
+ $called = false;
+ $db->onTransactionPreCommitOrIdle(
+ function () use ( &$called ) {
+ $called = true;
+ },
+ __METHOD__
+ );
+ $this->assertTrue( $called, 'Called when idle' );
+
+ $db->begin( __METHOD__ );
+ $called = false;
+ $db->onTransactionPreCommitOrIdle(
+ function () use ( &$called ) {
+ $called = true;
+ },
+ __METHOD__
+ );
+ $this->assertFalse( $called, 'Not called when transaction is active' );
+ $db->commit( __METHOD__ );
+ $this->assertTrue( $called, 'Called when transaction is committed' );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::onTransactionPreCommitOrIdle
+ * @covers Wikimedia\Rdbms\Database::runOnTransactionPreCommitCallbacks
+ */
+ public function testTransactionPreCommitOrIdle_TRX() {
+ $db = $this->getMockDB( [ 'isOpen', 'ping' ] );
+ $db->method( 'isOpen' )->willReturn( true );
+ $db->method( 'ping' )->willReturn( true );
+ $db->setFlag( DBO_TRX );
+
+ $lbFactory = LBFactorySingle::newFromConnection( $db );
+ // Ask for the connection so that LB sets internal state
+ // about this connection being the master connection
+ $lb = $lbFactory->getMainLB();
+ $conn = $lb->openConnection( $lb->getWriterIndex() );
+ $this->assertSame( $db, $conn, 'Same DB instance' );
+
+ $this->assertFalse( $lb->hasMasterChanges() );
+ $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX is set' );
+ $called = false;
+ $callback = function () use ( &$called ) {
+ $called = true;
+ };
+ $db->onTransactionPreCommitOrIdle( $callback, __METHOD__ );
+ $this->assertTrue( $called, 'Called when idle if DBO_TRX is set' );
+ $called = false;
+ $lbFactory->commitMasterChanges();
+ $this->assertFalse( $called );
+
+ $called = false;
+ $lbFactory->beginMasterChanges( __METHOD__ );
+ $db->onTransactionPreCommitOrIdle( $callback, __METHOD__ );
+ $this->assertFalse( $called, 'Not called when lb-transaction is active' );
+ $lbFactory->commitMasterChanges( __METHOD__ );
+ $this->assertTrue( $called, 'Called when lb-transaction is committed' );
+
+ $called = false;
+ $lbFactory->beginMasterChanges( __METHOD__ );
+ $db->onTransactionPreCommitOrIdle( $callback, __METHOD__ );
+ $this->assertFalse( $called, 'Not called when lb-transaction is active' );
+
+ $lbFactory->rollbackMasterChanges( __METHOD__ );
+ $this->assertFalse( $called, 'Not called when lb-transaction is rolled back' );
+
+ $lbFactory->commitMasterChanges( __METHOD__ );
+ $this->assertFalse( $called, 'Not called in next round commit' );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::onTransactionResolution
+ * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks
+ */
+ public function testTransactionResolution() {
+ $db = $this->db;
+
+ $db->clearFlag( DBO_TRX );
+ $db->begin( __METHOD__ );
+ $called = false;
+ $db->onTransactionResolution( function () use ( $db, &$called ) {
+ $called = true;
+ $db->setFlag( DBO_TRX );
+ } );
+ $db->commit( __METHOD__ );
+ $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' );
+ $this->assertTrue( $called, 'Callback reached' );
+
+ $db->clearFlag( DBO_TRX );
+ $db->begin( __METHOD__ );
+ $called = false;
+ $db->onTransactionResolution( function () use ( $db, &$called ) {
+ $called = true;
+ $db->setFlag( DBO_TRX );
+ } );
+ $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
+ $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' );
+ $this->assertTrue( $called, 'Callback reached' );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::setTransactionListener
+ */
+ public function testTransactionListener() {
+ $db = $this->db;
+
+ $db->setTransactionListener( 'ping', function () use ( $db, &$called ) {
+ $called = true;
+ } );
+
+ $called = false;
+ $db->begin( __METHOD__ );
+ $db->commit( __METHOD__ );
+ $this->assertTrue( $called, 'Callback reached' );
+
+ $called = false;
+ $db->begin( __METHOD__ );
+ $db->commit( __METHOD__ );
+ $this->assertTrue( $called, 'Callback still reached' );
+
+ $called = false;
+ $db->begin( __METHOD__ );
+ $db->rollback( __METHOD__ );
+ $this->assertTrue( $called, 'Callback reached' );
+
+ $db->setTransactionListener( 'ping', null );
+ $called = false;
+ $db->begin( __METHOD__ );
+ $db->commit( __METHOD__ );
+ $this->assertFalse( $called, 'Callback not reached' );
+ }
+
+ /**
+ * Use this mock instead of DatabaseTestHelper for cases where
+ * DatabaseTestHelper is too inflexibile due to mocking too much
+ * or being too restrictive about fname matching (e.g. for tests
+ * that assert behaviour when the name is a mismatch, we need to
+ * catch the error here instead of there).
+ *
+ * @return Database
+ */
+ private function getMockDB( $methods = [] ) {
+ static $abstractMethods = [
+ 'fetchAffectedRowCount',
+ 'closeConnection',
+ 'dataSeek',
+ 'doQuery',
+ 'fetchObject', 'fetchRow',
+ 'fieldInfo', 'fieldName',
+ 'getSoftwareLink', 'getServerVersion',
+ 'getType',
+ 'indexInfo',
+ 'insertId',
+ 'lastError', 'lastErrno',
+ 'numFields', 'numRows',
+ 'open',
+ 'strencode',
+ ];
+ $db = $this->getMockBuilder( Database::class )
+ ->disableOriginalConstructor()
+ ->setMethods( array_values( array_unique( array_merge(
+ $abstractMethods,
+ $methods
+ ) ) ) )
+ ->getMock();
+ $wdb = TestingAccessWrapper::newFromObject( $db );
+ $wdb->trxProfiler = new TransactionProfiler();
+ $wdb->connLogger = new \Psr\Log\NullLogger();
+ $wdb->queryLogger = new \Psr\Log\NullLogger();
+ return $db;
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::flushSnapshot
+ */
+ public function testFlushSnapshot() {
+ $db = $this->getMockDB( [ 'isOpen' ] );
+ $db->method( 'isOpen' )->willReturn( true );
+
+ $db->flushSnapshot( __METHOD__ ); // ok
+ $db->flushSnapshot( __METHOD__ ); // ok
+
+ $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR );
+ $db->query( 'SELECT 1', __METHOD__ );
+ $this->assertTrue( (bool)$db->trxLevel(), "Transaction started." );
+ $db->flushSnapshot( __METHOD__ ); // ok
+ $db->restoreFlags( $db::RESTORE_PRIOR );
+
+ $this->assertFalse( (bool)$db->trxLevel(), "Transaction cleared." );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::getScopedLockAndFlush
+ * @covers Wikimedia\Rdbms\Database::lock
+ * @covers Wikimedia\Rdbms\Database::unlock
+ * @covers Wikimedia\Rdbms\Database::lockIsFree
+ */
+ public function testGetScopedLock() {
+ $db = $this->getMockDB( [ 'isOpen' ] );
+ $db->method( 'isOpen' )->willReturn( true );
+
+ $this->assertEquals( 0, $db->trxLevel() );
+ $this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) );
+ $this->assertEquals( true, $db->lock( 'x', __METHOD__ ) );
+ $this->assertEquals( false, $db->lockIsFree( 'x', __METHOD__ ) );
+ $this->assertEquals( true, $db->unlock( 'x', __METHOD__ ) );
+ $this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) );
+ $this->assertEquals( 0, $db->trxLevel() );
+
+ $db->setFlag( DBO_TRX );
+ $this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) );
+ $this->assertEquals( true, $db->lock( 'x', __METHOD__ ) );
+ $this->assertEquals( false, $db->lockIsFree( 'x', __METHOD__ ) );
+ $this->assertEquals( true, $db->unlock( 'x', __METHOD__ ) );
+ $this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) );
+ $db->clearFlag( DBO_TRX );
+
+ $this->assertEquals( 0, $db->trxLevel() );
+
+ $db->setFlag( DBO_TRX );
+ try {
+ $this->badLockingMethodImplicit( $db );
+ } catch ( RunTimeException $e ) {
+ $this->assertTrue( $db->trxLevel() > 0, "Transaction not committed." );
+ }
+ $db->clearFlag( DBO_TRX );
+ $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
+ $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ) );
+
+ try {
+ $this->badLockingMethodExplicit( $db );
+ } catch ( RunTimeException $e ) {
+ $this->assertTrue( $db->trxLevel() > 0, "Transaction not committed." );
+ }
+ $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
+ $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ) );
+ }
+
+ private function badLockingMethodImplicit( IDatabase $db ) {
+ $lock = $db->getScopedLockAndFlush( 'meow', __METHOD__, 1 );
+ $db->query( "SELECT 1" ); // trigger DBO_TRX
+ throw new RunTimeException( "Uh oh!" );
+ }
+
+ private function badLockingMethodExplicit( IDatabase $db ) {
+ $lock = $db->getScopedLockAndFlush( 'meow', __METHOD__, 1 );
+ $db->begin( __METHOD__ );
+ throw new RunTimeException( "Uh oh!" );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::getFlag
+ * @covers Wikimedia\Rdbms\Database::setFlag
+ * @covers Wikimedia\Rdbms\Database::restoreFlags
+ */
+ public function testFlagSetting() {
+ $db = $this->db;
+ $origTrx = $db->getFlag( DBO_TRX );
+ $origSsl = $db->getFlag( DBO_SSL );
+
+ $origTrx
+ ? $db->clearFlag( DBO_TRX, $db::REMEMBER_PRIOR )
+ : $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR );
+ $this->assertEquals( !$origTrx, $db->getFlag( DBO_TRX ) );
+
+ $origSsl
+ ? $db->clearFlag( DBO_SSL, $db::REMEMBER_PRIOR )
+ : $db->setFlag( DBO_SSL, $db::REMEMBER_PRIOR );
+ $this->assertEquals( !$origSsl, $db->getFlag( DBO_SSL ) );
+
+ $db->restoreFlags( $db::RESTORE_INITIAL );
+ $this->assertEquals( $origTrx, $db->getFlag( DBO_TRX ) );
+ $this->assertEquals( $origSsl, $db->getFlag( DBO_SSL ) );
+
+ $origTrx
+ ? $db->clearFlag( DBO_TRX, $db::REMEMBER_PRIOR )
+ : $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR );
+ $origSsl
+ ? $db->clearFlag( DBO_SSL, $db::REMEMBER_PRIOR )
+ : $db->setFlag( DBO_SSL, $db::REMEMBER_PRIOR );
+
+ $db->restoreFlags();
+ $this->assertEquals( $origSsl, $db->getFlag( DBO_SSL ) );
+ $this->assertEquals( !$origTrx, $db->getFlag( DBO_TRX ) );
+
+ $db->restoreFlags();
+ $this->assertEquals( $origSsl, $db->getFlag( DBO_SSL ) );
+ $this->assertEquals( $origTrx, $db->getFlag( DBO_TRX ) );
+ }
+
+ /**
+ * @expectedException UnexpectedValueException
+ * @covers Wikimedia\Rdbms\Database::setFlag
+ */
+ public function testDBOIgnoreSet() {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( null )
+ ->getMock();
+
+ $db->setFlag( Database::DBO_IGNORE );
+ }
+
+ /**
+ * @expectedException UnexpectedValueException
+ * @covers Wikimedia\Rdbms\Database::clearFlag
+ */
+ public function testDBOIgnoreClear() {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( null )
+ ->getMock();
+
+ $db->clearFlag( Database::DBO_IGNORE );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::tablePrefix
+ * @covers Wikimedia\Rdbms\Database::dbSchema
+ */
+ public function testMutators() {
+ $old = $this->db->tablePrefix();
+ $this->assertInternalType( 'string', $old, 'Prefix is string' );
+ $this->assertEquals( $old, $this->db->tablePrefix(), "Prefix unchanged" );
+ $this->assertEquals( $old, $this->db->tablePrefix( 'xxx' ) );
+ $this->assertEquals( 'xxx', $this->db->tablePrefix(), "Prefix set" );
+ $this->db->tablePrefix( $old );
+ $this->assertNotEquals( 'xxx', $this->db->tablePrefix() );
+
+ $old = $this->db->dbSchema();
+ $this->assertInternalType( 'string', $old, 'Schema is string' );
+ $this->assertEquals( $old, $this->db->dbSchema(), "Schema unchanged" );
+ $this->assertEquals( $old, $this->db->dbSchema( 'xxx' ) );
+ $this->assertEquals( 'xxx', $this->db->dbSchema(), "Schema set" );
+ $this->db->dbSchema( $old );
+ $this->assertNotEquals( 'xxx', $this->db->dbSchema() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/xmp/XMPTest.php b/www/wiki/tests/phpunit/includes/libs/xmp/XMPTest.php
new file mode 100644
index 00000000..73fd4716
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/xmp/XMPTest.php
@@ -0,0 +1,227 @@
+<?php
+
+/**
+ * @group Media
+ * @covers XMPReader
+ */
+class XMPTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected function setUp() {
+ parent::setUp();
+ # Requires libxml to do XMP parsing
+ if ( !extension_loaded( 'exif' ) ) {
+ $this->markTestSkipped( "PHP extension 'exif' is not loaded, skipping." );
+ }
+ }
+
+ /**
+ * Put XMP in, compare what comes out...
+ *
+ * @param string $xmp The actual xml data.
+ * @param array $expected Expected result of parsing the xmp.
+ * @param string $info Short sentence on what's being tested.
+ *
+ * @throws Exception
+ * @dataProvider provideXMPParse
+ *
+ * @covers XMPReader::parse
+ */
+ public function testXMPParse( $xmp, $expected, $info ) {
+ if ( !is_string( $xmp ) || !is_array( $expected ) ) {
+ throw new Exception( "Invalid data provided to " . __METHOD__ );
+ }
+ $reader = new XMPReader;
+ $reader->parse( $xmp );
+ $this->assertEquals( $expected, $reader->getResults(), $info, 0.0000000001 );
+ }
+
+ public static function provideXMPParse() {
+ $xmpPath = __DIR__ . '/../../../data/xmp/';
+ $data = [];
+
+ // $xmpFiles format: array of arrays with first arg file base name,
+ // with the actual file having .xmp on the end for the xmp
+ // and .result.php on the end for a php file containing the result
+ // array. Second argument is some info on what's being tested.
+ $xmpFiles = [
+ [ '1', 'parseType=Resource test' ],
+ [ '2', 'Structure with mixed attribute and element props' ],
+ [ '3', 'Extra qualifiers (that should be ignored)' ],
+ [ '3-invalid', 'Test ignoring qualifiers that look like normal props' ],
+ [ '4', 'Flash as qualifier' ],
+ [ '5', 'Flash as qualifier 2' ],
+ [ '6', 'Multiple rdf:Description' ],
+ [ '7', 'Generic test of several property types' ],
+ [ 'flash', 'Test of Flash property' ],
+ [ 'invalid-child-not-struct', 'Test child props not in struct or ignored' ],
+ [ 'no-recognized-props', 'Test namespace and no recognized props' ],
+ [ 'no-namespace', 'Test non-namespaced attributes are ignored' ],
+ [ 'bag-for-seq', "Allow bag's instead of seq's. (T29105)" ],
+ [ 'utf16BE', 'UTF-16BE encoding' ],
+ [ 'utf16LE', 'UTF-16LE encoding' ],
+ [ 'utf32BE', 'UTF-32BE encoding' ],
+ [ 'utf32LE', 'UTF-32LE encoding' ],
+ [ 'xmpExt', 'Extended XMP missing second part' ],
+ [ 'gps', 'Handling of exif GPS parameters in XMP' ],
+ ];
+
+ $xmpFiles[] = [ 'doctype-included', 'XMP includes doctype' ];
+
+ foreach ( $xmpFiles as $file ) {
+ $xmp = file_get_contents( $xmpPath . $file[0] . '.xmp' );
+ // I'm not sure if this is the best way to handle getting the
+ // result array, but it seems kind of big to put directly in the test
+ // file.
+ $result = null;
+ include $xmpPath . $file[0] . '.result.php';
+ $data[] = [ $xmp, $result, '[' . $file[0] . '.xmp] ' . $file[1] ];
+ }
+
+ return $data;
+ }
+
+ /** Test ExtendedXMP block support. (Used when the XMP has to be split
+ * over multiple jpeg segments, due to 64k size limit on jpeg segments.
+ *
+ * @todo This is based on what the standard says. Need to find a real
+ * world example file to double check the support for this is right.
+ *
+ * @covers XMPReader::parseExtended
+ */
+ public function testExtendedXMP() {
+ $xmpPath = __DIR__ . '/../../../data/xmp/';
+ $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' );
+ $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' );
+
+ $md5sum = '28C74E0AC2D796886759006FBE2E57B7'; // of xmpExt2.xmp
+ $length = pack( 'N', strlen( $extendedXMP ) );
+ $offset = pack( 'N', 0 );
+ $extendedPacket = $md5sum . $length . $offset . $extendedXMP;
+
+ $reader = new XMPReader();
+ $reader->parse( $standardXMP );
+ $reader->parseExtended( $extendedPacket );
+ $actual = $reader->getResults();
+
+ $expected = [
+ 'xmp-exif' => [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => 9,
+ 'FNumber' => '2/10',
+ ]
+ ];
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ /**
+ * This test has an extended XMP block with a wrong guid (md5sum)
+ * and thus should only return the StandardXMP, not the ExtendedXMP.
+ *
+ * @covers XMPReader::parseExtended
+ */
+ public function testExtendedXMPWithWrongGUID() {
+ $xmpPath = __DIR__ . '/../../../data/xmp/';
+ $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' );
+ $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' );
+
+ $md5sum = '28C74E0AC2D796886759006FBE2E57B9'; // Note last digit.
+ $length = pack( 'N', strlen( $extendedXMP ) );
+ $offset = pack( 'N', 0 );
+ $extendedPacket = $md5sum . $length . $offset . $extendedXMP;
+
+ $reader = new XMPReader();
+ $reader->parse( $standardXMP );
+ $reader->parseExtended( $extendedPacket );
+ $actual = $reader->getResults();
+
+ $expected = [
+ 'xmp-exif' => [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => 9,
+ ]
+ ];
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ /**
+ * Have a high offset to simulate a missing packet,
+ * which should cause it to ignore the ExtendedXMP packet.
+ *
+ * @covers XMPReader::parseExtended
+ */
+ public function testExtendedXMPMissingPacket() {
+ $xmpPath = __DIR__ . '/../../../data/xmp/';
+ $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' );
+ $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' );
+
+ $md5sum = '28C74E0AC2D796886759006FBE2E57B7'; // of xmpExt2.xmp
+ $length = pack( 'N', strlen( $extendedXMP ) );
+ $offset = pack( 'N', 2048 );
+ $extendedPacket = $md5sum . $length . $offset . $extendedXMP;
+
+ $reader = new XMPReader();
+ $reader->parse( $standardXMP );
+ $reader->parseExtended( $extendedPacket );
+ $actual = $reader->getResults();
+
+ $expected = [
+ 'xmp-exif' => [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => 9,
+ ]
+ ];
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ /**
+ * Test for multi-section, hostile XML
+ * @covers XMPReader::checkParseSafety
+ */
+ public function testCheckParseSafety() {
+ // Test for detection
+ $xmpPath = __DIR__ . '/../../../data/xmp/';
+ $file = fopen( $xmpPath . 'doctype-included.xmp', 'rb' );
+ $valid = false;
+ $reader = new XMPReader();
+ do {
+ $chunk = fread( $file, 10 );
+ $valid = $reader->parse( $chunk, feof( $file ) );
+ } while ( !feof( $file ) );
+ $this->assertFalse( $valid, 'Check that doctype is detected in fragmented XML' );
+ $this->assertEquals(
+ [],
+ $reader->getResults(),
+ 'Check that doctype is detected in fragmented XML'
+ );
+ fclose( $file );
+ unset( $reader );
+
+ // Test for false positives
+ $file = fopen( $xmpPath . 'doctype-not-included.xmp', 'rb' );
+ $valid = false;
+ $reader = new XMPReader();
+ do {
+ $chunk = fread( $file, 10 );
+ $valid = $reader->parse( $chunk, feof( $file ) );
+ } while ( !feof( $file ) );
+ $this->assertTrue(
+ $valid,
+ 'Check for false-positive detecting doctype in fragmented XML'
+ );
+ $this->assertEquals(
+ [
+ 'xmp-exif' => [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => '9'
+ ]
+ ],
+ $reader->getResults(),
+ 'Check that doctype is detected in fragmented XML'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/xmp/XMPValidateTest.php b/www/wiki/tests/phpunit/includes/libs/xmp/XMPValidateTest.php
new file mode 100644
index 00000000..746f68ac
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/xmp/XMPValidateTest.php
@@ -0,0 +1,55 @@
+<?php
+
+use Psr\Log\NullLogger;
+
+/**
+ * @group Media
+ */
+class XMPValidateTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @dataProvider provideDates
+ * @covers XMPValidate::validateDate
+ */
+ public function testValidateDate( $value, $expected ) {
+ // The method should modify $value.
+ $validate = new XMPValidate( new NullLogger() );
+ $validate->validateDate( [], $value, true );
+ $this->assertEquals( $expected, $value );
+ }
+
+ public static function provideDates() {
+ /* For reference valid date formats are:
+ * YYYY
+ * YYYY-MM
+ * YYYY-MM-DD
+ * YYYY-MM-DDThh:mmTZD
+ * YYYY-MM-DDThh:mm:ssTZD
+ * YYYY-MM-DDThh:mm:ss.sTZD
+ * (Time zone is optional)
+ */
+ return [
+ [ '1992', '1992' ],
+ [ '1992-04', '1992:04' ],
+ [ '1992-02-01', '1992:02:01' ],
+ [ '2011-09-29', '2011:09:29' ],
+ [ '1982-12-15T20:12', '1982:12:15 20:12' ],
+ [ '1982-12-15T20:12Z', '1982:12:15 20:12' ],
+ [ '1982-12-15T20:12+02:30', '1982:12:15 22:42' ],
+ [ '1982-12-15T01:12-02:30', '1982:12:14 22:42' ],
+ [ '1982-12-15T20:12:11', '1982:12:15 20:12:11' ],
+ [ '1982-12-15T20:12:11Z', '1982:12:15 20:12:11' ],
+ [ '1982-12-15T20:12:11+01:10', '1982:12:15 21:22:11' ],
+ [ '2045-12-15T20:12:11', '2045:12:15 20:12:11' ],
+ [ '1867-06-01T15:00:00', '1867:06:01 15:00:00' ],
+ /* some invalid ones */
+ [ '2001--12', null ],
+ [ '2001-5-12', null ],
+ [ '2001-5-12TZ', null ],
+ [ '2001-05-12T15', null ],
+ [ '2001-12T15:13', null ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/linkeddata/PageDataRequestHandlerTest.php b/www/wiki/tests/phpunit/includes/linkeddata/PageDataRequestHandlerTest.php
new file mode 100644
index 00000000..ad0c3d1e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/linkeddata/PageDataRequestHandlerTest.php
@@ -0,0 +1,284 @@
+<?php
+
+/**
+ * @covers PageDataRequestHandler
+ * @group PageData
+ */
+class PageDataRequestHandlerTest extends \MediaWikiTestCase {
+
+ /**
+ * @var Title
+ */
+ private $interfaceTitle;
+
+ /**
+ * @var int
+ */
+ private $obLevel;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->interfaceTitle = Title::newFromText( "Special:PageDataRequestHandlerTest" );
+
+ $this->obLevel = ob_get_level();
+ }
+
+ protected function tearDown() {
+ $obLevel = ob_get_level();
+
+ while ( ob_get_level() > $this->obLevel ) {
+ ob_end_clean();
+ }
+
+ if ( $obLevel !== $this->obLevel ) {
+ $this->fail( "Test changed output buffer level: was {$this->obLevel}" .
+ "before test, but $obLevel after test."
+ );
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * @return PageDataRequestHandler
+ */
+ protected function newHandler() {
+ return new PageDataRequestHandler( 'json' );
+ }
+
+ /**
+ * @param array $params
+ * @param string[] $headers
+ *
+ * @return OutputPage
+ */
+ protected function makeOutputPage( array $params, array $headers ) {
+ // construct request
+ $request = new FauxRequest( $params );
+ $request->response()->header( 'Status: 200 OK', true, 200 ); // init/reset
+
+ foreach ( $headers as $name => $value ) {
+ $request->setHeader( strtoupper( $name ), $value );
+ }
+
+ // construct Context and OutputPage
+ $context = new DerivativeContext( RequestContext::getMain() );
+ $context->setRequest( $request );
+
+ $output = new OutputPage( $context );
+ $output->setTitle( $this->interfaceTitle );
+ $context->setOutput( $output );
+
+ return $output;
+ }
+
+ public function handleRequestProvider() {
+ $cases = [];
+
+ $cases[] = [ '', [], [], '!!', 400 ];
+
+ $cases[] = [ '', [ 'target' => 'Helsinki' ], [], '!!', 303, [ 'Location' => '!.+!' ] ];
+
+ $subpageCases = [];
+ foreach ( $cases as $c ) {
+ $case = $c;
+ $case[0] = 'main/';
+
+ if ( isset( $case[1]['target'] ) ) {
+ $case[0] .= $case[1]['target'];
+ unset( $case[1]['target'] );
+ }
+
+ $subpageCases[] = $case;
+ }
+
+ $cases = array_merge( $cases, $subpageCases );
+
+ $cases[] = [
+ '',
+ [ 'target' => 'Helsinki' ],
+ [ 'Accept' => 'text/HTML' ],
+ '!!',
+ 303,
+ [ 'Location' => '!Helsinki$!' ]
+ ];
+
+ $cases[] = [
+ '',
+ [
+ 'target' => 'Helsinki',
+ 'revision' => '4242',
+ ],
+ [ 'Accept' => 'text/HTML' ],
+ '!!',
+ 303,
+ [ 'Location' => '!Helsinki(\?|&)oldid=4242!' ]
+ ];
+
+ $cases[] = [
+ '/Helsinki',
+ [],
+ [],
+ '!!',
+ 303,
+ [ 'Location' => '!Helsinki&action=raw!' ]
+ ];
+
+ // #31: /Q5 with "Accept: text/foobar" triggers a 406
+ $cases[] = [
+ 'main/Helsinki',
+ [],
+ [ 'Accept' => 'text/foobar' ],
+ '!!',
+ 406,
+ [],
+ ];
+
+ $cases[] = [
+ 'main/Helsinki',
+ [],
+ [ 'Accept' => 'text/HTML' ],
+ '!!',
+ 303,
+ [ 'Location' => '!Helsinki$!' ]
+ ];
+
+ $cases[] = [
+ '/Helsinki',
+ [],
+ [ 'Accept' => 'text/HTML' ],
+ '!!',
+ 303,
+ [ 'Location' => '!Helsinki$!' ]
+ ];
+
+ $cases[] = [
+ 'main/AC/DC',
+ [],
+ [ 'Accept' => 'text/HTML' ],
+ '!!',
+ 303,
+ [ 'Location' => '!AC/DC$!' ]
+ ];
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider handleRequestProvider
+ *
+ * @param string $subpage The subpage to request (or '')
+ * @param array $params Request parameters
+ * @param array $headers Request headers
+ * @param string $expectedOutput Regex to match the output against.
+ * @param int $expectedStatusCode Expected HTTP status code.
+ * @param string[] $expectedHeaders Expected HTTP response headers.
+ */
+ public function testHandleRequest(
+ $subpage,
+ array $params,
+ array $headers,
+ $expectedOutput,
+ $expectedStatusCode = 200,
+ array $expectedHeaders = []
+ ) {
+ $output = $this->makeOutputPage( $params, $headers );
+ $request = $output->getRequest();
+
+ /* @var FauxResponse $response */
+ $response = $request->response();
+
+ // construct handler
+ $handler = $this->newHandler();
+
+ try {
+ ob_start();
+ $handler->handleRequest( $subpage, $request, $output );
+
+ if ( $output->getRedirect() !== '' ) {
+ // hack to apply redirect to web response
+ $output->output();
+ }
+
+ $text = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals( $expectedStatusCode, $response->getStatusCode(), 'status code' );
+ $this->assertRegExp( $expectedOutput, $text, 'output' );
+
+ foreach ( $expectedHeaders as $name => $exp ) {
+ $value = $response->getHeader( $name );
+ $this->assertNotNull( $value, "header: $name" );
+ $this->assertInternalType( 'string', $value, "header: $name" );
+ $this->assertRegExp( $exp, $value, "header: $name" );
+ }
+ } catch ( HttpError $e ) {
+ ob_end_clean();
+ $this->assertEquals( $expectedStatusCode, $e->getStatusCode(), 'status code' );
+ $this->assertRegExp( $expectedOutput, $e->getHTML(), 'error output' );
+ }
+
+ // We always set "Access-Control-Allow-Origin: *"
+ $this->assertSame( '*', $response->getHeader( 'Access-Control-Allow-Origin' ) );
+ }
+
+ public function provideHttpContentNegotiation() {
+ $helsinki = Title::newFromText( 'Helsinki' );
+ return [
+ 'Accept Header of HTML' => [
+ $helsinki,
+ [ 'ACCEPT' => 'text/html' ], // headers
+ 'Helsinki'
+ ],
+ 'Accept Header without weights' => [
+ $helsinki,
+ [ 'ACCEPT' => '*/*, text/html, text/x-wiki' ],
+ 'Helsinki&action=raw'
+ ],
+ 'Accept Header with weights' => [
+ $helsinki,
+ [ 'ACCEPT' => 'text/*; q=0.5, text/json; q=0.7, application/rdf+xml; q=0.8' ],
+ 'Helsinki&action=raw'
+ ],
+ 'Accept Header accepting evertyhing and HTML' => [
+ $helsinki,
+ [ 'ACCEPT' => 'text/html, */*' ],
+ 'Helsinki&action=raw'
+ ],
+ 'No Accept Header' => [
+ $helsinki,
+ [],
+ 'Helsinki&action=raw'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideHttpContentNegotiation
+ *
+ * @param Title $title
+ * @param array $headers Request headers
+ * @param string $expectedRedirectSuffix Expected suffix of the HTTP Location header.
+ *
+ * @throws HttpError
+ */
+ public function testHttpContentNegotiation(
+ Title $title,
+ array $headers,
+ $expectedRedirectSuffix
+ ) {
+ /* @var FauxResponse $response */
+ $output = $this->makeOutputPage( [], $headers );
+ $request = $output->getRequest();
+
+ $handler = $this->newHandler();
+ $handler->httpContentNegotiation( $request, $output, $title );
+
+ $this->assertStringEndsWith(
+ $expectedRedirectSuffix,
+ $output->getRedirect(),
+ 'redirect target'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/linker/LinkRendererFactoryTest.php b/www/wiki/tests/phpunit/includes/linker/LinkRendererFactoryTest.php
new file mode 100644
index 00000000..27e5a657
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/linker/LinkRendererFactoryTest.php
@@ -0,0 +1,82 @@
+<?php
+
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Linker\LinkRendererFactory;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @covers MediaWiki\Linker\LinkRendererFactory
+ */
+class LinkRendererFactoryTest extends MediaWikiLangTestCase {
+
+ /**
+ * @var TitleFormatter
+ */
+ private $titleFormatter;
+
+ /**
+ * @var LinkCache
+ */
+ private $linkCache;
+
+ public function setUp() {
+ parent::setUp();
+ $this->titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter();
+ $this->linkCache = MediaWikiServices::getInstance()->getLinkCache();
+ }
+
+ public static function provideCreateFromLegacyOptions() {
+ return [
+ [
+ [ 'forcearticlepath' ],
+ 'getForceArticlePath',
+ true
+ ],
+ [
+ [ 'http' ],
+ 'getExpandURLs',
+ PROTO_HTTP
+ ],
+ [
+ [ 'https' ],
+ 'getExpandURLs',
+ PROTO_HTTPS
+ ],
+ [
+ [ 'stubThreshold' => 150 ],
+ 'getStubThreshold',
+ 150
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCreateFromLegacyOptions
+ */
+ public function testCreateFromLegacyOptions( $options, $func, $val ) {
+ $factory = new LinkRendererFactory( $this->titleFormatter, $this->linkCache );
+ $linkRenderer = $factory->createFromLegacyOptions(
+ $options
+ );
+ $this->assertInstanceOf( LinkRenderer::class, $linkRenderer );
+ $this->assertEquals( $val, $linkRenderer->$func(), $func );
+ }
+
+ public function testCreate() {
+ $factory = new LinkRendererFactory( $this->titleFormatter, $this->linkCache );
+ $this->assertInstanceOf( LinkRenderer::class, $factory->create() );
+ }
+
+ public function testCreateForUser() {
+ /** @var PHPUnit_Framework_MockObject_MockObject|User $user */
+ $user = $this->getMockBuilder( User::class )
+ ->setMethods( [ 'getStubThreshold' ] )->getMock();
+ $user->expects( $this->once() )
+ ->method( 'getStubThreshold' )
+ ->willReturn( 15 );
+ $factory = new LinkRendererFactory( $this->titleFormatter, $this->linkCache );
+ $linkRenderer = $factory->createForUser( $user );
+ $this->assertInstanceOf( LinkRenderer::class, $linkRenderer );
+ $this->assertEquals( 15, $linkRenderer->getStubThreshold() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/linker/LinkRendererTest.php b/www/wiki/tests/phpunit/includes/linker/LinkRendererTest.php
new file mode 100644
index 00000000..6d096c20
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/linker/LinkRendererTest.php
@@ -0,0 +1,189 @@
+<?php
+
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Linker\LinkRendererFactory;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @covers MediaWiki\Linker\LinkRenderer
+ */
+class LinkRendererTest extends MediaWikiLangTestCase {
+
+ /**
+ * @var LinkRendererFactory
+ */
+ private $factory;
+
+ public function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( [
+ 'wgArticlePath' => '/wiki/$1',
+ 'wgServer' => '//example.org',
+ 'wgCanonicalServer' => 'http://example.org',
+ 'wgScriptPath' => '/w',
+ 'wgScript' => '/w/index.php',
+ ] );
+ $this->factory = MediaWikiServices::getInstance()->getLinkRendererFactory();
+ }
+
+ public function testMergeAttribs() {
+ $target = new TitleValue( NS_SPECIAL, 'Blankpage' );
+ $linkRenderer = $this->factory->create();
+ $link = $linkRenderer->makeBrokenLink( $target, null, [
+ // Appended to class
+ 'class' => 'foobar',
+ // Suppresses href attribute
+ 'href' => false,
+ // Extra attribute
+ 'bar' => 'baz'
+ ] );
+ $this->assertEquals(
+ '<a href="/wiki/Special:BlankPage" class="new foobar" '
+ . 'title="Special:BlankPage (page does not exist)" bar="baz">'
+ . 'Special:BlankPage</a>',
+ $link
+ );
+ }
+
+ public function testMakeKnownLink() {
+ $target = new TitleValue( NS_MAIN, 'Foobar' );
+ $linkRenderer = $this->factory->create();
+
+ // Query added
+ $this->assertEquals(
+ '<a href="/w/index.php?title=Foobar&amp;foo=bar" '. 'title="Foobar">Foobar</a>',
+ $linkRenderer->makeKnownLink( $target, null, [], [ 'foo' => 'bar' ] )
+ );
+
+ // forcearticlepath
+ $linkRenderer->setForceArticlePath( true );
+ $this->assertEquals(
+ '<a href="/wiki/Foobar?foo=bar" title="Foobar">Foobar</a>',
+ $linkRenderer->makeKnownLink( $target, null, [], [ 'foo' => 'bar' ] )
+ );
+
+ // expand = HTTPS
+ $linkRenderer->setForceArticlePath( false );
+ $linkRenderer->setExpandURLs( PROTO_HTTPS );
+ $this->assertEquals(
+ '<a href="https://example.org/wiki/Foobar" title="Foobar">Foobar</a>',
+ $linkRenderer->makeKnownLink( $target )
+ );
+ }
+
+ public function testMakeBrokenLink() {
+ $target = new TitleValue( NS_MAIN, 'Foobar' );
+ $special = new TitleValue( NS_SPECIAL, 'Foobar' );
+ $linkRenderer = $this->factory->create();
+
+ // action=edit&redlink=1 added
+ $this->assertEquals(
+ '<a href="/w/index.php?title=Foobar&amp;action=edit&amp;redlink=1" '
+ . 'class="new" title="Foobar (page does not exist)">Foobar</a>',
+ $linkRenderer->makeBrokenLink( $target )
+ );
+
+ // action=edit&redlink=1 not added due to action query parameter
+ $this->assertEquals(
+ '<a href="/w/index.php?title=Foobar&amp;action=foobar" class="new" '
+ . 'title="Foobar (page does not exist)">Foobar</a>',
+ $linkRenderer->makeBrokenLink( $target, null, [], [ 'action' => 'foobar' ] )
+ );
+
+ // action=edit&redlink=1 not added due to NS_SPECIAL
+ $this->assertEquals(
+ '<a href="/wiki/Special:Foobar" class="new" title="Special:Foobar '
+ . '(page does not exist)">Special:Foobar</a>',
+ $linkRenderer->makeBrokenLink( $special )
+ );
+
+ // fragment stripped
+ $this->assertEquals(
+ '<a href="/w/index.php?title=Foobar&amp;action=edit&amp;redlink=1" '
+ . 'class="new" title="Foobar (page does not exist)">Foobar</a>',
+ $linkRenderer->makeBrokenLink( $target->createFragmentTarget( 'foobar' ) )
+ );
+ }
+
+ public function testMakeLink() {
+ $linkRenderer = $this->factory->create();
+ $foobar = new TitleValue( NS_SPECIAL, 'Foobar' );
+ $blankpage = new TitleValue( NS_SPECIAL, 'Blankpage' );
+ $this->assertEquals(
+ '<a href="/wiki/Special:Foobar" class="new" title="Special:Foobar '
+ . '(page does not exist)">foo</a>',
+ $linkRenderer->makeLink( $foobar, 'foo' )
+ );
+
+ $this->assertEquals(
+ '<a href="/wiki/Special:BlankPage" title="Special:BlankPage">blank</a>',
+ $linkRenderer->makeLink( $blankpage, 'blank' )
+ );
+
+ $this->assertEquals(
+ '<a href="/wiki/Special:Foobar" class="new" title="Special:Foobar '
+ . '(page does not exist)">&lt;script&gt;evil()&lt;/script&gt;</a>',
+ $linkRenderer->makeLink( $foobar, '<script>evil()</script>' )
+ );
+
+ $this->assertEquals(
+ '<a href="/wiki/Special:Foobar" class="new" title="Special:Foobar '
+ . '(page does not exist)"><script>evil()</script></a>',
+ $linkRenderer->makeLink( $foobar, new HtmlArmor( '<script>evil()</script>' ) )
+ );
+ }
+
+ public function testGetLinkClasses() {
+ $wanCache = ObjectCache::getMainWANInstance();
+ $titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter();
+ $linkCache = new LinkCache( $titleFormatter, $wanCache );
+ $foobarTitle = new TitleValue( NS_MAIN, 'FooBar' );
+ $redirectTitle = new TitleValue( NS_MAIN, 'Redirect' );
+ $userTitle = new TitleValue( NS_USER, 'Someuser' );
+ $linkCache->addGoodLinkObj(
+ 1, // id
+ $foobarTitle,
+ 10, // len
+ 0 // redir
+ );
+ $linkCache->addGoodLinkObj(
+ 2, // id
+ $redirectTitle,
+ 10, // len
+ 1 // redir
+ );
+
+ $linkCache->addGoodLinkObj(
+ 3, // id
+ $userTitle,
+ 10, // len
+ 0 // redir
+ );
+
+ $linkRenderer = new LinkRenderer( $titleFormatter, $linkCache );
+ $linkRenderer->setStubThreshold( 0 );
+ $this->assertEquals(
+ '',
+ $linkRenderer->getLinkClasses( $foobarTitle )
+ );
+
+ $linkRenderer->setStubThreshold( 20 );
+ $this->assertEquals(
+ 'stub',
+ $linkRenderer->getLinkClasses( $foobarTitle )
+ );
+
+ $linkRenderer->setStubThreshold( 0 );
+ $this->assertEquals(
+ 'mw-redirect',
+ $linkRenderer->getLinkClasses( $redirectTitle )
+ );
+
+ $linkRenderer->setStubThreshold( 20 );
+ $this->assertEquals(
+ '',
+ $linkRenderer->getLinkClasses( $userTitle )
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/BlockLogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/BlockLogFormatterTest.php
new file mode 100644
index 00000000..03671ac8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/BlockLogFormatterTest.php
@@ -0,0 +1,379 @@
+<?php
+
+/**
+ * @covers BlockLogFormatter
+ */
+class BlockLogFormatterTest extends LogFormatterTestCase {
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideBlockLogDatabaseRows() {
+ return [
+ // Current log format
+ [
+ [
+ 'type' => 'block',
+ 'action' => 'block',
+ 'comment' => 'Block comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Logtestuser',
+ 'params' => [
+ '5::duration' => 'infinite',
+ '6::flags' => 'anononly',
+ ],
+ ],
+ [
+ 'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite'
+ . ' (anonymous users only)',
+ 'api' => [
+ 'duration' => 'infinite',
+ 'flags' => [ 'anononly' ],
+ ],
+ ],
+ ],
+
+ // Old legacy log
+ [
+ [
+ 'type' => 'block',
+ 'action' => 'block',
+ 'comment' => 'Block comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Logtestuser',
+ 'params' => [
+ 'infinite',
+ 'anononly',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite'
+ . ' (anonymous users only)',
+ 'api' => [
+ 'duration' => 'infinite',
+ 'flags' => [ 'anononly' ],
+ ],
+ ],
+ ],
+
+ // Old legacy log without flag
+ [
+ [
+ 'type' => 'block',
+ 'action' => 'block',
+ 'comment' => 'Block comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Logtestuser',
+ 'params' => [
+ 'infinite',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite',
+ 'api' => [
+ 'duration' => 'infinite',
+ 'flags' => [],
+ ],
+ ],
+ ],
+
+ // Very old legacy log without duration
+ [
+ [
+ 'type' => 'block',
+ 'action' => 'block',
+ 'comment' => 'Block comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Logtestuser',
+ 'params' => [],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite',
+ 'api' => [
+ 'duration' => 'infinite',
+ 'flags' => [],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideBlockLogDatabaseRows
+ */
+ public function testBlockLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideReblockLogDatabaseRows() {
+ return [
+ // Current log format
+ [
+ [
+ 'type' => 'block',
+ 'action' => 'reblock',
+ 'comment' => 'Block comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Logtestuser',
+ 'params' => [
+ '5::duration' => 'infinite',
+ '6::flags' => 'anononly',
+ ],
+ ],
+ [
+ 'text' => 'Sysop changed block settings for Logtestuser with an expiration time of'
+ . ' indefinite (anonymous users only)',
+ 'api' => [
+ 'duration' => 'infinite',
+ 'flags' => [ 'anononly' ],
+ ],
+ ],
+ ],
+
+ // Old log
+ [
+ [
+ 'type' => 'block',
+ 'action' => 'reblock',
+ 'comment' => 'Block comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Logtestuser',
+ 'params' => [
+ 'infinite',
+ 'anononly',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'Sysop changed block settings for Logtestuser with an expiration time of'
+ . ' indefinite (anonymous users only)',
+ 'api' => [
+ 'duration' => 'infinite',
+ 'flags' => [ 'anononly' ],
+ ],
+ ],
+ ],
+
+ // Older log without flag
+ [
+ [
+ 'type' => 'block',
+ 'action' => 'reblock',
+ 'comment' => 'Block comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Logtestuser',
+ 'params' => [
+ 'infinite',
+ ]
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'Sysop changed block settings for Logtestuser with an expiration time of indefinite',
+ 'api' => [
+ 'duration' => 'infinite',
+ 'flags' => [],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideReblockLogDatabaseRows
+ */
+ public function testReblockLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideUnblockLogDatabaseRows() {
+ return [
+ // Current log format
+ [
+ [
+ 'type' => 'block',
+ 'action' => 'unblock',
+ 'comment' => 'Block comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Logtestuser',
+ 'params' => [],
+ ],
+ [
+ 'text' => 'Sysop unblocked Logtestuser',
+ 'api' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideUnblockLogDatabaseRows
+ */
+ public function testUnblockLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideSuppressBlockLogDatabaseRows() {
+ return [
+ // Current log format
+ [
+ [
+ 'type' => 'suppress',
+ 'action' => 'block',
+ 'comment' => 'Block comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Logtestuser',
+ 'params' => [
+ '5::duration' => 'infinite',
+ '6::flags' => 'anononly',
+ ],
+ ],
+ [
+ 'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite'
+ . ' (anonymous users only)',
+ 'api' => [
+ 'duration' => 'infinite',
+ 'flags' => [ 'anononly' ],
+ ],
+ ],
+ ],
+
+ // legacy log
+ [
+ [
+ 'type' => 'suppress',
+ 'action' => 'block',
+ 'comment' => 'Block comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Logtestuser',
+ 'params' => [
+ 'infinite',
+ 'anononly',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite'
+ . ' (anonymous users only)',
+ 'api' => [
+ 'duration' => 'infinite',
+ 'flags' => [ 'anononly' ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideSuppressBlockLogDatabaseRows
+ */
+ public function testSuppressBlockLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideSuppressReblockLogDatabaseRows() {
+ return [
+ // Current log format
+ [
+ [
+ 'type' => 'suppress',
+ 'action' => 'reblock',
+ 'comment' => 'Block comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Logtestuser',
+ 'params' => [
+ '5::duration' => 'infinite',
+ '6::flags' => 'anononly',
+ ],
+ ],
+ [
+ 'text' => 'Sysop changed block settings for Logtestuser with an expiration time of'
+ . ' indefinite (anonymous users only)',
+ 'api' => [
+ 'duration' => 'infinite',
+ 'flags' => [ 'anononly' ],
+ ],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'suppress',
+ 'action' => 'reblock',
+ 'comment' => 'Block comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Logtestuser',
+ 'params' => [
+ 'infinite',
+ 'anononly',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'Sysop changed block settings for Logtestuser with an expiration time of'
+ . ' indefinite (anonymous users only)',
+ 'api' => [
+ 'duration' => 'infinite',
+ 'flags' => [ 'anononly' ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideSuppressReblockLogDatabaseRows
+ */
+ public function testSuppressReblockLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/ContentModelLogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/ContentModelLogFormatterTest.php
new file mode 100644
index 00000000..17e54115
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/ContentModelLogFormatterTest.php
@@ -0,0 +1,60 @@
+<?php
+
+/**
+ * @covers ContentModelLogFormatter
+ */
+class ContentModelLogFormatterTest extends LogFormatterTestCase {
+ public static function provideContentModelLogDatabaseRows() {
+ return [
+ [
+ [
+ 'type' => 'contentmodel',
+ 'action' => 'new',
+ 'comment' => 'new content model comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ContentModelPage',
+ 'params' => [
+ '5::newModel' => 'testcontentmodel',
+ ],
+ ],
+ [
+ 'text' => 'User created the page ContentModelPage ' .
+ 'using a non-default content model ' .
+ '"testcontentmodel"',
+ 'api' => [
+ 'newModel' => 'testcontentmodel',
+ ],
+ ],
+ ],
+ [
+ [
+ 'type' => 'contentmodel',
+ 'action' => 'change',
+ 'comment' => 'change content model comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ContentModelPage',
+ 'params' => [
+ '4::oldmodel' => 'wikitext',
+ '5::newModel' => 'testcontentmodel',
+ ],
+ ],
+ [
+ 'text' => 'User changed the content model of the page ' .
+ 'ContentModelPage from "wikitext" to ' .
+ '"testcontentmodel"',
+ 'api' => [
+ 'oldmodel' => 'wikitext',
+ 'newModel' => 'testcontentmodel',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideContentModelLogDatabaseRows
+ */
+ public function testContentModelLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/DatabaseLogEntryTest.php b/www/wiki/tests/phpunit/includes/logging/DatabaseLogEntryTest.php
new file mode 100644
index 00000000..4af1742e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/DatabaseLogEntryTest.php
@@ -0,0 +1,162 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\IDatabase;
+
+class DatabaseLogEntryTest extends MediaWikiTestCase {
+ public function setUp() {
+ parent::setUp();
+
+ // These services cache their joins
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'CommentStore' );
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'ActorMigration' );
+ }
+
+ public function tearDown() {
+ parent::tearDown();
+
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'CommentStore' );
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'ActorMigration' );
+ }
+
+ /**
+ * @covers DatabaseLogEntry::newFromId
+ * @covers DatabaseLogEntry::getSelectQueryData
+ *
+ * @dataProvider provideNewFromId
+ *
+ * @param int $id
+ * @param array $selectFields
+ * @param string[]|null $row
+ * @param string[]|null $expectedFields
+ * @param string $migration
+ */
+ public function testNewFromId( $id,
+ array $selectFields,
+ array $row = null,
+ array $expectedFields = null,
+ $migration
+ ) {
+ $this->setMwGlobals( [
+ 'wgCommentTableSchemaMigrationStage' => $migration,
+ 'wgActorTableSchemaMigrationStage' => $migration,
+ ] );
+
+ $row = $row ? (object)$row : null;
+ $db = $this->getMock( IDatabase::class );
+ $db->expects( self::once() )
+ ->method( 'selectRow' )
+ ->with( $selectFields['tables'],
+ $selectFields['fields'],
+ $selectFields['conds'],
+ 'DatabaseLogEntry::newFromId',
+ $selectFields['options'],
+ $selectFields['join_conds']
+ )
+ ->will( self::returnValue( $row ) );
+
+ /** @var IDatabase $db */
+ $logEntry = DatabaseLogEntry::newFromId( $id, $db );
+
+ if ( !$expectedFields ) {
+ self::assertNull( $logEntry, "Expected no log entry returned for id=$id" );
+ } else {
+ self::assertEquals( $id, $logEntry->getId() );
+ self::assertEquals( $expectedFields['type'], $logEntry->getType() );
+ self::assertEquals( $expectedFields['comment'], $logEntry->getComment() );
+ }
+ }
+
+ public function provideNewFromId() {
+ $oldTables = [
+ 'tables' => [ 'logging', 'user' ],
+ 'fields' => [
+ 'log_id',
+ 'log_type',
+ 'log_action',
+ 'log_timestamp',
+ 'log_namespace',
+ 'log_title',
+ 'log_params',
+ 'log_deleted',
+ 'user_id',
+ 'user_name',
+ 'user_editcount',
+ 'log_comment_text' => 'log_comment',
+ 'log_comment_data' => 'NULL',
+ 'log_comment_cid' => 'NULL',
+ 'log_user' => 'log_user',
+ 'log_user_text' => 'log_user_text',
+ 'log_actor' => 'NULL',
+ ],
+ 'options' => [],
+ 'join_conds' => [ 'user' => [ 'LEFT JOIN', 'user_id=log_user' ] ],
+ ];
+ $newTables = [
+ 'tables' => [
+ 'logging',
+ 'user',
+ 'comment_log_comment' => 'comment',
+ 'actor_log_user' => 'actor'
+ ],
+ 'fields' => [
+ 'log_id',
+ 'log_type',
+ 'log_action',
+ 'log_timestamp',
+ 'log_namespace',
+ 'log_title',
+ 'log_params',
+ 'log_deleted',
+ 'user_id',
+ 'user_name',
+ 'user_editcount',
+ 'log_comment_text' => 'comment_log_comment.comment_text',
+ 'log_comment_data' => 'comment_log_comment.comment_data',
+ 'log_comment_cid' => 'comment_log_comment.comment_id',
+ 'log_user' => 'actor_log_user.actor_user',
+ 'log_user_text' => 'actor_log_user.actor_name',
+ 'log_actor' => 'log_actor',
+ ],
+ 'options' => [],
+ 'join_conds' => [
+ 'user' => [ 'LEFT JOIN', 'user_id=actor_log_user.actor_user' ],
+ 'comment_log_comment' => [ 'JOIN', 'comment_log_comment.comment_id = log_comment_id' ],
+ 'actor_log_user' => [ 'JOIN', 'actor_log_user.actor_id = log_actor' ],
+ ],
+ ];
+ return [
+ [
+ 0,
+ $oldTables + [ 'conds' => [ 'log_id' => 0 ] ],
+ null,
+ null,
+ MIGRATION_OLD,
+ ],
+ [
+ 123,
+ $oldTables + [ 'conds' => [ 'log_id' => 123 ] ],
+ [
+ 'log_id' => 123,
+ 'log_type' => 'foobarize',
+ 'log_comment_text' => 'test!',
+ 'log_comment_data' => null,
+ ],
+ [ 'type' => 'foobarize', 'comment' => 'test!' ],
+ MIGRATION_OLD,
+ ],
+ [
+ 567,
+ $newTables + [ 'conds' => [ 'log_id' => 567 ] ],
+ [
+ 'log_id' => 567,
+ 'log_type' => 'foobarize',
+ 'log_comment_text' => 'test!',
+ 'log_comment_data' => null,
+ ],
+ [ 'type' => 'foobarize', 'comment' => 'test!' ],
+ MIGRATION_NEW,
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/DeleteLogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/DeleteLogFormatterTest.php
new file mode 100644
index 00000000..0e6855d9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/DeleteLogFormatterTest.php
@@ -0,0 +1,556 @@
+<?php
+
+/**
+ * @covers DeleteLogFormatter
+ */
+class DeleteLogFormatterTest extends LogFormatterTestCase {
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideDeleteLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'delete',
+ 'action' => 'delete',
+ 'comment' => 'delete comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [],
+ ],
+ [
+ 'text' => 'User deleted page Page',
+ 'api' => [],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'delete',
+ 'action' => 'delete',
+ 'comment' => 'delete comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User deleted page Page',
+ 'api' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideDeleteLogDatabaseRows
+ */
+ public function testDeleteLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideRestoreLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'delete',
+ 'action' => 'restore',
+ 'comment' => 'delete comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ ':assoc:count' => [
+ 'revisions' => 2,
+ 'files' => 1,
+ ],
+ ],
+ ],
+ [
+ 'text' => 'User restored page Page (2 revisions and 1 file)',
+ 'api' => [
+ 'count' => [
+ 'revisions' => 2,
+ 'files' => 1,
+ ],
+ ],
+ ],
+ ],
+
+ // Legacy format without counts
+ [
+ [
+ 'type' => 'delete',
+ 'action' => 'restore',
+ 'comment' => 'delete comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [],
+ ],
+ [
+ 'text' => 'User restored page Page',
+ 'api' => [],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'delete',
+ 'action' => 'restore',
+ 'comment' => 'delete comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User restored page Page',
+ 'api' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideRestoreLogDatabaseRows
+ */
+ public function testRestoreLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideRevisionLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'delete',
+ 'action' => 'revision',
+ 'comment' => 'delete comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ '4::type' => 'archive',
+ '5::ids' => [ '1', '3', '4' ],
+ '6::ofield' => '1',
+ '7::nfield' => '2',
+ ],
+ ],
+ [
+ 'text' => 'User changed visibility of 3 revisions on page Page: edit summary '
+ . 'hidden and content unhidden',
+ 'api' => [
+ 'type' => 'archive',
+ 'ids' => [ '1', '3', '4' ],
+ 'old' => [
+ 'bitmask' => 1,
+ 'content' => true,
+ 'comment' => false,
+ 'user' => false,
+ 'restricted' => false,
+ ],
+ 'new' => [
+ 'bitmask' => 2,
+ 'content' => false,
+ 'comment' => true,
+ 'user' => false,
+ 'restricted' => false,
+ ],
+ ],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'delete',
+ 'action' => 'revision',
+ 'comment' => 'delete comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ 'archive',
+ '1,3,4',
+ 'ofield=1',
+ 'nfield=2',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User changed visibility of 3 revisions on page Page: edit summary '
+ . 'hidden and content unhidden',
+ 'api' => [
+ 'type' => 'archive',
+ 'ids' => [ '1', '3', '4' ],
+ 'old' => [
+ 'bitmask' => 1,
+ 'content' => true,
+ 'comment' => false,
+ 'user' => false,
+ 'restricted' => false,
+ ],
+ 'new' => [
+ 'bitmask' => 2,
+ 'content' => false,
+ 'comment' => true,
+ 'user' => false,
+ 'restricted' => false,
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideRevisionLogDatabaseRows
+ */
+ public function testRevisionLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideEventLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'delete',
+ 'action' => 'event',
+ 'comment' => 'delete comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ '4::ids' => [ '1', '3', '4' ],
+ '5::ofield' => '1',
+ '6::nfield' => '2',
+ ],
+ ],
+ [
+ 'text' => 'User changed visibility of 3 log events on Page: edit summary hidden '
+ . 'and content unhidden',
+ 'api' => [
+ 'type' => 'logging',
+ 'ids' => [ '1', '3', '4' ],
+ 'old' => [
+ 'bitmask' => 1,
+ 'content' => true,
+ 'comment' => false,
+ 'user' => false,
+ 'restricted' => false,
+ ],
+ 'new' => [
+ 'bitmask' => 2,
+ 'content' => false,
+ 'comment' => true,
+ 'user' => false,
+ 'restricted' => false,
+ ],
+ ],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'delete',
+ 'action' => 'event',
+ 'comment' => 'delete comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ '1,3,4',
+ 'ofield=1',
+ 'nfield=2',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User changed visibility of 3 log events on Page: edit summary hidden '
+ . 'and content unhidden',
+ 'api' => [
+ 'type' => 'logging',
+ 'ids' => [ '1', '3', '4' ],
+ 'old' => [
+ 'bitmask' => 1,
+ 'content' => true,
+ 'comment' => false,
+ 'user' => false,
+ 'restricted' => false,
+ ],
+ 'new' => [
+ 'bitmask' => 2,
+ 'content' => false,
+ 'comment' => true,
+ 'user' => false,
+ 'restricted' => false,
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideEventLogDatabaseRows
+ */
+ public function testEventLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideSuppressRevisionLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'suppress',
+ 'action' => 'revision',
+ 'comment' => 'Suppress comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ '4::type' => 'archive',
+ '5::ids' => [ '1', '3', '4' ],
+ '6::ofield' => '1',
+ '7::nfield' => '10',
+ ],
+ ],
+ [
+ 'text' => 'User secretly changed visibility of 3 revisions on page Page: edit '
+ . 'summary hidden, content unhidden and applied restrictions to administrators',
+ 'api' => [
+ 'type' => 'archive',
+ 'ids' => [ '1', '3', '4' ],
+ 'old' => [
+ 'bitmask' => 1,
+ 'content' => true,
+ 'comment' => false,
+ 'user' => false,
+ 'restricted' => false,
+ ],
+ 'new' => [
+ 'bitmask' => 10,
+ 'content' => false,
+ 'comment' => true,
+ 'user' => false,
+ 'restricted' => true,
+ ],
+ ],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'suppress',
+ 'action' => 'revision',
+ 'comment' => 'Suppress comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ 'archive',
+ '1,3,4',
+ 'ofield=1',
+ 'nfield=10',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User secretly changed visibility of 3 revisions on page Page: edit '
+ . 'summary hidden, content unhidden and applied restrictions to administrators',
+ 'api' => [
+ 'type' => 'archive',
+ 'ids' => [ '1', '3', '4' ],
+ 'old' => [
+ 'bitmask' => 1,
+ 'content' => true,
+ 'comment' => false,
+ 'user' => false,
+ 'restricted' => false,
+ ],
+ 'new' => [
+ 'bitmask' => 10,
+ 'content' => false,
+ 'comment' => true,
+ 'user' => false,
+ 'restricted' => true,
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideSuppressRevisionLogDatabaseRows
+ */
+ public function testSuppressRevisionLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideSuppressEventLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'suppress',
+ 'action' => 'event',
+ 'comment' => 'Suppress comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ '4::ids' => [ '1', '3', '4' ],
+ '5::ofield' => '1',
+ '6::nfield' => '10',
+ ],
+ ],
+ [
+ 'text' => 'User secretly changed visibility of 3 log events on Page: edit '
+ . 'summary hidden, content unhidden and applied restrictions to administrators',
+ 'api' => [
+ 'type' => 'logging',
+ 'ids' => [ '1', '3', '4' ],
+ 'old' => [
+ 'bitmask' => 1,
+ 'content' => true,
+ 'comment' => false,
+ 'user' => false,
+ 'restricted' => false,
+ ],
+ 'new' => [
+ 'bitmask' => 10,
+ 'content' => false,
+ 'comment' => true,
+ 'user' => false,
+ 'restricted' => true,
+ ],
+ ],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'suppress',
+ 'action' => 'event',
+ 'comment' => 'Suppress comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ '1,3,4',
+ 'ofield=1',
+ 'nfield=10',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User secretly changed visibility of 3 log events on Page: edit '
+ . 'summary hidden, content unhidden and applied restrictions to administrators',
+ 'api' => [
+ 'type' => 'logging',
+ 'ids' => [ '1', '3', '4' ],
+ 'old' => [
+ 'bitmask' => 1,
+ 'content' => true,
+ 'comment' => false,
+ 'user' => false,
+ 'restricted' => false,
+ ],
+ 'new' => [
+ 'bitmask' => 10,
+ 'content' => false,
+ 'comment' => true,
+ 'user' => false,
+ 'restricted' => true,
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideSuppressEventLogDatabaseRows
+ */
+ public function testSuppressEventLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideSuppressDeleteLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'suppress',
+ 'action' => 'delete',
+ 'comment' => 'delete comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [],
+ ],
+ [
+ 'text' => 'User suppressed page Page',
+ 'api' => [],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'suppress',
+ 'action' => 'delete',
+ 'comment' => 'delete comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User suppressed page Page',
+ 'api' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideSuppressDeleteLogDatabaseRows
+ */
+ public function testSuppressDeleteLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/ImportLogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/ImportLogFormatterTest.php
new file mode 100644
index 00000000..80e4c0bc
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/ImportLogFormatterTest.php
@@ -0,0 +1,126 @@
+<?php
+
+/**
+ * @covers ImportLogFormatter
+ */
+class ImportLogFormatterTest extends LogFormatterTestCase {
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideUploadLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'import',
+ 'action' => 'upload',
+ 'comment' => 'upload comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ImportPage',
+ 'params' => [
+ '4:number:count' => '1',
+ ],
+ ],
+ [
+ 'text' => 'User imported ImportPage by file upload (1 revision)',
+ 'api' => [
+ 'count' => 1,
+ ],
+ ],
+ ],
+
+ // old format - without details
+ [
+ [
+ 'type' => 'import',
+ 'action' => 'upload',
+ 'comment' => '1 revision: import comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ImportPage',
+ 'params' => [],
+ ],
+ [
+ 'text' => 'User imported ImportPage by file upload',
+ 'api' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideUploadLogDatabaseRows
+ */
+ public function testUploadLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideInterwikiLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'import',
+ 'action' => 'interwiki',
+ 'comment' => 'interwiki comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ImportPage',
+ 'params' => [
+ '4:number:count' => '1',
+ '5:title-link:interwiki' => 'importiw:PageImport',
+ ],
+ ],
+ [
+ 'text' => 'User imported ImportPage from importiw:PageImport (1 revision)',
+ 'api' => [
+ 'count' => 1,
+ 'interwiki_ns' => 0,
+ 'interwiki_title' => 'importiw:PageImport',
+ ],
+ ],
+ ],
+
+ // old format - without details
+ [
+ [
+ 'type' => 'import',
+ 'action' => 'interwiki',
+ 'comment' => '1 revision from importiw:PageImport: interwiki comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ImportPage',
+ 'params' => [],
+ ],
+ [
+ 'text' => 'User imported ImportPage from another wiki',
+ 'api' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideInterwikiLogDatabaseRows
+ */
+ public function testInterwikiLogDatabaseRows( $row, $extra ) {
+ // Setup importiw: as interwiki prefix
+ $this->setMwGlobals( 'wgHooks', [
+ 'InterwikiLoadPrefix' => [
+ function ( $prefix, &$data ) {
+ if ( $prefix == 'importiw' ) {
+ $data = [ 'iw_url' => 'wikipedia' ];
+ }
+ return false;
+ }
+ ]
+ ] );
+
+ $this->doTestLogFormatter( $row, $extra );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/LogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/LogFormatterTest.php
new file mode 100644
index 00000000..e523a31e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/LogFormatterTest.php
@@ -0,0 +1,664 @@
+<?php
+
+/**
+ * @group Database
+ */
+class LogFormatterTest extends MediaWikiLangTestCase {
+ private static $oldExtMsgFiles;
+
+ /**
+ * @var User
+ */
+ protected $user;
+
+ /**
+ * @var Title
+ */
+ protected $title;
+
+ /**
+ * @var RequestContext
+ */
+ protected $context;
+
+ /**
+ * @var Title
+ */
+ protected $target;
+
+ /**
+ * @var string
+ */
+ protected $user_comment;
+
+ public static function setUpBeforeClass() {
+ parent::setUpBeforeClass();
+
+ global $wgExtensionMessagesFiles;
+ self::$oldExtMsgFiles = $wgExtensionMessagesFiles;
+ $wgExtensionMessagesFiles['LogTests'] = __DIR__ . '/LogTests.i18n.php';
+ Language::getLocalisationCache()->recache( 'en' );
+ }
+
+ public static function tearDownAfterClass() {
+ global $wgExtensionMessagesFiles;
+ $wgExtensionMessagesFiles = self::$oldExtMsgFiles;
+ Language::getLocalisationCache()->recache( 'en' );
+
+ parent::tearDownAfterClass();
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgLogTypes' => [ 'phpunit' ],
+ 'wgLogActionsHandlers' => [ 'phpunit/test' => LogFormatter::class,
+ 'phpunit/param' => LogFormatter::class ],
+ 'wgUser' => User::newFromName( 'Testuser' ),
+ ] );
+
+ $this->user = User::newFromName( 'Testuser' );
+ $this->title = Title::newFromText( 'SomeTitle' );
+ $this->target = Title::newFromText( 'TestTarget' );
+
+ $this->context = new RequestContext();
+ $this->context->setUser( $this->user );
+ $this->context->setTitle( $this->title );
+ $this->context->setLanguage( RequestContext::getMain()->getLanguage() );
+
+ $this->user_comment = '<User comment about action>';
+ }
+
+ public function newLogEntry( $action, $params ) {
+ $logEntry = new ManualLogEntry( 'phpunit', $action );
+ $logEntry->setPerformer( $this->user );
+ $logEntry->setTarget( $this->title );
+ $logEntry->setComment( 'A very good reason' );
+
+ $logEntry->setParameters( $params );
+
+ return $logEntry;
+ }
+
+ /**
+ * @covers LogFormatter::newFromEntry
+ */
+ public function testNormalLogParams() {
+ $entry = $this->newLogEntry( 'test', [] );
+ $formatter = LogFormatter::newFromEntry( $entry );
+ $formatter->setContext( $this->context );
+
+ $formatter->setShowUserToolLinks( false );
+ $paramsWithoutTools = $formatter->getMessageParametersForTesting();
+ unset( $formatter->parsedParameters );
+
+ $formatter->setShowUserToolLinks( true );
+ $paramsWithTools = $formatter->getMessageParametersForTesting();
+
+ $userLink = Linker::userLink(
+ $this->user->getId(),
+ $this->user->getName()
+ );
+
+ $userTools = Linker::userToolLinksRedContribs(
+ $this->user->getId(),
+ $this->user->getName(),
+ $this->user->getEditCount()
+ );
+
+ $titleLink = Linker::link( $this->title, null, [], [] );
+
+ // $paramsWithoutTools and $paramsWithTools should be only different
+ // in index 0
+ $this->assertEquals( $paramsWithoutTools[1], $paramsWithTools[1] );
+ $this->assertEquals( $paramsWithoutTools[2], $paramsWithTools[2] );
+
+ $this->assertEquals( $userLink, $paramsWithoutTools[0]['raw'] );
+ $this->assertEquals( $userLink . $userTools, $paramsWithTools[0]['raw'] );
+
+ $this->assertEquals( $this->user->getName(), $paramsWithoutTools[1] );
+
+ $this->assertEquals( $titleLink, $paramsWithoutTools[2]['raw'] );
+ }
+
+ /**
+ * @covers LogFormatter::newFromEntry
+ * @covers LogFormatter::getActionText
+ */
+ public function testLogParamsTypeRaw() {
+ $params = [ '4:raw:raw' => Linker::link( $this->title, null, [], [] ) ];
+ $expected = Linker::link( $this->title, null, [], [] );
+
+ $entry = $this->newLogEntry( 'param', $params );
+ $formatter = LogFormatter::newFromEntry( $entry );
+ $formatter->setContext( $this->context );
+
+ $logParam = $formatter->getActionText();
+
+ $this->assertEquals( $expected, $logParam );
+ }
+
+ /**
+ * @covers LogFormatter::newFromEntry
+ * @covers LogFormatter::getActionText
+ */
+ public function testLogParamsTypeMsg() {
+ $params = [ '4:msg:msg' => 'log-description-phpunit' ];
+ $expected = wfMessage( 'log-description-phpunit' )->text();
+
+ $entry = $this->newLogEntry( 'param', $params );
+ $formatter = LogFormatter::newFromEntry( $entry );
+ $formatter->setContext( $this->context );
+
+ $logParam = $formatter->getActionText();
+
+ $this->assertEquals( $expected, $logParam );
+ }
+
+ /**
+ * @covers LogFormatter::newFromEntry
+ * @covers LogFormatter::getActionText
+ */
+ public function testLogParamsTypeMsgContent() {
+ $params = [ '4:msg-content:msgContent' => 'log-description-phpunit' ];
+ $expected = wfMessage( 'log-description-phpunit' )->inContentLanguage()->text();
+
+ $entry = $this->newLogEntry( 'param', $params );
+ $formatter = LogFormatter::newFromEntry( $entry );
+ $formatter->setContext( $this->context );
+
+ $logParam = $formatter->getActionText();
+
+ $this->assertEquals( $expected, $logParam );
+ }
+
+ /**
+ * @covers LogFormatter::newFromEntry
+ * @covers LogFormatter::getActionText
+ */
+ public function testLogParamsTypeNumber() {
+ global $wgLang;
+
+ $params = [ '4:number:number' => 123456789 ];
+ $expected = $wgLang->formatNum( 123456789 );
+
+ $entry = $this->newLogEntry( 'param', $params );
+ $formatter = LogFormatter::newFromEntry( $entry );
+ $formatter->setContext( $this->context );
+
+ $logParam = $formatter->getActionText();
+
+ $this->assertEquals( $expected, $logParam );
+ }
+
+ /**
+ * @covers LogFormatter::newFromEntry
+ * @covers LogFormatter::getActionText
+ */
+ public function testLogParamsTypeUserLink() {
+ $params = [ '4:user-link:userLink' => $this->user->getName() ];
+ $expected = Linker::userLink(
+ $this->user->getId(),
+ $this->user->getName()
+ );
+
+ $entry = $this->newLogEntry( 'param', $params );
+ $formatter = LogFormatter::newFromEntry( $entry );
+ $formatter->setContext( $this->context );
+
+ $logParam = $formatter->getActionText();
+
+ $this->assertEquals( $expected, $logParam );
+ }
+
+ /**
+ * @covers LogFormatter::newFromEntry
+ * @covers LogFormatter::getActionText
+ */
+ public function testLogParamsTypeTitleLink() {
+ $params = [ '4:title-link:titleLink' => $this->title->getText() ];
+ $expected = Linker::link( $this->title, null, [], [] );
+
+ $entry = $this->newLogEntry( 'param', $params );
+ $formatter = LogFormatter::newFromEntry( $entry );
+ $formatter->setContext( $this->context );
+
+ $logParam = $formatter->getActionText();
+
+ $this->assertEquals( $expected, $logParam );
+ }
+
+ /**
+ * @covers LogFormatter::newFromEntry
+ * @covers LogFormatter::getActionText
+ */
+ public function testLogParamsTypePlain() {
+ $params = [ '4:plain:plain' => 'Some plain text' ];
+ $expected = 'Some plain text';
+
+ $entry = $this->newLogEntry( 'param', $params );
+ $formatter = LogFormatter::newFromEntry( $entry );
+ $formatter->setContext( $this->context );
+
+ $logParam = $formatter->getActionText();
+
+ $this->assertEquals( $expected, $logParam );
+ }
+
+ /**
+ * @covers LogFormatter::newFromEntry
+ * @covers LogFormatter::getComment
+ */
+ public function testLogComment() {
+ $entry = $this->newLogEntry( 'test', [] );
+ $formatter = LogFormatter::newFromEntry( $entry );
+ $formatter->setContext( $this->context );
+
+ $comment = ltrim( Linker::commentBlock( $entry->getComment() ) );
+
+ $this->assertEquals( $comment, $formatter->getComment() );
+ }
+
+ /**
+ * @dataProvider provideApiParamFormatting
+ * @covers LogFormatter::formatParametersForApi
+ * @covers LogFormatter::formatParameterValueForApi
+ */
+ public function testApiParamFormatting( $key, $value, $expected ) {
+ $entry = $this->newLogEntry( 'param', [ $key => $value ] );
+ $formatter = LogFormatter::newFromEntry( $entry );
+ $formatter->setContext( $this->context );
+
+ ApiResult::setIndexedTagName( $expected, 'param' );
+ ApiResult::setArrayType( $expected, 'assoc' );
+
+ $this->assertEquals( $expected, $formatter->formatParametersForApi() );
+ }
+
+ public static function provideApiParamFormatting() {
+ return [
+ [ 0, 'value', [ 'value' ] ],
+ [ 'named', 'value', [ 'named' => 'value' ] ],
+ [ '::key', 'value', [ 'key' => 'value' ] ],
+ [ '4::key', 'value', [ 'key' => 'value' ] ],
+ [ '4:raw:key', 'value', [ 'key' => 'value' ] ],
+ [ '4:plain:key', 'value', [ 'key' => 'value' ] ],
+ [ '4:bool:key', '1', [ 'key' => true ] ],
+ [ '4:bool:key', '0', [ 'key' => false ] ],
+ [ '4:number:key', '123', [ 'key' => 123 ] ],
+ [ '4:number:key', '123.5', [ 'key' => 123.5 ] ],
+ [ '4:array:key', [], [ 'key' => [ ApiResult::META_TYPE => 'array' ] ] ],
+ [ '4:assoc:key', [], [ 'key' => [ ApiResult::META_TYPE => 'assoc' ] ] ],
+ [ '4:kvp:key', [], [ 'key' => [ ApiResult::META_TYPE => 'kvp' ] ] ],
+ [ '4:timestamp:key', '20150102030405', [ 'key' => '2015-01-02T03:04:05Z' ] ],
+ [ '4:msg:key', 'parentheses', [
+ 'key_key' => 'parentheses',
+ 'key_text' => wfMessage( 'parentheses' )->text(),
+ ] ],
+ [ '4:msg-content:key', 'parentheses', [
+ 'key_key' => 'parentheses',
+ 'key_text' => wfMessage( 'parentheses' )->inContentLanguage()->text(),
+ ] ],
+ [ '4:title:key', 'project:foo', [
+ 'key_ns' => NS_PROJECT,
+ 'key_title' => Title::newFromText( 'project:foo' )->getFullText(),
+ ] ],
+ [ '4:title-link:key', 'project:foo', [
+ 'key_ns' => NS_PROJECT,
+ 'key_title' => Title::newFromText( 'project:foo' )->getFullText(),
+ ] ],
+ [ '4:title-link:key', '<invalid>', [
+ 'key_ns' => NS_SPECIAL,
+ 'key_title' => SpecialPage::getTitleFor( 'Badtitle', '<invalid>' )->getFullText(),
+ ] ],
+ [ '4:user:key', 'foo', [ 'key' => 'Foo' ] ],
+ [ '4:user-link:key', 'foo', [ 'key' => 'Foo' ] ],
+ ];
+ }
+
+ /**
+ * The testIrcMsgForAction* tests are supposed to cover the hacky
+ * LogFormatter::getIRCActionText / T36508
+ *
+ * Third parties bots listen to those messages. They are clever enough
+ * to fetch the i18n messages from the wiki and then analyze the IRC feed
+ * to reverse engineer the $1, $2 messages.
+ * One thing bots can not detect is when MediaWiki change the meaning of
+ * a message like what happened when we deployed 1.19. $1 became the user
+ * performing the action which broke basically all bots around.
+ *
+ * Should cover the following log actions (which are most commonly used by bots):
+ * - block/block
+ * - block/unblock
+ * - block/reblock
+ * - delete/delete
+ * - delete/restore
+ * - newusers/create
+ * - newusers/create2
+ * - newusers/autocreate
+ * - move/move
+ * - move/move_redir
+ * - protect/protect
+ * - protect/modifyprotect
+ * - protect/unprotect
+ * - protect/move_prot
+ * - upload/upload
+ * - merge/merge
+ * - import/upload
+ * - import/interwiki
+ *
+ * As well as the following Auto Edit Summaries:
+ * - blank
+ * - replace
+ * - rollback
+ * - undo
+ */
+
+ /**
+ * @covers LogFormatter::getIRCActionComment
+ * @covers LogFormatter::getIRCActionText
+ */
+ public function testIrcMsgForLogTypeBlock() {
+ $sep = $this->context->msg( 'colon-separator' )->text();
+
+ # block/block
+ $this->assertIRCComment(
+ $this->context->msg( 'blocklogentry', 'SomeTitle', 'duration', '(flags)' )->plain()
+ . $sep . $this->user_comment,
+ 'block', 'block',
+ [
+ '5::duration' => 'duration',
+ '6::flags' => 'flags',
+ ],
+ $this->user_comment
+ );
+ # block/block - legacy
+ $this->assertIRCComment(
+ $this->context->msg( 'blocklogentry', 'SomeTitle', 'duration', '(flags)' )->plain()
+ . $sep . $this->user_comment,
+ 'block', 'block',
+ [
+ 'duration',
+ 'flags',
+ ],
+ $this->user_comment,
+ '',
+ true
+ );
+ # block/unblock
+ $this->assertIRCComment(
+ $this->context->msg( 'unblocklogentry', 'SomeTitle' )->plain() . $sep . $this->user_comment,
+ 'block', 'unblock',
+ [],
+ $this->user_comment
+ );
+ # block/reblock
+ $this->assertIRCComment(
+ $this->context->msg( 'reblock-logentry', 'SomeTitle', 'duration', '(flags)' )->plain()
+ . $sep . $this->user_comment,
+ 'block', 'reblock',
+ [
+ '5::duration' => 'duration',
+ '6::flags' => 'flags',
+ ],
+ $this->user_comment
+ );
+ }
+
+ /**
+ * @covers LogFormatter::getIRCActionComment
+ * @covers LogFormatter::getIRCActionText
+ */
+ public function testIrcMsgForLogTypeDelete() {
+ $sep = $this->context->msg( 'colon-separator' )->text();
+
+ # delete/delete
+ $this->assertIRCComment(
+ $this->context->msg( 'deletedarticle', 'SomeTitle' )->plain() . $sep . $this->user_comment,
+ 'delete', 'delete',
+ [],
+ $this->user_comment
+ );
+
+ # delete/restore
+ $this->assertIRCComment(
+ $this->context->msg( 'undeletedarticle', 'SomeTitle' )->plain() . $sep . $this->user_comment,
+ 'delete', 'restore',
+ [],
+ $this->user_comment
+ );
+ }
+
+ /**
+ * @covers LogFormatter::getIRCActionComment
+ * @covers LogFormatter::getIRCActionText
+ */
+ public function testIrcMsgForLogTypeNewusers() {
+ $this->assertIRCComment(
+ 'New user account',
+ 'newusers', 'newusers',
+ []
+ );
+ $this->assertIRCComment(
+ 'New user account',
+ 'newusers', 'create',
+ []
+ );
+ $this->assertIRCComment(
+ 'created new account SomeTitle',
+ 'newusers', 'create2',
+ []
+ );
+ $this->assertIRCComment(
+ 'Account created automatically',
+ 'newusers', 'autocreate',
+ []
+ );
+ }
+
+ /**
+ * @covers LogFormatter::getIRCActionComment
+ * @covers LogFormatter::getIRCActionText
+ */
+ public function testIrcMsgForLogTypeMove() {
+ $move_params = [
+ '4::target' => $this->target->getPrefixedText(),
+ '5::noredir' => 0,
+ ];
+ $sep = $this->context->msg( 'colon-separator' )->text();
+
+ # move/move
+ $this->assertIRCComment(
+ $this->context->msg( '1movedto2', 'SomeTitle', 'TestTarget' )
+ ->plain() . $sep . $this->user_comment,
+ 'move', 'move',
+ $move_params,
+ $this->user_comment
+ );
+
+ # move/move_redir
+ $this->assertIRCComment(
+ $this->context->msg( '1movedto2_redir', 'SomeTitle', 'TestTarget' )
+ ->plain() . $sep . $this->user_comment,
+ 'move', 'move_redir',
+ $move_params,
+ $this->user_comment
+ );
+ }
+
+ /**
+ * @covers LogFormatter::getIRCActionComment
+ * @covers LogFormatter::getIRCActionText
+ */
+ public function testIrcMsgForLogTypePatrol() {
+ # patrol/patrol
+ $this->assertIRCComment(
+ $this->context->msg( 'patrol-log-line', 'revision 777', '[[SomeTitle]]', '' )->plain(),
+ 'patrol', 'patrol',
+ [
+ '4::curid' => '777',
+ '5::previd' => '666',
+ '6::auto' => 0,
+ ]
+ );
+ }
+
+ /**
+ * @covers LogFormatter::getIRCActionComment
+ * @covers LogFormatter::getIRCActionText
+ */
+ public function testIrcMsgForLogTypeProtect() {
+ $protectParams = [
+ '4::description' => '[edit=sysop] (indefinite) ‎[move=sysop] (indefinite)'
+ ];
+ $sep = $this->context->msg( 'colon-separator' )->text();
+
+ # protect/protect
+ $this->assertIRCComment(
+ $this->context->msg( 'protectedarticle', 'SomeTitle ' . $protectParams['4::description'] )
+ ->plain() . $sep . $this->user_comment,
+ 'protect', 'protect',
+ $protectParams,
+ $this->user_comment
+ );
+
+ # protect/unprotect
+ $this->assertIRCComment(
+ $this->context->msg( 'unprotectedarticle', 'SomeTitle' )->plain() . $sep . $this->user_comment,
+ 'protect', 'unprotect',
+ [],
+ $this->user_comment
+ );
+
+ # protect/modify
+ $this->assertIRCComment(
+ $this->context->msg(
+ 'modifiedarticleprotection',
+ 'SomeTitle ' . $protectParams['4::description']
+ )->plain() . $sep . $this->user_comment,
+ 'protect', 'modify',
+ $protectParams,
+ $this->user_comment
+ );
+
+ # protect/move_prot
+ $this->assertIRCComment(
+ $this->context->msg( 'movedarticleprotection', 'SomeTitle', 'OldTitle' )
+ ->plain() . $sep . $this->user_comment,
+ 'protect', 'move_prot',
+ [
+ '4::oldtitle' => 'OldTitle'
+ ],
+ $this->user_comment
+ );
+ }
+
+ /**
+ * @covers LogFormatter::getIRCActionComment
+ * @covers LogFormatter::getIRCActionText
+ */
+ public function testIrcMsgForLogTypeUpload() {
+ $sep = $this->context->msg( 'colon-separator' )->text();
+
+ # upload/upload
+ $this->assertIRCComment(
+ $this->context->msg( 'uploadedimage', 'SomeTitle' )->plain() . $sep . $this->user_comment,
+ 'upload', 'upload',
+ [],
+ $this->user_comment
+ );
+
+ # upload/overwrite
+ $this->assertIRCComment(
+ $this->context->msg( 'overwroteimage', 'SomeTitle' )->plain() . $sep . $this->user_comment,
+ 'upload', 'overwrite',
+ [],
+ $this->user_comment
+ );
+ }
+
+ /**
+ * @covers LogFormatter::getIRCActionComment
+ * @covers LogFormatter::getIRCActionText
+ */
+ public function testIrcMsgForLogTypeMerge() {
+ $sep = $this->context->msg( 'colon-separator' )->text();
+
+ # merge/merge
+ $this->assertIRCComment(
+ $this->context->msg( 'pagemerge-logentry', 'SomeTitle', 'Dest', 'timestamp' )->plain()
+ . $sep . $this->user_comment,
+ 'merge', 'merge',
+ [
+ '4::dest' => 'Dest',
+ '5::mergepoint' => 'timestamp',
+ ],
+ $this->user_comment
+ );
+ }
+
+ /**
+ * @covers LogFormatter::getIRCActionComment
+ * @covers LogFormatter::getIRCActionText
+ */
+ public function testIrcMsgForLogTypeImport() {
+ $sep = $this->context->msg( 'colon-separator' )->text();
+
+ # import/upload
+ $msg = $this->context->msg( 'import-logentry-upload', 'SomeTitle' )->plain() .
+ $sep .
+ $this->user_comment;
+ $this->assertIRCComment(
+ $msg,
+ 'import', 'upload',
+ [],
+ $this->user_comment
+ );
+
+ # import/interwiki
+ $msg = $this->context->msg( 'import-logentry-interwiki', 'SomeTitle' )->plain() .
+ $sep .
+ $this->user_comment;
+ $this->assertIRCComment(
+ $msg,
+ 'import', 'interwiki',
+ [],
+ $this->user_comment
+ );
+ }
+
+ /**
+ * @param string $expected Expected IRC text without colors codes
+ * @param string $type Log type (move, delete, suppress, patrol ...)
+ * @param string $action A log type action
+ * @param array $params
+ * @param string $comment (optional) A comment for the log action
+ * @param string $msg (optional) A message for PHPUnit :-)
+ */
+ protected function assertIRCComment( $expected, $type, $action, $params,
+ $comment = null, $msg = '', $legacy = false
+ ) {
+ $logEntry = new ManualLogEntry( $type, $action );
+ $logEntry->setPerformer( $this->user );
+ $logEntry->setTarget( $this->title );
+ if ( $comment !== null ) {
+ $logEntry->setComment( $comment );
+ }
+ $logEntry->setParameters( $params );
+ $logEntry->setLegacy( $legacy );
+
+ $formatter = LogFormatter::newFromEntry( $logEntry );
+ $formatter->setContext( $this->context );
+
+ // Apply the same transformation as done in IRCColourfulRCFeedFormatter::getLine for rc_comment
+ $ircRcComment = IRCColourfulRCFeedFormatter::cleanupForIRC( $formatter->getIRCActionComment() );
+
+ $this->assertEquals(
+ $expected,
+ $ircRcComment,
+ $msg
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/LogFormatterTestCase.php b/www/wiki/tests/phpunit/includes/logging/LogFormatterTestCase.php
new file mode 100644
index 00000000..786d7619
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/LogFormatterTestCase.php
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @since 1.26
+ */
+abstract class LogFormatterTestCase extends MediaWikiLangTestCase {
+
+ public function doTestLogFormatter( $row, $extra ) {
+ RequestContext::resetMain();
+ $row = $this->expandDatabaseRow( $row, $this->isLegacy( $extra ) );
+
+ $formatter = LogFormatter::newFromRow( $row );
+
+ $this->assertEquals(
+ $extra['text'],
+ self::removeSomeHtml( $formatter->getActionText() ),
+ 'Action text is equal to expected text'
+ );
+
+ $this->assertSame( // ensure types and array key order
+ $extra['api'],
+ self::removeApiMetaData( $formatter->formatParametersForApi() ),
+ 'Api log params is equal to expected array'
+ );
+ }
+
+ protected function isLegacy( $extra ) {
+ return isset( $extra['legacy'] ) && $extra['legacy'];
+ }
+
+ protected function expandDatabaseRow( $data, $legacy ) {
+ return [
+ // no log_id because no insert in database
+ 'log_type' => $data['type'],
+ 'log_action' => $data['action'],
+ 'log_timestamp' => isset( $data['timestamp'] ) ? $data['timestamp'] : wfTimestampNow(),
+ 'log_user' => isset( $data['user'] ) ? $data['user'] : 0,
+ 'log_user_text' => isset( $data['user_text'] ) ? $data['user_text'] : 'User',
+ 'log_actor' => isset( $data['actor'] ) ? $data['actor'] : 0,
+ 'log_namespace' => isset( $data['namespace'] ) ? $data['namespace'] : NS_MAIN,
+ 'log_title' => isset( $data['title'] ) ? $data['title'] : 'Main_Page',
+ 'log_page' => isset( $data['page'] ) ? $data['page'] : 0,
+ 'log_comment_text' => isset( $data['comment'] ) ? $data['comment'] : '',
+ 'log_comment_data' => null,
+ 'log_params' => $legacy
+ ? LogPage::makeParamBlob( $data['params'] )
+ : LogEntryBase::makeParamBlob( $data['params'] ),
+ 'log_deleted' => isset( $data['deleted'] ) ? $data['deleted'] : 0,
+ ];
+ }
+
+ private static function removeSomeHtml( $html ) {
+ $html = str_replace( '&quot;', '"', $html );
+ $html = preg_replace( '/\xE2\x80[\x8E\x8F]/', '', $html ); // Strip lrm/rlm
+ return trim( strip_tags( $html ) );
+ }
+
+ private static function removeApiMetaData( $val ) {
+ if ( is_array( $val ) ) {
+ unset( $val['_element'] );
+ unset( $val['_type'] );
+ foreach ( $val as $key => $value ) {
+ $val[$key] = self::removeApiMetaData( $value );
+ }
+ }
+ return $val;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/LogTests.i18n.php b/www/wiki/tests/phpunit/includes/logging/LogTests.i18n.php
new file mode 100644
index 00000000..23e62b53
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/LogTests.i18n.php
@@ -0,0 +1,15 @@
+<?php
+/**
+ * Internationalisation file for log tests.
+ *
+ * @file
+ */
+
+$messages = [];
+
+$messages['en'] = [
+ 'log-name-phpunit' => 'PHPUnit-log',
+ 'log-description-phpunit' => 'Log for PHPUnit-tests',
+ 'logentry-phpunit-test' => '$1 {{GENDER:$2|tests}} with page $3',
+ 'logentry-phpunit-param' => '$4',
+];
diff --git a/www/wiki/tests/phpunit/includes/logging/MergeLogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/MergeLogFormatterTest.php
new file mode 100644
index 00000000..1978f1b5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/MergeLogFormatterTest.php
@@ -0,0 +1,70 @@
+<?php
+
+/**
+ * @covers MergeLogFormatter
+ */
+class MergeLogFormatterTest extends LogFormatterTestCase {
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideMergeLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'merge',
+ 'action' => 'merge',
+ 'comment' => 'Merge comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'OldPage',
+ 'params' => [
+ '4::dest' => 'NewPage',
+ '5::mergepoint' => '20140804160710',
+ ],
+ ],
+ [
+ 'text' => 'User merged OldPage into NewPage (revisions up to 16:07, 4 August 2014)',
+ 'api' => [
+ 'dest_ns' => 0,
+ 'dest_title' => 'NewPage',
+ 'mergepoint' => '2014-08-04T16:07:10Z',
+ ],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'merge',
+ 'action' => 'merge',
+ 'comment' => 'merge comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'OldPage',
+ 'params' => [
+ 'NewPage',
+ '20140804160710',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User merged OldPage into NewPage (revisions up to 16:07, 4 August 2014)',
+ 'api' => [
+ 'dest_ns' => 0,
+ 'dest_title' => 'NewPage',
+ 'mergepoint' => '2014-08-04T16:07:10Z',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideMergeLogDatabaseRows
+ */
+ public function testMergeLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/MoveLogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/MoveLogFormatterTest.php
new file mode 100644
index 00000000..ebda46b2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/MoveLogFormatterTest.php
@@ -0,0 +1,273 @@
+<?php
+
+/**
+ * @covers MoveLogFormatter
+ */
+class MoveLogFormatterTest extends LogFormatterTestCase {
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideMoveLogDatabaseRows() {
+ return [
+ // Current format - with redirect
+ [
+ [
+ 'type' => 'move',
+ 'action' => 'move',
+ 'comment' => 'move comment with redirect',
+ 'namespace' => NS_MAIN,
+ 'title' => 'OldPage',
+ 'params' => [
+ '4::target' => 'NewPage',
+ '5::noredir' => '0',
+ ],
+ ],
+ [
+ 'text' => 'User moved page OldPage to NewPage',
+ 'api' => [
+ 'target_ns' => 0,
+ 'target_title' => 'NewPage',
+ 'suppressredirect' => false,
+ ],
+ ],
+ ],
+
+ // Current format - without redirect
+ [
+ [
+ 'type' => 'move',
+ 'action' => 'move',
+ 'comment' => 'move comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'OldPage',
+ 'params' => [
+ '4::target' => 'NewPage',
+ '5::noredir' => '1',
+ ],
+ ],
+ [
+ 'text' => 'User moved page OldPage to NewPage without leaving a redirect',
+ 'api' => [
+ 'target_ns' => 0,
+ 'target_title' => 'NewPage',
+ 'suppressredirect' => true,
+ ],
+ ],
+ ],
+
+ // legacy format - with redirect
+ [
+ [
+ 'type' => 'move',
+ 'action' => 'move',
+ 'comment' => 'move comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'OldPage',
+ 'params' => [
+ 'NewPage',
+ '',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User moved page OldPage to NewPage',
+ 'api' => [
+ 'target_ns' => 0,
+ 'target_title' => 'NewPage',
+ 'suppressredirect' => false,
+ ],
+ ],
+ ],
+
+ // legacy format - without redirect
+ [
+ [
+ 'type' => 'move',
+ 'action' => 'move',
+ 'comment' => 'move comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'OldPage',
+ 'params' => [
+ 'NewPage',
+ '1',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User moved page OldPage to NewPage without leaving a redirect',
+ 'api' => [
+ 'target_ns' => 0,
+ 'target_title' => 'NewPage',
+ 'suppressredirect' => true,
+ ],
+ ],
+ ],
+
+ // old format without flag for redirect suppression
+ [
+ [
+ 'type' => 'move',
+ 'action' => 'move',
+ 'comment' => 'move comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'OldPage',
+ 'params' => [
+ 'NewPage',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User moved page OldPage to NewPage',
+ 'api' => [
+ 'target_ns' => 0,
+ 'target_title' => 'NewPage',
+ 'suppressredirect' => false,
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideMoveLogDatabaseRows
+ */
+ public function testMoveLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideMoveRedirLogDatabaseRows() {
+ return [
+ // Current format - with redirect
+ [
+ [
+ 'type' => 'move',
+ 'action' => 'move_redir',
+ 'comment' => 'move comment with redirect',
+ 'namespace' => NS_MAIN,
+ 'title' => 'OldPage',
+ 'params' => [
+ '4::target' => 'NewPage',
+ '5::noredir' => '0',
+ ],
+ ],
+ [
+ 'text' => 'User moved page OldPage to NewPage over redirect',
+ 'api' => [
+ 'target_ns' => 0,
+ 'target_title' => 'NewPage',
+ 'suppressredirect' => false,
+ ],
+ ],
+ ],
+
+ // Current format - without redirect
+ [
+ [
+ 'type' => 'move',
+ 'action' => 'move_redir',
+ 'comment' => 'move comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'OldPage',
+ 'params' => [
+ '4::target' => 'NewPage',
+ '5::noredir' => '1',
+ ],
+ ],
+ [
+ 'text' => 'User moved page OldPage to NewPage over a redirect without leaving a redirect',
+ 'api' => [
+ 'target_ns' => 0,
+ 'target_title' => 'NewPage',
+ 'suppressredirect' => true,
+ ],
+ ],
+ ],
+
+ // legacy format - with redirect
+ [
+ [
+ 'type' => 'move',
+ 'action' => 'move_redir',
+ 'comment' => 'move comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'OldPage',
+ 'params' => [
+ 'NewPage',
+ '',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User moved page OldPage to NewPage over redirect',
+ 'api' => [
+ 'target_ns' => 0,
+ 'target_title' => 'NewPage',
+ 'suppressredirect' => false,
+ ],
+ ],
+ ],
+
+ // legacy format - without redirect
+ [
+ [
+ 'type' => 'move',
+ 'action' => 'move_redir',
+ 'comment' => 'move comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'OldPage',
+ 'params' => [
+ 'NewPage',
+ '1',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User moved page OldPage to NewPage over a redirect without leaving a redirect',
+ 'api' => [
+ 'target_ns' => 0,
+ 'target_title' => 'NewPage',
+ 'suppressredirect' => true,
+ ],
+ ],
+ ],
+
+ // old format without flag for redirect suppression
+ [
+ [
+ 'type' => 'move',
+ 'action' => 'move_redir',
+ 'comment' => 'move comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'OldPage',
+ 'params' => [
+ 'NewPage',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User moved page OldPage to NewPage over redirect',
+ 'api' => [
+ 'target_ns' => 0,
+ 'target_title' => 'NewPage',
+ 'suppressredirect' => false,
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideMoveRedirLogDatabaseRows
+ */
+ public function testMoveRedirLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/NewUsersLogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/NewUsersLogFormatterTest.php
new file mode 100644
index 00000000..eee2981c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/NewUsersLogFormatterTest.php
@@ -0,0 +1,204 @@
+<?php
+
+/**
+ * @covers NewUsersLogFormatter
+ * @group Database
+ */
+class NewUsersLogFormatterTest extends LogFormatterTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ // Register LogHandler, see $wgNewUserLog in Setup.php
+ $this->mergeMwGlobalArrayValue( 'wgLogActionsHandlers', [
+ 'newusers/newusers' => NewUsersLogFormatter::class,
+ 'newusers/create' => NewUsersLogFormatter::class,
+ 'newusers/create2' => NewUsersLogFormatter::class,
+ 'newusers/byemail' => NewUsersLogFormatter::class,
+ 'newusers/autocreate' => NewUsersLogFormatter::class,
+ ] );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideNewUsersLogDatabaseRows() {
+ return [
+ // Only old logs
+ [
+ [
+ 'type' => 'newusers',
+ 'action' => 'newusers',
+ 'comment' => 'newusers comment',
+ 'user' => 0,
+ 'user_text' => 'New user',
+ 'namespace' => NS_USER,
+ 'title' => 'New user',
+ 'params' => [],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User account New user was created',
+ 'api' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideNewUsersLogDatabaseRows
+ */
+ public function testNewUsersLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideCreateLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'newusers',
+ 'action' => 'create',
+ 'comment' => 'newusers comment',
+ 'user' => 0,
+ 'user_text' => 'New user',
+ 'namespace' => NS_USER,
+ 'title' => 'New user',
+ 'params' => [
+ '4::userid' => 1,
+ ],
+ ],
+ [
+ 'text' => 'User account New user was created',
+ 'api' => [
+ 'userid' => 1,
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCreateLogDatabaseRows
+ */
+ public function testCreateLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideCreate2LogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'newusers',
+ 'action' => 'create2',
+ 'comment' => 'newusers comment',
+ 'user' => 0,
+ 'user_text' => 'User',
+ 'namespace' => NS_USER,
+ 'title' => 'UTSysop'
+ ],
+ [
+ 'text' => 'User account UTSysop was created by User'
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCreate2LogDatabaseRows
+ */
+ public function testCreate2LogDatabaseRows( $row, $extra ) {
+ // Make UTSysop user and use its user_id (sequence does not reset to 1 for postgres)
+ $user = static::getTestSysop()->getUser();
+ $row['params']['4::userid'] = $user->getId();
+ $extra['api']['userid'] = $user->getId();
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideByemailLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'newusers',
+ 'action' => 'byemail',
+ 'comment' => 'newusers comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'UTSysop'
+ ],
+ [
+ 'text' => 'User account UTSysop was created by Sysop and password was sent by email'
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideByemailLogDatabaseRows
+ */
+ public function testByemailLogDatabaseRows( $row, $extra ) {
+ // Make UTSysop user and use its user_id (sequence does not reset to 1 for postgres)
+ $user = static::getTestSysop()->getUser();
+ $row['params']['4::userid'] = $user->getId();
+ $extra['api']['userid'] = $user->getId();
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideAutocreateLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'newusers',
+ 'action' => 'autocreate',
+ 'comment' => 'newusers comment',
+ 'user' => 0,
+ 'user_text' => 'New user',
+ 'namespace' => NS_USER,
+ 'title' => 'New user',
+ 'params' => [
+ '4::userid' => 1,
+ ],
+ ],
+ [
+ 'text' => 'User account New user was created automatically',
+ 'api' => [
+ 'userid' => 1,
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideAutocreateLogDatabaseRows
+ */
+ public function testAutocreateLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/PageLangLogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/PageLangLogFormatterTest.php
new file mode 100644
index 00000000..33fd68f6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/PageLangLogFormatterTest.php
@@ -0,0 +1,56 @@
+<?php
+
+/**
+ * @covers PageLangLogFormatter
+ */
+class PageLangLogFormatterTest extends LogFormatterTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ // Disable cldr extension
+ $this->setMwGlobals( 'wgHooks', [] );
+ // Register LogHandler, see $wgPageLanguageUseDB in Setup.php
+ $this->mergeMwGlobalArrayValue( 'wgLogActionsHandlers', [
+ 'pagelang/pagelang' => PageLangLogFormatter::class,
+ ] );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function providePageLangLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'pagelang',
+ 'action' => 'pagelang',
+ 'comment' => 'page lang comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ '4::oldlanguage' => 'en',
+ '5::newlanguage' => 'de[def]',
+ ],
+ ],
+ [
+ 'text' => 'User changed the language of Page from English (en) to Deutsch (de) [default]',
+ 'api' => [
+ 'oldlanguage' => 'en',
+ 'newlanguage' => 'de[def]'
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePageLangLogDatabaseRows
+ */
+ public function testPageLangLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/PatrolLogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/PatrolLogFormatterTest.php
new file mode 100644
index 00000000..0d78ed9c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/PatrolLogFormatterTest.php
@@ -0,0 +1,121 @@
+<?php
+
+/**
+ * @covers PatrolLogFormatter
+ */
+class PatrolLogFormatterTest extends LogFormatterTestCase {
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function providePatrolLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'patrol',
+ 'action' => 'patrol',
+ 'comment' => 'patrol comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ '4::curid' => 2,
+ '5::previd' => 1,
+ '6::auto' => 0,
+ ],
+ ],
+ [
+ 'text' => 'User marked revision 2 of page Page patrolled',
+ 'api' => [
+ 'curid' => 2,
+ 'previd' => 1,
+ 'auto' => false,
+ ],
+ ],
+ ],
+
+ // Current format - autopatrol
+ [
+ [
+ 'type' => 'patrol',
+ 'action' => 'patrol',
+ 'comment' => 'patrol comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ '4::curid' => 2,
+ '5::previd' => 1,
+ '6::auto' => 1,
+ ],
+ ],
+ [
+ 'text' => 'User automatically marked revision 2 of page Page patrolled',
+ 'api' => [
+ 'curid' => 2,
+ 'previd' => 1,
+ 'auto' => true,
+ ],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'patrol',
+ 'action' => 'patrol',
+ 'comment' => 'patrol comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ '2',
+ '1',
+ '0',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User marked revision 2 of page Page patrolled',
+ 'api' => [
+ 'curid' => 2,
+ 'previd' => 1,
+ 'auto' => false,
+ ],
+ ],
+ ],
+
+ // Legacy format - autopatrol
+ [
+ [
+ 'type' => 'patrol',
+ 'action' => 'patrol',
+ 'comment' => 'patrol comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'Page',
+ 'params' => [
+ '2',
+ '1',
+ '1',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User automatically marked revision 2 of page Page patrolled',
+ 'api' => [
+ 'curid' => 2,
+ 'previd' => 1,
+ 'auto' => true,
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePatrolLogDatabaseRows
+ */
+ public function testPatrolLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/ProtectLogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/ProtectLogFormatterTest.php
new file mode 100644
index 00000000..1c076cab
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/ProtectLogFormatterTest.php
@@ -0,0 +1,431 @@
+<?php
+
+/**
+ * @covers ProtectLogFormatter
+ */
+class ProtectLogFormatterTest extends LogFormatterTestCase {
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideProtectLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'protect',
+ 'action' => 'protect',
+ 'comment' => 'protect comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ProtectPage',
+ 'params' => [
+ '4::description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ '5:bool:cascade' => false,
+ 'details' => [
+ [
+ 'type' => 'edit',
+ 'level' => 'sysop',
+ 'expiry' => 'infinity',
+ 'cascade' => false,
+ ],
+ [
+ 'type' => 'move',
+ 'level' => 'sysop',
+ 'expiry' => 'infinity',
+ 'cascade' => false,
+ ],
+ ],
+ ],
+ ],
+ [
+ 'text' => 'User protected ProtectPage [Edit=Allow only administrators] ' .
+ '(indefinite) [Move=Allow only administrators] (indefinite)',
+ 'api' => [
+ 'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ 'cascade' => false,
+ 'details' => [
+ [
+ 'type' => 'edit',
+ 'level' => 'sysop',
+ 'expiry' => 'infinite',
+ 'cascade' => false,
+ ],
+ [
+ 'type' => 'move',
+ 'level' => 'sysop',
+ 'expiry' => 'infinite',
+ 'cascade' => false,
+ ],
+ ],
+ ],
+ ],
+ ],
+
+ // Current format with cascade
+ [
+ [
+ 'type' => 'protect',
+ 'action' => 'protect',
+ 'comment' => 'protect comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ProtectPage',
+ 'params' => [
+ '4::description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ '5:bool:cascade' => true,
+ 'details' => [
+ [
+ 'type' => 'edit',
+ 'level' => 'sysop',
+ 'expiry' => 'infinity',
+ 'cascade' => true,
+ ],
+ [
+ 'type' => 'move',
+ 'level' => 'sysop',
+ 'expiry' => 'infinity',
+ 'cascade' => false,
+ ],
+ ],
+ ],
+ ],
+ [
+ 'text' => 'User protected ProtectPage [Edit=Allow only administrators] ' .
+ '(indefinite) [Move=Allow only administrators] (indefinite) [cascading]',
+ 'api' => [
+ 'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ 'cascade' => true,
+ 'details' => [
+ [
+ 'type' => 'edit',
+ 'level' => 'sysop',
+ 'expiry' => 'infinite',
+ 'cascade' => true,
+ ],
+ [
+ 'type' => 'move',
+ 'level' => 'sysop',
+ 'expiry' => 'infinite',
+ 'cascade' => false,
+ ],
+ ],
+ ],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'protect',
+ 'action' => 'protect',
+ 'comment' => 'protect comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ProtectPage',
+ 'params' => [
+ '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ '',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User protected ProtectPage [edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ 'api' => [
+ 'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ 'cascade' => false,
+ ],
+ ],
+ ],
+
+ // Legacy format with cascade
+ [
+ [
+ 'type' => 'protect',
+ 'action' => 'protect',
+ 'comment' => 'protect comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ProtectPage',
+ 'params' => [
+ '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ 'cascade',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User protected ProtectPage [edit=sysop] ' .
+ '(indefinite)[move=sysop] (indefinite) [cascading]',
+ 'api' => [
+ 'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ 'cascade' => true,
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideProtectLogDatabaseRows
+ */
+ public function testProtectLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideModifyLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'protect',
+ 'action' => 'modify',
+ 'comment' => 'protect comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ProtectPage',
+ 'params' => [
+ '4::description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ '5:bool:cascade' => false,
+ 'details' => [
+ [
+ 'type' => 'edit',
+ 'level' => 'sysop',
+ 'expiry' => 'infinity',
+ 'cascade' => false,
+ ],
+ [
+ 'type' => 'move',
+ 'level' => 'sysop',
+ 'expiry' => 'infinity',
+ 'cascade' => false,
+ ],
+ ],
+ ],
+ ],
+ [
+ 'text' => 'User changed protection level for ProtectPage ' .
+ '[Edit=Allow only administrators] ' .
+ '(indefinite) [Move=Allow only administrators] (indefinite)',
+ 'api' => [
+ 'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ 'cascade' => false,
+ 'details' => [
+ [
+ 'type' => 'edit',
+ 'level' => 'sysop',
+ 'expiry' => 'infinite',
+ 'cascade' => false,
+ ],
+ [
+ 'type' => 'move',
+ 'level' => 'sysop',
+ 'expiry' => 'infinite',
+ 'cascade' => false,
+ ],
+ ],
+ ],
+ ],
+ ],
+
+ // Current format with cascade
+ [
+ [
+ 'type' => 'protect',
+ 'action' => 'modify',
+ 'comment' => 'protect comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ProtectPage',
+ 'params' => [
+ '4::description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ '5:bool:cascade' => true,
+ 'details' => [
+ [
+ 'type' => 'edit',
+ 'level' => 'sysop',
+ 'expiry' => 'infinity',
+ 'cascade' => true,
+ ],
+ [
+ 'type' => 'move',
+ 'level' => 'sysop',
+ 'expiry' => 'infinity',
+ 'cascade' => false,
+ ],
+ ],
+ ],
+ ],
+ [
+ 'text' => 'User changed protection level for ProtectPage ' .
+ '[Edit=Allow only administrators] (indefinite) ' .
+ '[Move=Allow only administrators] (indefinite) [cascading]',
+ 'api' => [
+ 'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ 'cascade' => true,
+ 'details' => [
+ [
+ 'type' => 'edit',
+ 'level' => 'sysop',
+ 'expiry' => 'infinite',
+ 'cascade' => true,
+ ],
+ [
+ 'type' => 'move',
+ 'level' => 'sysop',
+ 'expiry' => 'infinite',
+ 'cascade' => false,
+ ],
+ ],
+ ],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'protect',
+ 'action' => 'modify',
+ 'comment' => 'protect comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ProtectPage',
+ 'params' => [
+ '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ '',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User changed protection level for ProtectPage ' .
+ '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ 'api' => [
+ 'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ 'cascade' => false,
+ ],
+ ],
+ ],
+
+ // Legacy format with cascade
+ [
+ [
+ 'type' => 'protect',
+ 'action' => 'modify',
+ 'comment' => 'protect comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ProtectPage',
+ 'params' => [
+ '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ 'cascade',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User changed protection level for ProtectPage ' .
+ '[edit=sysop] (indefinite)[move=sysop] (indefinite) [cascading]',
+ 'api' => [
+ 'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
+ 'cascade' => true,
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideModifyLogDatabaseRows
+ */
+ public function testModifyLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideUnprotectLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'protect',
+ 'action' => 'unprotect',
+ 'comment' => 'unprotect comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'ProtectPage',
+ 'params' => [],
+ ],
+ [
+ 'text' => 'User removed protection from ProtectPage',
+ 'api' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideUnprotectLogDatabaseRows
+ */
+ public function testUnprotectLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideMoveProtLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'protect',
+ 'action' => 'move_prot',
+ 'comment' => 'Move comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'NewPage',
+ 'params' => [
+ '4::oldtitle' => 'OldPage',
+ ],
+ ],
+ [
+ 'text' => 'User moved protection settings from OldPage to NewPage',
+ 'api' => [
+ 'oldtitle_ns' => 0,
+ 'oldtitle_title' => 'OldPage',
+ ],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'protect',
+ 'action' => 'move_prot',
+ 'comment' => 'Move comment',
+ 'namespace' => NS_MAIN,
+ 'title' => 'NewPage',
+ 'params' => [
+ 'OldPage',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'User moved protection settings from OldPage to NewPage',
+ 'api' => [
+ 'oldtitle_ns' => 0,
+ 'oldtitle_title' => 'OldPage',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideMoveProtLogDatabaseRows
+ */
+ public function testMoveProtLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/RightsLogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/RightsLogFormatterTest.php
new file mode 100644
index 00000000..d081c61b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/RightsLogFormatterTest.php
@@ -0,0 +1,219 @@
+<?php
+
+/**
+ * @covers RightsLogFormatter
+ */
+class RightsLogFormatterTest extends LogFormatterTestCase {
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideRightsLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'rights',
+ 'action' => 'rights',
+ 'comment' => 'rights comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'User',
+ 'params' => [
+ '4::oldgroups' => [],
+ '5::newgroups' => [ 'sysop', 'bureaucrat' ],
+ 'oldmetadata' => [],
+ 'newmetadata' => [
+ [ 'expiry' => null ],
+ [ 'expiry' => '20160101123456' ]
+ ],
+ ],
+ ],
+ [
+ 'text' => 'Sysop changed group membership for User from (none) to '
+ . 'bureaucrat (temporary, until 12:34, 1 January 2016) and administrator',
+ 'api' => [
+ 'oldgroups' => [],
+ 'newgroups' => [ 'sysop', 'bureaucrat' ],
+ 'oldmetadata' => [],
+ 'newmetadata' => [
+ [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+ [ 'group' => 'bureaucrat', 'expiry' => '2016-01-01T12:34:56Z' ],
+ ],
+ ],
+ ],
+ ],
+
+ // Previous format (oldgroups and newgroups as arrays, no metadata)
+ [
+ [
+ 'type' => 'rights',
+ 'action' => 'rights',
+ 'comment' => 'rights comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'User',
+ 'params' => [
+ '4::oldgroups' => [],
+ '5::newgroups' => [ 'sysop', 'bureaucrat' ],
+ ],
+ ],
+ [
+ 'text' => 'Sysop changed group membership for User from (none) to '
+ . 'administrator and bureaucrat',
+ 'api' => [
+ 'oldgroups' => [],
+ 'newgroups' => [ 'sysop', 'bureaucrat' ],
+ 'oldmetadata' => [],
+ 'newmetadata' => [
+ [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+ [ 'group' => 'bureaucrat', 'expiry' => 'infinity' ],
+ ],
+ ],
+ ],
+ ],
+
+ // Legacy format (oldgroups and newgroups as numeric-keyed strings)
+ [
+ [
+ 'type' => 'rights',
+ 'action' => 'rights',
+ 'comment' => 'rights comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'User',
+ 'params' => [
+ '',
+ 'sysop, bureaucrat',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'Sysop changed group membership for User from (none) to '
+ . 'administrator and bureaucrat',
+ 'api' => [
+ 'oldgroups' => [],
+ 'newgroups' => [ 'sysop', 'bureaucrat' ],
+ 'oldmetadata' => [],
+ 'newmetadata' => [
+ [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+ [ 'group' => 'bureaucrat', 'expiry' => 'infinity' ],
+ ],
+ ],
+ ],
+ ],
+
+ // Really old entry
+ [
+ [
+ 'type' => 'rights',
+ 'action' => 'rights',
+ 'comment' => 'rights comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'User',
+ 'params' => [],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'Sysop changed group membership for User',
+ 'api' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideRightsLogDatabaseRows
+ */
+ public function testRightsLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideAutopromoteLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'rights',
+ 'action' => 'autopromote',
+ 'comment' => 'rights comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Sysop',
+ 'params' => [
+ '4::oldgroups' => [ 'sysop' ],
+ '5::newgroups' => [ 'sysop', 'bureaucrat' ],
+ ],
+ ],
+ [
+ 'text' => 'Sysop was automatically promoted from administrator to '
+ . 'administrator and bureaucrat',
+ 'api' => [
+ 'oldgroups' => [ 'sysop' ],
+ 'newgroups' => [ 'sysop', 'bureaucrat' ],
+ 'oldmetadata' => [
+ [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+ ],
+ 'newmetadata' => [
+ [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+ [ 'group' => 'bureaucrat', 'expiry' => 'infinity' ],
+ ],
+ ],
+ ],
+ ],
+
+ // Legacy format
+ [
+ [
+ 'type' => 'rights',
+ 'action' => 'autopromote',
+ 'comment' => 'rights comment',
+ 'user' => 0,
+ 'user_text' => 'Sysop',
+ 'namespace' => NS_USER,
+ 'title' => 'Sysop',
+ 'params' => [
+ 'sysop',
+ 'sysop, bureaucrat',
+ ],
+ ],
+ [
+ 'legacy' => true,
+ 'text' => 'Sysop was automatically promoted from administrator to '
+ . 'administrator and bureaucrat',
+ 'api' => [
+ 'oldgroups' => [ 'sysop' ],
+ 'newgroups' => [ 'sysop', 'bureaucrat' ],
+ 'oldmetadata' => [
+ [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+ ],
+ 'newmetadata' => [
+ [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+ [ 'group' => 'bureaucrat', 'expiry' => 'infinity' ],
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideAutopromoteLogDatabaseRows
+ */
+ public function testAutopromoteLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/logging/UploadLogFormatterTest.php b/www/wiki/tests/phpunit/includes/logging/UploadLogFormatterTest.php
new file mode 100644
index 00000000..2b4067f1
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/logging/UploadLogFormatterTest.php
@@ -0,0 +1,169 @@
+<?php
+
+/**
+ * @covers UploadLogFormatter
+ */
+class UploadLogFormatterTest extends LogFormatterTestCase {
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideUploadLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'upload',
+ 'action' => 'upload',
+ 'comment' => 'upload comment',
+ 'namespace' => NS_FILE,
+ 'title' => 'File.png',
+ 'params' => [
+ 'img_sha1' => 'hash',
+ 'img_timestamp' => '20150101000000',
+ ],
+ ],
+ [
+ 'text' => 'User uploaded File:File.png',
+ 'api' => [
+ 'img_sha1' => 'hash',
+ 'img_timestamp' => '2015-01-01T00:00:00Z',
+ ],
+ ],
+ ],
+
+ // Old format without params
+ [
+ [
+ 'type' => 'upload',
+ 'action' => 'upload',
+ 'comment' => 'upload comment',
+ 'namespace' => NS_FILE,
+ 'title' => 'File.png',
+ 'params' => [],
+ ],
+ [
+ 'text' => 'User uploaded File:File.png',
+ 'api' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideUploadLogDatabaseRows
+ */
+ public function testUploadLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideOverwriteLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'upload',
+ 'action' => 'overwrite',
+ 'comment' => 'upload comment',
+ 'namespace' => NS_FILE,
+ 'title' => 'File.png',
+ 'params' => [
+ 'img_sha1' => 'hash',
+ 'img_timestamp' => '20150101000000',
+ ],
+ ],
+ [
+ 'text' => 'User uploaded a new version of File:File.png',
+ 'api' => [
+ 'img_sha1' => 'hash',
+ 'img_timestamp' => '2015-01-01T00:00:00Z',
+ ],
+ ],
+ ],
+
+ // Old format without params
+ [
+ [
+ 'type' => 'upload',
+ 'action' => 'overwrite',
+ 'comment' => 'upload comment',
+ 'namespace' => NS_FILE,
+ 'title' => 'File.png',
+ 'params' => [],
+ ],
+ [
+ 'text' => 'User uploaded a new version of File:File.png',
+ 'api' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideOverwriteLogDatabaseRows
+ */
+ public function testOverwriteLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+
+ /**
+ * Provide different rows from the logging table to test
+ * for backward compatibility.
+ * Do not change the existing data, just add a new database row
+ */
+ public static function provideRevertLogDatabaseRows() {
+ return [
+ // Current format
+ [
+ [
+ 'type' => 'upload',
+ 'action' => 'revert',
+ 'comment' => 'upload comment',
+ 'namespace' => NS_FILE,
+ 'title' => 'File.png',
+ 'params' => [
+ 'img_sha1' => 'hash',
+ 'img_timestamp' => '20150101000000',
+ ],
+ ],
+ [
+ 'text' => 'User uploaded File:File.png',
+ 'api' => [
+ 'img_sha1' => 'hash',
+ 'img_timestamp' => '2015-01-01T00:00:00Z',
+ ],
+ ],
+ ],
+
+ // Old format without params
+ [
+ [
+ 'type' => 'upload',
+ 'action' => 'revert',
+ 'comment' => 'upload comment',
+ 'namespace' => NS_FILE,
+ 'title' => 'File.png',
+ 'params' => [],
+ ],
+ [
+ 'text' => 'User uploaded File:File.png',
+ 'api' => [],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideRevertLogDatabaseRows
+ */
+ public function testRevertLogDatabaseRows( $row, $extra ) {
+ $this->doTestLogFormatter( $row, $extra );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/mail/MailAddressTest.php b/www/wiki/tests/phpunit/includes/mail/MailAddressTest.php
new file mode 100644
index 00000000..459f5cc4
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/mail/MailAddressTest.php
@@ -0,0 +1,77 @@
+<?php
+
+class MailAddressTest extends MediaWikiTestCase {
+
+ /**
+ * @covers MailAddress::__construct
+ */
+ public function testConstructor() {
+ $ma = new MailAddress( 'foo@bar.baz', 'UserName', 'Real name' );
+ $this->assertInstanceOf( MailAddress::class, $ma );
+ }
+
+ /**
+ * @covers MailAddress::newFromUser
+ */
+ public function testNewFromUser() {
+ if ( wfIsWindows() ) {
+ $this->markTestSkipped( 'This test only works on non-Windows platforms' );
+ }
+ $user = $this->createMock( User::class );
+ $user->expects( $this->any() )->method( 'getName' )->will(
+ $this->returnValue( 'UserName' )
+ );
+ $user->expects( $this->any() )->method( 'getEmail' )->will(
+ $this->returnValue( 'foo@bar.baz' )
+ );
+ $user->expects( $this->any() )->method( 'getRealName' )->will(
+ $this->returnValue( 'Real name' )
+ );
+
+ $ma = MailAddress::newFromUser( $user );
+ $this->assertInstanceOf( MailAddress::class, $ma );
+ $this->setMwGlobals( 'wgEnotifUseRealName', true );
+ $this->assertEquals( '"Real name" <foo@bar.baz>', $ma->toString() );
+ $this->setMwGlobals( 'wgEnotifUseRealName', false );
+ $this->assertEquals( '"UserName" <foo@bar.baz>', $ma->toString() );
+ }
+
+ /**
+ * @covers MailAddress::toString
+ * @dataProvider provideToString
+ */
+ public function testToString( $useRealName, $address, $name, $realName, $expected ) {
+ if ( wfIsWindows() ) {
+ $this->markTestSkipped( 'This test only works on non-Windows platforms' );
+ }
+ $this->setMwGlobals( 'wgEnotifUseRealName', $useRealName );
+ $ma = new MailAddress( $address, $name, $realName );
+ $this->assertEquals( $expected, $ma->toString() );
+ }
+
+ public static function provideToString() {
+ return [
+ [ true, 'foo@bar.baz', 'FooBar', 'Foo Bar', '"Foo Bar" <foo@bar.baz>' ],
+ [ true, 'foo@bar.baz', 'UserName', null, '"UserName" <foo@bar.baz>' ],
+ [ true, 'foo@bar.baz', 'AUser', 'My real name', '"My real name" <foo@bar.baz>' ],
+ [ true, 'foo@bar.baz', 'AUser', 'My "real" name', '"My \"real\" name" <foo@bar.baz>' ],
+ [ true, 'foo@bar.baz', 'AUser', 'My "A/B" test', '"My \"A/B\" test" <foo@bar.baz>' ],
+ [ true, 'foo@bar.baz', 'AUser', 'E=MC2', '=?UTF-8?Q?E=3DMC2?= <foo@bar.baz>' ],
+ // A backslash (\) should be escaped (\\). In a string literal that is \\\\ (4x).
+ [ true, 'foo@bar.baz', 'AUser', 'My "B\C" test', '"My \"B\\\\C\" test" <foo@bar.baz>' ],
+ [ true, 'foo@bar.baz', 'A.user.name', 'my@real.name', '"my@real.name" <foo@bar.baz>' ],
+ [ false, 'foo@bar.baz', 'AUserName', 'Some real name', '"AUserName" <foo@bar.baz>' ],
+ [ false, 'foo@bar.baz', '', '', 'foo@bar.baz' ],
+ [ true, 'foo@bar.baz', '', '', 'foo@bar.baz' ],
+ [ true, '', '', '', '' ],
+ ];
+ }
+
+ /**
+ * @covers MailAddress::__toString
+ */
+ public function test__ToString() {
+ $ma = new MailAddress( 'some@email.com', 'UserName', 'A real name' );
+ $this->assertEquals( $ma->toString(), (string)$ma );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/mail/UserMailerTest.php b/www/wiki/tests/phpunit/includes/mail/UserMailerTest.php
new file mode 100644
index 00000000..dca8aeb9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/mail/UserMailerTest.php
@@ -0,0 +1,14 @@
+<?php
+
+class UserMailerTest extends MediaWikiLangTestCase {
+
+ /**
+ * @covers UserMailer::quotedPrintable
+ */
+ public function testQuotedPrintable() {
+ $this->assertEquals(
+ "=?UTF-8?Q?=C4=88u=20legebla=3F?=",
+ UserMailer::quotedPrintable( "\xc4\x88u legebla?", "UTF-8" ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php b/www/wiki/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php
new file mode 100644
index 00000000..a70c0054
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php
@@ -0,0 +1,167 @@
+<?php
+
+/**
+ * @group Media
+ */
+class BitmapMetadataHandlerTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( 'wgShowEXIF', false );
+
+ $this->filePath = __DIR__ . '/../../data/media/';
+ }
+
+ /**
+ * Test if having conflicting metadata values from different
+ * types of metadata, that the right one takes precedence.
+ *
+ * Basically the file has IPTC and XMP metadata, the
+ * IPTC should override the XMP, except for the multilingual
+ * translation (to en) where XMP should win.
+ * @covers BitmapMetadataHandler::Jpeg
+ */
+ public function testMultilingualCascade() {
+ $this->checkPHPExtension( 'exif' );
+ $this->checkPHPExtension( 'xml' );
+
+ $this->setMwGlobals( 'wgShowEXIF', true );
+
+ $meta = BitmapMetadataHandler::Jpeg( $this->filePath .
+ '/Xmp-exif-multilingual_test.jpg' );
+
+ $expected = [
+ 'x-default' => 'right(iptc)',
+ 'en' => 'right translation',
+ '_type' => 'lang'
+ ];
+
+ $this->assertArrayHasKey( 'ImageDescription', $meta,
+ 'Did not extract any ImageDescription info?!' );
+
+ $this->assertEquals( $expected, $meta['ImageDescription'] );
+ }
+
+ /**
+ * Test for jpeg comments are being handled by
+ * BitmapMetadataHandler correctly.
+ *
+ * There's more extensive tests of comment extraction in
+ * JpegMetadataExtractorTests.php
+ * @covers BitmapMetadataHandler::Jpeg
+ */
+ public function testJpegComment() {
+ $meta = BitmapMetadataHandler::Jpeg( $this->filePath .
+ 'jpeg-comment-utf.jpg' );
+
+ $this->assertEquals( 'UTF-8 JPEG Comment — ¼',
+ $meta['JPEGFileComment'][0] );
+ }
+
+ /**
+ * Make sure a bad iptc block doesn't stop the other metadata
+ * from being extracted.
+ * @covers BitmapMetadataHandler::Jpeg
+ */
+ public function testBadIPTC() {
+ $meta = BitmapMetadataHandler::Jpeg( $this->filePath .
+ 'iptc-invalid-psir.jpg' );
+ $this->assertEquals( 'Created with GIMP', $meta['JPEGFileComment'][0] );
+ }
+
+ /**
+ * @covers BitmapMetadataHandler::Jpeg
+ */
+ public function testIPTCDates() {
+ $meta = BitmapMetadataHandler::Jpeg( $this->filePath .
+ 'iptc-timetest.jpg' );
+
+ $this->assertEquals( '2020:07:14 01:36:05', $meta['DateTimeDigitized'] );
+ $this->assertEquals( '1997:03:02 00:01:02', $meta['DateTimeOriginal'] );
+ }
+
+ /**
+ * File has an invalid time (+ one valid but really weird time)
+ * that shouldn't be included
+ * @covers BitmapMetadataHandler::Jpeg
+ */
+ public function testIPTCDatesInvalid() {
+ $meta = BitmapMetadataHandler::Jpeg( $this->filePath .
+ 'iptc-timetest-invalid.jpg' );
+
+ $this->assertEquals( '1845:03:02 00:01:02', $meta['DateTimeOriginal'] );
+ $this->assertFalse( isset( $meta['DateTimeDigitized'] ) );
+ }
+
+ /**
+ * XMP data should take priority over iptc data
+ * when hash has been updated, but not when
+ * the hash is wrong.
+ * @covers BitmapMetadataHandler::addMetadata
+ * @covers BitmapMetadataHandler::getMetadataArray
+ */
+ public function testMerging() {
+ $merger = new BitmapMetadataHandler();
+ $merger->addMetadata( [ 'foo' => 'xmp' ], 'xmp-general' );
+ $merger->addMetadata( [ 'bar' => 'xmp' ], 'xmp-general' );
+ $merger->addMetadata( [ 'baz' => 'xmp' ], 'xmp-general' );
+ $merger->addMetadata( [ 'fred' => 'xmp' ], 'xmp-general' );
+ $merger->addMetadata( [ 'foo' => 'iptc (hash)' ], 'iptc-good-hash' );
+ $merger->addMetadata( [ 'bar' => 'iptc (bad hash)' ], 'iptc-bad-hash' );
+ $merger->addMetadata( [ 'baz' => 'iptc (bad hash)' ], 'iptc-bad-hash' );
+ $merger->addMetadata( [ 'fred' => 'iptc (no hash)' ], 'iptc-no-hash' );
+ $merger->addMetadata( [ 'baz' => 'exif' ], 'exif' );
+
+ $actual = $merger->getMetadataArray();
+ $expected = [
+ 'foo' => 'xmp',
+ 'bar' => 'iptc (bad hash)',
+ 'baz' => 'exif',
+ 'fred' => 'xmp',
+ ];
+ $this->assertEquals( $expected, $actual );
+ }
+
+ /**
+ * @covers BitmapMetadataHandler::png
+ */
+ public function testPNGXMP() {
+ if ( !extension_loaded( 'xml' ) ) {
+ $this->markTestSkipped( "This test needs the xml extension." );
+ }
+ $handler = new BitmapMetadataHandler();
+ $result = $handler->PNG( $this->filePath . 'xmp.png' );
+ $expected = [
+ 'frameCount' => 0,
+ 'loopCount' => 1,
+ 'duration' => 0,
+ 'bitDepth' => 1,
+ 'colorType' => 'index-coloured',
+ 'metadata' => [
+ 'SerialNumber' => '123456789',
+ '_MW_PNG_VERSION' => 1,
+ ],
+ ];
+ $this->assertEquals( $expected, $result );
+ }
+
+ /**
+ * @covers BitmapMetadataHandler::png
+ */
+ public function testPNGNative() {
+ $handler = new BitmapMetadataHandler();
+ $result = $handler->PNG( $this->filePath . 'Png-native-test.png' );
+ $expected = 'http://example.com/url';
+ $this->assertEquals( $expected, $result['metadata']['Identifier']['x-default'] );
+ }
+
+ /**
+ * @covers BitmapMetadataHandler::getTiffByteOrder
+ */
+ public function testTiffByteOrder() {
+ $handler = new BitmapMetadataHandler();
+ $res = $handler->getTiffByteOrder( $this->filePath . 'test.tiff' );
+ $this->assertEquals( 'LE', $res );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/BitmapScalingTest.php b/www/wiki/tests/phpunit/includes/media/BitmapScalingTest.php
new file mode 100644
index 00000000..fb96f7db
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/BitmapScalingTest.php
@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * @group Media
+ */
+class BitmapScalingTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgMaxImageArea' => 1.25e7, // 3500x3500
+ 'wgCustomConvertCommand' => 'dummy', // Set so that we don't get client side rendering
+ ] );
+ }
+
+ /**
+ * @dataProvider provideNormaliseParams
+ * @covers BitmapHandler::normaliseParams
+ */
+ public function testNormaliseParams( $fileDimensions, $expectedParams, $params, $msg ) {
+ $file = new FakeDimensionFile( $fileDimensions );
+ $handler = new BitmapHandler;
+ $valid = $handler->normaliseParams( $file, $params );
+ $this->assertTrue( $valid );
+ $this->assertEquals( $expectedParams, $params, $msg );
+ }
+
+ public static function provideNormaliseParams() {
+ return [
+ /* Regular resize operations */
+ [
+ [ 1024, 768 ],
+ [
+ 'width' => 512, 'height' => 384,
+ 'physicalWidth' => 512, 'physicalHeight' => 384,
+ 'page' => 1, 'interlace' => false,
+ ],
+ [ 'width' => 512 ],
+ 'Resizing with width set',
+ ],
+ [
+ [ 1024, 768 ],
+ [
+ 'width' => 512, 'height' => 384,
+ 'physicalWidth' => 512, 'physicalHeight' => 384,
+ 'page' => 1, 'interlace' => false,
+ ],
+ [ 'width' => 512, 'height' => 768 ],
+ 'Resizing with height set too high',
+ ],
+ [
+ [ 1024, 768 ],
+ [
+ 'width' => 512, 'height' => 384,
+ 'physicalWidth' => 512, 'physicalHeight' => 384,
+ 'page' => 1, 'interlace' => false,
+ ],
+ [ 'width' => 1024, 'height' => 384 ],
+ 'Resizing with height set',
+ ],
+
+ /* Very tall images */
+ [
+ [ 1000, 100 ],
+ [
+ 'width' => 5, 'height' => 1,
+ 'physicalWidth' => 5, 'physicalHeight' => 1,
+ 'page' => 1, 'interlace' => false,
+ ],
+ [ 'width' => 5 ],
+ 'Very wide image',
+ ],
+
+ [
+ [ 100, 1000 ],
+ [
+ 'width' => 1, 'height' => 10,
+ 'physicalWidth' => 1, 'physicalHeight' => 10,
+ 'page' => 1, 'interlace' => false,
+ ],
+ [ 'width' => 1 ],
+ 'Very high image',
+ ],
+ [
+ [ 100, 1000 ],
+ [
+ 'width' => 1, 'height' => 5,
+ 'physicalWidth' => 1, 'physicalHeight' => 10,
+ 'page' => 1, 'interlace' => false,
+ ],
+ [ 'width' => 10, 'height' => 5 ],
+ 'Very high image with height set',
+ ],
+ /* Max image area */
+ [
+ [ 4000, 4000 ],
+ [
+ 'width' => 5000, 'height' => 5000,
+ 'physicalWidth' => 4000, 'physicalHeight' => 4000,
+ 'page' => 1, 'interlace' => false,
+ ],
+ [ 'width' => 5000 ],
+ 'Bigger than max image size but doesn\'t need scaling',
+ ],
+ /* Max interlace image area */
+ [
+ [ 4000, 4000 ],
+ [
+ 'width' => 5000, 'height' => 5000,
+ 'physicalWidth' => 4000, 'physicalHeight' => 4000,
+ 'page' => 1, 'interlace' => false,
+ ],
+ [ 'width' => 5000, 'interlace' => true ],
+ 'Interlace bigger than max interlace area',
+ ],
+ ];
+ }
+
+ /**
+ * @covers BitmapHandler::doTransform
+ */
+ public function testTooBigImage() {
+ $file = new FakeDimensionFile( [ 4000, 4000 ] );
+ $handler = new BitmapHandler;
+ $params = [ 'width' => '3700' ]; // Still bigger than max size.
+ $this->assertEquals( TransformTooBigImageAreaError::class,
+ get_class( $handler->doTransform( $file, 'dummy path', '', $params ) ) );
+ }
+
+ /**
+ * @covers BitmapHandler::doTransform
+ */
+ public function testTooBigMustRenderImage() {
+ $file = new FakeDimensionFile( [ 4000, 4000 ] );
+ $file->mustRender = true;
+ $handler = new BitmapHandler;
+ $params = [ 'width' => '5000' ]; // Still bigger than max size.
+ $this->assertEquals( TransformTooBigImageAreaError::class,
+ get_class( $handler->doTransform( $file, 'dummy path', '', $params ) ) );
+ }
+
+ /**
+ * @covers BitmapHandler::getImageArea
+ */
+ public function testImageArea() {
+ $file = new FakeDimensionFile( [ 7, 9 ] );
+ $handler = new BitmapHandler;
+ $this->assertEquals( 63, $handler->getImageArea( $file ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/DjVuTest.php b/www/wiki/tests/phpunit/includes/media/DjVuTest.php
new file mode 100644
index 00000000..dbc0d2fb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/DjVuTest.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * @group Media
+ * @covers DjVuHandler
+ */
+class DjVuTest extends MediaWikiMediaTestCase {
+
+ /**
+ * @var DjVuHandler
+ */
+ protected $handler;
+
+ protected function setUp() {
+ parent::setUp();
+
+ // cli tool setup
+ $djvuSupport = new DjVuSupport();
+
+ if ( !$djvuSupport->isEnabled() ) {
+ $this->markTestSkipped(
+ 'This test needs the installation of the ddjvu, djvutoxml and djvudump tools' );
+ }
+
+ $this->handler = new DjVuHandler();
+ }
+
+ public function testGetImageSize() {
+ $this->assertArrayEquals(
+ [ 2480, 3508, 'DjVu', 'width="2480" height="3508"' ],
+ $this->handler->getImageSize( null, $this->filePath . '/LoremIpsum.djvu' ),
+ 'Test file LoremIpsum.djvu should have a size of 2480 * 3508'
+ );
+ }
+
+ public function testInvalidFile() {
+ $this->assertEquals(
+ 'a:1:{s:5:"error";s:25:"Error extracting metadata";}',
+ $this->handler->getMetadata( null, $this->filePath . '/some-nonexistent-file' ),
+ 'Getting metadata for an inexistent file should return false'
+ );
+ }
+
+ public function testPageCount() {
+ $file = $this->dataFile( 'LoremIpsum.djvu', 'image/x.djvu' );
+ $this->assertEquals(
+ 5,
+ $this->handler->pageCount( $file ),
+ 'Test file LoremIpsum.djvu should be detected as containing 5 pages'
+ );
+ }
+
+ public function testGetPageDimensions() {
+ $file = $this->dataFile( 'LoremIpsum.djvu', 'image/x.djvu' );
+ $this->assertArrayEquals(
+ [ 2480, 3508 ],
+ $this->handler->getPageDimensions( $file, 1 ),
+ 'Page 1 of test file LoremIpsum.djvu should have a size of 2480 * 3508'
+ );
+ }
+
+ public function testGetPageText() {
+ $file = $this->dataFile( 'LoremIpsum.djvu', 'image/x.djvu' );
+ $this->assertEquals(
+ "Lorem ipsum \n1 \n",
+ (string)$this->handler->getPageText( $file, 1 ),
+ "Text layer of page 1 of file LoremIpsum.djvu should be 'Lorem ipsum \n1 \n'"
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/ExifBitmapTest.php b/www/wiki/tests/phpunit/includes/media/ExifBitmapTest.php
new file mode 100644
index 00000000..eb02e7ed
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/ExifBitmapTest.php
@@ -0,0 +1,142 @@
+<?php
+
+/**
+ * @group Media
+ */
+class ExifBitmapTest extends MediaWikiMediaTestCase {
+
+ /**
+ * @var ExifBitmapHandler
+ */
+ protected $handler;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->checkPHPExtension( 'exif' );
+
+ $this->setMwGlobals( 'wgShowEXIF', true );
+
+ $this->handler = new ExifBitmapHandler;
+ }
+
+ /**
+ * @covers ExifBitmapHandler::isMetadataValid
+ */
+ public function testIsOldBroken() {
+ $res = $this->handler->isMetadataValid( null, ExifBitmapHandler::OLD_BROKEN_FILE );
+ $this->assertEquals( ExifBitmapHandler::METADATA_COMPATIBLE, $res );
+ }
+
+ /**
+ * @covers ExifBitmapHandler::isMetadataValid
+ */
+ public function testIsBrokenFile() {
+ $res = $this->handler->isMetadataValid( null, ExifBitmapHandler::BROKEN_FILE );
+ $this->assertEquals( ExifBitmapHandler::METADATA_GOOD, $res );
+ }
+
+ /**
+ * @covers ExifBitmapHandler::isMetadataValid
+ */
+ public function testIsInvalid() {
+ $res = $this->handler->isMetadataValid( null, 'Something Invalid Here.' );
+ $this->assertEquals( ExifBitmapHandler::METADATA_BAD, $res );
+ }
+
+ /**
+ * @covers ExifBitmapHandler::isMetadataValid
+ */
+ public function testGoodMetadata() {
+ // phpcs:ignore Generic.Files.LineLength
+ $meta = 'a:16:{s:10:"ImageWidth";i:20;s:11:"ImageLength";i:20;s:13:"BitsPerSample";a:3:{i:0;i:8;i:1;i:8;i:2;i:8;}s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:16:"ImageDescription";s:17:"Created with GIMP";s:12:"StripOffsets";i:8;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:3;s:12:"RowsPerStrip";i:64;s:15:"StripByteCounts";i:238;s:11:"XResolution";s:19:"1207959552/16777216";s:11:"YResolution";s:19:"1207959552/16777216";s:19:"PlanarConfiguration";i:1;s:14:"ResolutionUnit";i:2;s:22:"MEDIAWIKI_EXIF_VERSION";i:2;}';
+ $res = $this->handler->isMetadataValid( null, $meta );
+ $this->assertEquals( ExifBitmapHandler::METADATA_GOOD, $res );
+ }
+
+ /**
+ * @covers ExifBitmapHandler::isMetadataValid
+ */
+ public function testIsOldGood() {
+ // phpcs:ignore Generic.Files.LineLength
+ $meta = 'a:16:{s:10:"ImageWidth";i:20;s:11:"ImageLength";i:20;s:13:"BitsPerSample";a:3:{i:0;i:8;i:1;i:8;i:2;i:8;}s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:16:"ImageDescription";s:17:"Created with GIMP";s:12:"StripOffsets";i:8;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:3;s:12:"RowsPerStrip";i:64;s:15:"StripByteCounts";i:238;s:11:"XResolution";s:19:"1207959552/16777216";s:11:"YResolution";s:19:"1207959552/16777216";s:19:"PlanarConfiguration";i:1;s:14:"ResolutionUnit";i:2;s:22:"MEDIAWIKI_EXIF_VERSION";i:1;}';
+ $res = $this->handler->isMetadataValid( null, $meta );
+ $this->assertEquals( ExifBitmapHandler::METADATA_COMPATIBLE, $res );
+ }
+
+ /**
+ * Handle metadata from paged tiff handler (gotten via instant commons) gracefully.
+ * @covers ExifBitmapHandler::isMetadataValid
+ */
+ public function testPagedTiffHandledGracefully() {
+ // phpcs:ignore Generic.Files.LineLength
+ $meta = 'a:6:{s:9:"page_data";a:1:{i:1;a:5:{s:5:"width";i:643;s:6:"height";i:448;s:5:"alpha";s:4:"true";s:4:"page";i:1;s:6:"pixels";i:288064;}}s:10:"page_count";i:1;s:10:"first_page";i:1;s:9:"last_page";i:1;s:4:"exif";a:9:{s:10:"ImageWidth";i:643;s:11:"ImageLength";i:448;s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:4;s:12:"RowsPerStrip";i:50;s:19:"PlanarConfiguration";i:1;s:22:"MEDIAWIKI_EXIF_VERSION";i:1;}s:21:"TIFF_METADATA_VERSION";s:3:"1.4";}';
+ $res = $this->handler->isMetadataValid( null, $meta );
+ $this->assertEquals( ExifBitmapHandler::METADATA_BAD, $res );
+ }
+
+ /**
+ * @covers ExifBitmapHandler::convertMetadataVersion
+ */
+ public function testConvertMetadataLatest() {
+ $metadata = [
+ 'foo' => [ 'First', 'Second', '_type' => 'ol' ],
+ 'MEDIAWIKI_EXIF_VERSION' => 2
+ ];
+ $res = $this->handler->convertMetadataVersion( $metadata, 2 );
+ $this->assertEquals( $metadata, $res );
+ }
+
+ /**
+ * @covers ExifBitmapHandler::convertMetadataVersion
+ */
+ public function testConvertMetadataToOld() {
+ $metadata = [
+ 'foo' => [ 'First', 'Second', '_type' => 'ol' ],
+ 'bar' => [ 'First', 'Second', '_type' => 'ul' ],
+ 'baz' => [ 'First', 'Second' ],
+ 'fred' => 'Single',
+ 'MEDIAWIKI_EXIF_VERSION' => 2,
+ ];
+ $expected = [
+ 'foo' => "\n#First\n#Second",
+ 'bar' => "\n*First\n*Second",
+ 'baz' => "\n*First\n*Second",
+ 'fred' => 'Single',
+ 'MEDIAWIKI_EXIF_VERSION' => 1,
+ ];
+ $res = $this->handler->convertMetadataVersion( $metadata, 1 );
+ $this->assertEquals( $expected, $res );
+ }
+
+ /**
+ * @covers ExifBitmapHandler::convertMetadataVersion
+ */
+ public function testConvertMetadataSoftware() {
+ $metadata = [
+ 'Software' => [ [ 'GIMP', '1.1' ] ],
+ 'MEDIAWIKI_EXIF_VERSION' => 2,
+ ];
+ $expected = [
+ 'Software' => 'GIMP (Version 1.1)',
+ 'MEDIAWIKI_EXIF_VERSION' => 1,
+ ];
+ $res = $this->handler->convertMetadataVersion( $metadata, 1 );
+ $this->assertEquals( $expected, $res );
+ }
+
+ /**
+ * @covers ExifBitmapHandler::convertMetadataVersion
+ */
+ public function testConvertMetadataSoftwareNormal() {
+ $metadata = [
+ 'Software' => [ "GIMP 1.2", "vim" ],
+ 'MEDIAWIKI_EXIF_VERSION' => 2,
+ ];
+ $expected = [
+ 'Software' => "\n*GIMP 1.2\n*vim",
+ 'MEDIAWIKI_EXIF_VERSION' => 1,
+ ];
+ $res = $this->handler->convertMetadataVersion( $metadata, 1 );
+ $this->assertEquals( $expected, $res );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/ExifRotationTest.php b/www/wiki/tests/phpunit/includes/media/ExifRotationTest.php
new file mode 100644
index 00000000..fff101f3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/ExifRotationTest.php
@@ -0,0 +1,283 @@
+<?php
+/**
+ * Tests related to auto rotation.
+ *
+ * @group Media
+ * @group medium
+ *
+ * @covers BitmapHandler
+ */
+class ExifRotationTest extends MediaWikiMediaTestCase {
+
+ /** @var BitmapHandler */
+ private $handler;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->checkPHPExtension( 'exif' );
+
+ $this->handler = new BitmapHandler();
+
+ $this->setMwGlobals( [
+ 'wgShowEXIF' => true,
+ 'wgEnableAutoRotation' => true,
+ ] );
+ }
+
+ /**
+ * Mark this test as creating thumbnail files.
+ */
+ protected function createsThumbnails() {
+ return true;
+ }
+
+ /**
+ * @dataProvider provideFiles
+ */
+ public function testMetadata( $name, $type, $info ) {
+ if ( !$this->handler->canRotate() ) {
+ $this->markTestSkipped( "This test needs a rasterizer that can auto-rotate." );
+ }
+ $file = $this->dataFile( $name, $type );
+ $this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" );
+ $this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" );
+ }
+
+ /**
+ * Same as before, but with auto-rotation set to auto.
+ *
+ * This sets scaler to image magick, which we should detect as
+ * supporting rotation.
+ * @dataProvider provideFiles
+ */
+ public function testMetadataAutoRotate( $name, $type, $info ) {
+ $this->setMwGlobals( 'wgEnableAutoRotation', null );
+ $this->setMwGlobals( 'wgUseImageMagick', true );
+ $this->setMwGlobals( 'wgUseImageResize', true );
+
+ $file = $this->dataFile( $name, $type );
+ $this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" );
+ $this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" );
+ }
+
+ /**
+ *
+ * @dataProvider provideFiles
+ */
+ public function testRotationRendering( $name, $type, $info, $thumbs ) {
+ if ( !$this->handler->canRotate() ) {
+ $this->markTestSkipped( "This test needs a rasterizer that can auto-rotate." );
+ }
+ foreach ( $thumbs as $size => $out ) {
+ if ( preg_match( '/^(\d+)px$/', $size, $matches ) ) {
+ $params = [
+ 'width' => $matches[1],
+ ];
+ } elseif ( preg_match( '/^(\d+)x(\d+)px$/', $size, $matches ) ) {
+ $params = [
+ 'width' => $matches[1],
+ 'height' => $matches[2]
+ ];
+ } else {
+ throw new MWException( 'bogus test data format ' . $size );
+ }
+
+ $file = $this->dataFile( $name, $type );
+ $thumb = $file->transform( $params, File::RENDER_NOW | File::RENDER_FORCE );
+
+ $this->assertEquals(
+ $out[0],
+ $thumb->getWidth(),
+ "$name: thumb reported width check for $size"
+ );
+ $this->assertEquals(
+ $out[1],
+ $thumb->getHeight(),
+ "$name: thumb reported height check for $size"
+ );
+
+ $gis = getimagesize( $thumb->getLocalCopyPath() );
+ if ( $out[0] > $info['width'] ) {
+ // Physical image won't be scaled bigger than the original.
+ $this->assertEquals( $info['width'], $gis[0], "$name: thumb actual width check for $size" );
+ $this->assertEquals( $info['height'], $gis[1], "$name: thumb actual height check for $size" );
+ } else {
+ $this->assertEquals( $out[0], $gis[0], "$name: thumb actual width check for $size" );
+ $this->assertEquals( $out[1], $gis[1], "$name: thumb actual height check for $size" );
+ }
+ }
+ }
+
+ public static function provideFiles() {
+ return [
+ [
+ 'landscape-plain.jpg',
+ 'image/jpeg',
+ [
+ 'width' => 1024,
+ 'height' => 768,
+ ],
+ [
+ '800x600px' => [ 800, 600 ],
+ '9999x800px' => [ 1067, 800 ],
+ '800px' => [ 800, 600 ],
+ '600px' => [ 600, 450 ],
+ ]
+ ],
+ [
+ 'portrait-rotated.jpg',
+ 'image/jpeg',
+ [
+ 'width' => 768, // as rotated
+ 'height' => 1024, // as rotated
+ ],
+ [
+ '800x600px' => [ 450, 600 ],
+ '9999x800px' => [ 600, 800 ],
+ '800px' => [ 800, 1067 ],
+ '600px' => [ 600, 800 ],
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * Same as before, but with auto-rotation disabled.
+ * @dataProvider provideFilesNoAutoRotate
+ */
+ public function testMetadataNoAutoRotate( $name, $type, $info ) {
+ $this->setMwGlobals( 'wgEnableAutoRotation', false );
+
+ $file = $this->dataFile( $name, $type );
+ $this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" );
+ $this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" );
+ }
+
+ /**
+ * Same as before, but with auto-rotation set to auto and an image scaler that doesn't support it.
+ * @dataProvider provideFilesNoAutoRotate
+ */
+ public function testMetadataAutoRotateUnsupported( $name, $type, $info ) {
+ $this->setMwGlobals( 'wgEnableAutoRotation', null );
+ $this->setMwGlobals( 'wgUseImageResize', false );
+
+ $file = $this->dataFile( $name, $type );
+ $this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" );
+ $this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" );
+ }
+
+ /**
+ *
+ * @dataProvider provideFilesNoAutoRotate
+ */
+ public function testRotationRenderingNoAutoRotate( $name, $type, $info, $thumbs ) {
+ $this->setMwGlobals( 'wgEnableAutoRotation', false );
+
+ foreach ( $thumbs as $size => $out ) {
+ if ( preg_match( '/^(\d+)px$/', $size, $matches ) ) {
+ $params = [
+ 'width' => $matches[1],
+ ];
+ } elseif ( preg_match( '/^(\d+)x(\d+)px$/', $size, $matches ) ) {
+ $params = [
+ 'width' => $matches[1],
+ 'height' => $matches[2]
+ ];
+ } else {
+ throw new MWException( 'bogus test data format ' . $size );
+ }
+
+ $file = $this->dataFile( $name, $type );
+ $thumb = $file->transform( $params, File::RENDER_NOW | File::RENDER_FORCE );
+
+ $this->assertEquals(
+ $out[0],
+ $thumb->getWidth(),
+ "$name: thumb reported width check for $size"
+ );
+ $this->assertEquals(
+ $out[1],
+ $thumb->getHeight(),
+ "$name: thumb reported height check for $size"
+ );
+
+ $gis = getimagesize( $thumb->getLocalCopyPath() );
+ if ( $out[0] > $info['width'] ) {
+ // Physical image won't be scaled bigger than the original.
+ $this->assertEquals( $info['width'], $gis[0], "$name: thumb actual width check for $size" );
+ $this->assertEquals( $info['height'], $gis[1], "$name: thumb actual height check for $size" );
+ } else {
+ $this->assertEquals( $out[0], $gis[0], "$name: thumb actual width check for $size" );
+ $this->assertEquals( $out[1], $gis[1], "$name: thumb actual height check for $size" );
+ }
+ }
+ }
+
+ public static function provideFilesNoAutoRotate() {
+ return [
+ [
+ 'landscape-plain.jpg',
+ 'image/jpeg',
+ [
+ 'width' => 1024,
+ 'height' => 768,
+ ],
+ [
+ '800x600px' => [ 800, 600 ],
+ '9999x800px' => [ 1067, 800 ],
+ '800px' => [ 800, 600 ],
+ '600px' => [ 600, 450 ],
+ ]
+ ],
+ [
+ 'portrait-rotated.jpg',
+ 'image/jpeg',
+ [
+ 'width' => 1024, // since not rotated
+ 'height' => 768, // since not rotated
+ ],
+ [
+ '800x600px' => [ 800, 600 ],
+ '9999x800px' => [ 1067, 800 ],
+ '800px' => [ 800, 600 ],
+ '600px' => [ 600, 450 ],
+ ]
+ ]
+ ];
+ }
+
+ const TEST_WIDTH = 100;
+ const TEST_HEIGHT = 200;
+
+ /**
+ * @dataProvider provideBitmapExtractPreRotationDimensions
+ */
+ public function testBitmapExtractPreRotationDimensions( $rotation, $expected ) {
+ $result = $this->handler->extractPreRotationDimensions( [
+ 'physicalWidth' => self::TEST_WIDTH,
+ 'physicalHeight' => self::TEST_HEIGHT,
+ ], $rotation );
+ $this->assertEquals( $expected, $result );
+ }
+
+ public static function provideBitmapExtractPreRotationDimensions() {
+ return [
+ [
+ 0,
+ [ self::TEST_WIDTH, self::TEST_HEIGHT ]
+ ],
+ [
+ 90,
+ [ self::TEST_HEIGHT, self::TEST_WIDTH ]
+ ],
+ [
+ 180,
+ [ self::TEST_WIDTH, self::TEST_HEIGHT ]
+ ],
+ [
+ 270,
+ [ self::TEST_HEIGHT, self::TEST_WIDTH ]
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/ExifTest.php b/www/wiki/tests/phpunit/includes/media/ExifTest.php
new file mode 100644
index 00000000..876e4617
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/ExifTest.php
@@ -0,0 +1,47 @@
+<?php
+
+/**
+ * @group Media
+ * @covers Exif
+ */
+class ExifTest extends MediaWikiTestCase {
+
+ /** @var string */
+ protected $mediaPath;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->checkPHPExtension( 'exif' );
+
+ $this->mediaPath = __DIR__ . '/../../data/media/';
+
+ $this->setMwGlobals( 'wgShowEXIF', true );
+ }
+
+ public function testGPSExtraction() {
+ $filename = $this->mediaPath . 'exif-gps.jpg';
+ $seg = JpegMetadataExtractor::segmentSplitter( $filename );
+ $exif = new Exif( $filename, $seg['byteOrder'] );
+ $data = $exif->getFilteredData();
+ $expected = [
+ 'GPSLatitude' => 88.5180555556,
+ 'GPSLongitude' => -21.12357,
+ 'GPSAltitude' => -3.141592653,
+ 'GPSDOP' => '5/1',
+ 'GPSVersionID' => '2.2.0.0',
+ ];
+ $this->assertEquals( $expected, $data, '', 0.0000000001 );
+ }
+
+ public function testUnicodeUserComment() {
+ $filename = $this->mediaPath . 'exif-user-comment.jpg';
+ $seg = JpegMetadataExtractor::segmentSplitter( $filename );
+ $exif = new Exif( $filename, $seg['byteOrder'] );
+ $data = $exif->getFilteredData();
+
+ $expected = [
+ 'UserComment' => 'test⁔comment',
+ ];
+ $this->assertEquals( $expected, $data );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/FakeDimensionFile.php b/www/wiki/tests/phpunit/includes/media/FakeDimensionFile.php
new file mode 100644
index 00000000..5e3a3cba
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/FakeDimensionFile.php
@@ -0,0 +1,35 @@
+<?php
+
+class FakeDimensionFile extends File {
+ public $mustRender = false;
+ public $mime;
+ public $dimensions;
+
+ public function __construct( $dimensions, $mime = 'unknown/unknown' ) {
+ parent::__construct( Title::makeTitle( NS_FILE, 'Test' ),
+ new NullRepo( null ) );
+
+ $this->dimensions = $dimensions;
+ $this->mime = $mime;
+ }
+
+ public function getWidth( $page = 1 ) {
+ return $this->dimensions[0];
+ }
+
+ public function getHeight( $page = 1 ) {
+ return $this->dimensions[1];
+ }
+
+ public function mustRender() {
+ return $this->mustRender;
+ }
+
+ public function getPath() {
+ return '';
+ }
+
+ public function getMimeType() {
+ return $this->mime;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/FormatMetadataTest.php b/www/wiki/tests/phpunit/includes/media/FormatMetadataTest.php
new file mode 100644
index 00000000..0987bd0a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/FormatMetadataTest.php
@@ -0,0 +1,144 @@
+<?php
+
+/**
+ * @group Media
+ */
+class FormatMetadataTest extends MediaWikiMediaTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->checkPHPExtension( 'exif' );
+ $this->setMwGlobals( 'wgShowEXIF', true );
+ }
+
+ /**
+ * @covers File::formatMetadata
+ */
+ public function testInvalidDate() {
+ $file = $this->dataFile( 'broken_exif_date.jpg', 'image/jpeg' );
+
+ // Throws an error if bug hit
+ $meta = $file->formatMetadata();
+ $this->assertNotEquals( false, $meta, 'Valid metadata extracted' );
+
+ // Find date exif entry
+ $this->assertArrayHasKey( 'visible', $meta );
+ $dateIndex = null;
+ foreach ( $meta['visible'] as $i => $data ) {
+ if ( $data['id'] == 'exif-datetimeoriginal' ) {
+ $dateIndex = $i;
+ }
+ }
+ $this->assertNotNull( $dateIndex, 'Date entry exists in metadata' );
+ $this->assertEquals( '0000:01:00 00:02:27',
+ $meta['visible'][$dateIndex]['value'],
+ 'File with invalid date metadata (T31471)' );
+ }
+
+ /**
+ * @param mixed $input
+ * @param mixed $output
+ * @dataProvider provideResolveMultivalueValue
+ * @covers FormatMetadata::resolveMultivalueValue
+ */
+ public function testResolveMultivalueValue( $input, $output ) {
+ $formatMetadata = new FormatMetadata();
+ $class = new ReflectionClass( FormatMetadata::class );
+ $method = $class->getMethod( 'resolveMultivalueValue' );
+ $method->setAccessible( true );
+ $actualInput = $method->invoke( $formatMetadata, $input );
+ $this->assertEquals( $output, $actualInput );
+ }
+
+ public function provideResolveMultivalueValue() {
+ return [
+ 'nonArray' => [
+ 'foo',
+ 'foo'
+ ],
+ 'multiValue' => [
+ [ 'first', 'second', 'third', '_type' => 'ol' ],
+ 'first'
+ ],
+ 'noType' => [
+ [ 'first', 'second', 'third' ],
+ 'first'
+ ],
+ 'typeFirst' => [
+ [ '_type' => 'ol', 'first', 'second', 'third' ],
+ 'first'
+ ],
+ 'multilang' => [
+ [
+ 'en' => 'first',
+ 'de' => 'Erste',
+ '_type' => 'lang'
+ ],
+ [
+ 'en' => 'first',
+ 'de' => 'Erste',
+ '_type' => 'lang'
+ ],
+ ],
+ 'multilang-multivalue' => [
+ [
+ 'en' => [ 'first', 'second' ],
+ 'de' => [ 'Erste', 'Zweite' ],
+ '_type' => 'lang'
+ ],
+ [
+ 'en' => 'first',
+ 'de' => 'Erste',
+ '_type' => 'lang'
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @param mixed $input
+ * @param mixed $output
+ * @dataProvider provideGetFormattedData
+ * @covers FormatMetadata::getFormattedData
+ */
+ public function testGetFormattedData( $input, $output ) {
+ $this->assertEquals( $output, FormatMetadata::getFormattedData( $input ) );
+ }
+
+ public function provideGetFormattedData() {
+ return [
+ [
+ [ 'Software' => 'Adobe Photoshop CS6 (Macintosh)' ],
+ [ 'Software' => 'Adobe Photoshop CS6 (Macintosh)' ],
+ ],
+ [
+ [ 'Software' => [ 'FotoWare FotoStation' ] ],
+ [ 'Software' => 'FotoWare FotoStation' ],
+ ],
+ [
+ [ 'Software' => [ [ 'Capture One PRO', '3.7.7' ] ] ],
+ [ 'Software' => 'Capture One PRO (Version 3.7.7)' ],
+ ],
+ [
+ [ 'Software' => [ [ 'FotoWare ColorFactory', '' ] ] ],
+ [ 'Software' => 'FotoWare ColorFactory (Version )' ],
+ ],
+ [
+ [ 'Software' => [ 'x-default' => 'paint.net 4.0.12', '_type' => 'lang' ] ],
+ [ 'Software' => '<ul class="metadata-langlist">'.
+ '<li class="mw-metadata-lang-default">'.
+ '<span class="mw-metadata-lang-value">paint.net 4.0.12</span>'.
+ "</li>\n".
+ '</ul>'
+ ],
+ ],
+ [
+ // https://phabricator.wikimedia.org/T178130
+ // WebMHandler.php turns both 'muxingapp' & 'writingapp' to 'Software'
+ [ 'Software' => [ [ 'Lavf57.25.100' ], [ 'Lavf57.25.100' ] ] ],
+ [ 'Software' => "<ul><li>Lavf57.25.100</li>\n<li>Lavf57.25.100</li></ul>" ],
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/GIFMetadataExtractorTest.php b/www/wiki/tests/phpunit/includes/media/GIFMetadataExtractorTest.php
new file mode 100644
index 00000000..278b441b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/GIFMetadataExtractorTest.php
@@ -0,0 +1,110 @@
+<?php
+
+/**
+ * @group Media
+ */
+class GIFMetadataExtractorTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->mediaPath = __DIR__ . '/../../data/media/';
+ }
+
+ /**
+ * Put in a file, and see if the metadata coming out is as expected.
+ * @param string $filename
+ * @param array $expected The extracted metadata.
+ * @dataProvider provideGetMetadata
+ * @covers GIFMetadataExtractor::getMetadata
+ */
+ public function testGetMetadata( $filename, $expected ) {
+ $actual = GIFMetadataExtractor::getMetadata( $this->mediaPath . $filename );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideGetMetadata() {
+ $xmpNugget = <<<EOF
+<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
+<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 7.30'>
+<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
+
+ <rdf:Description rdf:about=''
+ xmlns:Iptc4xmpCore='http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/'>
+ <Iptc4xmpCore:Location>The interwebs</Iptc4xmpCore:Location>
+ </rdf:Description>
+
+ <rdf:Description rdf:about=''
+ xmlns:tiff='http://ns.adobe.com/tiff/1.0/'>
+ <tiff:Artist>Bawolff</tiff:Artist>
+ <tiff:ImageDescription>
+ <rdf:Alt>
+ <rdf:li xml:lang='x-default'>A file to test GIF</rdf:li>
+ </rdf:Alt>
+ </tiff:ImageDescription>
+ </rdf:Description>
+</rdf:RDF>
+</x:xmpmeta>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+<?xpacket end='w'?>
+EOF;
+ $xmpNugget = str_replace( "\r", '', $xmpNugget ); // Windows compat
+
+ return [
+ [
+ 'nonanimated.gif',
+ [
+ 'comment' => [ 'GIF test file ⁕ Created with GIMP' ],
+ 'duration' => 0.1,
+ 'frameCount' => 1,
+ 'looped' => false,
+ 'xmp' => '',
+ ]
+ ],
+ [
+ 'animated.gif',
+ [
+ 'comment' => [ 'GIF test file . Created with GIMP' ],
+ 'duration' => 2.4,
+ 'frameCount' => 4,
+ 'looped' => true,
+ 'xmp' => '',
+ ]
+ ],
+
+ [
+ 'animated-xmp.gif',
+ [
+ 'xmp' => $xmpNugget,
+ 'duration' => 2.4,
+ 'frameCount' => 4,
+ 'looped' => true,
+ 'comment' => [ 'GIƒ·test·file' ],
+ ]
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/GIFTest.php b/www/wiki/tests/phpunit/includes/media/GIFTest.php
new file mode 100644
index 00000000..4dd7443e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/GIFTest.php
@@ -0,0 +1,172 @@
+<?php
+
+/**
+ * @group Media
+ */
+class GIFHandlerTest extends MediaWikiMediaTestCase {
+
+ /** @var GIFHandler */
+ protected $handler;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->handler = new GIFHandler();
+ }
+
+ /**
+ * @covers GIFHandler::getMetadata
+ */
+ public function testInvalidFile() {
+ $res = $this->handler->getMetadata( null, $this->filePath . '/README' );
+ $this->assertEquals( GIFHandler::BROKEN_FILE, $res );
+ }
+
+ /**
+ * @param string $filename Basename of the file to check
+ * @param bool $expected Expected result.
+ * @dataProvider provideIsAnimated
+ * @covers GIFHandler::isAnimatedImage
+ */
+ public function testIsAnimanted( $filename, $expected ) {
+ $file = $this->dataFile( $filename, 'image/gif' );
+ $actual = $this->handler->isAnimatedImage( $file );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideIsAnimated() {
+ return [
+ [ 'animated.gif', true ],
+ [ 'nonanimated.gif', false ],
+ ];
+ }
+
+ /**
+ * @param string $filename
+ * @param int $expected Total image area
+ * @dataProvider provideGetImageArea
+ * @covers GIFHandler::getImageArea
+ */
+ public function testGetImageArea( $filename, $expected ) {
+ $file = $this->dataFile( $filename, 'image/gif' );
+ $actual = $this->handler->getImageArea( $file, $file->getWidth(), $file->getHeight() );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideGetImageArea() {
+ return [
+ [ 'animated.gif', 5400 ],
+ [ 'nonanimated.gif', 1350 ],
+ ];
+ }
+
+ /**
+ * @param string $metadata Serialized metadata
+ * @param int $expected One of the class constants of GIFHandler
+ * @dataProvider provideIsMetadataValid
+ * @covers GIFHandler::isMetadataValid
+ */
+ public function testIsMetadataValid( $metadata, $expected ) {
+ $actual = $this->handler->isMetadataValid( null, $metadata );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideIsMetadataValid() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [ GIFHandler::BROKEN_FILE, GIFHandler::METADATA_GOOD ],
+ [ '', GIFHandler::METADATA_BAD ],
+ [ null, GIFHandler::METADATA_BAD ],
+ [ 'Something invalid!', GIFHandler::METADATA_BAD ],
+ [
+ 'a:4:{s:10:"frameCount";i:1;s:6:"looped";b:0;s:8:"duration";d:0.1000000000000000055511151231257827021181583404541015625;s:8:"metadata";a:2:{s:14:"GIFFileComment";a:1:{i:0;s:35:"GIF test file ⁕ Created with GIMP";}s:15:"_MW_GIF_VERSION";i:1;}}',
+ GIFHandler::METADATA_GOOD
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @param string $filename
+ * @param string $expected Serialized array
+ * @dataProvider provideGetMetadata
+ * @covers GIFHandler::getMetadata
+ */
+ public function testGetMetadata( $filename, $expected ) {
+ $file = $this->dataFile( $filename, 'image/gif' );
+ $actual = $this->handler->getMetadata( $file, "$this->filePath/$filename" );
+ $this->assertEquals( unserialize( $expected ), unserialize( $actual ) );
+ }
+
+ public static function provideGetMetadata() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ 'nonanimated.gif',
+ 'a:4:{s:10:"frameCount";i:1;s:6:"looped";b:0;s:8:"duration";d:0.1000000000000000055511151231257827021181583404541015625;s:8:"metadata";a:2:{s:14:"GIFFileComment";a:1:{i:0;s:35:"GIF test file ⁕ Created with GIMP";}s:15:"_MW_GIF_VERSION";i:1;}}'
+ ],
+ [
+ 'animated-xmp.gif',
+ 'a:4:{s:10:"frameCount";i:4;s:6:"looped";b:1;s:8:"duration";d:2.399999999999999911182158029987476766109466552734375;s:8:"metadata";a:5:{s:6:"Artist";s:7:"Bawolff";s:16:"ImageDescription";a:2:{s:9:"x-default";s:18:"A file to test GIF";s:5:"_type";s:4:"lang";}s:15:"SublocationDest";s:13:"The interwebs";s:14:"GIFFileComment";a:1:{i:0;s:16:"GIƒ·test·file";}s:15:"_MW_GIF_VERSION";i:1;}}'
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @param string $filename
+ * @param string $expected Serialized array
+ * @dataProvider provideGetIndependentMetaArray
+ * @covers GIFHandler::getCommonMetaArray
+ */
+ public function testGetIndependentMetaArray( $filename, $expected ) {
+ $file = $this->dataFile( $filename, 'image/gif' );
+ $actual = $this->handler->getCommonMetaArray( $file );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideGetIndependentMetaArray() {
+ return [
+ [ 'nonanimated.gif', [
+ 'GIFFileComment' => [
+ 'GIF test file ⁕ Created with GIMP',
+ ],
+ ] ],
+ [ 'animated-xmp.gif',
+ [
+ 'Artist' => 'Bawolff',
+ 'ImageDescription' => [
+ 'x-default' => 'A file to test GIF',
+ '_type' => 'lang',
+ ],
+ 'SublocationDest' => 'The interwebs',
+ 'GIFFileComment' =>
+ [
+ 'GIƒ·test·file',
+ ],
+ ]
+ ],
+ ];
+ }
+
+ /**
+ * @param string $filename
+ * @param float $expectedLength
+ * @dataProvider provideGetLength
+ * @covers GIFHandler::getLength
+ */
+ public function testGetLength( $filename, $expectedLength ) {
+ $file = $this->dataFile( $filename, 'image/gif' );
+ $actualLength = $file->getLength();
+ $this->assertEquals( $expectedLength, $actualLength, '', 0.00001 );
+ }
+
+ public function provideGetLength() {
+ return [
+ [ 'animated.gif', 2.4 ],
+ [ 'animated-xmp.gif', 2.4 ],
+ [ 'nonanimated', 0.0 ],
+ [ 'Bishzilla_blink.gif', 1.4 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/IPTCTest.php b/www/wiki/tests/phpunit/includes/media/IPTCTest.php
new file mode 100644
index 00000000..4b3ba075
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/IPTCTest.php
@@ -0,0 +1,85 @@
+<?php
+
+/**
+ * @group Media
+ */
+class IPTCTest extends MediaWikiTestCase {
+
+ /**
+ * @covers IPTC::getCharset
+ */
+ public function testRecognizeUtf8() {
+ // utf-8 is the only one used in practise.
+ $res = IPTC::getCharset( "\x1b%G" );
+ $this->assertEquals( 'UTF-8', $res );
+ }
+
+ /**
+ * @covers IPTC::parse
+ */
+ public function testIPTCParseNoCharset88591() {
+ // basically IPTC for keyword with value of 0xBC which is 1/4 in iso-8859-1
+ // This data doesn't specify a charset. We're supposed to guess
+ // (which basically means utf-8 if valid, windows 1252 (iso 8859-1) if not)
+ $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x06\x1c\x02\x19\x00\x01\xBC";
+ $res = IPTC::parse( $iptcData );
+ $this->assertEquals( [ '¼' ], $res['Keywords'] );
+ }
+
+ /**
+ * @covers IPTC::parse
+ */
+ public function testIPTCParseNoCharset88591b() {
+ /* This one contains a sequence that's valid iso 8859-1 but not valid utf8 */
+ /* \xC3 = Ã, \xB8 = ¸ */
+ $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x09\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8";
+ $res = IPTC::parse( $iptcData );
+ $this->assertEquals( [ 'ÃÃø' ], $res['Keywords'] );
+ }
+
+ /**
+ * Same as testIPTCParseNoCharset88591b, but forcing the charset to utf-8.
+ * What should happen is the first "\xC3\xC3" should be dropped as invalid,
+ * leaving \xC3\xB8, which is ø
+ * @covers IPTC::parse
+ */
+ public function testIPTCParseForcedUTFButInvalid() {
+ $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x11\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8"
+ . "\x1c\x01\x5A\x00\x03\x1B\x25\x47";
+ $res = IPTC::parse( $iptcData );
+ $this->assertEquals( [ 'ø' ], $res['Keywords'] );
+ }
+
+ /**
+ * @covers IPTC::parse
+ */
+ public function testIPTCParseNoCharsetUTF8() {
+ $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x07\x1c\x02\x19\x00\x02¼";
+ $res = IPTC::parse( $iptcData );
+ $this->assertEquals( [ '¼' ], $res['Keywords'] );
+ }
+
+ /**
+ * Testing something that has 2 values for keyword
+ * @covers IPTC::parse
+ */
+ public function testIPTCParseMulti() {
+ $iptcData = /* identifier */ "Photoshop 3.0\08BIM\4\4"
+ /* length */ . "\0\0\0\0\0\x0D"
+ . "\x1c\x02\x19" . "\x00\x01" . "\xBC"
+ . "\x1c\x02\x19" . "\x00\x02" . "\xBC\xBD";
+ $res = IPTC::parse( $iptcData );
+ $this->assertEquals( [ '¼', '¼½' ], $res['Keywords'] );
+ }
+
+ /**
+ * @covers IPTC::parse
+ */
+ public function testIPTCParseUTF8() {
+ // This has the magic "\x1c\x01\x5A\x00\x03\x1B\x25\x47" which marks content as UTF8.
+ $iptcData =
+ "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x0F\x1c\x02\x19\x00\x02¼\x1c\x01\x5A\x00\x03\x1B\x25\x47";
+ $res = IPTC::parse( $iptcData );
+ $this->assertEquals( [ '¼' ], $res['Keywords'] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/JpegMetadataExtractorTest.php b/www/wiki/tests/phpunit/includes/media/JpegMetadataExtractorTest.php
new file mode 100644
index 00000000..c943cef9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/JpegMetadataExtractorTest.php
@@ -0,0 +1,128 @@
+<?php
+/**
+ * @todo Could use a test of extended XMP segments. Hard to find programs that
+ * create example files, and creating my own in vim propbably wouldn't
+ * serve as a very good "test". (Adobe photoshop probably creates such files
+ * but it costs money). The implementation of it currently in MediaWiki is based
+ * solely on reading the standard, without any real world test files.
+ *
+ * @group Media
+ * @covers JpegMetadataExtractor
+ */
+class JpegMetadataExtractorTest extends MediaWikiTestCase {
+
+ protected $filePath;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->filePath = __DIR__ . '/../../data/media/';
+ }
+
+ /**
+ * We also use this test to test padding bytes don't
+ * screw stuff up
+ *
+ * @param string $file Filename
+ *
+ * @dataProvider provideUtf8Comment
+ */
+ public function testUtf8Comment( $file ) {
+ $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . $file );
+ $this->assertEquals( [ 'UTF-8 JPEG Comment — ¼' ], $res['COM'] );
+ }
+
+ public static function provideUtf8Comment() {
+ return [
+ [ 'jpeg-comment-utf.jpg' ],
+ [ 'jpeg-padding-even.jpg' ],
+ [ 'jpeg-padding-odd.jpg' ],
+ ];
+ }
+
+ /** The file is iso-8859-1, but it should get auto converted */
+ public function testIso88591Comment() {
+ $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-iso8859-1.jpg' );
+ $this->assertEquals( [ 'ISO-8859-1 JPEG Comment - ¼' ], $res['COM'] );
+ }
+
+ /** Comment values that are non-textual (random binary junk) should not be shown.
+ * The example test file has a comment with a 0x5 byte in it which is a control character
+ * and considered binary junk for our purposes.
+ */
+ public function testBinaryCommentStripped() {
+ $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-binary.jpg' );
+ $this->assertEmpty( $res['COM'] );
+ }
+
+ /* Very rarely a file can have multiple comments.
+ * Order of comments is based on order inside the file.
+ */
+ public function testMultipleComment() {
+ $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-multiple.jpg' );
+ $this->assertEquals( [ 'foo', 'bar' ], $res['COM'] );
+ }
+
+ public function testXMPExtraction() {
+ $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' );
+ $expected = file_get_contents( $this->filePath . 'jpeg-xmp-psir.xmp' );
+ $this->assertEquals( $expected, $res['XMP'] );
+ }
+
+ public function testPSIRExtraction() {
+ $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' );
+ $expected = '50686f746f73686f7020332e30003842494d04040000000'
+ . '000181c02190004746573741c02190003666f6f1c020000020004';
+ $this->assertEquals( $expected, bin2hex( $res['PSIR'][0] ) );
+ }
+
+ public function testXMPExtractionAltAppId() {
+ $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-alt.jpg' );
+ $expected = file_get_contents( $this->filePath . 'jpeg-xmp-psir.xmp' );
+ $this->assertEquals( $expected, $res['XMP'] );
+ }
+
+ public function testIPTCHashComparisionNoHash() {
+ $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' );
+ $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] );
+
+ $this->assertEquals( 'iptc-no-hash', $res );
+ }
+
+ public function testIPTCHashComparisionBadHash() {
+ $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-iptc-bad-hash.jpg' );
+ $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] );
+
+ $this->assertEquals( 'iptc-bad-hash', $res );
+ }
+
+ public function testIPTCHashComparisionGoodHash() {
+ $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-iptc-good-hash.jpg' );
+ $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] );
+
+ $this->assertEquals( 'iptc-good-hash', $res );
+ }
+
+ public function testExifByteOrder() {
+ $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'exif-user-comment.jpg' );
+ $expected = 'BE';
+ $this->assertEquals( $expected, $res['byteOrder'] );
+ }
+
+ public function testInfiniteRead() {
+ // test file truncated right after a segment, which previously
+ // caused an infinite loop looking for the next segment byte.
+ // Should get past infinite loop and throw in wfUnpack()
+ $this->setExpectedException( 'MWException' );
+ $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-segment-loop1.jpg' );
+ }
+
+ public function testInfiniteRead2() {
+ // test file truncated after a segment's marker and size, which
+ // would cause a seek past end of file. Seek past end of file
+ // doesn't actually fail, but prevents further reading and was
+ // devolving into the previous case (testInfiniteRead).
+ $this->setExpectedException( 'MWException' );
+ $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-segment-loop2.jpg' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/JpegPixelFormatTest.php b/www/wiki/tests/phpunit/includes/media/JpegPixelFormatTest.php
new file mode 100644
index 00000000..6815a62b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/JpegPixelFormatTest.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * Tests related to JPEG chroma subsampling via $wgJpegPixelFormat setting.
+ *
+ * @group Media
+ * @group medium
+ *
+ * @todo covers tags
+ */
+class JpegPixelFormatTest extends MediaWikiMediaTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ }
+
+ /**
+ * Mark this test as creating thumbnail files.
+ */
+ protected function createsThumbnails() {
+ return true;
+ }
+
+ /**
+ *
+ * @dataProvider providePixelFormats
+ */
+ public function testPixelFormatRendering( $sourceFile, $pixelFormat, $samplingFactor ) {
+ global $wgUseImageMagick, $wgUseImageResize;
+ if ( !$wgUseImageMagick ) {
+ $this->markTestSkipped( "This test is only applicable when using ImageMagick thumbnailing" );
+ }
+ if ( !$wgUseImageResize ) {
+ $this->markTestSkipped( "This test is only applicable when using thumbnailing" );
+ }
+
+ $fmtStr = var_export( $pixelFormat, true );
+ $this->setMwGlobals( 'wgJpegPixelFormat', $pixelFormat );
+
+ $file = $this->dataFile( $sourceFile, 'image/jpeg' );
+
+ $params = [
+ 'width' => 320,
+ ];
+ $thumb = $file->transform( $params, File::RENDER_NOW | File::RENDER_FORCE );
+ $this->assertTrue( !$thumb->isError(), "created JPEG thumbnail for pixel format $fmtStr" );
+
+ $path = $thumb->getLocalCopyPath();
+ $this->assertTrue( is_string( $path ), "path returned for JPEG thumbnail for $fmtStr" );
+
+ $cmd = [
+ 'identify',
+ '-format',
+ '%[jpeg:sampling-factor]',
+ $path
+ ];
+ $retval = null;
+ $output = wfShellExec( $cmd, $retval );
+ $this->assertTrue( $retval === 0, "ImageMagick's identify command should return success" );
+
+ $expected = $samplingFactor;
+ $actual = trim( $output );
+ $this->assertEquals(
+ $expected,
+ trim( $output ),
+ "IM identify expects JPEG chroma subsampling \"$expected\" for $fmtStr"
+ );
+ }
+
+ public static function providePixelFormats() {
+ return [
+ // From 4:4:4 source file
+ [
+ 'yuv444.jpg',
+ false,
+ '1x1,1x1,1x1'
+ ],
+ [
+ 'yuv444.jpg',
+ 'yuv444',
+ '1x1,1x1,1x1'
+ ],
+ [
+ 'yuv444.jpg',
+ 'yuv422',
+ '2x1,1x1,1x1'
+ ],
+ [
+ 'yuv444.jpg',
+ 'yuv420',
+ '2x2,1x1,1x1'
+ ],
+ // From 4:2:0 source file
+ [
+ 'yuv420.jpg',
+ false,
+ '2x2,1x1,1x1'
+ ],
+ [
+ 'yuv420.jpg',
+ 'yuv444',
+ '1x1,1x1,1x1'
+ ],
+ [
+ 'yuv420.jpg',
+ 'yuv422',
+ '2x1,1x1,1x1'
+ ],
+ [
+ 'yuv420.jpg',
+ 'yuv420',
+ '2x2,1x1,1x1'
+ ]
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/JpegTest.php b/www/wiki/tests/phpunit/includes/media/JpegTest.php
new file mode 100644
index 00000000..13de7ff9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/JpegTest.php
@@ -0,0 +1,122 @@
+<?php
+
+/**
+ * @group Media
+ * @covers JpegHandler
+ */
+class JpegTest extends MediaWikiMediaTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ $this->checkPHPExtension( 'exif' );
+
+ $this->setMwGlobals( 'wgShowEXIF', true );
+
+ $this->handler = new JpegHandler;
+ }
+
+ public function testInvalidFile() {
+ $file = $this->dataFile( 'README', 'image/jpeg' );
+ $res = $this->handler->getMetadata( $file, $this->filePath . 'README' );
+ $this->assertEquals( ExifBitmapHandler::BROKEN_FILE, $res );
+ }
+
+ public function testJpegMetadataExtraction() {
+ $file = $this->dataFile( 'test.jpg', 'image/jpeg' );
+ $res = $this->handler->getMetadata( $file, $this->filePath . 'test.jpg' );
+ // phpcs:ignore Generic.Files.LineLength
+ $expected = 'a:7:{s:16:"ImageDescription";s:9:"Test file";s:11:"XResolution";s:4:"72/1";s:11:"YResolution";s:4:"72/1";s:14:"ResolutionUnit";i:2;s:16:"YCbCrPositioning";i:1;s:15:"JPEGFileComment";a:1:{i:0;s:17:"Created with GIMP";}s:22:"MEDIAWIKI_EXIF_VERSION";i:2;}';
+
+ // Unserialize in case serialization format ever changes.
+ $this->assertEquals( unserialize( $expected ), unserialize( $res ) );
+ }
+
+ /**
+ * @covers JpegHandler::getCommonMetaArray
+ */
+ public function testGetIndependentMetaArray() {
+ $file = $this->dataFile( 'test.jpg', 'image/jpeg' );
+ $res = $this->handler->getCommonMetaArray( $file );
+ $expected = [
+ 'ImageDescription' => 'Test file',
+ 'XResolution' => '72/1',
+ 'YResolution' => '72/1',
+ 'ResolutionUnit' => 2,
+ 'YCbCrPositioning' => 1,
+ 'JPEGFileComment' => [
+ 'Created with GIMP',
+ ],
+ ];
+
+ $this->assertEquals( $res, $expected );
+ }
+
+ /**
+ * @dataProvider provideSwappingICCProfile
+ * @covers JpegHandler::swapICCProfile
+ */
+ public function testSwappingICCProfile(
+ $sourceFilename, $controlFilename, $newProfileFilename, $oldProfileName
+ ) {
+ global $wgExiftool;
+
+ if ( !$wgExiftool || !is_file( $wgExiftool ) ) {
+ $this->markTestSkipped( "Exiftool not installed, cannot test ICC profile swapping" );
+ }
+
+ $this->setMwGlobals( 'wgUseTinyRGBForJPGThumbnails', true );
+
+ $sourceFilepath = $this->filePath . $sourceFilename;
+ $controlFilepath = $this->filePath . $controlFilename;
+ $profileFilepath = $this->filePath . $newProfileFilename;
+ $filepath = $this->getNewTempFile();
+
+ copy( $sourceFilepath, $filepath );
+
+ $file = $this->dataFile( $sourceFilename, 'image/jpeg' );
+ $this->handler->swapICCProfile(
+ $filepath,
+ [ 'sRGB', '-' ],
+ [ $oldProfileName ],
+ $profileFilepath
+ );
+
+ $this->assertEquals(
+ sha1( file_get_contents( $filepath ) ),
+ sha1( file_get_contents( $controlFilepath ) )
+ );
+ }
+
+ public function provideSwappingICCProfile() {
+ return [
+ // File with sRGB should end up with TinyRGB
+ [
+ 'srgb.jpg',
+ 'tinyrgb.jpg',
+ 'tinyrgb.icc',
+ 'sRGB IEC61966-2.1'
+ ],
+ // File with TinyRGB should be left unchanged
+ [
+ 'tinyrgb.jpg',
+ 'tinyrgb.jpg',
+ 'tinyrgb.icc',
+ 'sRGB IEC61966-2.1'
+ ],
+ // File without profile should end up with TinyRGB
+ [
+ 'missingprofile.jpg',
+ 'tinyrgb.jpg',
+ 'tinyrgb.icc',
+ 'sRGB IEC61966-2.1'
+ ],
+ // Non-sRGB file should be left untouched
+ [
+ 'adobergb.jpg',
+ 'adobergb.jpg',
+ 'tinyrgb.icc',
+ 'sRGB IEC61966-2.1'
+ ]
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/MediaHandlerTest.php b/www/wiki/tests/phpunit/includes/media/MediaHandlerTest.php
new file mode 100644
index 00000000..7a052f60
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/MediaHandlerTest.php
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @group Media
+ */
+class MediaHandlerTest extends MediaWikiTestCase {
+
+ /**
+ * @covers MediaHandler::fitBoxWidth
+ *
+ * @dataProvider provideTestFitBoxWidth
+ */
+ public function testFitBoxWidth( $width, $height, $max, $expected ) {
+ $y = round( $expected * $height / $width );
+ $result = MediaHandler::fitBoxWidth( $width, $height, $max );
+ $y2 = round( $result * $height / $width );
+ $this->assertEquals( $expected,
+ $result,
+ "($width, $height, $max) wanted: {$expected}x$y, got: {z$result}x$y2" );
+ }
+
+ public static function provideTestFitBoxWidth() {
+ return array_merge(
+ static::generateTestFitBoxWidthData( 50, 50, [
+ 50 => 50,
+ 17 => 17,
+ 18 => 18 ]
+ ),
+ static::generateTestFitBoxWidthData( 366, 300, [
+ 50 => 61,
+ 17 => 21,
+ 18 => 22 ]
+ ),
+ static::generateTestFitBoxWidthData( 300, 366, [
+ 50 => 41,
+ 17 => 14,
+ 18 => 15 ]
+ ),
+ static::generateTestFitBoxWidthData( 100, 400, [
+ 50 => 12,
+ 17 => 4,
+ 18 => 4 ]
+ )
+ );
+ }
+
+ /**
+ * Generate single test cases by combining the dimensions and tests contents
+ *
+ * It creates:
+ * [$width, $height, $max, $expected],
+ * [$width, $height, $max2, $expected2], ...
+ * out of parameters:
+ * $width, $height, { $max => $expected, $max2 => $expected2, ... }
+ *
+ * @param int $width
+ * @param int $height
+ * @param array $tests associative array of $max => $expected values
+ * @return array
+ */
+ private static function generateTestFitBoxWidthData( $width, $height, $tests ) {
+ $result = [];
+ foreach ( $tests as $max => $expected ) {
+ $result[] = [ $width, $height, $max, $expected ];
+ }
+ return $result;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/MediaWikiMediaTestCase.php b/www/wiki/tests/phpunit/includes/media/MediaWikiMediaTestCase.php
new file mode 100644
index 00000000..a4e8056a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/MediaWikiMediaTestCase.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * Specificly for testing Media handlers. Sets up a FileRepo backend
+ */
+abstract class MediaWikiMediaTestCase extends MediaWikiTestCase {
+
+ /** @var FileRepo */
+ protected $repo;
+ /** @var FSFileBackend */
+ protected $backend;
+ /** @var string */
+ protected $filePath;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->filePath = $this->getFilePath();
+ $containers = [ 'data' => $this->filePath ];
+ if ( $this->createsThumbnails() ) {
+ // We need a temp directory for the thumbnails
+ // the container is named 'temp-thumb' because it is the
+ // thumb directory for a repo named "temp".
+ $containers['temp-thumb'] = $this->getNewTempDirectory();
+ }
+
+ $this->backend = new FSFileBackend( [
+ 'name' => 'localtesting',
+ 'wikiId' => wfWikiID(),
+ 'containerPaths' => $containers,
+ 'tmpDirectory' => $this->getNewTempDirectory()
+ ] );
+ $this->repo = new FileRepo( $this->getRepoOptions() );
+ }
+
+ /**
+ * @return array Argument for FileRepo constructor
+ */
+ protected function getRepoOptions() {
+ return [
+ 'name' => 'temp',
+ 'url' => 'http://localhost/thumbtest',
+ 'backend' => $this->backend
+ ];
+ }
+
+ /**
+ * The result of this method will set the file path to use,
+ * as well as the protected member $filePath
+ *
+ * @return string Path where files are
+ */
+ protected function getFilePath() {
+ return __DIR__ . '/../../data/media/';
+ }
+
+ /**
+ * Will the test create thumbnails (and thus do we need to set aside
+ * a temporary directory for them?)
+ *
+ * Override this method if your test case creates thumbnails
+ *
+ * @return bool
+ */
+ protected function createsThumbnails() {
+ return false;
+ }
+
+ /**
+ * Utility function: Get a new file object for a file on disk but not actually in db.
+ *
+ * File must be in the path returned by getFilePath()
+ * @param string $name File name
+ * @param string $type MIME type [optional]
+ * @return UnregisteredLocalFile
+ */
+ protected function dataFile( $name, $type = null ) {
+ if ( !$type ) {
+ // Autodetect by file extension for the lazy.
+ $magic = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer();
+ $parts = explode( $name, '.' );
+ $type = $magic->guessTypesForExtension( $parts[count( $parts ) - 1] );
+ }
+ return new UnregisteredLocalFile( false, $this->repo,
+ "mwstore://localtesting/data/$name", $type );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/PNGMetadataExtractorTest.php b/www/wiki/tests/phpunit/includes/media/PNGMetadataExtractorTest.php
new file mode 100644
index 00000000..22de9357
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/PNGMetadataExtractorTest.php
@@ -0,0 +1,137 @@
+<?php
+
+/**
+ * @group Media
+ * @covers PNGMetadataExtractor
+ */
+class PNGMetadataExtractorTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ $this->filePath = __DIR__ . '/../../data/media/';
+ }
+
+ /**
+ * Tests zTXt tag (compressed textual metadata)
+ */
+ public function testPngNativetZtxt() {
+ $this->checkPHPExtension( 'zlib' );
+
+ $meta = PNGMetadataExtractor::getMetadata( $this->filePath .
+ 'Png-native-test.png' );
+ $expected = "foo bar baz foo foo foo foof foo foo foo foo";
+ $this->assertArrayHasKey( 'text', $meta );
+ $meta = $meta['text'];
+ $this->assertArrayHasKey( 'Make', $meta );
+ $this->assertArrayHasKey( 'x-default', $meta['Make'] );
+
+ $this->assertEquals( $expected, $meta['Make']['x-default'] );
+ }
+
+ /**
+ * Test tEXt tag (Uncompressed textual metadata)
+ */
+ public function testPngNativeText() {
+ $meta = PNGMetadataExtractor::getMetadata( $this->filePath .
+ 'Png-native-test.png' );
+ $expected = "Some long image desc";
+ $this->assertArrayHasKey( 'text', $meta );
+ $meta = $meta['text'];
+ $this->assertArrayHasKey( 'ImageDescription', $meta );
+ $this->assertArrayHasKey( 'x-default', $meta['ImageDescription'] );
+ $this->assertArrayHasKey( '_type', $meta['ImageDescription'] );
+
+ $this->assertEquals( $expected, $meta['ImageDescription']['x-default'] );
+ }
+
+ /**
+ * tEXt tags must be encoded iso-8859-1 (vs iTXt which are utf-8)
+ * Make sure non-ascii characters get converted properly
+ */
+ public function testPngNativeTextNonAscii() {
+ $meta = PNGMetadataExtractor::getMetadata( $this->filePath .
+ 'Png-native-test.png' );
+
+ // Note the Copyright symbol here is a utf-8 one
+ // (aka \xC2\xA9) where in the file its iso-8859-1
+ // encoded as just \xA9.
+ $expected = "© 2010 Bawolff";
+
+ $this->assertArrayHasKey( 'text', $meta );
+ $meta = $meta['text'];
+ $this->assertArrayHasKey( 'Copyright', $meta );
+ $this->assertArrayHasKey( 'x-default', $meta['Copyright'] );
+
+ $this->assertEquals( $expected, $meta['Copyright']['x-default'] );
+ }
+
+ /**
+ * Given a normal static PNG, check the animation metadata returned.
+ */
+ public function testStaticPngAnimationMetadata() {
+ $meta = PNGMetadataExtractor::getMetadata( $this->filePath .
+ 'Png-native-test.png' );
+
+ $this->assertEquals( 0, $meta['frameCount'] );
+ $this->assertEquals( 1, $meta['loopCount'] );
+ $this->assertEquals( 0, $meta['duration'] );
+ }
+
+ /**
+ * Given an animated APNG image file
+ * check it gets animated metadata right.
+ */
+ public function testApngAnimationMetadata() {
+ $meta = PNGMetadataExtractor::getMetadata( $this->filePath .
+ 'Animated_PNG_example_bouncing_beach_ball.png' );
+
+ $this->assertEquals( 20, $meta['frameCount'] );
+ // Note loop count of 0 = infinity
+ $this->assertEquals( 0, $meta['loopCount'] );
+ $this->assertEquals( 1.5, $meta['duration'], '', 0.00001 );
+ }
+
+ public function testPngBitDepth8() {
+ $meta = PNGMetadataExtractor::getMetadata( $this->filePath .
+ 'Png-native-test.png' );
+
+ $this->assertEquals( 8, $meta['bitDepth'] );
+ }
+
+ public function testPngBitDepth1() {
+ $meta = PNGMetadataExtractor::getMetadata( $this->filePath .
+ '1bit-png.png' );
+ $this->assertEquals( 1, $meta['bitDepth'] );
+ }
+
+ public function testPngIndexColour() {
+ $meta = PNGMetadataExtractor::getMetadata( $this->filePath .
+ 'Png-native-test.png' );
+
+ $this->assertEquals( 'index-coloured', $meta['colorType'] );
+ }
+
+ public function testPngRgbColour() {
+ $meta = PNGMetadataExtractor::getMetadata( $this->filePath .
+ 'rgb-png.png' );
+ $this->assertEquals( 'truecolour-alpha', $meta['colorType'] );
+ }
+
+ public function testPngRgbNoAlphaColour() {
+ $meta = PNGMetadataExtractor::getMetadata( $this->filePath .
+ 'rgb-na-png.png' );
+ $this->assertEquals( 'truecolour', $meta['colorType'] );
+ }
+
+ public function testPngGreyscaleColour() {
+ $meta = PNGMetadataExtractor::getMetadata( $this->filePath .
+ 'greyscale-png.png' );
+ $this->assertEquals( 'greyscale-alpha', $meta['colorType'] );
+ }
+
+ public function testPngGreyscaleNoAlphaColour() {
+ $meta = PNGMetadataExtractor::getMetadata( $this->filePath .
+ 'greyscale-na-png.png' );
+ $this->assertEquals( 'greyscale', $meta['colorType'] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/PNGTest.php b/www/wiki/tests/phpunit/includes/media/PNGTest.php
new file mode 100644
index 00000000..5a66586e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/PNGTest.php
@@ -0,0 +1,161 @@
+<?php
+
+/**
+ * @group Media
+ */
+class PNGHandlerTest extends MediaWikiMediaTestCase {
+
+ /** @var PNGHandler */
+ protected $handler;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->handler = new PNGHandler();
+ }
+
+ /**
+ * @covers PNGHandler::getMetadata
+ */
+ public function testInvalidFile() {
+ $res = $this->handler->getMetadata( null, $this->filePath . '/README' );
+ $this->assertEquals( PNGHandler::BROKEN_FILE, $res );
+ }
+
+ /**
+ * @param string $filename Basename of the file to check
+ * @param bool $expected Expected result.
+ * @dataProvider provideIsAnimated
+ * @covers PNGHandler::isAnimatedImage
+ */
+ public function testIsAnimanted( $filename, $expected ) {
+ $file = $this->dataFile( $filename, 'image/png' );
+ $actual = $this->handler->isAnimatedImage( $file );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideIsAnimated() {
+ return [
+ [ 'Animated_PNG_example_bouncing_beach_ball.png', true ],
+ [ '1bit-png.png', false ],
+ ];
+ }
+
+ /**
+ * @param string $filename
+ * @param int $expected Total image area
+ * @dataProvider provideGetImageArea
+ * @covers PNGHandler::getImageArea
+ */
+ public function testGetImageArea( $filename, $expected ) {
+ $file = $this->dataFile( $filename, 'image/png' );
+ $actual = $this->handler->getImageArea( $file, $file->getWidth(), $file->getHeight() );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideGetImageArea() {
+ return [
+ [ '1bit-png.png', 2500 ],
+ [ 'greyscale-png.png', 2500 ],
+ [ 'Png-native-test.png', 126000 ],
+ [ 'Animated_PNG_example_bouncing_beach_ball.png', 10000 ],
+ ];
+ }
+
+ /**
+ * @param string $metadata Serialized metadata
+ * @param int $expected One of the class constants of PNGHandler
+ * @dataProvider provideIsMetadataValid
+ * @covers PNGHandler::isMetadataValid
+ */
+ public function testIsMetadataValid( $metadata, $expected ) {
+ $actual = $this->handler->isMetadataValid( null, $metadata );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideIsMetadataValid() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [ PNGHandler::BROKEN_FILE, PNGHandler::METADATA_GOOD ],
+ [ '', PNGHandler::METADATA_BAD ],
+ [ null, PNGHandler::METADATA_BAD ],
+ [ 'Something invalid!', PNGHandler::METADATA_BAD ],
+ [
+ 'a:6:{s:10:"frameCount";i:0;s:9:"loopCount";i:1;s:8:"duration";d:0;s:8:"bitDepth";i:8;s:9:"colorType";s:10:"truecolour";s:8:"metadata";a:1:{s:15:"_MW_PNG_VERSION";i:1;}}',
+ PNGHandler::METADATA_GOOD
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @param string $filename
+ * @param string $expected Serialized array
+ * @dataProvider provideGetMetadata
+ * @covers PNGHandler::getMetadata
+ */
+ public function testGetMetadata( $filename, $expected ) {
+ $file = $this->dataFile( $filename, 'image/png' );
+ $actual = $this->handler->getMetadata( $file, "$this->filePath/$filename" );
+// $this->assertEquals( unserialize( $expected ), unserialize( $actual ) );
+ $this->assertEquals( ( $expected ), ( $actual ) );
+ }
+
+ public static function provideGetMetadata() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ 'rgb-na-png.png',
+ 'a:6:{s:10:"frameCount";i:0;s:9:"loopCount";i:1;s:8:"duration";d:0;s:8:"bitDepth";i:8;s:9:"colorType";s:10:"truecolour";s:8:"metadata";a:1:{s:15:"_MW_PNG_VERSION";i:1;}}'
+ ],
+ [
+ 'xmp.png',
+ 'a:6:{s:10:"frameCount";i:0;s:9:"loopCount";i:1;s:8:"duration";d:0;s:8:"bitDepth";i:1;s:9:"colorType";s:14:"index-coloured";s:8:"metadata";a:2:{s:12:"SerialNumber";s:9:"123456789";s:15:"_MW_PNG_VERSION";i:1;}}'
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @param string $filename
+ * @param array $expected Expected standard metadata
+ * @dataProvider provideGetIndependentMetaArray
+ * @covers PNGHandler::getCommonMetaArray
+ */
+ public function testGetIndependentMetaArray( $filename, $expected ) {
+ $file = $this->dataFile( $filename, 'image/png' );
+ $actual = $this->handler->getCommonMetaArray( $file );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideGetIndependentMetaArray() {
+ return [
+ [ 'rgb-na-png.png', [] ],
+ [ 'xmp.png',
+ [
+ 'SerialNumber' => '123456789',
+ ]
+ ],
+ ];
+ }
+
+ /**
+ * @param string $filename
+ * @param float $expectedLength
+ * @dataProvider provideGetLength
+ * @covers PNGHandler::getLength
+ */
+ public function testGetLength( $filename, $expectedLength ) {
+ $file = $this->dataFile( $filename, 'image/png' );
+ $actualLength = $file->getLength();
+ $this->assertEquals( $expectedLength, $actualLength, '', 0.00001 );
+ }
+
+ public function provideGetLength() {
+ return [
+ [ 'Animated_PNG_example_bouncing_beach_ball.png', 1.5 ],
+ [ 'Png-native-test.png', 0.0 ],
+ [ 'greyscale-png.png', 0.0 ],
+ [ '1bit-png.png', 0.0 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/SVGMetadataExtractorTest.php b/www/wiki/tests/phpunit/includes/media/SVGMetadataExtractorTest.php
new file mode 100644
index 00000000..6fbb4740
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/SVGMetadataExtractorTest.php
@@ -0,0 +1,155 @@
+<?php
+
+/**
+ * @group Media
+ * @covers SVGMetadataExtractor
+ */
+class SVGMetadataExtractorTest extends MediaWikiTestCase {
+
+ /**
+ * @dataProvider provideSvgFiles
+ */
+ public function testGetMetadata( $infile, $expected ) {
+ $this->assertMetadata( $infile, $expected );
+ }
+
+ /**
+ * @dataProvider provideSvgFilesWithXMLMetadata
+ */
+ public function testGetXMLMetadata( $infile, $expected ) {
+ $r = new XMLReader();
+ if ( !method_exists( $r, 'readInnerXML' ) ) {
+ $this->markTestSkipped( 'XMLReader::readInnerXML() does not exist (libxml >2.6.20 needed).' );
+
+ return;
+ }
+ $this->assertMetadata( $infile, $expected );
+ }
+
+ function assertMetadata( $infile, $expected ) {
+ try {
+ $data = SVGMetadataExtractor::getMetadata( $infile );
+ $this->assertEquals( $expected, $data, 'SVG metadata extraction test' );
+ } catch ( MWException $e ) {
+ if ( $expected === false ) {
+ $this->assertTrue( true, 'SVG metadata extracted test (expected failure)' );
+ } else {
+ throw $e;
+ }
+ }
+ }
+
+ public static function provideSvgFiles() {
+ $base = __DIR__ . '/../../data/media';
+
+ return [
+ [
+ "$base/Wikimedia-logo.svg",
+ [
+ 'width' => 1024,
+ 'height' => 1024,
+ 'originalWidth' => '1024',
+ 'originalHeight' => '1024',
+ 'translations' => [],
+ ]
+ ],
+ [
+ "$base/QA_icon.svg",
+ [
+ 'width' => 60,
+ 'height' => 60,
+ 'originalWidth' => '60',
+ 'originalHeight' => '60',
+ 'translations' => [],
+ ]
+ ],
+ [
+ "$base/Gtk-media-play-ltr.svg",
+ [
+ 'width' => 60,
+ 'height' => 60,
+ 'originalWidth' => '60.0000000',
+ 'originalHeight' => '60.0000000',
+ 'translations' => [],
+ ]
+ ],
+ [
+ "$base/Toll_Texas_1.svg",
+ // This file triggered T33719, needs entity expansion in the xmlns checks
+ [
+ 'width' => 385,
+ 'height' => 385,
+ 'originalWidth' => '385',
+ 'originalHeight' => '385.0004883',
+ 'translations' => [],
+ ]
+ ],
+ [
+ "$base/Tux.svg",
+ [
+ 'width' => 512,
+ 'height' => 594,
+ 'originalWidth' => '100%',
+ 'originalHeight' => '100%',
+ 'title' => 'Tux',
+ 'translations' => [],
+ 'description' => 'For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg',
+ ]
+ ],
+ [
+ "$base/Speech_bubbles.svg",
+ [
+ 'width' => 627,
+ 'height' => 461,
+ 'originalWidth' => '17.7cm',
+ 'originalHeight' => '13cm',
+ 'translations' => [
+ 'de' => SVGReader::LANG_FULL_MATCH,
+ 'fr' => SVGReader::LANG_FULL_MATCH,
+ 'nl' => SVGReader::LANG_FULL_MATCH,
+ 'tlh-ca' => SVGReader::LANG_FULL_MATCH,
+ 'tlh' => SVGReader::LANG_PREFIX_MATCH
+ ],
+ ]
+ ],
+ [
+ "$base/Soccer_ball_animated.svg",
+ [
+ 'width' => 150,
+ 'height' => 150,
+ 'originalWidth' => '150',
+ 'originalHeight' => '150',
+ 'animated' => true,
+ 'translations' => []
+ ],
+ ],
+ ];
+ }
+
+ public static function provideSvgFilesWithXMLMetadata() {
+ $base = __DIR__ . '/../../data/media';
+ // phpcs:disable Generic.Files.LineLength
+ $metadata = '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+ <ns4:Work xmlns:ns4="http://creativecommons.org/ns#" rdf:about="">
+ <ns5:format xmlns:ns5="http://purl.org/dc/elements/1.1/">image/svg+xml</ns5:format>
+ <ns5:type xmlns:ns5="http://purl.org/dc/elements/1.1/" rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+ </ns4:Work>
+ </rdf:RDF>';
+ // phpcs:enable
+
+ $metadata = str_replace( "\r", '', $metadata ); // Windows compat
+ return [
+ [
+ "$base/US_states_by_total_state_tax_revenue.svg",
+ [
+ 'height' => 593,
+ 'metadata' => $metadata,
+ 'width' => 959,
+ 'originalWidth' => '958.69',
+ 'originalHeight' => '592.78998',
+ 'translations' => [],
+ ]
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/SVGTest.php b/www/wiki/tests/phpunit/includes/media/SVGTest.php
new file mode 100644
index 00000000..b68dd0ee
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/SVGTest.php
@@ -0,0 +1,113 @@
+<?php
+
+/**
+ * @group Media
+ */
+class SVGTest extends MediaWikiMediaTestCase {
+
+ /**
+ * @var SvgHandler
+ */
+ private $handler;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->filePath = __DIR__ . '/../../data/media/';
+
+ $this->setMwGlobals( 'wgShowEXIF', true );
+
+ $this->handler = new SvgHandler;
+ }
+
+ /**
+ * @param string $filename
+ * @param array $expected The expected independent metadata
+ * @dataProvider providerGetIndependentMetaArray
+ * @covers SvgHandler::getCommonMetaArray
+ */
+ public function testGetIndependentMetaArray( $filename, $expected ) {
+ $file = $this->dataFile( $filename, 'image/svg+xml' );
+ $res = $this->handler->getCommonMetaArray( $file );
+
+ $this->assertEquals( $res, $expected );
+ }
+
+ public static function providerGetIndependentMetaArray() {
+ return [
+ [ 'Tux.svg', [
+ 'ObjectName' => 'Tux',
+ 'ImageDescription' =>
+ 'For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg',
+ ] ],
+ [ 'Wikimedia-logo.svg', [] ]
+ ];
+ }
+
+ /**
+ * @param string $userPreferredLanguage
+ * @param array $svgLanguages
+ * @param string $expectedMatch
+ * @dataProvider providerGetMatchedLanguage
+ * @covers SvgHandler::getMatchedLanguage
+ */
+ public function testGetMatchedLanguage( $userPreferredLanguage, $svgLanguages, $expectedMatch ) {
+ $match = $this->handler->getMatchedLanguage( $userPreferredLanguage, $svgLanguages );
+ $this->assertEquals( $expectedMatch, $match );
+ }
+
+ public function providerGetMatchedLanguage() {
+ return [
+ 'no match' => [
+ 'userPreferredLanguage' => 'en',
+ 'svgLanguages' => [ 'de-DE', 'zh', 'ga', 'fr', 'sr-Latn-ME' ],
+ 'expectedMatch' => null,
+ ],
+ 'no subtags' => [
+ 'userPreferredLanguage' => 'en',
+ 'svgLanguages' => [ 'de', 'zh', 'en', 'fr' ],
+ 'expectedMatch' => 'en',
+ ],
+ 'user no subtags, svg 1 subtag' => [
+ 'userPreferredLanguage' => 'en',
+ 'svgLanguages' => [ 'de-DE', 'en-GB', 'en-US', 'fr' ],
+ 'expectedMatch' => 'en-GB',
+ ],
+ 'user no subtags, svg >1 subtag' => [
+ 'userPreferredLanguage' => 'sr',
+ 'svgLanguages' => [ 'de-DE', 'sr-Cyrl-BA', 'sr-Latn-ME', 'en-US', 'fr' ],
+ 'expectedMatch' => 'sr-Cyrl-BA',
+ ],
+ 'user 1 subtag, svg no subtags' => [
+ 'userPreferredLanguage' => 'en-US',
+ 'svgLanguages' => [ 'de', 'en', 'en', 'fr' ],
+ 'expectedMatch' => null,
+ ],
+ 'user 1 subtag, svg 1 subtag' => [
+ 'userPreferredLanguage' => 'en-US',
+ 'svgLanguages' => [ 'de-DE', 'en-GB', 'en-US', 'fr' ],
+ 'expectedMatch' => 'en-US',
+ ],
+ 'user 1 subtag, svg >1 subtag' => [
+ 'userPreferredLanguage' => 'sr-Latn',
+ 'svgLanguages' => [ 'de-DE', 'sr-Cyrl-BA', 'sr-Latn-ME', 'fr' ],
+ 'expectedMatch' => 'sr-Latn-ME',
+ ],
+ 'user >1 subtag, svg >1 subtag' => [
+ 'userPreferredLanguage' => 'sr-Latn-ME',
+ 'svgLanguages' => [ 'de-DE', 'sr-Cyrl-BA', 'sr-Latn-ME', 'en-US', 'fr' ],
+ 'expectedMatch' => 'sr-Latn-ME',
+ ],
+ 'user >1 subtag, svg <=1 subtag' => [
+ 'userPreferredLanguage' => 'sr-Latn-ME',
+ 'svgLanguages' => [ 'de-DE', 'sr-Cyrl', 'sr-Latn', 'en-US', 'fr' ],
+ 'expectedMatch' => null,
+ ],
+ 'ensure case-insensitive' => [
+ 'userPreferredLanguage' => 'sr-latn',
+ 'svgLanguages' => [ 'de-DE', 'sr-Cyrl', 'sr-Latn-ME', 'en-US', 'fr' ],
+ 'expectedMatch' => 'sr-Latn-ME',
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/TiffTest.php b/www/wiki/tests/phpunit/includes/media/TiffTest.php
new file mode 100644
index 00000000..8a69ec5b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/TiffTest.php
@@ -0,0 +1,44 @@
+<?php
+
+/**
+ * @group Media
+ */
+class TiffTest extends MediaWikiTestCase {
+
+ /** @var TiffHandler */
+ protected $handler;
+ /** @var string */
+ protected $filePath;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->checkPHPExtension( 'exif' );
+
+ $this->setMwGlobals( 'wgShowEXIF', true );
+
+ $this->filePath = __DIR__ . '/../../data/media/';
+ $this->handler = new TiffHandler;
+ }
+
+ /**
+ * @covers TiffHandler::getMetadata
+ */
+ public function testInvalidFile() {
+ $res = $this->handler->getMetadata( null, $this->filePath . 'README' );
+ $this->assertEquals( ExifBitmapHandler::BROKEN_FILE, $res );
+ }
+
+ /**
+ * @covers TiffHandler::getMetadata
+ */
+ public function testTiffMetadataExtraction() {
+ $res = $this->handler->getMetadata( null, $this->filePath . 'test.tiff' );
+
+ // phpcs:ignore Generic.Files.LineLength
+ $expected = 'a:16:{s:10:"ImageWidth";i:20;s:11:"ImageLength";i:20;s:13:"BitsPerSample";a:3:{i:0;i:8;i:1;i:8;i:2;i:8;}s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:16:"ImageDescription";s:17:"Created with GIMP";s:12:"StripOffsets";i:8;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:3;s:12:"RowsPerStrip";i:64;s:15:"StripByteCounts";i:238;s:11:"XResolution";s:19:"1207959552/16777216";s:11:"YResolution";s:19:"1207959552/16777216";s:19:"PlanarConfiguration";i:1;s:14:"ResolutionUnit";i:2;s:22:"MEDIAWIKI_EXIF_VERSION";i:2;}';
+
+ // Re-unserialize in case there are subtle differences between how versions
+ // of php serialize stuff.
+ $this->assertEquals( unserialize( $expected ), unserialize( $res ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/media/WebPTest.php b/www/wiki/tests/phpunit/includes/media/WebPTest.php
new file mode 100644
index 00000000..a0a99cc2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/WebPTest.php
@@ -0,0 +1,145 @@
+<?php
+
+/**
+ * @covers WebPHandler
+ */
+class WebPHandlerTest extends MediaWikiTestCase {
+ public function setUp() {
+ parent::setUp();
+ // Allocated file for testing
+ $this->tempFileName = tempnam( wfTempDir(), 'WEBP' );
+ }
+ public function tearDown() {
+ parent::tearDown();
+ unlink( $this->tempFileName );
+ }
+ /**
+ * @dataProvider provideTestExtractMetaData
+ */
+ public function testExtractMetaData( $header, $expectedResult ) {
+ // Put header into file
+ file_put_contents( $this->tempFileName, $header );
+
+ $this->assertEquals( $expectedResult, WebPHandler::extractMetadata( $this->tempFileName ) );
+ }
+ public function provideTestExtractMetaData() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ // Files from https://developers.google.com/speed/webp/gallery2
+ [ "\x52\x49\x46\x46\x90\x68\x01\x00\x57\x45\x42\x50\x56\x50\x38\x4C\x83\x68\x01\x00\x2F\x8F\x01\x4B\x10\x8D\x38\x6C\xDB\x46\x92\xE0\xE0\x82\x7B\x6C",
+ [ 'compression' => 'lossless', 'width' => 400, 'height' => 301 ] ],
+ [ "\x52\x49\x46\x46\x64\x5B\x00\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\x8F\x01\x00\x2C\x01\x00\x41\x4C\x50\x48\xE5\x0E",
+ [ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 400, 'height' => 301 ] ],
+ [ "\x52\x49\x46\x46\xA8\x72\x00\x00\x57\x45\x42\x50\x56\x50\x38\x4C\x9B\x72\x00\x00\x2F\x81\x81\x62\x10\x8D\x40\x8C\x24\x39\x6E\x73\x73\x38\x01\x96",
+ [ 'compression' => 'lossless', 'width' => 386, 'height' => 395 ] ],
+ [ "\x52\x49\x46\x46\xE0\x42\x00\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\x81\x01\x00\x8A\x01\x00\x41\x4C\x50\x48\x56\x10",
+ [ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 386, 'height' => 395 ] ],
+ [ "\x52\x49\x46\x46\x70\x61\x02\x00\x57\x45\x42\x50\x56\x50\x38\x4C\x63\x61\x02\x00\x2F\x1F\xC3\x95\x10\x8D\xC8\x72\xDB\xC8\x92\x24\xD8\x91\xD9\x91",
+ [ 'compression' => 'lossless', 'width' => 800, 'height' => 600 ] ],
+ [ "\x52\x49\x46\x46\x1C\x1D\x01\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\x1F\x03\x00\x57\x02\x00\x41\x4C\x50\x48\x25\x8B",
+ [ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 800, 'height' => 600 ] ],
+ [ "\x52\x49\x46\x46\xFA\xC5\x00\x00\x57\x45\x42\x50\x56\x50\x38\x4C\xEE\xC5\x00\x00\x2F\xA4\x81\x28\x10\x8D\x40\x68\x24\xC9\x91\xA4\xAE\xF3\x97\x75",
+ [ 'compression' => 'lossless', 'width' => 421, 'height' => 163 ] ],
+ [ "\x52\x49\x46\x46\xF6\x5D\x00\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\xA4\x01\x00\xA2\x00\x00\x41\x4C\x50\x48\x38\x1A",
+ [ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 421, 'height' => 163 ] ],
+ [ "\x52\x49\x46\x46\xC4\x96\x01\x00\x57\x45\x42\x50\x56\x50\x38\x4C\xB8\x96\x01\x00\x2F\x2B\xC1\x4A\x10\x11\x87\x6D\xDB\x48\x12\xFC\x60\xB0\x83\x24",
+ [ 'compression' => 'lossless', 'width' => 300, 'height' => 300 ] ],
+ [ "\x52\x49\x46\x46\x0A\x11\x01\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\x2B\x01\x00\x2B\x01\x00\x41\x4C\x50\x48\x67\x6E",
+ [ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 300, 'height' => 300 ] ],
+
+ // Lossy files from https://developers.google.com/speed/webp/gallery1
+ [ "\x52\x49\x46\x46\x68\x76\x00\x00\x57\x45\x42\x50\x56\x50\x38\x20\x5C\x76\x00\x00\xD2\xBE\x01\x9D\x01\x2A\x26\x02\x70\x01\x3E\xD5\x4E\x97\x43\xA2",
+ [ 'compression' => 'lossy', 'width' => 550, 'height' => 368 ] ],
+ [ "\x52\x49\x46\x46\xB0\xEC\x00\x00\x57\x45\x42\x50\x56\x50\x38\x20\xA4\xEC\x00\x00\xB2\x4B\x02\x9D\x01\x2A\x26\x02\x94\x01\x3E\xD1\x50\x96\x46\x26",
+ [ 'compression' => 'lossy', 'width' => 550, 'height' => 404 ] ],
+ [ "\x52\x49\x46\x46\x7A\x19\x03\x00\x57\x45\x42\x50\x56\x50\x38\x20\x6E\x19\x03\x00\xB2\xF8\x09\x9D\x01\x2A\x00\x05\xD0\x02\x3E\xAD\x46\x99\x4A\xA5",
+ [ 'compression' => 'lossy', 'width' => 1280, 'height' => 720 ] ],
+ [ "\x52\x49\x46\x46\x44\xB3\x02\x00\x57\x45\x42\x50\x56\x50\x38\x20\x38\xB3\x02\x00\x52\x57\x06\x9D\x01\x2A\x00\x04\x04\x03\x3E\xA5\x44\x96\x49\x26",
+ [ 'compression' => 'lossy', 'width' => 1024, 'height' => 772 ] ],
+ [ "\x52\x49\x46\x46\x02\x43\x01\x00\x57\x45\x42\x50\x56\x50\x38\x20\xF6\x42\x01\x00\x12\xC0\x05\x9D\x01\x2A\x00\x04\xF0\x02\x3E\x79\x34\x93\x47\xA4",
+ [ 'compression' => 'lossy', 'width' => 1024, 'height' => 752 ] ],
+
+ // Animated file from https://groups.google.com/a/chromium.org/d/topic/blink-dev/Y8tRC4mdQz8/discussion
+ [ "\x52\x49\x46\x46\xD0\x0B\x02\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x12\x00\x00\x00\x3F\x01\x00\x3F\x01\x00\x41\x4E",
+ [ 'compression' => 'unknown', 'animated' => true, 'transparency' => true, 'width' => 320, 'height' => 320 ] ],
+
+ // Error cases
+ [ '', false ],
+ [ ' ', false ],
+ [ 'RIFF ', false ],
+ [ 'RIFF1234WEBP ', false ],
+ [ 'RIFF1234WEBPVP8 ', false ],
+ [ 'RIFF1234WEBPVP8L ', false ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @dataProvider provideTestWithFileExtractMetaData
+ */
+ public function testWithFileExtractMetaData( $filename, $expectedResult ) {
+ $this->assertEquals( $expectedResult, WebPHandler::extractMetadata( $filename ) );
+ }
+ public function provideTestWithFileExtractMetaData() {
+ return [
+ [ __DIR__ . '/../../data/media/2_webp_ll.webp',
+ [
+ 'compression' => 'lossless',
+ 'width' => 386,
+ 'height' => 395
+ ]
+ ],
+ [ __DIR__ . '/../../data/media/2_webp_a.webp',
+ [
+ 'compression' => 'lossy',
+ 'animated' => false,
+ 'transparency' => true,
+ 'width' => 386,
+ 'height' => 395
+ ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideTestGetImageSize
+ */
+ public function testGetImageSize( $path, $expectedResult ) {
+ $handler = new WebPHandler();
+ $this->assertEquals( $expectedResult, $handler->getImageSize( null, $path ) );
+ }
+ public function provideTestGetImageSize() {
+ return [
+ // Public domain files from https://developers.google.com/speed/webp/gallery2
+ [ __DIR__ . '/../../data/media/2_webp_a.webp', [ 386, 395 ] ],
+ [ __DIR__ . '/../../data/media/2_webp_ll.webp', [ 386, 395 ] ],
+ [ __DIR__ . '/../../data/media/webp_animated.webp', [ 300, 225 ] ],
+
+ // Error cases
+ [ __FILE__, false ],
+ ];
+ }
+
+ /**
+ * Tests the WebP MIME detection. This should really be a separate test, but sticking it
+ * here for now.
+ *
+ * @dataProvider provideTestGetMimeType
+ */
+ public function testGuessMimeType( $path ) {
+ $mime = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer();
+ $this->assertEquals( 'image/webp', $mime->guessMimeType( $path, false ) );
+ }
+ public function provideTestGetMimeType() {
+ return [
+ // Public domain files from https://developers.google.com/speed/webp/gallery2
+ [ __DIR__ . '/../../data/media/2_webp_a.webp' ],
+ [ __DIR__ . '/../../data/media/2_webp_ll.webp' ],
+ [ __DIR__ . '/../../data/media/webp_animated.webp' ],
+ ];
+ }
+}
+
+/* Python code to extract a header and convert to PHP format:
+ * print '"%s"' % ''.implode( '\\x%02X' % ord(c) for c in urllib.urlopen(url).read(36) )
+ */
diff --git a/www/wiki/tests/phpunit/includes/media/XCFTest.php b/www/wiki/tests/phpunit/includes/media/XCFTest.php
new file mode 100644
index 00000000..b75335d6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/media/XCFTest.php
@@ -0,0 +1,83 @@
+<?php
+
+/**
+ * @group Media
+ */
+class XCFHandlerTest extends MediaWikiMediaTestCase {
+
+ /** @var XCFHandler */
+ protected $handler;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->handler = new XCFHandler();
+ }
+
+ /**
+ * @param string $filename
+ * @param int $expectedWidth Width
+ * @param int $expectedHeight Height
+ * @dataProvider provideGetImageSize
+ * @covers XCFHandler::getImageSize
+ */
+ public function testGetImageSize( $filename, $expectedWidth, $expectedHeight ) {
+ $file = $this->dataFile( $filename, 'image/x-xcf' );
+ $actual = $this->handler->getImageSize( $file, $file->getLocalRefPath() );
+ $this->assertEquals( $expectedWidth, $actual[0] );
+ $this->assertEquals( $expectedHeight, $actual[1] );
+ }
+
+ public static function provideGetImageSize() {
+ return [
+ [ '80x60-2layers.xcf', 80, 60 ],
+ [ '80x60-RGB.xcf', 80, 60 ],
+ [ '80x60-Greyscale.xcf', 80, 60 ],
+ ];
+ }
+
+ /**
+ * @param string $metadata Serialized metadata
+ * @param int $expected One of the class constants of XCFHandler
+ * @dataProvider provideIsMetadataValid
+ * @covers XCFHandler::isMetadataValid
+ */
+ public function testIsMetadataValid( $metadata, $expected ) {
+ $actual = $this->handler->isMetadataValid( null, $metadata );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideIsMetadataValid() {
+ return [
+ [ '', XCFHandler::METADATA_BAD ],
+ [ serialize( [ 'error' => true ] ), XCFHandler::METADATA_GOOD ],
+ [ false, XCFHandler::METADATA_BAD ],
+ [ serialize( [ 'colorType' => 'greyscale-alpha' ] ), XCFHandler::METADATA_GOOD ],
+ ];
+ }
+
+ /**
+ * @param string $filename
+ * @param string $expected Serialized array
+ * @dataProvider provideGetMetadata
+ * @covers XCFHandler::getMetadata
+ */
+ public function testGetMetadata( $filename, $expected ) {
+ $file = $this->dataFile( $filename, 'image/png' );
+ $actual = $this->handler->getMetadata( $file, "$this->filePath/$filename" );
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideGetMetadata() {
+ return [
+ [ '80x60-2layers.xcf',
+ 'a:1:{s:9:"colorType";s:16:"truecolour-alpha";}'
+ ],
+ [ '80x60-RGB.xcf',
+ 'a:1:{s:9:"colorType";s:16:"truecolour-alpha";}'
+ ],
+ [ '80x60-Greyscale.xcf',
+ 'a:1:{s:9:"colorType";s:15:"greyscale-alpha";}'
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php b/www/wiki/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php
new file mode 100644
index 00000000..432754b6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php
@@ -0,0 +1,107 @@
+<?php
+/**
+ * @group BagOStuff
+ */
+class MemcachedBagOStuffTest extends MediaWikiTestCase {
+ /** @var MemcachedBagOStuff */
+ private $cache;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->cache = new MemcachedBagOStuff( [ 'keyspace' => 'test' ] );
+ }
+
+ /**
+ * @covers MemcachedBagOStuff::makeKey
+ */
+ public function testKeyNormalization() {
+ $this->assertEquals(
+ 'test:vanilla',
+ $this->cache->makeKey( 'vanilla' )
+ );
+
+ $this->assertEquals(
+ 'test:punctuation_marks_are_ok:!@$^&*()',
+ $this->cache->makeKey( 'punctuation_marks_are_ok', '!@$^&*()' )
+ );
+
+ $this->assertEquals(
+ 'test:but_spaces:hashes%23:and%0Anewlines:are_not',
+ $this->cache->makeKey( 'but spaces', 'hashes#', "and\nnewlines", 'are_not' )
+ );
+
+ $this->assertEquals(
+ 'test:this:key:contains:%F0%9D%95%9E%F0%9D%95%A6%F0%9D%95%9D%F0%9D%95%A5%F0%9' .
+ 'D%95%9A%F0%9D%95%93%F0%9D%95%AA%F0%9D%95%A5%F0%9D%95%96:characters',
+ $this->cache->makeKey( 'this', 'key', 'contains', '𝕞𝕦𝕝𝕥𝕚𝕓𝕪𝕥𝕖', 'characters' )
+ );
+
+ $this->assertEquals(
+ 'test:this:key:contains:#c118f92685a635cb843039de50014c9c',
+ $this->cache->makeKey( 'this', 'key', 'contains', '𝕥𝕠𝕠 𝕞𝕒𝕟𝕪 𝕞𝕦𝕝𝕥𝕚𝕓𝕪𝕥𝕖 𝕔𝕙𝕒𝕣𝕒𝕔𝕥𝕖𝕣𝕤' )
+ );
+
+ $this->assertEquals(
+ 'test:BagOStuff-long-key:##dc89dcb43b28614da27660240af478b5',
+ $this->cache->makeKey( '𝕖𝕧𝕖𝕟', '𝕚𝕗', '𝕨𝕖', '𝕄𝔻𝟝', '𝕖𝕒𝕔𝕙',
+ '𝕒𝕣𝕘𝕦𝕞𝕖𝕟𝕥', '𝕥𝕙𝕚𝕤', '𝕜𝕖𝕪', '𝕨𝕠𝕦𝕝𝕕', '𝕤𝕥𝕚𝕝𝕝', '𝕓𝕖', '𝕥𝕠𝕠', '𝕝𝕠𝕟𝕘' )
+ );
+
+ $this->assertEquals(
+ 'test:%23%235820ad1d105aa4dc698585c39df73e19',
+ $this->cache->makeKey( '##5820ad1d105aa4dc698585c39df73e19' )
+ );
+
+ $this->assertEquals(
+ 'test:percent_is_escaped:!@$%25^&*()',
+ $this->cache->makeKey( 'percent_is_escaped', '!@$%^&*()' )
+ );
+
+ $this->assertEquals(
+ 'test:colon_is_escaped:!@$%3A^&*()',
+ $this->cache->makeKey( 'colon_is_escaped', '!@$:^&*()' )
+ );
+
+ $this->assertEquals(
+ 'test:long_key_part_hashed:#0244f7b1811d982dd932dd7de01465ac',
+ $this->cache->makeKey( 'long_key_part_hashed', str_repeat( 'y', 500 ) )
+ );
+ }
+
+ /**
+ * @dataProvider validKeyProvider
+ * @covers MemcachedBagOStuff::validateKeyEncoding
+ */
+ public function testValidateKeyEncoding( $key ) {
+ $this->assertSame( $key, $this->cache->validateKeyEncoding( $key ) );
+ }
+
+ public function validKeyProvider() {
+ return [
+ 'empty' => [ '' ],
+ 'digits' => [ '09' ],
+ 'letters' => [ 'AZaz' ],
+ 'ASCII special characters' => [ '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' ],
+ ];
+ }
+
+ /**
+ * @dataProvider invalidKeyProvider
+ * @covers MemcachedBagOStuff::validateKeyEncoding
+ */
+ public function testValidateKeyEncodingThrowsException( $key ) {
+ $this->setExpectedException( Exception::class );
+ $this->cache->validateKeyEncoding( $key );
+ }
+
+ public function invalidKeyProvider() {
+ return [
+ [ "\x00" ],
+ [ ' ' ],
+ [ "\x1F" ],
+ [ "\x7F" ],
+ [ "\x80" ],
+ [ "\xFF" ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/objectcache/ObjectCacheTest.php b/www/wiki/tests/phpunit/includes/objectcache/ObjectCacheTest.php
new file mode 100644
index 00000000..43188528
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/objectcache/ObjectCacheTest.php
@@ -0,0 +1,115 @@
+<?php
+
+class ObjectCacheTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ // Parent calls ObjectCache::clear() among other things
+ parent::setUp();
+
+ $this->setCacheConfig();
+ $this->setMwGlobals( [
+ 'wgMainCacheType' => CACHE_NONE,
+ 'wgMessageCacheType' => CACHE_NONE,
+ 'wgParserCacheType' => CACHE_NONE,
+ ] );
+ }
+
+ private function setCacheConfig( $arr = [] ) {
+ $defaults = [
+ CACHE_NONE => [ 'class' => EmptyBagOStuff::class ],
+ CACHE_DB => [ 'class' => SqlBagOStuff::class ],
+ CACHE_ANYTHING => [ 'factory' => 'ObjectCache::newAnything' ],
+ // Mock ACCEL with 'hash' as being installed.
+ // This makes tests deterministic regardless of APC.
+ CACHE_ACCEL => [ 'class' => HashBagOStuff::class ],
+ 'hash' => [ 'class' => HashBagOStuff::class ],
+ ];
+ $this->setMwGlobals( 'wgObjectCaches', $arr + $defaults );
+ }
+
+ /** @covers ObjectCache::newAnything */
+ public function testNewAnythingNothing() {
+ $this->assertInstanceOf(
+ SqlBagOStuff::class,
+ ObjectCache::newAnything( [] ),
+ 'No available types. Fallback to DB'
+ );
+ }
+
+ /** @covers ObjectCache::newAnything */
+ public function testNewAnythingHash() {
+ $this->setMwGlobals( [
+ 'wgMainCacheType' => 'hash'
+ ] );
+
+ $this->assertInstanceOf(
+ HashBagOStuff::class,
+ ObjectCache::newAnything( [] ),
+ 'Use an available type (hash)'
+ );
+ }
+
+ /** @covers ObjectCache::newAnything */
+ public function testNewAnythingAccel() {
+ $this->setMwGlobals( [
+ 'wgMainCacheType' => CACHE_ACCEL
+ ] );
+
+ $this->assertInstanceOf(
+ HashBagOStuff::class,
+ ObjectCache::newAnything( [] ),
+ 'Use an available type (CACHE_ACCEL)'
+ );
+ }
+
+ /** @covers ObjectCache::newAnything */
+ public function testNewAnythingNoAccel() {
+ $this->setMwGlobals( [
+ 'wgMainCacheType' => CACHE_ACCEL
+ ] );
+
+ $this->setCacheConfig( [
+ // Mock APC not being installed (T160519, T147161)
+ CACHE_ACCEL => [ 'class' => EmptyBagOStuff::class ]
+ ] );
+
+ $this->assertInstanceOf(
+ SqlBagOStuff::class,
+ ObjectCache::newAnything( [] ),
+ 'Fallback to DB if available types fall back to Empty'
+ );
+ }
+
+ /** @covers ObjectCache::newAnything */
+ public function testNewAnythingNoAccelNoDb() {
+ $this->overrideMwServices(); // Ensures restore on tear down
+ MediaWiki\MediaWikiServices::disableStorageBackend();
+
+ $this->setMwGlobals( [
+ 'wgMainCacheType' => CACHE_ACCEL
+ ] );
+
+ $this->setCacheConfig( [
+ // Mock APC not being installed (T160519, T147161)
+ CACHE_ACCEL => [ 'class' => EmptyBagOStuff::class ]
+ ] );
+
+ $this->assertInstanceOf(
+ EmptyBagOStuff::class,
+ ObjectCache::newAnything( [] ),
+ 'Fallback to none if available types and DB are unavailable'
+ );
+ }
+
+ /** @covers ObjectCache::newAnything */
+ public function testNewAnythingNothingNoDb() {
+ $this->overrideMwServices();
+ MediaWiki\MediaWikiServices::disableStorageBackend();
+
+ $this->assertInstanceOf(
+ EmptyBagOStuff::class,
+ ObjectCache::newAnything( [] ),
+ 'No available types or DB. Fallback to none.'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php b/www/wiki/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php
new file mode 100644
index 00000000..66754fc9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * @group BagOStuff
+ *
+ * @covers RESTBagOStuff
+ */
+class RESTBagOStuffTest extends MediaWikiTestCase {
+
+ /**
+ * @var MultiHttpClient
+ */
+ private $client;
+ /**
+ * @var RESTBagOStuff
+ */
+ private $bag;
+
+ public function setUp() {
+ parent::setUp();
+ $this->client =
+ $this->getMockBuilder( MultiHttpClient::class )
+ ->setConstructorArgs( [ [] ] )
+ ->setMethods( [ 'run' ] )
+ ->getMock();
+ $this->bag = new RESTBagOStuff( [ 'client' => $this->client, 'url' => 'http://test/rest/' ] );
+ }
+
+ public function testGet() {
+ $this->client->expects( $this->once() )->method( 'run' )->with( [
+ 'method' => 'GET',
+ 'url' => 'http://test/rest/42xyz42'
+ // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr )
+ ] )->willReturn( [ 200, 'OK', [], 's:8:"somedata";', 0 ] );
+ $result = $this->bag->get( '42xyz42' );
+ $this->assertEquals( 'somedata', $result );
+ }
+
+ public function testGetNotExist() {
+ $this->client->expects( $this->once() )->method( 'run' )->with( [
+ 'method' => 'GET',
+ 'url' => 'http://test/rest/42xyz42'
+ // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr )
+ ] )->willReturn( [ 404, 'Not found', [], 'Nothing to see here', 0 ] );
+ $result = $this->bag->get( '42xyz42' );
+ $this->assertFalse( $result );
+ }
+
+ public function testGetBadClient() {
+ $this->client->expects( $this->once() )->method( 'run' )->with( [
+ 'method' => 'GET',
+ 'url' => 'http://test/rest/42xyz42'
+ // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr )
+ ] )->willReturn( [ 0, '', [], '', 'cURL has failed you today' ] );
+ $result = $this->bag->get( '42xyz42' );
+ $this->assertFalse( $result );
+ $this->assertEquals( BagOStuff::ERR_UNREACHABLE, $this->bag->getLastError() );
+ }
+
+ public function testGetBadServer() {
+ $this->client->expects( $this->once() )->method( 'run' )->with( [
+ 'method' => 'GET',
+ 'url' => 'http://test/rest/42xyz42'
+ // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr )
+ ] )->willReturn( [ 500, 'Too busy', [], 'Server is too busy', '' ] );
+ $result = $this->bag->get( '42xyz42' );
+ $this->assertFalse( $result );
+ $this->assertEquals( BagOStuff::ERR_UNEXPECTED, $this->bag->getLastError() );
+ }
+
+ public function testPut() {
+ $this->client->expects( $this->once() )->method( 'run' )->with( [
+ 'method' => 'PUT',
+ 'url' => 'http://test/rest/42xyz42',
+ 'body' => 's:8:"postdata";'
+ // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr )
+ ] )->willReturn( [ 200, 'OK', [], 'Done', 0 ] );
+ $result = $this->bag->set( '42xyz42', 'postdata' );
+ $this->assertTrue( $result );
+ }
+
+ public function testDelete() {
+ $this->client->expects( $this->once() )->method( 'run' )->with( [
+ 'method' => 'DELETE',
+ 'url' => 'http://test/rest/42xyz42',
+ // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr )
+ ] )->willReturn( [ 200, 'OK', [], 'Done', 0 ] );
+ $result = $this->bag->delete( '42xyz42' );
+ $this->assertTrue( $result );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php b/www/wiki/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php
new file mode 100644
index 00000000..df5614d8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php
@@ -0,0 +1,110 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group BagOStuff
+ */
+class RedisBagOStuffTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /** @var RedisBagOStuff */
+ private $cache;
+
+ protected function setUp() {
+ parent::setUp();
+ $cache = $this->getMockBuilder( RedisBagOStuff::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->cache = TestingAccessWrapper::newFromObject( $cache );
+ }
+
+ /**
+ * @covers RedisBagOStuff::unserialize
+ * @dataProvider unserializeProvider
+ */
+ public function testUnserialize( $expected, $input, $message ) {
+ $actual = $this->cache->unserialize( $input );
+ $this->assertSame( $expected, $actual, $message );
+ }
+
+ public function unserializeProvider() {
+ return [
+ [
+ -1,
+ '-1',
+ 'String representation of \'-1\'',
+ ],
+ [
+ 0,
+ '0',
+ 'String representation of \'0\'',
+ ],
+ [
+ 1,
+ '1',
+ 'String representation of \'1\'',
+ ],
+ [
+ -1.0,
+ 'd:-1;',
+ 'Serialized negative double',
+ ],
+ [
+ 'foo',
+ 's:3:"foo";',
+ 'Serialized string',
+ ]
+ ];
+ }
+
+ /**
+ * @covers RedisBagOStuff::serialize
+ * @dataProvider serializeProvider
+ */
+ public function testSerialize( $expected, $input, $message ) {
+ $actual = $this->cache->serialize( $input );
+ $this->assertSame( $expected, $actual, $message );
+ }
+
+ public function serializeProvider() {
+ return [
+ [
+ -1,
+ -1,
+ '-1 as integer',
+ ],
+ [
+ 0,
+ 0,
+ '0 as integer',
+ ],
+ [
+ 1,
+ 1,
+ '1 as integer',
+ ],
+ [
+ 'd:-1;',
+ -1.0,
+ 'Negative double',
+ ],
+ [
+ 's:3:"2.1";',
+ '2.1',
+ 'Decimal string',
+ ],
+ [
+ 's:1:"1";',
+ '1',
+ 'String representation of 1',
+ ],
+ [
+ 's:3:"foo";',
+ 'foo',
+ 'String',
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/page/ArticleTablesTest.php b/www/wiki/tests/phpunit/includes/page/ArticleTablesTest.php
new file mode 100644
index 00000000..34b25251
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/page/ArticleTablesTest.php
@@ -0,0 +1,52 @@
+<?php
+
+/**
+ * @group Database
+ */
+class ArticleTablesTest extends MediaWikiLangTestCase {
+ /**
+ * Make sure that T16404 doesn't strike again. We don't want
+ * templatelinks based on the user language when {{int:}} is used, only the
+ * content language.
+ *
+ * @covers Title::getTemplateLinksFrom
+ * @covers Title::getLinksFrom
+ */
+ public function testTemplatelinksUsesContentLanguage() {
+ $title = Title::newFromText( 'T16404' );
+ $page = WikiPage::factory( $title );
+ $user = new User();
+ $user->mRights = [ 'createpage', 'edit', 'purge' ];
+ $this->setContentLang( 'es' );
+ $this->setUserLang( 'fr' );
+
+ $page->doEditContent(
+ new WikitextContent( '{{:{{int:history}}}}' ),
+ 'Test code for T16404',
+ 0,
+ false,
+ $user
+ );
+ $templates1 = $title->getTemplateLinksFrom();
+
+ $this->setUserLang( 'de' );
+ $page = WikiPage::factory( $title ); // In order to force the re-rendering of the same wikitext
+
+ // We need an edit, a purge is not enough to regenerate the tables
+ $page->doEditContent(
+ new WikitextContent( '{{:{{int:history}}}}' ),
+ 'Test code for T16404',
+ EDIT_UPDATE,
+ false,
+ $user
+ );
+ $templates2 = $title->getTemplateLinksFrom();
+
+ /**
+ * @var Title[] $templates1
+ * @var Title[] $templates2
+ */
+ $this->assertEquals( $templates1, $templates2 );
+ $this->assertEquals( $templates1[0]->getFullText(), 'Historial' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/page/ArticleTest.php b/www/wiki/tests/phpunit/includes/page/ArticleTest.php
new file mode 100644
index 00000000..df4a2817
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/page/ArticleTest.php
@@ -0,0 +1,57 @@
+<?php
+
+class ArticleTest extends MediaWikiTestCase {
+
+ /**
+ * @var Title
+ */
+ private $title;
+ /**
+ * @var Article
+ */
+ private $article;
+
+ /** creates a title object and its article object */
+ protected function setUp() {
+ parent::setUp();
+ $this->title = Title::makeTitle( NS_MAIN, 'SomePage' );
+ $this->article = new Article( $this->title );
+ }
+
+ /** cleanup title object and its article object */
+ protected function tearDown() {
+ parent::tearDown();
+ $this->title = null;
+ $this->article = null;
+ }
+
+ /**
+ * @covers Article::__get
+ */
+ public function testImplementsGetMagic() {
+ $this->assertEquals( false, $this->article->mLatest, "Article __get magic" );
+ }
+
+ /**
+ * @depends testImplementsGetMagic
+ * @covers Article::__set
+ */
+ public function testImplementsSetMagic() {
+ $this->article->mLatest = 2;
+ $this->assertEquals( 2, $this->article->mLatest, "Article __set magic" );
+ }
+
+ /**
+ * @covers Article::__get
+ * @covers Article::__set
+ */
+ public function testGetOrSetOnNewProperty() {
+ $this->article->ext_someNewProperty = 12;
+ $this->assertEquals( 12, $this->article->ext_someNewProperty,
+ "Article get/set magic on new field" );
+
+ $this->article->ext_someNewProperty = -8;
+ $this->assertEquals( -8, $this->article->ext_someNewProperty,
+ "Article get/set magic on update to new field" );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/page/ImagePage404Test.php b/www/wiki/tests/phpunit/includes/page/ImagePage404Test.php
new file mode 100644
index 00000000..4faace21
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/page/ImagePage404Test.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * For doing Image Page tests that rely on 404 thumb handling
+ */
+class ImagePage404Test extends MediaWikiMediaTestCase {
+
+ protected function getRepoOptions() {
+ return parent::getRepoOptions() + [ 'transformVia404' => true ];
+ }
+
+ function setUp() {
+ $this->setMwGlobals( 'wgImageLimits', [
+ [ 320, 240 ],
+ [ 640, 480 ],
+ [ 800, 600 ],
+ [ 1024, 768 ],
+ [ 1280, 1024 ]
+ ] );
+ parent::setUp();
+ }
+
+ function getImagePage( $filename ) {
+ $title = Title::makeTitleSafe( NS_FILE, $filename );
+ $file = $this->dataFile( $filename );
+ $iPage = new ImagePage( $title );
+ $iPage->setFile( $file );
+ return $iPage;
+ }
+
+ /**
+ * @covers ImagePage::getThumbSizes
+ * @dataProvider providerGetThumbSizes
+ * @param string $filename
+ * @param int $expectedNumberThumbs How many thumbnails to show
+ */
+ function testGetThumbSizes( $filename, $expectedNumberThumbs ) {
+ $iPage = $this->getImagePage( $filename );
+ $reflection = new ReflectionClass( $iPage );
+ $reflMethod = $reflection->getMethod( 'getThumbSizes' );
+ $reflMethod->setAccessible( true );
+
+ $actual = $reflMethod->invoke( $iPage, 545, 700 );
+ $this->assertEquals( count( $actual ), $expectedNumberThumbs );
+ }
+
+ function providerGetThumbSizes() {
+ return [
+ [ 'animated.gif', 6 ],
+ [ 'Toll_Texas_1.svg', 6 ],
+ [ '80x60-Greyscale.xcf', 6 ],
+ [ 'jpeg-comment-binary.jpg', 6 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/page/ImagePageTest.php b/www/wiki/tests/phpunit/includes/page/ImagePageTest.php
new file mode 100644
index 00000000..8e49bf98
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/page/ImagePageTest.php
@@ -0,0 +1,92 @@
+<?php
+class ImagePageTest extends MediaWikiMediaTestCase {
+
+ function setUp() {
+ $this->setMwGlobals( 'wgImageLimits', [
+ [ 320, 240 ],
+ [ 640, 480 ],
+ [ 800, 600 ],
+ [ 1024, 768 ],
+ [ 1280, 1024 ]
+ ] );
+ parent::setUp();
+ }
+
+ function getImagePage( $filename ) {
+ $title = Title::makeTitleSafe( NS_FILE, $filename );
+ $file = $this->dataFile( $filename );
+ $iPage = new ImagePage( $title );
+ $iPage->setFile( $file );
+ return $iPage;
+ }
+
+ /**
+ * @covers ImagePage::getDisplayWidthHeight
+ * @dataProvider providerGetDisplayWidthHeight
+ * @param array $dim Array [maxWidth, maxHeight, width, height]
+ * @param array $expected Array [width, height] The width and height we expect to display at
+ */
+ function testGetDisplayWidthHeight( $dim, $expected ) {
+ $iPage = $this->getImagePage( 'animated.gif' );
+ $reflection = new ReflectionClass( $iPage );
+ $reflMethod = $reflection->getMethod( 'getDisplayWidthHeight' );
+ $reflMethod->setAccessible( true );
+
+ $actual = $reflMethod->invoke( $iPage, $dim[0], $dim[1], $dim[2], $dim[3] );
+ $this->assertEquals( $actual, $expected );
+ }
+
+ function providerGetDisplayWidthHeight() {
+ return [
+ [
+ [ 1024.0, 768.0, 600.0, 600.0 ],
+ [ 600.0, 600.0 ]
+ ],
+ [
+ [ 1024.0, 768.0, 1600.0, 600.0 ],
+ [ 1024.0, 384.0 ]
+ ],
+ [
+ [ 1024.0, 768.0, 1024.0, 768.0 ],
+ [ 1024.0, 768.0 ]
+ ],
+ [
+ [ 1024.0, 768.0, 800.0, 1000.0 ],
+ [ 614.0, 768.0 ]
+ ],
+ [
+ [ 1024.0, 768.0, 0, 1000 ],
+ [ 0, 0 ]
+ ],
+ [
+ [ 1024.0, 768.0, 2000, 0 ],
+ [ 0, 0 ]
+ ],
+ ];
+ }
+
+ /**
+ * @covers ImagePage::getThumbSizes
+ * @dataProvider providerGetThumbSizes
+ * @param string $filename
+ * @param int $expectedNumberThumbs How many thumbnails to show
+ */
+ function testGetThumbSizes( $filename, $expectedNumberThumbs ) {
+ $iPage = $this->getImagePage( $filename );
+ $reflection = new ReflectionClass( $iPage );
+ $reflMethod = $reflection->getMethod( 'getThumbSizes' );
+ $reflMethod->setAccessible( true );
+
+ $actual = $reflMethod->invoke( $iPage, 545, 700 );
+ $this->assertEquals( count( $actual ), $expectedNumberThumbs );
+ }
+
+ function providerGetThumbSizes() {
+ return [
+ [ 'animated.gif', 2 ],
+ [ 'Toll_Texas_1.svg', 1 ],
+ [ '80x60-Greyscale.xcf', 1 ],
+ [ 'jpeg-comment-binary.jpg', 2 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/page/WikiCategoryPageTest.php b/www/wiki/tests/phpunit/includes/page/WikiCategoryPageTest.php
new file mode 100644
index 00000000..5f1bf0ca
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/page/WikiCategoryPageTest.php
@@ -0,0 +1,63 @@
+<?php
+
+use Wikimedia\ScopedCallback;
+
+class WikiCategoryPageTest extends MediaWikiLangTestCase {
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|PageProps
+ */
+ private function getMockPageProps() {
+ return $this->getMockBuilder( PageProps::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ /**
+ * @covers WikiCategoryPage::isHidden
+ */
+ public function testHiddenCategory_PropertyNotSet() {
+ $title = Title::makeTitle( NS_CATEGORY, 'CategoryPage' );
+ $categoryPage = WikiCategoryPage::factory( $title );
+
+ $pageProps = $this->getMockPageProps();
+ $pageProps->expects( $this->once() )
+ ->method( 'getProperties' )
+ ->with( $title, 'hiddencat' )
+ ->will( $this->returnValue( [] ) );
+
+ $scopedOverride = PageProps::overrideInstance( $pageProps );
+
+ $this->assertFalse( $categoryPage->isHidden() );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ public function provideCategoryContent() {
+ return [
+ [ true ],
+ [ false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCategoryContent
+ * @covers WikiCategoryPage::isHidden
+ */
+ public function testHiddenCategory_PropertyIsSet( $isHidden ) {
+ $categoryTitle = Title::makeTitle( NS_CATEGORY, 'CategoryPage' );
+ $categoryPage = WikiCategoryPage::factory( $categoryTitle );
+
+ $pageProps = $this->getMockPageProps();
+ $pageProps->expects( $this->once() )
+ ->method( 'getProperties' )
+ ->with( $categoryTitle, 'hiddencat' )
+ ->will( $this->returnValue( $isHidden ? [ $categoryTitle->getArticleID() => '' ] : [] ) );
+
+ $scopedOverride = PageProps::overrideInstance( $pageProps );
+
+ $this->assertEquals( $isHidden, $categoryPage->isHidden() );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/page/WikiPageContentHandlerDbTest.php b/www/wiki/tests/phpunit/includes/page/WikiPageContentHandlerDbTest.php
new file mode 100644
index 00000000..2d7d6cc3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/page/WikiPageContentHandlerDbTest.php
@@ -0,0 +1,42 @@
+<?php
+
+/**
+ * @group ContentHandler
+ * @group Database
+ * @group medium
+ */
+class WikiPageContentHandlerDbTest extends WikiPageDbTestBase {
+
+ protected function getContentHandlerUseDB() {
+ return true;
+ }
+
+ /**
+ * @covers WikiPage::getContentModel
+ */
+ public function testGetContentModel() {
+ $page = $this->createPage(
+ __METHOD__,
+ "some text",
+ CONTENT_MODEL_JAVASCRIPT
+ );
+
+ $page = new WikiPage( $page->getTitle() );
+ $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $page->getContentModel() );
+ }
+
+ /**
+ * @covers WikiPage::getContentHandler
+ */
+ public function testGetContentHandler() {
+ $page = $this->createPage(
+ __METHOD__,
+ "some text",
+ CONTENT_MODEL_JAVASCRIPT
+ );
+
+ $page = new WikiPage( $page->getTitle() );
+ $this->assertEquals( JavaScriptContentHandler::class, get_class( $page->getContentHandler() ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/page/WikiPageDbTestBase.php b/www/wiki/tests/phpunit/includes/page/WikiPageDbTestBase.php
new file mode 100644
index 00000000..53b659f2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/page/WikiPageDbTestBase.php
@@ -0,0 +1,1903 @@
+<?php
+
+abstract class WikiPageDbTestBase extends MediaWikiLangTestCase {
+
+ private $pagesToDelete;
+
+ public function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+
+ $this->tablesUsed = array_merge(
+ $this->tablesUsed,
+ [ 'page',
+ 'revision',
+ 'redirect',
+ 'archive',
+ 'category',
+ 'ip_changes',
+ 'text',
+
+ 'recentchanges',
+ 'logging',
+
+ 'page_props',
+ 'pagelinks',
+ 'categorylinks',
+ 'langlinks',
+ 'externallinks',
+ 'imagelinks',
+ 'templatelinks',
+ 'iwlinks' ] );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( 'wgContentHandlerUseDB', $this->getContentHandlerUseDB() );
+ $this->pagesToDelete = [];
+ }
+
+ protected function tearDown() {
+ foreach ( $this->pagesToDelete as $p ) {
+ /* @var $p WikiPage */
+
+ try {
+ if ( $p->exists() ) {
+ $p->doDeleteArticle( "testing done." );
+ }
+ } catch ( MWException $ex ) {
+ // fail silently
+ }
+ }
+ parent::tearDown();
+ }
+
+ abstract protected function getContentHandlerUseDB();
+
+ /**
+ * @param Title|string $title
+ * @param string|null $model
+ * @return WikiPage
+ */
+ private function newPage( $title, $model = null ) {
+ if ( is_string( $title ) ) {
+ $ns = $this->getDefaultWikitextNS();
+ $title = Title::newFromText( $title, $ns );
+ }
+
+ $p = new WikiPage( $title );
+
+ $this->pagesToDelete[] = $p;
+
+ return $p;
+ }
+
+ /**
+ * @param string|Title|WikiPage $page
+ * @param string $text
+ * @param int $model
+ *
+ * @return WikiPage
+ */
+ protected function createPage( $page, $text, $model = null ) {
+ if ( is_string( $page ) || $page instanceof Title ) {
+ $page = $this->newPage( $page, $model );
+ }
+
+ $content = ContentHandler::makeContent( $text, $page->getTitle(), $model );
+ $page->doEditContent( $content, "testing", EDIT_NEW );
+
+ return $page;
+ }
+
+ /**
+ * @covers WikiPage::doEditContent
+ * @covers WikiPage::doModify
+ * @covers WikiPage::doCreate
+ * @covers WikiPage::doEditUpdates
+ */
+ public function testDoEditContent() {
+ $page = $this->newPage( __METHOD__ );
+ $title = $page->getTitle();
+
+ $content = ContentHandler::makeContent(
+ "[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam "
+ . " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.",
+ $title,
+ CONTENT_MODEL_WIKITEXT
+ );
+
+ $page->doEditContent( $content, "[[testing]] 1" );
+
+ $this->assertTrue( $title->getArticleID() > 0, "Title object should have new page id" );
+ $this->assertTrue( $page->getId() > 0, "WikiPage should have new page id" );
+ $this->assertTrue( $title->exists(), "Title object should indicate that the page now exists" );
+ $this->assertTrue( $page->exists(), "WikiPage object should indicate that the page now exists" );
+
+ $id = $page->getId();
+
+ # ------------------------
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select( 'pagelinks', '*', [ 'pl_from' => $id ] );
+ $n = $res->numRows();
+ $res->free();
+
+ $this->assertEquals( 1, $n, 'pagelinks should contain one link from the page' );
+
+ # ------------------------
+ $page = new WikiPage( $title );
+
+ $retrieved = $page->getContent();
+ $this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' );
+
+ # ------------------------
+ $content = ContentHandler::makeContent(
+ "At vero eos et accusam et justo duo [[dolores]] et ea rebum. "
+ . "Stet clita kasd [[gubergren]], no sea takimata sanctus est.",
+ $title,
+ CONTENT_MODEL_WIKITEXT
+ );
+
+ $page->doEditContent( $content, "testing 2" );
+
+ # ------------------------
+ $page = new WikiPage( $title );
+
+ $retrieved = $page->getContent();
+ $this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' );
+
+ # ------------------------
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select( 'pagelinks', '*', [ 'pl_from' => $id ] );
+ $n = $res->numRows();
+ $res->free();
+
+ $this->assertEquals( 2, $n, 'pagelinks should contain two links from the page' );
+ }
+
+ /**
+ * @covers WikiPage::doDeleteArticle
+ * @covers WikiPage::doDeleteArticleReal
+ */
+ public function testDoDeleteArticle() {
+ $page = $this->createPage(
+ __METHOD__,
+ "[[original text]] foo",
+ CONTENT_MODEL_WIKITEXT
+ );
+ $id = $page->getId();
+
+ $page->doDeleteArticle( "testing deletion" );
+
+ $this->assertFalse(
+ $page->getTitle()->getArticleID() > 0,
+ "Title object should now have page id 0"
+ );
+ $this->assertFalse( $page->getId() > 0, "WikiPage should now have page id 0" );
+ $this->assertFalse(
+ $page->exists(),
+ "WikiPage::exists should return false after page was deleted"
+ );
+ $this->assertNull(
+ $page->getContent(),
+ "WikiPage::getContent should return null after page was deleted"
+ );
+
+ $t = Title::newFromText( $page->getTitle()->getPrefixedText() );
+ $this->assertFalse(
+ $t->exists(),
+ "Title::exists should return false after page was deleted"
+ );
+
+ // Run the job queue
+ JobQueueGroup::destroySingletons();
+ $jobs = new RunJobs;
+ $jobs->loadParamsAndArgs( null, [ 'quiet' => true ], null );
+ $jobs->execute();
+
+ # ------------------------
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select( 'pagelinks', '*', [ 'pl_from' => $id ] );
+ $n = $res->numRows();
+ $res->free();
+
+ $this->assertEquals( 0, $n, 'pagelinks should contain no more links from the page' );
+ }
+
+ /**
+ * @covers WikiPage::doDeleteUpdates
+ */
+ public function testDoDeleteUpdates() {
+ $page = $this->createPage(
+ __METHOD__,
+ "[[original text]] foo",
+ CONTENT_MODEL_WIKITEXT
+ );
+ $id = $page->getId();
+
+ // Similar to MovePage logic
+ wfGetDB( DB_MASTER )->delete( 'page', [ 'page_id' => $id ], __METHOD__ );
+ $page->doDeleteUpdates( $id );
+
+ // Run the job queue
+ JobQueueGroup::destroySingletons();
+ $jobs = new RunJobs;
+ $jobs->loadParamsAndArgs( null, [ 'quiet' => true ], null );
+ $jobs->execute();
+
+ # ------------------------
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select( 'pagelinks', '*', [ 'pl_from' => $id ] );
+ $n = $res->numRows();
+ $res->free();
+
+ $this->assertEquals( 0, $n, 'pagelinks should contain no more links from the page' );
+ }
+
+ /**
+ * @covers WikiPage::getRevision
+ */
+ public function testGetRevision() {
+ $page = $this->newPage( __METHOD__ );
+
+ $rev = $page->getRevision();
+ $this->assertNull( $rev );
+
+ # -----------------
+ $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT );
+
+ $rev = $page->getRevision();
+
+ $this->assertEquals( $page->getLatest(), $rev->getId() );
+ $this->assertEquals( "some text", $rev->getContent()->getNativeData() );
+ }
+
+ /**
+ * @covers WikiPage::getContent
+ */
+ public function testGetContent() {
+ $page = $this->newPage( __METHOD__ );
+
+ $content = $page->getContent();
+ $this->assertNull( $content );
+
+ # -----------------
+ $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT );
+
+ $content = $page->getContent();
+ $this->assertEquals( "some text", $content->getNativeData() );
+ }
+
+ /**
+ * @covers WikiPage::exists
+ */
+ public function testExists() {
+ $page = $this->newPage( __METHOD__ );
+ $this->assertFalse( $page->exists() );
+
+ # -----------------
+ $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT );
+ $this->assertTrue( $page->exists() );
+
+ $page = new WikiPage( $page->getTitle() );
+ $this->assertTrue( $page->exists() );
+
+ # -----------------
+ $page->doDeleteArticle( "done testing" );
+ $this->assertFalse( $page->exists() );
+
+ $page = new WikiPage( $page->getTitle() );
+ $this->assertFalse( $page->exists() );
+ }
+
+ public function provideHasViewableContent() {
+ return [
+ [ 'WikiPageTest_testHasViewableContent', false, true ],
+ [ 'Special:WikiPageTest_testHasViewableContent', false ],
+ [ 'MediaWiki:WikiPageTest_testHasViewableContent', false ],
+ [ 'Special:Userlogin', true ],
+ [ 'MediaWiki:help', true ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideHasViewableContent
+ * @covers WikiPage::hasViewableContent
+ */
+ public function testHasViewableContent( $title, $viewable, $create = false ) {
+ $page = $this->newPage( $title );
+ $this->assertEquals( $viewable, $page->hasViewableContent() );
+
+ if ( $create ) {
+ $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT );
+ $this->assertTrue( $page->hasViewableContent() );
+
+ $page = new WikiPage( $page->getTitle() );
+ $this->assertTrue( $page->hasViewableContent() );
+ }
+ }
+
+ public function provideGetRedirectTarget() {
+ return [
+ [ 'WikiPageTest_testGetRedirectTarget_1', CONTENT_MODEL_WIKITEXT, "hello world", null ],
+ [
+ 'WikiPageTest_testGetRedirectTarget_2',
+ CONTENT_MODEL_WIKITEXT,
+ "#REDIRECT [[hello world]]",
+ "Hello world"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetRedirectTarget
+ * @covers WikiPage::getRedirectTarget
+ */
+ public function testGetRedirectTarget( $title, $model, $text, $target ) {
+ $this->setMwGlobals( [
+ 'wgCapitalLinks' => true,
+ ] );
+
+ $page = $this->createPage( $title, $text, $model );
+
+ # sanity check, because this test seems to fail for no reason for some people.
+ $c = $page->getContent();
+ $this->assertEquals( WikitextContent::class, get_class( $c ) );
+
+ # now, test the actual redirect
+ $t = $page->getRedirectTarget();
+ $this->assertEquals( $target, is_null( $t ) ? null : $t->getPrefixedText() );
+ }
+
+ /**
+ * @dataProvider provideGetRedirectTarget
+ * @covers WikiPage::isRedirect
+ */
+ public function testIsRedirect( $title, $model, $text, $target ) {
+ $page = $this->createPage( $title, $text, $model );
+ $this->assertEquals( !is_null( $target ), $page->isRedirect() );
+ }
+
+ public function provideIsCountable() {
+ return [
+
+ // any
+ [ 'WikiPageTest_testIsCountable',
+ CONTENT_MODEL_WIKITEXT,
+ '',
+ 'any',
+ true
+ ],
+ [ 'WikiPageTest_testIsCountable',
+ CONTENT_MODEL_WIKITEXT,
+ 'Foo',
+ 'any',
+ true
+ ],
+
+ // link
+ [ 'WikiPageTest_testIsCountable',
+ CONTENT_MODEL_WIKITEXT,
+ 'Foo',
+ 'link',
+ false
+ ],
+ [ 'WikiPageTest_testIsCountable',
+ CONTENT_MODEL_WIKITEXT,
+ 'Foo [[bar]]',
+ 'link',
+ true
+ ],
+
+ // redirects
+ [ 'WikiPageTest_testIsCountable',
+ CONTENT_MODEL_WIKITEXT,
+ '#REDIRECT [[bar]]',
+ 'any',
+ false
+ ],
+ [ 'WikiPageTest_testIsCountable',
+ CONTENT_MODEL_WIKITEXT,
+ '#REDIRECT [[bar]]',
+ 'link',
+ false
+ ],
+
+ // not a content namespace
+ [ 'Talk:WikiPageTest_testIsCountable',
+ CONTENT_MODEL_WIKITEXT,
+ 'Foo',
+ 'any',
+ false
+ ],
+ [ 'Talk:WikiPageTest_testIsCountable',
+ CONTENT_MODEL_WIKITEXT,
+ 'Foo [[bar]]',
+ 'link',
+ false
+ ],
+
+ // not a content namespace, different model
+ [ 'MediaWiki:WikiPageTest_testIsCountable.js',
+ null,
+ 'Foo',
+ 'any',
+ false
+ ],
+ [ 'MediaWiki:WikiPageTest_testIsCountable.js',
+ null,
+ 'Foo [[bar]]',
+ 'link',
+ false
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsCountable
+ * @covers WikiPage::isCountable
+ */
+ public function testIsCountable( $title, $model, $text, $mode, $expected ) {
+ global $wgContentHandlerUseDB;
+
+ $this->setMwGlobals( 'wgArticleCountMethod', $mode );
+
+ $title = Title::newFromText( $title );
+
+ if ( !$wgContentHandlerUseDB
+ && $model
+ && ContentHandler::getDefaultModelFor( $title ) != $model
+ ) {
+ $this->markTestSkipped( "Can not use non-default content model $model for "
+ . $title->getPrefixedDBkey() . " with \$wgContentHandlerUseDB disabled." );
+ }
+
+ $page = $this->createPage( $title, $text, $model );
+
+ $editInfo = $page->prepareContentForEdit( $page->getContent() );
+
+ $v = $page->isCountable();
+ $w = $page->isCountable( $editInfo );
+
+ $this->assertEquals(
+ $expected,
+ $v,
+ "isCountable( null ) returned unexpected value " . var_export( $v, true )
+ . " instead of " . var_export( $expected, true )
+ . " in mode `$mode` for text \"$text\""
+ );
+
+ $this->assertEquals(
+ $expected,
+ $w,
+ "isCountable( \$editInfo ) returned unexpected value " . var_export( $v, true )
+ . " instead of " . var_export( $expected, true )
+ . " in mode `$mode` for text \"$text\""
+ );
+ }
+
+ public function provideGetParserOutput() {
+ return [
+ [
+ CONTENT_MODEL_WIKITEXT,
+ "hello ''world''\n",
+ "<div class=\"mw-parser-output\"><p>hello <i>world</i></p></div>"
+ ],
+ // @todo more...?
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetParserOutput
+ * @covers WikiPage::getParserOutput
+ */
+ public function testGetParserOutput( $model, $text, $expectedHtml ) {
+ $page = $this->createPage( __METHOD__, $text, $model );
+
+ $opt = $page->makeParserOptions( 'canonical' );
+ $po = $page->getParserOutput( $opt );
+ $text = $po->getText();
+
+ $text = trim( preg_replace( '/<!--.*?-->/sm', '', $text ) ); # strip injected comments
+ $text = preg_replace( '!\s*(</p>|</div>)!sm', '\1', $text ); # don't let tidy confuse us
+
+ $this->assertEquals( $expectedHtml, $text );
+
+ return $po;
+ }
+
+ /**
+ * @covers WikiPage::getParserOutput
+ */
+ public function testGetParserOutput_nonexisting() {
+ $page = new WikiPage( Title::newFromText( __METHOD__ ) );
+
+ $opt = new ParserOptions();
+ $po = $page->getParserOutput( $opt );
+
+ $this->assertFalse( $po, "getParserOutput() shall return false for non-existing pages." );
+ }
+
+ /**
+ * @covers WikiPage::getParserOutput
+ */
+ public function testGetParserOutput_badrev() {
+ $page = $this->createPage( __METHOD__, 'dummy', CONTENT_MODEL_WIKITEXT );
+
+ $opt = new ParserOptions();
+ $po = $page->getParserOutput( $opt, $page->getLatest() + 1234 );
+
+ // @todo would be neat to also test deleted revision
+
+ $this->assertFalse( $po, "getParserOutput() shall return false for non-existing revisions." );
+ }
+
+ public static $sections =
+
+ "Intro
+
+== stuff ==
+hello world
+
+== test ==
+just a test
+
+== foo ==
+more stuff
+";
+
+ public function dataReplaceSection() {
+ // NOTE: assume the Help namespace to contain wikitext
+ return [
+ [ 'Help:WikiPageTest_testReplaceSection',
+ CONTENT_MODEL_WIKITEXT,
+ self::$sections,
+ "0",
+ "No more",
+ null,
+ trim( preg_replace( '/^Intro/sm', 'No more', self::$sections ) )
+ ],
+ [ 'Help:WikiPageTest_testReplaceSection',
+ CONTENT_MODEL_WIKITEXT,
+ self::$sections,
+ "",
+ "No more",
+ null,
+ "No more"
+ ],
+ [ 'Help:WikiPageTest_testReplaceSection',
+ CONTENT_MODEL_WIKITEXT,
+ self::$sections,
+ "2",
+ "== TEST ==\nmore fun",
+ null,
+ trim( preg_replace( '/^== test ==.*== foo ==/sm',
+ "== TEST ==\nmore fun\n\n== foo ==",
+ self::$sections ) )
+ ],
+ [ 'Help:WikiPageTest_testReplaceSection',
+ CONTENT_MODEL_WIKITEXT,
+ self::$sections,
+ "8",
+ "No more",
+ null,
+ trim( self::$sections )
+ ],
+ [ 'Help:WikiPageTest_testReplaceSection',
+ CONTENT_MODEL_WIKITEXT,
+ self::$sections,
+ "new",
+ "No more",
+ "New",
+ trim( self::$sections ) . "\n\n== New ==\n\nNo more"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataReplaceSection
+ * @covers WikiPage::replaceSectionContent
+ */
+ public function testReplaceSectionContent( $title, $model, $text, $section,
+ $with, $sectionTitle, $expected
+ ) {
+ $page = $this->createPage( $title, $text, $model );
+
+ $content = ContentHandler::makeContent( $with, $page->getTitle(), $page->getContentModel() );
+ $c = $page->replaceSectionContent( $section, $content, $sectionTitle );
+
+ $this->assertEquals( $expected, is_null( $c ) ? null : trim( $c->getNativeData() ) );
+ }
+
+ /**
+ * @dataProvider dataReplaceSection
+ * @covers WikiPage::replaceSectionAtRev
+ */
+ public function testReplaceSectionAtRev( $title, $model, $text, $section,
+ $with, $sectionTitle, $expected
+ ) {
+ $page = $this->createPage( $title, $text, $model );
+ $baseRevId = $page->getLatest();
+
+ $content = ContentHandler::makeContent( $with, $page->getTitle(), $page->getContentModel() );
+ $c = $page->replaceSectionAtRev( $section, $content, $sectionTitle, $baseRevId );
+
+ $this->assertEquals( $expected, is_null( $c ) ? null : trim( $c->getNativeData() ) );
+ }
+
+ /**
+ * @covers WikiPage::getOldestRevision
+ */
+ public function testGetOldestRevision() {
+ $page = $this->newPage( __METHOD__ );
+ $page->doEditContent(
+ new WikitextContent( 'one' ),
+ "first edit",
+ EDIT_NEW
+ );
+ $rev1 = $page->getRevision();
+
+ $page = new WikiPage( $page->getTitle() );
+ $page->doEditContent(
+ new WikitextContent( 'two' ),
+ "second edit",
+ EDIT_UPDATE
+ );
+
+ $page = new WikiPage( $page->getTitle() );
+ $page->doEditContent(
+ new WikitextContent( 'three' ),
+ "third edit",
+ EDIT_UPDATE
+ );
+
+ // sanity check
+ $this->assertNotEquals(
+ $rev1->getId(),
+ $page->getRevision()->getId(),
+ '$page->getRevision()->getId()'
+ );
+
+ // actual test
+ $this->assertEquals(
+ $rev1->getId(),
+ $page->getOldestRevision()->getId(),
+ '$page->getOldestRevision()->getId()'
+ );
+ }
+
+ /**
+ * @covers WikiPage::doRollback
+ * @covers WikiPage::commitRollback
+ */
+ public function testDoRollback() {
+ $admin = $this->getTestSysop()->getUser();
+ $user1 = $this->getTestUser()->getUser();
+ // Use the confirmed group for user2 to make sure the user is different
+ $user2 = $this->getTestUser( [ 'confirmed' ] )->getUser();
+
+ $page = $this->newPage( __METHOD__ );
+
+ // Make some edits
+ $text = "one";
+ $status1 = $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ),
+ "section one", EDIT_NEW, false, $admin );
+
+ $text .= "\n\ntwo";
+ $status2 = $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ),
+ "adding section two", 0, false, $user1 );
+
+ $text .= "\n\nthree";
+ $status3 = $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ),
+ "adding section three", 0, false, $user2 );
+
+ /** @var Revision $rev1 */
+ /** @var Revision $rev2 */
+ /** @var Revision $rev3 */
+ $rev1 = $status1->getValue()['revision'];
+ $rev2 = $status2->getValue()['revision'];
+ $rev3 = $status3->getValue()['revision'];
+
+ /**
+ * We are having issues with doRollback spuriously failing. Apparently
+ * the last revision somehow goes missing or not committed under some
+ * circumstances. So, make sure the revisions have the correct usernames.
+ */
+ $this->assertEquals( 3, Revision::countByPageId( wfGetDB( DB_REPLICA ), $page->getId() ) );
+ $this->assertEquals( $admin->getName(), $rev1->getUserText() );
+ $this->assertEquals( $user1->getName(), $rev2->getUserText() );
+ $this->assertEquals( $user2->getName(), $rev3->getUserText() );
+
+ // Now, try the actual rollback
+ $token = $admin->getEditToken( 'rollback' );
+ $rollbackErrors = $page->doRollback(
+ $user2->getName(),
+ "testing rollback",
+ $token,
+ false,
+ $resultDetails,
+ $admin
+ );
+
+ if ( $rollbackErrors ) {
+ $this->fail(
+ "Rollback failed:\n" .
+ print_r( $rollbackErrors, true ) . ";\n" .
+ print_r( $resultDetails, true )
+ );
+ }
+
+ $page = new WikiPage( $page->getTitle() );
+ $this->assertEquals( $rev2->getSha1(), $page->getRevision()->getSha1(),
+ "rollback did not revert to the correct revision" );
+ $this->assertEquals( "one\n\ntwo", $page->getContent()->getNativeData() );
+ }
+
+ /**
+ * @covers WikiPage::doRollback
+ * @covers WikiPage::commitRollback
+ */
+ public function testDoRollback_simple() {
+ $admin = $this->getTestSysop()->getUser();
+
+ $text = "one";
+ $page = $this->newPage( __METHOD__ );
+ $page->doEditContent(
+ ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
+ "section one",
+ EDIT_NEW,
+ false,
+ $admin
+ );
+ $rev1 = $page->getRevision();
+
+ $user1 = $this->getTestUser()->getUser();
+ $text .= "\n\ntwo";
+ $page = new WikiPage( $page->getTitle() );
+ $page->doEditContent(
+ ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
+ "adding section two",
+ 0,
+ false,
+ $user1
+ );
+
+ # now, try the rollback
+ $token = $admin->getEditToken( 'rollback' );
+ $errors = $page->doRollback(
+ $user1->getName(),
+ "testing revert",
+ $token,
+ false,
+ $details,
+ $admin
+ );
+
+ if ( $errors ) {
+ $this->fail( "Rollback failed:\n" . print_r( $errors, true )
+ . ";\n" . print_r( $details, true ) );
+ }
+
+ $page = new WikiPage( $page->getTitle() );
+ $this->assertEquals( $rev1->getSha1(), $page->getRevision()->getSha1(),
+ "rollback did not revert to the correct revision" );
+ $this->assertEquals( "one", $page->getContent()->getNativeData() );
+ }
+
+ /**
+ * @covers WikiPage::doRollback
+ * @covers WikiPage::commitRollback
+ */
+ public function testDoRollbackFailureSameContent() {
+ $admin = $this->getTestSysop()->getUser();
+
+ $text = "one";
+ $page = $this->newPage( __METHOD__ );
+ $page->doEditContent(
+ ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
+ "section one",
+ EDIT_NEW,
+ false,
+ $admin
+ );
+ $rev1 = $page->getRevision();
+
+ $user1 = $this->getTestUser( [ 'sysop' ] )->getUser();
+ $text .= "\n\ntwo";
+ $page = new WikiPage( $page->getTitle() );
+ $page->doEditContent(
+ ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
+ "adding section two",
+ 0,
+ false,
+ $user1
+ );
+
+ # now, do a the rollback from the same user was doing the edit before
+ $resultDetails = [];
+ $token = $user1->getEditToken( 'rollback' );
+ $errors = $page->doRollback(
+ $user1->getName(),
+ "testing revert same user",
+ $token,
+ false,
+ $resultDetails,
+ $admin
+ );
+
+ $this->assertEquals( [], $errors, "Rollback failed same user" );
+
+ # now, try the rollback
+ $resultDetails = [];
+ $token = $admin->getEditToken( 'rollback' );
+ $errors = $page->doRollback(
+ $user1->getName(),
+ "testing revert",
+ $token,
+ false,
+ $resultDetails,
+ $admin
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'alreadyrolled',
+ __METHOD__,
+ $user1->getName(),
+ $admin->getName(),
+ ],
+ ],
+ $errors,
+ "Rollback not failed"
+ );
+
+ $page = new WikiPage( $page->getTitle() );
+ $this->assertEquals( $rev1->getSha1(), $page->getRevision()->getSha1(),
+ "rollback did not revert to the correct revision" );
+ $this->assertEquals( "one", $page->getContent()->getNativeData() );
+ }
+
+ /**
+ * Tests tagging for edits that do rollback action
+ * @covers WikiPage::doRollback
+ */
+ public function testDoRollbackTagging() {
+ if ( !in_array( 'mw-rollback', ChangeTags::getSoftwareTags() ) ) {
+ $this->markTestSkipped( 'Rollback tag deactivated, skipped the test.' );
+ }
+
+ $admin = new User();
+ $admin->setName( 'Administrator' );
+ $admin->addToDatabase();
+
+ $text = 'First line';
+ $page = $this->newPage( 'WikiPageTest_testDoRollbackTagging' );
+ $page->doEditContent(
+ ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
+ 'Added first line',
+ EDIT_NEW,
+ false,
+ $admin
+ );
+
+ $secondUser = new User();
+ $secondUser->setName( '92.65.217.32' );
+ $text .= '\n\nSecond line';
+ $page = new WikiPage( $page->getTitle() );
+ $page->doEditContent(
+ ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
+ 'Adding second line',
+ 0,
+ false,
+ $secondUser
+ );
+
+ // Now, try the rollback
+ $admin->addGroup( 'sysop' ); // Make the test user a sysop
+ $token = $admin->getEditToken( 'rollback' );
+ $errors = $page->doRollback(
+ $secondUser->getName(),
+ 'testing rollback',
+ $token,
+ false,
+ $resultDetails,
+ $admin
+ );
+
+ // If doRollback completed without errors
+ if ( $errors === [] ) {
+ $tags = $resultDetails[ 'tags' ];
+ $this->assertContains( 'mw-rollback', $tags );
+ }
+ }
+
+ public function provideGetAutoDeleteReason() {
+ return [
+ [
+ [],
+ false,
+ false
+ ],
+
+ [
+ [
+ [ "first edit", null ],
+ ],
+ "/first edit.*only contributor/",
+ false
+ ],
+
+ [
+ [
+ [ "first edit", null ],
+ [ "second edit", null ],
+ ],
+ "/second edit.*only contributor/",
+ true
+ ],
+
+ [
+ [
+ [ "first edit", "127.0.2.22" ],
+ [ "second edit", "127.0.3.33" ],
+ ],
+ "/second edit/",
+ true
+ ],
+
+ [
+ [
+ [
+ "first edit: "
+ . "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam "
+ . " nonumy eirmod tempor invidunt ut labore et dolore magna "
+ . "aliquyam erat, sed diam voluptua. At vero eos et accusam "
+ . "et justo duo dolores et ea rebum. Stet clita kasd gubergren, "
+ . "no sea takimata sanctus est Lorem ipsum dolor sit amet.'",
+ null
+ ],
+ ],
+ '/first edit:.*\.\.\."/',
+ false
+ ],
+
+ [
+ [
+ [ "first edit", "127.0.2.22" ],
+ [ "", "127.0.3.33" ],
+ ],
+ "/before blanking.*first edit/",
+ true
+ ],
+
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetAutoDeleteReason
+ * @covers WikiPage::getAutoDeleteReason
+ */
+ public function testGetAutoDeleteReason( $edits, $expectedResult, $expectedHistory ) {
+ global $wgUser;
+
+ // NOTE: assume Help namespace to contain wikitext
+ $page = $this->newPage( "Help:WikiPageTest_testGetAutoDeleteReason" );
+
+ $c = 1;
+
+ foreach ( $edits as $edit ) {
+ $user = new User();
+
+ if ( !empty( $edit[1] ) ) {
+ $user->setName( $edit[1] );
+ } else {
+ $user = $wgUser;
+ }
+
+ $content = ContentHandler::makeContent( $edit[0], $page->getTitle(), $page->getContentModel() );
+
+ $page->doEditContent( $content, "test edit $c", $c < 2 ? EDIT_NEW : 0, false, $user );
+
+ $c += 1;
+ }
+
+ $reason = $page->getAutoDeleteReason( $hasHistory );
+
+ if ( is_bool( $expectedResult ) || is_null( $expectedResult ) ) {
+ $this->assertEquals( $expectedResult, $reason );
+ } else {
+ $this->assertTrue( (bool)preg_match( $expectedResult, $reason ),
+ "Autosummary didn't match expected pattern $expectedResult: $reason" );
+ }
+
+ $this->assertEquals( $expectedHistory, $hasHistory,
+ "expected \$hasHistory to be " . var_export( $expectedHistory, true ) );
+
+ $page->doDeleteArticle( "done" );
+ }
+
+ public function providePreSaveTransform() {
+ return [
+ [ 'hello this is ~~~',
+ "hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]",
+ ],
+ [ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
+ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
+ ],
+ ];
+ }
+
+ /**
+ * @covers WikiPage::factory
+ */
+ public function testWikiPageFactory() {
+ $title = Title::makeTitle( NS_FILE, 'Someimage.png' );
+ $page = WikiPage::factory( $title );
+ $this->assertEquals( WikiFilePage::class, get_class( $page ) );
+
+ $title = Title::makeTitle( NS_CATEGORY, 'SomeCategory' );
+ $page = WikiPage::factory( $title );
+ $this->assertEquals( WikiCategoryPage::class, get_class( $page ) );
+
+ $title = Title::makeTitle( NS_MAIN, 'SomePage' );
+ $page = WikiPage::factory( $title );
+ $this->assertEquals( WikiPage::class, get_class( $page ) );
+ }
+
+ /**
+ * @dataProvider provideCommentMigrationOnDeletion
+ *
+ * @param int $writeStage
+ * @param int $readStage
+ */
+ public function testCommentMigrationOnDeletion( $writeStage, $readStage ) {
+ $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', $writeStage );
+ $this->overrideMwServices();
+
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $page = $this->createPage(
+ __METHOD__,
+ "foo",
+ CONTENT_MODEL_WIKITEXT
+ );
+ $revid = $page->getLatest();
+ if ( $writeStage > MIGRATION_OLD ) {
+ $comment_id = $dbr->selectField(
+ 'revision_comment_temp',
+ 'revcomment_comment_id',
+ [ 'revcomment_rev' => $revid ],
+ __METHOD__
+ );
+ }
+
+ $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', $readStage );
+ $this->overrideMwServices();
+
+ $page->doDeleteArticle( "testing deletion" );
+
+ if ( $readStage > MIGRATION_OLD ) {
+ // Didn't leave behind any 'revision_comment_temp' rows
+ $n = $dbr->selectField(
+ 'revision_comment_temp', 'COUNT(*)', [ 'revcomment_rev' => $revid ], __METHOD__
+ );
+ $this->assertEquals( 0, $n, 'no entry in revision_comment_temp after deletion' );
+
+ // Copied or upgraded the comment_id, as applicable
+ $ar_comment_id = $dbr->selectField(
+ 'archive',
+ 'ar_comment_id',
+ [ 'ar_rev_id' => $revid ],
+ __METHOD__
+ );
+ if ( $writeStage > MIGRATION_OLD ) {
+ $this->assertSame( $comment_id, $ar_comment_id );
+ } else {
+ $this->assertNotEquals( 0, $ar_comment_id );
+ }
+ }
+
+ // Copied rev_comment, if applicable
+ if ( $readStage <= MIGRATION_WRITE_BOTH && $writeStage <= MIGRATION_WRITE_BOTH ) {
+ $ar_comment = $dbr->selectField(
+ 'archive',
+ 'ar_comment',
+ [ 'ar_rev_id' => $revid ],
+ __METHOD__
+ );
+ $this->assertSame( 'testing', $ar_comment );
+ }
+ }
+
+ public function provideCommentMigrationOnDeletion() {
+ return [
+ [ MIGRATION_OLD, MIGRATION_OLD ],
+ [ MIGRATION_OLD, MIGRATION_WRITE_BOTH ],
+ [ MIGRATION_OLD, MIGRATION_WRITE_NEW ],
+ [ MIGRATION_WRITE_BOTH, MIGRATION_OLD ],
+ [ MIGRATION_WRITE_BOTH, MIGRATION_WRITE_BOTH ],
+ [ MIGRATION_WRITE_BOTH, MIGRATION_WRITE_NEW ],
+ [ MIGRATION_WRITE_BOTH, MIGRATION_NEW ],
+ [ MIGRATION_WRITE_NEW, MIGRATION_WRITE_BOTH ],
+ [ MIGRATION_WRITE_NEW, MIGRATION_WRITE_NEW ],
+ [ MIGRATION_WRITE_NEW, MIGRATION_NEW ],
+ [ MIGRATION_NEW, MIGRATION_WRITE_BOTH ],
+ [ MIGRATION_NEW, MIGRATION_WRITE_NEW ],
+ [ MIGRATION_NEW, MIGRATION_NEW ],
+ ];
+ }
+
+ /**
+ * @covers WikiPage::updateCategoryCounts
+ */
+ public function testUpdateCategoryCounts() {
+ $page = new WikiPage( Title::newFromText( __METHOD__ ) );
+
+ // Add an initial category
+ $page->updateCategoryCounts( [ 'A' ], [], 0 );
+
+ $this->assertEquals( 1, Category::newFromName( 'A' )->getPageCount() );
+ $this->assertEquals( 0, Category::newFromName( 'B' )->getPageCount() );
+ $this->assertEquals( 0, Category::newFromName( 'C' )->getPageCount() );
+
+ // Add a new category
+ $page->updateCategoryCounts( [ 'B' ], [], 0 );
+
+ $this->assertEquals( 1, Category::newFromName( 'A' )->getPageCount() );
+ $this->assertEquals( 1, Category::newFromName( 'B' )->getPageCount() );
+ $this->assertEquals( 0, Category::newFromName( 'C' )->getPageCount() );
+
+ // Add and remove a category
+ $page->updateCategoryCounts( [ 'C' ], [ 'A' ], 0 );
+
+ $this->assertEquals( 0, Category::newFromName( 'A' )->getPageCount() );
+ $this->assertEquals( 1, Category::newFromName( 'B' )->getPageCount() );
+ $this->assertEquals( 1, Category::newFromName( 'C' )->getPageCount() );
+ }
+
+ public function provideUpdateRedirectOn() {
+ yield [ '#REDIRECT [[Foo]]', true, null, true, true, 0 ];
+ yield [ '#REDIRECT [[Foo]]', true, 'Foo', true, false, 1 ];
+ yield [ 'SomeText', false, null, false, true, 0 ];
+ yield [ 'SomeText', false, 'Foo', false, false, 1 ];
+ }
+
+ /**
+ * @dataProvider provideUpdateRedirectOn
+ * @covers WikiPage::updateRedirectOn
+ *
+ * @param string $initialText
+ * @param bool $initialRedirectState
+ * @param string|null $redirectTitle
+ * @param bool|null $lastRevIsRedirect
+ * @param bool $expectedSuccess
+ * @param int $expectedRowCount
+ */
+ public function testUpdateRedirectOn(
+ $initialText,
+ $initialRedirectState,
+ $redirectTitle,
+ $lastRevIsRedirect,
+ $expectedSuccess,
+ $expectedRowCount
+ ) {
+ static $pageCounter = 0;
+ $pageCounter++;
+
+ $page = $this->createPage( Title::newFromText( __METHOD__ . $pageCounter ), $initialText );
+ $this->assertSame( $initialRedirectState, $page->isRedirect() );
+
+ $redirectTitle = is_string( $redirectTitle )
+ ? Title::newFromText( $redirectTitle )
+ : $redirectTitle;
+
+ $success = $page->updateRedirectOn( $this->db, $redirectTitle, $lastRevIsRedirect );
+ $this->assertSame( $expectedSuccess, $success, 'Success assertion' );
+ /**
+ * updateRedirectOn explicitly updates the redirect table (and not the page table).
+ * Most of core checks the page table for redirect status, so we have to be ugly and
+ * assert a select from the table here.
+ */
+ $this->assertRedirectTableCountForPageId( $page->getId(), $expectedRowCount );
+ }
+
+ private function assertRedirectTableCountForPageId( $pageId, $expected ) {
+ $this->assertSelect(
+ 'redirect',
+ 'COUNT(*)',
+ [ 'rd_from' => $pageId ],
+ [ [ strval( $expected ) ] ]
+ );
+ }
+
+ /**
+ * @covers WikiPage::insertRedirectEntry
+ */
+ public function testInsertRedirectEntry_insertsRedirectEntry() {
+ $page = $this->createPage( Title::newFromText( __METHOD__ ), 'A' );
+ $this->assertRedirectTableCountForPageId( $page->getId(), 0 );
+
+ $targetTitle = Title::newFromText( 'SomeTarget#Frag' );
+ $targetTitle->mInterwiki = 'eninter';
+ $page->insertRedirectEntry( $targetTitle, null );
+
+ $this->assertSelect(
+ 'redirect',
+ [ 'rd_from', 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ],
+ [ 'rd_from' => $page->getId() ],
+ [ [
+ strval( $page->getId() ),
+ strval( $targetTitle->getNamespace() ),
+ strval( $targetTitle->getDBkey() ),
+ strval( $targetTitle->getFragment() ),
+ strval( $targetTitle->getInterwiki() ),
+ ] ]
+ );
+ }
+
+ /**
+ * @covers WikiPage::insertRedirectEntry
+ */
+ public function testInsertRedirectEntry_insertsRedirectEntryWithPageLatest() {
+ $page = $this->createPage( Title::newFromText( __METHOD__ ), 'A' );
+ $this->assertRedirectTableCountForPageId( $page->getId(), 0 );
+
+ $targetTitle = Title::newFromText( 'SomeTarget#Frag' );
+ $targetTitle->mInterwiki = 'eninter';
+ $page->insertRedirectEntry( $targetTitle, $page->getLatest() );
+
+ $this->assertSelect(
+ 'redirect',
+ [ 'rd_from', 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ],
+ [ 'rd_from' => $page->getId() ],
+ [ [
+ strval( $page->getId() ),
+ strval( $targetTitle->getNamespace() ),
+ strval( $targetTitle->getDBkey() ),
+ strval( $targetTitle->getFragment() ),
+ strval( $targetTitle->getInterwiki() ),
+ ] ]
+ );
+ }
+
+ /**
+ * @covers WikiPage::insertRedirectEntry
+ */
+ public function testInsertRedirectEntry_doesNotInsertIfPageLatestIncorrect() {
+ $page = $this->createPage( Title::newFromText( __METHOD__ ), 'A' );
+ $this->assertRedirectTableCountForPageId( $page->getId(), 0 );
+
+ $targetTitle = Title::newFromText( 'SomeTarget#Frag' );
+ $targetTitle->mInterwiki = 'eninter';
+ $page->insertRedirectEntry( $targetTitle, 215251 );
+
+ $this->assertRedirectTableCountForPageId( $page->getId(), 0 );
+ }
+
+ private function getRow( array $overrides = [] ) {
+ $row = [
+ 'page_id' => '44',
+ 'page_len' => '76',
+ 'page_is_redirect' => '1',
+ 'page_latest' => '99',
+ 'page_namespace' => '3',
+ 'page_title' => 'JaJaTitle',
+ 'page_restrictions' => 'edit=autoconfirmed,sysop:move=sysop',
+ 'page_touched' => '20120101020202',
+ 'page_links_updated' => '20140101020202',
+ ];
+ foreach ( $overrides as $key => $value ) {
+ $row[$key] = $value;
+ }
+ return (object)$row;
+ }
+
+ public function provideNewFromRowSuccess() {
+ yield 'basic row' => [
+ $this->getRow(),
+ function ( WikiPage $wikiPage, self $test ) {
+ $test->assertSame( 44, $wikiPage->getId() );
+ $test->assertSame( 76, $wikiPage->getTitle()->getLength() );
+ $test->assertTrue( $wikiPage->isRedirect() );
+ $test->assertSame( 99, $wikiPage->getLatest() );
+ $test->assertSame( 3, $wikiPage->getTitle()->getNamespace() );
+ $test->assertSame( 'JaJaTitle', $wikiPage->getTitle()->getDBkey() );
+ $test->assertSame(
+ [
+ 'edit' => [ 'autoconfirmed', 'sysop' ],
+ 'move' => [ 'sysop' ],
+ ],
+ $wikiPage->getTitle()->getAllRestrictions()
+ );
+ $test->assertSame( '20120101020202', $wikiPage->getTouched() );
+ $test->assertSame( '20140101020202', $wikiPage->getLinksTimestamp() );
+ }
+ ];
+ yield 'different timestamp formats' => [
+ $this->getRow( [
+ 'page_touched' => '2012-01-01 02:02:02',
+ 'page_links_updated' => '2014-01-01 02:02:02',
+ ] ),
+ function ( WikiPage $wikiPage, self $test ) {
+ $test->assertSame( '20120101020202', $wikiPage->getTouched() );
+ $test->assertSame( '20140101020202', $wikiPage->getLinksTimestamp() );
+ }
+ ];
+ yield 'no restrictions' => [
+ $this->getRow( [
+ 'page_restrictions' => '',
+ ] ),
+ function ( WikiPage $wikiPage, self $test ) {
+ $test->assertSame(
+ [
+ 'edit' => [],
+ 'move' => [],
+ ],
+ $wikiPage->getTitle()->getAllRestrictions()
+ );
+ }
+ ];
+ yield 'not redirect' => [
+ $this->getRow( [
+ 'page_is_redirect' => '0',
+ ] ),
+ function ( WikiPage $wikiPage, self $test ) {
+ $test->assertFalse( $wikiPage->isRedirect() );
+ }
+ ];
+ }
+
+ /**
+ * @covers WikiPage::newFromRow
+ * @covers WikiPage::loadFromRow
+ * @dataProvider provideNewFromRowSuccess
+ *
+ * @param object $row
+ * @param callable $assertions
+ */
+ public function testNewFromRow( $row, $assertions ) {
+ $page = WikiPage::newFromRow( $row, 'fromdb' );
+ $assertions( $page, $this );
+ }
+
+ public function provideTestNewFromId_returnsNullOnBadPageId() {
+ yield[ 0 ];
+ yield[ -11 ];
+ }
+
+ /**
+ * @covers WikiPage::newFromID
+ * @dataProvider provideTestNewFromId_returnsNullOnBadPageId
+ */
+ public function testNewFromId_returnsNullOnBadPageId( $pageId ) {
+ $this->assertNull( WikiPage::newFromID( $pageId ) );
+ }
+
+ /**
+ * @covers WikiPage::newFromID
+ */
+ public function testNewFromId_appearsToFetchCorrectRow() {
+ $createdPage = $this->createPage( __METHOD__, 'Xsfaij09' );
+ $fetchedPage = WikiPage::newFromID( $createdPage->getId() );
+ $this->assertSame( $createdPage->getId(), $fetchedPage->getId() );
+ $this->assertEquals(
+ $createdPage->getContent()->getNativeData(),
+ $fetchedPage->getContent()->getNativeData()
+ );
+ }
+
+ /**
+ * @covers WikiPage::newFromID
+ */
+ public function testNewFromId_returnsNullOnNonExistingId() {
+ $this->assertNull( WikiPage::newFromID( 2147483647 ) );
+ }
+
+ public function provideTestInsertProtectNullRevision() {
+ // phpcs:disable Generic.Files.LineLength
+ yield [
+ 'goat-message-key',
+ [ 'edit' => 'sysop' ],
+ [ 'edit' => '20200101040404' ],
+ false,
+ 'Goat Reason',
+ true,
+ '(goat-message-key: WikiPageDbTestBase::testInsertProtectNullRevision, UTSysop)(colon-separator)Goat Reason(word-separator)(parentheses: (protect-summary-desc: (restriction-edit), (protect-level-sysop), (protect-expiring: 04:04, 1 (january) 2020, 1 (january) 2020, 04:04)))'
+ ];
+ yield [
+ 'goat-key',
+ [ 'edit' => 'sysop', 'move' => 'something' ],
+ [ 'edit' => '20200101040404', 'move' => '20210101050505' ],
+ false,
+ 'Goat Goat',
+ true,
+ '(goat-key: WikiPageDbTestBase::testInsertProtectNullRevision, UTSysop)(colon-separator)Goat Goat(word-separator)(parentheses: (protect-summary-desc: (restriction-edit), (protect-level-sysop), (protect-expiring: 04:04, 1 (january) 2020, 1 (january) 2020, 04:04))(word-separator)(protect-summary-desc: (restriction-move), (protect-level-something), (protect-expiring: 05:05, 1 (january) 2021, 1 (january) 2021, 05:05)))'
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @dataProvider provideTestInsertProtectNullRevision
+ * @covers WikiPage::insertProtectNullRevision
+ * @covers WikiPage::protectDescription
+ *
+ * @param string $revCommentMsg
+ * @param array $limit
+ * @param array $expiry
+ * @param bool $cascade
+ * @param string $reason
+ * @param bool|null $user true if the test sysop should be used, or null
+ * @param string $expectedComment
+ */
+ public function testInsertProtectNullRevision(
+ $revCommentMsg,
+ array $limit,
+ array $expiry,
+ $cascade,
+ $reason,
+ $user,
+ $expectedComment
+ ) {
+ $this->setContentLang( 'qqx' );
+
+ $page = $this->createPage( __METHOD__, 'Goat' );
+
+ $user = $user === null ? $user : $this->getTestSysop()->getUser();
+
+ $result = $page->insertProtectNullRevision(
+ $revCommentMsg,
+ $limit,
+ $expiry,
+ $cascade,
+ $reason,
+ $user
+ );
+
+ $this->assertTrue( $result instanceof Revision );
+ $this->assertSame( $expectedComment, $result->getComment( Revision::RAW ) );
+ }
+
+ /**
+ * @covers WikiPage::updateRevisionOn
+ */
+ public function testUpdateRevisionOn_existingPage() {
+ $user = $this->getTestSysop()->getUser();
+ $page = $this->createPage( __METHOD__, 'StartText' );
+
+ $revision = new Revision(
+ [
+ 'id' => 9989,
+ 'page' => $page->getId(),
+ 'title' => $page->getTitle(),
+ 'comment' => __METHOD__,
+ 'minor_edit' => true,
+ 'text' => __METHOD__ . '-text',
+ 'len' => strlen( __METHOD__ . '-text' ),
+ 'user' => $user->getId(),
+ 'user_text' => $user->getName(),
+ 'timestamp' => '20170707040404',
+ 'content_model' => CONTENT_MODEL_WIKITEXT,
+ 'content_format' => CONTENT_FORMAT_WIKITEXT,
+ ]
+ );
+
+ $result = $page->updateRevisionOn( $this->db, $revision );
+ $this->assertTrue( $result );
+ $this->assertSame( 9989, $page->getLatest() );
+ $this->assertEquals( $revision, $page->getRevision() );
+ }
+
+ /**
+ * @covers WikiPage::updateRevisionOn
+ */
+ public function testUpdateRevisionOn_NonExistingPage() {
+ $user = $this->getTestSysop()->getUser();
+ $page = $this->createPage( __METHOD__, 'StartText' );
+ $page->doDeleteArticle( 'reason' );
+
+ $revision = new Revision(
+ [
+ 'id' => 9989,
+ 'page' => $page->getId(),
+ 'title' => $page->getTitle(),
+ 'comment' => __METHOD__,
+ 'minor_edit' => true,
+ 'text' => __METHOD__ . '-text',
+ 'len' => strlen( __METHOD__ . '-text' ),
+ 'user' => $user->getId(),
+ 'user_text' => $user->getName(),
+ 'timestamp' => '20170707040404',
+ 'content_model' => CONTENT_MODEL_WIKITEXT,
+ 'content_format' => CONTENT_FORMAT_WIKITEXT,
+ ]
+ );
+
+ $result = $page->updateRevisionOn( $this->db, $revision );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @covers WikiPage::updateIfNewerOn
+ */
+ public function testUpdateIfNewerOn_olderRevision() {
+ $user = $this->getTestSysop()->getUser();
+ $page = $this->createPage( __METHOD__, 'StartText' );
+ $initialRevision = $page->getRevision();
+
+ $olderTimeStamp = wfTimestamp(
+ TS_MW,
+ wfTimestamp( TS_UNIX, $initialRevision->getTimestamp() ) - 1
+ );
+
+ $olderRevison = new Revision(
+ [
+ 'id' => 9989,
+ 'page' => $page->getId(),
+ 'title' => $page->getTitle(),
+ 'comment' => __METHOD__,
+ 'minor_edit' => true,
+ 'text' => __METHOD__ . '-text',
+ 'len' => strlen( __METHOD__ . '-text' ),
+ 'user' => $user->getId(),
+ 'user_text' => $user->getName(),
+ 'timestamp' => $olderTimeStamp,
+ 'content_model' => CONTENT_MODEL_WIKITEXT,
+ 'content_format' => CONTENT_FORMAT_WIKITEXT,
+ ]
+ );
+
+ $result = $page->updateIfNewerOn( $this->db, $olderRevison );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @covers WikiPage::updateIfNewerOn
+ */
+ public function testUpdateIfNewerOn_newerRevision() {
+ $user = $this->getTestSysop()->getUser();
+ $page = $this->createPage( __METHOD__, 'StartText' );
+ $initialRevision = $page->getRevision();
+
+ $newerTimeStamp = wfTimestamp(
+ TS_MW,
+ wfTimestamp( TS_UNIX, $initialRevision->getTimestamp() ) + 1
+ );
+
+ $newerRevision = new Revision(
+ [
+ 'id' => 9989,
+ 'page' => $page->getId(),
+ 'title' => $page->getTitle(),
+ 'comment' => __METHOD__,
+ 'minor_edit' => true,
+ 'text' => __METHOD__ . '-text',
+ 'len' => strlen( __METHOD__ . '-text' ),
+ 'user' => $user->getId(),
+ 'user_text' => $user->getName(),
+ 'timestamp' => $newerTimeStamp,
+ 'content_model' => CONTENT_MODEL_WIKITEXT,
+ 'content_format' => CONTENT_FORMAT_WIKITEXT,
+ ]
+ );
+ $result = $page->updateIfNewerOn( $this->db, $newerRevision );
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * @covers WikiPage::insertOn
+ */
+ public function testInsertOn() {
+ $title = Title::newFromText( __METHOD__ );
+ $page = new WikiPage( $title );
+
+ $startTimeStamp = wfTimestampNow();
+ $result = $page->insertOn( $this->db );
+ $endTimeStamp = wfTimestampNow();
+
+ $this->assertInternalType( 'int', $result );
+ $this->assertTrue( $result > 0 );
+
+ $condition = [ 'page_id' => $result ];
+
+ // Check the default fields have been filled
+ $this->assertSelect(
+ 'page',
+ [
+ 'page_namespace',
+ 'page_title',
+ 'page_restrictions',
+ 'page_is_redirect',
+ 'page_is_new',
+ 'page_latest',
+ 'page_len',
+ ],
+ $condition,
+ [ [
+ '0',
+ __METHOD__,
+ '',
+ '0',
+ '1',
+ '0',
+ '0',
+ ] ]
+ );
+
+ // Check the page_random field has been filled
+ $pageRandom = $this->db->selectField( 'page', 'page_random', $condition );
+ $this->assertTrue( (float)$pageRandom < 1 && (float)$pageRandom > 0 );
+
+ // Assert the touched timestamp in the DB is roughly when we inserted the page
+ $pageTouched = $this->db->selectField( 'page', 'page_touched', $condition );
+ $this->assertTrue(
+ wfTimestamp( TS_UNIX, $startTimeStamp )
+ <= wfTimestamp( TS_UNIX, $pageTouched )
+ );
+ $this->assertTrue(
+ wfTimestamp( TS_UNIX, $endTimeStamp )
+ >= wfTimestamp( TS_UNIX, $pageTouched )
+ );
+
+ // Try inserting the same page again and checking the result is false (no change)
+ $result = $page->insertOn( $this->db );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @covers WikiPage::insertOn
+ */
+ public function testInsertOn_idSpecified() {
+ $title = Title::newFromText( __METHOD__ );
+ $page = new WikiPage( $title );
+ $id = 1478952189;
+
+ $result = $page->insertOn( $this->db, $id );
+
+ $this->assertSame( $id, $result );
+
+ $condition = [ 'page_id' => $result ];
+
+ // Check there is actually a row in the db
+ $this->assertSelect(
+ 'page',
+ [ 'page_title' ],
+ $condition,
+ [ [ __METHOD__ ] ]
+ );
+ }
+
+ public function provideTestDoUpdateRestrictions_setBasicRestrictions() {
+ // Note: Once the current dates passes the date in these tests they will fail.
+ yield 'move something' => [
+ true,
+ [ 'move' => 'something' ],
+ [],
+ [ 'edit' => [], 'move' => [ 'something' ] ],
+ [],
+ ];
+ yield 'move something, edit blank' => [
+ true,
+ [ 'move' => 'something', 'edit' => '' ],
+ [],
+ [ 'edit' => [], 'move' => [ 'something' ] ],
+ [],
+ ];
+ yield 'edit sysop, with expiry' => [
+ true,
+ [ 'edit' => 'sysop' ],
+ [ 'edit' => '21330101020202' ],
+ [ 'edit' => [ 'sysop' ], 'move' => [] ],
+ [ 'edit' => '21330101020202' ],
+ ];
+ yield 'move and edit, move with expiry' => [
+ true,
+ [ 'move' => 'something', 'edit' => 'another' ],
+ [ 'move' => '22220202010101' ],
+ [ 'edit' => [ 'another' ], 'move' => [ 'something' ] ],
+ [ 'move' => '22220202010101' ],
+ ];
+ yield 'move and edit, edit with infinity expiry' => [
+ true,
+ [ 'move' => 'something', 'edit' => 'another' ],
+ [ 'edit' => 'infinity' ],
+ [ 'edit' => [ 'another' ], 'move' => [ 'something' ] ],
+ [ 'edit' => 'infinity' ],
+ ];
+ yield 'non existing, create something' => [
+ false,
+ [ 'create' => 'something' ],
+ [],
+ [ 'create' => [ 'something' ] ],
+ [],
+ ];
+ yield 'non existing, create something with expiry' => [
+ false,
+ [ 'create' => 'something' ],
+ [ 'create' => '23451212112233' ],
+ [ 'create' => [ 'something' ] ],
+ [ 'create' => '23451212112233' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideTestDoUpdateRestrictions_setBasicRestrictions
+ * @covers WikiPage::doUpdateRestrictions
+ */
+ public function testDoUpdateRestrictions_setBasicRestrictions(
+ $pageExists,
+ array $limit,
+ array $expiry,
+ array $expectedRestrictions,
+ array $expectedRestrictionExpiries
+ ) {
+ if ( $pageExists ) {
+ $page = $this->createPage( __METHOD__, 'ABC' );
+ } else {
+ $page = new WikiPage( Title::newFromText( __METHOD__ . '-nonexist' ) );
+ }
+ $user = $this->getTestSysop()->getUser();
+ $cascade = false;
+
+ $status = $page->doUpdateRestrictions( $limit, $expiry, $cascade, 'aReason', $user, [] );
+
+ $logId = $status->getValue();
+ $allRestrictions = $page->getTitle()->getAllRestrictions();
+
+ $this->assertTrue( $status->isGood() );
+ $this->assertInternalType( 'int', $logId );
+ $this->assertSame( $expectedRestrictions, $allRestrictions );
+ foreach ( $expectedRestrictionExpiries as $key => $value ) {
+ $this->assertSame( $value, $page->getTitle()->getRestrictionExpiry( $key ) );
+ }
+
+ // Make sure the log entry looks good
+ // log_params is not checked here
+ $actorQuery = ActorMigration::newMigration()->getJoin( 'log_user' );
+ $this->assertSelect(
+ [ 'logging' ] + $actorQuery['tables'],
+ [
+ 'log_comment',
+ 'log_user' => $actorQuery['fields']['log_user'],
+ 'log_user_text' => $actorQuery['fields']['log_user_text'],
+ 'log_namespace',
+ 'log_title',
+ ],
+ [ 'log_id' => $logId ],
+ [ [
+ 'aReason',
+ (string)$user->getId(),
+ $user->getName(),
+ (string)$page->getTitle()->getNamespace(),
+ $page->getTitle()->getDBkey(),
+ ] ],
+ [],
+ $actorQuery['joins']
+ );
+ }
+
+ /**
+ * @covers WikiPage::doUpdateRestrictions
+ */
+ public function testDoUpdateRestrictions_failsOnReadOnly() {
+ $page = $this->createPage( __METHOD__, 'ABC' );
+ $user = $this->getTestSysop()->getUser();
+ $cascade = false;
+
+ // Set read only
+ $readOnly = $this->getMockBuilder( ReadOnlyMode::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'isReadOnly', 'getReason' ] )
+ ->getMock();
+ $readOnly->expects( $this->once() )
+ ->method( 'isReadOnly' )
+ ->will( $this->returnValue( true ) );
+ $readOnly->expects( $this->once() )
+ ->method( 'getReason' )
+ ->will( $this->returnValue( 'Some Read Only Reason' ) );
+ $this->setService( 'ReadOnlyMode', $readOnly );
+
+ $status = $page->doUpdateRestrictions( [], [], $cascade, 'aReason', $user, [] );
+ $this->assertFalse( $status->isOK() );
+ $this->assertSame( 'readonlytext', $status->getMessage()->getKey() );
+ }
+
+ /**
+ * @covers WikiPage::doUpdateRestrictions
+ */
+ public function testDoUpdateRestrictions_returnsGoodIfNothingChanged() {
+ $page = $this->createPage( __METHOD__, 'ABC' );
+ $user = $this->getTestSysop()->getUser();
+ $cascade = false;
+ $limit = [ 'edit' => 'sysop' ];
+
+ $status = $page->doUpdateRestrictions(
+ $limit,
+ [],
+ $cascade,
+ 'aReason',
+ $user,
+ []
+ );
+
+ // The first entry should have a logId as it did something
+ $this->assertTrue( $status->isGood() );
+ $this->assertInternalType( 'int', $status->getValue() );
+
+ $status = $page->doUpdateRestrictions(
+ $limit,
+ [],
+ $cascade,
+ 'aReason',
+ $user,
+ []
+ );
+
+ // The second entry should not have a logId as nothing changed
+ $this->assertTrue( $status->isGood() );
+ $this->assertNull( $status->getValue() );
+ }
+
+ /**
+ * @covers WikiPage::doUpdateRestrictions
+ */
+ public function testDoUpdateRestrictions_logEntryTypeAndAction() {
+ $page = $this->createPage( __METHOD__, 'ABC' );
+ $user = $this->getTestSysop()->getUser();
+ $cascade = false;
+
+ // Protect the page
+ $status = $page->doUpdateRestrictions(
+ [ 'edit' => 'sysop' ],
+ [],
+ $cascade,
+ 'aReason',
+ $user,
+ []
+ );
+ $this->assertTrue( $status->isGood() );
+ $this->assertInternalType( 'int', $status->getValue() );
+ $this->assertSelect(
+ 'logging',
+ [ 'log_type', 'log_action' ],
+ [ 'log_id' => $status->getValue() ],
+ [ [ 'protect', 'protect' ] ]
+ );
+
+ // Modify the protection
+ $status = $page->doUpdateRestrictions(
+ [ 'edit' => 'somethingElse' ],
+ [],
+ $cascade,
+ 'aReason',
+ $user,
+ []
+ );
+ $this->assertTrue( $status->isGood() );
+ $this->assertInternalType( 'int', $status->getValue() );
+ $this->assertSelect(
+ 'logging',
+ [ 'log_type', 'log_action' ],
+ [ 'log_id' => $status->getValue() ],
+ [ [ 'protect', 'modify' ] ]
+ );
+
+ // Remove the protection
+ $status = $page->doUpdateRestrictions(
+ [],
+ [],
+ $cascade,
+ 'aReason',
+ $user,
+ []
+ );
+ $this->assertTrue( $status->isGood() );
+ $this->assertInternalType( 'int', $status->getValue() );
+ $this->assertSelect(
+ 'logging',
+ [ 'log_type', 'log_action' ],
+ [ 'log_id' => $status->getValue() ],
+ [ [ 'protect', 'unprotect' ] ]
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/page/WikiPageNoContentHandlerDbTest.php b/www/wiki/tests/phpunit/includes/page/WikiPageNoContentHandlerDbTest.php
new file mode 100644
index 00000000..a6ce185a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/page/WikiPageNoContentHandlerDbTest.php
@@ -0,0 +1,14 @@
+<?php
+
+/**
+ * @group ContentHandler
+ * @group Database
+ * @group medium
+ */
+class WikiPageNoContentHandlerDbTest extends WikiPageDbTestBase {
+
+ protected function getContentHandlerUseDB() {
+ return false;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/pager/RangeChronologicalPagerTest.php b/www/wiki/tests/phpunit/includes/pager/RangeChronologicalPagerTest.php
new file mode 100644
index 00000000..72390ac8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/pager/RangeChronologicalPagerTest.php
@@ -0,0 +1,99 @@
+<?php
+
+/**
+ * Test class for RangeChronologicalPagerTest logic.
+ *
+ * @group Pager
+ *
+ * @author Geoffrey Mon <geofbot@gmail.com>
+ */
+class RangeChronologicalPagerTest extends MediaWikiLangTestCase {
+
+ /**
+ * @covers RangeChronologicalPager::getDateCond
+ * @dataProvider getDateCondProvider
+ */
+ public function testGetDateCond( $inputYear, $inputMonth, $inputDay, $expected ) {
+ $pager = $this->getMockForAbstractClass( RangeChronologicalPager::class );
+ $this->assertEquals(
+ $expected,
+ wfTimestamp( TS_MW, $pager->getDateCond( $inputYear, $inputMonth, $inputDay ) )
+ );
+ }
+
+ /**
+ * Data provider in [ input year, input month, input day, expected timestamp output ] format
+ */
+ public function getDateCondProvider() {
+ return [
+ [ 2016, 12, 5, '20161205235959' ],
+ [ 2016, 12, 31, '20161231235959' ],
+ [ 2016, 12, 1337, '20161231235959' ],
+ [ 2016, 1337, 1337, '20161231235959' ],
+ [ 2016, 1337, -1, '20161231235959' ],
+ [ 2016, 12, 32, '20161231235959' ],
+ [ 2016, 12, -1, '20161231235959' ],
+ [ 2016, -1, -1, '20161231235959' ],
+ ];
+ }
+
+ /**
+ * @covers RangeChronologicalPager::getDateRangeCond
+ * @dataProvider getDateRangeCondProvider
+ */
+ public function testGetDateRangeCond( $start, $end, $expected ) {
+ $pager = $this->getMockForAbstractClass( RangeChronologicalPager::class );
+ $this->assertArrayEquals( $expected, $pager->getDateRangeCond( $start, $end ) );
+ }
+
+ /**
+ * Data provider in [ start, end, [ expected output has start condition, has end cond ] ] format
+ */
+ public function getDateRangeCondProvider() {
+ $db = wfGetDB( DB_MASTER );
+
+ return [
+ [
+ '20161201000000',
+ '20161203000000',
+ [
+ '>=' . $db->addQuotes( $db->timestamp( '20161201000000' ) ),
+ '<=' . $db->addQuotes( $db->timestamp( '20161203000000' ) ),
+ ],
+ ],
+ [
+ '',
+ '20161203000000',
+ [
+ '<=' . $db->addQuotes( $db->timestamp( '20161203000000' ) ),
+ ],
+ ],
+ [
+ '20161201000000',
+ '',
+ [
+ '>=' . $db->addQuotes( $db->timestamp( '20161201000000' ) ),
+ ],
+ ],
+ [ '', '', [] ],
+ ];
+ }
+
+ /**
+ * @covers RangeChronologicalPager::getDateRangeCond
+ * @dataProvider getDateRangeCondInvalidProvider
+ */
+ public function testGetDateRangeCondInvalid( $start, $end ) {
+ $pager = $this->getMockForAbstractClass( RangeChronologicalPager::class );
+ $this->assertEquals( null, $pager->getDateRangeCond( $start, $end ) );
+ }
+
+ public function getDateRangeCondInvalidProvider() {
+ return [
+ [ '-2016-12-01', '2017-12-01', ],
+ [ '2016-12-01', '-2017-12-01', ],
+ [ 'abcdefghij', 'klmnopqrstu', ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/pager/ReverseChronologicalPagerTest.php b/www/wiki/tests/phpunit/includes/pager/ReverseChronologicalPagerTest.php
new file mode 100644
index 00000000..3910ab64
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/pager/ReverseChronologicalPagerTest.php
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * Test class for ReverseChronologicalPagerTest methods.
+ *
+ * @group Pager
+ *
+ * @author Geoffrey Mon <geofbot@gmail.com>
+ */
+class ReverseChronologicalPagerTest extends MediaWikiLangTestCase {
+
+ /**
+ * @covers ReverseChronologicalPager::getDateCond
+ */
+ public function testGetDateCond() {
+ $pager = $this->getMockForAbstractClass( ReverseChronologicalPager::class );
+ $timestamp = MWTimestamp::getInstance();
+ $db = wfGetDB( DB_MASTER );
+
+ $currYear = $timestamp->format( 'Y' );
+ $currMonth = $timestamp->format( 'n' );
+
+ // Test that getDateCond sets and returns mOffset
+ $this->assertEquals( $pager->getDateCond( 2006, 6 ), $pager->mOffset );
+
+ // Test year and month
+ $pager->getDateCond( 2006, 6 );
+ $this->assertEquals( $pager->mOffset, $db->timestamp( '20060701000000' ) );
+
+ // Test year, month, and day
+ $pager->getDateCond( 2006, 6, 5 );
+ $this->assertEquals( $pager->mOffset, $db->timestamp( '20060606000000' ) );
+
+ // Test month overflow into the next year
+ $pager->getDateCond( 2006, 12 );
+ $this->assertEquals( $pager->mOffset, $db->timestamp( '20070101000000' ) );
+
+ // Test day overflow to the next month
+ $pager->getDateCond( 2006, 6, 30 );
+ $this->assertEquals( $pager->mOffset, $db->timestamp( '20060701000000' ) );
+
+ // Test invalid month (should use end of year)
+ $pager->getDateCond( 2006, -1 );
+ $this->assertEquals( $pager->mOffset, $db->timestamp( '20070101000000' ) );
+
+ // Test invalid day (should use end of month)
+ $pager->getDateCond( 2006, 6, 1337 );
+ $this->assertEquals( $pager->mOffset, $db->timestamp( '20060701000000' ) );
+
+ // Test last day of year
+ $pager->getDateCond( 2006, 12, 31 );
+ $this->assertEquals( $pager->mOffset, $db->timestamp( '20070101000000' ) );
+
+ // Test invalid day that overflows to next year
+ $pager->getDateCond( 2006, 12, 32 );
+ $this->assertEquals( $pager->mOffset, $db->timestamp( '20070101000000' ) );
+
+ // Test month past current month (should use previous year)
+ if ( $currMonth < 5 ) {
+ $pager->getDateCond( -1, 5 );
+ $this->assertEquals( $pager->mOffset, $db->timestamp( $currYear - 1 . '0601000000' ) );
+ }
+ if ( $currMonth < 12 ) {
+ $pager->getDateCond( -1, 12 );
+ $this->assertEquals( $pager->mOffset, $db->timestamp( $currYear . '0101000000' ) );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/parser/CoreParserFunctionsTest.php b/www/wiki/tests/phpunit/includes/parser/CoreParserFunctionsTest.php
new file mode 100644
index 00000000..c6304477
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/parser/CoreParserFunctionsTest.php
@@ -0,0 +1,21 @@
+<?php
+/**
+ * @group Database
+ * @covers CoreParserFunctions
+ */
+class CoreParserFunctionsTest extends MediaWikiTestCase {
+
+ public function testGender() {
+ $user = User::createNew( '*Female' );
+ $user->setOption( 'gender', 'female' );
+ $user->saveSettings();
+
+ $msg = ( new RawMessage( '{{GENDER:*Female|m|f|o}}' ) )->parse();
+ $this->assertEquals( $msg, 'f', 'Works unescaped' );
+ $escapedName = wfEscapeWikiText( '*Female' );
+ $msg2 = ( new RawMessage( '{{GENDER:' . $escapedName . '|m|f|o}}' ) )
+ ->parse();
+ $this->assertEquals( $msg, 'f', 'Works escaped' );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/parser/MagicVariableTest.php b/www/wiki/tests/phpunit/includes/parser/MagicVariableTest.php
new file mode 100644
index 00000000..86b496e2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/parser/MagicVariableTest.php
@@ -0,0 +1,232 @@
+<?php
+/**
+ * This file is intended to test magic variables in the parser
+ * It was inspired by Raymond & Matěj Grabovský commenting about r66200
+ *
+ * As of february 2011, it only tests some revisions and date related
+ * magic variables.
+ *
+ * @author Antoine Musso
+ * @copyright Copyright © 2011, Antoine Musso
+ * @file
+ */
+
+/**
+ * @group Database
+ * @covers Parser::getVariableValue
+ */
+class MagicVariableTest extends MediaWikiTestCase {
+ /**
+ * @var Parser
+ */
+ private $testParser = null;
+
+ /**
+ * An array of magicword returned as type integer by the parser
+ * They are usually returned as a string for i18n since we support
+ * persan numbers for example, but some magic explicitly return
+ * them as integer.
+ * @see MagicVariableTest::assertMagic()
+ */
+ private $expectedAsInteger = [
+ 'revisionday',
+ 'revisionmonth1',
+ ];
+
+ /** setup a basic parser object */
+ protected function setUp() {
+ parent::setUp();
+
+ $contLang = Language::factory( 'en' );
+ $this->setMwGlobals( [
+ 'wgLanguageCode' => 'en',
+ 'wgContLang' => $contLang,
+ ] );
+
+ $this->testParser = new Parser();
+ $this->testParser->Options( ParserOptions::newFromUserAndLang( new User, $contLang ) );
+
+ # initialize parser output
+ $this->testParser->clearState();
+
+ # Needs a title to do magic word stuff
+ $title = Title::newFromText( 'Tests' );
+ # Else it needs a db connection just to check if it's a redirect
+ # (when deciding the page language).
+ $title->mRedirect = false;
+
+ $this->testParser->setTitle( $title );
+ }
+
+ /**
+ * @param int $num Upper limit for numbers
+ * @return array Array of numbers from 1 up to $num
+ */
+ private static function createProviderUpTo( $num ) {
+ $ret = [];
+ for ( $i = 1; $i <= $num; $i++ ) {
+ $ret[] = [ $i ];
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @return array Array of months numbers (as an integer)
+ */
+ public static function provideMonths() {
+ return self::createProviderUpTo( 12 );
+ }
+
+ /**
+ * @return array Array of days numbers (as an integer)
+ */
+ public static function provideDays() {
+ return self::createProviderUpTo( 31 );
+ }
+
+ # ############## TESTS #############################################
+ # @todo FIXME:
+ # - those got copy pasted, we can probably make them cleaner
+ # - tests are lacking useful messages
+
+ # day
+
+ /** @dataProvider provideDays */
+ public function testCurrentdayIsUnPadded( $day ) {
+ $this->assertUnPadded( 'currentday', $day );
+ }
+
+ /** @dataProvider provideDays */
+ public function testCurrentdaytwoIsZeroPadded( $day ) {
+ $this->assertZeroPadded( 'currentday2', $day );
+ }
+
+ /** @dataProvider provideDays */
+ public function testLocaldayIsUnPadded( $day ) {
+ $this->assertUnPadded( 'localday', $day );
+ }
+
+ /** @dataProvider provideDays */
+ public function testLocaldaytwoIsZeroPadded( $day ) {
+ $this->assertZeroPadded( 'localday2', $day );
+ }
+
+ # month
+
+ /** @dataProvider provideMonths */
+ public function testCurrentmonthIsZeroPadded( $month ) {
+ $this->assertZeroPadded( 'currentmonth', $month );
+ }
+
+ /** @dataProvider provideMonths */
+ public function testCurrentmonthoneIsUnPadded( $month ) {
+ $this->assertUnPadded( 'currentmonth1', $month );
+ }
+
+ /** @dataProvider provideMonths */
+ public function testLocalmonthIsZeroPadded( $month ) {
+ $this->assertZeroPadded( 'localmonth', $month );
+ }
+
+ /** @dataProvider provideMonths */
+ public function testLocalmonthoneIsUnPadded( $month ) {
+ $this->assertUnPadded( 'localmonth1', $month );
+ }
+
+ # revision day
+
+ /** @dataProvider provideDays */
+ public function testRevisiondayIsUnPadded( $day ) {
+ $this->assertUnPadded( 'revisionday', $day );
+ }
+
+ /** @dataProvider provideDays */
+ public function testRevisiondaytwoIsZeroPadded( $day ) {
+ $this->assertZeroPadded( 'revisionday2', $day );
+ }
+
+ # revision month
+
+ /** @dataProvider provideMonths */
+ public function testRevisionmonthIsZeroPadded( $month ) {
+ $this->assertZeroPadded( 'revisionmonth', $month );
+ }
+
+ /** @dataProvider provideMonths */
+ public function testRevisionmonthoneIsUnPadded( $month ) {
+ $this->assertUnPadded( 'revisionmonth1', $month );
+ }
+
+ # ############## HELPERS ############################################
+
+ /** assertion helper expecting a magic output which is zero padded */
+ public function assertZeroPadded( $magic, $value ) {
+ $this->assertMagicPadding( $magic, $value, '%02d' );
+ }
+
+ /** assertion helper expecting a magic output which is unpadded */
+ public function assertUnPadded( $magic, $value ) {
+ $this->assertMagicPadding( $magic, $value, '%d' );
+ }
+
+ /**
+ * Main assertion helper for magic variables padding
+ * @param string $magic Magic variable name
+ * @param mixed $value Month or day
+ * @param string $format Sprintf format for $value
+ */
+ private function assertMagicPadding( $magic, $value, $format ) {
+ # Initialize parser timestamp as year 2010 at 12h34 56s.
+ # month and day are given by the caller ($value). Month < 12!
+ if ( $value > 12 ) {
+ $month = $value % 12;
+ } else {
+ $month = $value;
+ }
+
+ $this->setParserTS(
+ sprintf( '2010%02d%02d123456', $month, $value )
+ );
+
+ # please keep the following commented line of code. It helps debugging.
+ // print "\nDEBUG (value $value):" . sprintf( '2010%02d%02d123456', $value, $value ) . "\n";
+
+ # format expectation and test it
+ $expected = sprintf( $format, $value );
+ $this->assertMagic( $expected, $magic );
+ }
+
+ /**
+ * helper to set the parser timestamp and revision timestamp
+ * @param string $ts
+ */
+ private function setParserTS( $ts ) {
+ $this->testParser->Options()->setTimestamp( $ts );
+ $this->testParser->mRevisionTimestamp = $ts;
+ }
+
+ /**
+ * Assertion helper to test a magic variable output
+ * @param string|int $expected
+ * @param string $magic
+ */
+ private function assertMagic( $expected, $magic ) {
+ if ( in_array( $magic, $this->expectedAsInteger ) ) {
+ $expected = (int)$expected;
+ }
+
+ # Generate a message for the assertion
+ $msg = sprintf( "Magic %s should be <%s:%s>",
+ $magic,
+ $expected,
+ gettype( $expected )
+ );
+
+ $this->assertSame(
+ $expected,
+ $this->testParser->getVariableValue( $magic ),
+ $msg
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/parser/ParserIntegrationTest.php b/www/wiki/tests/phpunit/includes/parser/ParserIntegrationTest.php
new file mode 100644
index 00000000..91653b5d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/parser/ParserIntegrationTest.php
@@ -0,0 +1,65 @@
+<?php
+use Wikimedia\ScopedCallback;
+
+/**
+ * This is the TestCase subclass for running a single parser test via the
+ * ParserTestRunner integration test system.
+ *
+ * Note: the following groups are not used by PHPUnit.
+ * The list in ParserTestFileSuite::__construct() is used instead.
+ *
+ * @group large
+ * @group Database
+ * @group Parser
+ * @group ParserTests
+ *
+ * @covers Parser
+ * @covers BlockLevelPass
+ * @covers CoreParserFunctions
+ * @covers CoreTagHooks
+ * @covers Sanitizer
+ * @covers Preprocessor
+ * @covers Preprocessor_DOM
+ * @covers Preprocessor_Hash
+ * @covers DateFormatter
+ * @covers LinkHolderArray
+ * @covers StripState
+ * @covers ParserOptions
+ * @covers ParserOutput
+ */
+class ParserIntegrationTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /** @var array */
+ private $ptTest;
+
+ /** @var ParserTestRunner */
+ private $ptRunner;
+
+ /** @var ScopedCallback */
+ private $ptTeardownScope;
+
+ public function __construct( $runner, $fileName, $test ) {
+ parent::__construct( 'testParse', [ '[details omitted]' ],
+ basename( $fileName ) . ': ' . $test['desc'] );
+ $this->ptTest = $test;
+ $this->ptRunner = $runner;
+ }
+
+ public function testParse() {
+ $this->ptRunner->getRecorder()->setTestCase( $this );
+ $result = $this->ptRunner->runTest( $this->ptTest );
+ $this->assertEquals( $result->expected, $result->actual );
+ }
+
+ public function setUp() {
+ $this->ptTeardownScope = $this->ptRunner->staticSetup();
+ }
+
+ public function tearDown() {
+ if ( $this->ptTeardownScope ) {
+ ScopedCallback::consume( $this->ptTeardownScope );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/parser/ParserMethodsTest.php b/www/wiki/tests/phpunit/includes/parser/ParserMethodsTest.php
new file mode 100644
index 00000000..d2ed4415
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/parser/ParserMethodsTest.php
@@ -0,0 +1,185 @@
+<?php
+
+/**
+ * @group Database
+ * @covers Parser
+ * @covers BlockLevelPass
+ */
+class ParserMethodsTest extends MediaWikiLangTestCase {
+
+ public static function providePreSaveTransform() {
+ return [
+ [ 'hello this is ~~~',
+ "hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]",
+ ],
+ [ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
+ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePreSaveTransform
+ */
+ public function testPreSaveTransform( $text, $expected ) {
+ global $wgParser;
+
+ $title = Title::newFromText( str_replace( '::', '__', __METHOD__ ) );
+ $user = new User();
+ $user->setName( "127.0.0.1" );
+ $popts = ParserOptions::newFromUser( $user );
+ $text = $wgParser->preSaveTransform( $text, $title, $user, $popts );
+
+ $this->assertEquals( $expected, $text );
+ }
+
+ public static function provideStripOuterParagraph() {
+ // This mimics the most common use case (stripping paragraphs generated by the parser).
+ $message = new RawMessage( "Message text." );
+
+ return [
+ [
+ "<p>Text.</p>",
+ "Text.",
+ ],
+ [
+ "<p class='foo'>Text.</p>",
+ "<p class='foo'>Text.</p>",
+ ],
+ [
+ "<p>Text.\n</p>\n",
+ "Text.",
+ ],
+ [
+ "<p>Text.</p><p>More text.</p>",
+ "<p>Text.</p><p>More text.</p>",
+ ],
+ [
+ $message->parse(),
+ "Message text.",
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideStripOuterParagraph
+ */
+ public function testStripOuterParagraph( $text, $expected ) {
+ $this->assertEquals( $expected, Parser::stripOuterParagraph( $text ) );
+ }
+
+ /**
+ * @expectedException MWException
+ * @expectedExceptionMessage Parser state cleared while parsing.
+ * Did you call Parser::parse recursively?
+ */
+ public function testRecursiveParse() {
+ global $wgParser;
+ $title = Title::newFromText( 'foo' );
+ $po = new ParserOptions;
+ $wgParser->setHook( 'recursivecallparser', [ $this, 'helperParserFunc' ] );
+ $wgParser->parse( '<recursivecallparser>baz</recursivecallparser>', $title, $po );
+ }
+
+ public function helperParserFunc( $input, $args, $parser ) {
+ $title = Title::newFromText( 'foo' );
+ $po = new ParserOptions;
+ $parser->parse( $input, $title, $po );
+ return 'bar';
+ }
+
+ public function testCallParserFunction() {
+ global $wgParser;
+
+ // Normal parses test passing PPNodes. Test passing an array.
+ $title = Title::newFromText( str_replace( '::', '__', __METHOD__ ) );
+ $wgParser->startExternalParse( $title, new ParserOptions(), Parser::OT_HTML );
+ $frame = $wgParser->getPreprocessor()->newFrame();
+ $ret = $wgParser->callParserFunction( $frame, '#tag',
+ [ 'pre', 'foo', 'style' => 'margin-left: 1.6em' ]
+ );
+ $ret['text'] = $wgParser->mStripState->unstripBoth( $ret['text'] );
+ $this->assertSame( [
+ 'found' => true,
+ 'text' => '<pre style="margin-left: 1.6em">foo</pre>',
+ ], $ret, 'callParserFunction works for {{#tag:pre|foo|style=margin-left: 1.6em}}' );
+ }
+
+ /**
+ * @covers Parser
+ * @covers ParserOutput::getSections
+ */
+ public function testGetSections() {
+ global $wgParser;
+
+ $title = Title::newFromText( str_replace( '::', '__', __METHOD__ ) );
+ $out = $wgParser->parse( "==foo==\n<h2>bar</h2>\n==baz==\n", $title, new ParserOptions() );
+ $this->assertSame( [
+ [
+ 'toclevel' => 1,
+ 'level' => '2',
+ 'line' => 'foo',
+ 'number' => '1',
+ 'index' => '1',
+ 'fromtitle' => $title->getPrefixedDBkey(),
+ 'byteoffset' => 0,
+ 'anchor' => 'foo',
+ ],
+ [
+ 'toclevel' => 1,
+ 'level' => '2',
+ 'line' => 'bar',
+ 'number' => '2',
+ 'index' => '',
+ 'fromtitle' => false,
+ 'byteoffset' => null,
+ 'anchor' => 'bar',
+ ],
+ [
+ 'toclevel' => 1,
+ 'level' => '2',
+ 'line' => 'baz',
+ 'number' => '3',
+ 'index' => '2',
+ 'fromtitle' => $title->getPrefixedDBkey(),
+ 'byteoffset' => 21,
+ 'anchor' => 'baz',
+ ],
+ ], $out->getSections(), 'getSections() with proper value when <h2> is used' );
+ }
+
+ /**
+ * @dataProvider provideNormalizeLinkUrl
+ */
+ public function testNormalizeLinkUrl( $explanation, $url, $expected ) {
+ $this->assertEquals( $expected, Parser::normalizeLinkUrl( $url ), $explanation );
+ }
+
+ public static function provideNormalizeLinkUrl() {
+ return [
+ [
+ 'Escaping of unsafe characters',
+ 'http://example.org/foo bar?param[]="value"&param[]=valüe',
+ 'http://example.org/foo%20bar?param%5B%5D=%22value%22&param%5B%5D=val%C3%BCe',
+ ],
+ [
+ 'Case normalization of percent-encoded characters',
+ 'http://example.org/%ab%cD%Ef%FF',
+ 'http://example.org/%AB%CD%EF%FF',
+ ],
+ [
+ 'Unescaping of safe characters',
+ 'http://example.org/%3C%66%6f%6F%3E?%3C%66%6f%6F%3E#%3C%66%6f%6F%3E',
+ 'http://example.org/%3Cfoo%3E?%3Cfoo%3E#%3Cfoo%3E',
+ ],
+ [
+ 'Context-sensitive replacement of sometimes-safe characters',
+ 'http://example.org/%23%2F%3F%26%3D%2B%3B?%23%2F%3F%26%3D%2B%3B#%23%2F%3F%26%3D%2B%3B',
+ 'http://example.org/%23%2F%3F&=+;?%23/?%26%3D%2B%3B#%23/?&=+;',
+ ],
+ ];
+ }
+
+ // @todo Add tests for cleanSig() / cleanSigInSig(), getSection(),
+ // replaceSection(), getPreloadText()
+}
diff --git a/www/wiki/tests/phpunit/includes/parser/ParserOptionsTest.php b/www/wiki/tests/phpunit/includes/parser/ParserOptionsTest.php
new file mode 100644
index 00000000..e2ed1d57
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/parser/ParserOptionsTest.php
@@ -0,0 +1,223 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+use Wikimedia\ScopedCallback;
+
+/**
+ * @covers ParserOptions
+ */
+class ParserOptionsTest extends MediaWikiTestCase {
+
+ private static function clearCache() {
+ $wrap = TestingAccessWrapper::newFromClass( ParserOptions::class );
+ $wrap->defaults = null;
+ $wrap->lazyOptions = [
+ 'dateformat' => [ ParserOptions::class, 'initDateFormat' ],
+ ];
+ $wrap->inCacheKey = [
+ 'dateformat' => true,
+ 'numberheadings' => true,
+ 'thumbsize' => true,
+ 'stubthreshold' => true,
+ 'printable' => true,
+ 'userlang' => true,
+ ];
+ }
+
+ protected function setUp() {
+ global $wgHooks;
+
+ parent::setUp();
+ self::clearCache();
+
+ $this->setMwGlobals( [
+ 'wgRenderHashAppend' => '',
+ 'wgHooks' => [
+ 'PageRenderingHash' => [],
+ ] + $wgHooks,
+ ] );
+ }
+
+ protected function tearDown() {
+ self::clearCache();
+ parent::tearDown();
+ }
+
+ /**
+ * @dataProvider provideIsSafeToCache
+ * @param bool $expect Expected value
+ * @param array $options Options to set
+ */
+ public function testIsSafeToCache( $expect, $options ) {
+ $popt = ParserOptions::newCanonical();
+ foreach ( $options as $name => $value ) {
+ $popt->setOption( $name, $value );
+ }
+ $this->assertSame( $expect, $popt->isSafeToCache() );
+ }
+
+ public static function provideIsSafeToCache() {
+ return [
+ 'No overrides' => [ true, [] ],
+ 'In-key options are ok' => [ true, [
+ 'thumbsize' => 1e100,
+ 'printable' => false,
+ ] ],
+ 'Non-in-key options are not ok' => [ false, [
+ 'removeComments' => false,
+ ] ],
+ 'Non-in-key options are not ok (2)' => [ false, [
+ 'wrapclass' => 'foobar',
+ ] ],
+ 'Canonical override, not default (1)' => [ true, [
+ 'tidy' => true,
+ ] ],
+ 'Canonical override, not default (2)' => [ false, [
+ 'tidy' => false,
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideOptionsHash
+ * @param array $usedOptions Used options
+ * @param string $expect Expected value
+ * @param array $options Options to set
+ * @param array $globals Globals to set
+ */
+ public function testOptionsHash( $usedOptions, $expect, $options, $globals = [] ) {
+ global $wgHooks;
+
+ $globals += [
+ 'wgHooks' => [],
+ ];
+ $globals['wgHooks'] += [
+ 'PageRenderingHash' => [],
+ ] + $wgHooks;
+ $this->setMwGlobals( $globals );
+
+ $popt = ParserOptions::newCanonical();
+ foreach ( $options as $name => $value ) {
+ $popt->setOption( $name, $value );
+ }
+ $this->assertSame( $expect, $popt->optionsHash( $usedOptions ) );
+ }
+
+ public static function provideOptionsHash() {
+ $used = [ 'thumbsize', 'printable' ];
+
+ $classWrapper = TestingAccessWrapper::newFromClass( ParserOptions::class );
+ $classWrapper->getDefaults();
+ $allUsableOptions = array_diff(
+ array_keys( $classWrapper->inCacheKey ),
+ array_keys( $classWrapper->lazyOptions )
+ );
+
+ return [
+ 'Canonical options, nothing used' => [ [], 'canonical', [] ],
+ 'Canonical options, used some options' => [ $used, 'canonical', [] ],
+ 'Used some options, non-default values' => [
+ $used,
+ 'printable=1!thumbsize=200',
+ [
+ 'thumbsize' => 200,
+ 'printable' => true,
+ ]
+ ],
+ 'Canonical options, used all non-lazy options' => [ $allUsableOptions, 'canonical', [] ],
+ 'Canonical options, nothing used, but with hooks and $wgRenderHashAppend' => [
+ [],
+ 'canonical!wgRenderHashAppend!onPageRenderingHash',
+ [],
+ [
+ 'wgRenderHashAppend' => '!wgRenderHashAppend',
+ 'wgHooks' => [ 'PageRenderingHash' => [ [ __CLASS__ . '::onPageRenderingHash' ] ] ],
+ ]
+ ],
+ ];
+ }
+
+ public static function onPageRenderingHash( &$confstr ) {
+ $confstr .= '!onPageRenderingHash';
+ }
+
+ /**
+ * @expectedException InvalidArgumentException
+ * @expectedExceptionMessage Unknown parser option bogus
+ */
+ public function testGetInvalidOption() {
+ $popt = ParserOptions::newCanonical();
+ $popt->getOption( 'bogus' );
+ }
+
+ /**
+ * @expectedException InvalidArgumentException
+ * @expectedExceptionMessage Unknown parser option bogus
+ */
+ public function testSetInvalidOption() {
+ $popt = ParserOptions::newCanonical();
+ $popt->setOption( 'bogus', true );
+ }
+
+ public function testMatches() {
+ $classWrapper = TestingAccessWrapper::newFromClass( ParserOptions::class );
+ $oldDefaults = $classWrapper->defaults;
+ $oldLazy = $classWrapper->lazyOptions;
+ $reset = new ScopedCallback( function () use ( $classWrapper, $oldDefaults, $oldLazy ) {
+ $classWrapper->defaults = $oldDefaults;
+ $classWrapper->lazyOptions = $oldLazy;
+ } );
+
+ $popt1 = ParserOptions::newCanonical();
+ $popt2 = ParserOptions::newCanonical();
+ $this->assertTrue( $popt1->matches( $popt2 ) );
+
+ $popt1->enableLimitReport( true );
+ $popt2->enableLimitReport( false );
+ $this->assertTrue( $popt1->matches( $popt2 ) );
+
+ $popt2->setTidy( !$popt2->getTidy() );
+ $this->assertFalse( $popt1->matches( $popt2 ) );
+
+ $ctr = 0;
+ $classWrapper->defaults += [ __METHOD__ => null ];
+ $classWrapper->lazyOptions += [ __METHOD__ => function () use ( &$ctr ) {
+ return ++$ctr;
+ } ];
+ $popt1 = ParserOptions::newCanonical();
+ $popt2 = ParserOptions::newCanonical();
+ $this->assertFalse( $popt1->matches( $popt2 ) );
+
+ ScopedCallback::consume( $reset );
+ }
+
+ public function testAllCacheVaryingOptions() {
+ global $wgHooks;
+
+ // $wgHooks is already saved in self::setUp(), so we can modify it freely here
+ $wgHooks['ParserOptionsRegister'] = [];
+ $this->assertSame( [
+ 'dateformat', 'numberheadings', 'printable', 'stubthreshold',
+ 'thumbsize', 'userlang'
+ ], ParserOptions::allCacheVaryingOptions() );
+
+ self::clearCache();
+
+ $wgHooks['ParserOptionsRegister'][] = function ( &$defaults, &$inCacheKey ) {
+ $defaults += [
+ 'foo' => 'foo',
+ 'bar' => 'bar',
+ 'baz' => 'baz',
+ ];
+ $inCacheKey += [
+ 'foo' => true,
+ 'bar' => false,
+ ];
+ };
+ $this->assertSame( [
+ 'dateformat', 'foo', 'numberheadings', 'printable', 'stubthreshold',
+ 'thumbsize', 'userlang'
+ ], ParserOptions::allCacheVaryingOptions() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/parser/ParserOutputTest.php b/www/wiki/tests/phpunit/includes/parser/ParserOutputTest.php
new file mode 100644
index 00000000..b08ba6c4
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/parser/ParserOutputTest.php
@@ -0,0 +1,294 @@
+<?php
+
+/**
+ * @group Database
+ * ^--- trigger DB shadowing because we are using Title magic
+ */
+class ParserOutputTest extends MediaWikiTestCase {
+
+ public static function provideIsLinkInternal() {
+ return [
+ // Different domains
+ [ false, 'http://example.org', 'http://mediawiki.org' ],
+ // Same domains
+ [ true, 'http://example.org', 'http://example.org' ],
+ [ true, 'https://example.org', 'https://example.org' ],
+ [ true, '//example.org', '//example.org' ],
+ // Same domain different cases
+ [ true, 'http://example.org', 'http://EXAMPLE.ORG' ],
+ // Paths, queries, and fragments are not relevant
+ [ true, 'http://example.org', 'http://example.org/wiki/Main_Page' ],
+ [ true, 'http://example.org', 'http://example.org?my=query' ],
+ [ true, 'http://example.org', 'http://example.org#its-a-fragment' ],
+ // Different protocols
+ [ false, 'http://example.org', 'https://example.org' ],
+ [ false, 'https://example.org', 'http://example.org' ],
+ // Protocol relative servers always match http and https links
+ [ true, '//example.org', 'http://example.org' ],
+ [ true, '//example.org', 'https://example.org' ],
+ // But they don't match strange things like this
+ [ false, '//example.org', 'irc://example.org' ],
+ ];
+ }
+
+ /**
+ * Test to make sure ParserOutput::isLinkInternal behaves properly
+ * @dataProvider provideIsLinkInternal
+ * @covers ParserOutput::isLinkInternal
+ */
+ public function testIsLinkInternal( $shouldMatch, $server, $url ) {
+ $this->assertEquals( $shouldMatch, ParserOutput::isLinkInternal( $server, $url ) );
+ }
+
+ /**
+ * @covers ParserOutput::setExtensionData
+ * @covers ParserOutput::getExtensionData
+ */
+ public function testExtensionData() {
+ $po = new ParserOutput();
+
+ $po->setExtensionData( "one", "Foo" );
+
+ $this->assertEquals( "Foo", $po->getExtensionData( "one" ) );
+ $this->assertNull( $po->getExtensionData( "spam" ) );
+
+ $po->setExtensionData( "two", "Bar" );
+ $this->assertEquals( "Foo", $po->getExtensionData( "one" ) );
+ $this->assertEquals( "Bar", $po->getExtensionData( "two" ) );
+
+ $po->setExtensionData( "one", null );
+ $this->assertNull( $po->getExtensionData( "one" ) );
+ $this->assertEquals( "Bar", $po->getExtensionData( "two" ) );
+ }
+
+ /**
+ * @covers ParserOutput::setProperty
+ * @covers ParserOutput::getProperty
+ * @covers ParserOutput::unsetProperty
+ * @covers ParserOutput::getProperties
+ */
+ public function testProperties() {
+ $po = new ParserOutput();
+
+ $po->setProperty( 'foo', 'val' );
+
+ $properties = $po->getProperties();
+ $this->assertEquals( $po->getProperty( 'foo' ), 'val' );
+ $this->assertEquals( $properties['foo'], 'val' );
+
+ $po->setProperty( 'foo', 'second val' );
+
+ $properties = $po->getProperties();
+ $this->assertEquals( $po->getProperty( 'foo' ), 'second val' );
+ $this->assertEquals( $properties['foo'], 'second val' );
+
+ $po->unsetProperty( 'foo' );
+
+ $properties = $po->getProperties();
+ $this->assertEquals( $po->getProperty( 'foo' ), false );
+ $this->assertArrayNotHasKey( 'foo', $properties );
+ }
+
+ /**
+ * @covers ParserOutput::getText
+ * @dataProvider provideGetText
+ * @param array $options Options to getText()
+ * @param string $text Parser text
+ * @param string $expect Expected output
+ */
+ public function testGetText( $options, $text, $expect ) {
+ $this->setMwGlobals( [
+ 'wgArticlePath' => '/wiki/$1',
+ 'wgScriptPath' => '/w',
+ 'wgScript' => '/w/index.php',
+ ] );
+
+ $po = new ParserOutput( $text );
+ $actual = $po->getText( $options );
+ $this->assertSame( $expect, $actual );
+ }
+
+ public static function provideGetText() {
+ // phpcs:disable Generic.Files.LineLength
+ $text = <<<EOF
+<div class="mw-parser-output"><p>Test document.
+</p>
+<mw:toc><div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Section_1"><span class="tocnumber">1</span> <span class="toctext">Section 1</span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#Section_2"><span class="tocnumber">2</span> <span class="toctext">Section 2</span></a>
+<ul>
+<li class="toclevel-2 tocsection-3"><a href="#Section_2.1"><span class="tocnumber">2.1</span> <span class="toctext">Section 2.1</span></a></li>
+</ul>
+</li>
+<li class="toclevel-1 tocsection-4"><a href="#Section_3"><span class="tocnumber">3</span> <span class="toctext">Section 3</span></a></li>
+</ul>
+</div>
+</mw:toc>
+<h2><span class="mw-headline" id="Section_1">Section 1</span><mw:editsection page="Test Page" section="1">Section 1</mw:editsection></h2>
+<p>One
+</p>
+<h2><span class="mw-headline" id="Section_2">Section 2</span><mw:editsection page="Test Page" section="2">Section 2</mw:editsection></h2>
+<p>Two
+</p>
+<h3><span class="mw-headline" id="Section_2.1">Section 2.1</span><mw:editsection page="Test Page" section="3">Section 2.1</mw:editsection></h3>
+<p>Two point one
+</p>
+<h2><span class="mw-headline" id="Section_3">Section 3</span><mw:editsection page="Test Page" section="4">Section 3</mw:editsection></h2>
+<p>Three
+</p></div>
+EOF;
+
+ $dedupText = <<<EOF
+<p>This is a test document.</p>
+<style data-mw-deduplicate="duplicate1">.Duplicate1 {}</style>
+<style data-mw-deduplicate="duplicate1">.Duplicate1 {}</style>
+<style data-mw-deduplicate="duplicate2">.Duplicate2 {}</style>
+<style data-mw-deduplicate="duplicate1">.Duplicate1 {}</style>
+<style data-mw-deduplicate="duplicate2">.Duplicate2 {}</style>
+<style data-mw-not-deduplicate="duplicate1">.Duplicate1 {}</style>
+<style data-mw-deduplicate="duplicate1">.Same-attribute-different-content {}</style>
+<style data-mw-deduplicate="duplicate3">.Duplicate1 {}</style>
+<style>.Duplicate1 {}</style>
+EOF;
+
+ return [
+ 'No options' => [
+ [], $text, <<<EOF
+<div class="mw-parser-output"><p>Test document.
+</p>
+<div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Section_1"><span class="tocnumber">1</span> <span class="toctext">Section 1</span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#Section_2"><span class="tocnumber">2</span> <span class="toctext">Section 2</span></a>
+<ul>
+<li class="toclevel-2 tocsection-3"><a href="#Section_2.1"><span class="tocnumber">2.1</span> <span class="toctext">Section 2.1</span></a></li>
+</ul>
+</li>
+<li class="toclevel-1 tocsection-4"><a href="#Section_3"><span class="tocnumber">3</span> <span class="toctext">Section 3</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Section_1">Section 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>One
+</p>
+<h2><span class="mw-headline" id="Section_2">Section 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=2" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>Two
+</p>
+<h3><span class="mw-headline" id="Section_2.1">Section 2.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=3" title="Edit section: Section 2.1">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<p>Two point one
+</p>
+<h2><span class="mw-headline" id="Section_3">Section 3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>Three
+</p></div>
+EOF
+ ],
+ 'Disable section edit links' => [
+ [ 'enableSectionEditLinks' => false ], $text, <<<EOF
+<div class="mw-parser-output"><p>Test document.
+</p>
+<div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Section_1"><span class="tocnumber">1</span> <span class="toctext">Section 1</span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#Section_2"><span class="tocnumber">2</span> <span class="toctext">Section 2</span></a>
+<ul>
+<li class="toclevel-2 tocsection-3"><a href="#Section_2.1"><span class="tocnumber">2.1</span> <span class="toctext">Section 2.1</span></a></li>
+</ul>
+</li>
+<li class="toclevel-1 tocsection-4"><a href="#Section_3"><span class="tocnumber">3</span> <span class="toctext">Section 3</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Section_1">Section 1</span></h2>
+<p>One
+</p>
+<h2><span class="mw-headline" id="Section_2">Section 2</span></h2>
+<p>Two
+</p>
+<h3><span class="mw-headline" id="Section_2.1">Section 2.1</span></h3>
+<p>Two point one
+</p>
+<h2><span class="mw-headline" id="Section_3">Section 3</span></h2>
+<p>Three
+</p></div>
+EOF
+ ],
+ 'Disable TOC' => [
+ [ 'allowTOC' => false ], $text, <<<EOF
+<div class="mw-parser-output"><p>Test document.
+</p>
+
+<h2><span class="mw-headline" id="Section_1">Section 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>One
+</p>
+<h2><span class="mw-headline" id="Section_2">Section 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=2" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>Two
+</p>
+<h3><span class="mw-headline" id="Section_2.1">Section 2.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=3" title="Edit section: Section 2.1">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<p>Two point one
+</p>
+<h2><span class="mw-headline" id="Section_3">Section 3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>Three
+</p></div>
+EOF
+ ],
+ 'Unwrap text' => [
+ [ 'unwrap' => true ], $text, <<<EOF
+<p>Test document.
+</p>
+<div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
+<ul>
+<li class="toclevel-1 tocsection-1"><a href="#Section_1"><span class="tocnumber">1</span> <span class="toctext">Section 1</span></a></li>
+<li class="toclevel-1 tocsection-2"><a href="#Section_2"><span class="tocnumber">2</span> <span class="toctext">Section 2</span></a>
+<ul>
+<li class="toclevel-2 tocsection-3"><a href="#Section_2.1"><span class="tocnumber">2.1</span> <span class="toctext">Section 2.1</span></a></li>
+</ul>
+</li>
+<li class="toclevel-1 tocsection-4"><a href="#Section_3"><span class="tocnumber">3</span> <span class="toctext">Section 3</span></a></li>
+</ul>
+</div>
+
+<h2><span class="mw-headline" id="Section_1">Section 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>One
+</p>
+<h2><span class="mw-headline" id="Section_2">Section 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=2" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>Two
+</p>
+<h3><span class="mw-headline" id="Section_2.1">Section 2.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=3" title="Edit section: Section 2.1">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
+<p>Two point one
+</p>
+<h2><span class="mw-headline" id="Section_3">Section 3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p>Three
+</p>
+EOF
+ ],
+ 'Unwrap without a mw-parser-output wrapper' => [
+ [ 'unwrap' => true ], '<div class="foobar">Content</div>', '<div class="foobar">Content</div>'
+ ],
+ 'Unwrap with extra comment at end' => [
+ [ 'unwrap' => true ], '<div class="mw-parser-output"><p>Test document.</p></div>
+<!-- Saved in parser cache... -->', '<p>Test document.</p>
+<!-- Saved in parser cache... -->'
+ ],
+ 'Style deduplication' => [
+ [], $dedupText, <<<EOF
+<p>This is a test document.</p>
+<style data-mw-deduplicate="duplicate1">.Duplicate1 {}</style>
+<link rel="mw-deduplicated-inline-style" href="mw-data:duplicate1"/>
+<style data-mw-deduplicate="duplicate2">.Duplicate2 {}</style>
+<link rel="mw-deduplicated-inline-style" href="mw-data:duplicate1"/>
+<link rel="mw-deduplicated-inline-style" href="mw-data:duplicate2"/>
+<style data-mw-not-deduplicate="duplicate1">.Duplicate1 {}</style>
+<link rel="mw-deduplicated-inline-style" href="mw-data:duplicate1"/>
+<style data-mw-deduplicate="duplicate3">.Duplicate1 {}</style>
+<style>.Duplicate1 {}</style>
+EOF
+ ],
+ 'Style deduplication disabled' => [
+ [ 'deduplicateStyles' => false ], $dedupText, $dedupText
+ ],
+ ];
+ // phpcs:enable
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/parser/ParserPreloadTest.php b/www/wiki/tests/phpunit/includes/parser/ParserPreloadTest.php
new file mode 100644
index 00000000..77073955
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/parser/ParserPreloadTest.php
@@ -0,0 +1,95 @@
+<?php
+/**
+ * Basic tests for Parser::getPreloadText
+ * @author Antoine Musso
+ *
+ * @covers Parser
+ * @covers StripState
+ *
+ * @covers Preprocessor_DOM
+ * @covers PPDStack
+ * @covers PPDStackElement
+ * @covers PPDPart
+ * @covers PPFrame_DOM
+ * @covers PPTemplateFrame_DOM
+ * @covers PPCustomFrame_DOM
+ * @covers PPNode_DOM
+ *
+ * @covers Preprocessor_Hash
+ * @covers PPDStack_Hash
+ * @covers PPDStackElement_Hash
+ * @covers PPDPart_Hash
+ * @covers PPFrame_Hash
+ * @covers PPTemplateFrame_Hash
+ * @covers PPCustomFrame_Hash
+ * @covers PPNode_Hash_Tree
+ * @covers PPNode_Hash_Text
+ * @covers PPNode_Hash_Array
+ * @covers PPNode_Hash_Attr
+ */
+class ParserPreloadTest extends MediaWikiTestCase {
+ /**
+ * @var Parser
+ */
+ private $testParser;
+ /**
+ * @var ParserOptions
+ */
+ private $testParserOptions;
+ /**
+ * @var Title
+ */
+ private $title;
+
+ protected function setUp() {
+ global $wgContLang;
+
+ parent::setUp();
+ $this->testParserOptions = ParserOptions::newFromUserAndLang( new User, $wgContLang );
+
+ $this->testParser = new Parser();
+ $this->testParser->Options( $this->testParserOptions );
+ $this->testParser->clearState();
+
+ $this->title = Title::newFromText( 'Preload Test' );
+ }
+
+ protected function tearDown() {
+ parent::tearDown();
+
+ unset( $this->testParser );
+ unset( $this->title );
+ }
+
+ public function testPreloadSimpleText() {
+ $this->assertPreloaded( 'simple', 'simple' );
+ }
+
+ public function testPreloadedPreIsUnstripped() {
+ $this->assertPreloaded(
+ '<pre>monospaced</pre>',
+ '<pre>monospaced</pre>',
+ '<pre> in preloaded text must be unstripped (T29467)'
+ );
+ }
+
+ public function testPreloadedNowikiIsUnstripped() {
+ $this->assertPreloaded(
+ '<nowiki>[[Dummy title]]</nowiki>',
+ '<nowiki>[[Dummy title]]</nowiki>',
+ '<nowiki> in preloaded text must be unstripped (T29467)'
+ );
+ }
+
+ protected function assertPreloaded( $expected, $text, $msg = '' ) {
+ $this->assertEquals(
+ $expected,
+ $this->testParser->getPreloadText(
+ $text,
+ $this->title,
+ $this->testParserOptions
+ ),
+ $msg
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/parser/PreprocessorTest.php b/www/wiki/tests/phpunit/includes/parser/PreprocessorTest.php
new file mode 100644
index 00000000..c415b586
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/parser/PreprocessorTest.php
@@ -0,0 +1,294 @@
+<?php
+
+/**
+ * @covers Preprocessor
+ *
+ * @covers Preprocessor_DOM
+ * @covers PPDStack
+ * @covers PPDStackElement
+ * @covers PPDPart
+ * @covers PPFrame_DOM
+ * @covers PPTemplateFrame_DOM
+ * @covers PPCustomFrame_DOM
+ * @covers PPNode_DOM
+ *
+ * @covers Preprocessor_Hash
+ * @covers PPDStack_Hash
+ * @covers PPDStackElement_Hash
+ * @covers PPDPart_Hash
+ * @covers PPFrame_Hash
+ * @covers PPTemplateFrame_Hash
+ * @covers PPCustomFrame_Hash
+ * @covers PPNode_Hash_Tree
+ * @covers PPNode_Hash_Text
+ * @covers PPNode_Hash_Array
+ * @covers PPNode_Hash_Attr
+ */
+class PreprocessorTest extends MediaWikiTestCase {
+ protected $mTitle = 'Page title';
+ protected $mPPNodeCount = 0;
+ /**
+ * @var ParserOptions
+ */
+ protected $mOptions;
+ /**
+ * @var array
+ */
+ protected $mPreprocessors;
+
+ protected static $classNames = [
+ Preprocessor_DOM::class,
+ Preprocessor_Hash::class
+ ];
+
+ protected function setUp() {
+ global $wgContLang;
+ parent::setUp();
+ $this->mOptions = ParserOptions::newFromUserAndLang( new User, $wgContLang );
+
+ $this->mPreprocessors = [];
+ foreach ( self::$classNames as $className ) {
+ $this->mPreprocessors[$className] = new $className( $this );
+ }
+ }
+
+ function getStripList() {
+ return [ 'gallery', 'display map' /* Used by Maps, see r80025 CR */, '/foo' ];
+ }
+
+ protected static function addClassArg( $testCases ) {
+ $newTestCases = [];
+ foreach ( self::$classNames as $className ) {
+ foreach ( $testCases as $testCase ) {
+ array_unshift( $testCase, $className );
+ $newTestCases[] = $testCase;
+ }
+ }
+ return $newTestCases;
+ }
+
+ public static function provideCases() {
+ // phpcs:disable Generic.Files.LineLength
+ return self::addClassArg( [
+ [ "Foo", "<root>Foo</root>" ],
+ [ "<!-- Foo -->", "<root><comment>&lt;!-- Foo --&gt;</comment></root>" ],
+ [ "<!-- Foo --><!-- Bar -->", "<root><comment>&lt;!-- Foo --&gt;</comment><comment>&lt;!-- Bar --&gt;</comment></root>" ],
+ [ "<!-- Foo --> <!-- Bar -->", "<root><comment>&lt;!-- Foo --&gt;</comment> <comment>&lt;!-- Bar --&gt;</comment></root>" ],
+ [ "<!-- Foo --> \n <!-- Bar -->", "<root><comment>&lt;!-- Foo --&gt;</comment> \n <comment>&lt;!-- Bar --&gt;</comment></root>" ],
+ [ "<!-- Foo --> \n <!-- Bar -->\n", "<root><comment>&lt;!-- Foo --&gt;</comment> \n<comment> &lt;!-- Bar --&gt;\n</comment></root>" ],
+ [ "<!-- Foo --> <!-- Bar -->\n", "<root><comment>&lt;!-- Foo --&gt;</comment> <comment>&lt;!-- Bar --&gt;</comment>\n</root>" ],
+ [ "<!-->Bar", "<root><comment>&lt;!--&gt;Bar</comment></root>" ],
+ [ "<!-- Comment -- comment", "<root><comment>&lt;!-- Comment -- comment</comment></root>" ],
+ [ "== Foo ==\n <!-- Bar -->\n== Baz ==\n", "<root><h level=\"2\" i=\"1\">== Foo ==</h>\n<comment> &lt;!-- Bar --&gt;\n</comment><h level=\"2\" i=\"2\">== Baz ==</h>\n</root>" ],
+ [ "<gallery/>", "<root><ext><name>gallery</name><attr></attr></ext></root>" ],
+ [ "Foo <gallery/> Bar", "<root>Foo <ext><name>gallery</name><attr></attr></ext> Bar</root>" ],
+ [ "<gallery></gallery>", "<root><ext><name>gallery</name><attr></attr><inner></inner><close>&lt;/gallery&gt;</close></ext></root>" ],
+ [ "<foo> <gallery></gallery>", "<root>&lt;foo&gt; <ext><name>gallery</name><attr></attr><inner></inner><close>&lt;/gallery&gt;</close></ext></root>" ],
+ [ "<foo> <gallery><gallery></gallery>", "<root>&lt;foo&gt; <ext><name>gallery</name><attr></attr><inner>&lt;gallery&gt;</inner><close>&lt;/gallery&gt;</close></ext></root>" ],
+ [ "<noinclude> Foo bar </noinclude>", "<root><ignore>&lt;noinclude&gt;</ignore> Foo bar <ignore>&lt;/noinclude&gt;</ignore></root>" ],
+ [ "<noinclude>\n{{Foo}}\n</noinclude>", "<root><ignore>&lt;noinclude&gt;</ignore>\n<template lineStart=\"1\"><title>Foo</title></template>\n<ignore>&lt;/noinclude&gt;</ignore></root>" ],
+ [ "<noinclude>\n{{Foo}}\n</noinclude>\n", "<root><ignore>&lt;noinclude&gt;</ignore>\n<template lineStart=\"1\"><title>Foo</title></template>\n<ignore>&lt;/noinclude&gt;</ignore>\n</root>" ],
+ [ "<gallery>foo bar", "<root>&lt;gallery&gt;foo bar</root>" ],
+ [ "<{{foo}}>", "<root>&lt;<template><title>foo</title></template>&gt;</root>" ],
+ [ "<{{{foo}}}>", "<root>&lt;<tplarg><title>foo</title></tplarg>&gt;</root>" ],
+ [ "<gallery></gallery</gallery>", "<root><ext><name>gallery</name><attr></attr><inner>&lt;/gallery</inner><close>&lt;/gallery&gt;</close></ext></root>" ],
+ [ "=== Foo === ", "<root><h level=\"3\" i=\"1\">=== Foo === </h></root>" ],
+ [ "==<!-- -->= Foo === ", "<root><h level=\"2\" i=\"1\">==<comment>&lt;!-- --&gt;</comment>= Foo === </h></root>" ],
+ [ "=== Foo ==<!-- -->= ", "<root><h level=\"1\" i=\"1\">=== Foo ==<comment>&lt;!-- --&gt;</comment>= </h></root>" ],
+ [ "=== Foo ===<!-- -->\n", "<root><h level=\"3\" i=\"1\">=== Foo ===<comment>&lt;!-- --&gt;</comment></h>\n</root>" ],
+ [ "=== Foo ===<!-- --> <!-- -->\n", "<root><h level=\"3\" i=\"1\">=== Foo ===<comment>&lt;!-- --&gt;</comment> <comment>&lt;!-- --&gt;</comment></h>\n</root>" ],
+ [ "== Foo ==\n== Bar == \n", "<root><h level=\"2\" i=\"1\">== Foo ==</h>\n<h level=\"2\" i=\"2\">== Bar == </h>\n</root>" ],
+ [ "===========", "<root><h level=\"5\" i=\"1\">===========</h></root>" ],
+ [ "Foo\n=\n==\n=\n", "<root>Foo\n=\n==\n=\n</root>" ],
+ [ "{{Foo}}", "<root><template><title>Foo</title></template></root>" ],
+ [ "\n{{Foo}}", "<root>\n<template lineStart=\"1\"><title>Foo</title></template></root>" ],
+ [ "{{Foo|bar}}", "<root><template><title>Foo</title><part><name index=\"1\" /><value>bar</value></part></template></root>" ],
+ [ "{{Foo|bar}}a", "<root><template><title>Foo</title><part><name index=\"1\" /><value>bar</value></part></template>a</root>" ],
+ [ "{{Foo|bar|baz}}", "<root><template><title>Foo</title><part><name index=\"1\" /><value>bar</value></part><part><name index=\"2\" /><value>baz</value></part></template></root>" ],
+ [ "{{Foo|1=bar}}", "<root><template><title>Foo</title><part><name>1</name>=<value>bar</value></part></template></root>" ],
+ [ "{{Foo|=bar}}", "<root><template><title>Foo</title><part><name></name>=<value>bar</value></part></template></root>" ],
+ [ "{{Foo|bar=baz}}", "<root><template><title>Foo</title><part><name>bar</name>=<value>baz</value></part></template></root>" ],
+ [ "{{Foo|{{bar}}=baz}}", "<root><template><title>Foo</title><part><name><template><title>bar</title></template></name>=<value>baz</value></part></template></root>" ],
+ [ "{{Foo|1=bar|baz}}", "<root><template><title>Foo</title><part><name>1</name>=<value>bar</value></part><part><name index=\"1\" /><value>baz</value></part></template></root>" ],
+ [ "{{Foo|1=bar|2=baz}}", "<root><template><title>Foo</title><part><name>1</name>=<value>bar</value></part><part><name>2</name>=<value>baz</value></part></template></root>" ],
+ [ "{{Foo|bar|foo=baz}}", "<root><template><title>Foo</title><part><name index=\"1\" /><value>bar</value></part><part><name>foo</name>=<value>baz</value></part></template></root>" ],
+ [ "{{{1}}}", "<root><tplarg><title>1</title></tplarg></root>" ],
+ [ "{{{1|}}}", "<root><tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg></root>" ],
+ [ "{{{Foo}}}", "<root><tplarg><title>Foo</title></tplarg></root>" ],
+ [ "{{{Foo|}}}", "<root><tplarg><title>Foo</title><part><name index=\"1\" /><value></value></part></tplarg></root>" ],
+ [ "{{{Foo|bar|baz}}}", "<root><tplarg><title>Foo</title><part><name index=\"1\" /><value>bar</value></part><part><name index=\"2\" /><value>baz</value></part></tplarg></root>" ],
+ [ "{<!-- -->{Foo}}", "<root>{<comment>&lt;!-- --&gt;</comment>{Foo}}</root>" ],
+ [ "{{{{Foobar}}}}", "<root>{<tplarg><title>Foobar</title></tplarg>}</root>" ],
+ [ "{{{ {{Foo}} }}}", "<root><tplarg><title> <template><title>Foo</title></template> </title></tplarg></root>" ],
+ [ "{{ {{{Foo}}} }}", "<root><template><title> <tplarg><title>Foo</title></tplarg> </title></template></root>" ],
+ [ "{{{{{Foo}}}}}", "<root><template><title><tplarg><title>Foo</title></tplarg></title></template></root>" ],
+ [ "{{{{{Foo}} }}}", "<root><tplarg><title><template><title>Foo</title></template> </title></tplarg></root>" ],
+ [ "{{{{{{Foo}}}}}}", "<root><tplarg><title><tplarg><title>Foo</title></tplarg></title></tplarg></root>" ],
+ [ "{{{{{{Foo}}}}}", "<root>{<template><title><tplarg><title>Foo</title></tplarg></title></template></root>" ],
+ [ "[[[Foo]]", "<root>[[[Foo]]</root>" ],
+ [ "{{Foo|[[[[bar]]|baz]]}}", "<root><template><title>Foo</title><part><name index=\"1\" /><value>[[[[bar]]|baz]]</value></part></template></root>" ], // This test is important, since it means the difference between having the [[ rule stacked or not
+ [ "{{Foo|[[[[bar]|baz]]}}", "<root>{{Foo|[[[[bar]|baz]]}}</root>" ],
+ [ "{{Foo|Foo [[[[bar]|baz]]}}", "<root>{{Foo|Foo [[[[bar]|baz]]}}</root>" ],
+ [ "Foo <display map>Bar</display map >Baz", "<root>Foo <ext><name>display map</name><attr></attr><inner>Bar</inner><close>&lt;/display map &gt;</close></ext>Baz</root>" ],
+ [ "Foo <display map foo>Bar</display map >Baz", "<root>Foo <ext><name>display map</name><attr> foo</attr><inner>Bar</inner><close>&lt;/display map &gt;</close></ext>Baz</root>" ],
+ [ "Foo <gallery bar=\"baz\" />", "<root>Foo <ext><name>gallery</name><attr> bar=&quot;baz&quot; </attr></ext></root>" ],
+ [ "Foo <gallery bar=\"1\" baz=2 />", "<root>Foo <ext><name>gallery</name><attr> bar=&quot;1&quot; baz=2 </attr></ext></root>" ],
+ [ "</foo>Foo<//foo>", "<root><ext><name>/foo</name><attr></attr><inner>Foo</inner><close>&lt;//foo&gt;</close></ext></root>" ], # Worth blacklisting IMHO
+ [ "{{#ifexpr: ({{{1|1}}} = 2) | Foo | Bar }}", "<root><template><title>#ifexpr: (<tplarg><title>1</title><part><name index=\"1\" /><value>1</value></part></tplarg> = 2) </title><part><name index=\"1\" /><value> Foo </value></part><part><name index=\"2\" /><value> Bar </value></part></template></root>" ],
+ [ "{{#if: {{{1|}}} | Foo | {{Bar}} }}", "<root><template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> Foo </value></part><part><name index=\"2\" /><value> <template><title>Bar</title></template> </value></part></template></root>" ],
+ [ "{{#if: {{{1|}}} | Foo | [[Bar]] }}", "<root><template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> Foo </value></part><part><name index=\"2\" /><value> [[Bar]] </value></part></template></root>" ],
+ [ "{{#if: {{{1|}}} | [[Foo]] | Bar }}", "<root><template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> [[Foo]] </value></part><part><name index=\"2\" /><value> Bar </value></part></template></root>" ],
+ [ "{{#if: {{{1|}}} | 1 | {{#if: {{{1|}}} | 2 | 3 }} }}", "<root><template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> 1 </value></part><part><name index=\"2\" /><value> <template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> 2 </value></part><part><name index=\"2\" /><value> 3 </value></part></template> </value></part></template></root>" ],
+ [ "{{ {{Foo}}", "<root>{{ <template><title>Foo</title></template></root>" ],
+ [ "{{Foobar {{Foo}} {{Bar}} {{Baz}} ", "<root>{{Foobar <template><title>Foo</title></template> <template><title>Bar</title></template> <template><title>Baz</title></template> </root>" ],
+ [ "[[Foo]] |", "<root>[[Foo]] |</root>" ],
+ [ "{{Foo|Bar|", "<root>{{Foo|Bar|</root>" ],
+ [ "[[Foo]", "<root>[[Foo]</root>" ],
+ [ "[[Foo|Bar]", "<root>[[Foo|Bar]</root>" ],
+ [ "{{Foo| [[Bar] }}", "<root>{{Foo| [[Bar] }}</root>" ],
+ [ "{{Foo| [[Bar|Baz] }}", "<root>{{Foo| [[Bar|Baz] }}</root>" ],
+ [ "{{Foo|bar=[[baz]}}", "<root>{{Foo|bar=[[baz]}}</root>" ],
+ [ "{{foo|", "<root>{{foo|</root>" ],
+ [ "{{foo|}", "<root>{{foo|}</root>" ],
+ [ "{{foo|} }}", "<root><template><title>foo</title><part><name index=\"1\" /><value>} </value></part></template></root>" ],
+ [ "{{foo|bar=|}", "<root>{{foo|bar=|}</root>" ],
+ [ "{{Foo|} Bar=", "<root>{{Foo|} Bar=</root>" ],
+ [ "{{Foo|} Bar=}}", "<root><template><title>Foo</title><part><name>} Bar</name>=<value></value></part></template></root>" ],
+ /* [ file_get_contents( __DIR__ . '/QuoteQuran.txt' ], file_get_contents( __DIR__ . '/QuoteQuranExpanded.txt' ) ], */
+ ] );
+ // phpcs:enable
+ }
+
+ /**
+ * Get XML preprocessor tree from the preprocessor (which may not be the
+ * native XML-based one).
+ *
+ * @param string $className
+ * @param string $wikiText
+ * @return string
+ */
+ protected function preprocessToXml( $className, $wikiText ) {
+ $preprocessor = $this->mPreprocessors[$className];
+ if ( method_exists( $preprocessor, 'preprocessToXml' ) ) {
+ return $this->normalizeXml( $preprocessor->preprocessToXml( $wikiText ) );
+ }
+
+ $dom = $preprocessor->preprocessToObj( $wikiText );
+ if ( is_callable( [ $dom, 'saveXML' ] ) ) {
+ return $dom->saveXML();
+ } else {
+ return $this->normalizeXml( $dom->__toString() );
+ }
+ }
+
+ /**
+ * Normalize XML string to the form that a DOMDocument saves out.
+ *
+ * @param string $xml
+ * @return string
+ */
+ protected function normalizeXml( $xml ) {
+ // Normalize self-closing tags
+ $xml = preg_replace( '!<([a-z]+)/>!', '<$1></$1>', str_replace( ' />', '/>', $xml ) );
+ // Remove <equals> tags, which only occur in Preprocessor_Hash and
+ // have no semantic value
+ $xml = preg_replace( '!</?equals>!', '', $xml );
+ return $xml;
+ }
+
+ /**
+ * @dataProvider provideCases
+ */
+ public function testPreprocessorOutput( $className, $wikiText, $expectedXml ) {
+ $this->assertEquals( $this->normalizeXml( $expectedXml ),
+ $this->preprocessToXml( $className, $wikiText ) );
+ }
+
+ /**
+ * These are more complex test cases taken out of wiki articles.
+ */
+ public static function provideFiles() {
+ // phpcs:disable Generic.Files.LineLength
+ return self::addClassArg( [
+ [ "QuoteQuran" ], # https://en.wikipedia.org/w/index.php?title=Template:QuoteQuran/sandbox&oldid=237348988 GFDL + CC BY-SA by Striver
+ [ "Factorial" ], # https://en.wikipedia.org/w/index.php?title=Template:Factorial&oldid=98548758 GFDL + CC BY-SA by Polonium
+ [ "All_system_messages" ], # https://tl.wiktionary.org/w/index.php?title=Suleras:All_system_messages&oldid=2765 GPL text generated by MediaWiki
+ [ "Fundraising" ], # https://tl.wiktionary.org/w/index.php?title=MediaWiki:Sitenotice&oldid=5716 GFDL + CC BY-SA, copied there by Sky Harbor.
+ [ "NestedTemplates" ], # T29936
+ ] );
+ // phpcs:enable
+ }
+
+ /**
+ * @dataProvider provideFiles
+ */
+ public function testPreprocessorOutputFiles( $className, $filename ) {
+ $folder = __DIR__ . "/../../../parser/preprocess";
+ $wikiText = file_get_contents( "$folder/$filename.txt" );
+ $output = $this->preprocessToXml( $className, $wikiText );
+
+ $expectedFilename = "$folder/$filename.expected";
+ if ( file_exists( $expectedFilename ) ) {
+ $expectedXml = $this->normalizeXml( file_get_contents( $expectedFilename ) );
+ $this->assertEquals( $expectedXml, $output );
+ } else {
+ $tempFilename = tempnam( $folder, "$filename." );
+ file_put_contents( $tempFilename, $output );
+ $this->markTestIncomplete( "File $expectedFilename missing. Output stored as $tempFilename" );
+ }
+ }
+
+ /**
+ * Tests from T30642 · https://phabricator.wikimedia.org/T30642
+ */
+ public static function provideHeadings() {
+ // phpcs:disable Generic.Files.LineLength
+ return self::addClassArg( [
+ /* These should become headings: */
+ [ "== h ==<!--c1-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment></h></root>" ],
+ [ "== h == <!--c1-->", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment></h></root>" ],
+ [ "== h ==<!--c1--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment> </h></root>" ],
+ [ "== h == <!--c1--> ", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment> </h></root>" ],
+ [ "== h ==<!--c1--><!--c2-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment></h></root>" ],
+ [ "== h == <!--c1--><!--c2-->", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment></h></root>" ],
+ [ "== h ==<!--c1--><!--c2--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment> </h></root>" ],
+ [ "== h == <!--c1--><!--c2--> ", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment> </h></root>" ],
+ [ "== h == <!--c1--> <!--c2-->", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment></h></root>" ],
+ [ "== h ==<!--c1--> <!--c2--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment> </h></root>" ],
+ [ "== h == <!--c1--> <!--c2--> ", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment> </h></root>" ],
+ [ "== h ==<!--c1--><!--c2--><!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment></h></root>" ],
+ [ "== h ==<!--c1--> <!--c2--><!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment></h></root>" ],
+ [ "== h ==<!--c1--><!--c2--> <!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment> <comment>&lt;!--c3--&gt;</comment></h></root>" ],
+ [ "== h ==<!--c1--> <!--c2--> <!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment> <comment>&lt;!--c3--&gt;</comment></h></root>" ],
+ [ "== h == <!--c1--><!--c2--><!--c3-->", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment></h></root>" ],
+ [ "== h == <!--c1--> <!--c2--><!--c3-->", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment></h></root>" ],
+ [ "== h == <!--c1--><!--c2--> <!--c3-->", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment> <comment>&lt;!--c3--&gt;</comment></h></root>" ],
+ [ "== h == <!--c1--> <!--c2--> <!--c3-->", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment> <comment>&lt;!--c3--&gt;</comment></h></root>" ],
+ [ "== h ==<!--c1--><!--c2--><!--c3--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment> </h></root>" ],
+ [ "== h ==<!--c1--> <!--c2--><!--c3--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment> </h></root>" ],
+ [ "== h ==<!--c1--><!--c2--> <!--c3--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment> <comment>&lt;!--c3--&gt;</comment> </h></root>" ],
+ [ "== h ==<!--c1--> <!--c2--> <!--c3--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment> <comment>&lt;!--c3--&gt;</comment> </h></root>" ],
+ [ "== h == <!--c1--><!--c2--><!--c3--> ", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment> </h></root>" ],
+ [ "== h == <!--c1--> <!--c2--><!--c3--> ", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment> </h></root>" ],
+ [ "== h == <!--c1--><!--c2--> <!--c3--> ", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment> <comment>&lt;!--c3--&gt;</comment> </h></root>" ],
+ [ "== h == <!--c1--> <!--c2--> <!--c3--> ", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment> <comment>&lt;!--c3--&gt;</comment> </h></root>" ],
+ [ "== h ==<!--c1--> <!--c2-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment></h></root>" ],
+ [ "== h == <!--c1--> <!--c2-->", "<root><h level=\"2\" i=\"1\">== h == <comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment></h></root>" ],
+ [ "== h ==<!--c1--> <!--c2--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment> <comment>&lt;!--c2--&gt;</comment> </h></root>" ],
+
+ /* These are not working: */
+ [ "== h == x <!--c1--><!--c2--><!--c3--> ", "<root>== h == x <comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment> </root>" ],
+ [ "== h ==<!--c1--> x <!--c2--><!--c3--> ", "<root>== h ==<comment>&lt;!--c1--&gt;</comment> x <comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment> </root>" ],
+ [ "== h ==<!--c1--><!--c2--><!--c3--> x ", "<root>== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment> x </root>" ],
+ ] );
+ // phpcs:enable
+ }
+
+ /**
+ * @dataProvider provideHeadings
+ */
+ public function testHeadings( $className, $wikiText, $expectedXml ) {
+ $this->assertEquals( $this->normalizeXml( $expectedXml ),
+ $this->preprocessToXml( $className, $wikiText ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/parser/SanitizerTest.php b/www/wiki/tests/phpunit/includes/parser/SanitizerTest.php
new file mode 100644
index 00000000..35b81fb9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/parser/SanitizerTest.php
@@ -0,0 +1,571 @@
+<?php
+
+/**
+ * @todo Tests covering decodeCharReferences can be refactored into a single
+ * method and dataprovider.
+ *
+ * @group Sanitizer
+ */
+class SanitizerTest extends MediaWikiTestCase {
+
+ protected function tearDown() {
+ MWTidy::destroySingleton();
+ parent::tearDown();
+ }
+
+ /**
+ * @covers Sanitizer::decodeCharReferences
+ */
+ public function testDecodeNamedEntities() {
+ $this->assertEquals(
+ "\xc3\xa9cole",
+ Sanitizer::decodeCharReferences( '&eacute;cole' ),
+ 'decode named entities'
+ );
+ }
+
+ /**
+ * @covers Sanitizer::decodeCharReferences
+ */
+ public function testDecodeNumericEntities() {
+ $this->assertEquals(
+ "\xc4\x88io bonas dans l'\xc3\xa9cole!",
+ Sanitizer::decodeCharReferences( "&#x108;io bonas dans l'&#233;cole!" ),
+ 'decode numeric entities'
+ );
+ }
+
+ /**
+ * @covers Sanitizer::decodeCharReferences
+ */
+ public function testDecodeMixedEntities() {
+ $this->assertEquals(
+ "\xc4\x88io bonas dans l'\xc3\xa9cole!",
+ Sanitizer::decodeCharReferences( "&#x108;io bonas dans l'&eacute;cole!" ),
+ 'decode mixed numeric/named entities'
+ );
+ }
+
+ /**
+ * @covers Sanitizer::decodeCharReferences
+ */
+ public function testDecodeMixedComplexEntities() {
+ $this->assertEquals(
+ "\xc4\x88io bonas dans l'\xc3\xa9cole! (mais pas &#x108;io dans l'&eacute;cole)",
+ Sanitizer::decodeCharReferences(
+ "&#x108;io bonas dans l'&eacute;cole! (mais pas &amp;#x108;io dans l'&#38;eacute;cole)"
+ ),
+ 'decode mixed complex entities'
+ );
+ }
+
+ /**
+ * @covers Sanitizer::decodeCharReferences
+ */
+ public function testInvalidAmpersand() {
+ $this->assertEquals(
+ 'a & b',
+ Sanitizer::decodeCharReferences( 'a & b' ),
+ 'Invalid ampersand'
+ );
+ }
+
+ /**
+ * @covers Sanitizer::decodeCharReferences
+ */
+ public function testInvalidEntities() {
+ $this->assertEquals(
+ '&foo;',
+ Sanitizer::decodeCharReferences( '&foo;' ),
+ 'Invalid named entity'
+ );
+ }
+
+ /**
+ * @covers Sanitizer::decodeCharReferences
+ */
+ public function testInvalidNumberedEntities() {
+ $this->assertEquals(
+ UtfNormal\Constants::UTF8_REPLACEMENT,
+ Sanitizer::decodeCharReferences( "&#88888888888888;" ),
+ 'Invalid numbered entity'
+ );
+ }
+
+ /**
+ * @covers Sanitizer::removeHTMLtags
+ * @dataProvider provideHtml5Tags
+ *
+ * @param string $tag Name of an HTML5 element (ie: 'video')
+ * @param bool $escaped Whether sanitizer let the tag in or escape it (ie: '&lt;video&gt;')
+ */
+ public function testRemovehtmltagsOnHtml5Tags( $tag, $escaped ) {
+ MWTidy::setInstance( false );
+
+ if ( $escaped ) {
+ $this->assertEquals( "&lt;$tag&gt;",
+ Sanitizer::removeHTMLtags( "<$tag>" )
+ );
+ } else {
+ $this->assertEquals( "<$tag></$tag>\n",
+ Sanitizer::removeHTMLtags( "<$tag>" )
+ );
+ }
+ }
+
+ /**
+ * Provide HTML5 tags
+ */
+ public static function provideHtml5Tags() {
+ $ESCAPED = true; # We want tag to be escaped
+ $VERBATIM = false; # We want to keep the tag
+ return [
+ [ 'data', $VERBATIM ],
+ [ 'mark', $VERBATIM ],
+ [ 'time', $VERBATIM ],
+ [ 'video', $ESCAPED ],
+ ];
+ }
+
+ function dataRemoveHTMLtags() {
+ return [
+ // former testSelfClosingTag
+ [
+ '<div>Hello world</div />',
+ '<div>Hello world</div>',
+ 'Self-closing closing div'
+ ],
+ // Make sure special nested HTML5 semantics are not broken
+ // https://html.spec.whatwg.org/multipage/semantics.html#the-kbd-element
+ [
+ '<kbd><kbd>Shift</kbd>+<kbd>F3</kbd></kbd>',
+ '<kbd><kbd>Shift</kbd>+<kbd>F3</kbd></kbd>',
+ 'Nested <kbd>.'
+ ],
+ // https://html.spec.whatwg.org/multipage/semantics.html#the-sub-and-sup-elements
+ [
+ '<var>x<sub><var>i</var></sub></var>, <var>y<sub><var>i</var></sub></var>',
+ '<var>x<sub><var>i</var></sub></var>, <var>y<sub><var>i</var></sub></var>',
+ 'Nested <var>.'
+ ],
+ // https://html.spec.whatwg.org/multipage/semantics.html#the-dfn-element
+ [
+ '<dfn><abbr title="Garage Door Opener">GDO</abbr></dfn>',
+ '<dfn><abbr title="Garage Door Opener">GDO</abbr></dfn>',
+ '<abbr> inside <dfn>',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider dataRemoveHTMLtags
+ * @covers Sanitizer::removeHTMLtags
+ */
+ public function testRemoveHTMLtags( $input, $output, $msg = null ) {
+ MWTidy::setInstance( false );
+ $this->assertEquals( $output, Sanitizer::removeHTMLtags( $input ), $msg );
+ }
+
+ /**
+ * @dataProvider provideTagAttributesToDecode
+ * @covers Sanitizer::decodeTagAttributes
+ */
+ public function testDecodeTagAttributes( $expected, $attributes, $message = '' ) {
+ $this->assertEquals( $expected,
+ Sanitizer::decodeTagAttributes( $attributes ),
+ $message
+ );
+ }
+
+ public static function provideTagAttributesToDecode() {
+ return [
+ [ [ 'foo' => 'bar' ], 'foo=bar', 'Unquoted attribute' ],
+ [ [ 'עברית' => 'bar' ], 'עברית=bar', 'Non-Latin attribute' ],
+ [ [ '६' => 'bar' ], '६=bar', 'Devanagari number' ],
+ [ [ '搭𨋢' => 'bar' ], '搭𨋢=bar', 'Non-BMP character' ],
+ [ [], 'ńgh=bar', 'Combining accent is not allowed' ],
+ [ [ 'foo' => 'bar' ], ' foo = bar ', 'Spaced attribute' ],
+ [ [ 'foo' => 'bar' ], 'foo="bar"', 'Double-quoted attribute' ],
+ [ [ 'foo' => 'bar' ], 'foo=\'bar\'', 'Single-quoted attribute' ],
+ [
+ [ 'foo' => 'bar', 'baz' => 'foo' ],
+ 'foo=\'bar\' baz="foo"',
+ 'Several attributes'
+ ],
+ [
+ [ 'foo' => 'bar', 'baz' => 'foo' ],
+ 'foo=\'bar\' baz="foo"',
+ 'Several attributes'
+ ],
+ [
+ [ 'foo' => 'bar', 'baz' => 'foo' ],
+ 'foo=\'bar\' baz="foo"',
+ 'Several attributes'
+ ],
+ [ [ ':foo' => 'bar' ], ':foo=\'bar\'', 'Leading :' ],
+ [ [ '_foo' => 'bar' ], '_foo=\'bar\'', 'Leading _' ],
+ [ [ 'foo' => 'bar' ], 'Foo=\'bar\'', 'Leading capital' ],
+ [ [ 'foo' => 'BAR' ], 'FOO=BAR', 'Attribute keys are normalized to lowercase' ],
+
+ # Invalid beginning
+ [ [], '-foo=bar', 'Leading - is forbidden' ],
+ [ [], '.foo=bar', 'Leading . is forbidden' ],
+ [ [ 'foo-bar' => 'bar' ], 'foo-bar=bar', 'A - is allowed inside the attribute' ],
+ [ [ 'foo-' => 'bar' ], 'foo-=bar', 'A - is allowed inside the attribute' ],
+ [ [ 'foo.bar' => 'baz' ], 'foo.bar=baz', 'A . is allowed inside the attribute' ],
+ [ [ 'foo.' => 'baz' ], 'foo.=baz', 'A . is allowed as last character' ],
+ [ [ 'foo6' => 'baz' ], 'foo6=baz', 'Numbers are allowed' ],
+
+ # This bit is more relaxed than XML rules, but some extensions use
+ # it, like ProofreadPage (see T29539)
+ [ [ '1foo' => 'baz' ], '1foo=baz', 'Leading numbers are allowed' ],
+ [ [], 'foo$=baz', 'Symbols are not allowed' ],
+ [ [], 'foo@=baz', 'Symbols are not allowed' ],
+ [ [], 'foo~=baz', 'Symbols are not allowed' ],
+ [
+ [ 'foo' => '1[#^`*%w/(' ],
+ 'foo=1[#^`*%w/(',
+ 'All kind of characters are allowed as values'
+ ],
+ [
+ [ 'foo' => '1[#^`*%\'w/(' ],
+ 'foo="1[#^`*%\'w/("',
+ 'Double quotes are allowed if quoted by single quotes'
+ ],
+ [
+ [ 'foo' => '1[#^`*%"w/(' ],
+ 'foo=\'1[#^`*%"w/(\'',
+ 'Single quotes are allowed if quoted by double quotes'
+ ],
+ [ [ 'foo' => '&"' ], 'foo=&amp;&quot;', 'Special chars can be provided as entities' ],
+ [ [ 'foo' => '&foobar;' ], 'foo=&foobar;', 'Entity-like items are accepted' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideDeprecatedAttributes
+ * @covers Sanitizer::fixTagAttributes
+ */
+ public function testDeprecatedAttributesUnaltered( $inputAttr, $inputEl, $message = '' ) {
+ $this->assertEquals( " $inputAttr",
+ Sanitizer::fixTagAttributes( $inputAttr, $inputEl ),
+ $message
+ );
+ }
+
+ public static function provideDeprecatedAttributes() {
+ /** [ <attribute>, <element>, [message] ] */
+ return [
+ [ 'clear="left"', 'br' ],
+ [ 'clear="all"', 'br' ],
+ [ 'width="100"', 'td' ],
+ [ 'nowrap="true"', 'td' ],
+ [ 'nowrap=""', 'td' ],
+ [ 'align="right"', 'td' ],
+ [ 'align="center"', 'table' ],
+ [ 'align="left"', 'tr' ],
+ [ 'align="center"', 'div' ],
+ [ 'align="left"', 'h1' ],
+ [ 'align="left"', 'p' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCssCommentsFixtures
+ * @covers Sanitizer::checkCss
+ */
+ public function testCssCommentsChecking( $expected, $css, $message = '' ) {
+ $this->assertEquals( $expected,
+ Sanitizer::checkCss( $css ),
+ $message
+ );
+ }
+
+ public static function provideCssCommentsFixtures() {
+ /** [ <expected>, <css>, [message] ] */
+ return [
+ // Valid comments spanning entire input
+ [ '/**/', '/**/' ],
+ [ '/* comment */', '/* comment */' ],
+ // Weird stuff
+ [ ' ', '/****/' ],
+ [ ' ', '/* /* */' ],
+ [ 'display: block;', "display:/* foo */block;" ],
+ [ 'display: block;', "display:\\2f\\2a foo \\2a\\2f block;",
+ 'Backslash-escaped comments must be stripped (T30450)' ],
+ [ '', '/* unfinished comment structure',
+ 'Remove anything after a comment-start token' ],
+ [ '', "\\2f\\2a unifinished comment'",
+ 'Remove anything after a backslash-escaped comment-start token' ],
+ [
+ '/* insecure input */',
+ 'filter: progid:DXImageTransform.Microsoft.AlphaImageLoader'
+ . '(src=\'asdf.png\',sizingMethod=\'scale\');'
+ ],
+ [
+ '/* insecure input */',
+ '-ms-filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader'
+ . '(src=\'asdf.png\',sizingMethod=\'scale\')";'
+ ],
+ [ '/* insecure input */', 'width: expression(1+1);' ],
+ [ '/* insecure input */', 'background-image: image(asdf.png);' ],
+ [ '/* insecure input */', 'background-image: -webkit-image(asdf.png);' ],
+ [ '/* insecure input */', 'background-image: -moz-image(asdf.png);' ],
+ [ '/* insecure input */', 'background-image: image-set("asdf.png" 1x, "asdf.png" 2x);' ],
+ [
+ '/* insecure input */',
+ 'background-image: -webkit-image-set("asdf.png" 1x, "asdf.png" 2x);'
+ ],
+ [
+ '/* insecure input */',
+ 'background-image: -moz-image-set("asdf.png" 1x, "asdf.png" 2x);'
+ ],
+ [ '/* insecure input */', 'foo: attr( title, url );' ],
+ [ '/* insecure input */', 'foo: attr( title url );' ],
+ [ '/* insecure input */', 'foo: var(--evil-attribute)' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideEscapeHtmlAllowEntities
+ * @covers Sanitizer::escapeHtmlAllowEntities
+ */
+ public function testEscapeHtmlAllowEntities( $expected, $html ) {
+ $this->assertEquals(
+ $expected,
+ Sanitizer::escapeHtmlAllowEntities( $html )
+ );
+ }
+
+ public static function provideEscapeHtmlAllowEntities() {
+ return [
+ [ 'foo', 'foo' ],
+ [ 'a¡b', 'a&#161;b' ],
+ [ 'foo&#039;bar', "foo'bar" ],
+ [ '&lt;script&gt;foo&lt;/script&gt;', '<script>foo</script>' ],
+ ];
+ }
+
+ /**
+ * Test Sanitizer::escapeId
+ *
+ * @dataProvider provideEscapeId
+ * @covers Sanitizer::escapeId
+ */
+ public function testEscapeId( $input, $output ) {
+ $this->assertEquals(
+ $output,
+ Sanitizer::escapeId( $input, [ 'noninitial', 'legacy' ] )
+ );
+ }
+
+ public static function provideEscapeId() {
+ return [
+ [ '+', '.2B' ],
+ [ '&', '.26' ],
+ [ '=', '.3D' ],
+ [ ':', ':' ],
+ [ ';', '.3B' ],
+ [ '@', '.40' ],
+ [ '$', '.24' ],
+ [ '-_.', '-_.' ],
+ [ '!', '.21' ],
+ [ '*', '.2A' ],
+ [ '/', '.2F' ],
+ [ '[]', '.5B.5D' ],
+ [ '<>', '.3C.3E' ],
+ [ '\'', '.27' ],
+ [ '§', '.C2.A7' ],
+ [ 'Test:A & B/Here', 'Test:A_.26_B.2FHere' ],
+ [ 'A&B&amp;C&amp;amp;D&amp;amp;amp;E', 'A.26B.26amp.3BC.26amp.3Bamp.3BD.26amp.3Bamp.3Bamp.3BE' ],
+ ];
+ }
+
+ /**
+ * Test escapeIdReferenceList for consistency with escapeIdForAttribute
+ *
+ * @dataProvider provideEscapeIdReferenceList
+ * @covers Sanitizer::escapeIdReferenceList
+ */
+ public function testEscapeIdReferenceList( $referenceList, $id1, $id2 ) {
+ $this->assertEquals(
+ Sanitizer::escapeIdReferenceList( $referenceList ),
+ Sanitizer::escapeIdForAttribute( $id1 )
+ . ' '
+ . Sanitizer::escapeIdForAttribute( $id2 )
+ );
+ }
+
+ public static function provideEscapeIdReferenceList() {
+ /** [ <reference list>, <individual id 1>, <individual id 2> ] */
+ return [
+ [ 'foo bar', 'foo', 'bar' ],
+ [ '#1 #2', '#1', '#2' ],
+ [ '+1 +2', '+1', '+2' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsReservedDataAttribute
+ * @covers Sanitizer::isReservedDataAttribute
+ */
+ public function testIsReservedDataAttribute( $attr, $expected ) {
+ $this->assertSame( $expected, Sanitizer::isReservedDataAttribute( $attr ) );
+ }
+
+ public static function provideIsReservedDataAttribute() {
+ return [
+ [ 'foo', false ],
+ [ 'data', false ],
+ [ 'data-foo', false ],
+ [ 'data-mw', true ],
+ [ 'data-ooui', true ],
+ [ 'data-parsoid', true ],
+ [ 'data-mw-foo', true ],
+ [ 'data-ooui-foo', true ],
+ [ 'data-mwfoo', true ], // could be false but this is how it's implemented currently
+ ];
+ }
+
+ /**
+ * @dataProvider provideEscapeIdForStuff
+ *
+ * @covers Sanitizer::escapeIdForAttribute()
+ * @covers Sanitizer::escapeIdForLink()
+ * @covers Sanitizer::escapeIdForExternalInterwiki()
+ * @covers Sanitizer::escapeIdInternal()
+ *
+ * @param string $stuff
+ * @param string[] $config
+ * @param string $id
+ * @param string|false $expected
+ * @param int|null $mode
+ */
+ public function testEscapeIdForStuff( $stuff, array $config, $id, $expected, $mode = null ) {
+ $func = "Sanitizer::escapeIdFor{$stuff}";
+ $iwFlavor = array_pop( $config );
+ $this->setMwGlobals( [
+ 'wgFragmentMode' => $config,
+ 'wgExternalInterwikiFragmentMode' => $iwFlavor,
+ ] );
+ $escaped = call_user_func( $func, $id, $mode );
+ self::assertEquals( $expected, $escaped );
+ }
+
+ public function provideEscapeIdForStuff() {
+ // Test inputs and outputs
+ $text = 'foo тест_#%!\'()[]:<>&&amp;&amp;amp;';
+ $legacyEncoded = 'foo_.D1.82.D0.B5.D1.81.D1.82_.23.25.21.27.28.29.5B.5D:.3C.3E' .
+ '.26.26amp.3B.26amp.3Bamp.3B';
+ $html5Encoded = 'foo_тест_#%!\'()[]:<>&&amp;&amp;amp;';
+ $html5Experimental = 'foo_тест_!_()[]:<>_amp;_amp;amp;';
+
+ // Settings: last element is $wgExternalInterwikiFragmentMode, the rest is $wgFragmentMode
+ $legacy = [ 'legacy', 'legacy' ];
+ $legacyNew = [ 'legacy', 'html5', 'legacy' ];
+ $newLegacy = [ 'html5', 'legacy', 'legacy' ];
+ $new = [ 'html5', 'legacy' ];
+ $allNew = [ 'html5', 'html5' ];
+ $experimentalLegacy = [ 'html5-legacy', 'legacy', 'legacy' ];
+ $newExperimental = [ 'html5', 'html5-legacy', 'legacy' ];
+
+ return [
+ // Pure legacy: how MW worked before 2017
+ [ 'Attribute', $legacy, $text, $legacyEncoded, Sanitizer::ID_PRIMARY ],
+ [ 'Attribute', $legacy, $text, false, Sanitizer::ID_FALLBACK ],
+ [ 'Link', $legacy, $text, $legacyEncoded ],
+ [ 'ExternalInterwiki', $legacy, $text, $legacyEncoded ],
+
+ // Transition to a new world: legacy links with HTML5 fallback
+ [ 'Attribute', $legacyNew, $text, $legacyEncoded, Sanitizer::ID_PRIMARY ],
+ [ 'Attribute', $legacyNew, $text, $html5Encoded, Sanitizer::ID_FALLBACK ],
+ [ 'Link', $legacyNew, $text, $legacyEncoded ],
+ [ 'ExternalInterwiki', $legacyNew, $text, $legacyEncoded ],
+
+ // New world: HTML5 links, legacy fallbacks
+ [ 'Attribute', $newLegacy, $text, $html5Encoded, Sanitizer::ID_PRIMARY ],
+ [ 'Attribute', $newLegacy, $text, $legacyEncoded, Sanitizer::ID_FALLBACK ],
+ [ 'Link', $newLegacy, $text, $html5Encoded ],
+ [ 'ExternalInterwiki', $newLegacy, $text, $legacyEncoded ],
+
+ // Distant future: no legacy fallbacks, but still linking to leagacy wikis
+ [ 'Attribute', $new, $text, $html5Encoded, Sanitizer::ID_PRIMARY ],
+ [ 'Attribute', $new, $text, false, Sanitizer::ID_FALLBACK ],
+ [ 'Link', $new, $text, $html5Encoded ],
+ [ 'ExternalInterwiki', $new, $text, $legacyEncoded ],
+
+ // Just before the heat death of universe: external interwikis are also HTML5 \m/
+ [ 'Attribute', $allNew, $text, $html5Encoded, Sanitizer::ID_PRIMARY ],
+ [ 'Attribute', $allNew, $text, false, Sanitizer::ID_FALLBACK ],
+ [ 'Link', $allNew, $text, $html5Encoded ],
+ [ 'ExternalInterwiki', $allNew, $text, $html5Encoded ],
+
+ // Someone flipped $wgExperimentalHtmlIds on
+ [ 'Attribute', $experimentalLegacy, $text, $html5Experimental, Sanitizer::ID_PRIMARY ],
+ [ 'Attribute', $experimentalLegacy, $text, $legacyEncoded, Sanitizer::ID_FALLBACK ],
+ [ 'Link', $experimentalLegacy, $text, $html5Experimental ],
+ [ 'ExternalInterwiki', $experimentalLegacy, $text, $legacyEncoded ],
+
+ // Migration from $wgExperimentalHtmlIds to modern HTML5
+ [ 'Attribute', $newExperimental, $text, $html5Encoded, Sanitizer::ID_PRIMARY ],
+ [ 'Attribute', $newExperimental, $text, $html5Experimental, Sanitizer::ID_FALLBACK ],
+ [ 'Link', $newExperimental, $text, $html5Encoded ],
+ [ 'ExternalInterwiki', $newExperimental, $text, $legacyEncoded ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideStripAllTags
+ *
+ * @covers Sanitizer::stripAllTags()
+ * @covers RemexStripTagHandler
+ *
+ * @param string $input
+ * @param string $expected
+ */
+ public function testStripAllTags( $input, $expected ) {
+ $this->assertEquals( $expected, Sanitizer::stripAllTags( $input ) );
+ }
+
+ public function provideStripAllTags() {
+ return [
+ [ '<p>Foo</p>', 'Foo' ],
+ [ '<p id="one">Foo</p><p id="two">Bar</p>', 'FooBar' ],
+ [ "<p>Foo</p>\n<p>Bar</p>", 'Foo Bar' ],
+ [ '<p>Hello &lt;strong&gt; wor&#x6c;&#100; caf&eacute;</p>', 'Hello <strong> world café' ],
+ [
+ '<p><small data-foo=\'bar"&lt;baz>quux\'><a href="./Foo">Bar</a></small> Whee!</p>',
+ 'Bar Whee!'
+ ],
+ [ '1<span class="<?php">2</span>3', '123' ],
+ [ '1<span class="<?">2</span>3', '123' ],
+ ];
+ }
+
+ /**
+ * @expectedException InvalidArgumentException
+ * @covers Sanitizer::escapeIdInternal()
+ */
+ public function testInvalidFragmentThrows() {
+ $this->setMwGlobals( 'wgFragmentMode', [ 'boom!' ] );
+ Sanitizer::escapeIdForAttribute( 'This should throw' );
+ }
+
+ /**
+ * @expectedException UnexpectedValueException
+ * @covers Sanitizer::escapeIdForAttribute()
+ */
+ public function testNoPrimaryFragmentModeThrows() {
+ $this->setMwGlobals( 'wgFragmentMode', [ 666 => 'html5' ] );
+ Sanitizer::escapeIdForAttribute( 'This should throw' );
+ }
+
+ /**
+ * @expectedException UnexpectedValueException
+ * @covers Sanitizer::escapeIdForLink()
+ */
+ public function testNoPrimaryFragmentModeThrows2() {
+ $this->setMwGlobals( 'wgFragmentMode', [ 666 => 'html5' ] );
+ Sanitizer::escapeIdForLink( 'This should throw' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/parser/StripStateTest.php b/www/wiki/tests/phpunit/includes/parser/StripStateTest.php
new file mode 100644
index 00000000..0f4f6e0f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/parser/StripStateTest.php
@@ -0,0 +1,136 @@
+<?php
+
+/**
+ * @covers StripState
+ */
+class StripStateTest extends MediaWikiTestCase {
+ public function setUp() {
+ parent::setUp();
+ $this->setContentLang( 'qqx' );
+ }
+
+ private function getMarker() {
+ static $i;
+ return Parser::MARKER_PREFIX . '-blah-' . sprintf( '%08X', $i++ ) . Parser::MARKER_SUFFIX;
+ }
+
+ private static function getWarning( $message, $max = '' ) {
+ return "<span class=\"error\">($message: $max)</span>";
+ }
+
+ public function testAddNoWiki() {
+ $ss = new StripState;
+ $marker = $this->getMarker();
+ $ss->addNoWiki( $marker, '<>' );
+ $text = "x{$marker}y";
+ $text = $ss->unstripGeneral( $text );
+ $text = str_replace( '<', '', $text );
+ $text = $ss->unstripNoWiki( $text );
+ $this->assertSame( 'x<>y', $text );
+ }
+
+ public function testAddGeneral() {
+ $ss = new StripState;
+ $marker = $this->getMarker();
+ $ss->addGeneral( $marker, '<>' );
+ $text = "x{$marker}y";
+ $text = $ss->unstripNoWiki( $text );
+ $text = str_replace( '<', '', $text );
+ $text = $ss->unstripGeneral( $text );
+ $this->assertSame( 'x<>y', $text );
+ }
+
+ public function testUnstripBoth() {
+ $ss = new StripState;
+ $mk1 = $this->getMarker();
+ $mk2 = $this->getMarker();
+ $ss->addNoWiki( $mk1, '<1>' );
+ $ss->addGeneral( $mk2, '<2>' );
+ $text = "x{$mk1}{$mk2}y";
+ $text = str_replace( '<', '', $text );
+ $text = $ss->unstripBoth( $text );
+ $this->assertSame( 'x<1><2>y', $text );
+ }
+
+ public static function provideUnstripRecursive() {
+ return [
+ [ 0, 'text' ],
+ [ 1, '=text=' ],
+ [ 2, '==text==' ],
+ [ 3, '==' . self::getWarning( 'unstrip-depth-warning', 2 ) . '==' ],
+ ];
+ }
+
+ /** @dataProvider provideUnstripRecursive */
+ public function testUnstripRecursive( $depth, $expected ) {
+ $ss = new StripState( null, [ 'depthLimit' => 2 ] );
+ $text = 'text';
+ for ( $i = 0; $i < $depth; $i++ ) {
+ $mk = $this->getMarker();
+ $ss->addNoWiki( $mk, "={$text}=" );
+ $text = $mk;
+ }
+ $text = $ss->unstripNoWiki( $text );
+ $this->assertSame( $expected, $text );
+ }
+
+ public function testUnstripLoop() {
+ $ss = new StripState( null, [ 'depthLimit' => 2 ] );
+ $mk = $this->getMarker();
+ $ss->addNoWiki( $mk, $mk );
+ $text = $ss->unstripNoWiki( $mk );
+ $this->assertSame( self::getWarning( 'parser-unstrip-loop-warning' ), $text );
+ }
+
+ public static function provideUnstripSize() {
+ return [
+ [ 0, 'x' ],
+ [ 1, 'xx' ],
+ [ 2, str_repeat( self::getWarning( 'unstrip-size-warning', 5 ), 2 ) ]
+ ];
+ }
+
+ /** @dataProvider provideUnstripSize */
+ public function testUnstripSize( $depth, $expected ) {
+ $ss = new StripState( null, [ 'sizeLimit' => 5 ] );
+ $text = 'x';
+ for ( $i = 0; $i < $depth; $i++ ) {
+ $mk = $this->getMarker();
+ $ss->addNoWiki( $mk, $text );
+ $text = "$mk$mk";
+ }
+ $text = $ss->unstripNoWiki( $text );
+ $this->assertSame( $expected, $text );
+ }
+
+ public function provideGetLimitReport() {
+ for ( $i = 1; $i < 4; $i++ ) {
+ yield [ $i ];
+ }
+ }
+
+ /** @dataProvider provideGetLimitReport */
+ public function testGetLimitReport( $depth ) {
+ $sizeLimit = 100000;
+ $ss = new StripState( null, [ 'depthLimit' => 5, 'sizeLimit' => $sizeLimit ] );
+ $text = 'x';
+ for ( $i = 0; $i < $depth; $i++ ) {
+ $mk = $this->getMarker();
+ $ss->addNoWiki( $mk, $text );
+ $text = "$mk$mk";
+ }
+ $text = $ss->unstripNoWiki( $text );
+ $report = $ss->getLimitReport();
+ $messages = [];
+ foreach ( $report as list( $msg, $params ) ) {
+ $messages[$msg] = $params;
+ }
+ $this->assertSame( [ $depth - 1, 5 ], $messages['limitreport-unstrip-depth'] );
+ $this->assertSame(
+ [
+ strlen( $this->getMarker() ) * 2 * ( pow( 2, $depth ) - 2 ) + pow( 2, $depth ),
+ $sizeLimit
+ ],
+ $messages['limitreport-unstrip-size' ] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/parser/TagHooksTest.php b/www/wiki/tests/phpunit/includes/parser/TagHooksTest.php
new file mode 100644
index 00000000..bc09adc8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/parser/TagHooksTest.php
@@ -0,0 +1,134 @@
+<?php
+
+/**
+ * @group Database
+ * @group Parser
+ *
+ * @covers Parser
+ * @covers BlockLevelPass
+ * @covers StripState
+ *
+ * @covers Preprocessor_DOM
+ * @covers PPDStack
+ * @covers PPDStackElement
+ * @covers PPDPart
+ * @covers PPFrame_DOM
+ * @covers PPTemplateFrame_DOM
+ * @covers PPCustomFrame_DOM
+ * @covers PPNode_DOM
+ *
+ * @covers Preprocessor_Hash
+ * @covers PPDStack_Hash
+ * @covers PPDStackElement_Hash
+ * @covers PPDPart_Hash
+ * @covers PPFrame_Hash
+ * @covers PPTemplateFrame_Hash
+ * @covers PPCustomFrame_Hash
+ * @covers PPNode_Hash_Tree
+ * @covers PPNode_Hash_Text
+ * @covers PPNode_Hash_Array
+ * @covers PPNode_Hash_Attr
+ */
+class TagHooksTest extends MediaWikiTestCase {
+ public static function provideValidNames() {
+ return [
+ [ 'foo' ],
+ [ 'foo-bar' ],
+ [ 'foo_bar' ],
+ [ 'FOO-BAR' ],
+ [ 'foo bar' ]
+ ];
+ }
+
+ public static function provideBadNames() {
+ return [ [ "foo<bar" ], [ "foo>bar" ], [ "foo\nbar" ], [ "foo\rbar" ] ];
+ }
+
+ private function getParserOptions() {
+ global $wgContLang;
+ $popt = ParserOptions::newFromUserAndLang( new User, $wgContLang );
+ return $popt;
+ }
+
+ /**
+ * @dataProvider provideValidNames
+ */
+ public function testTagHooks( $tag ) {
+ global $wgParserConf;
+ $parser = new Parser( $wgParserConf );
+
+ $parser->setHook( $tag, [ $this, 'tagCallback' ] );
+ $parserOutput = $parser->parse(
+ "Foo<$tag>Bar</$tag>Baz",
+ Title::newFromText( 'Test' ),
+ $this->getParserOptions()
+ );
+ $this->assertEquals( "<p>FooOneBaz\n</p>", $parserOutput->getText( [ 'unwrap' => true ] ) );
+
+ $parser->mPreprocessor = null; # Break the Parser <-> Preprocessor cycle
+ }
+
+ /**
+ * @dataProvider provideBadNames
+ * @expectedException MWException
+ */
+ public function testBadTagHooks( $tag ) {
+ global $wgParserConf;
+ $parser = new Parser( $wgParserConf );
+
+ $parser->setHook( $tag, [ $this, 'tagCallback' ] );
+ $parser->parse(
+ "Foo<$tag>Bar</$tag>Baz",
+ Title::newFromText( 'Test' ),
+ $this->getParserOptions()
+ );
+ $this->fail( 'Exception not thrown.' );
+ }
+
+ /**
+ * @dataProvider provideValidNames
+ */
+ public function testFunctionTagHooks( $tag ) {
+ global $wgParserConf;
+ $parser = new Parser( $wgParserConf );
+
+ $parser->setFunctionTagHook( $tag, [ $this, 'functionTagCallback' ], 0 );
+ $parserOutput = $parser->parse(
+ "Foo<$tag>Bar</$tag>Baz",
+ Title::newFromText( 'Test' ),
+ $this->getParserOptions()
+ );
+ $this->assertEquals( "<p>FooOneBaz\n</p>", $parserOutput->getText( [ 'unwrap' => true ] ) );
+
+ $parser->mPreprocessor = null; # Break the Parser <-> Preprocessor cycle
+ }
+
+ /**
+ * @dataProvider provideBadNames
+ * @expectedException MWException
+ */
+ public function testBadFunctionTagHooks( $tag ) {
+ global $wgParserConf;
+ $parser = new Parser( $wgParserConf );
+
+ $parser->setFunctionTagHook(
+ $tag,
+ [ $this, 'functionTagCallback' ],
+ Parser::SFH_OBJECT_ARGS
+ );
+ $parser->parse(
+ "Foo<$tag>Bar</$tag>Baz",
+ Title::newFromText( 'Test' ),
+ $this->getParserOptions()
+ );
+ $this->fail( 'Exception not thrown.' );
+ }
+
+ function tagCallback( $text, $params, $parser ) {
+ return str_rot13( $text );
+ }
+
+ function functionTagCallback( &$parser, $frame, $code, $attribs ) {
+ return str_rot13( $code );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/parser/TidyTest.php b/www/wiki/tests/phpunit/includes/parser/TidyTest.php
new file mode 100644
index 00000000..be5125c7
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/parser/TidyTest.php
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * @group Parser
+ */
+class TidyTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ if ( !MWTidy::isEnabled() ) {
+ $this->markTestSkipped( 'Tidy not found' );
+ }
+ }
+
+ /**
+ * @dataProvider provideTestWrapping
+ */
+ public function testTidyWrapping( $expected, $text, $msg = '' ) {
+ $text = MWTidy::tidy( $text );
+ // We don't care about where Tidy wants to stick is <p>s
+ $text = trim( preg_replace( '#</?p>#', '', $text ) );
+ // Windows, we love you!
+ $text = str_replace( "\r", '', $text );
+ $this->assertEquals( $expected, $text, $msg );
+ }
+
+ public static function provideTestWrapping() {
+ $testMathML = <<<'MathML'
+<math xmlns="http://www.w3.org/1998/Math/MathML">
+ <mrow>
+ <mi>a</mi>
+ <mo>&InvisibleTimes;</mo>
+ <msup>
+ <mi>x</mi>
+ <mn>2</mn>
+ </msup>
+ <mo>+</mo>
+ <mi>b</mi>
+ <mo>&InvisibleTimes; </mo>
+ <mi>x</mi>
+ <mo>+</mo>
+ <mi>c</mi>
+ </mrow>
+ </math>
+MathML;
+ return [
+ [
+ '<mw:editsection page="foo" section="bar">foo</mw:editsection>',
+ '<mw:editsection page="foo" section="bar">foo</mw:editsection>',
+ '<mw:editsection> should survive tidy'
+ ],
+ [
+ '<editsection page="foo" section="bar">foo</editsection>',
+ '<editsection page="foo" section="bar">foo</editsection>',
+ '<editsection> should survive tidy'
+ ],
+ [ '<mw:toc>foo</mw:toc>', '<mw:toc>foo</mw:toc>', '<mw:toc> should survive tidy' ],
+ [ "<link foo=\"bar\" />foo", '<link foo="bar"/>foo', '<link> should survive tidy' ],
+ [ "<meta foo=\"bar\" />foo", '<meta foo="bar"/>foo', '<meta> should survive tidy' ],
+ [ $testMathML, $testMathML, '<math> should survive tidy' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/password/BcryptPasswordTest.php b/www/wiki/tests/phpunit/includes/password/BcryptPasswordTest.php
new file mode 100644
index 00000000..952f5417
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/password/BcryptPasswordTest.php
@@ -0,0 +1,44 @@
+<?php
+
+/**
+ * @group large
+ * @covers BcryptPassword
+ * @covers ParameterizedPassword
+ * @covers Password
+ * @covers PasswordFactory
+ */
+class BcryptPasswordTest extends PasswordTestCase {
+ protected function getTypeConfigs() {
+ return [ 'bcrypt' => [
+ 'class' => BcryptPassword::class,
+ 'cost' => 9,
+ ] ];
+ }
+
+ public static function providePasswordTests() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ // Tests from glibc bcrypt implementation
+ [ true, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW', "U*U" ],
+ [ true, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$VGOzA784oUp/Z0DY336zx7pLYAy0lwK', "U*U*" ],
+ [ true, ':bcrypt:5$XXXXXXXXXXXXXXXXXXXXXO$AcXxm9kjPGEMsLznoKqmqw7tc8WCx4a', "U*U*U" ],
+ [ true, ':bcrypt:5$abcdefghijklmnopqrstuu$5s2v8.iXieOjg/.AySBTTZIIVFJeBui', "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789chars after 72 are ignored" ],
+ [ true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$CE5elHaaO4EbggVDjb8P19RukzXSM3e', "\xff\xff\xa3" ],
+ [ true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq', "\xa3" ],
+ [ true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq', "\xa3" ],
+ [ true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$o./n25XVfn6oAPaUvHe.Csk4zRfsYPi', "\xff\xa334\xff\xff\xff\xa3345" ],
+ [ true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$nRht2l/HRhr6zmCp9vYUvvsqynflf9e', "\xff\xa3345" ],
+ [ true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$nRht2l/HRhr6zmCp9vYUvvsqynflf9e', "\xff\xa3345" ],
+ [ true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$6IflQkJytoRVc1yuaNtHfiuq.FRlSIS', "\xa3ab" ],
+ [ true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$6IflQkJytoRVc1yuaNtHfiuq.FRlSIS', "\xa3ab" ],
+ [ true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$swQOIzjOiJ9GHEPuhEkvqrUyvWhEMx6', "\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaachars after 72 are ignored as usual" ],
+ [ true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$R9xrDjiycxMbQE2bp.vgqlYpW5wx2yy', "\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55" ],
+ [ true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$9tQZzcJfm3uj2NvJ/n5xkhpqLrMpWCe', "\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff" ],
+ [ true, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$7uG0VCzI2bS7j6ymqJi9CdcdxiRTWNy', "" ],
+ // One or two false sanity tests
+ [ false, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW', "UXU" ],
+ [ false, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW', "" ],
+ ];
+ // phpcs:enable
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/password/EncryptedPasswordTest.php b/www/wiki/tests/phpunit/includes/password/EncryptedPasswordTest.php
new file mode 100644
index 00000000..6dfdea69
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/password/EncryptedPasswordTest.php
@@ -0,0 +1,84 @@
+<?php
+
+/**
+ * @covers EncryptedPassword
+ * @covers ParameterizedPassword
+ * @covers Password
+ */
+class EncryptedPasswordTest extends PasswordTestCase {
+ protected function getTypeConfigs() {
+ return [
+ 'both' => [
+ 'class' => EncryptedPassword::class,
+ 'underlying' => 'pbkdf2',
+ 'secrets' => [
+ md5( 'secret1' ),
+ md5( 'secret2' ),
+ ],
+ 'cipher' => 'aes-256-cbc',
+ ],
+ 'secret1' => [
+ 'class' => EncryptedPassword::class,
+ 'underlying' => 'pbkdf2',
+ 'secrets' => [
+ md5( 'secret1' ),
+ ],
+ 'cipher' => 'aes-256-cbc',
+ ],
+ 'secret2' => [
+ 'class' => EncryptedPassword::class,
+ 'underlying' => 'pbkdf2',
+ 'secrets' => [
+ md5( 'secret2' ),
+ ],
+ 'cipher' => 'aes-256-cbc',
+ ],
+ 'pbkdf2' => [
+ 'class' => Pbkdf2Password::class,
+ 'algo' => 'sha256',
+ 'cost' => '10',
+ 'length' => '64',
+ ],
+ ];
+ }
+
+ public static function providePasswordTests() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ // Encrypted with secret1
+ [ true, ':both:aes-256-cbc:0:izBpxujqC1YbzpCB3qAzgg==:ZqHnitT1pL4YJqKqFES2KEevZYSy2LtlibW5+IMi4XKOGKGy6sE638BXyBbLQQsBtTSrt+JyzwOayKtwIfRbaQsBridx/O1JwBSai1TkGkOsYMBXnlu2Bu/EquCBj5QpjYh7p3Uq4rpiop1KQlin1BJMwnAa1PovhxjpxnYhlhkM4X5ALoGi3XM0bapN48vt', 'password' ],
+ [ true, ':secret1:aes-256-cbc:0:izBpxujqC1YbzpCB3qAzgg==:ZqHnitT1pL4YJqKqFES2KEevZYSy2LtlibW5+IMi4XKOGKGy6sE638BXyBbLQQsBtTSrt+JyzwOayKtwIfRbaQsBridx/O1JwBSai1TkGkOsYMBXnlu2Bu/EquCBj5QpjYh7p3Uq4rpiop1KQlin1BJMwnAa1PovhxjpxnYhlhkM4X5ALoGi3XM0bapN48vt', 'password' ],
+ [ false, ':secret1:aes-256-cbc:0:izBpxujqC1YbzpCB3qAzgg==:ZqHnitT1pL4YJqKqFES2KEevZYSy2LtlibW5+IMi4XKOGKGy6sE638BXyBbLQQsBtTSrt+JyzwOayKtwIfRbaQsBridx/O1JwBSai1TkGkOsYMBXnlu2Bu/EquCBj5QpjYh7p3Uq4rpiop1KQlin1BJMwnAa1PovhxjpxnYhlhkM4X5ALoGi3XM0bapN48vt', 'notpassword' ],
+
+ // Encrypted with secret2
+ [ true, ':both:aes-256-cbc:1:m1LCnQVIakfYBNlr9KEgQg==:5yPTctqrzsybdgaMEag18AZYbnL37pAtXVBqmWxkjXbnNmiDH+1bHoL8lsEVTH/sJntC82kNVgE7zeiD8xUVLYF2VUnvB5+sU+aysE45/zwsCu7a22TaischMAOWrsHZ/tIgS/TnZY2d+HNyxgsEeeYf/QoL+FhmqHquK02+4SRbA5lLuj9niYy1r5CoM9cQ', 'password' ],
+ [ true, ':secret2:aes-256-cbc:0:m1LCnQVIakfYBNlr9KEgQg==:5yPTctqrzsybdgaMEag18AZYbnL37pAtXVBqmWxkjXbnNmiDH+1bHoL8lsEVTH/sJntC82kNVgE7zeiD8xUVLYF2VUnvB5+sU+aysE45/zwsCu7a22TaischMAOWrsHZ/tIgS/TnZY2d+HNyxgsEeeYf/QoL+FhmqHquK02+4SRbA5lLuj9niYy1r5CoM9cQ', 'password' ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * Wrong encryption key selected
+ * @expectedException PasswordError
+ */
+ public function testDecryptionError() {
+ // phpcs:ignore Generic.Files.LineLength
+ $hash = ':secret1:aes-256-cbc:0:m1LCnQVIakfYBNlr9KEgQg==:5yPTctqrzsybdgaMEag18AZYbnL37pAtXVBqmWxkjXbnNmiDH+1bHoL8lsEVTH/sJntC82kNVgE7zeiD8xUVLYF2VUnvB5+sU+aysE45/zwsCu7a22TaischMAOWrsHZ/tIgS/TnZY2d+HNyxgsEeeYf/QoL+FhmqHquK02+4SRbA5lLuj9niYy1r5CoM9cQ';
+ $password = $this->passwordFactory->newFromCiphertext( $hash );
+ $password->crypt( 'password' );
+ }
+
+ public function testUpdate() {
+ // phpcs:ignore Generic.Files.LineLength
+ $hash = ':both:aes-256-cbc:0:izBpxujqC1YbzpCB3qAzgg==:ZqHnitT1pL4YJqKqFES2KEevZYSy2LtlibW5+IMi4XKOGKGy6sE638BXyBbLQQsBtTSrt+JyzwOayKtwIfRbaQsBridx/O1JwBSai1TkGkOsYMBXnlu2Bu/EquCBj5QpjYh7p3Uq4rpiop1KQlin1BJMwnAa1PovhxjpxnYhlhkM4X5ALoGi3XM0bapN48vt';
+ $fromHash = $this->passwordFactory->newFromCiphertext( $hash );
+ $fromPlaintext = $this->passwordFactory->newFromPlaintext( 'password', $fromHash );
+ $this->assertTrue( $fromHash->update() );
+
+ $serialized = $fromHash->toString();
+ $this->assertRegExp( '/^:both:aes-256-cbc:1:/', $serialized );
+ $fromNewHash = $this->passwordFactory->newFromCiphertext( $serialized );
+ $fromPlaintext = $this->passwordFactory->newFromPlaintext( 'password', $fromNewHash );
+ $this->assertTrue( $fromHash->equals( $fromPlaintext ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php b/www/wiki/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php
new file mode 100644
index 00000000..6a965a03
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * @covers LayeredParameterizedPassword
+ * @covers Password
+ */
+class LayeredParameterizedPasswordTest extends PasswordTestCase {
+ protected function getTypeConfigs() {
+ return [
+ 'testLargeLayeredTop' => [
+ 'class' => LayeredParameterizedPassword::class,
+ 'types' => [
+ 'testLargeLayeredBottom',
+ 'testLargeLayeredBottom',
+ 'testLargeLayeredBottom',
+ 'testLargeLayeredBottom',
+ 'testLargeLayeredFinal',
+ ],
+ ],
+ 'testLargeLayeredBottom' => [
+ 'class' => Pbkdf2Password::class,
+ 'algo' => 'sha512',
+ 'cost' => 1024,
+ 'length' => 512,
+ ],
+ 'testLargeLayeredFinal' => [
+ 'class' => BcryptPassword::class,
+ 'cost' => 5,
+ ]
+ ];
+ }
+
+ protected function getValidTypes() {
+ return [ 'testLargeLayeredFinal' ];
+ }
+
+ public static function providePasswordTests() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ true,
+ ':testLargeLayeredTop:sha512:1024:512!sha512:1024:512!sha512:1024:512!sha512:1024:512!5!vnRy+2SrSA0fHt3dwhTP5g==!AVnwfZsAQjn+gULv7FSGjA==!xvHUX3WcpkeSn1lvjWcvBg==!It+OC/N9tu+d3ByHhuB0BQ==!Tb.gqUOiD.aWktVwHM.Q/O!7CcyMfXUPky5ptyATJsR2nq3vUqtnBC',
+ 'testPassword123'
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @covers LayeredParameterizedPassword::partialCrypt
+ */
+ public function testLargeLayeredPartialUpdate() {
+ /** @var ParameterizedPassword $partialPassword */
+ $partialPassword = $this->passwordFactory->newFromType( 'testLargeLayeredBottom' );
+ $partialPassword->crypt( 'testPassword123' );
+
+ /** @var LayeredParameterizedPassword $totalPassword */
+ $totalPassword = $this->passwordFactory->newFromType( 'testLargeLayeredTop' );
+ $totalPassword->partialCrypt( $partialPassword );
+
+ $this->assertTrue( $totalPassword->equals( 'testPassword123' ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/password/MWOldPasswordTest.php b/www/wiki/tests/phpunit/includes/password/MWOldPasswordTest.php
new file mode 100644
index 00000000..50100826
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/password/MWOldPasswordTest.php
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * @covers MWOldPassword
+ * @covers ParameterizedPassword
+ * @covers Password
+ */
+class MWOldPasswordTest extends PasswordTestCase {
+ protected function getTypeConfigs() {
+ return [ 'A' => [
+ 'class' => MWOldPassword::class,
+ ] ];
+ }
+
+ public static function providePasswordTests() {
+ return [
+ [ true, ':A:5f4dcc3b5aa765d61d8327deb882cf99', 'password' ],
+ // Type-B password with incorrect type name is accepted
+ [ true, ':A:salt:9842afc7cb949c440c51347ed809362f', 'password' ],
+ [ false, ':A:d529e941509eb9e9b9cfaeae1fe7ca23', 'password' ],
+ [ false, ':A:salt:d529e941509eb9e9b9cfaeae1fe7ca23', 'password' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/password/MWSaltedPasswordTest.php b/www/wiki/tests/phpunit/includes/password/MWSaltedPasswordTest.php
new file mode 100644
index 00000000..5616868d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/password/MWSaltedPasswordTest.php
@@ -0,0 +1,21 @@
+<?php
+
+/**
+ * @covers MWSaltedPassword
+ * @covers ParameterizedPassword
+ * @covers Password
+ */
+class MWSaltedPasswordTest extends PasswordTestCase {
+ protected function getTypeConfigs() {
+ return [ 'B' => [
+ 'class' => MWSaltedPassword::class,
+ ] ];
+ }
+
+ public static function providePasswordTests() {
+ return [
+ [ true, ':B:salt:9842afc7cb949c440c51347ed809362f', 'password' ],
+ [ false, ':B:salt:d529e941509eb9e9b9cfaeae1fe7ca23', 'password' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/password/PasswordFactoryTest.php b/www/wiki/tests/phpunit/includes/password/PasswordFactoryTest.php
new file mode 100644
index 00000000..01b0de2c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/password/PasswordFactoryTest.php
@@ -0,0 +1,110 @@
+<?php
+
+/**
+ * @covers PasswordFactory
+ */
+class PasswordFactoryTest extends MediaWikiTestCase {
+ public function testRegister() {
+ $pf = new PasswordFactory;
+ $pf->register( 'foo', [ 'class' => InvalidPassword::class ] );
+ $this->assertArrayHasKey( 'foo', $pf->getTypes() );
+ }
+
+ public function testSetDefaultType() {
+ $pf = new PasswordFactory;
+ $pf->register( '1', [ 'class' => InvalidPassword::class ] );
+ $pf->register( '2', [ 'class' => InvalidPassword::class ] );
+ $pf->setDefaultType( '1' );
+ $this->assertSame( '1', $pf->getDefaultType() );
+ $pf->setDefaultType( '2' );
+ $this->assertSame( '2', $pf->getDefaultType() );
+ }
+
+ /**
+ * @expectedException Exception
+ */
+ public function testSetDefaultTypeError() {
+ $pf = new PasswordFactory;
+ $pf->setDefaultType( 'bogus' );
+ }
+
+ public function testInit() {
+ $config = new HashConfig( [
+ 'PasswordConfig' => [
+ 'foo' => [ 'class' => InvalidPassword::class ],
+ ],
+ 'PasswordDefault' => 'foo'
+ ] );
+ $pf = new PasswordFactory;
+ $pf->init( $config );
+ $this->assertSame( 'foo', $pf->getDefaultType() );
+ $this->assertArrayHasKey( 'foo', $pf->getTypes() );
+ }
+
+ public function testNewFromCiphertext() {
+ $pf = new PasswordFactory;
+ $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] );
+ $pw = $pf->newFromCiphertext( ':B:salt:d529e941509eb9e9b9cfaeae1fe7ca23' );
+ $this->assertInstanceOf( MWSaltedPassword::class, $pw );
+ }
+
+ public function provideNewFromCiphertextErrors() {
+ return [ [ 'blah' ], [ ':blah:' ] ];
+ }
+
+ /**
+ * @dataProvider provideNewFromCiphertextErrors
+ * @expectedException PasswordError
+ */
+ public function testNewFromCiphertextErrors( $hash ) {
+ $pf = new PasswordFactory;
+ $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] );
+ $pf->newFromCiphertext( $hash );
+ }
+
+ public function testNewFromType() {
+ $pf = new PasswordFactory;
+ $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] );
+ $pw = $pf->newFromType( 'B' );
+ $this->assertInstanceOf( MWSaltedPassword::class, $pw );
+ }
+
+ /**
+ * @expectedException PasswordError
+ */
+ public function testNewFromTypeError() {
+ $pf = new PasswordFactory;
+ $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] );
+ $pf->newFromType( 'bogus' );
+ }
+
+ public function testNewFromPlaintext() {
+ $pf = new PasswordFactory;
+ $pf->register( 'A', [ 'class' => MWOldPassword::class ] );
+ $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] );
+ $pf->setDefaultType( 'A' );
+
+ $this->assertInstanceOf( InvalidPassword::class, $pf->newFromPlaintext( null ) );
+ $this->assertInstanceOf( MWOldPassword::class, $pf->newFromPlaintext( 'password' ) );
+ $this->assertInstanceOf( MWSaltedPassword::class,
+ $pf->newFromPlaintext( 'password', $pf->newFromType( 'B' ) ) );
+ }
+
+ public function testNeedsUpdate() {
+ $pf = new PasswordFactory;
+ $pf->register( 'A', [ 'class' => MWOldPassword::class ] );
+ $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] );
+ $pf->setDefaultType( 'A' );
+
+ $this->assertFalse( $pf->needsUpdate( $pf->newFromType( 'A' ) ) );
+ $this->assertTrue( $pf->needsUpdate( $pf->newFromType( 'B' ) ) );
+ }
+
+ public function testGenerateRandomPasswordString() {
+ $this->assertSame( 13, strlen( PasswordFactory::generateRandomPasswordString( 13 ) ) );
+ }
+
+ public function testNewInvalidPassword() {
+ $this->assertInstanceOf( InvalidPassword::class, PasswordFactory::newInvalidPassword() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/password/PasswordPolicyChecksTest.php b/www/wiki/tests/phpunit/includes/password/PasswordPolicyChecksTest.php
new file mode 100644
index 00000000..7dfb3cf5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/password/PasswordPolicyChecksTest.php
@@ -0,0 +1,159 @@
+<?php
+/**
+ * Testing password-policy check functions
+ *
+ * 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
+ */
+
+class PasswordPolicyChecksTest extends MediaWikiTestCase {
+
+ /**
+ * @covers PasswordPolicyChecks::checkMinimalPasswordLength
+ */
+ public function testCheckMinimalPasswordLength() {
+ $statusOK = PasswordPolicyChecks::checkMinimalPasswordLength(
+ 3, // policy value
+ User::newFromName( 'user' ), // User
+ 'password' // password
+ );
+ $this->assertTrue( $statusOK->isGood(), 'Password is longer than minimal policy' );
+ $statusShort = PasswordPolicyChecks::checkMinimalPasswordLength(
+ 10, // policy value
+ User::newFromName( 'user' ), // User
+ 'password' // password
+ );
+ $this->assertFalse(
+ $statusShort->isGood(),
+ 'Password is shorter than minimal policy'
+ );
+ $this->assertTrue(
+ $statusShort->isOK(),
+ 'Password is shorter than minimal policy, not fatal'
+ );
+ }
+
+ /**
+ * @covers PasswordPolicyChecks::checkMinimumPasswordLengthToLogin
+ */
+ public function testCheckMinimumPasswordLengthToLogin() {
+ $statusOK = PasswordPolicyChecks::checkMinimumPasswordLengthToLogin(
+ 3, // policy value
+ User::newFromName( 'user' ), // User
+ 'password' // password
+ );
+ $this->assertTrue( $statusOK->isGood(), 'Password is longer than minimal policy' );
+ $statusShort = PasswordPolicyChecks::checkMinimumPasswordLengthToLogin(
+ 10, // policy value
+ User::newFromName( 'user' ), // User
+ 'password' // password
+ );
+ $this->assertFalse(
+ $statusShort->isGood(),
+ 'Password is shorter than minimum login policy'
+ );
+ $this->assertFalse(
+ $statusShort->isOK(),
+ 'Password is shorter than minimum login policy, fatal'
+ );
+ }
+
+ /**
+ * @covers PasswordPolicyChecks::checkMaximalPasswordLength
+ */
+ public function testCheckMaximalPasswordLength() {
+ $statusOK = PasswordPolicyChecks::checkMaximalPasswordLength(
+ 100, // policy value
+ User::newFromName( 'user' ), // User
+ 'password' // password
+ );
+ $this->assertTrue( $statusOK->isGood(), 'Password is shorter than maximal policy' );
+ $statusLong = PasswordPolicyChecks::checkMaximalPasswordLength(
+ 4, // policy value
+ User::newFromName( 'user' ), // User
+ 'password' // password
+ );
+ $this->assertFalse( $statusLong->isGood(),
+ 'Password is longer than maximal policy'
+ );
+ $this->assertFalse( $statusLong->isOK(),
+ 'Password is longer than maximal policy, fatal'
+ );
+ }
+
+ /**
+ * @covers PasswordPolicyChecks::checkPasswordCannotMatchUsername
+ */
+ public function testCheckPasswordCannotMatchUsername() {
+ $statusOK = PasswordPolicyChecks::checkPasswordCannotMatchUsername(
+ 1, // policy value
+ User::newFromName( 'user' ), // User
+ 'password' // password
+ );
+ $this->assertTrue( $statusOK->isGood(), 'Password does not match username' );
+ $statusLong = PasswordPolicyChecks::checkPasswordCannotMatchUsername(
+ 1, // policy value
+ User::newFromName( 'user' ), // User
+ 'user' // password
+ );
+ $this->assertFalse( $statusLong->isGood(), 'Password matches username' );
+ $this->assertTrue( $statusLong->isOK(), 'Password matches username, not fatal' );
+ }
+
+ /**
+ * @covers PasswordPolicyChecks::checkPasswordCannotMatchBlacklist
+ */
+ public function testCheckPasswordCannotMatchBlacklist() {
+ $statusOK = PasswordPolicyChecks::checkPasswordCannotMatchBlacklist(
+ true, // policy value
+ User::newFromName( 'Username' ), // User
+ 'AUniquePassword' // password
+ );
+ $this->assertTrue( $statusOK->isGood(), 'Password is not on blacklist' );
+ $statusLong = PasswordPolicyChecks::checkPasswordCannotMatchBlacklist(
+ true, // policy value
+ User::newFromName( 'Useruser1' ), // User
+ 'Passpass1' // password
+ );
+ $this->assertFalse( $statusLong->isGood(), 'Password matches blacklist' );
+ $this->assertTrue( $statusLong->isOK(), 'Password matches blacklist, not fatal' );
+ }
+
+ public static function providePopularBlacklist() {
+ return [
+ [ false, 'sitename' ],
+ [ false, 'password' ],
+ [ false, '12345' ],
+ [ true, 'hqY98gCZ6qM8s8' ],
+ ];
+ }
+
+ /**
+ * @covers PasswordPolicyChecks::checkPopularPasswordBlacklist
+ * @dataProvider providePopularBlacklist
+ */
+ public function testCheckPopularPasswordBlacklist( $expected, $password ) {
+ global $IP;
+ $this->setMwGlobals( [
+ 'wgSitename' => 'sitename',
+ 'wgPopularPasswordFile' => "$IP/serialized/commonpasswords.cdb"
+ ] );
+ $user = User::newFromName( 'username' );
+ $status = PasswordPolicyChecks::checkPopularPasswordBlacklist( PHP_INT_MAX, $user, $password );
+ $this->assertSame( $expected, $status->isGood() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/password/PasswordTest.php b/www/wiki/tests/phpunit/includes/password/PasswordTest.php
new file mode 100644
index 00000000..65c91993
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/password/PasswordTest.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * Testing framework for the Password infrastructure
+ *
+ * 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
+ */
+
+/**
+ * @covers InvalidPassword
+ */
+class PasswordTest extends MediaWikiTestCase {
+ public function testInvalidUnequalInvalid() {
+ $passwordFactory = new PasswordFactory();
+ $invalid1 = $passwordFactory->newFromCiphertext( null );
+ $invalid2 = $passwordFactory->newFromCiphertext( null );
+
+ $this->assertFalse( $invalid1->equals( $invalid2 ) );
+ }
+
+ public function testInvalidPlaintext() {
+ $passwordFactory = new PasswordFactory();
+ $invalid = $passwordFactory->newFromPlaintext( null );
+
+ $this->assertInstanceOf( InvalidPassword::class, $invalid );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/password/PasswordTestCase.php b/www/wiki/tests/phpunit/includes/password/PasswordTestCase.php
new file mode 100644
index 00000000..80b9838d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/password/PasswordTestCase.php
@@ -0,0 +1,112 @@
+<?php
+/**
+ * Testing framework for the password hashes
+ *
+ * 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
+ */
+
+/**
+ * @since 1.24
+ */
+abstract class PasswordTestCase extends MediaWikiTestCase {
+ /**
+ * @var PasswordFactory
+ */
+ protected $passwordFactory;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->passwordFactory = new PasswordFactory();
+ foreach ( $this->getTypeConfigs() as $type => $config ) {
+ $this->passwordFactory->register( $type, $config );
+ }
+ }
+
+ /**
+ * Return an array of configs to be used for this class's password type.
+ *
+ * @return array[]
+ */
+ abstract protected function getTypeConfigs();
+
+ /**
+ * An array of tests in the form of (bool, string, string), where the first
+ * element is whether the second parameter (a password hash) and the third
+ * parameter (a password) should match.
+ * @return array
+ * @throws MWException
+ * @abstract
+ */
+ public static function providePasswordTests() {
+ throw new MWException( "Not implemented" );
+ }
+
+ /**
+ * @dataProvider providePasswordTests
+ */
+ public function testHashing( $shouldMatch, $hash, $password ) {
+ $hash = $this->passwordFactory->newFromCiphertext( $hash );
+ $password = $this->passwordFactory->newFromPlaintext( $password, $hash );
+ $this->assertSame( $shouldMatch, $hash->equals( $password ) );
+ }
+
+ /**
+ * @dataProvider providePasswordTests
+ */
+ public function testStringSerialization( $shouldMatch, $hash, $password ) {
+ $hashObj = $this->passwordFactory->newFromCiphertext( $hash );
+ $serialized = $hashObj->toString();
+ $unserialized = $this->passwordFactory->newFromCiphertext( $serialized );
+ $this->assertTrue( $hashObj->equals( $unserialized ) );
+ }
+
+ /**
+ * @dataProvider providePasswordTests
+ * @covers InvalidPassword
+ */
+ public function testInvalidUnequalNormal( $shouldMatch, $hash, $password ) {
+ $invalid = $this->passwordFactory->newFromCiphertext( null );
+ $normal = $this->passwordFactory->newFromCiphertext( $hash );
+
+ $this->assertFalse( $invalid->equals( $normal ) );
+ $this->assertFalse( $normal->equals( $invalid ) );
+ }
+
+ protected function getValidTypes() {
+ return array_keys( $this->getTypeConfigs() );
+ }
+
+ public function provideTypes( $type ) {
+ $params = [];
+ foreach ( $this->getValidTypes() as $type ) {
+ $params[] = [ $type ];
+ }
+ return $params;
+ }
+
+ /**
+ * @dataProvider provideTypes
+ */
+ public function testCrypt( $type ) {
+ $fromType = $this->passwordFactory->newFromType( $type );
+ $fromType->crypt( 'password' );
+ $fromPlaintext = $this->passwordFactory->newFromPlaintext( 'password', $fromType );
+ $this->assertTrue( $fromType->equals( $fromPlaintext ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/password/Pbkdf2PasswordFallbackTest.php b/www/wiki/tests/phpunit/includes/password/Pbkdf2PasswordFallbackTest.php
new file mode 100644
index 00000000..cf851c81
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/password/Pbkdf2PasswordFallbackTest.php
@@ -0,0 +1,29 @@
+<?php
+
+
+/**
+ * @group large
+ * @covers Pbkdf2Password
+ */
+class Pbkdf2PasswordFallbackTest extends PasswordTestCase {
+ protected function getTypeConfigs() {
+ return [
+ 'pbkdf2' => [
+ 'class' => Pbkdf2Password::class,
+ 'algo' => 'sha256',
+ 'cost' => '10000',
+ 'length' => '128',
+ 'use-hash-extension' => false,
+ ],
+ ];
+ }
+
+ public static function providePasswordTests() {
+ return [
+ [ true, ":pbkdf2:sha1:1:20:c2FsdA==:DGDID5YfDnHzqbUkr2ASBi/gN6Y=", 'password' ],
+ [ true, ":pbkdf2:sha1:2:20:c2FsdA==:6mwBTcctb4zNHtkqzh1B8NjeiVc=", 'password' ],
+ [ true, ":pbkdf2:sha1:4096:20:c2FsdA==:SwB5AbdlSJq+rUnZJvch0GWkKcE=", 'password' ],
+ [ true, ":pbkdf2:sha1:4096:16:c2EAbHQ=:Vvpqp1VICZ3MN9fwNCXgww==", "pass\x00word" ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/password/Pbkdf2PasswordTest.php b/www/wiki/tests/phpunit/includes/password/Pbkdf2PasswordTest.php
new file mode 100644
index 00000000..7e97ab1a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/password/Pbkdf2PasswordTest.php
@@ -0,0 +1,29 @@
+<?php
+
+/**
+ * @group large
+ * @covers Pbkdf2Password
+ * @covers Password
+ * @covers ParameterizedPassword
+ * @requires function hash_pbkdf2
+ */
+class Pbkdf2PasswordTest extends PasswordTestCase {
+ protected function getTypeConfigs() {
+ return [ 'pbkdf2' => [
+ 'class' => Pbkdf2Password::class,
+ 'algo' => 'sha256',
+ 'cost' => '10000',
+ 'length' => '128',
+ 'use-hash-extension' => true,
+ ] ];
+ }
+
+ public static function providePasswordTests() {
+ return [
+ [ true, ":pbkdf2:sha1:1:20:c2FsdA==:DGDID5YfDnHzqbUkr2ASBi/gN6Y=", 'password' ],
+ [ true, ":pbkdf2:sha1:2:20:c2FsdA==:6mwBTcctb4zNHtkqzh1B8NjeiVc=", 'password' ],
+ [ true, ":pbkdf2:sha1:4096:20:c2FsdA==:SwB5AbdlSJq+rUnZJvch0GWkKcE=", 'password' ],
+ [ true, ":pbkdf2:sha1:4096:16:c2EAbHQ=:Vvpqp1VICZ3MN9fwNCXgww==", "pass\x00word" ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/password/UserPasswordPolicyTest.php b/www/wiki/tests/phpunit/includes/password/UserPasswordPolicyTest.php
new file mode 100644
index 00000000..78175fac
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/password/UserPasswordPolicyTest.php
@@ -0,0 +1,232 @@
+<?php
+/**
+ * Testing for password-policy enforcement, based on a user's groups.
+ *
+ * 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
+ */
+
+/**
+ * @group Database
+ * @covers UserPasswordPolicy
+ */
+class UserPasswordPolicyTest extends MediaWikiTestCase {
+
+ protected $tablesUsed = [ 'user', 'user_groups' ];
+
+ protected $policies = [
+ 'checkuser' => [
+ 'MinimalPasswordLength' => 10,
+ 'MinimumPasswordLengthToLogin' => 6,
+ 'PasswordCannotMatchUsername' => true,
+ ],
+ 'sysop' => [
+ 'MinimalPasswordLength' => 8,
+ 'MinimumPasswordLengthToLogin' => 1,
+ 'PasswordCannotMatchUsername' => true,
+ ],
+ 'default' => [
+ 'MinimalPasswordLength' => 4,
+ 'MinimumPasswordLengthToLogin' => 1,
+ 'PasswordCannotMatchBlacklist' => true,
+ 'MaximalPasswordLength' => 4096,
+ ],
+ ];
+
+ protected $checks = [
+ 'MinimalPasswordLength' => 'PasswordPolicyChecks::checkMinimalPasswordLength',
+ 'MinimumPasswordLengthToLogin' => 'PasswordPolicyChecks::checkMinimumPasswordLengthToLogin',
+ 'PasswordCannotMatchUsername' => 'PasswordPolicyChecks::checkPasswordCannotMatchUsername',
+ 'PasswordCannotMatchBlacklist' => 'PasswordPolicyChecks::checkPasswordCannotMatchBlacklist',
+ 'MaximalPasswordLength' => 'PasswordPolicyChecks::checkMaximalPasswordLength',
+ ];
+
+ private function getUserPasswordPolicy() {
+ return new UserPasswordPolicy( $this->policies, $this->checks );
+ }
+
+ public function testGetPoliciesForUser() {
+ $upp = $this->getUserPasswordPolicy();
+
+ $user = User::newFromName( 'TestUserPolicy' );
+ $user->addToDatabase();
+ $user->addGroup( 'sysop' );
+
+ $this->assertArrayEquals(
+ [
+ 'MinimalPasswordLength' => 8,
+ 'MinimumPasswordLengthToLogin' => 1,
+ 'PasswordCannotMatchUsername' => 1,
+ 'PasswordCannotMatchBlacklist' => true,
+ 'MaximalPasswordLength' => 4096,
+ ],
+ $upp->getPoliciesForUser( $user )
+ );
+ }
+
+ public function testGetPoliciesForGroups() {
+ $effective = UserPasswordPolicy::getPoliciesForGroups(
+ $this->policies,
+ [ 'user', 'checkuser' ],
+ $this->policies['default']
+ );
+
+ $this->assertArrayEquals(
+ [
+ 'MinimalPasswordLength' => 10,
+ 'MinimumPasswordLengthToLogin' => 6,
+ 'PasswordCannotMatchUsername' => true,
+ 'PasswordCannotMatchBlacklist' => true,
+ 'MaximalPasswordLength' => 4096,
+ ],
+ $effective
+ );
+ }
+
+ /**
+ * @dataProvider provideCheckUserPassword
+ */
+ public function testCheckUserPassword( $username, $groups, $password, $valid, $ok, $msg ) {
+ $upp = $this->getUserPasswordPolicy();
+
+ $user = User::newFromName( $username );
+ $user->addToDatabase();
+ foreach ( $groups as $group ) {
+ $user->addGroup( $group );
+ }
+
+ $status = $upp->checkUserPassword( $user, $password );
+ $this->assertSame( $valid, $status->isGood(), $msg . ' - password valid' );
+ $this->assertSame( $ok, $status->isOK(), $msg . ' - can login' );
+ }
+
+ public function provideCheckUserPassword() {
+ return [
+ [
+ 'PassPolicyUser',
+ [],
+ '',
+ false,
+ false,
+ 'No groups, default policy, password too short to login'
+ ],
+ [
+ 'PassPolicyUser',
+ [ 'user' ],
+ 'aaa',
+ false,
+ true,
+ 'Default policy, short password'
+ ],
+ [
+ 'PassPolicyUser',
+ [ 'sysop' ],
+ 'abcdabcdabcd',
+ true,
+ true,
+ 'Sysop with good password'
+ ],
+ [
+ 'PassPolicyUser',
+ [ 'sysop' ],
+ 'abcd',
+ false,
+ true,
+ 'Sysop with short password'
+ ],
+ [
+ 'PassPolicyUser',
+ [ 'sysop', 'checkuser' ],
+ 'abcdabcd',
+ false,
+ true,
+ 'Checkuser with short password'
+ ],
+ [
+ 'PassPolicyUser',
+ [ 'sysop', 'checkuser' ],
+ 'abcd',
+ false,
+ false,
+ 'Checkuser with too short password to login'
+ ],
+ [
+ 'Useruser',
+ [ 'user' ],
+ 'Passpass',
+ false,
+ true,
+ 'Username & password on blacklist'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideMaxOfPolicies
+ */
+ public function testMaxOfPolicies( $p1, $p2, $max, $msg ) {
+ $this->assertArrayEquals(
+ $max,
+ UserPasswordPolicy::maxOfPolicies( $p1, $p2 ),
+ $msg
+ );
+ }
+
+ public function provideMaxOfPolicies() {
+ return [
+ [
+ [ 'MinimalPasswordLength' => 8 ], // p1
+ [ 'MinimalPasswordLength' => 2 ], // p2
+ [ 'MinimalPasswordLength' => 8 ], // max
+ 'Basic max in p1'
+ ],
+ [
+ [ 'MinimalPasswordLength' => 2 ], // p1
+ [ 'MinimalPasswordLength' => 8 ], // p2
+ [ 'MinimalPasswordLength' => 8 ], // max
+ 'Basic max in p2'
+ ],
+ [
+ [ 'MinimalPasswordLength' => 8 ], // p1
+ [
+ 'MinimalPasswordLength' => 2,
+ 'PasswordCannotMatchUsername' => 1,
+ ], // p2
+ [
+ 'MinimalPasswordLength' => 8,
+ 'PasswordCannotMatchUsername' => 1,
+ ], // max
+ 'Missing items in p1'
+ ],
+ [
+ [
+ 'MinimalPasswordLength' => 8,
+ 'PasswordCannotMatchUsername' => 1,
+ ], // p1
+ [
+ 'MinimalPasswordLength' => 2,
+ ], // p2
+ [
+ 'MinimalPasswordLength' => 8,
+ 'PasswordCannotMatchUsername' => 1,
+ ], // max
+ 'Missing items in p2'
+ ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/poolcounter/PoolCounterTest.php b/www/wiki/tests/phpunit/includes/poolcounter/PoolCounterTest.php
new file mode 100644
index 00000000..f7f2013c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/poolcounter/PoolCounterTest.php
@@ -0,0 +1,84 @@
+<?php
+
+// We will use this class with getMockForAbstractClass to create a concrete mock class.
+// That call will die if the contructor is not public, unless we use disableOriginalConstructor(),
+// in which case we could not test the constructor.
+abstract class PoolCounterAbstractMock extends PoolCounter {
+ public function __construct() {
+ call_user_func_array( 'parent::__construct', func_get_args() );
+ }
+}
+
+/**
+ * @covers PoolCounter
+ */
+class PoolCounterTest extends MediaWikiTestCase {
+ public function testConstruct() {
+ $poolCounterConfig = [
+ 'class' => 'PoolCounterMock',
+ 'timeout' => 10,
+ 'workers' => 10,
+ 'maxqueue' => 100,
+ ];
+
+ $poolCounter = $this->getMockBuilder( PoolCounterAbstractMock::class )
+ ->setConstructorArgs( [ $poolCounterConfig, 'testCounter', 'someKey' ] )
+ // don't mock anything - the proper syntax would be setMethods(null), but due
+ // to a PHPUnit bug that does not work with getMockForAbstractClass()
+ ->setMethods( [ 'idontexist' ] )
+ ->getMockForAbstractClass();
+ $this->assertInstanceOf( PoolCounter::class, $poolCounter );
+ }
+
+ public function testConstructWithSlots() {
+ $poolCounterConfig = [
+ 'class' => 'PoolCounterMock',
+ 'timeout' => 10,
+ 'workers' => 10,
+ 'slots' => 2,
+ 'maxqueue' => 100,
+ ];
+
+ $poolCounter = $this->getMockBuilder( PoolCounterAbstractMock::class )
+ ->setConstructorArgs( [ $poolCounterConfig, 'testCounter', 'key' ] )
+ ->setMethods( [ 'idontexist' ] ) // don't mock anything
+ ->getMockForAbstractClass();
+ $this->assertInstanceOf( PoolCounter::class, $poolCounter );
+ }
+
+ public function testHashKeyIntoSlots() {
+ $poolCounter = $this->getMockBuilder( PoolCounterAbstractMock::class )
+ // don't mock anything - the proper syntax would be setMethods(null), but due
+ // to a PHPUnit bug that does not work with getMockForAbstractClass()
+ ->setMethods( [ 'idontexist' ] )
+ ->disableOriginalConstructor()
+ ->getMockForAbstractClass();
+
+ $hashKeyIntoSlots = new ReflectionMethod( $poolCounter, 'hashKeyIntoSlots' );
+ $hashKeyIntoSlots->setAccessible( true );
+
+ $keysWithTwoSlots = $keysWithFiveSlots = [];
+ foreach ( range( 1, 100 ) as $i ) {
+ $keysWithTwoSlots[] = $hashKeyIntoSlots->invoke( $poolCounter, 'test', 'key ' . $i, 2 );
+ $keysWithFiveSlots[] = $hashKeyIntoSlots->invoke( $poolCounter, 'test', 'key ' . $i, 5 );
+ }
+
+ $twoSlotKeys = [];
+ for ( $i = 0; $i <= 1; $i++ ) {
+ $twoSlotKeys[] = "test:$i";
+ }
+ $fiveSlotKeys = [];
+ for ( $i = 0; $i <= 4; $i++ ) {
+ $fiveSlotKeys[] = "test:$i";
+ }
+
+ $this->assertArrayEquals( $twoSlotKeys, array_unique( $keysWithTwoSlots ) );
+ $this->assertArrayEquals( $fiveSlotKeys, array_unique( $keysWithFiveSlots ) );
+
+ // make sure it is deterministic
+ $this->assertEquals(
+ $hashKeyIntoSlots->invoke( $poolCounter, 'test', 'asdfgh', 1000 ),
+ $hashKeyIntoSlots->invoke( $poolCounter, 'test', 'asdfgh', 1000 )
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/preferences/DefaultPreferencesFactoryTest.php b/www/wiki/tests/phpunit/includes/preferences/DefaultPreferencesFactoryTest.php
new file mode 100644
index 00000000..c1015234
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/preferences/DefaultPreferencesFactoryTest.php
@@ -0,0 +1,183 @@
+<?php
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Preferences\DefaultPreferencesFactory;
+use Wikimedia\ObjectFactory;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * 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
+ */
+
+/**
+ * @group Preferences
+ */
+class DefaultPreferencesFactoryTest extends MediaWikiTestCase {
+
+ /** @var IContextSource */
+ protected $context;
+
+ /** @var Config */
+ protected $config;
+
+ public function setUp() {
+ parent::setUp();
+ global $wgParserConf;
+ $this->context = new RequestContext();
+ $this->context->setTitle( Title::newFromText( self::class ) );
+ $this->setMwGlobals( 'wgParser',
+ ObjectFactory::constructClassInstance( $wgParserConf['class'], [ $wgParserConf ] )
+ );
+ $this->config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+
+ /**
+ * Get a basic PreferencesFactory for testing with.
+ * @return DefaultPreferencesFactory
+ */
+ protected function getPreferencesFactory() {
+ return new DefaultPreferencesFactory(
+ $this->config,
+ new Language(),
+ AuthManager::singleton(),
+ MediaWikiServices::getInstance()->getLinkRenderer()
+ );
+ }
+
+ /**
+ * @covers MediaWiki\Preferences\DefaultPreferencesFactory::getForm()
+ */
+ public function testGetForm() {
+ $testUser = $this->getTestUser();
+ $form = $this->getPreferencesFactory()->getForm( $testUser->getUser(), $this->context );
+ $this->assertInstanceOf( PreferencesForm::class, $form );
+ $this->assertCount( 5, $form->getPreferenceSections() );
+ }
+
+ /**
+ * CSS classes for emailauthentication preference field when there's no email.
+ * @see https://phabricator.wikimedia.org/T36302
+ * @covers MediaWiki\Preferences\DefaultPreferencesFactory::profilePreferences()
+ * @dataProvider emailAuthenticationProvider
+ */
+ public function testEmailAuthentication( $user, $cssClass ) {
+ $prefs = $this->getPreferencesFactory()->getFormDescriptor( $user, $this->context );
+ $this->assertArrayHasKey( 'cssclass', $prefs['emailauthentication'] );
+ $this->assertEquals( $cssClass, $prefs['emailauthentication']['cssclass'] );
+ }
+
+ public function emailAuthenticationProvider() {
+ $userNoEmail = new User;
+ $userEmailUnauthed = new User;
+ $userEmailUnauthed->setEmail( 'noauth@example.org' );
+ $userEmailAuthed = new User;
+ $userEmailAuthed->setEmail( 'noauth@example.org' );
+ $userEmailAuthed->setEmailAuthenticationTimestamp( wfTimestamp() );
+ return [
+ [ $userNoEmail, 'mw-email-none' ],
+ [ $userEmailUnauthed, 'mw-email-not-authenticated' ],
+ [ $userEmailAuthed, 'mw-email-authenticated' ],
+ ];
+ }
+
+ /**
+ * Test that PreferencesFormPreSave hook has correct data:
+ * - user Object is passed
+ * - oldUserOptions contains previous user options (before save)
+ * - formData and User object have set up new properties
+ *
+ * @see https://phabricator.wikimedia.org/T169365
+ * @covers MediaWiki\Preferences\DefaultPreferencesFactory::submitForm()
+ */
+ public function testPreferencesFormPreSaveHookHasCorrectData() {
+ $oldOptions = [
+ 'test' => 'abc',
+ 'option' => 'old'
+ ];
+ $newOptions = [
+ 'test' => 'abc',
+ 'option' => 'new'
+ ];
+ $configMock = new HashConfig( [
+ 'HiddenPrefs' => []
+ ] );
+ $form = $this->getMockBuilder( PreferencesForm::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $userMock = $this->getMockBuilder( User::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $userMock->method( 'getOptions' )
+ ->willReturn( $oldOptions );
+ $userMock->method( 'isAllowedAny' )
+ ->willReturn( true );
+ $userMock->method( 'isAllowed' )
+ ->willReturn( true );
+
+ $userMock->expects( $this->exactly( 2 ) )
+ ->method( 'setOption' )
+ ->withConsecutive(
+ [ $this->equalTo( 'test' ), $this->equalTo( $newOptions[ 'test' ] ) ],
+ [ $this->equalTo( 'option' ), $this->equalTo( $newOptions[ 'option' ] ) ]
+ );
+
+ $form->expects( $this->any() )
+ ->method( 'getModifiedUser' )
+ ->willReturn( $userMock );
+
+ $form->expects( $this->any() )
+ ->method( 'getContext' )
+ ->willReturn( $this->context );
+
+ $form->expects( $this->any() )
+ ->method( 'getConfig' )
+ ->willReturn( $configMock );
+
+ $this->setTemporaryHook( 'PreferencesFormPreSave',
+ function ( $formData, $form, $user, &$result, $oldUserOptions )
+ use ( $newOptions, $oldOptions, $userMock ) {
+ $this->assertSame( $userMock, $user );
+ foreach ( $newOptions as $option => $value ) {
+ $this->assertSame( $value, $formData[ $option ] );
+ }
+ foreach ( $oldOptions as $option => $value ) {
+ $this->assertSame( $value, $oldUserOptions[ $option ] );
+ }
+ $this->assertEquals( true, $result );
+ }
+ );
+
+ $factory = TestingAccessWrapper::newFromObject( $this->getPreferencesFactory() );
+ $factory->saveFormData( $newOptions, $form );
+ }
+
+ /**
+ * The rclimit preference should accept non-integer input and filter it to become an integer.
+ */
+ public function testIntvalFilter() {
+ // Test a string with leading zeros (i.e. not octal) and spaces.
+ $this->context->getRequest()->setVal( 'wprclimit', ' 0012 ' );
+ $user = new User;
+ $form = $this->getPreferencesFactory()->getForm( $user, $this->context );
+ $form->show();
+ $form->trySubmit();
+ $this->assertEquals( 12, $user->getOption( 'rclimit' ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/rcfeed/RCFeedIntegrationTest.php b/www/wiki/tests/phpunit/includes/rcfeed/RCFeedIntegrationTest.php
new file mode 100644
index 00000000..871ea911
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/rcfeed/RCFeedIntegrationTest.php
@@ -0,0 +1,98 @@
+<?php
+
+/**
+ * @group medium
+ * @group Database
+ * @covers FormattedRCFeed
+ * @covers RecentChange
+ * @covers JSONRCFeedFormatter
+ * @covers MachineReadableRCFeedFormatter
+ * @covers RCFeed
+ */
+class RCFeedIntegrationTest extends MediaWikiTestCase {
+ protected function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( [
+ 'wgCanonicalServer' => 'https://example.org',
+ 'wgServerName' => 'example.org',
+ 'wgScriptPath' => '/w',
+ 'wgDBname' => 'example',
+ 'wgDBprefix' => '',
+ 'wgRCFeeds' => [],
+ 'wgRCEngines' => [],
+ ] );
+ }
+
+ public function testNotify() {
+ $feed = $this->getMockBuilder( RCFeedEngine::class )
+ ->setConstructorArgs( [ [ 'formatter' => JSONRCFeedFormatter::class ] ] )
+ ->setMethods( [ 'send' ] )
+ ->getMock();
+
+ $feed->method( 'send' )
+ ->willReturn( true );
+
+ $feed->expects( $this->once() )
+ ->method( 'send' )
+ ->with( $this->anything(), $this->callback( function ( $line ) {
+ $this->assertJsonStringEqualsJsonString(
+ json_encode( [
+ 'id' => null,
+ 'type' => 'log',
+ 'namespace' => 0,
+ 'title' => 'Example',
+ 'comment' => '',
+ 'timestamp' => 1301644800,
+ 'user' => 'UTSysop',
+ 'bot' => false,
+ 'log_id' => 0,
+ 'log_type' => 'move',
+ 'log_action' => 'move',
+ 'log_params' => [
+ 'color' => 'green',
+ 'nr' => 42,
+ 'pet' => 'cat',
+ ],
+ 'log_action_comment' => '',
+ 'server_url' => 'https://example.org',
+ 'server_name' => 'example.org',
+ 'server_script_path' => '/w',
+ 'wiki' => 'example',
+ ] ),
+ $line
+ );
+ return true;
+ } ) );
+
+ $this->setMwGlobals( [
+ 'wgRCFeeds' => [
+ 'myfeed' => [
+ 'uri' => 'test://localhost:1234',
+ 'formatter' => JSONRCFeedFormatter::class,
+ ],
+ ],
+ 'wgRCEngines' => [
+ 'test' => $feed,
+ ],
+ ] );
+ $logpage = SpecialPage::getTitleFor( 'Log', 'move' );
+ $user = $this->getTestSysop()->getUser();
+ $rc = RecentChange::newLogEntry(
+ '20110401080000',
+ $logpage, // &$title
+ $user, // &$user
+ '', // $actionComment
+ '127.0.0.1', // $ip
+ 'move', // $type
+ 'move', // $action
+ Title::makeTitle( 0, 'Example' ), // $target
+ '', // $logComment
+ LogEntryBase::makeParamBlob( [
+ '4::color' => 'green',
+ '5:number:nr' => 42,
+ 'pet' => 'cat',
+ ] )
+ );
+ $rc->notifyRCFeeds();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/registration/ExtensionJsonValidatorTest.php b/www/wiki/tests/phpunit/includes/registration/ExtensionJsonValidatorTest.php
new file mode 100644
index 00000000..d69ad597
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/registration/ExtensionJsonValidatorTest.php
@@ -0,0 +1,84 @@
+<?php
+/**
+ * Copyright (C) 2018 Kunal Mehta <legoktm@member.fsf.org>
+ *
+ * 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.
+ *
+ */
+
+/**
+ * @covers ExtensionJsonValidator
+ */
+class ExtensionJsonValidatorTest extends MediaWikiTestCase {
+
+ /**
+ * @dataProvider provideValidate
+ */
+ public function testValidate( $file, $expected ) {
+ // If a dependency is missing, skip this test.
+ $validator = new ExtensionJsonValidator( function ( $msg ) {
+ $this->markTestSkipped( $msg );
+ } );
+
+ if ( is_string( $expected ) ) {
+ $this->setExpectedException(
+ ExtensionJsonValidationError::class,
+ $expected
+ );
+ }
+
+ $dir = __DIR__ . '/../../data/registration/';
+ $this->assertSame(
+ $expected,
+ $validator->validate( $dir . $file )
+ );
+ }
+
+ public function provideValidate() {
+ return [
+ [
+ 'notjson.txt',
+ 'notjson.txt is not valid JSON'
+ ],
+ [
+ 'no_manifest_version.json',
+ 'no_manifest_version.json does not have manifest_version set.'
+ ],
+ [
+ 'old_manifest_version.json',
+ 'old_manifest_version.json is using a non-supported schema version'
+ ],
+ [
+ 'newer_manifest_version.json',
+ 'newer_manifest_version.json is using a non-supported schema version'
+ ],
+ [
+ 'bad_spdx.json',
+ "bad_spdx.json did not pass validation.
+[license-name] Invalid SPDX license identifier, see <https://spdx.org/licenses/>"
+ ],
+ [
+ 'invalid.json',
+ "invalid.json did not pass validation.
+[license-name] Array value found, but a string is required"
+ ],
+ [
+ 'good.json',
+ true
+ ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/registration/ExtensionProcessorTest.php b/www/wiki/tests/phpunit/includes/registration/ExtensionProcessorTest.php
new file mode 100644
index 00000000..d9e091dc
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/registration/ExtensionProcessorTest.php
@@ -0,0 +1,742 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers ExtensionProcessor
+ */
+class ExtensionProcessorTest extends MediaWikiTestCase {
+
+ private $dir, $dirname;
+
+ public function setUp() {
+ parent::setUp();
+ $this->dir = __DIR__ . '/FooBar/extension.json';
+ $this->dirname = dirname( $this->dir );
+ }
+
+ /**
+ * 'name' is absolutely required
+ *
+ * @var array
+ */
+ public static $default = [
+ 'name' => 'FooBar',
+ ];
+
+ public function testExtractInfo() {
+ // Test that attributes that begin with @ are ignored
+ $processor = new ExtensionProcessor();
+ $processor->extractInfo( $this->dir, self::$default + [
+ '@metadata' => [ 'foobarbaz' ],
+ 'AnAttribute' => [ 'omg' ],
+ 'AutoloadClasses' => [ 'FooBar' => 'includes/FooBar.php' ],
+ 'SpecialPages' => [ 'Foo' => 'SpecialFoo' ],
+ 'callback' => 'FooBar::onRegistration',
+ ], 1 );
+
+ $extracted = $processor->getExtractedInfo();
+ $attributes = $extracted['attributes'];
+ $this->assertArrayHasKey( 'AnAttribute', $attributes );
+ $this->assertArrayNotHasKey( '@metadata', $attributes );
+ $this->assertArrayNotHasKey( 'AutoloadClasses', $attributes );
+ $this->assertSame(
+ [ 'FooBar' => 'FooBar::onRegistration' ],
+ $extracted['callbacks']
+ );
+ $this->assertSame(
+ [ 'Foo' => 'SpecialFoo' ],
+ $extracted['globals']['wgSpecialPages']
+ );
+ }
+
+ public function testExtractNamespaces() {
+ // Test that namespace IDs can be overwritten
+ if ( !defined( 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X' ) ) {
+ define( 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', 123456 );
+ }
+
+ $processor = new ExtensionProcessor();
+ $processor->extractInfo( $this->dir, self::$default + [
+ 'namespaces' => [
+ [
+ 'id' => 332200,
+ 'constant' => 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A',
+ 'name' => 'Test_A',
+ 'defaultcontentmodel' => 'TestModel',
+ 'gender' => [
+ 'male' => 'Male test',
+ 'female' => 'Female test',
+ ],
+ 'subpages' => true,
+ 'content' => true,
+ 'protection' => 'userright',
+ ],
+ [ // Test_X will use ID 123456 not 334400
+ 'id' => 334400,
+ 'constant' => 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X',
+ 'name' => 'Test_X',
+ 'defaultcontentmodel' => 'TestModel'
+ ],
+ ]
+ ], 1 );
+
+ $extracted = $processor->getExtractedInfo();
+
+ $this->assertArrayHasKey(
+ 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A',
+ $extracted['defines']
+ );
+ $this->assertArrayNotHasKey(
+ 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X',
+ $extracted['defines']
+ );
+
+ $this->assertSame(
+ $extracted['defines']['MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A'],
+ 332200
+ );
+
+ $this->assertArrayHasKey( 'ExtensionNamespaces', $extracted['attributes'] );
+ $this->assertArrayHasKey( 123456, $extracted['attributes']['ExtensionNamespaces'] );
+ $this->assertArrayHasKey( 332200, $extracted['attributes']['ExtensionNamespaces'] );
+ $this->assertArrayNotHasKey( 334400, $extracted['attributes']['ExtensionNamespaces'] );
+
+ $this->assertSame( 'Test_X', $extracted['attributes']['ExtensionNamespaces'][123456] );
+ $this->assertSame( 'Test_A', $extracted['attributes']['ExtensionNamespaces'][332200] );
+ $this->assertSame(
+ [ 'male' => 'Male test', 'female' => 'Female test' ],
+ $extracted['globals']['wgExtraGenderNamespaces'][332200]
+ );
+ // A has subpages, X does not
+ $this->assertTrue( $extracted['globals']['wgNamespacesWithSubpages'][332200] );
+ $this->assertArrayNotHasKey( 123456, $extracted['globals']['wgNamespacesWithSubpages'] );
+ }
+
+ public static function provideRegisterHooks() {
+ $merge = [ ExtensionRegistry::MERGE_STRATEGY => 'array_merge_recursive' ];
+ // Format:
+ // Current $wgHooks
+ // Content in extension.json
+ // Expected value of $wgHooks
+ return [
+ // No hooks
+ [
+ [],
+ self::$default,
+ $merge,
+ ],
+ // No current hooks, adding one for "FooBaz" in string format
+ [
+ [],
+ [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default,
+ [ 'FooBaz' => [ 'FooBazCallback' ] ] + $merge,
+ ],
+ // Hook for "FooBaz", adding another one
+ [
+ [ 'FooBaz' => [ 'PriorCallback' ] ],
+ [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default,
+ [ 'FooBaz' => [ 'PriorCallback', 'FooBazCallback' ] ] + $merge,
+ ],
+ // No current hooks, adding one for "FooBaz" in verbose array format
+ [
+ [],
+ [ 'Hooks' => [ 'FooBaz' => [ 'FooBazCallback' ] ] ] + self::$default,
+ [ 'FooBaz' => [ 'FooBazCallback' ] ] + $merge,
+ ],
+ // Hook for "BarBaz", adding one for "FooBaz"
+ [
+ [ 'BarBaz' => [ 'BarBazCallback' ] ],
+ [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default,
+ [
+ 'BarBaz' => [ 'BarBazCallback' ],
+ 'FooBaz' => [ 'FooBazCallback' ],
+ ] + $merge,
+ ],
+ // Callbacks for FooBaz wrapped in an array
+ [
+ [],
+ [ 'Hooks' => [ 'FooBaz' => [ 'Callback1' ] ] ] + self::$default,
+ [
+ 'FooBaz' => [ 'Callback1' ],
+ ] + $merge,
+ ],
+ // Multiple callbacks for FooBaz hook
+ [
+ [],
+ [ 'Hooks' => [ 'FooBaz' => [ 'Callback1', 'Callback2' ] ] ] + self::$default,
+ [
+ 'FooBaz' => [ 'Callback1', 'Callback2' ],
+ ] + $merge,
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideRegisterHooks
+ */
+ public function testRegisterHooks( $pre, $info, $expected ) {
+ $processor = new MockExtensionProcessor( [ 'wgHooks' => $pre ] );
+ $processor->extractInfo( $this->dir, $info, 1 );
+ $extracted = $processor->getExtractedInfo();
+ $this->assertEquals( $expected, $extracted['globals']['wgHooks'] );
+ }
+
+ public function testExtractConfig1() {
+ $processor = new ExtensionProcessor;
+ $info = [
+ 'config' => [
+ 'Bar' => 'somevalue',
+ 'Foo' => 10,
+ '@IGNORED' => 'yes',
+ ],
+ ] + self::$default;
+ $info2 = [
+ 'config' => [
+ '_prefix' => 'eg',
+ 'Bar' => 'somevalue'
+ ],
+ 'name' => 'FooBar2',
+ ];
+ $processor->extractInfo( $this->dir, $info, 1 );
+ $processor->extractInfo( $this->dir, $info2, 1 );
+ $extracted = $processor->getExtractedInfo();
+ $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] );
+ $this->assertEquals( 10, $extracted['globals']['wgFoo'] );
+ $this->assertArrayNotHasKey( 'wg@IGNORED', $extracted['globals'] );
+ // Custom prefix:
+ $this->assertEquals( 'somevalue', $extracted['globals']['egBar'] );
+ }
+
+ public function testExtractConfig2() {
+ $processor = new ExtensionProcessor;
+ $info = [
+ 'config' => [
+ 'Bar' => [ 'value' => 'somevalue' ],
+ 'Foo' => [ 'value' => 10 ],
+ 'Path' => [ 'value' => 'foo.txt', 'path' => true ],
+ 'Namespaces' => [
+ 'value' => [
+ '10' => true,
+ '12' => false,
+ ],
+ 'merge_strategy' => 'array_plus',
+ ],
+ ],
+ ] + self::$default;
+ $info2 = [
+ 'config' => [
+ 'Bar' => [ 'value' => 'somevalue' ],
+ ],
+ 'config_prefix' => 'eg',
+ 'name' => 'FooBar2',
+ ];
+ $processor->extractInfo( $this->dir, $info, 2 );
+ $processor->extractInfo( $this->dir, $info2, 2 );
+ $extracted = $processor->getExtractedInfo();
+ $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] );
+ $this->assertEquals( 10, $extracted['globals']['wgFoo'] );
+ $this->assertEquals( "{$this->dirname}/foo.txt", $extracted['globals']['wgPath'] );
+ // Custom prefix:
+ $this->assertEquals( 'somevalue', $extracted['globals']['egBar'] );
+ $this->assertSame(
+ [ 10 => true, 12 => false, ExtensionRegistry::MERGE_STRATEGY => 'array_plus' ],
+ $extracted['globals']['wgNamespaces']
+ );
+ }
+
+ /**
+ * @expectedException RuntimeException
+ */
+ public function testDuplicateConfigKey1() {
+ $processor = new ExtensionProcessor;
+ $info = [
+ 'config' => [
+ 'Bar' => '',
+ ]
+ ] + self::$default;
+ $info2 = [
+ 'config' => [
+ 'Bar' => 'g',
+ ],
+ 'name' => 'FooBar2',
+ ];
+ $processor->extractInfo( $this->dir, $info, 1 );
+ $processor->extractInfo( $this->dir, $info2, 1 );
+ }
+
+ /**
+ * @expectedException RuntimeException
+ */
+ public function testDuplicateConfigKey2() {
+ $processor = new ExtensionProcessor;
+ $info = [
+ 'config' => [
+ 'Bar' => [ 'value' => 'somevalue' ],
+ ]
+ ] + self::$default;
+ $info2 = [
+ 'config' => [
+ 'Bar' => [ 'value' => 'somevalue' ],
+ ],
+ 'name' => 'FooBar2',
+ ];
+ $processor->extractInfo( $this->dir, $info, 2 );
+ $processor->extractInfo( $this->dir, $info2, 2 );
+ }
+
+ public static function provideExtractExtensionMessagesFiles() {
+ $dir = __DIR__ . '/FooBar/';
+ return [
+ [
+ [ 'ExtensionMessagesFiles' => [ 'FooBarAlias' => 'FooBar.alias.php' ] ],
+ [ 'wgExtensionMessagesFiles' => [ 'FooBarAlias' => $dir . 'FooBar.alias.php' ] ]
+ ],
+ [
+ [
+ 'ExtensionMessagesFiles' => [
+ 'FooBarAlias' => 'FooBar.alias.php',
+ 'FooBarMagic' => 'FooBar.magic.i18n.php',
+ ],
+ ],
+ [
+ 'wgExtensionMessagesFiles' => [
+ 'FooBarAlias' => $dir . 'FooBar.alias.php',
+ 'FooBarMagic' => $dir . 'FooBar.magic.i18n.php',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideExtractExtensionMessagesFiles
+ */
+ public function testExtractExtensionMessagesFiles( $input, $expected ) {
+ $processor = new ExtensionProcessor();
+ $processor->extractInfo( $this->dir, $input + self::$default, 1 );
+ $out = $processor->getExtractedInfo();
+ foreach ( $expected as $key => $value ) {
+ $this->assertEquals( $value, $out['globals'][$key] );
+ }
+ }
+
+ public static function provideExtractMessagesDirs() {
+ $dir = __DIR__ . '/FooBar/';
+ return [
+ [
+ [ 'MessagesDirs' => [ 'VisualEditor' => 'i18n' ] ],
+ [ 'wgMessagesDirs' => [ 'VisualEditor' => [ $dir . 'i18n' ] ] ]
+ ],
+ [
+ [ 'MessagesDirs' => [ 'VisualEditor' => [ 'i18n', 'foobar' ] ] ],
+ [ 'wgMessagesDirs' => [ 'VisualEditor' => [ $dir . 'i18n', $dir . 'foobar' ] ] ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideExtractMessagesDirs
+ */
+ public function testExtractMessagesDirs( $input, $expected ) {
+ $processor = new ExtensionProcessor();
+ $processor->extractInfo( $this->dir, $input + self::$default, 1 );
+ $out = $processor->getExtractedInfo();
+ foreach ( $expected as $key => $value ) {
+ $this->assertEquals( $value, $out['globals'][$key] );
+ }
+ }
+
+ public function testExtractCredits() {
+ $processor = new ExtensionProcessor();
+ $processor->extractInfo( $this->dir, self::$default, 1 );
+ $this->setExpectedException( Exception::class );
+ $processor->extractInfo( $this->dir, self::$default, 1 );
+ }
+
+ /**
+ * @dataProvider provideExtractResourceLoaderModules
+ */
+ public function testExtractResourceLoaderModules( $input, $expected ) {
+ $processor = new ExtensionProcessor();
+ $processor->extractInfo( $this->dir, $input + self::$default, 1 );
+ $out = $processor->getExtractedInfo();
+ foreach ( $expected as $key => $value ) {
+ $this->assertEquals( $value, $out['globals'][$key] );
+ }
+ }
+
+ public static function provideExtractResourceLoaderModules() {
+ $dir = __DIR__ . '/FooBar';
+ return [
+ // Generic module with localBasePath/remoteExtPath specified
+ [
+ // Input
+ [
+ 'ResourceModules' => [
+ 'test.foo' => [
+ 'styles' => 'foobar.js',
+ 'localBasePath' => '',
+ 'remoteExtPath' => 'FooBar',
+ ],
+ ],
+ ],
+ // Expected
+ [
+ 'wgResourceModules' => [
+ 'test.foo' => [
+ 'styles' => 'foobar.js',
+ 'localBasePath' => $dir,
+ 'remoteExtPath' => 'FooBar',
+ ],
+ ],
+ ],
+ ],
+ // ResourceFileModulePaths specified:
+ [
+ // Input
+ [
+ 'ResourceFileModulePaths' => [
+ 'localBasePath' => 'modules',
+ 'remoteExtPath' => 'FooBar/modules',
+ ],
+ 'ResourceModules' => [
+ // No paths
+ 'test.foo' => [
+ 'styles' => 'foo.js',
+ ],
+ // Different paths set
+ 'test.bar' => [
+ 'styles' => 'bar.js',
+ 'localBasePath' => 'subdir',
+ 'remoteExtPath' => 'FooBar/subdir',
+ ],
+ // Custom class with no paths set
+ 'test.class' => [
+ 'class' => 'FooBarModule',
+ 'extra' => 'argument',
+ ],
+ // Custom class with a localBasePath
+ 'test.class.with.path' => [
+ 'class' => 'FooBarPathModule',
+ 'extra' => 'argument',
+ 'localBasePath' => '',
+ ]
+ ],
+ ],
+ // Expected
+ [
+ 'wgResourceModules' => [
+ 'test.foo' => [
+ 'styles' => 'foo.js',
+ 'localBasePath' => "$dir/modules",
+ 'remoteExtPath' => 'FooBar/modules',
+ ],
+ 'test.bar' => [
+ 'styles' => 'bar.js',
+ 'localBasePath' => "$dir/subdir",
+ 'remoteExtPath' => 'FooBar/subdir',
+ ],
+ 'test.class' => [
+ 'class' => 'FooBarModule',
+ 'extra' => 'argument',
+ 'localBasePath' => "$dir/modules",
+ 'remoteExtPath' => 'FooBar/modules',
+ ],
+ 'test.class.with.path' => [
+ 'class' => 'FooBarPathModule',
+ 'extra' => 'argument',
+ 'localBasePath' => $dir,
+ 'remoteExtPath' => 'FooBar/modules',
+ ]
+ ],
+ ],
+ ],
+ // ResourceModuleSkinStyles with file module paths
+ [
+ // Input
+ [
+ 'ResourceFileModulePaths' => [
+ 'localBasePath' => '',
+ 'remoteSkinPath' => 'FooBar',
+ ],
+ 'ResourceModuleSkinStyles' => [
+ 'foobar' => [
+ 'test.foo' => 'foo.css',
+ ]
+ ],
+ ],
+ // Expected
+ [
+ 'wgResourceModuleSkinStyles' => [
+ 'foobar' => [
+ 'test.foo' => 'foo.css',
+ 'localBasePath' => $dir,
+ 'remoteSkinPath' => 'FooBar',
+ ],
+ ],
+ ],
+ ],
+ // ResourceModuleSkinStyles with file module paths and an override
+ [
+ // Input
+ [
+ 'ResourceFileModulePaths' => [
+ 'localBasePath' => '',
+ 'remoteSkinPath' => 'FooBar',
+ ],
+ 'ResourceModuleSkinStyles' => [
+ 'foobar' => [
+ 'test.foo' => 'foo.css',
+ 'remoteSkinPath' => 'BarFoo'
+ ],
+ ],
+ ],
+ // Expected
+ [
+ 'wgResourceModuleSkinStyles' => [
+ 'foobar' => [
+ 'test.foo' => 'foo.css',
+ 'localBasePath' => $dir,
+ 'remoteSkinPath' => 'BarFoo',
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ public static function provideSetToGlobal() {
+ return [
+ [
+ [ 'wgAPIModules', 'wgAvailableRights' ],
+ [],
+ [
+ 'APIModules' => [ 'foobar' => 'ApiFooBar' ],
+ 'AvailableRights' => [ 'foobar', 'unfoobar' ],
+ ],
+ [
+ 'wgAPIModules' => [ 'foobar' => 'ApiFooBar' ],
+ 'wgAvailableRights' => [ 'foobar', 'unfoobar' ],
+ ],
+ ],
+ [
+ [ 'wgAPIModules', 'wgAvailableRights' ],
+ [
+ 'wgAPIModules' => [ 'barbaz' => 'ApiBarBaz' ],
+ 'wgAvailableRights' => [ 'barbaz' ]
+ ],
+ [
+ 'APIModules' => [ 'foobar' => 'ApiFooBar' ],
+ 'AvailableRights' => [ 'foobar', 'unfoobar' ],
+ ],
+ [
+ 'wgAPIModules' => [ 'barbaz' => 'ApiBarBaz', 'foobar' => 'ApiFooBar' ],
+ 'wgAvailableRights' => [ 'barbaz', 'foobar', 'unfoobar' ],
+ ],
+ ],
+ [
+ [ 'wgGroupPermissions' ],
+ [
+ 'wgGroupPermissions' => [
+ 'sysop' => [ 'delete' ]
+ ],
+ ],
+ [
+ 'GroupPermissions' => [
+ 'sysop' => [ 'undelete' ],
+ 'user' => [ 'edit' ]
+ ],
+ ],
+ [
+ 'wgGroupPermissions' => [
+ 'sysop' => [ 'delete', 'undelete' ],
+ 'user' => [ 'edit' ]
+ ],
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * Attributes under manifest_version 2
+ */
+ public function testExtractAttributes() {
+ $processor = new ExtensionProcessor();
+ // Load FooBar extension
+ $processor->extractInfo( $this->dir, [ 'name' => 'FooBar' ], 2 );
+ $processor->extractInfo(
+ $this->dir,
+ [
+ 'name' => 'Baz',
+ 'attributes' => [
+ // Loaded
+ 'FooBar' => [
+ 'Plugins' => [
+ 'ext.baz.foobar',
+ ],
+ ],
+ // Not loaded
+ 'FizzBuzz' => [
+ 'MorePlugins' => [
+ 'ext.baz.fizzbuzz',
+ ],
+ ],
+ ],
+ ],
+ 2
+ );
+
+ $info = $processor->getExtractedInfo();
+ $this->assertArrayHasKey( 'FooBarPlugins', $info['attributes'] );
+ $this->assertSame( [ 'ext.baz.foobar' ], $info['attributes']['FooBarPlugins'] );
+ $this->assertArrayNotHasKey( 'FizzBuzzMorePlugins', $info['attributes'] );
+ }
+
+ /**
+ * Attributes under manifest_version 1
+ */
+ public function testAttributes1() {
+ $processor = new ExtensionProcessor();
+ $processor->extractInfo(
+ $this->dir,
+ [
+ 'name' => 'FooBar',
+ 'FooBarPlugins' => [
+ 'ext.baz.foobar',
+ ],
+ 'FizzBuzzMorePlugins' => [
+ 'ext.baz.fizzbuzz',
+ ],
+ ],
+ 1
+ );
+ $processor->extractInfo(
+ $this->dir,
+ [
+ 'name' => 'FooBar2',
+ 'FizzBuzzMorePlugins' => [
+ 'ext.bar.fizzbuzz',
+ ]
+ ],
+ 1
+ );
+
+ $info = $processor->getExtractedInfo();
+ $this->assertArrayHasKey( 'FooBarPlugins', $info['attributes'] );
+ $this->assertSame( [ 'ext.baz.foobar' ], $info['attributes']['FooBarPlugins'] );
+ $this->assertArrayHasKey( 'FizzBuzzMorePlugins', $info['attributes'] );
+ $this->assertSame(
+ [ 'ext.baz.fizzbuzz', 'ext.bar.fizzbuzz' ],
+ $info['attributes']['FizzBuzzMorePlugins']
+ );
+ }
+
+ public function testAttributes1_notarray() {
+ $processor = new ExtensionProcessor();
+ $this->setExpectedException(
+ InvalidArgumentException::class,
+ "The value for 'FooBarPlugins' should be an array (from {$this->dir})"
+ );
+ $processor->extractInfo(
+ $this->dir,
+ [
+ 'FooBarPlugins' => 'ext.baz.foobar',
+ ] + self::$default,
+ 1
+ );
+ }
+
+ public function testExtractPathBasedGlobal() {
+ $processor = new ExtensionProcessor();
+ $processor->extractInfo(
+ $this->dir,
+ [
+ 'ParserTestFiles' => [
+ 'tests/parserTests.txt',
+ 'tests/extraParserTests.txt',
+ ],
+ 'ServiceWiringFiles' => [
+ 'includes/ServiceWiring.php'
+ ],
+ ] + self::$default,
+ 1
+ );
+ $globals = $processor->getExtractedInfo()['globals'];
+ $this->assertArrayHasKey( 'wgParserTestFiles', $globals );
+ $this->assertSame( [
+ "{$this->dirname}/tests/parserTests.txt",
+ "{$this->dirname}/tests/extraParserTests.txt"
+ ], $globals['wgParserTestFiles'] );
+ $this->assertArrayHasKey( 'wgServiceWiringFiles', $globals );
+ $this->assertSame( [
+ "{$this->dirname}/includes/ServiceWiring.php"
+ ], $globals['wgServiceWiringFiles'] );
+ }
+
+ public function testGetRequirements() {
+ $info = self::$default + [
+ 'requires' => [
+ 'MediaWiki' => '>= 1.25.0',
+ 'extensions' => [
+ 'Bar' => '*'
+ ]
+ ]
+ ];
+ $processor = new ExtensionProcessor();
+ $this->assertSame(
+ $info['requires'],
+ $processor->getRequirements( $info )
+ );
+ $this->assertSame(
+ [],
+ $processor->getRequirements( [] )
+ );
+ }
+
+ public function testGetExtraAutoloaderPaths() {
+ $processor = new ExtensionProcessor();
+ $this->assertSame(
+ [ "{$this->dirname}/vendor/autoload.php" ],
+ $processor->getExtraAutoloaderPaths( $this->dirname, [
+ 'load_composer_autoloader' => true,
+ ] )
+ );
+ }
+
+ /**
+ * Verify that extension.schema.json is in sync with ExtensionProcessor
+ *
+ * @coversNothing
+ */
+ public function testGlobalSettingsDocumentedInSchema() {
+ global $IP;
+ $globalSettings = TestingAccessWrapper::newFromClass(
+ ExtensionProcessor::class )->globalSettings;
+
+ $version = ExtensionRegistry::MANIFEST_VERSION;
+ $schema = FormatJson::decode(
+ file_get_contents( "$IP/docs/extension.schema.v$version.json" ),
+ true
+ );
+ $missing = [];
+ foreach ( $globalSettings as $global ) {
+ if ( !isset( $schema['properties'][$global] ) ) {
+ $missing[] = $global;
+ }
+ }
+
+ $this->assertEquals( [], $missing,
+ "The following global settings are not documented in docs/extension.schema.json" );
+ }
+}
+
+/**
+ * Allow overriding the default value of $this->globals
+ * so we can test merging
+ */
+class MockExtensionProcessor extends ExtensionProcessor {
+ public function __construct( $globals = [] ) {
+ $this->globals = $globals + $this->globals;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/registration/ExtensionRegistryTest.php b/www/wiki/tests/phpunit/includes/registration/ExtensionRegistryTest.php
new file mode 100644
index 00000000..67bc088d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/registration/ExtensionRegistryTest.php
@@ -0,0 +1,352 @@
+<?php
+
+/**
+ * @covers ExtensionRegistry
+ */
+class ExtensionRegistryTest extends MediaWikiTestCase {
+
+ private $dataDir;
+
+ public function setUp() {
+ parent::setUp();
+ $this->dataDir = __DIR__ . '/../../data/registration';
+ }
+
+ public function testQueue_invalid() {
+ $registry = new ExtensionRegistry();
+ $path = __DIR__ . '/doesnotexist.json';
+ $this->setExpectedException(
+ Exception::class,
+ "$path does not exist!"
+ );
+ $registry->queue( $path );
+ }
+
+ public function testQueue() {
+ $registry = new ExtensionRegistry();
+ $path = "{$this->dataDir}/good.json";
+ $registry->queue( $path );
+ $this->assertArrayHasKey(
+ $path,
+ $registry->getQueue()
+ );
+ $registry->clearQueue();
+ $this->assertEmpty( $registry->getQueue() );
+ }
+
+ public function testLoadFromQueue_empty() {
+ $registry = new ExtensionRegistry();
+ $registry->loadFromQueue();
+ $this->assertEmpty( $registry->getAllThings() );
+ }
+
+ public function testLoadFromQueue_late() {
+ $registry = new ExtensionRegistry();
+ $registry->finish();
+ $registry->queue( "{$this->dataDir}/good.json" );
+ $this->setExpectedException(
+ MWException::class,
+ "The following paths tried to load late: {$this->dataDir}/good.json"
+ );
+ $registry->loadFromQueue();
+ }
+
+ /**
+ * @dataProvider provideExportExtractedDataGlobals
+ */
+ public function testExportExtractedDataGlobals( $desc, $before, $globals, $expected ) {
+ // Set globals for test
+ if ( $before ) {
+ foreach ( $before as $key => $value ) {
+ // mw prefixed globals does not exist normally
+ if ( substr( $key, 0, 2 ) == 'mw' ) {
+ $GLOBALS[$key] = $value;
+ } else {
+ $this->setMwGlobals( $key, $value );
+ }
+ }
+ }
+
+ $info = [
+ 'globals' => $globals,
+ 'callbacks' => [],
+ 'defines' => [],
+ 'credits' => [],
+ 'attributes' => [],
+ 'autoloaderPaths' => []
+ ];
+ $registry = new ExtensionRegistry();
+ $class = new ReflectionClass( ExtensionRegistry::class );
+ $method = $class->getMethod( 'exportExtractedData' );
+ $method->setAccessible( true );
+ $method->invokeArgs( $registry, [ $info ] );
+ foreach ( $expected as $name => $value ) {
+ $this->assertArrayHasKey( $name, $GLOBALS, $desc );
+ $this->assertEquals( $value, $GLOBALS[$name], $desc );
+ }
+
+ // Remove mw prefixed globals
+ if ( $before ) {
+ foreach ( $before as $key => $value ) {
+ if ( substr( $key, 0, 2 ) == 'mw' ) {
+ unset( $GLOBALS[$key] );
+ }
+ }
+ }
+ }
+
+ public static function provideExportExtractedDataGlobals() {
+ // "mwtest" prefix used instead of "$wg" to avoid potential conflicts
+ return [
+ [
+ 'Simple non-array values',
+ [
+ 'mwtestFooBarConfig' => true,
+ 'mwtestFooBarConfig2' => 'string',
+ ],
+ [
+ 'mwtestFooBarDefault' => 1234,
+ 'mwtestFooBarConfig' => false,
+ ],
+ [
+ 'mwtestFooBarConfig' => true,
+ 'mwtestFooBarConfig2' => 'string',
+ 'mwtestFooBarDefault' => 1234,
+ ],
+ ],
+ [
+ 'No global already set, simple array',
+ null,
+ [
+ 'mwtestDefaultOptions' => [
+ 'foobar' => true,
+ ]
+ ],
+ [
+ 'mwtestDefaultOptions' => [
+ 'foobar' => true,
+ ]
+ ],
+ ],
+ [
+ 'Global already set, simple array',
+ [
+ 'mwtestDefaultOptions' => [
+ 'foobar' => true,
+ 'foo' => 'string'
+ ],
+ ],
+ [
+ 'mwtestDefaultOptions' => [
+ 'barbaz' => 12345,
+ 'foobar' => false,
+ ],
+ ],
+ [
+ 'mwtestDefaultOptions' => [
+ 'barbaz' => 12345,
+ 'foo' => 'string',
+ 'foobar' => true,
+ ],
+ ]
+ ],
+ [
+ 'Global already set, 1d array that appends',
+ [
+ 'mwAvailableRights' => [
+ 'foobar',
+ 'foo'
+ ],
+ ],
+ [
+ 'mwAvailableRights' => [
+ 'barbaz',
+ ],
+ ],
+ [
+ 'mwAvailableRights' => [
+ 'barbaz',
+ 'foobar',
+ 'foo',
+ ],
+ ]
+ ],
+ [
+ 'Global already set, array with integer keys',
+ [
+ 'mwNamespacesFoo' => [
+ 100 => true,
+ 102 => false
+ ],
+ ],
+ [
+ 'mwNamespacesFoo' => [
+ 100 => false,
+ 500 => true,
+ ExtensionRegistry::MERGE_STRATEGY => 'array_plus',
+ ],
+ ],
+ [
+ 'mwNamespacesFoo' => [
+ 100 => true,
+ 102 => false,
+ 500 => true,
+ ],
+ ]
+ ],
+ [
+ 'No global already set, $wgHooks',
+ [
+ 'wgHooks' => [],
+ ],
+ [
+ 'wgHooks' => [
+ 'FooBarEvent' => [
+ 'FooBarClass::onFooBarEvent'
+ ],
+ ExtensionRegistry::MERGE_STRATEGY => 'array_merge_recursive'
+ ],
+ ],
+ [
+ 'wgHooks' => [
+ 'FooBarEvent' => [
+ 'FooBarClass::onFooBarEvent'
+ ],
+ ],
+ ],
+ ],
+ [
+ 'Global already set, $wgHooks',
+ [
+ 'wgHooks' => [
+ 'FooBarEvent' => [
+ 'FooBarClass::onFooBarEvent'
+ ],
+ 'BazBarEvent' => [
+ 'FooBarClass::onBazBarEvent',
+ ],
+ ],
+ ],
+ [
+ 'wgHooks' => [
+ 'FooBarEvent' => [
+ 'BazBarClass::onFooBarEvent',
+ ],
+ ExtensionRegistry::MERGE_STRATEGY => 'array_merge_recursive',
+ ],
+ ],
+ [
+ 'wgHooks' => [
+ 'FooBarEvent' => [
+ 'FooBarClass::onFooBarEvent',
+ 'BazBarClass::onFooBarEvent',
+ ],
+ 'BazBarEvent' => [
+ 'FooBarClass::onBazBarEvent',
+ ],
+ ],
+ ],
+ ],
+ [
+ 'Global already set, $wgGroupPermissions',
+ [
+ 'wgGroupPermissions' => [
+ 'sysop' => [
+ 'something' => true,
+ ],
+ 'user' => [
+ 'somethingtwo' => true,
+ ]
+ ],
+ ],
+ [
+ 'wgGroupPermissions' => [
+ 'customgroup' => [
+ 'right' => true,
+ ],
+ 'user' => [
+ 'right' => true,
+ 'somethingtwo' => false,
+ 'nonduplicated' => true,
+ ],
+ ExtensionRegistry::MERGE_STRATEGY => 'array_plus_2d',
+ ],
+ ],
+ [
+ 'wgGroupPermissions' => [
+ 'customgroup' => [
+ 'right' => true,
+ ],
+ 'sysop' => [
+ 'something' => true,
+ ],
+ 'user' => [
+ 'somethingtwo' => true,
+ 'right' => true,
+ 'nonduplicated' => true,
+ ]
+ ],
+ ],
+ ],
+ [
+ 'False local setting should not be overridden (T100767)',
+ [
+ 'mwtestT100767' => false,
+ ],
+ [
+ 'mwtestT100767' => true,
+ ],
+ [
+ 'mwtestT100767' => false,
+ ],
+ ],
+ [
+ 'test array_replace_recursive',
+ [
+ 'mwtestJsonConfigs' => [
+ 'JsonZeroConfig' => [
+ 'namespace' => 480,
+ 'nsName' => 'Zero',
+ 'isLocal' => true,
+ ],
+ ],
+ ],
+ [
+ 'mwtestJsonConfigs' => [
+ 'JsonZeroConfig' => [
+ 'isLocal' => false,
+ 'remote' => [
+ 'username' => 'foo',
+ ],
+ ],
+ ExtensionRegistry::MERGE_STRATEGY => 'array_replace_recursive',
+ ],
+ ],
+ [
+ 'mwtestJsonConfigs' => [
+ 'JsonZeroConfig' => [
+ 'namespace' => 480,
+ 'nsName' => 'Zero',
+ 'isLocal' => false,
+ 'remote' => [
+ 'username' => 'foo',
+ ],
+ ],
+ ],
+ ],
+ ],
+ [
+ 'global is null before',
+ [
+ 'NullGlobal' => null,
+ ],
+ [
+ 'NullGlobal' => 'not-null'
+ ],
+ [
+ 'NullGlobal' => null
+ ],
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/registration/VersionCheckerTest.php b/www/wiki/tests/phpunit/includes/registration/VersionCheckerTest.php
new file mode 100644
index 00000000..b668a9ad
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/registration/VersionCheckerTest.php
@@ -0,0 +1,207 @@
+<?php
+
+/**
+ * @covers VersionChecker
+ */
+class VersionCheckerTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ /**
+ * @dataProvider provideCheck
+ */
+ public function testCheck( $coreVersion, $constraint, $expected ) {
+ $checker = new VersionChecker( $coreVersion );
+ $this->assertEquals( $expected, !(bool)$checker->checkArray( [
+ 'FakeExtension' => [
+ 'MediaWiki' => $constraint,
+ ],
+ ] ) );
+ }
+
+ public static function provideCheck() {
+ return [
+ // [ $wgVersion, constraint, expected ]
+ [ '1.25alpha', '>= 1.26', false ],
+ [ '1.25.0', '>= 1.26', false ],
+ [ '1.26alpha', '>= 1.26', true ],
+ [ '1.26alpha', '>= 1.26.0', true ],
+ [ '1.26alpha', '>= 1.26.0-stable', false ],
+ [ '1.26.0', '>= 1.26.0-stable', true ],
+ [ '1.26.1', '>= 1.26.0-stable', true ],
+ [ '1.27.1', '>= 1.26.0-stable', true ],
+ [ '1.26alpha', '>= 1.26.1', false ],
+ [ '1.26alpha', '>= 1.26alpha', true ],
+ [ '1.26alpha', '>= 1.25', true ],
+ [ '1.26.0-alpha.14', '>= 1.26.0-alpha.15', false ],
+ [ '1.26.0-alpha.14', '>= 1.26.0-alpha.10', true ],
+ [ '1.26.1', '>= 1.26.2, <=1.26.0', false ],
+ [ '1.26.1', '^1.26.2', false ],
+ // Accept anything for un-parsable version strings
+ [ '1.26mwf14', '== 1.25alpha', true ],
+ [ 'totallyinvalid', '== 1.0', true ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideType
+ */
+ public function testType( $given, $expected ) {
+ $checker = new VersionChecker( '1.0.0' );
+ $checker->setLoadedExtensionsAndSkins( [
+ 'FakeDependency' => [
+ 'version' => '1.0.0',
+ ],
+ 'NoVersionGiven' => [],
+ ] );
+ $this->assertEquals( $expected, $checker->checkArray( [
+ 'FakeExtension' => $given,
+ ] ) );
+ }
+
+ public static function provideType() {
+ return [
+ // valid type
+ [
+ [
+ 'extensions' => [
+ 'FakeDependency' => '1.0.0',
+ ],
+ ],
+ [],
+ ],
+ [
+ [
+ 'MediaWiki' => '1.0.0',
+ ],
+ [],
+ ],
+ [
+ [
+ 'extensions' => [
+ 'NoVersionGiven' => '*',
+ ],
+ ],
+ [],
+ ],
+ [
+ [
+ 'extensions' => [
+ 'NoVersionGiven' => '1.0',
+ ],
+ ],
+ [
+ [
+ 'incompatible' => 'FakeExtension',
+ 'type' => 'incompatible-extensions',
+ 'msg' => 'NoVersionGiven does not expose its version, but FakeExtension requires: 1.0.',
+ ],
+ ],
+ ],
+ [
+ [
+ 'extensions' => [
+ 'Missing' => '*',
+ ],
+ ],
+ [
+ [
+ 'missing' => 'Missing',
+ 'type' => 'missing-extensions',
+ 'msg' => 'FakeExtension requires Missing to be installed.',
+ ],
+ ],
+ ],
+ [
+ [
+ 'extensions' => [
+ 'FakeDependency' => '2.0.0',
+ ],
+ ],
+ [
+ [
+ 'incompatible' => 'FakeExtension',
+ 'type' => 'incompatible-extensions',
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ 'msg' => 'FakeExtension is not compatible with the current installed version of FakeDependency (1.0.0), it requires: 2.0.0.',
+ ],
+ ],
+ ],
+ [
+ [
+ 'skins' => [
+ 'FakeSkin' => '*',
+ ],
+ ],
+ [
+ [
+ 'missing' => 'FakeSkin',
+ 'type' => 'missing-skins',
+ 'msg' => 'FakeExtension requires FakeSkin to be installed.',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Check, if a non-parsable version constraint does not throw an exception or
+ * returns any error message.
+ */
+ public function testInvalidConstraint() {
+ $checker = new VersionChecker( '1.0.0' );
+ $checker->setLoadedExtensionsAndSkins( [
+ 'FakeDependency' => [
+ 'version' => 'not really valid',
+ ],
+ ] );
+ $this->assertEquals( [
+ [
+ 'type' => 'invalid-version',
+ 'msg' => "FakeDependency does not have a valid version string.",
+ ],
+ ], $checker->checkArray( [
+ 'FakeExtension' => [
+ 'extensions' => [
+ 'FakeDependency' => '1.24.3',
+ ],
+ ],
+ ] ) );
+
+ $checker = new VersionChecker( '1.0.0' );
+ $checker->setLoadedExtensionsAndSkins( [
+ 'FakeDependency' => [
+ 'version' => '1.24.3',
+ ],
+ ] );
+
+ $this->setExpectedException( UnexpectedValueException::class );
+ $checker->checkArray( [
+ 'FakeExtension' => [
+ 'FakeDependency' => 'not really valid',
+ ],
+ ] );
+ }
+
+ /**
+ * T197478
+ */
+ public function testInvalidDependency() {
+ $checker = new VersionChecker( '1.0.0' );
+ $this->setExpectedException( UnexpectedValueException::class,
+ 'Dependency type skin unknown in FakeExtension' );
+ $this->assertEquals( [
+ [
+ 'type' => 'invalid-version',
+ 'msg' => 'FakeDependency does not have a valid version string.',
+ ],
+ ], $checker->checkArray( [
+ 'FakeExtension' => [
+ 'skin' => [
+ 'FakeSkin' => '*',
+ ],
+ ],
+ ] ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php b/www/wiki/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php
new file mode 100644
index 00000000..e4f58eb1
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php
@@ -0,0 +1,128 @@
+<?php
+
+/**
+ * @group ResourceLoader
+ * @covers DerivativeResourceLoaderContext
+ */
+class DerivativeResourceLoaderContextTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected static function getContext() {
+ $request = new FauxRequest( [
+ 'lang' => 'zh',
+ 'modules' => 'test.context',
+ 'only' => 'scripts',
+ 'skin' => 'fallback',
+ 'target' => 'test',
+ ] );
+ return new ResourceLoaderContext( new ResourceLoader(), $request );
+ }
+
+ public function testGetInherited() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ // Request parameters
+ $this->assertEquals( $derived->getDebug(), false );
+ $this->assertEquals( $derived->getLanguage(), 'zh' );
+ $this->assertEquals( $derived->getModules(), [ 'test.context' ] );
+ $this->assertEquals( $derived->getOnly(), 'scripts' );
+ $this->assertEquals( $derived->getSkin(), 'fallback' );
+ $this->assertEquals( $derived->getUser(), null );
+
+ // Misc
+ $this->assertEquals( $derived->getDirection(), 'ltr' );
+ $this->assertEquals( $derived->getHash(), 'zh|fallback|||scripts|||||' );
+ }
+
+ public function testModules() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setModules( [ 'test.override' ] );
+ $this->assertEquals( $derived->getModules(), [ 'test.override' ] );
+ }
+
+ public function testLanguage() {
+ $context = self::getContext();
+ $derived = new DerivativeResourceLoaderContext( $context );
+
+ $derived->setLanguage( 'nl' );
+ $this->assertEquals( $derived->getLanguage(), 'nl' );
+ }
+
+ public function testDirection() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setLanguage( 'nl' );
+ $this->assertEquals( $derived->getDirection(), 'ltr' );
+
+ $derived->setLanguage( 'he' );
+ $this->assertEquals( $derived->getDirection(), 'rtl' );
+
+ $derived->setDirection( 'ltr' );
+ $this->assertEquals( $derived->getDirection(), 'ltr' );
+ }
+
+ public function testSkin() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setSkin( 'override' );
+ $this->assertEquals( $derived->getSkin(), 'override' );
+ }
+
+ public function testUser() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setUser( 'Example' );
+ $this->assertEquals( $derived->getUser(), 'Example' );
+ }
+
+ public function testDebug() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setDebug( true );
+ $this->assertEquals( $derived->getDebug(), true );
+ }
+
+ public function testOnly() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setOnly( 'styles' );
+ $this->assertEquals( $derived->getOnly(), 'styles' );
+
+ $derived->setOnly( null );
+ $this->assertEquals( $derived->getOnly(), null );
+ }
+
+ public function testVersion() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setVersion( 'hw1' );
+ $this->assertEquals( $derived->getVersion(), 'hw1' );
+ }
+
+ public function testRaw() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setRaw( true );
+ $this->assertEquals( $derived->getRaw(), true );
+ }
+
+ public function testGetHash() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $this->assertEquals( $derived->getHash(), 'zh|fallback|||scripts|||||' );
+
+ $derived->setLanguage( 'nl' );
+ $derived->setUser( 'Example' );
+ // Assert that subclass is able to clear parent class "hash" member
+ $this->assertEquals( $derived->getHash(), 'nl|fallback||Example|scripts|||||' );
+ }
+
+ public function testAccessors() {
+ $context = self::getContext();
+ $derived = new DerivativeResourceLoaderContext( $context );
+ $this->assertSame( $derived->getRequest(), $context->getRequest() );
+ $this->assertSame( $derived->getResourceLoader(), $context->getResourceLoader() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php b/www/wiki/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php
new file mode 100644
index 00000000..7eb09441
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php
@@ -0,0 +1,224 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Cache
+ * @covers MessageBlobStore
+ */
+class MessageBlobStoreTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected function setUp() {
+ parent::setUp();
+ // MediaWiki tests defaults $wgMainWANCache to CACHE_NONE.
+ // Use hash instead so that caching is observed
+ $this->wanCache = $this->getMockBuilder( WANObjectCache::class )
+ ->setConstructorArgs( [ [
+ 'cache' => new HashBagOStuff(),
+ 'pool' => 'test',
+ 'relayer' => new EventRelayerNull( [] )
+ ] ] )
+ ->setMethods( [ 'makePurgeValue' ] )
+ ->getMock();
+
+ $this->wanCache->expects( $this->any() )
+ ->method( 'makePurgeValue' )
+ ->will( $this->returnCallback( function ( $timestamp, $holdoff ) {
+ // Disable holdoff as it messes with testing. Aside from a 0-second holdoff,
+ // make sure that "time" passes between getMulti() check init and the set()
+ // in recacheMessageBlob(). This especially matters for Windows clocks.
+ $ts = (float)$timestamp - 0.0001;
+
+ return WANObjectCache::PURGE_VAL_PREFIX . $ts . ':0';
+ } ) );
+ }
+
+ protected function makeBlobStore( $methods = null, $rl = null ) {
+ $blobStore = $this->getMockBuilder( MessageBlobStore::class )
+ ->setConstructorArgs( [ $rl ] )
+ ->setMethods( $methods )
+ ->getMock();
+
+ $access = TestingAccessWrapper::newFromObject( $blobStore );
+ $access->wanCache = $this->wanCache;
+ return $blobStore;
+ }
+
+ protected function makeModule( array $messages ) {
+ $module = new ResourceLoaderTestModule( [ 'messages' => $messages ] );
+ $module->setName( 'test.blobstore' );
+ return $module;
+ }
+
+ /** @covers MessageBlobStore::setLogger */
+ public function testSetLogger() {
+ $blobStore = $this->makeBlobStore();
+ $this->assertSame( null, $blobStore->setLogger( new Psr\Log\NullLogger() ) );
+ }
+
+ /** @covers MessageBlobStore::getResourceLoader */
+ public function testGetResourceLoader() {
+ // Call protected method
+ $blobStore = TestingAccessWrapper::newFromObject( $this->makeBlobStore() );
+ $this->assertInstanceOf(
+ ResourceLoader::class,
+ $blobStore->getResourceLoader()
+ );
+ }
+
+ /** @covers MessageBlobStore::fetchMessage */
+ public function testFetchMessage() {
+ $module = $this->makeModule( [ 'mainpage' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+
+ $blobStore = $this->makeBlobStore( null, $rl );
+ $blob = $blobStore->getBlob( $module, 'en' );
+
+ $this->assertEquals( '{"mainpage":"Main Page"}', $blob, 'Generated blob' );
+ }
+
+ /** @covers MessageBlobStore::fetchMessage */
+ public function testFetchMessageFail() {
+ $module = $this->makeModule( [ 'i-dont-exist' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+
+ $blobStore = $this->makeBlobStore( null, $rl );
+ $blob = $blobStore->getBlob( $module, 'en' );
+
+ $this->assertEquals( '{"i-dont-exist":"\u29fci-dont-exist\u29fd"}', $blob, 'Generated blob' );
+ }
+
+ public function testGetBlob() {
+ $module = $this->makeModule( [ 'foo' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->once() )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValue( 'Example' ) );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+
+ $this->assertEquals( '{"foo":"Example"}', $blob, 'Generated blob' );
+ }
+
+ /**
+ * Seems to fail sometimes (T176097).
+ *
+ * @group Broken
+ */
+ public function testGetBlobCached() {
+ $module = $this->makeModule( [ 'example' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->once() )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValue( 'First' ) );
+
+ $module = $this->makeModule( [ 'example' ] );
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"First"}', $blob, 'Generated blob' );
+
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->never() )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValue( 'Second' ) );
+
+ $module = $this->makeModule( [ 'example' ] );
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"First"}', $blob, 'Cache hit' );
+ }
+
+ public function testUpdateMessage() {
+ $module = $this->makeModule( [ 'example' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->once() )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValue( 'First' ) );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"First"}', $blob, 'Generated blob' );
+
+ $blobStore->updateMessage( 'example' );
+
+ $module = $this->makeModule( [ 'example' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->once() )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValue( 'Second' ) );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"Second"}', $blob, 'Updated blob' );
+ }
+
+ public function testValidation() {
+ $module = $this->makeModule( [ 'foo' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->once() )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValueMap( [
+ [ 'foo', 'en', 'Hello' ],
+ ] ) );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"foo":"Hello"}', $blob, 'Generated blob' );
+
+ // Now, imagine a change to the module is deployed. The module now contains
+ // message 'foo' and 'bar'. While updateMessage() was not called (since no
+ // message values were changed) it should detect the change in list of
+ // message keys.
+ $module = $this->makeModule( [ 'foo', 'bar' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->exactly( 2 ) )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValueMap( [
+ [ 'foo', 'en', 'Hello' ],
+ [ 'bar', 'en', 'World' ],
+ ] ) );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"foo":"Hello","bar":"World"}', $blob, 'Updated blob' );
+ }
+
+ public function testClear() {
+ $module = $this->makeModule( [ 'example' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->exactly( 2 ) )
+ ->method( 'fetchMessage' )
+ ->will( $this->onConsecutiveCalls( 'First', 'Second' ) );
+
+ $now = microtime( true );
+ $this->wanCache->setMockTime( $now );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"First"}', $blob, 'Generated blob' );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"First"}', $blob, 'Cache-hit' );
+
+ $now += 1;
+ $blobStore->clear();
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"Second"}', $blob, 'Updated blob' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php
new file mode 100644
index 00000000..07956f1d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php
@@ -0,0 +1,405 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group ResourceLoader
+ */
+class ResourceLoaderClientHtmlTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected static function expandVariables( $text ) {
+ return strtr( $text, [
+ '{blankVer}' => ResourceLoaderTestCase::BLANK_VERSION
+ ] );
+ }
+
+ protected static function makeContext( $extraQuery = [] ) {
+ $conf = new HashConfig( [
+ 'ResourceLoaderSources' => [],
+ 'ResourceModuleSkinStyles' => [],
+ 'ResourceModules' => [],
+ 'EnableJavaScriptTest' => false,
+ 'ResourceLoaderDebug' => false,
+ 'LoadScript' => '/w/load.php',
+ ] );
+ return new ResourceLoaderContext(
+ new ResourceLoader( $conf ),
+ new FauxRequest( array_merge( [
+ 'lang' => 'nl',
+ 'skin' => 'fallback',
+ 'user' => 'Example',
+ 'target' => 'phpunit',
+ ], $extraQuery ) )
+ );
+ }
+
+ protected static function makeModule( array $options = [] ) {
+ return new ResourceLoaderTestModule( $options );
+ }
+
+ protected static function makeSampleModules() {
+ $modules = [
+ 'test' => [],
+ 'test.private' => [ 'group' => 'private' ],
+ 'test.shouldembed.empty' => [ 'shouldEmbed' => true, 'isKnownEmpty' => true ],
+ 'test.shouldembed' => [ 'shouldEmbed' => true ],
+
+ 'test.styles.pure' => [ 'type' => ResourceLoaderModule::LOAD_STYLES ],
+ 'test.styles.mixed' => [],
+ 'test.styles.noscript' => [
+ 'type' => ResourceLoaderModule::LOAD_STYLES,
+ 'group' => 'noscript',
+ ],
+ 'test.styles.user' => [
+ 'type' => ResourceLoaderModule::LOAD_STYLES,
+ 'group' => 'user',
+ ],
+ 'test.styles.user.empty' => [
+ 'type' => ResourceLoaderModule::LOAD_STYLES,
+ 'group' => 'user',
+ 'isKnownEmpty' => true,
+ ],
+ 'test.styles.private' => [
+ 'type' => ResourceLoaderModule::LOAD_STYLES,
+ 'group' => 'private',
+ 'styles' => '.private{}',
+ ],
+ 'test.styles.shouldembed' => [
+ 'type' => ResourceLoaderModule::LOAD_STYLES,
+ 'shouldEmbed' => true,
+ 'styles' => '.shouldembed{}',
+ ],
+
+ 'test.scripts' => [],
+ 'test.scripts.user' => [ 'group' => 'user' ],
+ 'test.scripts.user.empty' => [ 'group' => 'user', 'isKnownEmpty' => true ],
+ 'test.scripts.raw' => [ 'isRaw' => true ],
+ 'test.scripts.shouldembed' => [ 'shouldEmbed' => true ],
+
+ 'test.ordering.a' => [ 'shouldEmbed' => false ],
+ 'test.ordering.b' => [ 'shouldEmbed' => false ],
+ 'test.ordering.c' => [ 'shouldEmbed' => true, 'styles' => '.orderingC{}' ],
+ 'test.ordering.d' => [ 'shouldEmbed' => true, 'styles' => '.orderingD{}' ],
+ 'test.ordering.e' => [ 'shouldEmbed' => false ],
+ ];
+ return array_map( function ( $options ) {
+ return self::makeModule( $options );
+ }, $modules );
+ }
+
+ /**
+ * @covers ResourceLoaderClientHtml::getDocumentAttributes
+ */
+ public function testGetDocumentAttributes() {
+ $client = new ResourceLoaderClientHtml( self::makeContext() );
+ $this->assertInternalType( 'array', $client->getDocumentAttributes() );
+ }
+
+ /**
+ * @covers ResourceLoaderClientHtml::__construct
+ * @covers ResourceLoaderClientHtml::setModules
+ * @covers ResourceLoaderClientHtml::setModuleStyles
+ * @covers ResourceLoaderClientHtml::setModuleScripts
+ * @covers ResourceLoaderClientHtml::getData
+ * @covers ResourceLoaderClientHtml::getContext
+ */
+ public function testGetData() {
+ $context = self::makeContext();
+ $context->getResourceLoader()->register( self::makeSampleModules() );
+
+ $client = new ResourceLoaderClientHtml( $context );
+ $client->setModules( [
+ 'test',
+ 'test.private',
+ 'test.shouldembed.empty',
+ 'test.shouldembed',
+ 'test.unregistered',
+ ] );
+ $client->setModuleStyles( [
+ 'test.styles.mixed',
+ 'test.styles.user.empty',
+ 'test.styles.private',
+ 'test.styles.pure',
+ 'test.styles.shouldembed',
+ 'test.unregistered.styles',
+ ] );
+ $client->setModuleScripts( [
+ 'test.scripts',
+ 'test.scripts.user',
+ 'test.scripts.user.empty',
+ 'test.scripts.shouldembed',
+ 'test.unregistered.scripts',
+ ] );
+
+ $expected = [
+ 'states' => [
+ 'test.private' => 'loading',
+ 'test.shouldembed.empty' => 'ready',
+ 'test.shouldembed' => 'loading',
+ 'test.styles.pure' => 'ready',
+ 'test.styles.user.empty' => 'ready',
+ 'test.styles.private' => 'ready',
+ 'test.styles.shouldembed' => 'ready',
+ 'test.scripts' => 'loading',
+ 'test.scripts.user' => 'loading',
+ 'test.scripts.user.empty' => 'ready',
+ 'test.scripts.shouldembed' => 'loading',
+ ],
+ 'general' => [
+ 'test',
+ ],
+ 'styles' => [
+ 'test.styles.pure',
+ ],
+ 'scripts' => [
+ 'test.scripts',
+ 'test.scripts.user',
+ 'test.scripts.shouldembed',
+ ],
+ 'embed' => [
+ 'styles' => [ 'test.styles.private', 'test.styles.shouldembed' ],
+ 'general' => [
+ 'test.private',
+ 'test.shouldembed',
+ ],
+ ],
+ ];
+
+ $access = TestingAccessWrapper::newFromObject( $client );
+ $this->assertEquals( $expected, $access->getData() );
+ }
+
+ /**
+ * @covers ResourceLoaderClientHtml::setConfig
+ * @covers ResourceLoaderClientHtml::setExemptStates
+ * @covers ResourceLoaderClientHtml::getHeadHtml
+ * @covers ResourceLoaderClientHtml::getLoad
+ * @covers ResourceLoader::makeLoaderStateScript
+ */
+ public function testGetHeadHtml() {
+ $context = self::makeContext();
+ $context->getResourceLoader()->register( self::makeSampleModules() );
+
+ $client = new ResourceLoaderClientHtml( $context );
+ $client->setConfig( [ 'key' => 'value' ] );
+ $client->setModules( [
+ 'test',
+ 'test.private',
+ ] );
+ $client->setModuleStyles( [
+ 'test.styles.pure',
+ 'test.styles.private',
+ ] );
+ $client->setModuleScripts( [
+ 'test.scripts',
+ ] );
+ $client->setExemptStates( [
+ 'test.exempt' => 'ready',
+ ] );
+
+ // phpcs:disable Generic.Files.LineLength
+ $expected = '<script>document.documentElement.className = document.documentElement.className.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );</script>' . "\n"
+ . '<script>(window.RLQ=window.RLQ||[]).push(function(){'
+ . 'mw.config.set({"key":"value"});'
+ . 'mw.loader.state({"test.exempt":"ready","test.private":"loading","test.styles.pure":"ready","test.styles.private":"ready","test.scripts":"loading"});'
+ . 'mw.loader.implement("test.private@{blankVer}",function($,jQuery,require,module){},{"css":[]});'
+ . 'mw.loader.load(["test"]);'
+ . 'mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts\u0026only=scripts\u0026skin=fallback");'
+ . '});</script>' . "\n"
+ . '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.styles.pure&amp;only=styles&amp;skin=fallback"/>' . "\n"
+ . '<style>.private{}</style>' . "\n"
+ . '<script async="" src="/w/load.php?debug=false&amp;lang=nl&amp;modules=startup&amp;only=scripts&amp;skin=fallback"></script>';
+ // phpcs:enable
+ $expected = self::expandVariables( $expected );
+
+ $this->assertEquals( $expected, $client->getHeadHtml() );
+ }
+
+ /**
+ * Confirm that 'target' is passed down to the startup module's load url.
+ *
+ * @covers ResourceLoaderClientHtml::getHeadHtml
+ */
+ public function testGetHeadHtmlWithTarget() {
+ $client = new ResourceLoaderClientHtml(
+ self::makeContext(),
+ [ 'target' => 'example' ]
+ );
+
+ // phpcs:disable Generic.Files.LineLength
+ $expected = '<script>document.documentElement.className = document.documentElement.className.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );</script>' . "\n"
+ . '<script async="" src="/w/load.php?debug=false&amp;lang=nl&amp;modules=startup&amp;only=scripts&amp;skin=fallback&amp;target=example"></script>';
+ // phpcs:enable
+
+ $this->assertEquals( $expected, $client->getHeadHtml() );
+ }
+
+ /**
+ * Confirm that a null 'target' is the same as no target.
+ *
+ * @covers ResourceLoaderClientHtml::getHeadHtml
+ */
+ public function testGetHeadHtmlWithNullTarget() {
+ $client = new ResourceLoaderClientHtml(
+ self::makeContext(),
+ [ 'target' => null ]
+ );
+
+ // phpcs:disable Generic.Files.LineLength
+ $expected = '<script>document.documentElement.className = document.documentElement.className.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );</script>' . "\n"
+ . '<script async="" src="/w/load.php?debug=false&amp;lang=nl&amp;modules=startup&amp;only=scripts&amp;skin=fallback"></script>';
+ // phpcs:enable
+
+ $this->assertEquals( $expected, $client->getHeadHtml() );
+ }
+
+ /**
+ * @covers ResourceLoaderClientHtml::getBodyHtml
+ * @covers ResourceLoaderClientHtml::getLoad
+ */
+ public function testGetBodyHtml() {
+ $context = self::makeContext();
+ $context->getResourceLoader()->register( self::makeSampleModules() );
+
+ $client = new ResourceLoaderClientHtml( $context );
+ $client->setConfig( [ 'key' => 'value' ] );
+ $client->setModules( [
+ 'test',
+ 'test.private.bottom',
+ ] );
+ $client->setModuleScripts( [
+ 'test.scripts',
+ ] );
+
+ $expected = '';
+ $expected = self::expandVariables( $expected );
+
+ $this->assertEquals( $expected, $client->getBodyHtml() );
+ }
+
+ public static function provideMakeLoad() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ 'context' => [],
+ 'modules' => [ 'test.unknown' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.styles.private' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<style>.private{}</style>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.private' ],
+ 'only' => ResourceLoaderModule::TYPE_COMBINED,
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.implement("test.private@{blankVer}",function($,jQuery,require,module){},{"css":[]});});</script>',
+ ],
+ [
+ 'context' => [],
+ // Eg. startup module
+ 'modules' => [ 'test.scripts.raw' ],
+ 'only' => ResourceLoaderModule::TYPE_SCRIPTS,
+ 'output' => '<script async="" src="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.scripts.raw&amp;only=scripts&amp;skin=fallback"></script>',
+ ],
+ [
+ 'context' => [ 'sync' => true ],
+ 'modules' => [ 'test.scripts.raw' ],
+ 'only' => ResourceLoaderModule::TYPE_SCRIPTS,
+ 'output' => '<script src="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.scripts.raw&amp;only=scripts&amp;skin=fallback&amp;sync=1"></script>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.scripts.user' ],
+ 'only' => ResourceLoaderModule::TYPE_SCRIPTS,
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts.user\u0026only=scripts\u0026skin=fallback\u0026user=Example\u0026version=0a56zyi");});</script>',
+ ],
+ [
+ 'context' => [ 'debug' => true ],
+ 'modules' => [ 'test.styles.pure', 'test.styles.mixed' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<link rel="stylesheet" href="/w/load.php?debug=true&amp;lang=nl&amp;modules=test.styles.mixed&amp;only=styles&amp;skin=fallback"/>' . "\n"
+ . '<link rel="stylesheet" href="/w/load.php?debug=true&amp;lang=nl&amp;modules=test.styles.pure&amp;only=styles&amp;skin=fallback"/>',
+ ],
+ [
+ 'context' => [ 'debug' => false ],
+ 'modules' => [ 'test.styles.pure', 'test.styles.mixed' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.styles.mixed%2Cpure&amp;only=styles&amp;skin=fallback"/>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.styles.noscript' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<noscript><link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.styles.noscript&amp;only=styles&amp;skin=fallback"/></noscript>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.shouldembed' ],
+ 'only' => ResourceLoaderModule::TYPE_COMBINED,
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.implement("test.shouldembed@09p30q0",function($,jQuery,require,module){},{"css":[]});});</script>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.styles.shouldembed' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<style>.shouldembed{}</style>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.scripts.shouldembed' ],
+ 'only' => ResourceLoaderModule::TYPE_SCRIPTS,
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.state({"test.scripts.shouldembed":"ready"});});</script>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test', 'test.shouldembed' ],
+ 'only' => ResourceLoaderModule::TYPE_COMBINED,
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test\u0026skin=fallback");mw.loader.implement("test.shouldembed@09p30q0",function($,jQuery,require,module){},{"css":[]});});</script>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.styles.pure', 'test.styles.shouldembed' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' =>
+ '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.styles.pure&amp;only=styles&amp;skin=fallback"/>' . "\n"
+ . '<style>.shouldembed{}</style>'
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.ordering.a', 'test.ordering.e', 'test.ordering.b', 'test.ordering.d', 'test.ordering.c' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' =>
+ '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.ordering.a%2Cb&amp;only=styles&amp;skin=fallback"/>' . "\n"
+ . '<style>.orderingC{}.orderingD{}</style>' . "\n"
+ . '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.ordering.e&amp;only=styles&amp;skin=fallback"/>'
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @dataProvider provideMakeLoad
+ * @covers ResourceLoaderClientHtml::makeLoad
+ * @covers ResourceLoaderClientHtml::makeContext
+ * @covers ResourceLoader::makeModuleResponse
+ * @covers ResourceLoaderModule::getModuleContent
+ * @covers ResourceLoader::getCombinedVersion
+ * @covers ResourceLoader::createLoaderURL
+ * @covers ResourceLoader::createLoaderQuery
+ * @covers ResourceLoader::makeLoaderQuery
+ * @covers ResourceLoader::makeInlineScript
+ */
+ public function testMakeLoad( array $extraQuery, array $modules, $type, $expected ) {
+ $context = self::makeContext( $extraQuery );
+ $context->getResourceLoader()->register( self::makeSampleModules() );
+ $actual = ResourceLoaderClientHtml::makeLoad( $context, $modules, $type, $extraQuery );
+ $expected = self::expandVariables( $expected );
+ $this->assertEquals( $expected, (string)$actual );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php
new file mode 100644
index 00000000..b226ee1c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php
@@ -0,0 +1,120 @@
+<?php
+
+/**
+ * See also:
+ * - ResourceLoaderTest::testExpandModuleNames
+ * - ResourceLoaderImageModuleTest::testContext
+ *
+ * @group Cache
+ * @covers ResourceLoaderContext
+ */
+class ResourceLoaderContextTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected static function getResourceLoader() {
+ return new EmptyResourceLoader( new HashConfig( [
+ 'ResourceLoaderDebug' => false,
+ 'DefaultSkin' => 'fallback',
+ 'LanguageCode' => 'nl',
+ 'LoadScript' => '/w/load.php',
+ ] ) );
+ }
+
+ public function testEmpty() {
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) );
+
+ // Request parameters
+ $this->assertEquals( [], $ctx->getModules() );
+ $this->assertEquals( 'nl', $ctx->getLanguage() );
+ $this->assertEquals( false, $ctx->getDebug() );
+ $this->assertEquals( null, $ctx->getOnly() );
+ $this->assertEquals( 'fallback', $ctx->getSkin() );
+ $this->assertEquals( null, $ctx->getUser() );
+
+ // Misc
+ $this->assertEquals( 'ltr', $ctx->getDirection() );
+ $this->assertEquals( 'nl|fallback||||||||', $ctx->getHash() );
+ $this->assertInstanceOf( User::class, $ctx->getUserObj() );
+ }
+
+ public function testDummy() {
+ $this->assertInstanceOf(
+ ResourceLoaderContext::class,
+ ResourceLoaderContext::newDummyContext()
+ );
+ }
+
+ public function testAccessors() {
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) );
+ $this->assertInstanceOf( WebRequest::class, $ctx->getRequest() );
+ $this->assertInstanceOf( \Psr\Log\LoggerInterface::class, $ctx->getLogger() );
+ }
+
+ public function testTypicalRequest() {
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [
+ 'debug' => 'false',
+ 'lang' => 'zh',
+ 'modules' => 'foo|foo.quux,baz,bar|baz.quux',
+ 'only' => 'styles',
+ 'skin' => 'fallback',
+ ] ) );
+
+ // Request parameters
+ $this->assertEquals(
+ $ctx->getModules(),
+ [ 'foo', 'foo.quux', 'foo.baz', 'foo.bar', 'baz.quux' ]
+ );
+ $this->assertEquals( false, $ctx->getDebug() );
+ $this->assertEquals( 'zh', $ctx->getLanguage() );
+ $this->assertEquals( 'styles', $ctx->getOnly() );
+ $this->assertEquals( 'fallback', $ctx->getSkin() );
+ $this->assertEquals( null, $ctx->getUser() );
+
+ // Misc
+ $this->assertEquals( 'ltr', $ctx->getDirection() );
+ $this->assertEquals( 'zh|fallback|||styles|||||', $ctx->getHash() );
+ }
+
+ public function testShouldInclude() {
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) );
+ $this->assertTrue( $ctx->shouldIncludeScripts(), 'Scripts in combined' );
+ $this->assertTrue( $ctx->shouldIncludeStyles(), 'Styles in combined' );
+ $this->assertTrue( $ctx->shouldIncludeMessages(), 'Messages in combined' );
+
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [
+ 'only' => 'styles'
+ ] ) );
+ $this->assertFalse( $ctx->shouldIncludeScripts(), 'Scripts not in styles-only' );
+ $this->assertTrue( $ctx->shouldIncludeStyles(), 'Styles in styles-only' );
+ $this->assertFalse( $ctx->shouldIncludeMessages(), 'Messages not in styles-only' );
+
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [
+ 'only' => 'scripts'
+ ] ) );
+ $this->assertTrue( $ctx->shouldIncludeScripts(), 'Scripts in scripts-only' );
+ $this->assertFalse( $ctx->shouldIncludeStyles(), 'Styles not in scripts-only' );
+ $this->assertFalse( $ctx->shouldIncludeMessages(), 'Messages not in scripts-only' );
+ }
+
+ public function testGetUser() {
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) );
+ $this->assertSame( null, $ctx->getUser() );
+ $this->assertTrue( $ctx->getUserObj()->isAnon() );
+
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [
+ 'user' => 'Example'
+ ] ) );
+ $this->assertSame( 'Example', $ctx->getUser() );
+ $this->assertEquals( 'Example', $ctx->getUserObj()->getName() );
+ }
+
+ public function testMsg() {
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [
+ 'lang' => 'en'
+ ] ) );
+ $msg = $ctx->msg( 'mainpage' );
+ $this->assertInstanceOf( Message::class, $msg );
+ $this->assertSame( 'Main Page', $msg->useDatabase( false )->plain() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php
new file mode 100644
index 00000000..e82bab72
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php
@@ -0,0 +1,353 @@
+<?php
+
+/**
+ * @group Database
+ * @group ResourceLoader
+ */
+class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ // The return value of the closure shouldn't matter since this test should
+ // never call it
+ SkinFactory::getDefaultInstance()->register(
+ 'fakeskin',
+ 'FakeSkin',
+ function () {
+ }
+ );
+ }
+
+ private static function getModules() {
+ $base = [
+ 'localBasePath' => realpath( __DIR__ ),
+ ];
+
+ return [
+ 'noTemplateModule' => [],
+
+ 'deprecatedModule' => $base + [
+ 'deprecated' => true,
+ ],
+ 'deprecatedTomorrow' => $base + [
+ 'deprecated' => 'Will be removed tomorrow.'
+ ],
+
+ 'htmlTemplateModule' => $base + [
+ 'templates' => [
+ 'templates/template.html',
+ 'templates/template2.html',
+ ]
+ ],
+
+ 'htmlTemplateUnknown' => $base + [
+ 'templates' => [
+ 'templates/notfound.html',
+ ]
+ ],
+
+ 'aliasedHtmlTemplateModule' => $base + [
+ 'templates' => [
+ 'foo.html' => 'templates/template.html',
+ 'bar.html' => 'templates/template2.html',
+ ]
+ ],
+
+ 'templateModuleHandlebars' => $base + [
+ 'templates' => [
+ 'templates/template_awesome.handlebars',
+ ],
+ ],
+
+ 'aliasFooFromBar' => $base + [
+ 'templates' => [
+ 'foo.foo' => 'templates/template.bar',
+ ],
+ ],
+ ];
+ }
+
+ public static function providerTemplateDependencies() {
+ $modules = self::getModules();
+
+ return [
+ [
+ $modules['noTemplateModule'],
+ [],
+ ],
+ [
+ $modules['htmlTemplateModule'],
+ [
+ 'mediawiki.template',
+ ],
+ ],
+ [
+ $modules['templateModuleHandlebars'],
+ [
+ 'mediawiki.template',
+ 'mediawiki.template.handlebars',
+ ],
+ ],
+ [
+ $modules['aliasFooFromBar'],
+ [
+ 'mediawiki.template',
+ 'mediawiki.template.foo',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providerTemplateDependencies
+ * @covers ResourceLoaderFileModule::__construct
+ * @covers ResourceLoaderFileModule::getDependencies
+ */
+ public function testTemplateDependencies( $module, $expected ) {
+ $rl = new ResourceLoaderFileModule( $module );
+ $rl->setName( 'testing' );
+ $this->assertEquals( $rl->getDependencies(), $expected );
+ }
+
+ public static function providerDeprecatedModules() {
+ return [
+ [
+ 'deprecatedModule',
+ 'mw.log.warn("This page is using the deprecated ResourceLoader module \"deprecatedModule\".");',
+ ],
+ [
+ 'deprecatedTomorrow',
+ 'mw.log.warn(' .
+ '"This page is using the deprecated ResourceLoader module \"deprecatedTomorrow\".\\n' .
+ "Will be removed tomorrow." .
+ '");'
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider providerDeprecatedModules
+ * @covers ResourceLoaderFileModule::getScript
+ */
+ public function testDeprecatedModules( $name, $expected ) {
+ $modules = self::getModules();
+ $module = new ResourceLoaderFileModule( $modules[$name] );
+ $module->setName( $name );
+ $ctx = $this->getResourceLoaderContext();
+ $this->assertEquals( $module->getScript( $ctx ), $expected );
+ }
+
+ /**
+ * @covers ResourceLoaderFileModule::getScript
+ */
+ public function testGetScript() {
+ $module = new ResourceLoaderFileModule( [
+ 'localBasePath' => __DIR__ . '/../../data/resourceloader',
+ 'scripts' => [ 'script-nosemi.js', 'script-comment.js' ],
+ ] );
+ $module->setName( 'testing' );
+ $ctx = $this->getResourceLoaderContext();
+ $this->assertEquals(
+ "/* eslint-disable */\nmw.foo()\n" .
+ "\n" .
+ "/* eslint-disable */\nmw.foo()\n// mw.bar();\n" .
+ "\n",
+ $module->getScript( $ctx ),
+ 'scripts are concatenated with a new-line'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderFileModule::getAllStyleFiles
+ * @covers ResourceLoaderFileModule::getAllSkinStyleFiles
+ * @covers ResourceLoaderFileModule::getSkinStyleFiles
+ */
+ public function testGetAllSkinStyleFiles() {
+ $baseParams = [
+ 'scripts' => [
+ 'foo.js',
+ 'bar.js',
+ ],
+ 'styles' => [
+ 'foo.css',
+ 'bar.css' => [ 'media' => 'print' ],
+ 'screen.less' => [ 'media' => 'screen' ],
+ 'screen-query.css' => [ 'media' => 'screen and (min-width: 400px)' ],
+ ],
+ 'skinStyles' => [
+ 'default' => 'quux-fallback.less',
+ 'fakeskin' => [
+ 'baz-vector.css',
+ 'quux-vector.less',
+ ],
+ ],
+ 'messages' => [
+ 'hello',
+ 'world',
+ ],
+ ];
+
+ $module = new ResourceLoaderFileModule( $baseParams );
+ $module->setName( 'testing' );
+
+ $this->assertEquals(
+ [
+ 'foo.css',
+ 'baz-vector.css',
+ 'quux-vector.less',
+ 'quux-fallback.less',
+ 'bar.css',
+ 'screen.less',
+ 'screen-query.css',
+ ],
+ array_map( 'basename', $module->getAllStyleFiles() )
+ );
+ }
+
+ /**
+ * Strip @noflip annotations from CSS code.
+ * @param string $css
+ * @return string
+ */
+ private static function stripNoflip( $css ) {
+ return str_replace( '/*@noflip*/ ', '', $css );
+ }
+
+ /**
+ * What happens when you mix @embed and @noflip?
+ * This really is an integration test, but oh well.
+ *
+ * @covers ResourceLoaderFileModule::getStyles
+ * @covers ResourceLoaderFileModule::getStyleFiles
+ */
+ public function testMixedCssAnnotations() {
+ $basePath = __DIR__ . '/../../data/css';
+ $testModule = new ResourceLoaderFileModule( [
+ 'localBasePath' => $basePath,
+ 'styles' => [ 'test.css' ],
+ ] );
+ $testModule->setName( 'testing' );
+ $expectedModule = new ResourceLoaderFileModule( [
+ 'localBasePath' => $basePath,
+ 'styles' => [ 'expected.css' ],
+ ] );
+ $expectedModule->setName( 'testing' );
+
+ $contextLtr = $this->getResourceLoaderContext( [
+ 'lang' => 'en',
+ 'dir' => 'ltr',
+ ] );
+ $contextRtl = $this->getResourceLoaderContext( [
+ 'lang' => 'he',
+ 'dir' => 'rtl',
+ ] );
+
+ // Since we want to compare the effect of @noflip+@embed against the effect of just @embed, and
+ // the @noflip annotations are always preserved, we need to strip them first.
+ $this->assertEquals(
+ $expectedModule->getStyles( $contextLtr ),
+ self::stripNoflip( $testModule->getStyles( $contextLtr ) ),
+ "/*@noflip*/ with /*@embed*/ gives correct results in LTR mode"
+ );
+ $this->assertEquals(
+ $expectedModule->getStyles( $contextLtr ),
+ self::stripNoflip( $testModule->getStyles( $contextRtl ) ),
+ "/*@noflip*/ with /*@embed*/ gives correct results in RTL mode"
+ );
+ }
+
+ public static function providerGetTemplates() {
+ $modules = self::getModules();
+
+ return [
+ [
+ $modules['noTemplateModule'],
+ [],
+ ],
+ [
+ $modules['templateModuleHandlebars'],
+ [
+ 'templates/template_awesome.handlebars' => "wow\n",
+ ],
+ ],
+ [
+ $modules['htmlTemplateModule'],
+ [
+ 'templates/template.html' => "<strong>hello</strong>\n",
+ 'templates/template2.html' => "<div>goodbye</div>\n",
+ ],
+ ],
+ [
+ $modules['aliasedHtmlTemplateModule'],
+ [
+ 'foo.html' => "<strong>hello</strong>\n",
+ 'bar.html' => "<div>goodbye</div>\n",
+ ],
+ ],
+ [
+ $modules['htmlTemplateUnknown'],
+ false,
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providerGetTemplates
+ * @covers ResourceLoaderFileModule::getTemplates
+ */
+ public function testGetTemplates( $module, $expected ) {
+ $rl = new ResourceLoaderFileModule( $module );
+ $rl->setName( 'testing' );
+
+ if ( $expected === false ) {
+ $this->setExpectedException( MWException::class );
+ $rl->getTemplates();
+ } else {
+ $this->assertEquals( $rl->getTemplates(), $expected );
+ }
+ }
+
+ /**
+ * @covers ResourceLoaderFileModule::stripBom
+ */
+ public function testBomConcatenation() {
+ $basePath = __DIR__ . '/../../data/css';
+ $testModule = new ResourceLoaderFileModule( [
+ 'localBasePath' => $basePath,
+ 'styles' => [ 'bom.css' ],
+ ] );
+ $testModule->setName( 'testing' );
+ $this->assertEquals(
+ substr( file_get_contents( "$basePath/bom.css" ), 0, 10 ),
+ "\xef\xbb\xbf.efbbbf",
+ 'File has leading BOM'
+ );
+
+ $context = $this->getResourceLoaderContext();
+ $this->assertEquals(
+ $testModule->getStyles( $context ),
+ [ 'all' => ".efbbbf_bom_char_at_start_of_file {}\n" ],
+ 'Leading BOM removed when concatenating files'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderFileModule::getDefinitionSummary
+ */
+ public function testGetVersionHash() {
+ $context = $this->getResourceLoaderContext();
+
+ // Less variables
+ $module = new ResourceLoaderFileTestModule();
+ $version = $module->getVersionHash( $context );
+ $module = new ResourceLoaderFileTestModule( [], [
+ 'lessVars' => [ 'key' => 'value' ],
+ ] );
+ $this->assertNotEquals(
+ $version,
+ $module->getVersionHash( $context ),
+ 'Using less variables is significant'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php
new file mode 100644
index 00000000..3f5704d6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php
@@ -0,0 +1,265 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group ResourceLoader
+ */
+class ResourceLoaderImageModuleTest extends ResourceLoaderTestCase {
+
+ public static $commonImageData = [
+ 'abc' => 'abc.gif',
+ 'def' => [
+ 'file' => 'def.svg',
+ 'variants' => [ 'destructive' ],
+ ],
+ 'ghi' => [
+ 'file' => [
+ 'ltr' => 'ghi.svg',
+ 'rtl' => 'jkl.svg'
+ ],
+ ],
+ 'mno' => [
+ 'file' => [
+ 'ltr' => 'mno-ltr.svg',
+ 'rtl' => 'mno-rtl.svg',
+ 'lang' => [
+ 'he' => 'mno-ltr.svg',
+ ]
+ ],
+ ],
+ 'pqr' => [
+ 'file' => [
+ 'default' => 'pqr-a.svg',
+ 'lang' => [
+ 'en' => 'pqr-b.svg',
+ 'ar,de' => 'pqr-f.svg',
+ ]
+ ],
+ ]
+ ];
+
+ public static $commonImageVariants = [
+ 'invert' => [
+ 'color' => '#FFFFFF',
+ 'global' => true,
+ ],
+ 'primary' => [
+ 'color' => '#598AD1',
+ ],
+ 'constructive' => [
+ 'color' => '#00C697',
+ ],
+ 'destructive' => [
+ 'color' => '#E81915',
+ ],
+ ];
+
+ public static function providerGetModules() {
+ return [
+ [
+ [
+ 'class' => ResourceLoaderImageModule::class,
+ 'prefix' => 'oo-ui-icon',
+ 'variants' => self::$commonImageVariants,
+ 'images' => self::$commonImageData,
+ ],
+ '.oo-ui-icon-abc {
+ ...
+}
+.oo-ui-icon-abc-invert {
+ ...
+}
+.oo-ui-icon-def {
+ ...
+}
+.oo-ui-icon-def-invert {
+ ...
+}
+.oo-ui-icon-def-destructive {
+ ...
+}
+.oo-ui-icon-ghi {
+ ...
+}
+.oo-ui-icon-ghi-invert {
+ ...
+}
+.oo-ui-icon-mno {
+ ...
+}
+.oo-ui-icon-mno-invert {
+ ...
+}
+.oo-ui-icon-pqr {
+ ...
+}
+.oo-ui-icon-pqr-invert {
+ ...
+}',
+ ],
+ [
+ [
+ 'class' => ResourceLoaderImageModule::class,
+ 'selectorWithoutVariant' => '.mw-ui-icon-{name}:after, .mw-ui-icon-{name}:before',
+ 'selectorWithVariant' =>
+ '.mw-ui-icon-{name}-{variant}:after, .mw-ui-icon-{name}-{variant}:before',
+ 'variants' => self::$commonImageVariants,
+ 'images' => self::$commonImageData,
+ ],
+ '.mw-ui-icon-abc:after, .mw-ui-icon-abc:before {
+ ...
+}
+.mw-ui-icon-abc-invert:after, .mw-ui-icon-abc-invert:before {
+ ...
+}
+.mw-ui-icon-def:after, .mw-ui-icon-def:before {
+ ...
+}
+.mw-ui-icon-def-invert:after, .mw-ui-icon-def-invert:before {
+ ...
+}
+.mw-ui-icon-def-destructive:after, .mw-ui-icon-def-destructive:before {
+ ...
+}
+.mw-ui-icon-ghi:after, .mw-ui-icon-ghi:before {
+ ...
+}
+.mw-ui-icon-ghi-invert:after, .mw-ui-icon-ghi-invert:before {
+ ...
+}
+.mw-ui-icon-mno:after, .mw-ui-icon-mno:before {
+ ...
+}
+.mw-ui-icon-mno-invert:after, .mw-ui-icon-mno-invert:before {
+ ...
+}
+.mw-ui-icon-pqr:after, .mw-ui-icon-pqr:before {
+ ...
+}
+.mw-ui-icon-pqr-invert:after, .mw-ui-icon-pqr-invert:before {
+ ...
+}',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providerGetModules
+ * @covers ResourceLoaderImageModule::getStyles
+ */
+ public function testGetStyles( $module, $expected ) {
+ $module = new ResourceLoaderImageModuleTestable(
+ $module,
+ __DIR__ . '/../../data/resourceloader'
+ );
+ $styles = $module->getStyles( $this->getResourceLoaderContext() );
+ $this->assertEquals( $expected, $styles['all'] );
+ }
+
+ /**
+ * @covers ResourceLoaderContext::getImageObj
+ */
+ public function testContext() {
+ $context = new ResourceLoaderContext( new EmptyResourceLoader(), new FauxRequest() );
+ $this->assertFalse( $context->getImageObj(), 'Missing image parameter' );
+
+ $context = new ResourceLoaderContext( new EmptyResourceLoader(), new FauxRequest( [
+ 'image' => 'example',
+ ] ) );
+ $this->assertFalse( $context->getImageObj(), 'Missing module parameter' );
+
+ $context = new ResourceLoaderContext( new EmptyResourceLoader(), new FauxRequest( [
+ 'modules' => 'unknown',
+ 'image' => 'example',
+ ] ) );
+ $this->assertFalse( $context->getImageObj(), 'Not an image module' );
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'test', [
+ 'class' => ResourceLoaderImageModule::class,
+ 'prefix' => 'test',
+ 'images' => [ 'example' => 'example.png' ],
+ ] );
+ $context = new ResourceLoaderContext( $rl, new FauxRequest( [
+ 'modules' => 'test',
+ 'image' => 'unknown',
+ ] ) );
+ $this->assertFalse( $context->getImageObj(), 'Unknown image' );
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'test', [
+ 'class' => ResourceLoaderImageModule::class,
+ 'prefix' => 'test',
+ 'images' => [ 'example' => 'example.png' ],
+ ] );
+ $context = new ResourceLoaderContext( $rl, new FauxRequest( [
+ 'modules' => 'test',
+ 'image' => 'example',
+ ] ) );
+ $this->assertInstanceOf( ResourceLoaderImage::class, $context->getImageObj() );
+ }
+
+ public static function providerGetStyleDeclarations() {
+ return [
+ [
+ false,
+<<<TEXT
+background-image: url(rasterized.png);
+ background-image: linear-gradient(transparent, transparent), url(original.svg);
+TEXT
+ ],
+ [
+ 'data:image/svg+xml',
+<<<TEXT
+background-image: url(rasterized.png);
+ background-image: linear-gradient(transparent, transparent), url(data:image/svg+xml);
+TEXT
+ ],
+
+ ];
+ }
+
+ /**
+ * @dataProvider providerGetStyleDeclarations
+ * @covers ResourceLoaderImageModule::getStyleDeclarations
+ */
+ public function testGetStyleDeclarations( $dataUriReturnValue, $expected ) {
+ $module = TestingAccessWrapper::newFromObject( new ResourceLoaderImageModule() );
+ $context = $this->getResourceLoaderContext();
+ $image = $this->getImageMock( $context, $dataUriReturnValue );
+
+ $styles = $module->getStyleDeclarations(
+ $context,
+ $image,
+ 'load.php'
+ );
+
+ $this->assertEquals( $expected, $styles );
+ }
+
+ private function getImageMock( ResourceLoaderContext $context, $dataUriReturnValue ) {
+ $image = $this->getMockBuilder( ResourceLoaderImage::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $image->method( 'getDataUri' )
+ ->will( $this->returnValue( $dataUriReturnValue ) );
+ $image->expects( $this->any() )
+ ->method( 'getUrl' )
+ ->will( $this->returnValueMap( [
+ [ $context, 'load.php', null, 'original', 'original.svg' ],
+ [ $context, 'load.php', null, 'rasterized', 'rasterized.png' ],
+ ] ) );
+
+ return $image;
+ }
+}
+
+class ResourceLoaderImageModuleTestable extends ResourceLoaderImageModule {
+ /**
+ * Replace with a stub to make test cases easier to write.
+ */
+ protected function getCssDeclarations( $primary, $fallback ) {
+ return [ '...' ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php
new file mode 100644
index 00000000..35c3ef64
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php
@@ -0,0 +1,136 @@
+<?php
+
+/**
+ * @group ResourceLoader
+ */
+class ResourceLoaderImageTest extends ResourceLoaderTestCase {
+
+ protected $imagesPath;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->imagesPath = __DIR__ . '/../../data/resourceloader';
+ }
+
+ protected function getTestImage( $name ) {
+ $options = ResourceLoaderImageModuleTest::$commonImageData[$name];
+ $fileDescriptor = is_string( $options ) ? $options : $options['file'];
+ $allowedVariants = ( is_array( $options ) && isset( $options['variants'] ) ) ?
+ $options['variants'] : [];
+ $variants = array_fill_keys( $allowedVariants, [ 'color' => 'red' ] );
+ return new ResourceLoaderImageTestable(
+ $name,
+ 'test',
+ $fileDescriptor,
+ $this->imagesPath,
+ $variants
+ );
+ }
+
+ public static function provideGetPath() {
+ return [
+ [ 'abc', 'en', 'abc.gif' ],
+ [ 'abc', 'he', 'abc.gif' ],
+ [ 'def', 'en', 'def.svg' ],
+ [ 'def', 'he', 'def.svg' ],
+ [ 'ghi', 'en', 'ghi.svg' ],
+ [ 'ghi', 'he', 'jkl.svg' ],
+ [ 'mno', 'en', 'mno-ltr.svg' ],
+ [ 'mno', 'ar', 'mno-rtl.svg' ],
+ [ 'mno', 'he', 'mno-ltr.svg' ],
+ [ 'pqr', 'en', 'pqr-b.svg' ],
+ [ 'pqr', 'en-gb', 'pqr-b.svg' ],
+ [ 'pqr', 'de', 'pqr-f.svg' ],
+ [ 'pqr', 'de-formal', 'pqr-f.svg' ],
+ [ 'pqr', 'ar', 'pqr-f.svg' ],
+ [ 'pqr', 'fr', 'pqr-a.svg' ],
+ [ 'pqr', 'he', 'pqr-a.svg' ],
+ ];
+ }
+
+ /**
+ * @covers ResourceLoaderImage::getPath
+ * @dataProvider provideGetPath
+ */
+ public function testGetPath( $imageName, $languageCode, $path ) {
+ static $dirMap = [
+ 'en' => 'ltr',
+ 'en-gb' => 'ltr',
+ 'de' => 'ltr',
+ 'de-formal' => 'ltr',
+ 'fr' => 'ltr',
+ 'he' => 'rtl',
+ 'ar' => 'rtl',
+ ];
+ static $contexts = [];
+
+ $image = $this->getTestImage( $imageName );
+ $context = $this->getResourceLoaderContext( [
+ 'lang' => $languageCode,
+ 'dir' => $dirMap[$languageCode],
+ ] );
+
+ $this->assertEquals( $image->getPath( $context ), $this->imagesPath . '/' . $path );
+ }
+
+ /**
+ * @covers ResourceLoaderImage::getExtension
+ * @covers ResourceLoaderImage::getMimeType
+ */
+ public function testGetExtension() {
+ $image = $this->getTestImage( 'def' );
+ $this->assertEquals( $image->getExtension(), 'svg' );
+ $this->assertEquals( $image->getExtension( 'original' ), 'svg' );
+ $this->assertEquals( $image->getExtension( 'rasterized' ), 'png' );
+ $image = $this->getTestImage( 'abc' );
+ $this->assertEquals( $image->getExtension(), 'gif' );
+ $this->assertEquals( $image->getExtension( 'original' ), 'gif' );
+ $this->assertEquals( $image->getExtension( 'rasterized' ), 'gif' );
+ }
+
+ /**
+ * @covers ResourceLoaderImage::getImageData
+ * @covers ResourceLoaderImage::variantize
+ * @covers ResourceLoaderImage::massageSvgPathdata
+ */
+ public function testGetImageData() {
+ $context = $this->getResourceLoaderContext();
+
+ $image = $this->getTestImage( 'def' );
+ $data = file_get_contents( $this->imagesPath . '/def.svg' );
+ $dataConstructive = file_get_contents( $this->imagesPath . '/def_variantize.svg' );
+ $this->assertEquals( $image->getImageData( $context, null, 'original' ), $data );
+ $this->assertEquals(
+ $image->getImageData( $context, 'destructive', 'original' ),
+ $dataConstructive
+ );
+ // Stub, since we don't know if we even have a SVG handler, much less what exactly it'll output
+ $this->assertEquals( $image->getImageData( $context, null, 'rasterized' ), 'RASTERIZESTUB' );
+
+ $image = $this->getTestImage( 'abc' );
+ $data = file_get_contents( $this->imagesPath . '/abc.gif' );
+ $this->assertEquals( $image->getImageData( $context, null, 'original' ), $data );
+ $this->assertEquals( $image->getImageData( $context, null, 'rasterized' ), $data );
+ }
+
+ /**
+ * @covers ResourceLoaderImage::massageSvgPathdata
+ */
+ public function testMassageSvgPathdata() {
+ $image = $this->getTestImage( 'ghi' );
+ $data = file_get_contents( $this->imagesPath . '/ghi.svg' );
+ $dataMassaged = file_get_contents( $this->imagesPath . '/ghi_massage.svg' );
+ $this->assertEquals( $image->massageSvgPathdata( $data ), $dataMassaged );
+ }
+}
+
+class ResourceLoaderImageTestable extends ResourceLoaderImage {
+ // Make some protected methods public
+ public function massageSvgPathdata( $svg ) {
+ return parent::massageSvgPathdata( $svg );
+ }
+ // Stub, since we don't know if we even have a SVG handler, much less what exactly it'll output
+ public function rasterize( $svg ) {
+ return 'RASTERIZESTUB';
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php
new file mode 100644
index 00000000..c917882a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php
@@ -0,0 +1,224 @@
+<?php
+
+class ResourceLoaderModuleTest extends ResourceLoaderTestCase {
+
+ /**
+ * @covers ResourceLoaderModule::getVersionHash
+ * @covers ResourceLoaderModule::getModifiedTime
+ * @covers ResourceLoaderModule::getModifiedHash
+ */
+ public function testGetVersionHash() {
+ $context = $this->getResourceLoaderContext();
+
+ $baseParams = [
+ 'scripts' => [ 'foo.js', 'bar.js' ],
+ 'dependencies' => [ 'jquery', 'mediawiki' ],
+ 'messages' => [ 'hello', 'world' ],
+ ];
+
+ $module = new ResourceLoaderFileModule( $baseParams );
+ $version = json_encode( $module->getVersionHash( $context ) );
+
+ // Exactly the same
+ $module = new ResourceLoaderFileModule( $baseParams );
+ $this->assertEquals(
+ $version,
+ json_encode( $module->getVersionHash( $context ) ),
+ 'Instance is insignificant'
+ );
+
+ // Re-order dependencies
+ $module = new ResourceLoaderFileModule( [
+ 'dependencies' => [ 'mediawiki', 'jquery' ],
+ ] + $baseParams );
+ $this->assertEquals(
+ $version,
+ json_encode( $module->getVersionHash( $context ) ),
+ 'Order of dependencies is insignificant'
+ );
+
+ // Re-order messages
+ $module = new ResourceLoaderFileModule( [
+ 'messages' => [ 'world', 'hello' ],
+ ] + $baseParams );
+ $this->assertEquals(
+ $version,
+ json_encode( $module->getVersionHash( $context ) ),
+ 'Order of messages is insignificant'
+ );
+
+ // Re-order scripts
+ $module = new ResourceLoaderFileModule( [
+ 'scripts' => [ 'bar.js', 'foo.js' ],
+ ] + $baseParams );
+ $this->assertNotEquals(
+ $version,
+ json_encode( $module->getVersionHash( $context ) ),
+ 'Order of scripts is significant'
+ );
+
+ // Subclass
+ $module = new ResourceLoaderFileModuleTestModule( $baseParams );
+ $this->assertNotEquals(
+ $version,
+ json_encode( $module->getVersionHash( $context ) ),
+ 'Class is significant'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderModule::validateScriptFile
+ */
+ public function testValidateScriptFile() {
+ $this->setMwGlobals( 'wgResourceLoaderValidateJS', true );
+
+ $context = $this->getResourceLoaderContext();
+
+ $module = new ResourceLoaderTestModule( [
+ 'script' => "var a = 'this is';\n {\ninvalid"
+ ] );
+ $this->assertEquals(
+ 'mw.log.error(' .
+ '"JavaScript parse error: Parse error: Unexpected token; ' .
+ 'token } expected in file \'input\' on line 3"' .
+ ');',
+ $module->getScript( $context ),
+ 'Replace invalid syntax with error logging'
+ );
+
+ $module = new ResourceLoaderTestModule( [
+ 'script' => "\n'valid';"
+ ] );
+ $this->assertEquals(
+ "\n'valid';",
+ $module->getScript( $context ),
+ 'Leave valid scripts as-is'
+ );
+ }
+
+ public static function provideBuildContentScripts() {
+ return [
+ [
+ "mw.foo()",
+ "mw.foo()\n",
+ ],
+ [
+ "mw.foo();",
+ "mw.foo();\n",
+ ],
+ [
+ "mw.foo();\n",
+ "mw.foo();\n",
+ ],
+ [
+ "mw.foo()\n",
+ "mw.foo()\n",
+ ],
+ [
+ "mw.foo()\n// mw.bar();",
+ "mw.foo()\n// mw.bar();\n",
+ ],
+ [
+ "mw.foo()\n// mw.bar()",
+ "mw.foo()\n// mw.bar()\n",
+ ],
+ [
+ "mw.foo()// mw.bar();",
+ "mw.foo()// mw.bar();\n",
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideBuildContentScripts
+ * @covers ResourceLoaderModule::buildContent
+ */
+ public function testBuildContentScripts( $raw, $build, $message = null ) {
+ $context = $this->getResourceLoaderContext();
+ $module = new ResourceLoaderTestModule( [
+ 'script' => $raw
+ ] );
+ $this->assertEquals( $raw, $module->getScript( $context ), 'Raw script' );
+ $this->assertEquals(
+ [ 'scripts' => $build ],
+ $module->getModuleContent( $context ),
+ $message
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderModule::getRelativePaths
+ * @covers ResourceLoaderModule::expandRelativePaths
+ */
+ public function testPlaceholderize() {
+ $getRelativePaths = new ReflectionMethod( ResourceLoaderModule::class, 'getRelativePaths' );
+ $getRelativePaths->setAccessible( true );
+ $expandRelativePaths = new ReflectionMethod( ResourceLoaderModule::class, 'expandRelativePaths' );
+ $expandRelativePaths->setAccessible( true );
+
+ $this->setMwGlobals( [
+ 'IP' => '/srv/example/mediawiki/core',
+ ] );
+ $raw = [
+ '/srv/example/mediawiki/core/resources/foo.js',
+ '/srv/example/mediawiki/core/extensions/Example/modules/bar.js',
+ '/srv/example/mediawiki/skins/Example/baz.css',
+ '/srv/example/mediawiki/skins/Example/images/quux.png',
+ ];
+ $canonical = [
+ 'resources/foo.js',
+ 'extensions/Example/modules/bar.js',
+ '../skins/Example/baz.css',
+ '../skins/Example/images/quux.png',
+ ];
+ $this->assertEquals(
+ $canonical,
+ $getRelativePaths->invoke( null, $raw ),
+ 'Insert placeholders'
+ );
+ $this->assertEquals(
+ $raw,
+ $expandRelativePaths->invoke( null, $canonical ),
+ 'Substitute placeholders'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderModule::getHeaders
+ * @covers ResourceLoaderModule::getPreloadLinks
+ */
+ public function testGetHeaders() {
+ $context = $this->getResourceLoaderContext();
+
+ $module = new ResourceLoaderTestModule();
+ $this->assertSame( [], $module->getHeaders( $context ), 'Default' );
+
+ $module = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getPreloadLinks' ] )->getMock();
+ $module->method( 'getPreloadLinks' )->willReturn( [
+ 'https://example.org/script.js' => [ 'as' => 'script' ],
+ ] );
+ $this->assertSame(
+ [
+ 'Link: <https://example.org/script.js>;rel=preload;as=script'
+ ],
+ $module->getHeaders( $context ),
+ 'Preload one resource'
+ );
+
+ $module = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getPreloadLinks' ] )->getMock();
+ $module->method( 'getPreloadLinks' )->willReturn( [
+ 'https://example.org/script.js' => [ 'as' => 'script' ],
+ '/example.png' => [ 'as' => 'image' ],
+ ] );
+ $this->assertSame(
+ [
+ 'Link: <https://example.org/script.js>;rel=preload;as=script,' .
+ '</example.png>;rel=preload;as=image'
+ ],
+ $module->getHeaders( $context ),
+ 'Preload two resources'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderOOUIImageModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderOOUIImageModuleTest.php
new file mode 100644
index 00000000..ea220f11
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderOOUIImageModuleTest.php
@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * @group ResourceLoader
+ */
+class ResourceLoaderOOUIImageModuleTest extends ResourceLoaderTestCase {
+
+ /**
+ * @covers ResourceLoaderOOUIImageModule::loadFromDefinition
+ */
+ public function testNonDefaultSkin() {
+ $module = new ResourceLoaderOOUIImageModule( [
+ 'class' => ResourceLoaderOOUIImageModule::class,
+ 'name' => 'icons',
+ 'rootPath' => 'tests/phpunit/data/resourceloader/oouiimagemodule',
+ ] );
+
+ // Pretend that 'fakemonobook' is a real skin using the Apex theme
+ SkinFactory::getDefaultInstance()->register(
+ 'fakemonobook',
+ 'FakeMonoBook',
+ function () {
+ }
+ );
+ $r = new ReflectionMethod( ExtensionRegistry::class, 'exportExtractedData' );
+ $r->setAccessible( true );
+ $r->invoke( ExtensionRegistry::getInstance(), [
+ 'globals' => [],
+ 'defines' => [],
+ 'callbacks' => [],
+ 'credits' => [],
+ 'autoloaderPaths' => [],
+ 'attributes' => [
+ 'SkinOOUIThemes' => [
+ 'fakemonobook' => 'Apex',
+ ],
+ ],
+ ] );
+
+ $styles = $module->getStyles( $this->getResourceLoaderContext( [ 'skin' => 'fakemonobook' ] ) );
+ $this->assertRegExp(
+ '/stu-apex/',
+ $styles['all'],
+ 'Generated styles use the non-default image (embed)'
+ );
+ $this->assertRegExp(
+ '/fakemonobook/',
+ $styles['all'],
+ 'Generated styles use the non-default image (link)'
+ );
+
+ $styles = $module->getStyles( $this->getResourceLoaderContext() );
+ $this->assertRegExp(
+ '/stu-wikimediaui/',
+ $styles['all'],
+ 'Generated styles use the default image (embed)'
+ );
+ $this->assertRegExp(
+ '/vector/',
+ $styles['all'],
+ 'Generated styles use the default image (link)'
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php
new file mode 100644
index 00000000..a1b14220
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php
@@ -0,0 +1,207 @@
+<?php
+
+/**
+ * @group ResourceLoader
+ */
+class ResourceLoaderSkinModuleTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public static function provideGetStyles() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ 'parent' => [],
+ 'logo' => '/logo.png',
+ 'expected' => [
+ 'all' => [ '.mw-wiki-logo { background-image: url(/logo.png); }' ],
+ ],
+ ],
+ [
+ 'parent' => [
+ 'screen' => '.example {}',
+ ],
+ 'logo' => '/logo.png',
+ 'expected' => [
+ 'screen' => [ '.example {}' ],
+ 'all' => [ '.mw-wiki-logo { background-image: url(/logo.png); }' ],
+ ],
+ ],
+ [
+ 'parent' => [],
+ 'logo' => [
+ '1x' => '/logo.png',
+ '1.5x' => '/logo@1.5x.png',
+ '2x' => '/logo@2x.png',
+ ],
+ 'expected' => [
+ 'all' => [ <<<CSS
+.mw-wiki-logo { background-image: url(/logo.png); }
+CSS
+ ],
+ '(-webkit-min-device-pixel-ratio: 1.5), (min--moz-device-pixel-ratio: 1.5), (min-resolution: 1.5dppx), (min-resolution: 144dpi)' => [ <<<CSS
+.mw-wiki-logo { background-image: url(/logo@1.5x.png);background-size: 135px auto; }
+CSS
+ ],
+ '(-webkit-min-device-pixel-ratio: 2), (min--moz-device-pixel-ratio: 2), (min-resolution: 2dppx), (min-resolution: 192dpi)' => [ <<<CSS
+.mw-wiki-logo { background-image: url(/logo@2x.png);background-size: 135px auto; }
+CSS
+ ],
+ ],
+ ],
+ [
+ 'parent' => [],
+ 'logo' => [
+ '1x' => '/logo.png',
+ 'svg' => '/logo.svg',
+ ],
+ 'expected' => [
+ 'all' => [ <<<CSS
+.mw-wiki-logo { background-image: url(/logo.png); }
+CSS
+ , <<<CSS
+.mw-wiki-logo { background-image: -webkit-linear-gradient(transparent, transparent), url(/logo.svg); background-image: linear-gradient(transparent, transparent), url(/logo.svg);background-size: 135px auto; }
+CSS
+ ],
+ ],
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @dataProvider provideGetStyles
+ * @covers ResourceLoaderSkinModule::normalizeStyles
+ * @covers ResourceLoaderSkinModule::getStyles
+ */
+ public function testGetStyles( $parent, $logo, $expected ) {
+ $module = $this->getMockBuilder( ResourceLoaderSkinModule::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'readStyleFiles', 'getConfig', 'getLogoData' ] )
+ ->getMock();
+ $module->expects( $this->once() )->method( 'readStyleFiles' )
+ ->willReturn( $parent );
+ $module->expects( $this->once() )->method( 'getConfig' )
+ ->willReturn( new HashConfig() );
+ $module->expects( $this->once() )->method( 'getLogoData' )
+ ->willReturn( $logo );
+
+ $ctx = $this->getMockBuilder( ResourceLoaderContext::class )
+ ->disableOriginalConstructor()->getMock();
+
+ $this->assertEquals(
+ $expected,
+ $module->getStyles( $ctx )
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderSkinModule::isKnownEmpty
+ */
+ public function testIsKnownEmpty() {
+ $module = $this->getMockBuilder( ResourceLoaderSkinModule::class )
+ ->disableOriginalConstructor()->setMethods( null )->getMock();
+ $ctx = $this->getMockBuilder( ResourceLoaderContext::class )
+ ->disableOriginalConstructor()->getMock();
+
+ $this->assertFalse( $module->isKnownEmpty( $ctx ) );
+ }
+
+ /**
+ * @dataProvider provideGetLogo
+ * @covers ResourceLoaderSkinModule::getLogo
+ */
+ public function testGetLogo( $config, $expected, $baseDir = null ) {
+ if ( $baseDir ) {
+ $oldIP = $GLOBALS['IP'];
+ $GLOBALS['IP'] = $baseDir;
+ $teardown = new Wikimedia\ScopedCallback( function () use ( $oldIP ) {
+ $GLOBALS['IP'] = $oldIP;
+ } );
+ }
+
+ $this->assertEquals(
+ $expected,
+ ResourceLoaderSkinModule::getLogo( new HashConfig( $config ) )
+ );
+ }
+
+ public function provideGetLogo() {
+ return [
+ 'simple' => [
+ 'config' => [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => false,
+ ],
+ 'expected' => '/img/default.png',
+ ],
+ 'default and 2x' => [
+ 'config' => [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => [
+ '2x' => '/img/two-x.png',
+ ],
+ ],
+ 'expected' => [
+ '1x' => '/img/default.png',
+ '2x' => '/img/two-x.png',
+ ],
+ ],
+ 'default and all HiDPIs' => [
+ 'config' => [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => [
+ '1.5x' => '/img/one-point-five.png',
+ '2x' => '/img/two-x.png',
+ ],
+ ],
+ 'expected' => [
+ '1x' => '/img/default.png',
+ '1.5x' => '/img/one-point-five.png',
+ '2x' => '/img/two-x.png',
+ ],
+ ],
+ 'default and SVG' => [
+ 'config' => [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => [
+ 'svg' => '/img/vector.svg',
+ ],
+ ],
+ 'expected' => [
+ '1x' => '/img/default.png',
+ 'svg' => '/img/vector.svg',
+ ],
+ ],
+ 'everything' => [
+ 'config' => [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => [
+ '1.5x' => '/img/one-point-five.png',
+ '2x' => '/img/two-x.png',
+ 'svg' => '/img/vector.svg',
+ ],
+ ],
+ 'expected' => [
+ '1x' => '/img/default.png',
+ 'svg' => '/img/vector.svg',
+ ],
+ ],
+ 'versioned url' => [
+ 'config' => [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/w/test.jpg',
+ 'LogoHD' => false,
+ 'UploadPath' => '/w/images',
+ ],
+ 'expected' => '/w/test.jpg?edcf2',
+ 'baseDir' => dirname( dirname( __DIR__ ) ) . '/data/media',
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php
new file mode 100644
index 00000000..564f50bc
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php
@@ -0,0 +1,494 @@
+<?php
+
+class ResourceLoaderStartUpModuleTest extends ResourceLoaderTestCase {
+
+ protected static function expandPlaceholders( $text ) {
+ return strtr( $text, [
+ '{blankVer}' => self::BLANK_VERSION
+ ] );
+ }
+
+ public function provideGetModuleRegistrations() {
+ return [
+ [ [
+ 'msg' => 'Empty registry',
+ 'modules' => [],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [] );'
+ ] ],
+ [ [
+ 'msg' => 'Basic registry',
+ 'modules' => [
+ 'test.blank' => new ResourceLoaderTestModule(),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ]
+] );',
+ ] ],
+ [ [
+ 'msg' => 'Omit raw modules from registry',
+ 'modules' => [
+ 'test.raw' => new ResourceLoaderTestModule( [ 'isRaw' => true ] ),
+ 'test.blank' => new ResourceLoaderTestModule(),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ]
+] );',
+ ] ],
+ [ [
+ 'msg' => 'Version falls back gracefully if getVersionHash throws',
+ 'modules' => [
+ 'test.fail' => (
+ ( $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getVersionHash' ] )->getMock() )
+ && $mock->method( 'getVersionHash' )->will(
+ $this->throwException( new Exception )
+ )
+ ) ? $mock : $mock
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.fail",
+ ""
+ ]
+] );
+mw.loader.state( {
+ "test.fail": "error"
+} );',
+ ] ],
+ [ [
+ 'msg' => 'Use version from getVersionHash',
+ 'modules' => [
+ 'test.version' => (
+ ( $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getVersionHash' ] )->getMock() )
+ && $mock->method( 'getVersionHash' )->willReturn( '1234567' )
+ ) ? $mock : $mock
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.version",
+ "1234567"
+ ]
+] );',
+ ] ],
+ [ [
+ 'msg' => 'Re-hash version from getVersionHash if too long',
+ 'modules' => [
+ 'test.version' => (
+ ( $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getVersionHash' ] )->getMock() )
+ && $mock->method( 'getVersionHash' )->willReturn( '12345678' )
+ ) ? $mock : $mock
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.version",
+ "016es8l"
+ ]
+] );',
+ ] ],
+ [ [
+ 'msg' => 'Group signature',
+ 'modules' => [
+ 'test.blank' => new ResourceLoaderTestModule(),
+ 'test.group.foo' => new ResourceLoaderTestModule( [ 'group' => 'x-foo' ] ),
+ 'test.group.bar' => new ResourceLoaderTestModule( [ 'group' => 'x-bar' ] ),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ],
+ [
+ "test.group.foo",
+ "{blankVer}",
+ [],
+ "x-foo"
+ ],
+ [
+ "test.group.bar",
+ "{blankVer}",
+ [],
+ "x-bar"
+ ]
+] );'
+ ] ],
+ [ [
+ 'msg' => 'Different target (non-test should not be registered)',
+ 'modules' => [
+ 'test.blank' => new ResourceLoaderTestModule(),
+ 'test.target.foo' => new ResourceLoaderTestModule( [ 'targets' => [ 'x-foo' ] ] ),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ]
+] );'
+ ] ],
+ [ [
+ 'msg' => 'Foreign source',
+ 'sources' => [
+ 'example' => [
+ 'loadScript' => 'http://example.org/w/load.php',
+ 'apiScript' => 'http://example.org/w/api.php',
+ ],
+ ],
+ 'modules' => [
+ 'test.blank' => new ResourceLoaderTestModule( [ 'source' => 'example' ] ),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php",
+ "example": "http://example.org/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}",
+ [],
+ null,
+ "example"
+ ]
+] );'
+ ] ],
+ [ [
+ 'msg' => 'Conditional dependency function',
+ 'modules' => [
+ 'test.x.core' => new ResourceLoaderTestModule(),
+ 'test.x.polyfill' => new ResourceLoaderTestModule( [
+ 'skipFunction' => 'return true;'
+ ] ),
+ 'test.y.polyfill' => new ResourceLoaderTestModule( [
+ 'skipFunction' =>
+ 'return !!(' .
+ ' window.JSON &&' .
+ ' JSON.parse &&' .
+ ' JSON.stringify' .
+ ');'
+ ] ),
+ 'test.z.foo' => new ResourceLoaderTestModule( [
+ 'dependencies' => [
+ 'test.x.core',
+ 'test.x.polyfill',
+ 'test.y.polyfill',
+ ],
+ ] ),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.x.core",
+ "{blankVer}"
+ ],
+ [
+ "test.x.polyfill",
+ "{blankVer}",
+ [],
+ null,
+ null,
+ "return true;"
+ ],
+ [
+ "test.y.polyfill",
+ "{blankVer}",
+ [],
+ null,
+ null,
+ "return !!( window.JSON \u0026\u0026 JSON.parse \u0026\u0026 JSON.stringify);"
+ ],
+ [
+ "test.z.foo",
+ "{blankVer}",
+ [
+ 0,
+ 1,
+ 2
+ ]
+ ]
+] );',
+ ] ],
+ [ [
+ // This may seem like an edge case, but a plain MediaWiki core install
+ // with a few extensions installed is likely far more complex than this
+ // even, not to mention an install like Wikipedia.
+ // TODO: Make this even more realistic.
+ 'msg' => 'Advanced (everything combined)',
+ 'sources' => [
+ 'example' => [
+ 'loadScript' => 'http://example.org/w/load.php',
+ 'apiScript' => 'http://example.org/w/api.php',
+ ],
+ ],
+ 'modules' => [
+ 'test.blank' => new ResourceLoaderTestModule(),
+ 'test.x.core' => new ResourceLoaderTestModule(),
+ 'test.x.util' => new ResourceLoaderTestModule( [
+ 'dependencies' => [
+ 'test.x.core',
+ ],
+ ] ),
+ 'test.x.foo' => new ResourceLoaderTestModule( [
+ 'dependencies' => [
+ 'test.x.core',
+ ],
+ ] ),
+ 'test.x.bar' => new ResourceLoaderTestModule( [
+ 'dependencies' => [
+ 'test.x.core',
+ 'test.x.util',
+ ],
+ ] ),
+ 'test.x.quux' => new ResourceLoaderTestModule( [
+ 'dependencies' => [
+ 'test.x.foo',
+ 'test.x.bar',
+ 'test.x.util',
+ 'test.x.unknown',
+ ],
+ ] ),
+ 'test.group.foo.1' => new ResourceLoaderTestModule( [
+ 'group' => 'x-foo',
+ ] ),
+ 'test.group.foo.2' => new ResourceLoaderTestModule( [
+ 'group' => 'x-foo',
+ ] ),
+ 'test.group.bar.1' => new ResourceLoaderTestModule( [
+ 'group' => 'x-bar',
+ ] ),
+ 'test.group.bar.2' => new ResourceLoaderTestModule( [
+ 'group' => 'x-bar',
+ 'source' => 'example',
+ ] ),
+ 'test.target.foo' => new ResourceLoaderTestModule( [
+ 'targets' => [ 'x-foo' ],
+ ] ),
+ 'test.target.bar' => new ResourceLoaderTestModule( [
+ 'source' => 'example',
+ 'targets' => [ 'x-foo' ],
+ ] ),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php",
+ "example": "http://example.org/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ],
+ [
+ "test.x.core",
+ "{blankVer}"
+ ],
+ [
+ "test.x.util",
+ "{blankVer}",
+ [
+ 1
+ ]
+ ],
+ [
+ "test.x.foo",
+ "{blankVer}",
+ [
+ 1
+ ]
+ ],
+ [
+ "test.x.bar",
+ "{blankVer}",
+ [
+ 2
+ ]
+ ],
+ [
+ "test.x.quux",
+ "{blankVer}",
+ [
+ 3,
+ 4,
+ "test.x.unknown"
+ ]
+ ],
+ [
+ "test.group.foo.1",
+ "{blankVer}",
+ [],
+ "x-foo"
+ ],
+ [
+ "test.group.foo.2",
+ "{blankVer}",
+ [],
+ "x-foo"
+ ],
+ [
+ "test.group.bar.1",
+ "{blankVer}",
+ [],
+ "x-bar"
+ ],
+ [
+ "test.group.bar.2",
+ "{blankVer}",
+ [],
+ "x-bar",
+ "example"
+ ]
+] );'
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetModuleRegistrations
+ * @covers ResourceLoaderStartUpModule::getModuleRegistrations
+ * @covers ResourceLoaderStartUpModule::compileUnresolvedDependencies
+ * @covers ResourceLoader::makeLoaderRegisterScript
+ */
+ public function testGetModuleRegistrations( $case ) {
+ if ( isset( $case['sources'] ) ) {
+ $this->setMwGlobals( 'wgResourceLoaderSources', $case['sources'] );
+ }
+
+ $context = $this->getResourceLoaderContext();
+ $rl = $context->getResourceLoader();
+ $rl->register( $case['modules'] );
+ $module = new ResourceLoaderStartUpModule();
+ $out = ltrim( $case['out'], "\n" );
+
+ // Disable log from getModuleRegistrations via MWExceptionHandler
+ // for case where getVersionHash() is expected to throw.
+ $this->setLogger( 'exception', new Psr\Log\NullLogger() );
+
+ $this->assertEquals(
+ self::expandPlaceholders( $out ),
+ $module->getModuleRegistrations( $context ),
+ $case['msg']
+ );
+ }
+
+ public static function provideRegistrations() {
+ return [
+ [ [
+ 'test.blank' => new ResourceLoaderTestModule(),
+ 'test.min' => new ResourceLoaderTestModule( [
+ 'skipFunction' =>
+ 'return !!(' .
+ ' window.JSON &&' .
+ ' JSON.parse &&' .
+ ' JSON.stringify' .
+ ');',
+ 'dependencies' => [
+ 'test.blank',
+ ],
+ ] ),
+ ] ]
+ ];
+ }
+ /**
+ * @covers ResourceLoaderStartUpModule::getModuleRegistrations
+ * @dataProvider provideRegistrations
+ */
+ public function testRegistrationsMinified( $modules ) {
+ $this->setMwGlobals( 'wgResourceLoaderDebug', false );
+
+ $context = $this->getResourceLoaderContext();
+ $rl = $context->getResourceLoader();
+ $rl->register( $modules );
+ $module = new ResourceLoaderStartUpModule();
+ $out = 'mw.loader.addSource({"local":"/w/load.php"});' . "\n"
+ . 'mw.loader.register(['
+ . '["test.blank","{blankVer}"],'
+ . '["test.min","{blankVer}",[0],null,null,'
+ . '"return!!(window.JSON\u0026\u0026JSON.parse\u0026\u0026JSON.stringify);"'
+ . ']]);';
+
+ $this->assertEquals(
+ self::expandPlaceholders( $out ),
+ $module->getModuleRegistrations( $context ),
+ 'Minified output'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderStartUpModule::getModuleRegistrations
+ * @dataProvider provideRegistrations
+ */
+ public function testRegistrationsUnminified( $modules ) {
+ $context = $this->getResourceLoaderContext();
+ $rl = $context->getResourceLoader();
+ $rl->register( $modules );
+ $module = new ResourceLoaderStartUpModule();
+ $out =
+'mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ],
+ [
+ "test.min",
+ "{blankVer}",
+ [
+ 0
+ ],
+ null,
+ null,
+ "return !!( window.JSON \u0026\u0026 JSON.parse \u0026\u0026 JSON.stringify);"
+ ]
+] );';
+
+ $this->assertEquals(
+ self::expandPlaceholders( $out ),
+ $module->getModuleRegistrations( $context ),
+ 'Unminified output'
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php
new file mode 100644
index 00000000..4e9f5399
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php
@@ -0,0 +1,911 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+class ResourceLoaderTest extends ResourceLoaderTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgResourceLoaderLESSImportPaths' => [
+ dirname( dirname( __DIR__ ) ) . '/data/less/common',
+ ],
+ 'wgResourceLoaderLESSVars' => [
+ 'foo' => '2px',
+ 'Foo' => '#eeeeee',
+ 'bar' => 5,
+ ],
+ // Clear ResourceLoaderGetConfigVars hooks (called by StartupModule)
+ // to avoid notices during testMakeModuleResponse for missing
+ // wgResourceLoaderLESSVars keys in extension hooks.
+ 'wgHooks' => [],
+ 'wgShowExceptionDetails' => true,
+ ] );
+ }
+
+ /**
+ * Ensure the ResourceLoaderRegisterModules hook is called.
+ *
+ * @covers ResourceLoader::__construct
+ */
+ public function testConstructRegistrationHook() {
+ $resourceLoaderRegisterModulesHook = false;
+
+ $this->setMwGlobals( 'wgHooks', [
+ 'ResourceLoaderRegisterModules' => [
+ function ( &$resourceLoader ) use ( &$resourceLoaderRegisterModulesHook ) {
+ $resourceLoaderRegisterModulesHook = true;
+ }
+ ]
+ ] );
+
+ $unused = new ResourceLoader();
+ $this->assertTrue(
+ $resourceLoaderRegisterModulesHook,
+ 'Hook ResourceLoaderRegisterModules called'
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::register
+ * @covers ResourceLoader::getModule
+ */
+ public function testRegisterValidObject() {
+ $module = new ResourceLoaderTestModule();
+ $resourceLoader = new EmptyResourceLoader();
+ $resourceLoader->register( 'test', $module );
+ $this->assertEquals( $module, $resourceLoader->getModule( 'test' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::register
+ * @covers ResourceLoader::getModule
+ */
+ public function testRegisterValidArray() {
+ $module = new ResourceLoaderTestModule();
+ $resourceLoader = new EmptyResourceLoader();
+ // Covers case of register() setting $rl->moduleInfos,
+ // but $rl->modules lazy-populated by getModule()
+ $resourceLoader->register( 'test', [ 'object' => $module ] );
+ $this->assertEquals( $module, $resourceLoader->getModule( 'test' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::register
+ */
+ public function testRegisterEmptyString() {
+ $module = new ResourceLoaderTestModule();
+ $resourceLoader = new EmptyResourceLoader();
+ $resourceLoader->register( '', $module );
+ $this->assertEquals( $module, $resourceLoader->getModule( '' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::register
+ */
+ public function testRegisterInvalidName() {
+ $resourceLoader = new EmptyResourceLoader();
+ $this->setExpectedException( MWException::class, "name 'test!invalid' is invalid" );
+ $resourceLoader->register( 'test!invalid', new ResourceLoaderTestModule() );
+ }
+
+ /**
+ * @covers ResourceLoader::register
+ */
+ public function testRegisterInvalidType() {
+ $resourceLoader = new EmptyResourceLoader();
+ $this->setExpectedException( MWException::class, 'ResourceLoader module info type error' );
+ $resourceLoader->register( 'test', new stdClass() );
+ }
+
+ /**
+ * @covers ResourceLoader::getModuleNames
+ */
+ public function testGetModuleNames() {
+ // Use an empty one so that core and extension modules don't get in.
+ $resourceLoader = new EmptyResourceLoader();
+ $resourceLoader->register( 'test.foo', new ResourceLoaderTestModule() );
+ $resourceLoader->register( 'test.bar', new ResourceLoaderTestModule() );
+ $this->assertEquals(
+ [ 'test.foo', 'test.bar' ],
+ $resourceLoader->getModuleNames()
+ );
+ }
+
+ public function provideTestIsFileModule() {
+ $fileModuleObj = $this->getMockBuilder( ResourceLoaderFileModule::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ return [
+ 'object' => [ false,
+ new ResourceLoaderTestModule()
+ ],
+ 'FileModule object' => [ false,
+ $fileModuleObj
+ ],
+ 'simple empty' => [ true,
+ []
+ ],
+ 'simple scripts' => [ true,
+ [ 'scripts' => 'example.js' ]
+ ],
+ 'simple scripts, raw and targets' => [ true, [
+ 'scripts' => [ 'a.js', 'b.js' ],
+ 'raw' => true,
+ 'targets' => [ 'desktop', 'mobile' ],
+ ] ],
+ 'FileModule' => [ true,
+ [ 'class' => ResourceLoaderFileModule::class, 'scripts' => 'example.js' ]
+ ],
+ 'TestModule' => [ false,
+ [ 'class' => ResourceLoaderTestModule::class, 'scripts' => 'example.js' ]
+ ],
+ 'SkinModule (FileModule subclass)' => [ true,
+ [ 'class' => ResourceLoaderSkinModule::class, 'scripts' => 'example.js' ]
+ ],
+ 'JqueryMsgModule (FileModule subclass)' => [ true, [
+ 'class' => ResourceLoaderJqueryMsgModule::class,
+ 'scripts' => 'example.js',
+ ] ],
+ 'WikiModule' => [ false, [
+ 'class' => ResourceLoaderWikiModule::class,
+ 'scripts' => [ 'MediaWiki:Example.js' ],
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideTestIsFileModule
+ * @covers ResourceLoader::isFileModule
+ */
+ public function testIsFileModule( $expected, $module ) {
+ $rl = TestingAccessWrapper::newFromObject( new EmptyResourceLoader() );
+ $rl->register( 'test', $module );
+ $this->assertSame( $expected, $rl->isFileModule( 'test' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::isFileModule
+ */
+ public function testIsFileModuleUnknown() {
+ $rl = TestingAccessWrapper::newFromObject( new EmptyResourceLoader() );
+ $this->assertSame( false, $rl->isFileModule( 'unknown' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::isModuleRegistered
+ */
+ public function testIsModuleRegistered() {
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'test', new ResourceLoaderTestModule() );
+ $this->assertTrue( $rl->isModuleRegistered( 'test' ) );
+ $this->assertFalse( $rl->isModuleRegistered( 'test.unknown' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::getModule
+ */
+ public function testGetModuleUnknown() {
+ $rl = new EmptyResourceLoader();
+ $this->assertSame( null, $rl->getModule( 'test' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::getModule
+ */
+ public function testGetModuleClass() {
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'test', [ 'class' => ResourceLoaderTestModule::class ] );
+ $this->assertInstanceOf(
+ ResourceLoaderTestModule::class,
+ $rl->getModule( 'test' )
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::getModule
+ */
+ public function testGetModuleFactory() {
+ $factory = function ( array $info ) {
+ $this->assertArrayHasKey( 'kitten', $info );
+ return new ResourceLoaderTestModule( $info );
+ };
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'test', [ 'factory' => $factory, 'kitten' => 'little ball of fur' ] );
+ $this->assertInstanceOf(
+ ResourceLoaderTestModule::class,
+ $rl->getModule( 'test' )
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::getModule
+ */
+ public function testGetModuleClassDefault() {
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'test', [] );
+ $this->assertInstanceOf(
+ ResourceLoaderFileModule::class,
+ $rl->getModule( 'test' ),
+ 'Array-style module registrations default to FileModule'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderFileModule::compileLessFile
+ */
+ public function testLessFileCompilation() {
+ $context = $this->getResourceLoaderContext();
+ $basePath = __DIR__ . '/../../data/less/module';
+ $module = new ResourceLoaderFileModule( [
+ 'localBasePath' => $basePath,
+ 'styles' => [ 'styles.less' ],
+ ] );
+ $module->setName( 'test.less' );
+ $styles = $module->getStyles( $context );
+ $this->assertStringEqualsFile( $basePath . '/styles.css', $styles['all'] );
+ }
+
+ public static function providePackedModules() {
+ return [
+ [
+ 'Example from makePackedModulesString doc comment',
+ [ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ],
+ 'foo.bar,baz|bar.baz,quux',
+ ],
+ [
+ 'Example from expandModuleNames doc comment',
+ [ 'jquery.foo', 'jquery.bar', 'jquery.ui.baz', 'jquery.ui.quux' ],
+ 'jquery.foo,bar|jquery.ui.baz,quux',
+ ],
+ [
+ 'Regression fixed in r87497 (7fee86c38e) with dotless names',
+ [ 'foo', 'bar', 'baz' ],
+ 'foo,bar,baz',
+ ],
+ [
+ 'Prefixless modules after a prefixed module',
+ [ 'single.module', 'foobar', 'foobaz' ],
+ 'single.module|foobar,foobaz',
+ ],
+ [
+ 'Ordering',
+ [ 'foo', 'foo.baz', 'baz.quux', 'foo.bar' ],
+ 'foo|foo.baz,bar|baz.quux',
+ [ 'foo', 'foo.baz', 'foo.bar', 'baz.quux' ],
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider providePackedModules
+ * @covers ResourceLoader::makePackedModulesString
+ */
+ public function testMakePackedModulesString( $desc, $modules, $packed ) {
+ $this->assertEquals( $packed, ResourceLoader::makePackedModulesString( $modules ), $desc );
+ }
+
+ /**
+ * @dataProvider providePackedModules
+ * @covers ResourceLoaderContext::expandModuleNames
+ */
+ public function testExpandModuleNames( $desc, $modules, $packed, $unpacked = null ) {
+ $this->assertEquals(
+ $unpacked ?: $modules,
+ ResourceLoaderContext::expandModuleNames( $packed ),
+ $desc
+ );
+ }
+
+ public static function provideAddSource() {
+ return [
+ [ 'foowiki', 'https://example.org/w/load.php', 'foowiki' ],
+ [ 'foowiki', [ 'loadScript' => 'https://example.org/w/load.php' ], 'foowiki' ],
+ [
+ [
+ 'foowiki' => 'https://example.org/w/load.php',
+ 'bazwiki' => 'https://example.com/w/load.php',
+ ],
+ null,
+ [ 'foowiki', 'bazwiki' ]
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideAddSource
+ * @covers ResourceLoader::addSource
+ * @covers ResourceLoader::getSources
+ */
+ public function testAddSource( $name, $info, $expected ) {
+ $rl = new ResourceLoader;
+ $rl->addSource( $name, $info );
+ if ( is_array( $expected ) ) {
+ foreach ( $expected as $source ) {
+ $this->assertArrayHasKey( $source, $rl->getSources() );
+ }
+ } else {
+ $this->assertArrayHasKey( $expected, $rl->getSources() );
+ }
+ }
+
+ /**
+ * @covers ResourceLoader::addSource
+ */
+ public function testAddSourceDupe() {
+ $rl = new ResourceLoader;
+ $this->setExpectedException(
+ MWException::class, 'ResourceLoader duplicate source addition error'
+ );
+ $rl->addSource( 'foo', 'https://example.org/w/load.php' );
+ $rl->addSource( 'foo', 'https://example.com/w/load.php' );
+ }
+
+ /**
+ * @covers ResourceLoader::addSource
+ */
+ public function testAddSourceInvalid() {
+ $rl = new ResourceLoader;
+ $this->setExpectedException( MWException::class, 'with no "loadScript" key' );
+ $rl->addSource( 'foo', [ 'x' => 'https://example.org/w/load.php' ] );
+ }
+
+ public static function provideLoaderImplement() {
+ return [
+ [ [
+ 'title' => 'Implement scripts, styles and messages',
+
+ 'name' => 'test.example',
+ 'scripts' => 'mw.example();',
+ 'styles' => [ 'css' => [ '.mw-example {}' ] ],
+ 'messages' => [ 'example' => '' ],
+ 'templates' => [],
+
+ 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) {
+mw.example();
+}, {
+ "css": [
+ ".mw-example {}"
+ ]
+}, {
+ "example": ""
+} );',
+ ] ],
+ [ [
+ 'title' => 'Implement scripts',
+
+ 'name' => 'test.example',
+ 'scripts' => 'mw.example();',
+ 'styles' => [],
+
+ 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) {
+mw.example();
+} );',
+ ] ],
+ [ [
+ 'title' => 'Implement styles',
+
+ 'name' => 'test.example',
+ 'scripts' => [],
+ 'styles' => [ 'css' => [ '.mw-example {}' ] ],
+
+ 'expected' => 'mw.loader.implement( "test.example", [], {
+ "css": [
+ ".mw-example {}"
+ ]
+} );',
+ ] ],
+ [ [
+ 'title' => 'Implement scripts and messages',
+
+ 'name' => 'test.example',
+ 'scripts' => 'mw.example();',
+ 'messages' => [ 'example' => '' ],
+
+ 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) {
+mw.example();
+}, {}, {
+ "example": ""
+} );',
+ ] ],
+ [ [
+ 'title' => 'Implement scripts and templates',
+
+ 'name' => 'test.example',
+ 'scripts' => 'mw.example();',
+ 'templates' => [ 'example.html' => '' ],
+
+ 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) {
+mw.example();
+}, {}, {}, {
+ "example.html": ""
+} );',
+ ] ],
+ [ [
+ 'title' => 'Implement unwrapped user script',
+
+ 'name' => 'user',
+ 'scripts' => 'mw.example( 1 );',
+ 'wrap' => false,
+
+ 'expected' => 'mw.loader.implement( "user", "mw.example( 1 );" );',
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideLoaderImplement
+ * @covers ResourceLoader::makeLoaderImplementScript
+ * @covers ResourceLoader::trimArray
+ */
+ public function testMakeLoaderImplementScript( $case ) {
+ $case += [
+ 'wrap' => true,
+ 'styles' => [], 'templates' => [], 'messages' => new XmlJsCode( '{}' )
+ ];
+ ResourceLoader::clearCache();
+ $this->setMwGlobals( 'wgResourceLoaderDebug', true );
+
+ $rl = TestingAccessWrapper::newFromClass( ResourceLoader::class );
+ $this->assertEquals(
+ $case['expected'],
+ $rl->makeLoaderImplementScript(
+ $case['name'],
+ ( $case['wrap'] && is_string( $case['scripts'] ) )
+ ? new XmlJsCode( $case['scripts'] )
+ : $case['scripts'],
+ $case['styles'],
+ $case['messages'],
+ $case['templates']
+ )
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::makeLoaderImplementScript
+ */
+ public function testMakeLoaderImplementScriptInvalid() {
+ $this->setExpectedException( MWException::class, 'Invalid scripts error' );
+ $rl = TestingAccessWrapper::newFromClass( ResourceLoader::class );
+ $rl->makeLoaderImplementScript(
+ 'test', // name
+ 123, // scripts
+ null, // styles
+ null, // messages
+ null // templates
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::makeLoaderRegisterScript
+ */
+ public function testMakeLoaderRegisterScript() {
+ $this->assertEquals(
+ 'mw.loader.register( [
+ [
+ "test.name",
+ "1234567"
+ ]
+] );',
+ ResourceLoader::makeLoaderRegisterScript( [
+ [ 'test.name', '1234567' ],
+ ] ),
+ 'Nested array parameter'
+ );
+
+ $this->assertEquals(
+ 'mw.loader.register( "test.name", "1234567" );',
+ ResourceLoader::makeLoaderRegisterScript(
+ 'test.name',
+ '1234567'
+ ),
+ 'Variadic parameters'
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::makeLoaderSourcesScript
+ */
+ public function testMakeLoaderSourcesScript() {
+ $this->assertEquals(
+ 'mw.loader.addSource( "local", "/w/load.php" );',
+ ResourceLoader::makeLoaderSourcesScript( 'local', '/w/load.php' )
+ );
+ $this->assertEquals(
+ 'mw.loader.addSource( {
+ "local": "/w/load.php"
+} );',
+ ResourceLoader::makeLoaderSourcesScript( [ 'local' => '/w/load.php' ] )
+ );
+ $this->assertEquals(
+ 'mw.loader.addSource( {
+ "local": "/w/load.php",
+ "example": "https://example.org/w/load.php"
+} );',
+ ResourceLoader::makeLoaderSourcesScript( [
+ 'local' => '/w/load.php',
+ 'example' => 'https://example.org/w/load.php'
+ ] )
+ );
+ $this->assertEquals(
+ 'mw.loader.addSource( [] );',
+ ResourceLoader::makeLoaderSourcesScript( [] )
+ );
+ }
+
+ private static function fakeSources() {
+ return [
+ 'examplewiki' => [
+ 'loadScript' => '//example.org/w/load.php',
+ 'apiScript' => '//example.org/w/api.php',
+ ],
+ 'example2wiki' => [
+ 'loadScript' => '//example.com/w/load.php',
+ 'apiScript' => '//example.com/w/api.php',
+ ],
+ ];
+ }
+
+ /**
+ * @covers ResourceLoader::getLoadScript
+ */
+ public function testGetLoadScript() {
+ $this->setMwGlobals( 'wgResourceLoaderSources', [] );
+ $rl = new ResourceLoader();
+ $sources = self::fakeSources();
+ $rl->addSource( $sources );
+ foreach ( [ 'examplewiki', 'example2wiki' ] as $name ) {
+ $this->assertEquals( $rl->getLoadScript( $name ), $sources[$name]['loadScript'] );
+ }
+
+ try {
+ $rl->getLoadScript( 'thiswasneverreigstered' );
+ $this->assertTrue( false, 'ResourceLoader::getLoadScript should have thrown an exception' );
+ } catch ( MWException $e ) {
+ $this->assertTrue( true );
+ }
+ }
+
+ protected function getFailFerryMock() {
+ $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getScript' ] )
+ ->getMock();
+ $mock->method( 'getScript' )->will( $this->throwException(
+ new Exception( 'Ferry not found' )
+ ) );
+ return $mock;
+ }
+
+ protected function getSimpleModuleMock( $script = '' ) {
+ $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getScript' ] )
+ ->getMock();
+ $mock->method( 'getScript' )->willReturn( $script );
+ return $mock;
+ }
+
+ /**
+ * @covers ResourceLoader::getCombinedVersion
+ */
+ public function testGetCombinedVersion() {
+ $rl = $this->getMockBuilder( EmptyResourceLoader::class )
+ // Disable log from outputErrorAndLog
+ ->setMethods( [ 'outputErrorAndLog' ] )->getMock();
+ $rl->register( [
+ 'foo' => self::getSimpleModuleMock(),
+ 'ferry' => self::getFailFerryMock(),
+ 'bar' => self::getSimpleModuleMock(),
+ ] );
+ $context = $this->getResourceLoaderContext( [], $rl );
+
+ $this->assertEquals(
+ '',
+ $rl->getCombinedVersion( $context, [] ),
+ 'empty list'
+ );
+
+ $this->assertEquals(
+ ResourceLoader::makeHash( self::BLANK_VERSION ),
+ $rl->getCombinedVersion( $context, [ 'foo' ] ),
+ 'compute foo'
+ );
+
+ // Verify that getCombinedVersion() does not throw when ferry fails.
+ // Instead it gracefully continues to combine the remaining modules.
+ $this->assertEquals(
+ ResourceLoader::makeHash( self::BLANK_VERSION . self::BLANK_VERSION ),
+ $rl->getCombinedVersion( $context, [ 'foo', 'ferry', 'bar' ] ),
+ 'compute foo+ferry+bar (T152266)'
+ );
+ }
+
+ public static function provideMakeModuleResponseConcat() {
+ $testcases = [
+ [
+ 'modules' => [
+ 'foo' => 'foo()',
+ ],
+ 'expected' => "foo()\n" . 'mw.loader.state( {
+ "foo": "ready"
+} );',
+ 'minified' => "foo()\n" . 'mw.loader.state({"foo":"ready"});',
+ 'message' => 'Script without semi-colon',
+ ],
+ [
+ 'modules' => [
+ 'foo' => 'foo()',
+ 'bar' => 'bar()',
+ ],
+ 'expected' => "foo()\nbar()\n" . 'mw.loader.state( {
+ "foo": "ready",
+ "bar": "ready"
+} );',
+ 'minified' => "foo()\nbar()\n" . 'mw.loader.state({"foo":"ready","bar":"ready"});',
+ 'message' => 'Two scripts without semi-colon',
+ ],
+ [
+ 'modules' => [
+ 'foo' => "foo()\n// bar();"
+ ],
+ 'expected' => "foo()\n// bar();\n" . 'mw.loader.state( {
+ "foo": "ready"
+} );',
+ 'minified' => "foo()\n" . 'mw.loader.state({"foo":"ready"});',
+ 'message' => 'Script with semi-colon in comment (T162719)',
+ ],
+ ];
+ $ret = [];
+ foreach ( $testcases as $i => $case ) {
+ $ret["#$i"] = [
+ $case['modules'],
+ $case['expected'],
+ true, // debug
+ $case['message'],
+ ];
+ $ret["#$i (minified)"] = [
+ $case['modules'],
+ $case['minified'],
+ false, // debug
+ $case['message'],
+ ];
+ }
+ return $ret;
+ }
+
+ /**
+ * Verify how multiple scripts and mw.loader.state() calls are concatenated.
+ *
+ * @dataProvider provideMakeModuleResponseConcat
+ * @covers ResourceLoader::makeModuleResponse
+ */
+ public function testMakeModuleResponseConcat( $scripts, $expected, $debug, $message = null ) {
+ $rl = new EmptyResourceLoader();
+ $modules = array_map( function ( $script ) {
+ return self::getSimpleModuleMock( $script );
+ }, $scripts );
+ $rl->register( $modules );
+
+ $this->setMwGlobals( 'wgResourceLoaderDebug', $debug );
+ $context = $this->getResourceLoaderContext(
+ [
+ 'modules' => implode( '|', array_keys( $modules ) ),
+ 'only' => 'scripts',
+ ],
+ $rl
+ );
+
+ $response = $rl->makeModuleResponse( $context, $modules );
+ $this->assertSame( [], $rl->getErrors(), 'Errors' );
+ $this->assertEquals( $expected, $response, $message ?: 'Response' );
+ }
+
+ /**
+ * Verify that when building module content in a load.php response,
+ * an exception from one module will not break script output from
+ * other modules.
+ *
+ * @covers ResourceLoader::makeModuleResponse
+ */
+ public function testMakeModuleResponseError() {
+ $modules = [
+ 'foo' => self::getSimpleModuleMock( 'foo();' ),
+ 'ferry' => self::getFailFerryMock(),
+ 'bar' => self::getSimpleModuleMock( 'bar();' ),
+ ];
+ $rl = new EmptyResourceLoader();
+ $rl->register( $modules );
+ $context = $this->getResourceLoaderContext(
+ [
+ 'modules' => 'foo|ferry|bar',
+ 'only' => 'scripts',
+ ],
+ $rl
+ );
+
+ // Disable log from makeModuleResponse via outputErrorAndLog
+ $this->setLogger( 'exception', new Psr\Log\NullLogger() );
+
+ $response = $rl->makeModuleResponse( $context, $modules );
+ $errors = $rl->getErrors();
+
+ $this->assertCount( 1, $errors );
+ $this->assertRegExp( '/Ferry not found/', $errors[0] );
+ $this->assertEquals(
+ "foo();\nbar();\n" . 'mw.loader.state( {
+ "ferry": "error",
+ "foo": "ready",
+ "bar": "ready"
+} );',
+ $response
+ );
+ }
+
+ /**
+ * Verify that when building the startup module response,
+ * an exception from one module class will not break the entire
+ * startup module response. See T152266.
+ *
+ * @covers ResourceLoader::makeModuleResponse
+ */
+ public function testMakeModuleResponseStartupError() {
+ $rl = new EmptyResourceLoader();
+ $rl->register( [
+ 'foo' => self::getSimpleModuleMock( 'foo();' ),
+ 'ferry' => self::getFailFerryMock(),
+ 'bar' => self::getSimpleModuleMock( 'bar();' ),
+ 'startup' => [ 'class' => ResourceLoaderStartUpModule::class ],
+ ] );
+ $context = $this->getResourceLoaderContext(
+ [
+ 'modules' => 'startup',
+ 'only' => 'scripts',
+ ],
+ $rl
+ );
+
+ $this->assertEquals(
+ [ 'foo', 'ferry', 'bar', 'startup' ],
+ $rl->getModuleNames(),
+ 'getModuleNames'
+ );
+
+ // Disable log from makeModuleResponse via outputErrorAndLog
+ $this->setLogger( 'exception', new Psr\Log\NullLogger() );
+
+ $modules = [ 'startup' => $rl->getModule( 'startup' ) ];
+ $response = $rl->makeModuleResponse( $context, $modules );
+ $errors = $rl->getErrors();
+
+ $this->assertRegExp( '/Ferry not found/', $errors[0] );
+ $this->assertCount( 1, $errors );
+ $this->assertRegExp(
+ '/isCompatible.*function startUp/s',
+ $response,
+ 'startup response undisrupted (T152266)'
+ );
+ $this->assertRegExp(
+ '/register\([^)]+"ferry",\s*""/s',
+ $response,
+ 'startup response registers broken module'
+ );
+ $this->assertRegExp(
+ '/state\([^)]+"ferry":\s*"error"/s',
+ $response,
+ 'startup response sets state to error'
+ );
+ }
+
+ /**
+ * Integration test for modules sending extra HTTP response headers.
+ *
+ * @covers ResourceLoaderModule::getHeaders
+ * @covers ResourceLoaderModule::buildContent
+ * @covers ResourceLoader::makeModuleResponse
+ */
+ public function testMakeModuleResponseExtraHeaders() {
+ $module = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getPreloadLinks' ] )->getMock();
+ $module->method( 'getPreloadLinks' )->willReturn( [
+ 'https://example.org/script.js' => [ 'as' => 'script' ],
+ ] );
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( [
+ 'foo' => $module,
+ ] );
+ $context = $this->getResourceLoaderContext(
+ [ 'modules' => 'foo', 'only' => 'scripts' ],
+ $rl
+ );
+
+ $modules = [ 'foo' => $rl->getModule( 'foo' ) ];
+ $response = $rl->makeModuleResponse( $context, $modules );
+ $extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders;
+
+ $this->assertEquals(
+ [
+ 'Link: <https://example.org/script.js>;rel=preload;as=script'
+ ],
+ $extraHeaders,
+ 'Extra headers'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderModule::getHeaders
+ * @covers ResourceLoaderModule::buildContent
+ * @covers ResourceLoader::makeModuleResponse
+ */
+ public function testMakeModuleResponseExtraHeadersMulti() {
+ $foo = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getPreloadLinks' ] )->getMock();
+ $foo->method( 'getPreloadLinks' )->willReturn( [
+ 'https://example.org/script.js' => [ 'as' => 'script' ],
+ ] );
+
+ $bar = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getPreloadLinks' ] )->getMock();
+ $bar->method( 'getPreloadLinks' )->willReturn( [
+ '/example.png' => [ 'as' => 'image' ],
+ '/example.jpg' => [ 'as' => 'image' ],
+ ] );
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( [ 'foo' => $foo, 'bar' => $bar ] );
+ $context = $this->getResourceLoaderContext(
+ [ 'modules' => 'foo|bar', 'only' => 'scripts' ],
+ $rl
+ );
+
+ $modules = [ 'foo' => $rl->getModule( 'foo' ), 'bar' => $rl->getModule( 'bar' ) ];
+ $response = $rl->makeModuleResponse( $context, $modules );
+ $extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders;
+ $this->assertEquals(
+ [
+ 'Link: <https://example.org/script.js>;rel=preload;as=script',
+ 'Link: </example.png>;rel=preload;as=image,</example.jpg>;rel=preload;as=image'
+ ],
+ $extraHeaders,
+ 'Extra headers'
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::respond
+ */
+ public function testRespond() {
+ $rl = $this->getMockBuilder( EmptyResourceLoader::class )
+ ->setMethods( [
+ 'tryRespondNotModified',
+ 'sendResponseHeaders',
+ 'measureResponseTime',
+ ] )
+ ->getMock();
+ $context = $this->getResourceLoaderContext( [ 'modules' => '' ], $rl );
+
+ $rl->expects( $this->once() )->method( 'measureResponseTime' );
+ $this->expectOutputRegex( '/no modules were requested/' );
+
+ $rl->respond( $context );
+ }
+
+ /**
+ * @covers ResourceLoader::measureResponseTime
+ */
+ public function testMeasureResponseTime() {
+ $stats = $this->getMockBuilder( NullStatsdDataFactory::class )
+ ->setMethods( [ 'timing' ] )->getMock();
+ $this->setService( 'StatsdDataFactory', $stats );
+
+ $stats->expects( $this->once() )->method( 'timing' )
+ ->with( 'resourceloader.responseTime', $this->anything() );
+
+ $timing = new Timing();
+ $timing->mark( 'requestShutdown' );
+ $rl = TestingAccessWrapper::newFromObject( new EmptyResourceLoader );
+ $rl->measureResponseTime( $timing );
+ DeferredUpdates::doUpdates();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php
new file mode 100644
index 00000000..0aa37d23
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php
@@ -0,0 +1,380 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\TestingAccessWrapper;
+
+class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase {
+
+ /**
+ * @covers ResourceLoaderWikiModule::__construct
+ * @dataProvider provideConstructor
+ */
+ public function testConstructor( $params ) {
+ $module = new ResourceLoaderWikiModule( $params );
+ $this->assertInstanceOf( ResourceLoaderWikiModule::class, $module );
+ }
+
+ public static function provideConstructor() {
+ return [
+ // Nothing
+ [ null ],
+ [ [] ],
+ // Unrecognized settings
+ [ [ 'foo' => 'baz' ] ],
+ // Real settings
+ [ [ 'scripts' => [ 'MediaWiki:Common.js' ] ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetPages
+ * @covers ResourceLoaderWikiModule::getPages
+ */
+ public function testGetPages( $params, Config $config, $expected ) {
+ $module = new ResourceLoaderWikiModule( $params );
+ $module->setConfig( $config );
+
+ // Because getPages is protected..
+ $getPages = new ReflectionMethod( $module, 'getPages' );
+ $getPages->setAccessible( true );
+ $out = $getPages->invoke( $module, ResourceLoaderContext::newDummyContext() );
+ $this->assertEquals( $expected, $out );
+ }
+
+ public static function provideGetPages() {
+ $settings = self::getSettings() + [
+ 'UseSiteJs' => true,
+ 'UseSiteCss' => true,
+ ];
+
+ $params = [
+ 'styles' => [ 'MediaWiki:Common.css' ],
+ 'scripts' => [ 'MediaWiki:Common.js' ],
+ ];
+
+ return [
+ [ [], new HashConfig( $settings ), [] ],
+ [ $params, new HashConfig( $settings ), [
+ 'MediaWiki:Common.js' => [ 'type' => 'script' ],
+ 'MediaWiki:Common.css' => [ 'type' => 'style' ]
+ ] ],
+ [ $params, new HashConfig( [ 'UseSiteCss' => false ] + $settings ), [
+ 'MediaWiki:Common.js' => [ 'type' => 'script' ],
+ ] ],
+ [ $params, new HashConfig( [ 'UseSiteJs' => false ] + $settings ), [
+ 'MediaWiki:Common.css' => [ 'type' => 'style' ],
+ ] ],
+ [ $params,
+ new HashConfig(
+ [ 'UseSiteJs' => false, 'UseSiteCss' => false ]
+ ),
+ []
+ ],
+ ];
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::getGroup
+ * @dataProvider provideGetGroup
+ */
+ public function testGetGroup( $params, $expected ) {
+ $module = new ResourceLoaderWikiModule( $params );
+ $this->assertEquals( $expected, $module->getGroup() );
+ }
+
+ public static function provideGetGroup() {
+ return [
+ // No group specified
+ [ [], null ],
+ // A random group
+ [ [ 'group' => 'foobar' ], 'foobar' ],
+ ];
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::isKnownEmpty
+ * @dataProvider provideIsKnownEmpty
+ */
+ public function testIsKnownEmpty( $titleInfo, $group, $expected ) {
+ $module = $this->getMockBuilder( ResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getTitleInfo', 'getGroup' ] )
+ ->getMock();
+ $module->expects( $this->any() )
+ ->method( 'getTitleInfo' )
+ ->will( $this->returnValue( $titleInfo ) );
+ $module->expects( $this->any() )
+ ->method( 'getGroup' )
+ ->will( $this->returnValue( $group ) );
+ $context = $this->getMockBuilder( ResourceLoaderContext::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->assertEquals( $expected, $module->isKnownEmpty( $context ) );
+ }
+
+ public static function provideIsKnownEmpty() {
+ return [
+ // No valid pages
+ [ [], 'test1', true ],
+ // 'site' module with a non-empty page
+ [
+ [ 'MediaWiki:Common.js' => [ 'page_len' => 1234 ] ],
+ 'site',
+ false,
+ ],
+ // 'site' module with an empty page
+ [
+ [ 'MediaWiki:Foo.js' => [ 'page_len' => 0 ] ],
+ 'site',
+ false,
+ ],
+ // 'user' module with a non-empty page
+ [
+ [ 'User:Example/common.js' => [ 'page_len' => 25 ] ],
+ 'user',
+ false,
+ ],
+ // 'user' module with an empty page
+ [
+ [ 'User:Example/foo.js' => [ 'page_len' => 0 ] ],
+ 'user',
+ true,
+ ],
+ ];
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::getTitleInfo
+ */
+ public function testGetTitleInfo() {
+ $pages = [
+ 'MediaWiki:Common.css' => [ 'type' => 'styles' ],
+ 'mediawiki: fallback.css' => [ 'type' => 'styles' ],
+ ];
+ $titleInfo = [
+ 'MediaWiki:Common.css' => [ 'page_len' => 1234 ],
+ 'MediaWiki:Fallback.css' => [ 'page_len' => 0 ],
+ ];
+ $expected = $titleInfo;
+
+ $module = $this->getMockBuilder( TestResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getPages' ] )
+ ->getMock();
+ $module->method( 'getPages' )->willReturn( $pages );
+ // Can't mock static methods
+ $module::$returnFetchTitleInfo = $titleInfo;
+
+ $context = $this->getMockBuilder( ResourceLoaderContext::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $module = TestingAccessWrapper::newFromObject( $module );
+ $this->assertEquals( $expected, $module->getTitleInfo( $context ), 'Title info' );
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::getTitleInfo
+ * @covers ResourceLoaderWikiModule::setTitleInfo
+ * @covers ResourceLoaderWikiModule::preloadTitleInfo
+ */
+ public function testGetPreloadedTitleInfo() {
+ $pages = [
+ 'MediaWiki:Common.css' => [ 'type' => 'styles' ],
+ // Regression against T145673. It's impossible to statically declare page names in
+ // a canonical way since the canonical prefix is localised. As such, the preload
+ // cache computed the right cache key, but failed to find the results when
+ // doing an intersect on the canonical result, producing an empty array.
+ 'mediawiki: fallback.css' => [ 'type' => 'styles' ],
+ ];
+ $titleInfo = [
+ 'MediaWiki:Common.css' => [ 'page_len' => 1234 ],
+ 'MediaWiki:Fallback.css' => [ 'page_len' => 0 ],
+ ];
+ $expected = $titleInfo;
+
+ $module = $this->getMockBuilder( TestResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getPages' ] )
+ ->getMock();
+ $module->method( 'getPages' )->willReturn( $pages );
+ // Can't mock static methods
+ $module::$returnFetchTitleInfo = $titleInfo;
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'testmodule', $module );
+ $context = new ResourceLoaderContext( $rl, new FauxRequest() );
+
+ TestResourceLoaderWikiModule::invalidateModuleCache(
+ Title::newFromText( 'MediaWiki:Common.css' ),
+ null,
+ null,
+ wfWikiID()
+ );
+ TestResourceLoaderWikiModule::preloadTitleInfo(
+ $context,
+ wfGetDB( DB_REPLICA ),
+ [ 'testmodule' ]
+ );
+
+ $module = TestingAccessWrapper::newFromObject( $module );
+ $this->assertEquals( $expected, $module->getTitleInfo( $context ), 'Title info' );
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::preloadTitleInfo
+ */
+ public function testGetPreloadedBadTitle() {
+ // Mock values
+ $pages = [
+ // Covers else branch for invalid page name
+ '[x]' => [ 'type' => 'styles' ],
+ ];
+ $titleInfo = [];
+
+ // Set up objects
+ $module = $this->getMockBuilder( TestResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getPages' ] ) ->getMock();
+ $module->method( 'getPages' )->willReturn( $pages );
+ $module::$returnFetchTitleInfo = $titleInfo;
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'testmodule', $module );
+ $context = new ResourceLoaderContext( $rl, new FauxRequest() );
+
+ // Act
+ TestResourceLoaderWikiModule::preloadTitleInfo(
+ $context,
+ wfGetDB( DB_REPLICA ),
+ [ 'testmodule' ]
+ );
+
+ // Assert
+ $module = TestingAccessWrapper::newFromObject( $module );
+ $this->assertEquals( $titleInfo, $module->getTitleInfo( $context ), 'Title info' );
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::preloadTitleInfo
+ */
+ public function testGetPreloadedTitleInfoEmpty() {
+ $context = new ResourceLoaderContext( new EmptyResourceLoader(), new FauxRequest() );
+ // Covers early return
+ $this->assertSame(
+ null,
+ ResourceLoaderWikiModule::preloadTitleInfo(
+ $context,
+ wfGetDB( DB_REPLICA ),
+ []
+ )
+ );
+ }
+
+ public static function provideGetContent() {
+ return [
+ 'Bad title' => [ null, '[x]' ],
+ 'Dead redirect' => [ null, [
+ 'text' => 'Dead redirect',
+ 'title' => 'Dead_redirect',
+ 'redirect' => 1,
+ ] ],
+ 'Bad content model' => [ null, [
+ 'text' => 'MediaWiki:Wikitext',
+ 'ns' => NS_MEDIAWIKI,
+ 'title' => 'Wikitext',
+ ] ],
+ 'No JS content found' => [ null, [
+ 'text' => 'MediaWiki:Script.js',
+ 'ns' => NS_MEDIAWIKI,
+ 'title' => 'Script.js',
+ ] ],
+ 'No CSS content found' => [ null, [
+ 'text' => 'MediaWiki:Styles.css',
+ 'ns' => NS_MEDIAWIKI,
+ 'title' => 'Script.css',
+ ] ],
+ ];
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::getContent
+ * @dataProvider provideGetContent
+ */
+ public function testGetContent( $expected, $title ) {
+ $context = $this->getResourceLoaderContext( [], new EmptyResourceLoader );
+ $module = $this->getMockBuilder( ResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getContentObj' ] ) ->getMock();
+ $module->expects( $this->any() )
+ ->method( 'getContentObj' )->willReturn( null );
+
+ if ( is_array( $title ) ) {
+ $title += [ 'ns' => NS_MAIN, 'id' => 1, 'len' => 1, 'redirect' => 0 ];
+ $titleText = $title['text'];
+ // Mock Title db access via LinkCache
+ MediaWikiServices::getInstance()->getLinkCache()->addGoodLinkObj(
+ $title['id'],
+ new TitleValue( $title['ns'], $title['title'] ),
+ $title['len'],
+ $title['redirect']
+ );
+ } else {
+ $titleText = $title;
+ }
+
+ $module = TestingAccessWrapper::newFromObject( $module );
+ $this->assertEquals(
+ $expected,
+ $module->getContent( $titleText )
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::getContent
+ */
+ public function testGetContentForRedirects() {
+ // Set up context and module object
+ $context = $this->getResourceLoaderContext( [], new EmptyResourceLoader );
+ $module = $this->getMockBuilder( ResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getPages', 'getContentObj' ] )
+ ->getMock();
+ $module->expects( $this->any() )
+ ->method( 'getPages' )
+ ->will( $this->returnValue( [
+ 'MediaWiki:Redirect.js' => [ 'type' => 'script' ]
+ ] ) );
+ $module->expects( $this->any() )
+ ->method( 'getContentObj' )
+ ->will( $this->returnCallback( function ( Title $title ) {
+ if ( $title->getPrefixedText() === 'MediaWiki:Redirect.js' ) {
+ $handler = new JavaScriptContentHandler();
+ return $handler->makeRedirectContent(
+ Title::makeTitle( NS_MEDIAWIKI, 'Target.js' )
+ );
+ } elseif ( $title->getPrefixedText() === 'MediaWiki:Target.js' ) {
+ return new JavaScriptContent( 'target;' );
+ } else {
+ return null;
+ }
+ } ) );
+
+ // Mock away Title's db queries with LinkCache
+ MediaWikiServices::getInstance()->getLinkCache()->addGoodLinkObj(
+ 1, // id
+ new TitleValue( NS_MEDIAWIKI, 'Redirect.js' ),
+ 1, // len
+ 1 // redirect
+ );
+
+ $this->assertEquals(
+ "/*\nMediaWiki:Redirect.js\n*/\ntarget;\n",
+ $module->getScript( $context ),
+ 'Redirect resolved by getContent'
+ );
+ }
+}
+
+class TestResourceLoaderWikiModule extends ResourceLoaderWikiModule {
+ public static $returnFetchTitleInfo = null;
+ protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = null ) {
+ $ret = self::$returnFetchTitleInfo;
+ self::$returnFetchTitleInfo = null;
+ return $ret;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/templates/template.html b/www/wiki/tests/phpunit/includes/resourceloader/templates/template.html
new file mode 100644
index 00000000..1f6a7d22
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/templates/template.html
@@ -0,0 +1 @@
+<strong>hello</strong>
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/templates/template2.html b/www/wiki/tests/phpunit/includes/resourceloader/templates/template2.html
new file mode 100644
index 00000000..a322f67d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/templates/template2.html
@@ -0,0 +1 @@
+<div>goodbye</div>
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/templates/template_awesome.handlebars b/www/wiki/tests/phpunit/includes/resourceloader/templates/template_awesome.handlebars
new file mode 100644
index 00000000..5f5c07d5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/templates/template_awesome.handlebars
@@ -0,0 +1 @@
+wow
diff --git a/www/wiki/tests/phpunit/includes/search/ParserOutputSearchDataExtractorTest.php b/www/wiki/tests/phpunit/includes/search/ParserOutputSearchDataExtractorTest.php
new file mode 100644
index 00000000..69d0b76f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/search/ParserOutputSearchDataExtractorTest.php
@@ -0,0 +1,70 @@
+<?php
+
+use MediaWiki\Search\ParserOutputSearchDataExtractor;
+
+/**
+ * @group Search
+ * @covers MediaWiki\Search\ParserOutputSearchDataExtractor
+ */
+class ParserOutputSearchDataExtractorTest extends MediaWikiLangTestCase {
+
+ public function testGetCategories() {
+ $categories = [
+ 'Foo_bar' => 'Bar',
+ 'New_page' => ''
+ ];
+
+ $parserOutput = new ParserOutput( '', [], $categories );
+
+ $searchDataExtractor = new ParserOutputSearchDataExtractor();
+
+ $this->assertEquals(
+ [ 'Foo bar', 'New page' ],
+ $searchDataExtractor->getCategories( $parserOutput )
+ );
+ }
+
+ public function testGetExternalLinks() {
+ $parserOutput = new ParserOutput();
+
+ $parserOutput->addExternalLink( 'https://foo' );
+ $parserOutput->addExternalLink( 'https://bar' );
+
+ $searchDataExtractor = new ParserOutputSearchDataExtractor();
+
+ $this->assertEquals(
+ [ 'https://foo', 'https://bar' ],
+ $searchDataExtractor->getExternalLinks( $parserOutput )
+ );
+ }
+
+ public function testGetOutgoingLinks() {
+ $parserOutput = new ParserOutput();
+
+ $parserOutput->addLink( Title::makeTitle( NS_MAIN, 'Foo_bar' ), 1 );
+ $parserOutput->addLink( Title::makeTitle( NS_HELP, 'Contents' ), 2 );
+
+ $searchDataExtractor = new ParserOutputSearchDataExtractor();
+
+ // this indexes links with db key
+ $this->assertEquals(
+ [ 'Foo_bar', 'Help:Contents' ],
+ $searchDataExtractor->getOutgoingLinks( $parserOutput )
+ );
+ }
+
+ public function testGetTemplates() {
+ $title = Title::makeTitle( NS_TEMPLATE, 'Cite_news' );
+
+ $parserOutput = new ParserOutput();
+ $parserOutput->addTemplate( $title, 10, 100 );
+
+ $searchDataExtractor = new ParserOutputSearchDataExtractor();
+
+ $this->assertEquals(
+ [ 'Template:Cite news' ],
+ $searchDataExtractor->getTemplates( $parserOutput )
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/search/SearchEnginePrefixTest.php b/www/wiki/tests/phpunit/includes/search/SearchEnginePrefixTest.php
new file mode 100644
index 00000000..3f59295a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/search/SearchEnginePrefixTest.php
@@ -0,0 +1,362 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Search
+ * @group Database
+ */
+class SearchEnginePrefixTest extends MediaWikiLangTestCase {
+ private $originalHandlers;
+
+ /**
+ * @var SearchEngine
+ */
+ private $search;
+
+ public function addDBDataOnce() {
+ if ( !$this->isWikitextNS( NS_MAIN ) ) {
+ // tests are skipped if NS_MAIN is not wikitext
+ return;
+ }
+
+ $this->insertPage( 'Sandbox' );
+ $this->insertPage( 'Bar' );
+ $this->insertPage( 'Example' );
+ $this->insertPage( 'Example Bar' );
+ $this->insertPage( 'Example Foo' );
+ $this->insertPage( 'Example Foo/Bar' );
+ $this->insertPage( 'Example/Baz' );
+ $this->insertPage( 'Sample' );
+ $this->insertPage( 'Sample Ban' );
+ $this->insertPage( 'Sample Eat' );
+ $this->insertPage( 'Sample Who' );
+ $this->insertPage( 'Sample Zoo' );
+ $this->insertPage( 'Redirect test', '#REDIRECT [[Redirect Test]]' );
+ $this->insertPage( 'Redirect Test' );
+ $this->insertPage( 'Redirect Test Worse Result' );
+ $this->insertPage( 'Redirect test2', '#REDIRECT [[Redirect Test2]]' );
+ $this->insertPage( 'Redirect TEST2', '#REDIRECT [[Redirect Test2]]' );
+ $this->insertPage( 'Redirect Test2' );
+ $this->insertPage( 'Redirect Test2 Worse Result' );
+
+ $this->insertPage( 'Talk:Sandbox' );
+ $this->insertPage( 'Talk:Example' );
+
+ $this->insertPage( 'User:Example' );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ if ( !$this->isWikitextNS( NS_MAIN ) ) {
+ $this->markTestSkipped( 'Main namespace does not support wikitext.' );
+ }
+
+ // Avoid special pages from extensions interferring with the tests
+ $this->setMwGlobals( [
+ 'wgSpecialPages' => [],
+ 'wgHooks' => [],
+ ] );
+
+ $this->search = MediaWikiServices::getInstance()->newSearchEngine();
+ $this->search->setNamespaces( [] );
+
+ $this->originalHandlers = TestingAccessWrapper::newFromClass( Hooks::class )->handlers;
+ TestingAccessWrapper::newFromClass( Hooks::class )->handlers = [];
+
+ SpecialPageFactory::resetList();
+ }
+
+ public function tearDown() {
+ parent::tearDown();
+
+ TestingAccessWrapper::newFromClass( Hooks::class )->handlers = $this->originalHandlers;
+
+ SpecialPageFactory::resetList();
+ }
+
+ protected function searchProvision( array $results = null ) {
+ if ( $results === null ) {
+ $this->setMwGlobals( 'wgHooks', [] );
+ } else {
+ $this->setMwGlobals( 'wgHooks', [
+ 'PrefixSearchBackend' => [
+ function ( $namespaces, $search, $limit, &$srchres ) use ( $results ) {
+ $srchres = $results;
+ return false;
+ }
+ ],
+ ] );
+ }
+ }
+
+ public static function provideSearch() {
+ return [
+ [ [
+ 'Empty string',
+ 'query' => '',
+ 'results' => [],
+ ] ],
+ [ [
+ 'Main namespace with title prefix',
+ 'query' => 'Sa',
+ 'results' => [
+ 'Sample',
+ 'Sample Ban',
+ 'Sample Eat',
+ ],
+ // Third result when testing offset
+ 'offsetresult' => [
+ 'Sample Who',
+ ],
+ ] ],
+ [ [
+ 'Talk namespace prefix',
+ 'query' => 'Talk:',
+ 'results' => [
+ 'Talk:Example',
+ 'Talk:Sandbox',
+ ],
+ ] ],
+ [ [
+ 'User namespace prefix',
+ 'query' => 'User:',
+ 'results' => [
+ 'User:Example',
+ ],
+ ] ],
+ [ [
+ 'Special namespace prefix',
+ 'query' => 'Special:',
+ 'results' => [
+ 'Special:ActiveUsers',
+ 'Special:AllMessages',
+ 'Special:AllMyUploads',
+ ],
+ // Third result when testing offset
+ 'offsetresult' => [
+ 'Special:AllPages',
+ ],
+ ] ],
+ [ [
+ 'Special namespace with prefix',
+ 'query' => 'Special:Un',
+ 'results' => [
+ 'Special:Unblock',
+ 'Special:UncategorizedCategories',
+ 'Special:UncategorizedFiles',
+ ],
+ // Third result when testing offset
+ 'offsetresult' => [
+ 'Special:UncategorizedPages',
+ ],
+ ] ],
+ [ [
+ 'Special page name',
+ 'query' => 'Special:EditWatchlist',
+ 'results' => [
+ 'Special:EditWatchlist',
+ ],
+ ] ],
+ [ [
+ 'Special page subpages',
+ 'query' => 'Special:EditWatchlist/',
+ 'results' => [
+ 'Special:EditWatchlist/clear',
+ 'Special:EditWatchlist/raw',
+ ],
+ ] ],
+ [ [
+ 'Special page subpages with prefix',
+ 'query' => 'Special:EditWatchlist/cl',
+ 'results' => [
+ 'Special:EditWatchlist/clear',
+ ],
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideSearch
+ * @covers SearchEngine::defaultPrefixSearch
+ */
+ public function testSearch( array $case ) {
+ $this->search->setLimitOffset( 3 );
+ $results = $this->search->defaultPrefixSearch( $case['query'] );
+ $results = array_map( function ( Title $t ) {
+ return $t->getPrefixedText();
+ }, $results );
+
+ $this->assertEquals(
+ $case['results'],
+ $results,
+ $case[0]
+ );
+ }
+
+ /**
+ * @dataProvider provideSearch
+ * @covers SearchEngine::defaultPrefixSearch
+ */
+ public function testSearchWithOffset( array $case ) {
+ $this->search->setLimitOffset( 3, 1 );
+ $results = $this->search->defaultPrefixSearch( $case['query'] );
+ $results = array_map( function ( Title $t ) {
+ return $t->getPrefixedText();
+ }, $results );
+
+ // We don't expect the first result when offsetting
+ array_shift( $case['results'] );
+ // And sometimes we expect a different last result
+ $expected = isset( $case['offsetresult'] ) ?
+ array_merge( $case['results'], $case['offsetresult'] ) :
+ $case['results'];
+
+ $this->assertEquals(
+ $expected,
+ $results,
+ $case[0]
+ );
+ }
+
+ public static function provideSearchBackend() {
+ return [
+ [ [
+ 'Simple case',
+ 'provision' => [
+ 'Bar',
+ 'Barcelona',
+ 'Barbara',
+ ],
+ 'query' => 'Bar',
+ 'results' => [
+ 'Bar',
+ 'Barcelona',
+ 'Barbara',
+ ],
+ ] ],
+ [ [
+ 'Exact match not on top (T72958)',
+ 'provision' => [
+ 'Barcelona',
+ 'Bar',
+ 'Barbara',
+ ],
+ 'query' => 'Bar',
+ 'results' => [
+ 'Bar',
+ 'Barcelona',
+ 'Barbara',
+ ],
+ ] ],
+ [ [
+ 'Exact match missing (T72958)',
+ 'provision' => [
+ 'Barcelona',
+ 'Barbara',
+ 'Bart',
+ ],
+ 'query' => 'Bar',
+ 'results' => [
+ 'Bar',
+ 'Barcelona',
+ 'Barbara',
+ ],
+ ] ],
+ [ [
+ 'Exact match missing and not existing',
+ 'provision' => [
+ 'Exile',
+ 'Exist',
+ 'External',
+ ],
+ 'query' => 'Ex',
+ 'results' => [
+ 'Exile',
+ 'Exist',
+ 'External',
+ ],
+ ] ],
+ [ [
+ "Exact match shouldn't override already found match if " .
+ "exact is redirect and found isn't",
+ 'provision' => [
+ // Target of the exact match is low in the list
+ 'Redirect Test Worse Result',
+ 'Redirect Test',
+ ],
+ 'query' => 'redirect test',
+ 'results' => [
+ // Redirect target is pulled up and exact match isn't added
+ 'Redirect Test',
+ 'Redirect Test Worse Result',
+ ],
+ ] ],
+ [ [
+ "Exact match shouldn't override already found match if " .
+ "both exact match and found match are redirect",
+ 'provision' => [
+ // Another redirect to the same target as the exact match
+ // is low in the list
+ 'Redirect Test2 Worse Result',
+ 'Redirect test2',
+ ],
+ 'query' => 'redirect TEST2',
+ 'results' => [
+ // Found redirect is pulled to the top and exact match isn't
+ // added
+ 'Redirect test2',
+ 'Redirect Test2 Worse Result',
+ ],
+ ] ],
+ [ [
+ "Exact match should override any already found matches that " .
+ "are redirects to it",
+ 'provision' => [
+ // Another redirect to the same target as the exact match
+ // is low in the list
+ 'Redirect Test Worse Result',
+ 'Redirect test',
+ ],
+ 'query' => 'Redirect Test',
+ 'results' => [
+ // Found redirect is pulled to the top and exact match isn't
+ // added
+ 'Redirect Test',
+ 'Redirect Test Worse Result',
+ 'Redirect test',
+ ],
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideSearchBackend
+ * @covers PrefixSearch::searchBackend
+ */
+ public function testSearchBackend( array $case ) {
+ $search = $stub = $this->getMockBuilder( SearchEngine::class )
+ ->setMethods( [ 'completionSearchBackend' ] )->getMock();
+
+ $return = SearchSuggestionSet::fromStrings( $case['provision'] );
+
+ $search->expects( $this->any() )
+ ->method( 'completionSearchBackend' )
+ ->will( $this->returnValue( $return ) );
+
+ $search->setLimitOffset( 3 );
+ $results = $search->completionSearch( $case['query'] );
+
+ $results = $results->map( function ( SearchSuggestion $s ) {
+ return $s->getText();
+ } );
+
+ $this->assertEquals(
+ $case['results'],
+ $results,
+ $case[0]
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/search/SearchEngineTest.php b/www/wiki/tests/phpunit/includes/search/SearchEngineTest.php
new file mode 100644
index 00000000..b7bc1530
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/search/SearchEngineTest.php
@@ -0,0 +1,368 @@
+<?php
+
+/**
+ * @group Search
+ * @group Database
+ *
+ * @covers SearchEngine<extended>
+ * @note Coverage will only ever show one of on of the Search* classes
+ */
+class SearchEngineTest extends MediaWikiLangTestCase {
+
+ /**
+ * @var SearchEngine
+ */
+ protected $search;
+
+ /**
+ * Checks for database type & version.
+ * Will skip current test if DB does not support search.
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ // Search tests require MySQL or SQLite with FTS
+ $dbType = $this->db->getType();
+ $dbSupported = ( $dbType === 'mysql' )
+ || ( $dbType === 'sqlite' && $this->db->getFulltextSearchModule() == 'FTS3' );
+
+ if ( !$dbSupported ) {
+ $this->markTestSkipped( "MySQL or SQLite with FTS3 only" );
+ }
+
+ $searchType = SearchEngineFactory::getSearchEngineClass( $this->db );
+ $this->setMwGlobals( [
+ 'wgSearchType' => $searchType,
+ 'wgCapitalLinks' => true,
+ 'wgCapitalLinkOverrides' => [
+ NS_CATEGORY => false // for testCompletionSearchMustRespectCapitalLinkOverrides
+ ]
+ ] );
+
+ $this->search = new $searchType( $this->db );
+ }
+
+ protected function tearDown() {
+ unset( $this->search );
+
+ parent::tearDown();
+ }
+
+ public function addDBDataOnce() {
+ if ( !$this->isWikitextNS( NS_MAIN ) ) {
+ // @todo cover the case of non-wikitext content in the main namespace
+ return;
+ }
+
+ // Reset the search type back to default - some extensions may have
+ // overridden it.
+ $this->setMwGlobals( [
+ 'wgSearchType' => null,
+ 'wgCapitalLinks' => true,
+ 'wgCapitalLinkOverrides' => [
+ NS_CATEGORY => false // for testCompletionSearchMustRespectCapitalLinkOverrides
+ ]
+ ] );
+
+ $this->insertPage( 'Not_Main_Page', 'This is not a main page' );
+ $this->insertPage(
+ 'Talk:Not_Main_Page',
+ 'This is not a talk page to the main page, see [[smithee]]'
+ );
+ $this->insertPage( 'Smithee', 'A smithee is one who smiths. See also [[Alan Smithee]]' );
+ $this->insertPage( 'Talk:Smithee', 'This article sucks.' );
+ $this->insertPage( 'Unrelated_page', 'Nothing in this page is about the S word.' );
+ $this->insertPage( 'Another_page', 'This page also is unrelated.' );
+ $this->insertPage( 'Help:Help', 'Help me!' );
+ $this->insertPage( 'Thppt', 'Blah blah' );
+ $this->insertPage( 'Alan_Smithee', 'yum' );
+ $this->insertPage( 'Pages', 'are\'food' );
+ $this->insertPage( 'HalfOneUp', 'AZ' );
+ $this->insertPage( 'FullOneUp', 'AZ' );
+ $this->insertPage( 'HalfTwoLow', 'az' );
+ $this->insertPage( 'FullTwoLow', 'az' );
+ $this->insertPage( 'HalfNumbers', '1234567890' );
+ $this->insertPage( 'FullNumbers', '1234567890' );
+ $this->insertPage( 'DomainName', 'example.com' );
+ $this->insertPage( 'DomainName', 'example.com' );
+ $this->insertPage( 'Category:search is not Search', '' );
+ $this->insertPage( 'Category:Search is not search', '' );
+ }
+
+ protected function fetchIds( $results ) {
+ if ( !$this->isWikitextNS( NS_MAIN ) ) {
+ $this->markTestIncomplete( __CLASS__ . " does no yet support non-wikitext content "
+ . "in the main namespace" );
+ }
+ $this->assertTrue( is_object( $results ) );
+
+ $matches = [];
+ $row = $results->next();
+ while ( $row ) {
+ $matches[] = $row->getTitle()->getPrefixedText();
+ $row = $results->next();
+ }
+ $results->free();
+ # Search is not guaranteed to return results in a certain order;
+ # sort them numerically so we will compare simply that we received
+ # the expected matches.
+ sort( $matches );
+
+ return $matches;
+ }
+
+ public function testFullWidth() {
+ $this->assertEquals(
+ [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
+ $this->fetchIds( $this->search->searchText( 'AZ' ) ),
+ "Search for normalized from Half-width Upper" );
+ $this->assertEquals(
+ [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
+ $this->fetchIds( $this->search->searchText( 'az' ) ),
+ "Search for normalized from Half-width Lower" );
+ $this->assertEquals(
+ [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
+ $this->fetchIds( $this->search->searchText( 'AZ' ) ),
+ "Search for normalized from Full-width Upper" );
+ $this->assertEquals(
+ [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
+ $this->fetchIds( $this->search->searchText( 'az' ) ),
+ "Search for normalized from Full-width Lower" );
+ }
+
+ public function testTextSearch() {
+ $this->assertEquals(
+ [ 'Smithee' ],
+ $this->fetchIds( $this->search->searchText( 'smithee' ) ),
+ "Plain search" );
+ }
+
+ public function testWildcardSearch() {
+ $res = $this->search->searchText( 'smith*' );
+ $this->assertEquals(
+ [ 'Smithee' ],
+ $this->fetchIds( $res ),
+ "Search with wildcards" );
+
+ $res = $this->search->searchText( 'smithson*' );
+ $this->assertEquals(
+ [],
+ $this->fetchIds( $res ),
+ "Search with wildcards must not find unrelated articles" );
+
+ $res = $this->search->searchText( 'smith* smithee' );
+ $this->assertEquals(
+ [ 'Smithee' ],
+ $this->fetchIds( $res ),
+ "Search with wildcards can be combined with simple terms" );
+
+ $res = $this->search->searchText( 'smith* "one who smiths"' );
+ $this->assertEquals(
+ [ 'Smithee' ],
+ $this->fetchIds( $res ),
+ "Search with wildcards can be combined with phrase search" );
+ }
+
+ public function testPhraseSearch() {
+ $res = $this->search->searchText( '"smithee is one who smiths"' );
+ $this->assertEquals(
+ [ 'Smithee' ],
+ $this->fetchIds( $res ),
+ "Search a phrase" );
+
+ $res = $this->search->searchText( '"smithee is who smiths"' );
+ $this->assertEquals(
+ [],
+ $this->fetchIds( $res ),
+ "Phrase search is not sloppy, search terms must be adjacent" );
+
+ $res = $this->search->searchText( '"is smithee one who smiths"' );
+ $this->assertEquals(
+ [],
+ $this->fetchIds( $res ),
+ "Phrase search is ordered" );
+ }
+
+ public function testPhraseSearchHighlight() {
+ $phrase = "smithee is one who smiths";
+ $res = $this->search->searchText( "\"$phrase\"" );
+ $match = $res->next();
+ $snippet = "A <span class='searchmatch'>" . $phrase . "</span>";
+ $this->assertStringStartsWith( $snippet,
+ $match->getTextSnippet( $res->termMatches() ),
+ "Highlight a phrase search" );
+ }
+
+ public function testTextPowerSearch() {
+ $this->search->setNamespaces( [ 0, 1, 4 ] );
+ $this->assertEquals(
+ [
+ 'Smithee',
+ 'Talk:Not Main Page',
+ ],
+ $this->fetchIds( $this->search->searchText( 'smithee' ) ),
+ "Power search" );
+ }
+
+ public function testTitleSearch() {
+ $this->assertEquals(
+ [
+ 'Alan Smithee',
+ 'Smithee',
+ ],
+ $this->fetchIds( $this->search->searchTitle( 'smithee' ) ),
+ "Title search" );
+ }
+
+ public function testTextTitlePowerSearch() {
+ $this->search->setNamespaces( [ 0, 1, 4 ] );
+ $this->assertEquals(
+ [
+ 'Alan Smithee',
+ 'Smithee',
+ 'Talk:Smithee',
+ ],
+ $this->fetchIds( $this->search->searchTitle( 'smithee' ) ),
+ "Title power search" );
+ }
+
+ public function provideCompletionSearchMustRespectCapitalLinkOverrides() {
+ return [
+ 'Searching for "smithee" finds Smithee on NS_MAIN' => [
+ 'smithee',
+ 'Smithee',
+ [ NS_MAIN ],
+ ],
+ 'Searching for "search is" will finds "search is not Search" on NS_CATEGORY' => [
+ 'search is',
+ 'Category:search is not Search',
+ [ NS_CATEGORY ],
+ ],
+ 'Searching for "Search is" will finds "search is not Search" on NS_CATEGORY' => [
+ 'Search is',
+ 'Category:Search is not search',
+ [ NS_CATEGORY ],
+ ],
+ ];
+ }
+
+ /**
+ * Test that the search query is not munged using wrong CapitalLinks setup
+ * (in other test that the default search backend can benefit from wgCapitalLinksOverride)
+ * Guard against regressions like T208255
+ * @dataProvider provideCompletionSearchMustRespectCapitalLinkOverrides
+ * @covers SearchEngine::completionSearch
+ * @covers PrefixSearch::defaultSearchBackend
+ * @param string $search
+ * @param string $expectedSuggestion
+ * @param int[] $namespaces
+ */
+ public function testCompletionSearchMustRespectCapitalLinkOverrides(
+ $search,
+ $expectedSuggestion,
+ array $namespaces
+ ) {
+ $this->search->setNamespaces( $namespaces );
+ $results = $this->search->completionSearch( $search );
+ $this->assertEquals( 1, $results->getSize() );
+ $this->assertEquals( $expectedSuggestion, $results->getSuggestions()[0]->getText() );
+ }
+
+ /**
+ * @covers SearchEngine::getSearchIndexFields
+ */
+ public function testSearchIndexFields() {
+ /**
+ * @var $mockEngine SearchEngine
+ */
+ $mockEngine = $this->getMockBuilder( SearchEngine::class )
+ ->setMethods( [ 'makeSearchFieldMapping' ] )->getMock();
+
+ $mockFieldBuilder = function ( $name, $type ) {
+ $mockField =
+ $this->getMockBuilder( SearchIndexFieldDefinition::class )->setConstructorArgs( [
+ $name,
+ $type
+ ] )->getMock();
+
+ $mockField->expects( $this->any() )->method( 'getMapping' )->willReturn( [
+ 'testData' => 'test',
+ 'name' => $name,
+ 'type' => $type,
+ ] );
+
+ $mockField->expects( $this->any() )
+ ->method( 'merge' )
+ ->willReturn( $mockField );
+
+ return $mockField;
+ };
+
+ $mockEngine->expects( $this->atLeastOnce() )
+ ->method( 'makeSearchFieldMapping' )
+ ->willReturnCallback( $mockFieldBuilder );
+
+ // Not using mock since PHPUnit mocks do not work properly with references in params
+ $this->setTemporaryHook( 'SearchIndexFields',
+ function ( &$fields, SearchEngine $engine ) use ( $mockFieldBuilder ) {
+ $fields['testField'] =
+ $mockFieldBuilder( "testField", SearchIndexField::INDEX_TYPE_TEXT );
+ return true;
+ } );
+
+ $fields = $mockEngine->getSearchIndexFields();
+ $this->assertArrayHasKey( 'language', $fields );
+ $this->assertArrayHasKey( 'category', $fields );
+ $this->assertInstanceOf( SearchIndexField::class, $fields['testField'] );
+
+ $mapping = $fields['testField']->getMapping( $mockEngine );
+ $this->assertArrayHasKey( 'testData', $mapping );
+ $this->assertEquals( 'test', $mapping['testData'] );
+ }
+
+ public function hookSearchIndexFields( $mockFieldBuilder, &$fields, SearchEngine $engine ) {
+ $fields['testField'] = $mockFieldBuilder( "testField", SearchIndexField::INDEX_TYPE_TEXT );
+ return true;
+ }
+
+ public function testAugmentorSearch() {
+ $this->search->setNamespaces( [ 0, 1, 4 ] );
+ $resultSet = $this->search->searchText( 'smithee' );
+ // Not using mock since PHPUnit mocks do not work properly with references in params
+ $this->mergeMwGlobalArrayValue( 'wgHooks',
+ [ 'SearchResultsAugment' => [ [ $this, 'addAugmentors' ] ] ] );
+ $this->search->augmentSearchResults( $resultSet );
+ for ( $result = $resultSet->next(); $result; $result = $resultSet->next() ) {
+ $id = $result->getTitle()->getArticleID();
+ $augmentData = "Result:$id:" . $result->getTitle()->getText();
+ $augmentData2 = "Result2:$id:" . $result->getTitle()->getText();
+ $this->assertEquals( [ 'testSet' => $augmentData, 'testRow' => $augmentData2 ],
+ $result->getExtensionData() );
+ }
+ }
+
+ public function addAugmentors( &$setAugmentors, &$rowAugmentors ) {
+ $setAugmentor = $this->createMock( ResultSetAugmentor::class );
+ $setAugmentor->expects( $this->once() )
+ ->method( 'augmentAll' )
+ ->willReturnCallback( function ( SearchResultSet $resultSet ) {
+ $data = [];
+ for ( $result = $resultSet->next(); $result; $result = $resultSet->next() ) {
+ $id = $result->getTitle()->getArticleID();
+ $data[$id] = "Result:$id:" . $result->getTitle()->getText();
+ }
+ $resultSet->rewind();
+ return $data;
+ } );
+ $setAugmentors['testSet'] = $setAugmentor;
+
+ $rowAugmentor = $this->createMock( ResultAugmentor::class );
+ $rowAugmentor->expects( $this->exactly( 2 ) )
+ ->method( 'augment' )
+ ->willReturnCallback( function ( SearchResult $result ) {
+ $id = $result->getTitle()->getArticleID();
+ return "Result2:$id:" . $result->getTitle()->getText();
+ } );
+ $rowAugmentors['testRow'] = $rowAugmentor;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/search/SearchIndexFieldTest.php b/www/wiki/tests/phpunit/includes/search/SearchIndexFieldTest.php
new file mode 100644
index 00000000..8b4119e0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/search/SearchIndexFieldTest.php
@@ -0,0 +1,56 @@
+<?php
+
+/**
+ * @group Search
+ * @covers SearchIndexFieldDefinition
+ */
+class SearchIndexFieldTest extends MediaWikiTestCase {
+
+ public function getMergeCases() {
+ return [
+ [ 0, 'test', 0, 'test', true ],
+ [ SearchIndexField::INDEX_TYPE_NESTED, 'test',
+ SearchIndexField::INDEX_TYPE_NESTED, 'test', false ],
+ [ 0, 'test', 0, 'test2', true ],
+ [ 0, 'test', 1, 'test', false ],
+ ];
+ }
+
+ /**
+ * @dataProvider getMergeCases
+ * @param int $t1
+ * @param string $n1
+ * @param int $t2
+ * @param string $n2
+ * @param bool $result
+ */
+ public function testMerge( $t1, $n1, $t2, $n2, $result ) {
+ $field1 =
+ $this->getMockBuilder( SearchIndexFieldDefinition::class )
+ ->setMethods( [ 'getMapping' ] )
+ ->setConstructorArgs( [ $n1, $t1 ] )
+ ->getMock();
+ $field2 =
+ $this->getMockBuilder( SearchIndexFieldDefinition::class )
+ ->setMethods( [ 'getMapping' ] )
+ ->setConstructorArgs( [ $n2, $t2 ] )
+ ->getMock();
+
+ if ( $result ) {
+ $this->assertNotFalse( $field1->merge( $field2 ) );
+ } else {
+ $this->assertFalse( $field1->merge( $field2 ) );
+ }
+
+ $field1->setFlag( 0xFF );
+ $this->assertFalse( $field1->merge( $field2 ) );
+
+ $field1->setMergeCallback(
+ function ( $a, $b ) {
+ return "test";
+ }
+ );
+ $this->assertEquals( "test", $field1->merge( $field2 ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/search/SearchSuggestionSetTest.php b/www/wiki/tests/phpunit/includes/search/SearchSuggestionSetTest.php
new file mode 100644
index 00000000..54533a73
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/search/SearchSuggestionSetTest.php
@@ -0,0 +1,104 @@
+<?php
+
+/**
+ * Test for filter utilities.
+ *
+ * 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
+ */
+
+class SearchSuggestionSetTest extends \PHPUnit\Framework\TestCase {
+ /**
+ * Test that adding a new suggestion at the end
+ * will keep proper score ordering
+ */
+ public function testAppend() {
+ $set = SearchSuggestionSet::emptySuggestionSet();
+ $this->assertEquals( 0, $set->getSize() );
+ $set->append( new SearchSuggestion( 3 ) );
+ $this->assertEquals( 3, $set->getWorstScore() );
+ $this->assertEquals( 3, $set->getBestScore() );
+
+ $suggestion = new SearchSuggestion( 4 );
+ $set->append( $suggestion );
+ $this->assertEquals( 2, $set->getWorstScore() );
+ $this->assertEquals( 3, $set->getBestScore() );
+ $this->assertEquals( 2, $suggestion->getScore() );
+
+ $suggestion = new SearchSuggestion( 2 );
+ $set->append( $suggestion );
+ $this->assertEquals( 1, $set->getWorstScore() );
+ $this->assertEquals( 3, $set->getBestScore() );
+ $this->assertEquals( 1, $suggestion->getScore() );
+
+ $scores = $set->map( function ( $s ) {
+ return $s->getScore();
+ } );
+ $sorted = $scores;
+ asort( $sorted );
+ $this->assertEquals( $sorted, $scores );
+ }
+
+ /**
+ * Test that adding a new best suggestion will keep proper score
+ * ordering
+ */
+ public function testInsertBest() {
+ $set = SearchSuggestionSet::emptySuggestionSet();
+ $this->assertEquals( 0, $set->getSize() );
+ $set->prepend( new SearchSuggestion( 3 ) );
+ $this->assertEquals( 3, $set->getWorstScore() );
+ $this->assertEquals( 3, $set->getBestScore() );
+
+ $suggestion = new SearchSuggestion( 4 );
+ $set->prepend( $suggestion );
+ $this->assertEquals( 3, $set->getWorstScore() );
+ $this->assertEquals( 4, $set->getBestScore() );
+ $this->assertEquals( 4, $suggestion->getScore() );
+
+ $suggestion = new SearchSuggestion( 0 );
+ $set->prepend( $suggestion );
+ $this->assertEquals( 3, $set->getWorstScore() );
+ $this->assertEquals( 5, $set->getBestScore() );
+ $this->assertEquals( 5, $suggestion->getScore() );
+
+ $suggestion = new SearchSuggestion( 2 );
+ $set->prepend( $suggestion );
+ $this->assertEquals( 3, $set->getWorstScore() );
+ $this->assertEquals( 6, $set->getBestScore() );
+ $this->assertEquals( 6, $suggestion->getScore() );
+
+ $scores = $set->map( function ( $s ) {
+ return $s->getScore();
+ } );
+ $sorted = $scores;
+ asort( $sorted );
+ $this->assertEquals( $sorted, $scores );
+ }
+
+ public function testShrink() {
+ $set = SearchSuggestionSet::emptySuggestionSet();
+ for ( $i = 0; $i < 100; $i++ ) {
+ $set->append( new SearchSuggestion( 0 ) );
+ }
+ $set->shrink( 10 );
+ $this->assertEquals( 10, $set->getSize() );
+
+ $set->shrink( 0 );
+ $this->assertEquals( 0, $set->getSize() );
+ }
+
+ // TODO: test for fromTitles
+}
diff --git a/www/wiki/tests/phpunit/includes/services/ServiceContainerTest.php b/www/wiki/tests/phpunit/includes/services/ServiceContainerTest.php
new file mode 100644
index 00000000..a760908f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/services/ServiceContainerTest.php
@@ -0,0 +1,414 @@
+<?php
+use MediaWiki\Services\ServiceContainer;
+
+/**
+ * @covers MediaWiki\Services\ServiceContainer
+ *
+ * @group MediaWiki
+ */
+class ServiceContainerTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ private function newServiceContainer( $extraArgs = [] ) {
+ return new ServiceContainer( $extraArgs );
+ }
+
+ public function testGetServiceNames() {
+ $services = $this->newServiceContainer();
+ $names = $services->getServiceNames();
+
+ $this->assertInternalType( 'array', $names );
+ $this->assertEmpty( $names );
+
+ $name = 'TestService92834576';
+ $services->defineService( $name, function () {
+ return null;
+ } );
+
+ $names = $services->getServiceNames();
+ $this->assertContains( $name, $names );
+ }
+
+ public function testHasService() {
+ $services = $this->newServiceContainer();
+
+ $name = 'TestService92834576';
+ $this->assertFalse( $services->hasService( $name ) );
+
+ $services->defineService( $name, function () {
+ return null;
+ } );
+
+ $this->assertTrue( $services->hasService( $name ) );
+ }
+
+ public function testGetService() {
+ $services = $this->newServiceContainer( [ 'Foo' ] );
+
+ $theService = new stdClass();
+ $name = 'TestService92834576';
+ $count = 0;
+
+ $services->defineService(
+ $name,
+ function ( $actualLocator, $extra ) use ( $services, $theService, &$count ) {
+ $count++;
+ PHPUnit_Framework_Assert::assertSame( $services, $actualLocator );
+ PHPUnit_Framework_Assert::assertSame( $extra, 'Foo' );
+ return $theService;
+ }
+ );
+
+ $this->assertSame( $theService, $services->getService( $name ) );
+
+ $services->getService( $name );
+ $this->assertSame( 1, $count, 'instantiator should be called exactly once!' );
+ }
+
+ public function testGetService_fail_unknown() {
+ $services = $this->newServiceContainer();
+
+ $name = 'TestService92834576';
+
+ $this->setExpectedException( MediaWiki\Services\NoSuchServiceException::class );
+
+ $services->getService( $name );
+ }
+
+ public function testPeekService() {
+ $services = $this->newServiceContainer();
+
+ $services->defineService(
+ 'Foo',
+ function () {
+ return new stdClass();
+ }
+ );
+
+ $services->defineService(
+ 'Bar',
+ function () {
+ return new stdClass();
+ }
+ );
+
+ // trigger instantiation of Foo
+ $services->getService( 'Foo' );
+
+ $this->assertInternalType(
+ 'object',
+ $services->peekService( 'Foo' ),
+ 'Peek should return the service object if it had been accessed before.'
+ );
+
+ $this->assertNull(
+ $services->peekService( 'Bar' ),
+ 'Peek should return null if the service was never accessed.'
+ );
+ }
+
+ public function testPeekService_fail_unknown() {
+ $services = $this->newServiceContainer();
+
+ $name = 'TestService92834576';
+
+ $this->setExpectedException( MediaWiki\Services\NoSuchServiceException::class );
+
+ $services->peekService( $name );
+ }
+
+ public function testDefineService() {
+ $services = $this->newServiceContainer();
+
+ $theService = new stdClass();
+ $name = 'TestService92834576';
+
+ $services->defineService( $name, function ( $actualLocator ) use ( $services, $theService ) {
+ PHPUnit_Framework_Assert::assertSame( $services, $actualLocator );
+ return $theService;
+ } );
+
+ $this->assertTrue( $services->hasService( $name ) );
+ $this->assertSame( $theService, $services->getService( $name ) );
+ }
+
+ public function testDefineService_fail_duplicate() {
+ $services = $this->newServiceContainer();
+
+ $theService = new stdClass();
+ $name = 'TestService92834576';
+
+ $services->defineService( $name, function () use ( $theService ) {
+ return $theService;
+ } );
+
+ $this->setExpectedException( MediaWiki\Services\ServiceAlreadyDefinedException::class );
+
+ $services->defineService( $name, function () use ( $theService ) {
+ return $theService;
+ } );
+ }
+
+ public function testApplyWiring() {
+ $services = $this->newServiceContainer();
+
+ $wiring = [
+ 'Foo' => function () {
+ return 'Foo!';
+ },
+ 'Bar' => function () {
+ return 'Bar!';
+ },
+ ];
+
+ $services->applyWiring( $wiring );
+
+ $this->assertSame( 'Foo!', $services->getService( 'Foo' ) );
+ $this->assertSame( 'Bar!', $services->getService( 'Bar' ) );
+ }
+
+ public function testImportWiring() {
+ $services = $this->newServiceContainer();
+
+ $wiring = [
+ 'Foo' => function () {
+ return 'Foo!';
+ },
+ 'Bar' => function () {
+ return 'Bar!';
+ },
+ 'Car' => function () {
+ return 'FUBAR!';
+ },
+ ];
+
+ $services->applyWiring( $wiring );
+
+ $newServices = $this->newServiceContainer();
+
+ // define a service before importing, so we can later check that
+ // existing service instances survive importWiring()
+ $newServices->defineService( 'Car', function () {
+ return 'Car!';
+ } );
+
+ // force instantiation
+ $newServices->getService( 'Car' );
+
+ // Define another service, so we can later check that extra wiring
+ // is not lost.
+ $newServices->defineService( 'Xar', function () {
+ return 'Xar!';
+ } );
+
+ // import wiring, but skip `Bar`
+ $newServices->importWiring( $services, [ 'Bar' ] );
+
+ $this->assertNotContains( 'Bar', $newServices->getServiceNames(), 'Skip `Bar` service' );
+ $this->assertSame( 'Foo!', $newServices->getService( 'Foo' ) );
+
+ // import all wiring, but preserve existing service instance
+ $newServices->importWiring( $services );
+
+ $this->assertContains( 'Bar', $newServices->getServiceNames(), 'Import all services' );
+ $this->assertSame( 'Bar!', $newServices->getService( 'Bar' ) );
+ $this->assertSame( 'Car!', $newServices->getService( 'Car' ), 'Use existing service instance' );
+ $this->assertSame( 'Xar!', $newServices->getService( 'Xar' ), 'Predefined services are kept' );
+ }
+
+ public function testLoadWiringFiles() {
+ $services = $this->newServiceContainer();
+
+ $wiringFiles = [
+ __DIR__ . '/TestWiring1.php',
+ __DIR__ . '/TestWiring2.php',
+ ];
+
+ $services->loadWiringFiles( $wiringFiles );
+
+ $this->assertSame( 'Foo!', $services->getService( 'Foo' ) );
+ $this->assertSame( 'Bar!', $services->getService( 'Bar' ) );
+ }
+
+ public function testLoadWiringFiles_fail_duplicate() {
+ $services = $this->newServiceContainer();
+
+ $wiringFiles = [
+ __DIR__ . '/TestWiring1.php',
+ __DIR__ . '/./TestWiring1.php',
+ ];
+
+ // loading the same file twice should fail, because
+ $this->setExpectedException( MediaWiki\Services\ServiceAlreadyDefinedException::class );
+
+ $services->loadWiringFiles( $wiringFiles );
+ }
+
+ public function testRedefineService() {
+ $services = $this->newServiceContainer( [ 'Foo' ] );
+
+ $theService1 = new stdClass();
+ $name = 'TestService92834576';
+
+ $services->defineService( $name, function () {
+ PHPUnit_Framework_Assert::fail(
+ 'The original instantiator function should not get called'
+ );
+ } );
+
+ // redefine before instantiation
+ $services->redefineService(
+ $name,
+ function ( $actualLocator, $extra ) use ( $services, $theService1 ) {
+ PHPUnit_Framework_Assert::assertSame( $services, $actualLocator );
+ PHPUnit_Framework_Assert::assertSame( 'Foo', $extra );
+ return $theService1;
+ }
+ );
+
+ // force instantiation, check result
+ $this->assertSame( $theService1, $services->getService( $name ) );
+ }
+
+ public function testRedefineService_disabled() {
+ $services = $this->newServiceContainer( [ 'Foo' ] );
+
+ $theService1 = new stdClass();
+ $name = 'TestService92834576';
+
+ $services->defineService( $name, function () {
+ return 'Foo';
+ } );
+
+ // disable the service. we should be able to redefine it anyway.
+ $services->disableService( $name );
+
+ $services->redefineService( $name, function () use ( $theService1 ) {
+ return $theService1;
+ } );
+
+ // force instantiation, check result
+ $this->assertSame( $theService1, $services->getService( $name ) );
+ }
+
+ public function testRedefineService_fail_undefined() {
+ $services = $this->newServiceContainer();
+
+ $theService = new stdClass();
+ $name = 'TestService92834576';
+
+ $this->setExpectedException( MediaWiki\Services\NoSuchServiceException::class );
+
+ $services->redefineService( $name, function () use ( $theService ) {
+ return $theService;
+ } );
+ }
+
+ public function testRedefineService_fail_in_use() {
+ $services = $this->newServiceContainer( [ 'Foo' ] );
+
+ $theService = new stdClass();
+ $name = 'TestService92834576';
+
+ $services->defineService( $name, function () {
+ return 'Foo';
+ } );
+
+ // create the service, so it can no longer be redefined
+ $services->getService( $name );
+
+ $this->setExpectedException( MediaWiki\Services\CannotReplaceActiveServiceException::class );
+
+ $services->redefineService( $name, function () use ( $theService ) {
+ return $theService;
+ } );
+ }
+
+ public function testDisableService() {
+ $services = $this->newServiceContainer( [ 'Foo' ] );
+
+ $destructible = $this->getMockBuilder( MediaWiki\Services\DestructibleService::class )
+ ->getMock();
+ $destructible->expects( $this->once() )
+ ->method( 'destroy' );
+
+ $services->defineService( 'Foo', function () use ( $destructible ) {
+ return $destructible;
+ } );
+ $services->defineService( 'Bar', function () {
+ return new stdClass();
+ } );
+ $services->defineService( 'Qux', function () {
+ return new stdClass();
+ } );
+
+ // instantiate Foo and Bar services
+ $services->getService( 'Foo' );
+ $services->getService( 'Bar' );
+
+ // disable service, should call destroy() once.
+ $services->disableService( 'Foo' );
+
+ // disabled service should still be listed
+ $this->assertContains( 'Foo', $services->getServiceNames() );
+
+ // getting other services should still work
+ $services->getService( 'Bar' );
+
+ // disable non-destructible service, and not-yet-instantiated service
+ $services->disableService( 'Bar' );
+ $services->disableService( 'Qux' );
+
+ $this->assertNull( $services->peekService( 'Bar' ) );
+ $this->assertNull( $services->peekService( 'Qux' ) );
+
+ // disabled service should still be listed
+ $this->assertContains( 'Bar', $services->getServiceNames() );
+ $this->assertContains( 'Qux', $services->getServiceNames() );
+
+ $this->setExpectedException( MediaWiki\Services\ServiceDisabledException::class );
+ $services->getService( 'Qux' );
+ }
+
+ public function testDisableService_fail_undefined() {
+ $services = $this->newServiceContainer();
+
+ $theService = new stdClass();
+ $name = 'TestService92834576';
+
+ $this->setExpectedException( MediaWiki\Services\NoSuchServiceException::class );
+
+ $services->redefineService( $name, function () use ( $theService ) {
+ return $theService;
+ } );
+ }
+
+ public function testDestroy() {
+ $services = $this->newServiceContainer();
+
+ $destructible = $this->getMockBuilder( MediaWiki\Services\DestructibleService::class )
+ ->getMock();
+ $destructible->expects( $this->once() )
+ ->method( 'destroy' );
+
+ $services->defineService( 'Foo', function () use ( $destructible ) {
+ return $destructible;
+ } );
+
+ $services->defineService( 'Bar', function () {
+ return new stdClass();
+ } );
+
+ // create the service
+ $services->getService( 'Foo' );
+
+ // destroy the container
+ $services->destroy();
+
+ $this->setExpectedException( MediaWiki\Services\ContainerDisabledException::class );
+ $services->getService( 'Bar' );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/services/TestWiring1.php b/www/wiki/tests/phpunit/includes/services/TestWiring1.php
new file mode 100644
index 00000000..b6ff4eb3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/services/TestWiring1.php
@@ -0,0 +1,10 @@
+<?php
+/**
+ * Test file for testing ServiceContainer::loadWiringFiles
+ */
+
+return [
+ 'Foo' => function () {
+ return 'Foo!';
+ },
+];
diff --git a/www/wiki/tests/phpunit/includes/services/TestWiring2.php b/www/wiki/tests/phpunit/includes/services/TestWiring2.php
new file mode 100644
index 00000000..dfff64f0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/services/TestWiring2.php
@@ -0,0 +1,10 @@
+<?php
+/**
+ * Test file for testing ServiceContainer::loadWiringFiles
+ */
+
+return [
+ 'Bar' => function () {
+ return 'Bar!';
+ },
+];
diff --git a/www/wiki/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php b/www/wiki/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php
new file mode 100644
index 00000000..47679940
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php
@@ -0,0 +1,340 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LogLevel;
+use MediaWikiTestCase;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\BotPasswordSessionProvider
+ */
+class BotPasswordSessionProviderTest extends MediaWikiTestCase {
+
+ private $config;
+
+ private function getProvider( $name = null, $prefix = null ) {
+ global $wgSessionProviders;
+
+ $params = [
+ 'priority' => 40,
+ 'sessionCookieName' => $name,
+ 'sessionCookieOptions' => [],
+ ];
+ if ( $prefix !== null ) {
+ $params['sessionCookieOptions']['prefix'] = $prefix;
+ }
+
+ if ( !$this->config ) {
+ $this->config = new \HashConfig( [
+ 'CookiePrefix' => 'wgCookiePrefix',
+ 'EnableBotPasswords' => true,
+ 'BotPasswordsDatabase' => false,
+ 'SessionProviders' => $wgSessionProviders + [
+ BotPasswordSessionProvider::class => [
+ 'class' => BotPasswordSessionProvider::class,
+ 'args' => [ $params ],
+ ]
+ ],
+ ] );
+ }
+ $manager = new SessionManager( [
+ 'config' => new \MultiConfig( [ $this->config, \RequestContext::getMain()->getConfig() ] ),
+ 'logger' => new \Psr\Log\NullLogger,
+ 'store' => new TestBagOStuff,
+ ] );
+
+ return $manager->getProvider( BotPasswordSessionProvider::class );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgEnableBotPasswords' => true,
+ 'wgBotPasswordsDatabase' => false,
+ 'wgCentralIdLookupProvider' => 'local',
+ 'wgGrantPermissions' => [
+ 'test' => [ 'read' => true ],
+ ],
+ ] );
+ }
+
+ public function addDBDataOnce() {
+ $passwordFactory = new \PasswordFactory();
+ $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+ $passwordHash = $passwordFactory->newFromPlaintext( 'foobaz' );
+
+ $sysop = static::getTestSysop()->getUser();
+ $userId = \CentralIdLookup::factory( 'local' )->centralIdFromName( $sysop->getName() );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete(
+ 'bot_passwords',
+ [ 'bp_user' => $userId, 'bp_app_id' => 'BotPasswordSessionProvider' ],
+ __METHOD__
+ );
+ $dbw->insert(
+ 'bot_passwords',
+ [
+ 'bp_user' => $userId,
+ 'bp_app_id' => 'BotPasswordSessionProvider',
+ 'bp_password' => $passwordHash->toString(),
+ 'bp_token' => 'token!',
+ 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
+ 'bp_grants' => '["test"]',
+ ],
+ __METHOD__
+ );
+ }
+
+ public function testConstructor() {
+ try {
+ $provider = new BotPasswordSessionProvider();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: priority must be specified',
+ $ex->getMessage()
+ );
+ }
+
+ try {
+ $provider = new BotPasswordSessionProvider( [
+ 'priority' => SessionInfo::MIN_PRIORITY - 1
+ ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: Invalid priority',
+ $ex->getMessage()
+ );
+ }
+
+ try {
+ $provider = new BotPasswordSessionProvider( [
+ 'priority' => SessionInfo::MAX_PRIORITY + 1
+ ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: Invalid priority',
+ $ex->getMessage()
+ );
+ }
+
+ $provider = new BotPasswordSessionProvider( [
+ 'priority' => 40
+ ] );
+ $priv = TestingAccessWrapper::newFromObject( $provider );
+ $this->assertSame( 40, $priv->priority );
+ $this->assertSame( '_BPsession', $priv->sessionCookieName );
+ $this->assertSame( [], $priv->sessionCookieOptions );
+
+ $provider = new BotPasswordSessionProvider( [
+ 'priority' => 40,
+ 'sessionCookieName' => null,
+ ] );
+ $priv = TestingAccessWrapper::newFromObject( $provider );
+ $this->assertSame( '_BPsession', $priv->sessionCookieName );
+
+ $provider = new BotPasswordSessionProvider( [
+ 'priority' => 40,
+ 'sessionCookieName' => 'Foo',
+ 'sessionCookieOptions' => [ 'Bar' ],
+ ] );
+ $priv = TestingAccessWrapper::newFromObject( $provider );
+ $this->assertSame( 'Foo', $priv->sessionCookieName );
+ $this->assertSame( [ 'Bar' ], $priv->sessionCookieOptions );
+ }
+
+ public function testBasics() {
+ $provider = $this->getProvider();
+
+ $this->assertTrue( $provider->persistsSessionId() );
+ $this->assertFalse( $provider->canChangeUser() );
+
+ $this->assertNull( $provider->newSessionInfo() );
+ $this->assertNull( $provider->newSessionInfo( 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ) );
+ }
+
+ public function testProvideSessionInfo() {
+ $provider = $this->getProvider();
+ $request = new \FauxRequest;
+ $request->setCookie( '_BPsession', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'wgCookiePrefix' );
+
+ if ( !defined( 'MW_API' ) ) {
+ $this->assertNull( $provider->provideSessionInfo( $request ) );
+ define( 'MW_API', 1 );
+ }
+
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertInstanceOf( SessionInfo::class, $info );
+ $this->assertSame( 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $info->getId() );
+
+ $this->config->set( 'EnableBotPasswords', false );
+ $this->assertNull( $provider->provideSessionInfo( $request ) );
+ $this->config->set( 'EnableBotPasswords', true );
+
+ $this->assertNull( $provider->provideSessionInfo( new \FauxRequest ) );
+ }
+
+ public function testNewSessionInfoForRequest() {
+ $provider = $this->getProvider();
+ $user = static::getTestSysop()->getUser();
+ $request = $this->getMockBuilder( \FauxRequest::class )
+ ->setMethods( [ 'getIP' ] )->getMock();
+ $request->expects( $this->any() )->method( 'getIP' )
+ ->will( $this->returnValue( '127.0.0.1' ) );
+ $bp = \BotPassword::newFromUser( $user, 'BotPasswordSessionProvider' );
+
+ $session = $provider->newSessionForRequest( $user, $bp, $request );
+ $this->assertInstanceOf( Session::class, $session );
+
+ $this->assertEquals( $session->getId(), $request->getSession()->getId() );
+ $this->assertEquals( $user->getName(), $session->getUser()->getName() );
+
+ $this->assertEquals( [
+ 'centralId' => $bp->getUserCentralId(),
+ 'appId' => $bp->getAppId(),
+ 'token' => $bp->getToken(),
+ 'rights' => [ 'read' ],
+ ], $session->getProviderMetadata() );
+
+ $this->assertEquals( [ 'read' ], $session->getAllowedUserRights() );
+ }
+
+ public function testCheckSessionInfo() {
+ $logger = new \TestLogger( true );
+ $provider = $this->getProvider();
+ $provider->setLogger( $logger );
+
+ $user = static::getTestSysop()->getUser();
+ $request = $this->getMockBuilder( \FauxRequest::class )
+ ->setMethods( [ 'getIP' ] )->getMock();
+ $request->expects( $this->any() )->method( 'getIP' )
+ ->will( $this->returnValue( '127.0.0.1' ) );
+ $bp = \BotPassword::newFromUser( $user, 'BotPasswordSessionProvider' );
+
+ $data = [
+ 'provider' => $provider,
+ 'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ 'userInfo' => UserInfo::newFromUser( $user, true ),
+ 'persisted' => false,
+ 'metadata' => [
+ 'centralId' => $bp->getUserCentralId(),
+ 'appId' => $bp->getAppId(),
+ 'token' => $bp->getToken(),
+ ],
+ ];
+ $dataMD = $data['metadata'];
+
+ foreach ( array_keys( $data['metadata'] ) as $key ) {
+ $data['metadata'] = $dataMD;
+ unset( $data['metadata'][$key] );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+ $metadata = $info->getProviderMetadata();
+
+ $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+ $this->assertSame( [
+ [ LogLevel::INFO, 'Session "{session}": Missing metadata: {missing}' ]
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ }
+
+ $data['metadata'] = $dataMD;
+ $data['metadata']['appId'] = 'Foobar';
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+ $metadata = $info->getProviderMetadata();
+ $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+ $this->assertSame( [
+ [ LogLevel::INFO, 'Session "{session}": No BotPassword for {centralId} {appId}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $data['metadata'] = $dataMD;
+ $data['metadata']['token'] = 'Foobar';
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+ $metadata = $info->getProviderMetadata();
+ $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+ $this->assertSame( [
+ [ LogLevel::INFO, 'Session "{session}": BotPassword token check failed' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $request2 = $this->getMockBuilder( \FauxRequest::class )
+ ->setMethods( [ 'getIP' ] )->getMock();
+ $request2->expects( $this->any() )->method( 'getIP' )
+ ->will( $this->returnValue( '10.0.0.1' ) );
+ $data['metadata'] = $dataMD;
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+ $metadata = $info->getProviderMetadata();
+ $this->assertFalse( $provider->refreshSessionInfo( $info, $request2, $metadata ) );
+ $this->assertSame( [
+ [ LogLevel::INFO, 'Session "{session}": Restrictions check failed' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+ $metadata = $info->getProviderMetadata();
+ $this->assertTrue( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+ $this->assertSame( [], $logger->getBuffer() );
+ $this->assertEquals( $dataMD + [ 'rights' => [ 'read' ] ], $metadata );
+ }
+
+ public function testGetAllowedUserRights() {
+ $logger = new \TestLogger( true );
+ $provider = $this->getProvider();
+ $provider->setLogger( $logger );
+
+ $backend = TestUtils::getDummySessionBackend();
+ $backendPriv = TestingAccessWrapper::newFromObject( $backend );
+
+ try {
+ $provider->getAllowedUserRights( $backend );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Backend\'s provider isn\'t $this', $ex->getMessage() );
+ }
+
+ $backendPriv->provider = $provider;
+ $backendPriv->providerMetadata = [ 'rights' => [ 'foo', 'bar', 'baz' ] ];
+ $this->assertSame( [ 'foo', 'bar', 'baz' ], $provider->getAllowedUserRights( $backend ) );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $backendPriv->providerMetadata = [ 'foo' => 'bar' ];
+ $this->assertSame( [], $provider->getAllowedUserRights( $backend ) );
+ $this->assertSame( [
+ [
+ LogLevel::DEBUG,
+ 'MediaWiki\\Session\\BotPasswordSessionProvider::getAllowedUserRights: ' .
+ 'No provider metadata, returning no rights allowed'
+ ]
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $backendPriv->providerMetadata = [ 'rights' => 'bar' ];
+ $this->assertSame( [], $provider->getAllowedUserRights( $backend ) );
+ $this->assertSame( [
+ [
+ LogLevel::DEBUG,
+ 'MediaWiki\\Session\\BotPasswordSessionProvider::getAllowedUserRights: ' .
+ 'No provider metadata, returning no rights allowed'
+ ]
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $backendPriv->providerMetadata = null;
+ $this->assertSame( [], $provider->getAllowedUserRights( $backend ) );
+ $this->assertSame( [
+ [
+ LogLevel::DEBUG,
+ 'MediaWiki\\Session\\BotPasswordSessionProvider::getAllowedUserRights: ' .
+ 'No provider metadata, returning no rights allowed'
+ ]
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/session/CookieSessionProviderTest.php b/www/wiki/tests/phpunit/includes/session/CookieSessionProviderTest.php
new file mode 100644
index 00000000..c1df365a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/CookieSessionProviderTest.php
@@ -0,0 +1,842 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+use Psr\Log\LogLevel;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\CookieSessionProvider
+ */
+class CookieSessionProviderTest extends MediaWikiTestCase {
+
+ private function getConfig() {
+ return new \HashConfig( [
+ 'CookiePrefix' => 'CookiePrefix',
+ 'CookiePath' => 'CookiePath',
+ 'CookieDomain' => 'CookieDomain',
+ 'CookieSecure' => true,
+ 'CookieHttpOnly' => true,
+ 'SessionName' => false,
+ 'CookieExpiration' => 100,
+ 'ExtendedLoginCookieExpiration' => 200,
+ ] );
+ }
+
+ public function testConstructor() {
+ try {
+ new CookieSessionProvider();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\CookieSessionProvider::__construct: priority must be specified',
+ $ex->getMessage()
+ );
+ }
+
+ try {
+ new CookieSessionProvider( [ 'priority' => 'foo' ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority',
+ $ex->getMessage()
+ );
+ }
+ try {
+ new CookieSessionProvider( [ 'priority' => SessionInfo::MIN_PRIORITY - 1 ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority',
+ $ex->getMessage()
+ );
+ }
+ try {
+ new CookieSessionProvider( [ 'priority' => SessionInfo::MAX_PRIORITY + 1 ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority',
+ $ex->getMessage()
+ );
+ }
+
+ try {
+ new CookieSessionProvider( [ 'priority' => 1, 'cookieOptions' => null ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\CookieSessionProvider::__construct: cookieOptions must be an array',
+ $ex->getMessage()
+ );
+ }
+
+ $config = $this->getConfig();
+ $p = TestingAccessWrapper::newFromObject(
+ new CookieSessionProvider( [ 'priority' => 1 ] )
+ );
+ $p->setLogger( new \TestLogger() );
+ $p->setConfig( $config );
+ $this->assertEquals( 1, $p->priority );
+ $this->assertEquals( [
+ 'callUserSetCookiesHook' => false,
+ 'sessionName' => 'CookiePrefix_session',
+ ], $p->params );
+ $this->assertEquals( [
+ 'prefix' => 'CookiePrefix',
+ 'path' => 'CookiePath',
+ 'domain' => 'CookieDomain',
+ 'secure' => true,
+ 'httpOnly' => true,
+ ], $p->cookieOptions );
+
+ $config->set( 'SessionName', 'SessionName' );
+ $p = TestingAccessWrapper::newFromObject(
+ new CookieSessionProvider( [ 'priority' => 3 ] )
+ );
+ $p->setLogger( new \TestLogger() );
+ $p->setConfig( $config );
+ $this->assertEquals( 3, $p->priority );
+ $this->assertEquals( [
+ 'callUserSetCookiesHook' => false,
+ 'sessionName' => 'SessionName',
+ ], $p->params );
+ $this->assertEquals( [
+ 'prefix' => 'CookiePrefix',
+ 'path' => 'CookiePath',
+ 'domain' => 'CookieDomain',
+ 'secure' => true,
+ 'httpOnly' => true,
+ ], $p->cookieOptions );
+
+ $p = TestingAccessWrapper::newFromObject( new CookieSessionProvider( [
+ 'priority' => 10,
+ 'callUserSetCookiesHook' => true,
+ 'cookieOptions' => [
+ 'prefix' => 'XPrefix',
+ 'path' => 'XPath',
+ 'domain' => 'XDomain',
+ 'secure' => 'XSecure',
+ 'httpOnly' => 'XHttpOnly',
+ ],
+ 'sessionName' => 'XSession',
+ ] ) );
+ $p->setLogger( new \TestLogger() );
+ $p->setConfig( $config );
+ $this->assertEquals( 10, $p->priority );
+ $this->assertEquals( [
+ 'callUserSetCookiesHook' => true,
+ 'sessionName' => 'XSession',
+ ], $p->params );
+ $this->assertEquals( [
+ 'prefix' => 'XPrefix',
+ 'path' => 'XPath',
+ 'domain' => 'XDomain',
+ 'secure' => 'XSecure',
+ 'httpOnly' => 'XHttpOnly',
+ ], $p->cookieOptions );
+ }
+
+ public function testBasics() {
+ $provider = new CookieSessionProvider( [ 'priority' => 10 ] );
+
+ $this->assertTrue( $provider->persistsSessionId() );
+ $this->assertTrue( $provider->canChangeUser() );
+
+ $extendedCookies = [ 'UserID', 'UserName', 'Token' ];
+
+ $this->assertEquals(
+ $extendedCookies,
+ TestingAccessWrapper::newFromObject( $provider )->getExtendedLoginCookies(),
+ 'List of extended cookies (subclasses can add values, but we\'re calling the core one here)'
+ );
+
+ $msg = $provider->whyNoSession();
+ $this->assertInstanceOf( \Message::class, $msg );
+ $this->assertSame( 'sessionprovider-nocookies', $msg->getKey() );
+ }
+
+ public function testProvideSessionInfo() {
+ $params = [
+ 'priority' => 20,
+ 'sessionName' => 'session',
+ 'cookieOptions' => [ 'prefix' => 'x' ],
+ ];
+ $provider = new CookieSessionProvider( $params );
+ $logger = new \TestLogger( true );
+ $provider->setLogger( $logger );
+ $provider->setConfig( $this->getConfig() );
+ $provider->setManager( new SessionManager() );
+
+ $user = static::getTestSysop()->getUser();
+ $id = $user->getId();
+ $name = $user->getName();
+ $token = $user->getToken( true );
+
+ $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+ // No data
+ $request = new \FauxRequest();
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertNull( $info );
+ $this->assertSame( [], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Session key only
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ 'session' => $sessionId,
+ ], '' );
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertNotNull( $info );
+ $this->assertSame( $params['priority'], $info->getPriority() );
+ $this->assertSame( $sessionId, $info->getId() );
+ $this->assertNotNull( $info->getUserInfo() );
+ $this->assertSame( 0, $info->getUserInfo()->getId() );
+ $this->assertNull( $info->getUserInfo()->getName() );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertSame( [
+ [
+ LogLevel::DEBUG,
+ 'Session "{session}" requested without UserID cookie',
+ ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // User, no session key
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ 'xUserID' => $id,
+ 'xToken' => $token,
+ ], '' );
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertNotNull( $info );
+ $this->assertSame( $params['priority'], $info->getPriority() );
+ $this->assertNotSame( $sessionId, $info->getId() );
+ $this->assertNotNull( $info->getUserInfo() );
+ $this->assertSame( $id, $info->getUserInfo()->getId() );
+ $this->assertSame( $name, $info->getUserInfo()->getName() );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertSame( [], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // User and session key
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ 'session' => $sessionId,
+ 'xUserID' => $id,
+ 'xToken' => $token,
+ ], '' );
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertNotNull( $info );
+ $this->assertSame( $params['priority'], $info->getPriority() );
+ $this->assertSame( $sessionId, $info->getId() );
+ $this->assertNotNull( $info->getUserInfo() );
+ $this->assertSame( $id, $info->getUserInfo()->getId() );
+ $this->assertSame( $name, $info->getUserInfo()->getName() );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertSame( [], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // User with bad token
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ 'session' => $sessionId,
+ 'xUserID' => $id,
+ 'xToken' => 'BADTOKEN',
+ ], '' );
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertNull( $info );
+ $this->assertSame( [
+ [
+ LogLevel::WARNING,
+ 'Session "{session}" requested with invalid Token cookie.'
+ ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // User id with no token
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ 'session' => $sessionId,
+ 'xUserID' => $id,
+ ], '' );
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertNotNull( $info );
+ $this->assertSame( $params['priority'], $info->getPriority() );
+ $this->assertSame( $sessionId, $info->getId() );
+ $this->assertNotNull( $info->getUserInfo() );
+ $this->assertFalse( $info->getUserInfo()->isVerified() );
+ $this->assertSame( $id, $info->getUserInfo()->getId() );
+ $this->assertSame( $name, $info->getUserInfo()->getName() );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertSame( [], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ 'xUserID' => $id,
+ ], '' );
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertNull( $info );
+ $this->assertSame( [], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // User and session key, with forceHTTPS flag
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ 'session' => $sessionId,
+ 'xUserID' => $id,
+ 'xToken' => $token,
+ 'forceHTTPS' => true,
+ ], '' );
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertNotNull( $info );
+ $this->assertSame( $params['priority'], $info->getPriority() );
+ $this->assertSame( $sessionId, $info->getId() );
+ $this->assertNotNull( $info->getUserInfo() );
+ $this->assertSame( $id, $info->getUserInfo()->getId() );
+ $this->assertSame( $name, $info->getUserInfo()->getName() );
+ $this->assertTrue( $info->forceHTTPS() );
+ $this->assertSame( [], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Invalid user id
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ 'session' => $sessionId,
+ 'xUserID' => '-1',
+ ], '' );
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertNull( $info );
+ $this->assertSame( [], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // User id with matching name
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ 'session' => $sessionId,
+ 'xUserID' => $id,
+ 'xUserName' => $name,
+ ], '' );
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertNotNull( $info );
+ $this->assertSame( $params['priority'], $info->getPriority() );
+ $this->assertSame( $sessionId, $info->getId() );
+ $this->assertNotNull( $info->getUserInfo() );
+ $this->assertFalse( $info->getUserInfo()->isVerified() );
+ $this->assertSame( $id, $info->getUserInfo()->getId() );
+ $this->assertSame( $name, $info->getUserInfo()->getName() );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertSame( [], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // User id with wrong name
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ 'session' => $sessionId,
+ 'xUserID' => $id,
+ 'xUserName' => 'Wrong',
+ ], '' );
+ $info = $provider->provideSessionInfo( $request );
+ $this->assertNull( $info );
+ $this->assertSame( [
+ [
+ LogLevel::WARNING,
+ 'Session "{session}" requested with mismatched UserID and UserName cookies.',
+ ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ }
+
+ public function testGetVaryCookies() {
+ $provider = new CookieSessionProvider( [
+ 'priority' => 1,
+ 'sessionName' => 'MySessionName',
+ 'cookieOptions' => [ 'prefix' => 'MyCookiePrefix' ],
+ ] );
+ $this->assertArrayEquals( [
+ 'MyCookiePrefixToken',
+ 'MyCookiePrefixLoggedOut',
+ 'MySessionName',
+ 'forceHTTPS',
+ ], $provider->getVaryCookies() );
+ }
+
+ public function testSuggestLoginUsername() {
+ $provider = new CookieSessionProvider( [
+ 'priority' => 1,
+ 'sessionName' => 'MySessionName',
+ 'cookieOptions' => [ 'prefix' => 'x' ],
+ ] );
+
+ $request = new \FauxRequest();
+ $this->assertEquals( null, $provider->suggestLoginUsername( $request ) );
+
+ $request->setCookies( [
+ 'xUserName' => 'Example',
+ ], '' );
+ $this->assertEquals( 'Example', $provider->suggestLoginUsername( $request ) );
+ }
+
+ public function testPersistSession() {
+ $provider = new CookieSessionProvider( [
+ 'priority' => 1,
+ 'sessionName' => 'MySessionName',
+ 'callUserSetCookiesHook' => false,
+ 'cookieOptions' => [ 'prefix' => 'x' ],
+ ] );
+ $config = $this->getConfig();
+ $provider->setLogger( new \TestLogger() );
+ $provider->setConfig( $config );
+ $provider->setManager( SessionManager::singleton() );
+
+ $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+ $store = new TestBagOStuff();
+ $user = static::getTestSysop()->getUser();
+ $anon = new User;
+
+ $backend = new SessionBackend(
+ new SessionId( $sessionId ),
+ new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $sessionId,
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] ),
+ $store,
+ new \Psr\Log\NullLogger(),
+ 10
+ );
+ TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
+
+ $mock = $this->getMockBuilder( stdClass::class )
+ ->setMethods( [ 'onUserSetCookies' ] )
+ ->getMock();
+ $mock->expects( $this->never() )->method( 'onUserSetCookies' );
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'UserSetCookies' => [ $mock ] ] );
+
+ // Anonymous user
+ $backend->setUser( $anon );
+ $backend->setRememberUser( true );
+ $backend->setForceHTTPS( false );
+ $request = new \FauxRequest();
+ $provider->persistSession( $backend, $request );
+ $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) );
+ $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
+ $this->assertSame( [], $backend->getData() );
+
+ // Logged-in user, no remember
+ $backend->setUser( $user );
+ $backend->setRememberUser( false );
+ $backend->setForceHTTPS( false );
+ $request = new \FauxRequest();
+ $provider->persistSession( $backend, $request );
+ $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+ $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
+ $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
+ $this->assertSame( [], $backend->getData() );
+
+ // Logged-in user, remember
+ $backend->setUser( $user );
+ $backend->setRememberUser( true );
+ $backend->setForceHTTPS( true );
+ $request = new \FauxRequest();
+ $time = time();
+ $provider->persistSession( $backend, $request );
+ $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+ $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
+ $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
+ $this->assertSame( $user->getToken(), $request->response()->getCookie( 'xToken' ) );
+ $this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) );
+ $this->assertSame( [], $backend->getData() );
+ }
+
+ /**
+ * @dataProvider provideCookieData
+ * @param bool $secure
+ * @param bool $remember
+ */
+ public function testCookieData( $secure, $remember ) {
+ $this->setMwGlobals( [
+ 'wgSecureLogin' => false,
+ ] );
+
+ $provider = new CookieSessionProvider( [
+ 'priority' => 1,
+ 'sessionName' => 'MySessionName',
+ 'callUserSetCookiesHook' => false,
+ 'cookieOptions' => [ 'prefix' => 'x' ],
+ ] );
+ $config = $this->getConfig();
+ $config->set( 'CookieSecure', $secure );
+ $provider->setLogger( new \TestLogger() );
+ $provider->setConfig( $config );
+ $provider->setManager( SessionManager::singleton() );
+
+ $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+ $user = static::getTestSysop()->getUser();
+ $this->assertFalse( $user->requiresHTTPS(), 'sanity check' );
+
+ $backend = new SessionBackend(
+ new SessionId( $sessionId ),
+ new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $sessionId,
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] ),
+ new TestBagOStuff(),
+ new \Psr\Log\NullLogger(),
+ 10
+ );
+ TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
+ $backend->setUser( $user );
+ $backend->setRememberUser( $remember );
+ $backend->setForceHTTPS( $secure );
+ $request = new \FauxRequest();
+ $time = time();
+ $provider->persistSession( $backend, $request );
+
+ $defaults = [
+ 'expire' => (int)100,
+ 'path' => $config->get( 'CookiePath' ),
+ 'domain' => $config->get( 'CookieDomain' ),
+ 'secure' => $secure,
+ 'httpOnly' => $config->get( 'CookieHttpOnly' ),
+ 'raw' => false,
+ ];
+
+ $normalExpiry = $config->get( 'CookieExpiration' );
+ $extendedExpiry = $config->get( 'ExtendedLoginCookieExpiration' );
+ $extendedExpiry = (int)( $extendedExpiry === null ? 0 : $extendedExpiry );
+ $expect = [
+ 'MySessionName' => [
+ 'value' => (string)$sessionId,
+ 'expire' => 0,
+ ] + $defaults,
+ 'xUserID' => [
+ 'value' => (string)$user->getId(),
+ 'expire' => $remember ? $extendedExpiry : $normalExpiry,
+ ] + $defaults,
+ 'xUserName' => [
+ 'value' => $user->getName(),
+ 'expire' => $remember ? $extendedExpiry : $normalExpiry
+ ] + $defaults,
+ 'xToken' => [
+ 'value' => $remember ? $user->getToken() : '',
+ 'expire' => $remember ? $extendedExpiry : -31536000,
+ ] + $defaults,
+ 'forceHTTPS' => [
+ 'value' => $secure ? 'true' : '',
+ 'secure' => false,
+ 'expire' => $secure ? $remember ? $defaults['expire'] : 0 : -31536000,
+ ] + $defaults,
+ ];
+ foreach ( $expect as $key => $value ) {
+ $actual = $request->response()->getCookieData( $key );
+ if ( $actual && $actual['expire'] > 0 ) {
+ // Round expiry so we don't randomly fail if the seconds ticked during the test.
+ $actual['expire'] = round( $actual['expire'] - $time, -2 );
+ }
+ $this->assertEquals( $value, $actual, "Cookie $key" );
+ }
+ }
+
+ public static function provideCookieData() {
+ return [
+ [ false, false ],
+ [ false, true ],
+ [ true, false ],
+ [ true, true ],
+ ];
+ }
+
+ protected function getSentRequest() {
+ $sentResponse = $this->getMockBuilder( \FauxResponse::class )
+ ->setMethods( [ 'headersSent', 'setCookie', 'header' ] )->getMock();
+ $sentResponse->expects( $this->any() )->method( 'headersSent' )
+ ->will( $this->returnValue( true ) );
+ $sentResponse->expects( $this->never() )->method( 'setCookie' );
+ $sentResponse->expects( $this->never() )->method( 'header' );
+
+ $sentRequest = $this->getMockBuilder( \FauxRequest::class )
+ ->setMethods( [ 'response' ] )->getMock();
+ $sentRequest->expects( $this->any() )->method( 'response' )
+ ->will( $this->returnValue( $sentResponse ) );
+ return $sentRequest;
+ }
+
+ public function testPersistSessionWithHook() {
+ $provider = new CookieSessionProvider( [
+ 'priority' => 1,
+ 'sessionName' => 'MySessionName',
+ 'callUserSetCookiesHook' => true,
+ 'cookieOptions' => [ 'prefix' => 'x' ],
+ ] );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setConfig( $this->getConfig() );
+ $provider->setManager( SessionManager::singleton() );
+
+ $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+ $store = new TestBagOStuff();
+ $user = static::getTestSysop()->getUser();
+ $anon = new User;
+
+ $backend = new SessionBackend(
+ new SessionId( $sessionId ),
+ new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $sessionId,
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] ),
+ $store,
+ new \Psr\Log\NullLogger(),
+ 10
+ );
+ TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
+
+ // Anonymous user
+ $mock = $this->getMockBuilder( stdClass::class )
+ ->setMethods( [ 'onUserSetCookies' ] )->getMock();
+ $mock->expects( $this->never() )->method( 'onUserSetCookies' );
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'UserSetCookies' => [ $mock ] ] );
+ $backend->setUser( $anon );
+ $backend->setRememberUser( true );
+ $backend->setForceHTTPS( false );
+ $request = new \FauxRequest();
+ $provider->persistSession( $backend, $request );
+ $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) );
+ $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
+ $this->assertSame( [], $backend->getData() );
+
+ $provider->persistSession( $backend, $this->getSentRequest() );
+
+ // Logged-in user, no remember
+ $mock = $this->getMockBuilder( __CLASS__ )
+ ->setMethods( [ 'onUserSetCookies' ] )->getMock();
+ $mock->expects( $this->once() )->method( 'onUserSetCookies' )
+ ->will( $this->returnCallback( function ( $u, &$sessionData, &$cookies ) use ( $user ) {
+ $this->assertSame( $user, $u );
+ $this->assertEquals( [
+ 'wsUserID' => $user->getId(),
+ 'wsUserName' => $user->getName(),
+ 'wsToken' => $user->getToken(),
+ ], $sessionData );
+ $this->assertEquals( [
+ 'UserID' => $user->getId(),
+ 'UserName' => $user->getName(),
+ 'Token' => false,
+ ], $cookies );
+
+ $sessionData['foo'] = 'foo!';
+ $cookies['bar'] = 'bar!';
+ return true;
+ } ) );
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'UserSetCookies' => [ $mock ] ] );
+ $backend->setUser( $user );
+ $backend->setRememberUser( false );
+ $backend->setForceHTTPS( false );
+ $backend->setLoggedOutTimestamp( $loggedOut = time() );
+ $request = new \FauxRequest();
+ $provider->persistSession( $backend, $request );
+ $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+ $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
+ $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
+ $this->assertSame( 'bar!', $request->response()->getCookie( 'xbar' ) );
+ $this->assertSame( (string)$loggedOut, $request->response()->getCookie( 'xLoggedOut' ) );
+ $this->assertEquals( [
+ 'wsUserID' => $user->getId(),
+ 'wsUserName' => $user->getName(),
+ 'wsToken' => $user->getToken(),
+ 'foo' => 'foo!',
+ ], $backend->getData() );
+
+ $provider->persistSession( $backend, $this->getSentRequest() );
+
+ // Logged-in user, remember
+ $mock = $this->getMockBuilder( __CLASS__ )
+ ->setMethods( [ 'onUserSetCookies' ] )->getMock();
+ $mock->expects( $this->once() )->method( 'onUserSetCookies' )
+ ->will( $this->returnCallback( function ( $u, &$sessionData, &$cookies ) use ( $user ) {
+ $this->assertSame( $user, $u );
+ $this->assertEquals( [
+ 'wsUserID' => $user->getId(),
+ 'wsUserName' => $user->getName(),
+ 'wsToken' => $user->getToken(),
+ ], $sessionData );
+ $this->assertEquals( [
+ 'UserID' => $user->getId(),
+ 'UserName' => $user->getName(),
+ 'Token' => $user->getToken(),
+ ], $cookies );
+
+ $sessionData['foo'] = 'foo 2!';
+ $cookies['bar'] = 'bar 2!';
+ return true;
+ } ) );
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'UserSetCookies' => [ $mock ] ] );
+ $backend->setUser( $user );
+ $backend->setRememberUser( true );
+ $backend->setForceHTTPS( true );
+ $backend->setLoggedOutTimestamp( 0 );
+ $request = new \FauxRequest();
+ $provider->persistSession( $backend, $request );
+ $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+ $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
+ $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
+ $this->assertSame( $user->getToken(), $request->response()->getCookie( 'xToken' ) );
+ $this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) );
+ $this->assertSame( 'bar 2!', $request->response()->getCookie( 'xbar' ) );
+ $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) );
+ $this->assertEquals( [
+ 'wsUserID' => $user->getId(),
+ 'wsUserName' => $user->getName(),
+ 'wsToken' => $user->getToken(),
+ 'foo' => 'foo 2!',
+ ], $backend->getData() );
+
+ $provider->persistSession( $backend, $this->getSentRequest() );
+ }
+
+ public function testUnpersistSession() {
+ $provider = new CookieSessionProvider( [
+ 'priority' => 1,
+ 'sessionName' => 'MySessionName',
+ 'cookieOptions' => [ 'prefix' => 'x' ],
+ ] );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setConfig( $this->getConfig() );
+ $provider->setManager( SessionManager::singleton() );
+
+ $request = new \FauxRequest();
+ $provider->unpersistSession( $request );
+ $this->assertSame( '', $request->response()->getCookie( 'MySessionName' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) );
+ $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+ $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
+
+ $provider->unpersistSession( $this->getSentRequest() );
+ }
+
+ public function testSetLoggedOutCookie() {
+ $provider = TestingAccessWrapper::newFromObject( new CookieSessionProvider( [
+ 'priority' => 1,
+ 'sessionName' => 'MySessionName',
+ 'cookieOptions' => [ 'prefix' => 'x' ],
+ ] ) );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setConfig( $this->getConfig() );
+ $provider->setManager( SessionManager::singleton() );
+
+ $t1 = time();
+ $t2 = time() - 86400 * 2;
+
+ // Set it
+ $request = new \FauxRequest();
+ $provider->setLoggedOutCookie( $t1, $request );
+ $this->assertSame( (string)$t1, $request->response()->getCookie( 'xLoggedOut' ) );
+
+ // Too old
+ $request = new \FauxRequest();
+ $provider->setLoggedOutCookie( $t2, $request );
+ $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) );
+
+ // Don't reset if it's already set
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ 'xLoggedOut' => $t1,
+ ], '' );
+ $provider->setLoggedOutCookie( $t1, $request );
+ $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) );
+ }
+
+ /**
+ * To be mocked for hooks, since PHPUnit can't otherwise mock methods that
+ * take references.
+ */
+ public function onUserSetCookies( $user, &$sessionData, &$cookies ) {
+ }
+
+ public function testGetCookie() {
+ $provider = new CookieSessionProvider( [
+ 'priority' => 1,
+ 'sessionName' => 'MySessionName',
+ 'cookieOptions' => [ 'prefix' => 'x' ],
+ ] );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setConfig( $this->getConfig() );
+ $provider->setManager( SessionManager::singleton() );
+ $provider = TestingAccessWrapper::newFromObject( $provider );
+
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ 'xFoo' => 'foo!',
+ 'xBar' => 'deleted',
+ ], '' );
+ $this->assertSame( 'foo!', $provider->getCookie( $request, 'Foo', 'x' ) );
+ $this->assertNull( $provider->getCookie( $request, 'Bar', 'x' ) );
+ $this->assertNull( $provider->getCookie( $request, 'Baz', 'x' ) );
+ }
+
+ public function testGetRememberUserDuration() {
+ $config = $this->getConfig();
+ $provider = new CookieSessionProvider( [ 'priority' => 10 ] );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setConfig( $config );
+ $provider->setManager( SessionManager::singleton() );
+
+ $this->assertSame( 200, $provider->getRememberUserDuration() );
+
+ $config->set( 'ExtendedLoginCookieExpiration', null );
+
+ $this->assertSame( 100, $provider->getRememberUserDuration() );
+
+ $config->set( 'ExtendedLoginCookieExpiration', 0 );
+
+ $this->assertSame( null, $provider->getRememberUserDuration() );
+ }
+
+ public function testGetLoginCookieExpiration() {
+ $config = $this->getConfig();
+ $provider = TestingAccessWrapper::newFromObject( new CookieSessionProvider( [
+ 'priority' => 10
+ ] ) );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $provider->setConfig( $config );
+ $provider->setManager( SessionManager::singleton() );
+
+ // First cookie is an extended cookie, remember me true
+ $this->assertSame( 200, $provider->getLoginCookieExpiration( 'Token', true ) );
+ $this->assertSame( 100, $provider->getLoginCookieExpiration( 'User', true ) );
+
+ // First cookie is an extended cookie, remember me false
+ $this->assertSame( 100, $provider->getLoginCookieExpiration( 'UserID', false ) );
+ $this->assertSame( 100, $provider->getLoginCookieExpiration( 'User', false ) );
+
+ $config->set( 'ExtendedLoginCookieExpiration', null );
+
+ $this->assertSame( 100, $provider->getLoginCookieExpiration( 'Token', true ) );
+ $this->assertSame( 100, $provider->getLoginCookieExpiration( 'User', true ) );
+
+ $this->assertSame( 100, $provider->getLoginCookieExpiration( 'Token', false ) );
+ $this->assertSame( 100, $provider->getLoginCookieExpiration( 'User', false ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php b/www/wiki/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php
new file mode 100644
index 00000000..6dd32fcd
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php
@@ -0,0 +1,305 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\ImmutableSessionProviderWithCookie
+ */
+class ImmutableSessionProviderWithCookieTest extends MediaWikiTestCase {
+
+ private function getProvider( $name, $prefix = null ) {
+ $config = new \HashConfig();
+ $config->set( 'CookiePrefix', 'wgCookiePrefix' );
+
+ $params = [
+ 'sessionCookieName' => $name,
+ 'sessionCookieOptions' => [],
+ ];
+ if ( $prefix !== null ) {
+ $params['sessionCookieOptions']['prefix'] = $prefix;
+ }
+
+ $provider = $this->getMockBuilder( ImmutableSessionProviderWithCookie::class )
+ ->setConstructorArgs( [ $params ] )
+ ->getMockForAbstractClass();
+ $provider->setLogger( new \TestLogger() );
+ $provider->setConfig( $config );
+ $provider->setManager( new SessionManager() );
+
+ return $provider;
+ }
+
+ public function testConstructor() {
+ $provider = $this->getMockBuilder( ImmutableSessionProviderWithCookie::class )
+ ->getMockForAbstractClass();
+ $priv = TestingAccessWrapper::newFromObject( $provider );
+ $this->assertNull( $priv->sessionCookieName );
+ $this->assertSame( [], $priv->sessionCookieOptions );
+
+ $provider = $this->getMockBuilder( ImmutableSessionProviderWithCookie::class )
+ ->setConstructorArgs( [ [
+ 'sessionCookieName' => 'Foo',
+ 'sessionCookieOptions' => [ 'Bar' ],
+ ] ] )
+ ->getMockForAbstractClass();
+ $priv = TestingAccessWrapper::newFromObject( $provider );
+ $this->assertSame( 'Foo', $priv->sessionCookieName );
+ $this->assertSame( [ 'Bar' ], $priv->sessionCookieOptions );
+
+ try {
+ $provider = $this->getMockBuilder( ImmutableSessionProviderWithCookie::class )
+ ->setConstructorArgs( [ [
+ 'sessionCookieName' => false,
+ ] ] )
+ ->getMockForAbstractClass();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'sessionCookieName must be a string',
+ $ex->getMessage()
+ );
+ }
+
+ try {
+ $provider = $this->getMockBuilder( ImmutableSessionProviderWithCookie::class )
+ ->setConstructorArgs( [ [
+ 'sessionCookieOptions' => 'x',
+ ] ] )
+ ->getMockForAbstractClass();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'sessionCookieOptions must be an array',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ public function testBasics() {
+ $provider = $this->getProvider( null );
+ $this->assertFalse( $provider->persistsSessionID() );
+ $this->assertFalse( $provider->canChangeUser() );
+
+ $provider = $this->getProvider( 'Foo' );
+ $this->assertTrue( $provider->persistsSessionID() );
+ $this->assertFalse( $provider->canChangeUser() );
+
+ $msg = $provider->whyNoSession();
+ $this->assertInstanceOf( \Message::class, $msg );
+ $this->assertSame( 'sessionprovider-nocookies', $msg->getKey() );
+ }
+
+ public function testGetVaryCookies() {
+ $provider = $this->getProvider( null );
+ $this->assertSame( [], $provider->getVaryCookies() );
+
+ $provider = $this->getProvider( 'Foo' );
+ $this->assertSame( [ 'wgCookiePrefixFoo' ], $provider->getVaryCookies() );
+
+ $provider = $this->getProvider( 'Foo', 'Bar' );
+ $this->assertSame( [ 'BarFoo' ], $provider->getVaryCookies() );
+
+ $provider = $this->getProvider( 'Foo', '' );
+ $this->assertSame( [ 'Foo' ], $provider->getVaryCookies() );
+ }
+
+ public function testGetSessionIdFromCookie() {
+ $this->setMwGlobals( 'wgCookiePrefix', 'wgCookiePrefix' );
+ $request = new \FauxRequest();
+ $request->setCookies( [
+ '' => 'empty---------------------------',
+ 'Foo' => 'foo-----------------------------',
+ 'wgCookiePrefixFoo' => 'wgfoo---------------------------',
+ 'BarFoo' => 'foobar--------------------------',
+ 'bad' => 'bad',
+ ], '' );
+
+ $provider = TestingAccessWrapper::newFromObject( $this->getProvider( null ) );
+ try {
+ $provider->getSessionIdFromCookie( $request );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \BadMethodCallException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie::getSessionIdFromCookie ' .
+ 'may not be called when $this->sessionCookieName === null',
+ $ex->getMessage()
+ );
+ }
+
+ $provider = TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo' ) );
+ $this->assertSame(
+ 'wgfoo---------------------------',
+ $provider->getSessionIdFromCookie( $request )
+ );
+
+ $provider = TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo', 'Bar' ) );
+ $this->assertSame(
+ 'foobar--------------------------',
+ $provider->getSessionIdFromCookie( $request )
+ );
+
+ $provider = TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo', '' ) );
+ $this->assertSame(
+ 'foo-----------------------------',
+ $provider->getSessionIdFromCookie( $request )
+ );
+
+ $provider = TestingAccessWrapper::newFromObject( $this->getProvider( 'bad', '' ) );
+ $this->assertSame( null, $provider->getSessionIdFromCookie( $request ) );
+
+ $provider = TestingAccessWrapper::newFromObject( $this->getProvider( 'none', '' ) );
+ $this->assertSame( null, $provider->getSessionIdFromCookie( $request ) );
+ }
+
+ protected function getSentRequest() {
+ $sentResponse = $this->getMockBuilder( \FauxResponse::class )
+ ->setMethods( [ 'headersSent', 'setCookie', 'header' ] )
+ ->getMock();
+ $sentResponse->expects( $this->any() )->method( 'headersSent' )
+ ->will( $this->returnValue( true ) );
+ $sentResponse->expects( $this->never() )->method( 'setCookie' );
+ $sentResponse->expects( $this->never() )->method( 'header' );
+
+ $sentRequest = $this->getMockBuilder( \FauxRequest::class )
+ ->setMethods( [ 'response' ] )->getMock();
+ $sentRequest->expects( $this->any() )->method( 'response' )
+ ->will( $this->returnValue( $sentResponse ) );
+ return $sentRequest;
+ }
+
+ /**
+ * @dataProvider providePersistSession
+ * @param bool $secure
+ * @param bool $remember
+ */
+ public function testPersistSession( $secure, $remember ) {
+ $this->setMwGlobals( [
+ 'wgCookieExpiration' => 100,
+ 'wgSecureLogin' => false,
+ ] );
+
+ $provider = $this->getProvider( 'session' );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $priv = TestingAccessWrapper::newFromObject( $provider );
+ $priv->sessionCookieOptions = [
+ 'prefix' => 'x',
+ 'path' => 'CookiePath',
+ 'domain' => 'CookieDomain',
+ 'secure' => false,
+ 'httpOnly' => true,
+ ];
+
+ $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+ $user = User::newFromName( 'UTSysop' );
+ $this->assertFalse( $user->requiresHTTPS(), 'sanity check' );
+
+ $backend = new SessionBackend(
+ new SessionId( $sessionId ),
+ new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $sessionId,
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newFromUser( $user, true ),
+ 'idIsSafe' => true,
+ ] ),
+ new TestBagOStuff(),
+ new \Psr\Log\NullLogger(),
+ 10
+ );
+ TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
+ $backend->setRememberUser( $remember );
+ $backend->setForceHTTPS( $secure );
+
+ // No cookie
+ $priv->sessionCookieName = null;
+ $request = new \FauxRequest();
+ $provider->persistSession( $backend, $request );
+ $this->assertSame( [], $request->response()->getCookies() );
+
+ // Cookie
+ $priv->sessionCookieName = 'session';
+ $request = new \FauxRequest();
+ $time = time();
+ $provider->persistSession( $backend, $request );
+
+ $cookie = $request->response()->getCookieData( 'xsession' );
+ $this->assertInternalType( 'array', $cookie );
+ if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) {
+ // Round expiry so we don't randomly fail if the seconds ticked during the test.
+ $cookie['expire'] = round( $cookie['expire'] - $time, -2 );
+ }
+ $this->assertEquals( [
+ 'value' => $sessionId,
+ 'expire' => null,
+ 'path' => 'CookiePath',
+ 'domain' => 'CookieDomain',
+ 'secure' => $secure,
+ 'httpOnly' => true,
+ 'raw' => false,
+ ], $cookie );
+
+ $cookie = $request->response()->getCookieData( 'forceHTTPS' );
+ if ( $secure ) {
+ $this->assertInternalType( 'array', $cookie );
+ if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) {
+ // Round expiry so we don't randomly fail if the seconds ticked during the test.
+ $cookie['expire'] = round( $cookie['expire'] - $time, -2 );
+ }
+ $this->assertEquals( [
+ 'value' => 'true',
+ 'expire' => null,
+ 'path' => 'CookiePath',
+ 'domain' => 'CookieDomain',
+ 'secure' => false,
+ 'httpOnly' => true,
+ 'raw' => false,
+ ], $cookie );
+ } else {
+ $this->assertNull( $cookie );
+ }
+
+ // Headers sent
+ $request = $this->getSentRequest();
+ $provider->persistSession( $backend, $request );
+ $this->assertSame( [], $request->response()->getCookies() );
+ }
+
+ public static function providePersistSession() {
+ return [
+ [ false, false ],
+ [ false, true ],
+ [ true, false ],
+ [ true, true ],
+ ];
+ }
+
+ public function testUnpersistSession() {
+ $provider = $this->getProvider( 'session', '' );
+ $provider->setLogger( new \Psr\Log\NullLogger() );
+ $priv = TestingAccessWrapper::newFromObject( $provider );
+
+ // No cookie
+ $priv->sessionCookieName = null;
+ $request = new \FauxRequest();
+ $provider->unpersistSession( $request );
+ $this->assertSame( null, $request->response()->getCookie( 'session', '' ) );
+
+ // Cookie
+ $priv->sessionCookieName = 'session';
+ $request = new \FauxRequest();
+ $provider->unpersistSession( $request );
+ $this->assertSame( '', $request->response()->getCookie( 'session', '' ) );
+
+ // Headers sent
+ $request = $this->getSentRequest();
+ $provider->unpersistSession( $request );
+ $this->assertSame( null, $request->response()->getCookie( 'session', '' ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/session/MetadataMergeExceptionTest.php b/www/wiki/tests/phpunit/includes/session/MetadataMergeExceptionTest.php
new file mode 100644
index 00000000..8cb4302a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/MetadataMergeExceptionTest.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+
+/**
+ * @group Session
+ * @covers MediaWiki\Session\MetadataMergeException
+ */
+class MetadataMergeExceptionTest extends MediaWikiTestCase {
+
+ public function testBasics() {
+ $data = [ 'foo' => 'bar' ];
+
+ $ex = new MetadataMergeException();
+ $this->assertInstanceOf( \UnexpectedValueException::class, $ex );
+ $this->assertSame( [], $ex->getContext() );
+
+ $ex2 = new MetadataMergeException( 'Message', 42, $ex, $data );
+ $this->assertSame( 'Message', $ex2->getMessage() );
+ $this->assertSame( 42, $ex2->getCode() );
+ $this->assertSame( $ex, $ex2->getPrevious() );
+ $this->assertSame( $data, $ex2->getContext() );
+
+ $ex->setContext( $data );
+ $this->assertSame( $data, $ex->getContext() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/session/PHPSessionHandlerTest.php b/www/wiki/tests/phpunit/includes/session/PHPSessionHandlerTest.php
new file mode 100644
index 00000000..045ba2f0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/PHPSessionHandlerTest.php
@@ -0,0 +1,361 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LogLevel;
+use MediaWikiTestCase;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Session
+ * @covers MediaWiki\Session\PHPSessionHandler
+ */
+class PHPSessionHandlerTest extends MediaWikiTestCase {
+
+ private function getResetter( &$rProp = null ) {
+ $reset = [];
+
+ $rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' );
+ $rProp->setAccessible( true );
+ if ( $rProp->getValue() ) {
+ $old = TestingAccessWrapper::newFromObject( $rProp->getValue() );
+ $oldManager = $old->manager;
+ $oldStore = $old->store;
+ $oldLogger = $old->logger;
+ $reset[] = new \Wikimedia\ScopedCallback(
+ [ PHPSessionHandler::class, 'install' ],
+ [ $oldManager, $oldStore, $oldLogger ]
+ );
+ }
+
+ return $reset;
+ }
+
+ public function testEnableFlags() {
+ $handler = TestingAccessWrapper::newFromObject(
+ $this->getMockBuilder( PHPSessionHandler::class )
+ ->setMethods( null )
+ ->disableOriginalConstructor()
+ ->getMock()
+ );
+
+ $rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' );
+ $rProp->setAccessible( true );
+ $reset = new \Wikimedia\ScopedCallback( [ $rProp, 'setValue' ], [ $rProp->getValue() ] );
+ $rProp->setValue( $handler );
+
+ $handler->setEnableFlags( 'enable' );
+ $this->assertTrue( $handler->enable );
+ $this->assertFalse( $handler->warn );
+ $this->assertTrue( PHPSessionHandler::isEnabled() );
+
+ $handler->setEnableFlags( 'warn' );
+ $this->assertTrue( $handler->enable );
+ $this->assertTrue( $handler->warn );
+ $this->assertTrue( PHPSessionHandler::isEnabled() );
+
+ $handler->setEnableFlags( 'disable' );
+ $this->assertFalse( $handler->enable );
+ $this->assertFalse( PHPSessionHandler::isEnabled() );
+
+ $rProp->setValue( null );
+ $this->assertFalse( PHPSessionHandler::isEnabled() );
+ }
+
+ public function testInstall() {
+ $reset = $this->getResetter( $rProp );
+ $rProp->setValue( null );
+
+ session_write_close();
+ ini_set( 'session.use_cookies', 1 );
+ ini_set( 'session.use_trans_sid', 1 );
+
+ $store = new TestBagOStuff();
+ $logger = new \TestLogger();
+ $manager = new SessionManager( [
+ 'store' => $store,
+ 'logger' => $logger,
+ ] );
+
+ $this->assertFalse( PHPSessionHandler::isInstalled() );
+ PHPSessionHandler::install( $manager );
+ $this->assertTrue( PHPSessionHandler::isInstalled() );
+
+ $this->assertFalse( wfIniGetBool( 'session.use_cookies' ) );
+ $this->assertFalse( wfIniGetBool( 'session.use_trans_sid' ) );
+
+ $this->assertNotNull( $rProp->getValue() );
+ $priv = TestingAccessWrapper::newFromObject( $rProp->getValue() );
+ $this->assertSame( $manager, $priv->manager );
+ $this->assertSame( $store, $priv->store );
+ $this->assertSame( $logger, $priv->logger );
+ }
+
+ /**
+ * @dataProvider provideHandlers
+ * @param string $handler php serialize_handler to use
+ */
+ public function testSessionHandling( $handler ) {
+ $this->hideDeprecated( '$_SESSION' );
+ $reset[] = $this->getResetter( $rProp );
+
+ $this->setMwGlobals( [
+ 'wgSessionProviders' => [ [ 'class' => \DummySessionProvider::class ] ],
+ 'wgObjectCacheSessionExpiry' => 2,
+ ] );
+
+ $store = new TestBagOStuff();
+ $logger = new \TestLogger( true, function ( $m ) {
+ // Discard all log events starting with expected prefix
+ return preg_match( '/^SessionBackend "\{session\}" /', $m ) ? null : $m;
+ } );
+ $manager = new SessionManager( [
+ 'store' => $store,
+ 'logger' => $logger,
+ ] );
+ PHPSessionHandler::install( $manager );
+ $wrap = TestingAccessWrapper::newFromObject( $rProp->getValue() );
+ $reset[] = new \Wikimedia\ScopedCallback(
+ [ $wrap, 'setEnableFlags' ],
+ [ $wrap->enable ? $wrap->warn ? 'warn' : 'enable' : 'disable' ]
+ );
+ $wrap->setEnableFlags( 'warn' );
+
+ \Wikimedia\suppressWarnings();
+ ini_set( 'session.serialize_handler', $handler );
+ \Wikimedia\restoreWarnings();
+ if ( ini_get( 'session.serialize_handler' ) !== $handler ) {
+ $this->markTestSkipped( "Cannot set session.serialize_handler to \"$handler\"" );
+ }
+
+ // Session IDs for testing
+ $sessionA = str_repeat( 'a', 32 );
+ $sessionB = str_repeat( 'b', 32 );
+ $sessionC = str_repeat( 'c', 32 );
+
+ // Set up garbage data in the session
+ $_SESSION['AuthenticationSessionTest'] = 'bogus';
+
+ session_id( $sessionA );
+ session_start();
+ $this->assertSame( [], $_SESSION );
+ $this->assertSame( $sessionA, session_id() );
+
+ // Set some data in the session so we can see if it works.
+ $rand = mt_rand();
+ $_SESSION['AuthenticationSessionTest'] = $rand;
+ $expect = [ 'AuthenticationSessionTest' => $rand ];
+ session_write_close();
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Something wrote to $_SESSION!' ],
+ ], $logger->getBuffer() );
+
+ // Screw up $_SESSION so we can tell the difference between "this
+ // worked" and "this did nothing"
+ $_SESSION['AuthenticationSessionTest'] = 'bogus';
+
+ // Re-open the session and see that data was actually reloaded
+ session_start();
+ $this->assertSame( $expect, $_SESSION );
+
+ // Make sure session_reset() works too.
+ if ( function_exists( 'session_reset' ) ) {
+ $_SESSION['AuthenticationSessionTest'] = 'bogus';
+ session_reset();
+ $this->assertSame( $expect, $_SESSION );
+ }
+
+ // Re-fill the session, then test that session_destroy() works.
+ $_SESSION['AuthenticationSessionTest'] = $rand;
+ session_write_close();
+ session_start();
+ $this->assertSame( $expect, $_SESSION );
+ session_destroy();
+ session_id( $sessionA );
+ session_start();
+ $this->assertSame( [], $_SESSION );
+ session_write_close();
+
+ // Test that our session handler won't clone someone else's session
+ session_id( $sessionB );
+ session_start();
+ $this->assertSame( $sessionB, session_id() );
+ $_SESSION['id'] = 'B';
+ session_write_close();
+
+ session_id( $sessionC );
+ session_start();
+ $this->assertSame( [], $_SESSION );
+ $_SESSION['id'] = 'C';
+ session_write_close();
+
+ session_id( $sessionB );
+ session_start();
+ $this->assertSame( [ 'id' => 'B' ], $_SESSION );
+ session_write_close();
+
+ session_id( $sessionC );
+ session_start();
+ $this->assertSame( [ 'id' => 'C' ], $_SESSION );
+ session_destroy();
+
+ session_id( $sessionB );
+ session_start();
+ $this->assertSame( [ 'id' => 'B' ], $_SESSION );
+
+ // Test merging between Session and $_SESSION
+ session_write_close();
+
+ $session = $manager->getEmptySession();
+ $session->set( 'Unchanged', 'setup' );
+ $session->set( 'Unchanged, null', null );
+ $session->set( 'Changed in $_SESSION', 'setup' );
+ $session->set( 'Changed in Session', 'setup' );
+ $session->set( 'Changed in both', 'setup' );
+ $session->set( 'Deleted in Session', 'setup' );
+ $session->set( 'Deleted in $_SESSION', 'setup' );
+ $session->set( 'Deleted in both', 'setup' );
+ $session->set( 'Deleted in Session, changed in $_SESSION', 'setup' );
+ $session->set( 'Deleted in $_SESSION, changed in Session', 'setup' );
+ $session->persist();
+ $session->save();
+
+ session_id( $session->getId() );
+ session_start();
+ $session->set( 'Added in Session', 'Session' );
+ $session->set( 'Added in both', 'Session' );
+ $session->set( 'Changed in Session', 'Session' );
+ $session->set( 'Changed in both', 'Session' );
+ $session->set( 'Deleted in $_SESSION, changed in Session', 'Session' );
+ $session->remove( 'Deleted in Session' );
+ $session->remove( 'Deleted in both' );
+ $session->remove( 'Deleted in Session, changed in $_SESSION' );
+ $session->save();
+ $_SESSION['Added in $_SESSION'] = '$_SESSION';
+ $_SESSION['Added in both'] = '$_SESSION';
+ $_SESSION['Changed in $_SESSION'] = '$_SESSION';
+ $_SESSION['Changed in both'] = '$_SESSION';
+ $_SESSION['Deleted in Session, changed in $_SESSION'] = '$_SESSION';
+ unset( $_SESSION['Deleted in $_SESSION'] );
+ unset( $_SESSION['Deleted in both'] );
+ unset( $_SESSION['Deleted in $_SESSION, changed in Session'] );
+ session_write_close();
+
+ $this->assertEquals( [
+ 'Added in Session' => 'Session',
+ 'Added in $_SESSION' => '$_SESSION',
+ 'Added in both' => 'Session',
+ 'Unchanged' => 'setup',
+ 'Unchanged, null' => null,
+ 'Changed in Session' => 'Session',
+ 'Changed in $_SESSION' => '$_SESSION',
+ 'Changed in both' => 'Session',
+ 'Deleted in Session, changed in $_SESSION' => '$_SESSION',
+ 'Deleted in $_SESSION, changed in Session' => 'Session',
+ ], iterator_to_array( $session ) );
+
+ $session->clear();
+ $session->set( 42, 'forty-two' );
+ $session->set( 'forty-two', 42 );
+ $session->set( 'wrong', 43 );
+ $session->persist();
+ $session->save();
+
+ session_start();
+ $this->assertArrayHasKey( 'forty-two', $_SESSION );
+ $this->assertSame( 42, $_SESSION['forty-two'] );
+ $this->assertArrayHasKey( 'wrong', $_SESSION );
+ unset( $_SESSION['wrong'] );
+ session_write_close();
+
+ $this->assertEquals( [
+ 42 => 'forty-two',
+ 'forty-two' => 42,
+ ], iterator_to_array( $session ) );
+
+ // Test that write doesn't break if the session is invalid
+ $session = $manager->getEmptySession();
+ $session->persist();
+ $id = $session->getId();
+ unset( $session );
+ session_id( $id );
+ session_start();
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'SessionCheckInfo' => [ function ( &$reason ) {
+ $reason = 'Testing';
+ return false;
+ } ],
+ ] );
+ $this->assertNull( $manager->getSessionById( $id, true ), 'sanity check' );
+ session_write_close();
+
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'SessionCheckInfo' => [],
+ ] );
+ $this->assertNotNull( $manager->getSessionById( $id, true ), 'sanity check' );
+ }
+
+ public static function provideHandlers() {
+ return [
+ [ 'php' ],
+ [ 'php_binary' ],
+ [ 'php_serialize' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideDisabled
+ * @expectedException BadMethodCallException
+ * @expectedExceptionMessage Attempt to use PHP session management
+ */
+ public function testDisabled( $method, $args ) {
+ $rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' );
+ $rProp->setAccessible( true );
+ $handler = $this->getMockBuilder( PHPSessionHandler::class )
+ ->setMethods( null )
+ ->disableOriginalConstructor()
+ ->getMock();
+ TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'disable' );
+ $oldValue = $rProp->getValue();
+ $rProp->setValue( $handler );
+ $reset = new \Wikimedia\ScopedCallback( [ $rProp, 'setValue' ], [ $oldValue ] );
+
+ call_user_func_array( [ $handler, $method ], $args );
+ }
+
+ public static function provideDisabled() {
+ return [
+ [ 'open', [ '', '' ] ],
+ [ 'read', [ '' ] ],
+ [ 'write', [ '', '' ] ],
+ [ 'destroy', [ '' ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideWrongInstance
+ * @expectedException UnexpectedValueException
+ * @expectedExceptionMessageRegExp /: Wrong instance called!$/
+ */
+ public function testWrongInstance( $method, $args ) {
+ $handler = $this->getMockBuilder( PHPSessionHandler::class )
+ ->setMethods( null )
+ ->disableOriginalConstructor()
+ ->getMock();
+ TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'enable' );
+
+ call_user_func_array( [ $handler, $method ], $args );
+ }
+
+ public static function provideWrongInstance() {
+ return [
+ [ 'open', [ '', '' ] ],
+ [ 'close', [] ],
+ [ 'read', [ '' ] ],
+ [ 'write', [ '', '' ] ],
+ [ 'destroy', [ '' ] ],
+ [ 'gc', [ 0 ] ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/session/SessionBackendTest.php b/www/wiki/tests/phpunit/includes/session/SessionBackendTest.php
new file mode 100644
index 00000000..48c3d179
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/SessionBackendTest.php
@@ -0,0 +1,963 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Config;
+use MediaWikiTestCase;
+use User;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\SessionBackend
+ */
+class SessionBackendTest extends MediaWikiTestCase {
+ const SESSIONID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+ /** @var SessionManager */
+ protected $manager;
+
+ /** @var Config */
+ protected $config;
+
+ /** @var SessionProvider */
+ protected $provider;
+
+ /** @var TestBagOStuff */
+ protected $store;
+
+ protected $onSessionMetadataCalled = false;
+
+ /**
+ * Returns a non-persistent backend that thinks it has at least one session active
+ * @param User|null $user
+ * @param string $id
+ * @return SessionBackend
+ */
+ protected function getBackend( User $user = null, $id = null ) {
+ if ( !$this->config ) {
+ $this->config = new \HashConfig();
+ $this->manager = null;
+ }
+ if ( !$this->store ) {
+ $this->store = new TestBagOStuff();
+ $this->manager = null;
+ }
+
+ $logger = new \Psr\Log\NullLogger();
+ if ( !$this->manager ) {
+ $this->manager = new SessionManager( [
+ 'store' => $this->store,
+ 'logger' => $logger,
+ 'config' => $this->config,
+ ] );
+ }
+
+ if ( !$this->provider ) {
+ $this->provider = new \DummySessionProvider();
+ }
+ $this->provider->setLogger( $logger );
+ $this->provider->setConfig( $this->config );
+ $this->provider->setManager( $this->manager );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $this->provider,
+ 'id' => $id ?: self::SESSIONID,
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newFromUser( $user ?: new User, true ),
+ 'idIsSafe' => true,
+ ] );
+ $id = new SessionId( $info->getId() );
+
+ $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 );
+ $priv = TestingAccessWrapper::newFromObject( $backend );
+ $priv->persist = false;
+ $priv->requests = [ 100 => new \FauxRequest() ];
+ $priv->requests[100]->setSessionId( $id );
+ $priv->usePhpSessionHandling = false;
+
+ $manager = TestingAccessWrapper::newFromObject( $this->manager );
+ $manager->allSessionBackends = [ $backend->getId() => $backend ] + $manager->allSessionBackends;
+ $manager->allSessionIds = [ $backend->getId() => $id ] + $manager->allSessionIds;
+ $manager->sessionProviders = [ (string)$this->provider => $this->provider ];
+
+ return $backend;
+ }
+
+ public function testConstructor() {
+ // Set variables
+ $this->getBackend();
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $this->provider,
+ 'id' => self::SESSIONID,
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newFromName( 'UTSysop', false ),
+ 'idIsSafe' => true,
+ ] );
+ $id = new SessionId( $info->getId() );
+ $logger = new \Psr\Log\NullLogger();
+ try {
+ new SessionBackend( $id, $info, $this->store, $logger, 10 );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ "Refusing to create session for unverified user {$info->getUserInfo()}",
+ $ex->getMessage()
+ );
+ }
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'id' => self::SESSIONID,
+ 'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+ 'idIsSafe' => true,
+ ] );
+ $id = new SessionId( $info->getId() );
+ try {
+ new SessionBackend( $id, $info, $this->store, $logger, 10 );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Cannot create session without a provider', $ex->getMessage() );
+ }
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $this->provider,
+ 'id' => self::SESSIONID,
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+ 'idIsSafe' => true,
+ ] );
+ $id = new SessionId( '!' . $info->getId() );
+ try {
+ new SessionBackend( $id, $info, $this->store, $logger, 10 );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'SessionId and SessionInfo don\'t match',
+ $ex->getMessage()
+ );
+ }
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $this->provider,
+ 'id' => self::SESSIONID,
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+ 'idIsSafe' => true,
+ ] );
+ $id = new SessionId( $info->getId() );
+ $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 );
+ $this->assertSame( self::SESSIONID, $backend->getId() );
+ $this->assertSame( $id, $backend->getSessionId() );
+ $this->assertSame( $this->provider, $backend->getProvider() );
+ $this->assertInstanceOf( User::class, $backend->getUser() );
+ $this->assertSame( 'UTSysop', $backend->getUser()->getName() );
+ $this->assertSame( $info->wasPersisted(), $backend->isPersistent() );
+ $this->assertSame( $info->wasRemembered(), $backend->shouldRememberUser() );
+ $this->assertSame( $info->forceHTTPS(), $backend->shouldForceHTTPS() );
+
+ $expire = time() + 100;
+ $this->store->setSessionMeta( self::SESSIONID, [ 'expires' => $expire ] );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $this->provider,
+ 'id' => self::SESSIONID,
+ 'persisted' => true,
+ 'forceHTTPS' => true,
+ 'metadata' => [ 'foo' ],
+ 'idIsSafe' => true,
+ ] );
+ $id = new SessionId( $info->getId() );
+ $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 );
+ $this->assertSame( self::SESSIONID, $backend->getId() );
+ $this->assertSame( $id, $backend->getSessionId() );
+ $this->assertSame( $this->provider, $backend->getProvider() );
+ $this->assertInstanceOf( User::class, $backend->getUser() );
+ $this->assertTrue( $backend->getUser()->isAnon() );
+ $this->assertSame( $info->wasPersisted(), $backend->isPersistent() );
+ $this->assertSame( $info->wasRemembered(), $backend->shouldRememberUser() );
+ $this->assertSame( $info->forceHTTPS(), $backend->shouldForceHTTPS() );
+ $this->assertSame( $expire, TestingAccessWrapper::newFromObject( $backend )->expires );
+ $this->assertSame( [ 'foo' ], $backend->getProviderMetadata() );
+ }
+
+ public function testSessionStuff() {
+ $backend = $this->getBackend();
+ $priv = TestingAccessWrapper::newFromObject( $backend );
+ $priv->requests = []; // Remove dummy session
+
+ $manager = TestingAccessWrapper::newFromObject( $this->manager );
+
+ $request1 = new \FauxRequest();
+ $session1 = $backend->getSession( $request1 );
+ $request2 = new \FauxRequest();
+ $session2 = $backend->getSession( $request2 );
+
+ $this->assertInstanceOf( Session::class, $session1 );
+ $this->assertInstanceOf( Session::class, $session2 );
+ $this->assertSame( 2, count( $priv->requests ) );
+
+ $index = TestingAccessWrapper::newFromObject( $session1 )->index;
+
+ $this->assertSame( $request1, $backend->getRequest( $index ) );
+ $this->assertSame( null, $backend->suggestLoginUsername( $index ) );
+ $request1->setCookie( 'UserName', 'Example' );
+ $this->assertSame( 'Example', $backend->suggestLoginUsername( $index ) );
+
+ $session1 = null;
+ $this->assertSame( 1, count( $priv->requests ) );
+ $this->assertArrayHasKey( $backend->getId(), $manager->allSessionBackends );
+ $this->assertSame( $backend, $manager->allSessionBackends[$backend->getId()] );
+ try {
+ $backend->getRequest( $index );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid session index', $ex->getMessage() );
+ }
+ try {
+ $backend->suggestLoginUsername( $index );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid session index', $ex->getMessage() );
+ }
+
+ $session2 = null;
+ $this->assertSame( 0, count( $priv->requests ) );
+ $this->assertArrayNotHasKey( $backend->getId(), $manager->allSessionBackends );
+ $this->assertArrayHasKey( $backend->getId(), $manager->allSessionIds );
+ }
+
+ public function testSetProviderMetadata() {
+ $backend = $this->getBackend();
+ $priv = TestingAccessWrapper::newFromObject( $backend );
+ $priv->providerMetadata = [ 'dummy' ];
+
+ try {
+ $backend->setProviderMetadata( 'foo' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( '$metadata must be an array or null', $ex->getMessage() );
+ }
+
+ try {
+ $backend->setProviderMetadata( (object)[] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( '$metadata must be an array or null', $ex->getMessage() );
+ }
+
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'sanity check' );
+ $backend->setProviderMetadata( [ 'dummy' ] );
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ) );
+
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'sanity check' );
+ $backend->setProviderMetadata( [ 'test' ] );
+ $this->assertNotFalse( $this->store->getSession( self::SESSIONID ) );
+ $this->assertSame( [ 'test' ], $backend->getProviderMetadata() );
+ $this->store->deleteSession( self::SESSIONID );
+
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'sanity check' );
+ $backend->setProviderMetadata( null );
+ $this->assertNotFalse( $this->store->getSession( self::SESSIONID ) );
+ $this->assertSame( null, $backend->getProviderMetadata() );
+ $this->store->deleteSession( self::SESSIONID );
+ }
+
+ public function testResetId() {
+ $id = session_id();
+
+ $builder = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'persistsSessionId', 'sessionIdWasReset' ] );
+
+ $this->provider = $builder->getMock();
+ $this->provider->expects( $this->any() )->method( 'persistsSessionId' )
+ ->will( $this->returnValue( false ) );
+ $this->provider->expects( $this->never() )->method( 'sessionIdWasReset' );
+ $backend = $this->getBackend( User::newFromName( 'UTSysop' ) );
+ $manager = TestingAccessWrapper::newFromObject( $this->manager );
+ $sessionId = $backend->getSessionId();
+ $backend->resetId();
+ $this->assertSame( self::SESSIONID, $backend->getId() );
+ $this->assertSame( $backend->getId(), $sessionId->getId() );
+ $this->assertSame( $id, session_id() );
+ $this->assertSame( $backend, $manager->allSessionBackends[self::SESSIONID] );
+
+ $this->provider = $builder->getMock();
+ $this->provider->expects( $this->any() )->method( 'persistsSessionId' )
+ ->will( $this->returnValue( true ) );
+ $backend = $this->getBackend();
+ $this->provider->expects( $this->once() )->method( 'sessionIdWasReset' )
+ ->with( $this->identicalTo( $backend ), $this->identicalTo( self::SESSIONID ) );
+ $manager = TestingAccessWrapper::newFromObject( $this->manager );
+ $sessionId = $backend->getSessionId();
+ $backend->resetId();
+ $this->assertNotEquals( self::SESSIONID, $backend->getId() );
+ $this->assertSame( $backend->getId(), $sessionId->getId() );
+ $this->assertInternalType( 'array', $this->store->getSession( $backend->getId() ) );
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ) );
+ $this->assertSame( $id, session_id() );
+ $this->assertArrayNotHasKey( self::SESSIONID, $manager->allSessionBackends );
+ $this->assertArrayHasKey( $backend->getId(), $manager->allSessionBackends );
+ $this->assertSame( $backend, $manager->allSessionBackends[$backend->getId()] );
+ }
+
+ public function testPersist() {
+ $this->provider = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'persistSession' ] )->getMock();
+ $this->provider->expects( $this->once() )->method( 'persistSession' );
+ $backend = $this->getBackend();
+ $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+ $backend->save(); // This one shouldn't call $provider->persistSession()
+
+ $backend->persist();
+ $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+
+ $this->provider = null;
+ $backend = $this->getBackend();
+ $wrap = TestingAccessWrapper::newFromObject( $backend );
+ $wrap->persist = true;
+ $wrap->expires = 0;
+ $backend->persist();
+ $this->assertNotEquals( 0, $wrap->expires );
+ }
+
+ public function testUnpersist() {
+ $this->provider = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'unpersistSession' ] )->getMock();
+ $this->provider->expects( $this->once() )->method( 'unpersistSession' );
+ $backend = $this->getBackend();
+ $wrap = TestingAccessWrapper::newFromObject( $backend );
+ $wrap->store = new \CachedBagOStuff( $this->store );
+ $wrap->persist = true;
+ $wrap->dataDirty = true;
+
+ $backend->save(); // This one shouldn't call $provider->persistSession(), but should save
+ $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+ $this->assertNotFalse( $this->store->getSession( self::SESSIONID ), 'sanity check' );
+
+ $backend->unpersist();
+ $this->assertFalse( $backend->isPersistent() );
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ) );
+ $this->assertNotFalse(
+ $wrap->store->get( $wrap->store->makeKey( 'MWSession', self::SESSIONID ) )
+ );
+ }
+
+ public function testRememberUser() {
+ $backend = $this->getBackend();
+
+ $remembered = $backend->shouldRememberUser();
+ $backend->setRememberUser( !$remembered );
+ $this->assertNotEquals( $remembered, $backend->shouldRememberUser() );
+ $backend->setRememberUser( $remembered );
+ $this->assertEquals( $remembered, $backend->shouldRememberUser() );
+ }
+
+ public function testForceHTTPS() {
+ $backend = $this->getBackend();
+
+ $force = $backend->shouldForceHTTPS();
+ $backend->setForceHTTPS( !$force );
+ $this->assertNotEquals( $force, $backend->shouldForceHTTPS() );
+ $backend->setForceHTTPS( $force );
+ $this->assertEquals( $force, $backend->shouldForceHTTPS() );
+ }
+
+ public function testLoggedOutTimestamp() {
+ $backend = $this->getBackend();
+
+ $backend->setLoggedOutTimestamp( 42 );
+ $this->assertSame( 42, $backend->getLoggedOutTimestamp() );
+ $backend->setLoggedOutTimestamp( '123' );
+ $this->assertSame( 123, $backend->getLoggedOutTimestamp() );
+ }
+
+ public function testSetUser() {
+ $user = static::getTestSysop()->getUser();
+
+ $this->provider = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'canChangeUser' ] )->getMock();
+ $this->provider->expects( $this->any() )->method( 'canChangeUser' )
+ ->will( $this->returnValue( false ) );
+ $backend = $this->getBackend();
+ $this->assertFalse( $backend->canSetUser() );
+ try {
+ $backend->setUser( $user );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \BadMethodCallException $ex ) {
+ $this->assertSame(
+ 'Cannot set user on this session; check $session->canSetUser() first',
+ $ex->getMessage()
+ );
+ }
+ $this->assertNotSame( $user, $backend->getUser() );
+
+ $this->provider = null;
+ $backend = $this->getBackend();
+ $this->assertTrue( $backend->canSetUser() );
+ $this->assertNotSame( $user, $backend->getUser(), 'sanity check' );
+ $backend->setUser( $user );
+ $this->assertSame( $user, $backend->getUser() );
+ }
+
+ public function testDirty() {
+ $backend = $this->getBackend();
+ $priv = TestingAccessWrapper::newFromObject( $backend );
+ $priv->dataDirty = false;
+ $backend->dirty();
+ $this->assertTrue( $priv->dataDirty );
+ }
+
+ public function testGetData() {
+ $backend = $this->getBackend();
+ $data = $backend->getData();
+ $this->assertSame( [], $data );
+ $this->assertTrue( TestingAccessWrapper::newFromObject( $backend )->dataDirty );
+ $data['???'] = '!!!';
+ $this->assertSame( [ '???' => '!!!' ], $data );
+
+ $testData = [ 'foo' => 'foo!', 'bar', [ 'baz', null ] ];
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend();
+ $this->assertSame( $testData, $backend->getData() );
+ $this->assertFalse( TestingAccessWrapper::newFromObject( $backend )->dataDirty );
+ }
+
+ public function testAddData() {
+ $backend = $this->getBackend();
+ $priv = TestingAccessWrapper::newFromObject( $backend );
+
+ $priv->data = [ 'foo' => 1 ];
+ $priv->dataDirty = false;
+ $backend->addData( [ 'foo' => 1 ] );
+ $this->assertSame( [ 'foo' => 1 ], $priv->data );
+ $this->assertFalse( $priv->dataDirty );
+
+ $priv->data = [ 'foo' => 1 ];
+ $priv->dataDirty = false;
+ $backend->addData( [ 'foo' => '1' ] );
+ $this->assertSame( [ 'foo' => '1' ], $priv->data );
+ $this->assertTrue( $priv->dataDirty );
+
+ $priv->data = [ 'foo' => 1 ];
+ $priv->dataDirty = false;
+ $backend->addData( [ 'bar' => 2 ] );
+ $this->assertSame( [ 'foo' => 1, 'bar' => 2 ], $priv->data );
+ $this->assertTrue( $priv->dataDirty );
+ }
+
+ public function testDelaySave() {
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
+ $backend = $this->getBackend();
+ $priv = TestingAccessWrapper::newFromObject( $backend );
+ $priv->persist = true;
+
+ // Saves happen normally when no delay is in effect
+ $this->onSessionMetadataCalled = false;
+ $priv->metaDirty = true;
+ $backend->save();
+ $this->assertTrue( $this->onSessionMetadataCalled, 'sanity check' );
+
+ $this->onSessionMetadataCalled = false;
+ $priv->metaDirty = true;
+ $priv->autosave();
+ $this->assertTrue( $this->onSessionMetadataCalled, 'sanity check' );
+
+ $delay = $backend->delaySave();
+
+ // Autosave doesn't happen when no delay is in effect
+ $this->onSessionMetadataCalled = false;
+ $priv->metaDirty = true;
+ $priv->autosave();
+ $this->assertFalse( $this->onSessionMetadataCalled );
+
+ // Save still does happen when no delay is in effect
+ $priv->save();
+ $this->assertTrue( $this->onSessionMetadataCalled );
+
+ // Save happens when delay is consumed
+ $this->onSessionMetadataCalled = false;
+ $priv->metaDirty = true;
+ \Wikimedia\ScopedCallback::consume( $delay );
+ $this->assertTrue( $this->onSessionMetadataCalled );
+
+ // Test multiple delays
+ $delay1 = $backend->delaySave();
+ $delay2 = $backend->delaySave();
+ $delay3 = $backend->delaySave();
+ $this->onSessionMetadataCalled = false;
+ $priv->metaDirty = true;
+ $priv->autosave();
+ $this->assertFalse( $this->onSessionMetadataCalled );
+ \Wikimedia\ScopedCallback::consume( $delay3 );
+ $this->assertFalse( $this->onSessionMetadataCalled );
+ \Wikimedia\ScopedCallback::consume( $delay1 );
+ $this->assertFalse( $this->onSessionMetadataCalled );
+ \Wikimedia\ScopedCallback::consume( $delay2 );
+ $this->assertTrue( $this->onSessionMetadataCalled );
+ }
+
+ public function testSave() {
+ $user = static::getTestSysop()->getUser();
+ $this->store = new TestBagOStuff();
+ $testData = [ 'foo' => 'foo!', 'bar', [ 'baz', null ] ];
+
+ $neverHook = $this->getMockBuilder( __CLASS__ )
+ ->setMethods( [ 'onSessionMetadata' ] )->getMock();
+ $neverHook->expects( $this->never() )->method( 'onSessionMetadata' );
+
+ $builder = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'persistSession', 'unpersistSession' ] );
+
+ $neverProvider = $builder->getMock();
+ $neverProvider->expects( $this->never() )->method( 'persistSession' );
+ $neverProvider->expects( $this->never() )->method( 'unpersistSession' );
+
+ // Not persistent or dirty
+ $this->provider = $neverProvider;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $neverHook ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+ TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+ TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+ $backend->save();
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+
+ // (but does unpersist if forced)
+ $this->provider = $builder->getMock();
+ $this->provider->expects( $this->never() )->method( 'persistSession' );
+ $this->provider->expects( $this->atLeastOnce() )->method( 'unpersistSession' );
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $neverHook ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ TestingAccessWrapper::newFromObject( $backend )->persist = false;
+ TestingAccessWrapper::newFromObject( $backend )->forcePersist = true;
+ $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+ TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+ TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+ $backend->save();
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+
+ // (but not to a WebRequest associated with a different session)
+ $this->provider = $neverProvider;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $neverHook ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ TestingAccessWrapper::newFromObject( $backend )->requests[100]
+ ->setSessionId( new SessionId( 'x' ) );
+ $this->store->deleteSession( self::SESSIONID );
+ TestingAccessWrapper::newFromObject( $backend )->persist = false;
+ TestingAccessWrapper::newFromObject( $backend )->forcePersist = true;
+ $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+ TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+ TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+ $backend->save();
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+
+ // Not persistent, but dirty
+ $this->provider = $neverProvider;
+ $this->onSessionMetadataCalled = false;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+ TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+ TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
+ $backend->save();
+ $this->assertTrue( $this->onSessionMetadataCalled );
+ $blob = $this->store->getSession( self::SESSIONID );
+ $this->assertInternalType( 'array', $blob );
+ $this->assertArrayHasKey( 'metadata', $blob );
+ $metadata = $blob['metadata'];
+ $this->assertInternalType( 'array', $metadata );
+ $this->assertArrayHasKey( '???', $metadata );
+ $this->assertSame( '!!!', $metadata['???'] );
+ $this->assertFalse( $this->store->getSessionFromBackend( self::SESSIONID ),
+ 'making sure it didn\'t save to backend' );
+
+ // Persistent, not dirty
+ $this->provider = $neverProvider;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $neverHook ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ TestingAccessWrapper::newFromObject( $backend )->persist = true;
+ $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+ TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+ TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+ $backend->save();
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+
+ // (but will persist if forced)
+ $this->provider = $builder->getMock();
+ $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
+ $this->provider->expects( $this->never() )->method( 'unpersistSession' );
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $neverHook ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ TestingAccessWrapper::newFromObject( $backend )->persist = true;
+ TestingAccessWrapper::newFromObject( $backend )->forcePersist = true;
+ $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+ TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+ TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+ $backend->save();
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+
+ // Persistent and dirty
+ $this->provider = $neverProvider;
+ $this->onSessionMetadataCalled = false;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ TestingAccessWrapper::newFromObject( $backend )->persist = true;
+ $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+ TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+ TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
+ $backend->save();
+ $this->assertTrue( $this->onSessionMetadataCalled );
+ $blob = $this->store->getSession( self::SESSIONID );
+ $this->assertInternalType( 'array', $blob );
+ $this->assertArrayHasKey( 'metadata', $blob );
+ $metadata = $blob['metadata'];
+ $this->assertInternalType( 'array', $metadata );
+ $this->assertArrayHasKey( '???', $metadata );
+ $this->assertSame( '!!!', $metadata['???'] );
+ $this->assertNotSame( false, $this->store->getSessionFromBackend( self::SESSIONID ),
+ 'making sure it did save to backend' );
+
+ // (also persists if forced)
+ $this->provider = $builder->getMock();
+ $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
+ $this->provider->expects( $this->never() )->method( 'unpersistSession' );
+ $this->onSessionMetadataCalled = false;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ TestingAccessWrapper::newFromObject( $backend )->persist = true;
+ TestingAccessWrapper::newFromObject( $backend )->forcePersist = true;
+ $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+ TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+ TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
+ $backend->save();
+ $this->assertTrue( $this->onSessionMetadataCalled );
+ $blob = $this->store->getSession( self::SESSIONID );
+ $this->assertInternalType( 'array', $blob );
+ $this->assertArrayHasKey( 'metadata', $blob );
+ $metadata = $blob['metadata'];
+ $this->assertInternalType( 'array', $metadata );
+ $this->assertArrayHasKey( '???', $metadata );
+ $this->assertSame( '!!!', $metadata['???'] );
+ $this->assertNotSame( false, $this->store->getSessionFromBackend( self::SESSIONID ),
+ 'making sure it did save to backend' );
+
+ // (also persists if metadata dirty)
+ $this->provider = $builder->getMock();
+ $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
+ $this->provider->expects( $this->never() )->method( 'unpersistSession' );
+ $this->onSessionMetadataCalled = false;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ TestingAccessWrapper::newFromObject( $backend )->persist = true;
+ $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+ TestingAccessWrapper::newFromObject( $backend )->metaDirty = true;
+ TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+ $backend->save();
+ $this->assertTrue( $this->onSessionMetadataCalled );
+ $blob = $this->store->getSession( self::SESSIONID );
+ $this->assertInternalType( 'array', $blob );
+ $this->assertArrayHasKey( 'metadata', $blob );
+ $metadata = $blob['metadata'];
+ $this->assertInternalType( 'array', $metadata );
+ $this->assertArrayHasKey( '???', $metadata );
+ $this->assertSame( '!!!', $metadata['???'] );
+ $this->assertNotSame( false, $this->store->getSessionFromBackend( self::SESSIONID ),
+ 'making sure it did save to backend' );
+
+ // Not marked dirty, but dirty data
+ // (e.g. indirect modification from ArrayAccess::offsetGet)
+ $this->provider = $neverProvider;
+ $this->onSessionMetadataCalled = false;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ TestingAccessWrapper::newFromObject( $backend )->persist = true;
+ $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+ TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+ TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+ TestingAccessWrapper::newFromObject( $backend )->dataHash = 'Doesn\'t match';
+ $backend->save();
+ $this->assertTrue( $this->onSessionMetadataCalled );
+ $blob = $this->store->getSession( self::SESSIONID );
+ $this->assertInternalType( 'array', $blob );
+ $this->assertArrayHasKey( 'metadata', $blob );
+ $metadata = $blob['metadata'];
+ $this->assertInternalType( 'array', $metadata );
+ $this->assertArrayHasKey( '???', $metadata );
+ $this->assertSame( '!!!', $metadata['???'] );
+ $this->assertNotSame( false, $this->store->getSessionFromBackend( self::SESSIONID ),
+ 'making sure it did save to backend' );
+
+ // Bad hook
+ $this->provider = null;
+ $mockHook = $this->getMockBuilder( __CLASS__ )
+ ->setMethods( [ 'onSessionMetadata' ] )->getMock();
+ $mockHook->expects( $this->any() )->method( 'onSessionMetadata' )
+ ->will( $this->returnCallback(
+ function ( SessionBackend $backend, array &$metadata, array $requests ) {
+ $metadata['userId']++;
+ }
+ ) );
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $mockHook ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $backend->dirty();
+ try {
+ $backend->save();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'SessionMetadata hook changed metadata key "userId"',
+ $ex->getMessage()
+ );
+ }
+
+ // SessionManager::preventSessionsForUser
+ TestingAccessWrapper::newFromObject( $this->manager )->preventUsers = [
+ $user->getName() => true,
+ ];
+ $this->provider = $neverProvider;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $neverHook ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ TestingAccessWrapper::newFromObject( $backend )->persist = true;
+ $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+ TestingAccessWrapper::newFromObject( $backend )->metaDirty = true;
+ TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
+ $backend->save();
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+ }
+
+ public function testRenew() {
+ $user = static::getTestSysop()->getUser();
+ $this->store = new TestBagOStuff();
+ $testData = [ 'foo' => 'foo!', 'bar', [ 'baz', null ] ];
+
+ // Not persistent
+ $this->provider = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'persistSession' ] )->getMock();
+ $this->provider->expects( $this->never() )->method( 'persistSession' );
+ $this->onSessionMetadataCalled = false;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ $wrap = TestingAccessWrapper::newFromObject( $backend );
+ $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+ $wrap->metaDirty = false;
+ $wrap->dataDirty = false;
+ $wrap->forcePersist = false;
+ $wrap->expires = 0;
+ $backend->renew();
+ $this->assertTrue( $this->onSessionMetadataCalled );
+ $blob = $this->store->getSession( self::SESSIONID );
+ $this->assertInternalType( 'array', $blob );
+ $this->assertArrayHasKey( 'metadata', $blob );
+ $metadata = $blob['metadata'];
+ $this->assertInternalType( 'array', $metadata );
+ $this->assertArrayHasKey( '???', $metadata );
+ $this->assertSame( '!!!', $metadata['???'] );
+ $this->assertNotEquals( 0, $wrap->expires );
+
+ // Persistent
+ $this->provider = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'persistSession' ] )->getMock();
+ $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
+ $this->onSessionMetadataCalled = false;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ $wrap = TestingAccessWrapper::newFromObject( $backend );
+ $wrap->persist = true;
+ $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+ $wrap->metaDirty = false;
+ $wrap->dataDirty = false;
+ $wrap->forcePersist = false;
+ $wrap->expires = 0;
+ $backend->renew();
+ $this->assertTrue( $this->onSessionMetadataCalled );
+ $blob = $this->store->getSession( self::SESSIONID );
+ $this->assertInternalType( 'array', $blob );
+ $this->assertArrayHasKey( 'metadata', $blob );
+ $metadata = $blob['metadata'];
+ $this->assertInternalType( 'array', $metadata );
+ $this->assertArrayHasKey( '???', $metadata );
+ $this->assertSame( '!!!', $metadata['???'] );
+ $this->assertNotEquals( 0, $wrap->expires );
+
+ // Not persistent, not expiring
+ $this->provider = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'persistSession' ] )->getMock();
+ $this->provider->expects( $this->never() )->method( 'persistSession' );
+ $this->onSessionMetadataCalled = false;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
+ $this->store->setSessionData( self::SESSIONID, $testData );
+ $backend = $this->getBackend( $user );
+ $this->store->deleteSession( self::SESSIONID );
+ $wrap = TestingAccessWrapper::newFromObject( $backend );
+ $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+ $wrap->metaDirty = false;
+ $wrap->dataDirty = false;
+ $wrap->forcePersist = false;
+ $expires = time() + $wrap->lifetime + 100;
+ $wrap->expires = $expires;
+ $backend->renew();
+ $this->assertFalse( $this->onSessionMetadataCalled );
+ $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+ $this->assertEquals( $expires, $wrap->expires );
+ }
+
+ public function onSessionMetadata( SessionBackend $backend, array &$metadata, array $requests ) {
+ $this->onSessionMetadataCalled = true;
+ $metadata['???'] = '!!!';
+ }
+
+ public function testTakeOverGlobalSession() {
+ if ( !PHPSessionHandler::isInstalled() ) {
+ PHPSessionHandler::install( SessionManager::singleton() );
+ }
+ if ( !PHPSessionHandler::isEnabled() ) {
+ $rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' );
+ $rProp->setAccessible( true );
+ $handler = TestingAccessWrapper::newFromObject( $rProp->getValue() );
+ $resetHandler = new \Wikimedia\ScopedCallback( function () use ( $handler ) {
+ session_write_close();
+ $handler->enable = false;
+ } );
+ $handler->enable = true;
+ }
+
+ $backend = $this->getBackend( static::getTestSysop()->getUser() );
+ TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = true;
+
+ $resetSingleton = TestUtils::setSessionManagerSingleton( $this->manager );
+
+ $manager = TestingAccessWrapper::newFromObject( $this->manager );
+ $request = \RequestContext::getMain()->getRequest();
+ $manager->globalSession = $backend->getSession( $request );
+ $manager->globalSessionRequest = $request;
+
+ session_id( '' );
+ TestingAccessWrapper::newFromObject( $backend )->checkPHPSession();
+ $this->assertSame( $backend->getId(), session_id() );
+ session_write_close();
+
+ $backend2 = $this->getBackend(
+ User::newFromName( 'UTSysop' ), 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
+ );
+ TestingAccessWrapper::newFromObject( $backend2 )->usePhpSessionHandling = true;
+
+ session_id( '' );
+ TestingAccessWrapper::newFromObject( $backend2 )->checkPHPSession();
+ $this->assertSame( '', session_id() );
+ }
+
+ public function testResetIdOfGlobalSession() {
+ if ( !PHPSessionHandler::isInstalled() ) {
+ PHPSessionHandler::install( SessionManager::singleton() );
+ }
+ if ( !PHPSessionHandler::isEnabled() ) {
+ $rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' );
+ $rProp->setAccessible( true );
+ $handler = TestingAccessWrapper::newFromObject( $rProp->getValue() );
+ $resetHandler = new \Wikimedia\ScopedCallback( function () use ( $handler ) {
+ session_write_close();
+ $handler->enable = false;
+ } );
+ $handler->enable = true;
+ }
+
+ $backend = $this->getBackend( User::newFromName( 'UTSysop' ) );
+ TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = true;
+
+ $resetSingleton = TestUtils::setSessionManagerSingleton( $this->manager );
+
+ $manager = TestingAccessWrapper::newFromObject( $this->manager );
+ $request = \RequestContext::getMain()->getRequest();
+ $manager->globalSession = $backend->getSession( $request );
+ $manager->globalSessionRequest = $request;
+
+ session_id( self::SESSIONID );
+ \Wikimedia\quietCall( 'session_start' );
+ $_SESSION['foo'] = __METHOD__;
+ $backend->resetId();
+ $this->assertNotEquals( self::SESSIONID, $backend->getId() );
+ $this->assertSame( $backend->getId(), session_id() );
+ $this->assertArrayHasKey( 'foo', $_SESSION );
+ $this->assertSame( __METHOD__, $_SESSION['foo'] );
+ session_write_close();
+ }
+
+ public function testUnpersistOfGlobalSession() {
+ if ( !PHPSessionHandler::isInstalled() ) {
+ PHPSessionHandler::install( SessionManager::singleton() );
+ }
+ if ( !PHPSessionHandler::isEnabled() ) {
+ $rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' );
+ $rProp->setAccessible( true );
+ $handler = TestingAccessWrapper::newFromObject( $rProp->getValue() );
+ $resetHandler = new \Wikimedia\ScopedCallback( function () use ( $handler ) {
+ session_write_close();
+ $handler->enable = false;
+ } );
+ $handler->enable = true;
+ }
+
+ $backend = $this->getBackend( User::newFromName( 'UTSysop' ) );
+ $wrap = TestingAccessWrapper::newFromObject( $backend );
+ $wrap->usePhpSessionHandling = true;
+ $wrap->persist = true;
+
+ $resetSingleton = TestUtils::setSessionManagerSingleton( $this->manager );
+
+ $manager = TestingAccessWrapper::newFromObject( $this->manager );
+ $request = \RequestContext::getMain()->getRequest();
+ $manager->globalSession = $backend->getSession( $request );
+ $manager->globalSessionRequest = $request;
+
+ session_id( self::SESSIONID . 'x' );
+ \Wikimedia\quietCall( 'session_start' );
+ $backend->unpersist();
+ $this->assertSame( self::SESSIONID . 'x', session_id() );
+ session_write_close();
+
+ session_id( self::SESSIONID );
+ $wrap->persist = true;
+ $backend->unpersist();
+ $this->assertSame( '', session_id() );
+ }
+
+ public function testGetAllowedUserRights() {
+ $this->provider = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'getAllowedUserRights' ] )
+ ->getMock();
+ $this->provider->expects( $this->any() )->method( 'getAllowedUserRights' )
+ ->will( $this->returnValue( [ 'foo', 'bar' ] ) );
+
+ $backend = $this->getBackend();
+ $this->assertSame( [ 'foo', 'bar' ], $backend->getAllowedUserRights() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/session/SessionIdTest.php b/www/wiki/tests/phpunit/includes/session/SessionIdTest.php
new file mode 100644
index 00000000..2b06d971
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/SessionIdTest.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+
+/**
+ * @group Session
+ * @covers MediaWiki\Session\SessionId
+ */
+class SessionIdTest extends MediaWikiTestCase {
+
+ public function testEverything() {
+ $id = new SessionId( 'foo' );
+ $this->assertSame( 'foo', $id->getId() );
+ $this->assertSame( 'foo', (string)$id );
+ $id->setId( 'bar' );
+ $this->assertSame( 'bar', $id->getId() );
+ $this->assertSame( 'bar', (string)$id );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/session/SessionInfoTest.php b/www/wiki/tests/phpunit/includes/session/SessionInfoTest.php
new file mode 100644
index 00000000..8f7b2a6e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/SessionInfoTest.php
@@ -0,0 +1,356 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\SessionInfo
+ */
+class SessionInfoTest extends MediaWikiTestCase {
+
+ public function testBasics() {
+ $anonInfo = UserInfo::newAnonymous();
+ $userInfo = UserInfo::newFromName( 'UTSysop', true );
+ $unverifiedUserInfo = UserInfo::newFromName( 'UTSysop', false );
+
+ try {
+ new SessionInfo( SessionInfo::MIN_PRIORITY - 1, [] );
+ $this->fail( 'Expected exception not thrown', 'priority < min' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority < min' );
+ }
+
+ try {
+ new SessionInfo( SessionInfo::MAX_PRIORITY + 1, [] );
+ $this->fail( 'Expected exception not thrown', 'priority > max' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority > max' );
+ }
+
+ try {
+ new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'id' => 'ABC?' ] );
+ $this->fail( 'Expected exception not thrown', 'bad session ID' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid session ID', $ex->getMessage(), 'bad session ID' );
+ }
+
+ try {
+ new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'userInfo' => new \stdClass ] );
+ $this->fail( 'Expected exception not thrown', 'bad userInfo' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid userInfo', $ex->getMessage(), 'bad userInfo' );
+ }
+
+ try {
+ new SessionInfo( SessionInfo::MIN_PRIORITY, [] );
+ $this->fail( 'Expected exception not thrown', 'no provider, no id' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Must supply an ID when no provider is given', $ex->getMessage(),
+ 'no provider, no id' );
+ }
+
+ try {
+ new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'copyFrom' => new \stdClass ] );
+ $this->fail( 'Expected exception not thrown', 'bad copyFrom' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid copyFrom', $ex->getMessage(),
+ 'bad copyFrom' );
+ }
+
+ $manager = new SessionManager();
+ $provider = $this->getMockBuilder( SessionProvider::class )
+ ->setMethods( [ 'persistsSessionId', 'canChangeUser', '__toString' ] )
+ ->getMockForAbstractClass();
+ $provider->setManager( $manager );
+ $provider->expects( $this->any() )->method( 'persistsSessionId' )
+ ->will( $this->returnValue( true ) );
+ $provider->expects( $this->any() )->method( 'canChangeUser' )
+ ->will( $this->returnValue( true ) );
+ $provider->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'Mock' ) );
+
+ $provider2 = $this->getMockBuilder( SessionProvider::class )
+ ->setMethods( [ 'persistsSessionId', 'canChangeUser', '__toString' ] )
+ ->getMockForAbstractClass();
+ $provider2->setManager( $manager );
+ $provider2->expects( $this->any() )->method( 'persistsSessionId' )
+ ->will( $this->returnValue( true ) );
+ $provider2->expects( $this->any() )->method( 'canChangeUser' )
+ ->will( $this->returnValue( true ) );
+ $provider2->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'Mock2' ) );
+
+ try {
+ new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'userInfo' => $anonInfo,
+ 'metadata' => 'foo',
+ ] );
+ $this->fail( 'Expected exception not thrown', 'bad metadata' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid metadata', $ex->getMessage(), 'bad metadata' );
+ }
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'provider' => $provider,
+ 'userInfo' => $anonInfo
+ ] );
+ $this->assertSame( $provider, $info->getProvider() );
+ $this->assertNotNull( $info->getId() );
+ $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+ $this->assertSame( $anonInfo, $info->getUserInfo() );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertFalse( $info->forceUse() );
+ $this->assertFalse( $info->wasPersisted() );
+ $this->assertFalse( $info->wasRemembered() );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertNull( $info->getProviderMetadata() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'provider' => $provider,
+ 'userInfo' => $unverifiedUserInfo,
+ 'metadata' => [ 'Foo' ],
+ ] );
+ $this->assertSame( $provider, $info->getProvider() );
+ $this->assertNotNull( $info->getId() );
+ $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+ $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertFalse( $info->forceUse() );
+ $this->assertFalse( $info->wasPersisted() );
+ $this->assertFalse( $info->wasRemembered() );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertSame( [ 'Foo' ], $info->getProviderMetadata() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'provider' => $provider,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertSame( $provider, $info->getProvider() );
+ $this->assertNotNull( $info->getId() );
+ $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+ $this->assertSame( $userInfo, $info->getUserInfo() );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertFalse( $info->forceUse() );
+ $this->assertFalse( $info->wasPersisted() );
+ $this->assertTrue( $info->wasRemembered() );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertNull( $info->getProviderMetadata() );
+
+ $id = $manager->generateSessionId();
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'persisted' => true,
+ 'userInfo' => $anonInfo
+ ] );
+ $this->assertSame( $provider, $info->getProvider() );
+ $this->assertSame( $id, $info->getId() );
+ $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+ $this->assertSame( $anonInfo, $info->getUserInfo() );
+ $this->assertFalse( $info->isIdSafe() );
+ $this->assertFalse( $info->forceUse() );
+ $this->assertTrue( $info->wasPersisted() );
+ $this->assertFalse( $info->wasRemembered() );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertNull( $info->getProviderMetadata() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertSame( $provider, $info->getProvider() );
+ $this->assertSame( $id, $info->getId() );
+ $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+ $this->assertSame( $userInfo, $info->getUserInfo() );
+ $this->assertFalse( $info->isIdSafe() );
+ $this->assertFalse( $info->forceUse() );
+ $this->assertFalse( $info->wasPersisted() );
+ $this->assertTrue( $info->wasRemembered() );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertNull( $info->getProviderMetadata() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'id' => $id,
+ 'persisted' => true,
+ 'userInfo' => $userInfo,
+ 'metadata' => [ 'Foo' ],
+ ] );
+ $this->assertSame( $id, $info->getId() );
+ $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+ $this->assertSame( $userInfo, $info->getUserInfo() );
+ $this->assertFalse( $info->isIdSafe() );
+ $this->assertFalse( $info->forceUse() );
+ $this->assertTrue( $info->wasPersisted() );
+ $this->assertFalse( $info->wasRemembered() );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertNull( $info->getProviderMetadata() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'id' => $id,
+ 'remembered' => true,
+ 'userInfo' => $userInfo,
+ ] );
+ $this->assertFalse( $info->wasRemembered(), 'no provider' );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'remembered' => true,
+ ] );
+ $this->assertFalse( $info->wasRemembered(), 'no user' );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'remembered' => true,
+ 'userInfo' => $anonInfo,
+ ] );
+ $this->assertFalse( $info->wasRemembered(), 'anonymous user' );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'remembered' => true,
+ 'userInfo' => $unverifiedUserInfo,
+ ] );
+ $this->assertFalse( $info->wasRemembered(), 'unverified user' );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'remembered' => false,
+ 'userInfo' => $userInfo,
+ ] );
+ $this->assertFalse( $info->wasRemembered(), 'specific override' );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'id' => $id,
+ 'idIsSafe' => true,
+ ] );
+ $this->assertSame( $id, $info->getId() );
+ $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+ $this->assertTrue( $info->isIdSafe() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'id' => $id,
+ 'forceUse' => true,
+ ] );
+ $this->assertFalse( $info->forceUse(), 'no provider' );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'provider' => $provider,
+ 'forceUse' => true,
+ ] );
+ $this->assertFalse( $info->forceUse(), 'no id' );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'forceUse' => true,
+ ] );
+ $this->assertTrue( $info->forceUse(), 'correct use' );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'id' => $id,
+ 'forceHTTPS' => 1,
+ ] );
+ $this->assertTrue( $info->forceHTTPS() );
+
+ $fromInfo = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'id' => $id . 'A',
+ 'provider' => $provider,
+ 'userInfo' => $userInfo,
+ 'idIsSafe' => true,
+ 'forceUse' => true,
+ 'persisted' => true,
+ 'remembered' => true,
+ 'forceHTTPS' => true,
+ 'metadata' => [ 'foo!' ],
+ ] );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, [
+ 'copyFrom' => $fromInfo,
+ ] );
+ $this->assertSame( $id . 'A', $info->getId() );
+ $this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() );
+ $this->assertSame( $provider, $info->getProvider() );
+ $this->assertSame( $userInfo, $info->getUserInfo() );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertTrue( $info->forceUse() );
+ $this->assertTrue( $info->wasPersisted() );
+ $this->assertTrue( $info->wasRemembered() );
+ $this->assertTrue( $info->forceHTTPS() );
+ $this->assertSame( [ 'foo!' ], $info->getProviderMetadata() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, [
+ 'id' => $id . 'X',
+ 'provider' => $provider2,
+ 'userInfo' => $unverifiedUserInfo,
+ 'idIsSafe' => false,
+ 'forceUse' => false,
+ 'persisted' => false,
+ 'remembered' => false,
+ 'forceHTTPS' => false,
+ 'metadata' => null,
+ 'copyFrom' => $fromInfo,
+ ] );
+ $this->assertSame( $id . 'X', $info->getId() );
+ $this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() );
+ $this->assertSame( $provider2, $info->getProvider() );
+ $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
+ $this->assertFalse( $info->isIdSafe() );
+ $this->assertFalse( $info->forceUse() );
+ $this->assertFalse( $info->wasPersisted() );
+ $this->assertFalse( $info->wasRemembered() );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertNull( $info->getProviderMetadata() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'id' => $id,
+ ] );
+ $this->assertSame(
+ '[' . SessionInfo::MIN_PRIORITY . "]null<null>$id",
+ (string)$info,
+ 'toString'
+ );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'persisted' => true,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertSame(
+ '[' . SessionInfo::MIN_PRIORITY . "]Mock<+:{$userInfo->getId()}:UTSysop>$id",
+ (string)$info,
+ 'toString'
+ );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'persisted' => true,
+ 'userInfo' => $unverifiedUserInfo
+ ] );
+ $this->assertSame(
+ '[' . SessionInfo::MIN_PRIORITY . "]Mock<-:{$userInfo->getId()}:UTSysop>$id",
+ (string)$info,
+ 'toString'
+ );
+ }
+
+ public function testCompare() {
+ $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+ $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [ 'id' => $id ] );
+ $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, [ 'id' => $id ] );
+
+ $this->assertTrue( SessionInfo::compare( $info1, $info2 ) < 0, '<' );
+ $this->assertTrue( SessionInfo::compare( $info2, $info1 ) > 0, '>' );
+ $this->assertTrue( SessionInfo::compare( $info1, $info1 ) === 0, '==' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/session/SessionManagerTest.php b/www/wiki/tests/phpunit/includes/session/SessionManagerTest.php
new file mode 100644
index 00000000..b33cd24a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/SessionManagerTest.php
@@ -0,0 +1,1521 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use Psr\Log\LogLevel;
+use User;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\SessionManager
+ */
+class SessionManagerTest extends MediaWikiTestCase {
+
+ /** @var \HashConfig */
+ private $config;
+
+ /** @var \TestLogger */
+ private $logger;
+
+ /** @var TestBagOStuff */
+ private $store;
+
+ protected function getManager() {
+ \ObjectCache::$instances['testSessionStore'] = new TestBagOStuff();
+ $this->config = new \HashConfig( [
+ 'LanguageCode' => 'en',
+ 'SessionCacheType' => 'testSessionStore',
+ 'ObjectCacheSessionExpiry' => 100,
+ 'SessionProviders' => [
+ [ 'class' => \DummySessionProvider::class ],
+ ]
+ ] );
+ $this->logger = new \TestLogger( false, function ( $m ) {
+ return substr( $m, 0, 15 ) === 'SessionBackend ' ? null : $m;
+ } );
+ $this->store = new TestBagOStuff();
+
+ return new SessionManager( [
+ 'config' => $this->config,
+ 'logger' => $this->logger,
+ 'store' => $this->store,
+ ] );
+ }
+
+ protected function objectCacheDef( $object ) {
+ return [ 'factory' => function () use ( $object ) {
+ return $object;
+ } ];
+ }
+
+ public function testSingleton() {
+ $reset = TestUtils::setSessionManagerSingleton( null );
+
+ $singleton = SessionManager::singleton();
+ $this->assertInstanceOf( SessionManager::class, $singleton );
+ $this->assertSame( $singleton, SessionManager::singleton() );
+ }
+
+ public function testGetGlobalSession() {
+ $context = \RequestContext::getMain();
+
+ if ( !PHPSessionHandler::isInstalled() ) {
+ PHPSessionHandler::install( SessionManager::singleton() );
+ }
+ $rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' );
+ $rProp->setAccessible( true );
+ $handler = TestingAccessWrapper::newFromObject( $rProp->getValue() );
+ $oldEnable = $handler->enable;
+ $reset[] = new \Wikimedia\ScopedCallback( function () use ( $handler, $oldEnable ) {
+ if ( $handler->enable ) {
+ session_write_close();
+ }
+ $handler->enable = $oldEnable;
+ } );
+ $reset[] = TestUtils::setSessionManagerSingleton( $this->getManager() );
+
+ $handler->enable = true;
+ $request = new \FauxRequest();
+ $context->setRequest( $request );
+ $id = $request->getSession()->getId();
+
+ session_write_close();
+ session_id( '' );
+ $session = SessionManager::getGlobalSession();
+ $this->assertSame( $id, $session->getId() );
+
+ session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' );
+ $session = SessionManager::getGlobalSession();
+ $this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $session->getId() );
+ $this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $request->getSession()->getId() );
+
+ session_write_close();
+ $handler->enable = false;
+ $request = new \FauxRequest();
+ $context->setRequest( $request );
+ $id = $request->getSession()->getId();
+
+ session_id( '' );
+ $session = SessionManager::getGlobalSession();
+ $this->assertSame( $id, $session->getId() );
+
+ session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' );
+ $session = SessionManager::getGlobalSession();
+ $this->assertSame( $id, $session->getId() );
+ $this->assertSame( $id, $request->getSession()->getId() );
+ }
+
+ public function testConstructor() {
+ $manager = TestingAccessWrapper::newFromObject( $this->getManager() );
+ $this->assertSame( $this->config, $manager->config );
+ $this->assertSame( $this->logger, $manager->logger );
+ $this->assertSame( $this->store, $manager->store );
+
+ $manager = TestingAccessWrapper::newFromObject( new SessionManager() );
+ $this->assertSame( \RequestContext::getMain()->getConfig(), $manager->config );
+
+ $manager = TestingAccessWrapper::newFromObject( new SessionManager( [
+ 'config' => $this->config,
+ ] ) );
+ $this->assertSame( \ObjectCache::$instances['testSessionStore'], $manager->store );
+
+ foreach ( [
+ 'config' => '$options[\'config\'] must be an instance of Config',
+ 'logger' => '$options[\'logger\'] must be an instance of LoggerInterface',
+ 'store' => '$options[\'store\'] must be an instance of BagOStuff',
+ ] as $key => $error ) {
+ try {
+ new SessionManager( [ $key => new \stdClass ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( $error, $ex->getMessage() );
+ }
+ }
+ }
+
+ public function testGetSessionForRequest() {
+ $manager = $this->getManager();
+ $request = new \FauxRequest();
+ $request->unpersist1 = false;
+ $request->unpersist2 = false;
+
+ $id1 = '';
+ $id2 = '';
+ $idEmpty = 'empty-session-------------------';
+
+ $providerBuilder = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods(
+ [ 'provideSessionInfo', 'newSessionInfo', '__toString', 'describe', 'unpersistSession' ]
+ );
+
+ $provider1 = $providerBuilder->getMock();
+ $provider1->expects( $this->any() )->method( 'provideSessionInfo' )
+ ->with( $this->identicalTo( $request ) )
+ ->will( $this->returnCallback( function ( $request ) {
+ return $request->info1;
+ } ) );
+ $provider1->expects( $this->any() )->method( 'newSessionInfo' )
+ ->will( $this->returnCallback( function () use ( $idEmpty, $provider1 ) {
+ return new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider1,
+ 'id' => $idEmpty,
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ } ) );
+ $provider1->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'Provider1' ) );
+ $provider1->expects( $this->any() )->method( 'describe' )
+ ->will( $this->returnValue( '#1 sessions' ) );
+ $provider1->expects( $this->any() )->method( 'unpersistSession' )
+ ->will( $this->returnCallback( function ( $request ) {
+ $request->unpersist1 = true;
+ } ) );
+
+ $provider2 = $providerBuilder->getMock();
+ $provider2->expects( $this->any() )->method( 'provideSessionInfo' )
+ ->with( $this->identicalTo( $request ) )
+ ->will( $this->returnCallback( function ( $request ) {
+ return $request->info2;
+ } ) );
+ $provider2->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'Provider2' ) );
+ $provider2->expects( $this->any() )->method( 'describe' )
+ ->will( $this->returnValue( '#2 sessions' ) );
+ $provider2->expects( $this->any() )->method( 'unpersistSession' )
+ ->will( $this->returnCallback( function ( $request ) {
+ $request->unpersist2 = true;
+ } ) );
+
+ $this->config->set( 'SessionProviders', [
+ $this->objectCacheDef( $provider1 ),
+ $this->objectCacheDef( $provider2 ),
+ ] );
+
+ // No provider returns info
+ $request->info1 = null;
+ $request->info2 = null;
+ $session = $manager->getSessionForRequest( $request );
+ $this->assertInstanceOf( Session::class, $session );
+ $this->assertSame( $idEmpty, $session->getId() );
+ $this->assertFalse( $request->unpersist1 );
+ $this->assertFalse( $request->unpersist2 );
+
+ // Both providers return info, picks best one
+ $request->info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [
+ 'provider' => $provider1,
+ 'id' => ( $id1 = $manager->generateSessionId() ),
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, [
+ 'provider' => $provider2,
+ 'id' => ( $id2 = $manager->generateSessionId() ),
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $session = $manager->getSessionForRequest( $request );
+ $this->assertInstanceOf( Session::class, $session );
+ $this->assertSame( $id2, $session->getId() );
+ $this->assertFalse( $request->unpersist1 );
+ $this->assertFalse( $request->unpersist2 );
+
+ $request->info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, [
+ 'provider' => $provider1,
+ 'id' => ( $id1 = $manager->generateSessionId() ),
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [
+ 'provider' => $provider2,
+ 'id' => ( $id2 = $manager->generateSessionId() ),
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $session = $manager->getSessionForRequest( $request );
+ $this->assertInstanceOf( Session::class, $session );
+ $this->assertSame( $id1, $session->getId() );
+ $this->assertFalse( $request->unpersist1 );
+ $this->assertFalse( $request->unpersist2 );
+
+ // Tied priorities
+ $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
+ 'provider' => $provider1,
+ 'id' => ( $id1 = $manager->generateSessionId() ),
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newAnonymous(),
+ 'idIsSafe' => true,
+ ] );
+ $request->info2 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
+ 'provider' => $provider2,
+ 'id' => ( $id2 = $manager->generateSessionId() ),
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newAnonymous(),
+ 'idIsSafe' => true,
+ ] );
+ try {
+ $manager->getSessionForRequest( $request );
+ $this->fail( 'Expcected exception not thrown' );
+ } catch ( \OverflowException $ex ) {
+ $this->assertStringStartsWith(
+ 'Multiple sessions for this request tied for top priority: ',
+ $ex->getMessage()
+ );
+ $this->assertCount( 2, $ex->sessionInfos );
+ $this->assertContains( $request->info1, $ex->sessionInfos );
+ $this->assertContains( $request->info2, $ex->sessionInfos );
+ }
+ $this->assertFalse( $request->unpersist1 );
+ $this->assertFalse( $request->unpersist2 );
+
+ // Bad provider
+ $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
+ 'provider' => $provider2,
+ 'id' => ( $id1 = $manager->generateSessionId() ),
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $request->info2 = null;
+ try {
+ $manager->getSessionForRequest( $request );
+ $this->fail( 'Expcected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'Provider1 returned session info for a different provider: ' . $request->info1,
+ $ex->getMessage()
+ );
+ }
+ $this->assertFalse( $request->unpersist1 );
+ $this->assertFalse( $request->unpersist2 );
+
+ // Unusable session info
+ $this->logger->setCollect( true );
+ $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
+ 'provider' => $provider1,
+ 'id' => ( $id1 = $manager->generateSessionId() ),
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newFromName( 'UTSysop', false ),
+ 'idIsSafe' => true,
+ ] );
+ $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider2,
+ 'id' => ( $id2 = $manager->generateSessionId() ),
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $session = $manager->getSessionForRequest( $request );
+ $this->assertInstanceOf( Session::class, $session );
+ $this->assertSame( $id2, $session->getId() );
+ $this->logger->setCollect( false );
+ $this->assertTrue( $request->unpersist1 );
+ $this->assertFalse( $request->unpersist2 );
+ $request->unpersist1 = false;
+
+ $this->logger->setCollect( true );
+ $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
+ 'provider' => $provider1,
+ 'id' => ( $id1 = $manager->generateSessionId() ),
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $request->info2 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
+ 'provider' => $provider2,
+ 'id' => ( $id2 = $manager->generateSessionId() ),
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newFromName( 'UTSysop', false ),
+ 'idIsSafe' => true,
+ ] );
+ $session = $manager->getSessionForRequest( $request );
+ $this->assertInstanceOf( Session::class, $session );
+ $this->assertSame( $id1, $session->getId() );
+ $this->logger->setCollect( false );
+ $this->assertFalse( $request->unpersist1 );
+ $this->assertTrue( $request->unpersist2 );
+ $request->unpersist2 = false;
+
+ // Unpersisted session ID
+ $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
+ 'provider' => $provider1,
+ 'id' => ( $id1 = $manager->generateSessionId() ),
+ 'persisted' => false,
+ 'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+ 'idIsSafe' => true,
+ ] );
+ $request->info2 = null;
+ $session = $manager->getSessionForRequest( $request );
+ $this->assertInstanceOf( Session::class, $session );
+ $this->assertSame( $id1, $session->getId() );
+ $this->assertTrue( $request->unpersist1 ); // The saving of the session does it
+ $this->assertFalse( $request->unpersist2 );
+ $session->persist();
+ $this->assertTrue( $session->isPersistent(), 'sanity check' );
+ }
+
+ public function testGetSessionById() {
+ $manager = $this->getManager();
+ try {
+ $manager->getSessionById( 'bad' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid session ID', $ex->getMessage() );
+ }
+
+ // Unknown session ID
+ $id = $manager->generateSessionId();
+ $session = $manager->getSessionById( $id, true );
+ $this->assertInstanceOf( Session::class, $session );
+ $this->assertSame( $id, $session->getId() );
+
+ $id = $manager->generateSessionId();
+ $this->assertNull( $manager->getSessionById( $id, false ) );
+
+ // Known but unloadable session ID
+ $this->logger->setCollect( true );
+ $id = $manager->generateSessionId();
+ $this->store->setSession( $id, [ 'metadata' => [
+ 'userId' => User::idFromName( 'UTSysop' ),
+ 'userToken' => 'bad',
+ ] ] );
+
+ $this->assertNull( $manager->getSessionById( $id, true ) );
+ $this->assertNull( $manager->getSessionById( $id, false ) );
+ $this->logger->setCollect( false );
+
+ // Known session ID
+ $this->store->setSession( $id, [] );
+ $session = $manager->getSessionById( $id, false );
+ $this->assertInstanceOf( Session::class, $session );
+ $this->assertSame( $id, $session->getId() );
+
+ // Store isn't checked if the session is already loaded
+ $this->store->setSession( $id, [ 'metadata' => [
+ 'userId' => User::idFromName( 'UTSysop' ),
+ 'userToken' => 'bad',
+ ] ] );
+ $session2 = $manager->getSessionById( $id, false );
+ $this->assertInstanceOf( Session::class, $session2 );
+ $this->assertSame( $id, $session2->getId() );
+ unset( $session, $session2 );
+ $this->logger->setCollect( true );
+ $this->assertNull( $manager->getSessionById( $id, true ) );
+ $this->logger->setCollect( false );
+
+ // Failure to create an empty session
+ $manager = $this->getManager();
+ $provider = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'provideSessionInfo', 'newSessionInfo', '__toString' ] )
+ ->getMock();
+ $provider->expects( $this->any() )->method( 'provideSessionInfo' )
+ ->will( $this->returnValue( null ) );
+ $provider->expects( $this->any() )->method( 'newSessionInfo' )
+ ->will( $this->returnValue( null ) );
+ $provider->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'MockProvider' ) );
+ $this->config->set( 'SessionProviders', [
+ $this->objectCacheDef( $provider ),
+ ] );
+ $this->logger->setCollect( true );
+ $this->assertNull( $manager->getSessionById( $id, true ) );
+ $this->logger->setCollect( false );
+ $this->assertSame( [
+ [ LogLevel::ERROR, 'Failed to create empty session: {exception}' ]
+ ], $this->logger->getBuffer() );
+ }
+
+ public function testGetEmptySession() {
+ $manager = $this->getManager();
+ $pmanager = TestingAccessWrapper::newFromObject( $manager );
+ $request = new \FauxRequest();
+
+ $providerBuilder = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'provideSessionInfo', 'newSessionInfo', '__toString' ] );
+
+ $expectId = null;
+ $info1 = null;
+ $info2 = null;
+
+ $provider1 = $providerBuilder->getMock();
+ $provider1->expects( $this->any() )->method( 'provideSessionInfo' )
+ ->will( $this->returnValue( null ) );
+ $provider1->expects( $this->any() )->method( 'newSessionInfo' )
+ ->with( $this->callback( function ( $id ) use ( &$expectId ) {
+ return $id === $expectId;
+ } ) )
+ ->will( $this->returnCallback( function () use ( &$info1 ) {
+ return $info1;
+ } ) );
+ $provider1->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'MockProvider1' ) );
+
+ $provider2 = $providerBuilder->getMock();
+ $provider2->expects( $this->any() )->method( 'provideSessionInfo' )
+ ->will( $this->returnValue( null ) );
+ $provider2->expects( $this->any() )->method( 'newSessionInfo' )
+ ->with( $this->callback( function ( $id ) use ( &$expectId ) {
+ return $id === $expectId;
+ } ) )
+ ->will( $this->returnCallback( function () use ( &$info2 ) {
+ return $info2;
+ } ) );
+ $provider1->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'MockProvider2' ) );
+
+ $this->config->set( 'SessionProviders', [
+ $this->objectCacheDef( $provider1 ),
+ $this->objectCacheDef( $provider2 ),
+ ] );
+
+ // No info
+ $expectId = null;
+ $info1 = null;
+ $info2 = null;
+ try {
+ $manager->getEmptySession();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'No provider could provide an empty session!',
+ $ex->getMessage()
+ );
+ }
+
+ // Info
+ $expectId = null;
+ $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider1,
+ 'id' => 'empty---------------------------',
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $info2 = null;
+ $session = $manager->getEmptySession();
+ $this->assertInstanceOf( Session::class, $session );
+ $this->assertSame( 'empty---------------------------', $session->getId() );
+
+ // Info, explicitly
+ $expectId = 'expected------------------------';
+ $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider1,
+ 'id' => $expectId,
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $info2 = null;
+ $session = $pmanager->getEmptySessionInternal( null, $expectId );
+ $this->assertInstanceOf( Session::class, $session );
+ $this->assertSame( $expectId, $session->getId() );
+
+ // Wrong ID
+ $expectId = 'expected-----------------------2';
+ $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider1,
+ 'id' => "un$expectId",
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $info2 = null;
+ try {
+ $pmanager->getEmptySessionInternal( null, $expectId );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'MockProvider1 returned empty session info with a wrong id: ' .
+ "un$expectId != $expectId",
+ $ex->getMessage()
+ );
+ }
+
+ // Unsafe ID
+ $expectId = 'expected-----------------------2';
+ $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider1,
+ 'id' => $expectId,
+ 'persisted' => true,
+ ] );
+ $info2 = null;
+ try {
+ $pmanager->getEmptySessionInternal( null, $expectId );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'MockProvider1 returned empty session info with id flagged unsafe',
+ $ex->getMessage()
+ );
+ }
+
+ // Wrong provider
+ $expectId = null;
+ $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider2,
+ 'id' => 'empty---------------------------',
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $info2 = null;
+ try {
+ $manager->getEmptySession();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'MockProvider1 returned an empty session info for a different provider: ' . $info1,
+ $ex->getMessage()
+ );
+ }
+
+ // Highest priority wins
+ $expectId = null;
+ $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [
+ 'provider' => $provider1,
+ 'id' => 'empty1--------------------------',
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider2,
+ 'id' => 'empty2--------------------------',
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $session = $manager->getEmptySession();
+ $this->assertInstanceOf( Session::class, $session );
+ $this->assertSame( 'empty1--------------------------', $session->getId() );
+
+ $expectId = null;
+ $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [
+ 'provider' => $provider1,
+ 'id' => 'empty1--------------------------',
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, [
+ 'provider' => $provider2,
+ 'id' => 'empty2--------------------------',
+ 'persisted' => true,
+ 'idIsSafe' => true,
+ ] );
+ $session = $manager->getEmptySession();
+ $this->assertInstanceOf( Session::class, $session );
+ $this->assertSame( 'empty2--------------------------', $session->getId() );
+
+ // Tied priorities throw an exception
+ $expectId = null;
+ $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider1,
+ 'id' => 'empty1--------------------------',
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newAnonymous(),
+ 'idIsSafe' => true,
+ ] );
+ $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider2,
+ 'id' => 'empty2--------------------------',
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newAnonymous(),
+ 'idIsSafe' => true,
+ ] );
+ try {
+ $manager->getEmptySession();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertStringStartsWith(
+ 'Multiple empty sessions tied for top priority: ',
+ $ex->getMessage()
+ );
+ }
+
+ // Bad id
+ try {
+ $pmanager->getEmptySessionInternal( null, 'bad' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid session ID', $ex->getMessage() );
+ }
+
+ // Session already exists
+ $expectId = 'expected-----------------------3';
+ $this->store->setSessionMeta( $expectId, [
+ 'provider' => 'MockProvider2',
+ 'userId' => 0,
+ 'userName' => null,
+ 'userToken' => null,
+ ] );
+ try {
+ $pmanager->getEmptySessionInternal( null, $expectId );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Session ID already exists', $ex->getMessage() );
+ }
+ }
+
+ public function testInvalidateSessionsForUser() {
+ $user = User::newFromName( 'UTSysop' );
+ $manager = $this->getManager();
+
+ $providerBuilder = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'invalidateSessionsForUser', '__toString' ] );
+
+ $provider1 = $providerBuilder->getMock();
+ $provider1->expects( $this->once() )->method( 'invalidateSessionsForUser' )
+ ->with( $this->identicalTo( $user ) );
+ $provider1->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'MockProvider1' ) );
+
+ $provider2 = $providerBuilder->getMock();
+ $provider2->expects( $this->once() )->method( 'invalidateSessionsForUser' )
+ ->with( $this->identicalTo( $user ) );
+ $provider2->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'MockProvider2' ) );
+
+ $this->config->set( 'SessionProviders', [
+ $this->objectCacheDef( $provider1 ),
+ $this->objectCacheDef( $provider2 ),
+ ] );
+
+ $oldToken = $user->getToken( true );
+ $manager->invalidateSessionsForUser( $user );
+ $this->assertNotEquals( $oldToken, $user->getToken() );
+ }
+
+ public function testGetVaryHeaders() {
+ $manager = $this->getManager();
+
+ $providerBuilder = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'getVaryHeaders', '__toString' ] );
+
+ $provider1 = $providerBuilder->getMock();
+ $provider1->expects( $this->once() )->method( 'getVaryHeaders' )
+ ->will( $this->returnValue( [
+ 'Foo' => null,
+ 'Bar' => [ 'X', 'Bar1' ],
+ 'Quux' => null,
+ ] ) );
+ $provider1->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'MockProvider1' ) );
+
+ $provider2 = $providerBuilder->getMock();
+ $provider2->expects( $this->once() )->method( 'getVaryHeaders' )
+ ->will( $this->returnValue( [
+ 'Baz' => null,
+ 'Bar' => [ 'X', 'Bar2' ],
+ 'Quux' => [ 'Quux' ],
+ ] ) );
+ $provider2->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'MockProvider2' ) );
+
+ $this->config->set( 'SessionProviders', [
+ $this->objectCacheDef( $provider1 ),
+ $this->objectCacheDef( $provider2 ),
+ ] );
+
+ $expect = [
+ 'Foo' => [],
+ 'Bar' => [ 'X', 'Bar1', 3 => 'Bar2' ],
+ 'Quux' => [ 'Quux' ],
+ 'Baz' => [],
+ ];
+
+ $this->assertEquals( $expect, $manager->getVaryHeaders() );
+
+ // Again, to ensure it's cached
+ $this->assertEquals( $expect, $manager->getVaryHeaders() );
+ }
+
+ public function testGetVaryCookies() {
+ $manager = $this->getManager();
+
+ $providerBuilder = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'getVaryCookies', '__toString' ] );
+
+ $provider1 = $providerBuilder->getMock();
+ $provider1->expects( $this->once() )->method( 'getVaryCookies' )
+ ->will( $this->returnValue( [ 'Foo', 'Bar' ] ) );
+ $provider1->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'MockProvider1' ) );
+
+ $provider2 = $providerBuilder->getMock();
+ $provider2->expects( $this->once() )->method( 'getVaryCookies' )
+ ->will( $this->returnValue( [ 'Foo', 'Baz' ] ) );
+ $provider2->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'MockProvider2' ) );
+
+ $this->config->set( 'SessionProviders', [
+ $this->objectCacheDef( $provider1 ),
+ $this->objectCacheDef( $provider2 ),
+ ] );
+
+ $expect = [ 'Foo', 'Bar', 'Baz' ];
+
+ $this->assertEquals( $expect, $manager->getVaryCookies() );
+
+ // Again, to ensure it's cached
+ $this->assertEquals( $expect, $manager->getVaryCookies() );
+ }
+
+ public function testGetProviders() {
+ $realManager = $this->getManager();
+ $manager = TestingAccessWrapper::newFromObject( $realManager );
+
+ $this->config->set( 'SessionProviders', [
+ [ 'class' => \DummySessionProvider::class ],
+ ] );
+ $providers = $manager->getProviders();
+ $this->assertArrayHasKey( 'DummySessionProvider', $providers );
+ $provider = TestingAccessWrapper::newFromObject( $providers['DummySessionProvider'] );
+ $this->assertSame( $manager->logger, $provider->logger );
+ $this->assertSame( $manager->config, $provider->config );
+ $this->assertSame( $realManager, $provider->getManager() );
+
+ $this->config->set( 'SessionProviders', [
+ [ 'class' => \DummySessionProvider::class ],
+ [ 'class' => \DummySessionProvider::class ],
+ ] );
+ $manager->sessionProviders = null;
+ try {
+ $manager->getProviders();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'Duplicate provider name "DummySessionProvider"',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ public function testShutdown() {
+ $manager = TestingAccessWrapper::newFromObject( $this->getManager() );
+ $manager->setLogger( new \Psr\Log\NullLogger() );
+
+ $mock = $this->getMockBuilder( stdClass::class )
+ ->setMethods( [ 'shutdown' ] )->getMock();
+ $mock->expects( $this->once() )->method( 'shutdown' );
+
+ $manager->allSessionBackends = [ $mock ];
+ $manager->shutdown();
+ }
+
+ public function testGetSessionFromInfo() {
+ $manager = TestingAccessWrapper::newFromObject( $this->getManager() );
+ $request = new \FauxRequest();
+
+ $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $manager->getProvider( 'DummySessionProvider' ),
+ 'id' => $id,
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+ 'idIsSafe' => true,
+ ] );
+ TestingAccessWrapper::newFromObject( $info )->idIsSafe = true;
+ $session1 = TestingAccessWrapper::newFromObject(
+ $manager->getSessionFromInfo( $info, $request )
+ );
+ $session2 = TestingAccessWrapper::newFromObject(
+ $manager->getSessionFromInfo( $info, $request )
+ );
+
+ $this->assertSame( $session1->backend, $session2->backend );
+ $this->assertNotEquals( $session1->index, $session2->index );
+ $this->assertSame( $session1->getSessionId(), $session2->getSessionId() );
+ $this->assertSame( $id, $session1->getId() );
+
+ TestingAccessWrapper::newFromObject( $info )->idIsSafe = false;
+ $session3 = $manager->getSessionFromInfo( $info, $request );
+ $this->assertNotSame( $id, $session3->getId() );
+ }
+
+ public function testBackendRegistration() {
+ $manager = $this->getManager();
+
+ $session = $manager->getSessionForRequest( new \FauxRequest );
+ $backend = TestingAccessWrapper::newFromObject( $session )->backend;
+ $sessionId = $session->getSessionId();
+ $id = (string)$sessionId;
+
+ $this->assertSame( $sessionId, $manager->getSessionById( $id, true )->getSessionId() );
+
+ $manager->changeBackendId( $backend );
+ $this->assertSame( $sessionId, $session->getSessionId() );
+ $this->assertNotEquals( $id, (string)$sessionId );
+ $id = (string)$sessionId;
+
+ $this->assertSame( $sessionId, $manager->getSessionById( $id, true )->getSessionId() );
+
+ // Destruction of the session here causes the backend to be deregistered
+ $session = null;
+
+ try {
+ $manager->changeBackendId( $backend );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Backend was not registered with this SessionManager', $ex->getMessage()
+ );
+ }
+
+ try {
+ $manager->deregisterSessionBackend( $backend );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Backend was not registered with this SessionManager', $ex->getMessage()
+ );
+ }
+
+ $session = $manager->getSessionById( $id, true );
+ $this->assertSame( $sessionId, $session->getSessionId() );
+ }
+
+ public function testGenerateSessionId() {
+ $manager = $this->getManager();
+
+ $id = $manager->generateSessionId();
+ $this->assertTrue( SessionManager::validateSessionId( $id ), "Generated ID: $id" );
+ }
+
+ public function testPreventSessionsForUser() {
+ $manager = $this->getManager();
+
+ $providerBuilder = $this->getMockBuilder( \DummySessionProvider::class )
+ ->setMethods( [ 'preventSessionsForUser', '__toString' ] );
+
+ $provider1 = $providerBuilder->getMock();
+ $provider1->expects( $this->once() )->method( 'preventSessionsForUser' )
+ ->with( $this->equalTo( 'UTSysop' ) );
+ $provider1->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'MockProvider1' ) );
+
+ $this->config->set( 'SessionProviders', [
+ $this->objectCacheDef( $provider1 ),
+ ] );
+
+ $this->assertFalse( $manager->isUserSessionPrevented( 'UTSysop' ) );
+ $manager->preventSessionsForUser( 'UTSysop' );
+ $this->assertTrue( $manager->isUserSessionPrevented( 'UTSysop' ) );
+ }
+
+ public function testLoadSessionInfoFromStore() {
+ $manager = $this->getManager();
+ $logger = new \TestLogger( true );
+ $manager->setLogger( $logger );
+ $request = new \FauxRequest();
+
+ // TestingAccessWrapper can't handle methods with reference arguments, sigh.
+ $rClass = new \ReflectionClass( $manager );
+ $rMethod = $rClass->getMethod( 'loadSessionInfoFromStore' );
+ $rMethod->setAccessible( true );
+ $loadSessionInfoFromStore = function ( &$info ) use ( $rMethod, $manager, $request ) {
+ return $rMethod->invokeArgs( $manager, [ &$info, $request ] );
+ };
+
+ $userInfo = UserInfo::newFromName( 'UTSysop', true );
+ $unverifiedUserInfo = UserInfo::newFromName( 'UTSysop', false );
+
+ $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+ $metadata = [
+ 'userId' => $userInfo->getId(),
+ 'userName' => $userInfo->getName(),
+ 'userToken' => $userInfo->getToken( true ),
+ 'provider' => 'Mock',
+ ];
+
+ $builder = $this->getMockBuilder( SessionProvider::class )
+ ->setMethods( [ '__toString', 'mergeMetadata', 'refreshSessionInfo' ] );
+
+ $provider = $builder->getMockForAbstractClass();
+ $provider->setManager( $manager );
+ $provider->expects( $this->any() )->method( 'persistsSessionId' )
+ ->will( $this->returnValue( true ) );
+ $provider->expects( $this->any() )->method( 'canChangeUser' )
+ ->will( $this->returnValue( true ) );
+ $provider->expects( $this->any() )->method( 'refreshSessionInfo' )
+ ->will( $this->returnValue( true ) );
+ $provider->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'Mock' ) );
+ $provider->expects( $this->any() )->method( 'mergeMetadata' )
+ ->will( $this->returnCallback( function ( $a, $b ) {
+ if ( $b === [ 'Throw' ] ) {
+ throw new MetadataMergeException( 'no merge!' );
+ }
+ return [ 'Merged' ];
+ } ) );
+
+ $provider2 = $builder->getMockForAbstractClass();
+ $provider2->setManager( $manager );
+ $provider2->expects( $this->any() )->method( 'persistsSessionId' )
+ ->will( $this->returnValue( false ) );
+ $provider2->expects( $this->any() )->method( 'canChangeUser' )
+ ->will( $this->returnValue( false ) );
+ $provider2->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'Mock2' ) );
+ $provider2->expects( $this->any() )->method( 'refreshSessionInfo' )
+ ->will( $this->returnCallback( function ( $info, $request, &$metadata ) {
+ $metadata['changed'] = true;
+ return true;
+ } ) );
+
+ $provider3 = $builder->getMockForAbstractClass();
+ $provider3->setManager( $manager );
+ $provider3->expects( $this->any() )->method( 'persistsSessionId' )
+ ->will( $this->returnValue( true ) );
+ $provider3->expects( $this->any() )->method( 'canChangeUser' )
+ ->will( $this->returnValue( true ) );
+ $provider3->expects( $this->once() )->method( 'refreshSessionInfo' )
+ ->will( $this->returnValue( false ) );
+ $provider3->expects( $this->any() )->method( '__toString' )
+ ->will( $this->returnValue( 'Mock3' ) );
+
+ TestingAccessWrapper::newFromObject( $manager )->sessionProviders = [
+ (string)$provider => $provider,
+ (string)$provider2 => $provider2,
+ (string)$provider3 => $provider3,
+ ];
+
+ // No metadata, basic usage
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertFalse( $info->isIdSafe() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertTrue( $info->isIdSafe(), 'sanity check' );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider2,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ // Unverified user, no metadata
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $unverifiedUserInfo
+ ] );
+ $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [
+ LogLevel::INFO,
+ 'Session "{session}": Unverified user provided and no metadata to auth it',
+ ]
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // No metadata, missing data
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": Null provider and no metadata' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ ] );
+ $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertInstanceOf( UserInfo::class, $info->getUserInfo() );
+ $this->assertTrue( $info->getUserInfo()->isVerified() );
+ $this->assertTrue( $info->getUserInfo()->isAnon() );
+ $this->assertFalse( $info->isIdSafe() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider2,
+ 'id' => $id,
+ ] );
+ $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::INFO, 'Session "{session}": No user provided and provider cannot set user' ]
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Incomplete/bad metadata
+ $this->store->setRawSession( $id, true );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": Bad data' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $this->store->setRawSession( $id, [ 'data' => [] ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": Bad data structure' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $this->store->deleteSession( $id );
+ $this->store->setRawSession( $id, [ 'metadata' => $metadata ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": Bad data structure' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $this->store->setRawSession( $id, [ 'metadata' => $metadata, 'data' => true ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": Bad data structure' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $this->store->setRawSession( $id, [ 'metadata' => true, 'data' => [] ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": Bad data structure' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ foreach ( $metadata as $key => $dummy ) {
+ $tmp = $metadata;
+ unset( $tmp[$key] );
+ $this->store->setRawSession( $id, [ 'metadata' => $tmp, 'data' => [] ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": Bad metadata' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ }
+
+ // Basic usage with metadata
+ $this->store->setRawSession( $id, [ 'metadata' => $metadata, 'data' => [] ] );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ // Mismatched provider
+ $this->store->setSessionMeta( $id, [ 'provider' => 'Bad' ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": Wrong provider Bad !== Mock' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Unknown provider
+ $this->store->setSessionMeta( $id, [ 'provider' => 'Bad' ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": Unknown provider Bad' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Fill in provider
+ $this->store->setSessionMeta( $id, $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ // Bad user metadata
+ $this->store->setSessionMeta( $id, [ 'userId' => -1, 'userToken' => null ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::ERROR, 'Session "{session}": {exception}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $this->store->setSessionMeta(
+ $id, [ 'userId' => 0, 'userName' => '<X>', 'userToken' => null ] + $metadata
+ );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::ERROR, 'Session "{session}": {exception}', ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Mismatched user by ID
+ $this->store->setSessionMeta(
+ $id, [ 'userId' => $userInfo->getId() + 1, 'userToken' => null ] + $metadata
+ );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Mismatched user by name
+ $this->store->setSessionMeta(
+ $id, [ 'userId' => 0, 'userName' => 'X', 'userToken' => null ] + $metadata
+ );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": User name mismatch, {uname_a} !== {uname_b}' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // ID matches, name doesn't
+ $this->store->setSessionMeta(
+ $id, [ 'userId' => $userInfo->getId(), 'userName' => 'X', 'userToken' => null ] + $metadata
+ );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [
+ LogLevel::WARNING,
+ 'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}'
+ ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Mismatched anon user
+ $this->store->setSessionMeta(
+ $id, [ 'userId' => 0, 'userName' => null, 'userToken' => null ] + $metadata
+ );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [
+ LogLevel::WARNING,
+ 'Session "{session}": Metadata has an anonymous user, ' .
+ 'but a non-anon user was provided',
+ ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Lookup user by ID
+ $this->store->setSessionMeta( $id, [ 'userToken' => null ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ ] );
+ $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ // Lookup user by name
+ $this->store->setSessionMeta(
+ $id, [ 'userId' => 0, 'userName' => 'UTSysop', 'userToken' => null ] + $metadata
+ );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ ] );
+ $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ // Lookup anonymous user
+ $this->store->setSessionMeta(
+ $id, [ 'userId' => 0, 'userName' => null, 'userToken' => null ] + $metadata
+ );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ ] );
+ $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->getUserInfo()->isAnon() );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ // Unverified user with metadata
+ $this->store->setSessionMeta( $id, $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $unverifiedUserInfo
+ ] );
+ $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->getUserInfo()->isVerified() );
+ $this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() );
+ $this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ // Unverified user with metadata
+ $this->store->setSessionMeta( $id, $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $unverifiedUserInfo
+ ] );
+ $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->getUserInfo()->isVerified() );
+ $this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() );
+ $this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() );
+ $this->assertTrue( $info->isIdSafe() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ // Wrong token
+ $this->store->setSessionMeta( $id, [ 'userToken' => 'Bad' ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": User token mismatch' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Provider metadata
+ $this->store->setSessionMeta( $id, [ 'provider' => 'Mock2' ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider2,
+ 'id' => $id,
+ 'userInfo' => $userInfo,
+ 'metadata' => [ 'Info' ],
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [ 'Info', 'changed' => true ], $info->getProviderMetadata() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $this->store->setSessionMeta( $id, [ 'providerMetadata' => [ 'Saved' ] ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo,
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [ 'Saved' ], $info->getProviderMetadata() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo,
+ 'metadata' => [ 'Info' ],
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [ 'Merged' ], $info->getProviderMetadata() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo,
+ 'metadata' => [ 'Throw' ],
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [
+ [
+ LogLevel::WARNING,
+ 'Session "{session}": Metadata merge failed: {exception}',
+ ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Remember from session
+ $this->store->setSessionMeta( $id, $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertFalse( $info->wasRemembered() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $this->store->setSessionMeta( $id, [ 'remember' => true ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->wasRemembered() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $this->store->setSessionMeta( $id, [ 'remember' => false ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->wasRemembered() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ // forceHTTPS from session
+ $this->store->setSessionMeta( $id, $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertFalse( $info->forceHTTPS() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $this->store->setSessionMeta( $id, [ 'forceHTTPS' => true ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->forceHTTPS() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $this->store->setSessionMeta( $id, [ 'forceHTTPS' => false ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo,
+ 'forceHTTPS' => true
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->forceHTTPS() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ // "Persist" flag from session
+ $this->store->setSessionMeta( $id, $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertFalse( $info->wasPersisted() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $this->store->setSessionMeta( $id, [ 'persisted' => true ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->wasPersisted() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ $this->store->setSessionMeta( $id, [ 'persisted' => false ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo,
+ 'persisted' => true
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $info->wasPersisted() );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ // Provider refreshSessionInfo() returning false
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider3,
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertSame( [], $logger->getBuffer() );
+
+ // Hook
+ $called = false;
+ $data = [ 'foo' => 1 ];
+ $this->store->setSession( $id, [ 'metadata' => $metadata, 'data' => $data ] );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo
+ ] );
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'SessionCheckInfo' => [ function ( &$reason, $i, $r, $m, $d ) use (
+ $info, $metadata, $data, $request, &$called
+ ) {
+ $this->assertSame( $info->getId(), $i->getId() );
+ $this->assertSame( $info->getProvider(), $i->getProvider() );
+ $this->assertSame( $info->getUserInfo(), $i->getUserInfo() );
+ $this->assertSame( $request, $r );
+ $this->assertEquals( $metadata, $m );
+ $this->assertEquals( $data, $d );
+ $called = true;
+ return false;
+ } ]
+ ] );
+ $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+ $this->assertTrue( $called );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": Hook aborted' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionCheckInfo' => [] ] );
+
+ // forceUse deletes bad backend data
+ $this->store->setSessionMeta( $id, [ 'userToken' => 'Bad' ] + $metadata );
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $provider,
+ 'id' => $id,
+ 'userInfo' => $userInfo,
+ 'forceUse' => true,
+ ] );
+ $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+ $this->assertFalse( $this->store->getSession( $id ) );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Session "{session}": User token mismatch' ],
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/session/SessionProviderTest.php b/www/wiki/tests/phpunit/includes/session/SessionProviderTest.php
new file mode 100644
index 00000000..052c0167
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/SessionProviderTest.php
@@ -0,0 +1,206 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\SessionProvider
+ */
+class SessionProviderTest extends MediaWikiTestCase {
+
+ public function testBasics() {
+ $manager = new SessionManager();
+ $logger = new \TestLogger();
+ $config = new \HashConfig();
+
+ $provider = $this->getMockForAbstractClass( SessionProvider::class );
+ $priv = TestingAccessWrapper::newFromObject( $provider );
+
+ $provider->setConfig( $config );
+ $this->assertSame( $config, $priv->config );
+ $provider->setLogger( $logger );
+ $this->assertSame( $logger, $priv->logger );
+ $provider->setManager( $manager );
+ $this->assertSame( $manager, $priv->manager );
+ $this->assertSame( $manager, $provider->getManager() );
+
+ $provider->invalidateSessionsForUser( new \User );
+
+ $this->assertSame( [], $provider->getVaryHeaders() );
+ $this->assertSame( [], $provider->getVaryCookies() );
+ $this->assertSame( null, $provider->suggestLoginUsername( new \FauxRequest ) );
+
+ $this->assertSame( get_class( $provider ), (string)$provider );
+
+ $this->assertNull( $provider->getRememberUserDuration() );
+
+ $this->assertNull( $provider->whyNoSession() );
+
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ 'provider' => $provider,
+ ] );
+ $metadata = [ 'foo' ];
+ $this->assertTrue( $provider->refreshSessionInfo( $info, new \FauxRequest, $metadata ) );
+ $this->assertSame( [ 'foo' ], $metadata );
+ }
+
+ /**
+ * @dataProvider provideNewSessionInfo
+ * @param bool $persistId Return value for ->persistsSessionId()
+ * @param bool $persistUser Return value for ->persistsSessionUser()
+ * @param bool $ok Whether a SessionInfo is provided
+ */
+ public function testNewSessionInfo( $persistId, $persistUser, $ok ) {
+ $manager = new SessionManager();
+
+ $provider = $this->getMockBuilder( SessionProvider::class )
+ ->setMethods( [ 'canChangeUser', 'persistsSessionId' ] )
+ ->getMockForAbstractClass();
+ $provider->expects( $this->any() )->method( 'persistsSessionId' )
+ ->will( $this->returnValue( $persistId ) );
+ $provider->expects( $this->any() )->method( 'canChangeUser' )
+ ->will( $this->returnValue( $persistUser ) );
+ $provider->setManager( $manager );
+
+ if ( $ok ) {
+ $info = $provider->newSessionInfo();
+ $this->assertNotNull( $info );
+ $this->assertFalse( $info->wasPersisted() );
+ $this->assertTrue( $info->isIdSafe() );
+
+ $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+ $info = $provider->newSessionInfo( $id );
+ $this->assertNotNull( $info );
+ $this->assertSame( $id, $info->getId() );
+ $this->assertFalse( $info->wasPersisted() );
+ $this->assertTrue( $info->isIdSafe() );
+ } else {
+ $this->assertNull( $provider->newSessionInfo() );
+ }
+ }
+
+ public function testMergeMetadata() {
+ $provider = $this->getMockBuilder( SessionProvider::class )
+ ->getMockForAbstractClass();
+
+ try {
+ $provider->mergeMetadata(
+ [ 'foo' => 1, 'baz' => 3 ],
+ [ 'bar' => 2, 'baz' => '3' ]
+ );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( MetadataMergeException $ex ) {
+ $this->assertSame( 'Key "baz" changed', $ex->getMessage() );
+ $this->assertSame(
+ [ 'old_value' => 3, 'new_value' => '3' ], $ex->getContext() );
+ }
+
+ $res = $provider->mergeMetadata(
+ [ 'foo' => 1, 'baz' => 3 ],
+ [ 'bar' => 2, 'baz' => 3 ]
+ );
+ $this->assertSame( [ 'bar' => 2, 'baz' => 3 ], $res );
+ }
+
+ public static function provideNewSessionInfo() {
+ return [
+ [ false, false, false ],
+ [ true, false, false ],
+ [ false, true, false ],
+ [ true, true, true ],
+ ];
+ }
+
+ public function testImmutableSessions() {
+ $provider = $this->getMockBuilder( SessionProvider::class )
+ ->setMethods( [ 'canChangeUser', 'persistsSessionId' ] )
+ ->getMockForAbstractClass();
+ $provider->expects( $this->any() )->method( 'canChangeUser' )
+ ->will( $this->returnValue( true ) );
+ $provider->preventSessionsForUser( 'Foo' );
+
+ $provider = $this->getMockBuilder( SessionProvider::class )
+ ->setMethods( [ 'canChangeUser', 'persistsSessionId' ] )
+ ->getMockForAbstractClass();
+ $provider->expects( $this->any() )->method( 'canChangeUser' )
+ ->will( $this->returnValue( false ) );
+ try {
+ $provider->preventSessionsForUser( 'Foo' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \BadMethodCallException $ex ) {
+ $this->assertSame(
+ 'MediaWiki\\Session\\SessionProvider::preventSessionsForUser must be implmented ' .
+ 'when canChangeUser() is false',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ public function testHashToSessionId() {
+ $config = new \HashConfig( [
+ 'SecretKey' => 'Shhh!',
+ ] );
+
+ $provider = $this->getMockForAbstractClass( SessionProvider::class,
+ [], 'MockSessionProvider' );
+ $provider->setConfig( $config );
+ $priv = TestingAccessWrapper::newFromObject( $provider );
+
+ $this->assertSame( 'eoq8cb1mg7j30ui5qolafps4hg29k5bb', $priv->hashToSessionId( 'foobar' ) );
+ $this->assertSame( '4do8j7tfld1g8tte9jqp3csfgmulaun9',
+ $priv->hashToSessionId( 'foobar', 'secret' ) );
+
+ try {
+ $priv->hashToSessionId( [] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ '$data must be a string, array was passed',
+ $ex->getMessage()
+ );
+ }
+ try {
+ $priv->hashToSessionId( '', false );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ '$key must be a string or null, boolean was passed',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ public function testDescribe() {
+ $provider = $this->getMockForAbstractClass( SessionProvider::class,
+ [], 'MockSessionProvider' );
+
+ $this->assertSame(
+ 'MockSessionProvider sessions',
+ $provider->describe( \Language::factory( 'en' ) )
+ );
+ }
+
+ public function testGetAllowedUserRights() {
+ $provider = $this->getMockForAbstractClass( SessionProvider::class );
+ $backend = TestUtils::getDummySessionBackend();
+
+ try {
+ $provider->getAllowedUserRights( $backend );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Backend\'s provider isn\'t $this',
+ $ex->getMessage()
+ );
+ }
+
+ TestingAccessWrapper::newFromObject( $backend )->provider = $provider;
+ $this->assertNull( $provider->getAllowedUserRights( $backend ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/session/SessionTest.php b/www/wiki/tests/phpunit/includes/session/SessionTest.php
new file mode 100644
index 00000000..f84d435f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/SessionTest.php
@@ -0,0 +1,373 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LogLevel;
+use MediaWikiTestCase;
+use User;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Session
+ * @covers MediaWiki\Session\Session
+ */
+class SessionTest extends MediaWikiTestCase {
+
+ public function testConstructor() {
+ $backend = TestUtils::getDummySessionBackend();
+ TestingAccessWrapper::newFromObject( $backend )->requests = [ -1 => 'dummy' ];
+ TestingAccessWrapper::newFromObject( $backend )->id = new SessionId( 'abc' );
+
+ $session = new Session( $backend, 42, new \TestLogger );
+ $priv = TestingAccessWrapper::newFromObject( $session );
+ $this->assertSame( $backend, $priv->backend );
+ $this->assertSame( 42, $priv->index );
+
+ $request = new \FauxRequest();
+ $priv2 = TestingAccessWrapper::newFromObject( $session->sessionWithRequest( $request ) );
+ $this->assertSame( $backend, $priv2->backend );
+ $this->assertNotSame( $priv->index, $priv2->index );
+ $this->assertSame( $request, $priv2->getRequest() );
+ }
+
+ /**
+ * @dataProvider provideMethods
+ * @param string $m Method to test
+ * @param array $args Arguments to pass to the method
+ * @param bool $index Whether the backend method gets passed the index
+ * @param bool $ret Whether the method returns a value
+ */
+ public function testMethods( $m, $args, $index, $ret ) {
+ $mock = $this->getMockBuilder( DummySessionBackend::class )
+ ->setMethods( [ $m, 'deregisterSession' ] )
+ ->getMock();
+ $mock->expects( $this->once() )->method( 'deregisterSession' )
+ ->with( $this->identicalTo( 42 ) );
+
+ $tmp = $mock->expects( $this->once() )->method( $m );
+ $expectArgs = [];
+ if ( $index ) {
+ $expectArgs[] = $this->identicalTo( 42 );
+ }
+ foreach ( $args as $arg ) {
+ $expectArgs[] = $this->identicalTo( $arg );
+ }
+ $tmp = call_user_func_array( [ $tmp, 'with' ], $expectArgs );
+
+ $retval = new \stdClass;
+ $tmp->will( $this->returnValue( $retval ) );
+
+ $session = TestUtils::getDummySession( $mock, 42 );
+
+ if ( $ret ) {
+ $this->assertSame( $retval, call_user_func_array( [ $session, $m ], $args ) );
+ } else {
+ $this->assertNull( call_user_func_array( [ $session, $m ], $args ) );
+ }
+
+ // Trigger Session destructor
+ $session = null;
+ }
+
+ public static function provideMethods() {
+ return [
+ [ 'getId', [], false, true ],
+ [ 'getSessionId', [], false, true ],
+ [ 'resetId', [], false, true ],
+ [ 'getProvider', [], false, true ],
+ [ 'isPersistent', [], false, true ],
+ [ 'persist', [], false, false ],
+ [ 'unpersist', [], false, false ],
+ [ 'shouldRememberUser', [], false, true ],
+ [ 'setRememberUser', [ true ], false, false ],
+ [ 'getRequest', [], true, true ],
+ [ 'getUser', [], false, true ],
+ [ 'getAllowedUserRights', [], false, true ],
+ [ 'canSetUser', [], false, true ],
+ [ 'setUser', [ new \stdClass ], false, false ],
+ [ 'suggestLoginUsername', [], true, true ],
+ [ 'shouldForceHTTPS', [], false, true ],
+ [ 'setForceHTTPS', [ true ], false, false ],
+ [ 'getLoggedOutTimestamp', [], false, true ],
+ [ 'setLoggedOutTimestamp', [ 123 ], false, false ],
+ [ 'getProviderMetadata', [], false, true ],
+ [ 'save', [], false, false ],
+ [ 'delaySave', [], false, true ],
+ [ 'renew', [], false, false ],
+ ];
+ }
+
+ public function testDataAccess() {
+ $session = TestUtils::getDummySession();
+ $backend = TestingAccessWrapper::newFromObject( $session )->backend;
+
+ $this->assertEquals( 1, $session->get( 'foo' ) );
+ $this->assertEquals( 'zero', $session->get( 0 ) );
+ $this->assertFalse( $backend->dirty );
+
+ $this->assertEquals( null, $session->get( 'null' ) );
+ $this->assertEquals( 'default', $session->get( 'null', 'default' ) );
+ $this->assertFalse( $backend->dirty );
+
+ $session->set( 'foo', 55 );
+ $this->assertEquals( 55, $backend->data['foo'] );
+ $this->assertTrue( $backend->dirty );
+ $backend->dirty = false;
+
+ $session->set( 1, 'one' );
+ $this->assertEquals( 'one', $backend->data[1] );
+ $this->assertTrue( $backend->dirty );
+ $backend->dirty = false;
+
+ $session->set( 1, 'one' );
+ $this->assertFalse( $backend->dirty );
+
+ $this->assertTrue( $session->exists( 'foo' ) );
+ $this->assertTrue( $session->exists( 1 ) );
+ $this->assertFalse( $session->exists( 'null' ) );
+ $this->assertFalse( $session->exists( 100 ) );
+ $this->assertFalse( $backend->dirty );
+
+ $session->remove( 'foo' );
+ $this->assertArrayNotHasKey( 'foo', $backend->data );
+ $this->assertTrue( $backend->dirty );
+ $backend->dirty = false;
+ $session->remove( 1 );
+ $this->assertArrayNotHasKey( 1, $backend->data );
+ $this->assertTrue( $backend->dirty );
+ $backend->dirty = false;
+
+ $session->remove( 101 );
+ $this->assertFalse( $backend->dirty );
+
+ $backend->data = [ 'a', 'b', '?' => 'c' ];
+ $this->assertSame( 3, $session->count() );
+ $this->assertSame( 3, count( $session ) );
+ $this->assertFalse( $backend->dirty );
+
+ $data = [];
+ foreach ( $session as $key => $value ) {
+ $data[$key] = $value;
+ }
+ $this->assertEquals( $backend->data, $data );
+ $this->assertFalse( $backend->dirty );
+
+ $this->assertEquals( $backend->data, iterator_to_array( $session ) );
+ $this->assertFalse( $backend->dirty );
+ }
+
+ public function testArrayAccess() {
+ $logger = new \TestLogger;
+ $session = TestUtils::getDummySession( null, -1, $logger );
+ $backend = TestingAccessWrapper::newFromObject( $session )->backend;
+
+ $this->assertEquals( 1, $session['foo'] );
+ $this->assertEquals( 'zero', $session[0] );
+ $this->assertFalse( $backend->dirty );
+
+ $logger->setCollect( true );
+ $this->assertEquals( null, $session['null'] );
+ $logger->setCollect( false );
+ $this->assertFalse( $backend->dirty );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, 'Undefined index (auto-adds to session with a null value): null' ]
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $session['foo'] = 55;
+ $this->assertEquals( 55, $backend->data['foo'] );
+ $this->assertTrue( $backend->dirty );
+ $backend->dirty = false;
+
+ $session[1] = 'one';
+ $this->assertEquals( 'one', $backend->data[1] );
+ $this->assertTrue( $backend->dirty );
+ $backend->dirty = false;
+
+ $session[1] = 'one';
+ $this->assertFalse( $backend->dirty );
+
+ $session['bar'] = [ 'baz' => [] ];
+ $session['bar']['baz']['quux'] = 2;
+ $this->assertEquals( [ 'baz' => [ 'quux' => 2 ] ], $backend->data['bar'] );
+
+ $logger->setCollect( true );
+ $session['bar2']['baz']['quux'] = 3;
+ $logger->setCollect( false );
+ $this->assertEquals( [ 'baz' => [ 'quux' => 3 ] ], $backend->data['bar2'] );
+ $this->assertSame( [
+ [ LogLevel::DEBUG, 'Undefined index (auto-adds to session with a null value): bar2' ]
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ $backend->dirty = false;
+ $this->assertTrue( isset( $session['foo'] ) );
+ $this->assertTrue( isset( $session[1] ) );
+ $this->assertFalse( isset( $session['null'] ) );
+ $this->assertFalse( isset( $session['missing'] ) );
+ $this->assertFalse( isset( $session[100] ) );
+ $this->assertFalse( $backend->dirty );
+
+ unset( $session['foo'] );
+ $this->assertArrayNotHasKey( 'foo', $backend->data );
+ $this->assertTrue( $backend->dirty );
+ $backend->dirty = false;
+ unset( $session[1] );
+ $this->assertArrayNotHasKey( 1, $backend->data );
+ $this->assertTrue( $backend->dirty );
+ $backend->dirty = false;
+
+ unset( $session[101] );
+ $this->assertFalse( $backend->dirty );
+ }
+
+ public function testClear() {
+ $session = TestUtils::getDummySession();
+ $priv = TestingAccessWrapper::newFromObject( $session );
+
+ $backend = $this->getMockBuilder( DummySessionBackend::class )
+ ->setMethods( [ 'canSetUser', 'setUser', 'save' ] )
+ ->getMock();
+ $backend->expects( $this->once() )->method( 'canSetUser' )
+ ->will( $this->returnValue( true ) );
+ $backend->expects( $this->once() )->method( 'setUser' )
+ ->with( $this->callback( function ( $user ) {
+ return $user instanceof User && $user->isAnon();
+ } ) );
+ $backend->expects( $this->once() )->method( 'save' );
+ $priv->backend = $backend;
+ $session->clear();
+ $this->assertSame( [], $backend->data );
+ $this->assertTrue( $backend->dirty );
+
+ $backend = $this->getMockBuilder( DummySessionBackend::class )
+ ->setMethods( [ 'canSetUser', 'setUser', 'save' ] )
+ ->getMock();
+ $backend->data = [];
+ $backend->expects( $this->once() )->method( 'canSetUser' )
+ ->will( $this->returnValue( true ) );
+ $backend->expects( $this->once() )->method( 'setUser' )
+ ->with( $this->callback( function ( $user ) {
+ return $user instanceof User && $user->isAnon();
+ } ) );
+ $backend->expects( $this->once() )->method( 'save' );
+ $priv->backend = $backend;
+ $session->clear();
+ $this->assertFalse( $backend->dirty );
+
+ $backend = $this->getMockBuilder( DummySessionBackend::class )
+ ->setMethods( [ 'canSetUser', 'setUser', 'save' ] )
+ ->getMock();
+ $backend->expects( $this->once() )->method( 'canSetUser' )
+ ->will( $this->returnValue( false ) );
+ $backend->expects( $this->never() )->method( 'setUser' );
+ $backend->expects( $this->once() )->method( 'save' );
+ $priv->backend = $backend;
+ $session->clear();
+ $this->assertSame( [], $backend->data );
+ $this->assertTrue( $backend->dirty );
+ }
+
+ public function testTokens() {
+ $session = TestUtils::getDummySession();
+ $priv = TestingAccessWrapper::newFromObject( $session );
+ $backend = $priv->backend;
+
+ $token = TestingAccessWrapper::newFromObject( $session->getToken() );
+ $this->assertArrayHasKey( 'wsTokenSecrets', $backend->data );
+ $this->assertArrayHasKey( 'default', $backend->data['wsTokenSecrets'] );
+ $secret = $backend->data['wsTokenSecrets']['default'];
+ $this->assertSame( $secret, $token->secret );
+ $this->assertSame( '', $token->salt );
+ $this->assertTrue( $token->wasNew() );
+
+ $token = TestingAccessWrapper::newFromObject( $session->getToken( 'foo' ) );
+ $this->assertSame( $secret, $token->secret );
+ $this->assertSame( 'foo', $token->salt );
+ $this->assertFalse( $token->wasNew() );
+
+ $backend->data['wsTokenSecrets']['secret'] = 'sekret';
+ $token = TestingAccessWrapper::newFromObject(
+ $session->getToken( [ 'bar', 'baz' ], 'secret' )
+ );
+ $this->assertSame( 'sekret', $token->secret );
+ $this->assertSame( 'bar|baz', $token->salt );
+ $this->assertFalse( $token->wasNew() );
+
+ $session->resetToken( 'secret' );
+ $this->assertArrayHasKey( 'wsTokenSecrets', $backend->data );
+ $this->assertArrayHasKey( 'default', $backend->data['wsTokenSecrets'] );
+ $this->assertArrayNotHasKey( 'secret', $backend->data['wsTokenSecrets'] );
+
+ $session->resetAllTokens();
+ $this->assertArrayNotHasKey( 'wsTokenSecrets', $backend->data );
+ }
+
+ /**
+ * @dataProvider provideSecretsRoundTripping
+ * @param mixed $data
+ */
+ public function testSecretsRoundTripping( $data ) {
+ $session = TestUtils::getDummySession();
+
+ // Simple round-trip
+ $session->setSecret( 'secret', $data );
+ $this->assertNotEquals( $data, $session->get( 'secret' ) );
+ $this->assertEquals( $data, $session->getSecret( 'secret', 'defaulted' ) );
+ }
+
+ public static function provideSecretsRoundTripping() {
+ return [
+ [ 'Foobar' ],
+ [ 42 ],
+ [ [ 'foo', 'bar' => 'baz', 'subarray' => [ 1, 2, 3 ] ] ],
+ [ (object)[ 'foo', 'bar' => 'baz', 'subarray' => [ 1, 2, 3 ] ] ],
+ [ true ],
+ [ false ],
+ [ null ],
+ ];
+ }
+
+ public function testSecrets() {
+ $logger = new \TestLogger;
+ $session = TestUtils::getDummySession( null, -1, $logger );
+
+ // Simple defaulting
+ $this->assertEquals( 'defaulted', $session->getSecret( 'test', 'defaulted' ) );
+
+ // Bad encrypted data
+ $session->set( 'test', 'foobar' );
+ $logger->setCollect( true );
+ $this->assertEquals( 'defaulted', $session->getSecret( 'test', 'defaulted' ) );
+ $logger->setCollect( false );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Invalid sealed-secret format' ]
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Tampered data
+ $session->setSecret( 'test', 'foobar' );
+ $encrypted = $session->get( 'test' );
+ $session->set( 'test', $encrypted . 'x' );
+ $logger->setCollect( true );
+ $this->assertEquals( 'defaulted', $session->getSecret( 'test', 'defaulted' ) );
+ $logger->setCollect( false );
+ $this->assertSame( [
+ [ LogLevel::WARNING, 'Sealed secret has been tampered with, aborting.' ]
+ ], $logger->getBuffer() );
+ $logger->clearBuffer();
+
+ // Unserializable data
+ $iv = \MWCryptRand::generate( 16, true );
+ list( $encKey, $hmacKey ) = TestingAccessWrapper::newFromObject( $session )->getSecretKeys();
+ $ciphertext = openssl_encrypt( 'foobar', 'aes-256-ctr', $encKey, OPENSSL_RAW_DATA, $iv );
+ $sealed = base64_encode( $iv ) . '.' . base64_encode( $ciphertext );
+ $hmac = hash_hmac( 'sha256', $sealed, $hmacKey, true );
+ $encrypted = base64_encode( $hmac ) . '.' . $sealed;
+ $session->set( 'test', $encrypted );
+ \Wikimedia\suppressWarnings();
+ $this->assertEquals( 'defaulted', $session->getSecret( 'test', 'defaulted' ) );
+ \Wikimedia\restoreWarnings();
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/session/TestBagOStuff.php b/www/wiki/tests/phpunit/includes/session/TestBagOStuff.php
new file mode 100644
index 00000000..f9e30f06
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/TestBagOStuff.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace MediaWiki\Session;
+
+/**
+ * BagOStuff with utility functions for MediaWiki\\Session\\* testing
+ */
+class TestBagOStuff extends \CachedBagOStuff {
+
+ public function __construct() {
+ parent::__construct( new \HashBagOStuff );
+ }
+
+ /**
+ * @param string $id Session ID
+ * @param array $data Session data
+ */
+ public function setSessionData( $id, array $data ) {
+ $this->setSession( $id, [ 'data' => $data ] );
+ }
+
+ /**
+ * @param string $id Session ID
+ * @param array $metadata Session metadata
+ */
+ public function setSessionMeta( $id, array $metadata ) {
+ $this->setSession( $id, [ 'metadata' => $metadata ] );
+ }
+
+ /**
+ * @param string $id Session ID
+ * @param array $blob Session metadata and data
+ */
+ public function setSession( $id, array $blob ) {
+ $blob += [
+ 'data' => [],
+ 'metadata' => [],
+ ];
+ $blob['metadata'] += [
+ 'userId' => 0,
+ 'userName' => null,
+ 'userToken' => null,
+ 'provider' => 'DummySessionProvider',
+ ];
+
+ $this->setRawSession( $id, $blob );
+ }
+
+ /**
+ * @param string $id Session ID
+ * @param array|mixed $blob Session metadata and data
+ */
+ public function setRawSession( $id, $blob ) {
+ $expiry = \RequestContext::getMain()->getConfig()->get( 'ObjectCacheSessionExpiry' );
+ $this->set( $this->makeKey( 'MWSession', $id ), $blob, $expiry );
+ }
+
+ /**
+ * @param string $id Session ID
+ * @return mixed
+ */
+ public function getSession( $id ) {
+ return $this->get( $this->makeKey( 'MWSession', $id ) );
+ }
+
+ /**
+ * @param string $id Session ID
+ * @return mixed
+ */
+ public function getSessionFromBackend( $id ) {
+ return $this->backend->get( $this->makeKey( 'MWSession', $id ) );
+ }
+
+ /**
+ * @param string $id Session ID
+ */
+ public function deleteSession( $id ) {
+ $this->delete( $this->makeKey( 'MWSession', $id ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/session/TestUtils.php b/www/wiki/tests/phpunit/includes/session/TestUtils.php
new file mode 100644
index 00000000..5db1ad0e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/TestUtils.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerInterface;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * Utility functions for Session unit tests
+ */
+class TestUtils {
+
+ /**
+ * Override the singleton for unit testing
+ * @param SessionManager|null $manager
+ * @return \\Wikimedia\ScopedCallback|null
+ */
+ public static function setSessionManagerSingleton( SessionManager $manager = null ) {
+ session_write_close();
+
+ $rInstance = new \ReflectionProperty(
+ SessionManager::class, 'instance'
+ );
+ $rInstance->setAccessible( true );
+ $rGlobalSession = new \ReflectionProperty(
+ SessionManager::class, 'globalSession'
+ );
+ $rGlobalSession->setAccessible( true );
+ $rGlobalSessionRequest = new \ReflectionProperty(
+ SessionManager::class, 'globalSessionRequest'
+ );
+ $rGlobalSessionRequest->setAccessible( true );
+
+ $oldInstance = $rInstance->getValue();
+
+ $reset = [
+ [ $rInstance, $oldInstance ],
+ [ $rGlobalSession, $rGlobalSession->getValue() ],
+ [ $rGlobalSessionRequest, $rGlobalSessionRequest->getValue() ],
+ ];
+
+ $rInstance->setValue( $manager );
+ $rGlobalSession->setValue( null );
+ $rGlobalSessionRequest->setValue( null );
+ if ( $manager && PHPSessionHandler::isInstalled() ) {
+ PHPSessionHandler::install( $manager );
+ }
+
+ return new \Wikimedia\ScopedCallback( function () use ( &$reset, $oldInstance ) {
+ foreach ( $reset as &$arr ) {
+ $arr[0]->setValue( $arr[1] );
+ }
+ if ( $oldInstance && PHPSessionHandler::isInstalled() ) {
+ PHPSessionHandler::install( $oldInstance );
+ }
+ } );
+ }
+
+ /**
+ * If you need a SessionBackend for testing but don't want to create a real
+ * one, use this.
+ * @return SessionBackend Unconfigured! Use reflection to set any private
+ * fields necessary.
+ */
+ public static function getDummySessionBackend() {
+ $rc = new \ReflectionClass( SessionBackend::class );
+ if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) {
+ \PHPUnit_Framework_Assert::markTestSkipped(
+ 'ReflectionClass::newInstanceWithoutConstructor isn\'t available'
+ );
+ }
+
+ $ret = $rc->newInstanceWithoutConstructor();
+ TestingAccessWrapper::newFromObject( $ret )->logger = new \TestLogger;
+ return $ret;
+ }
+
+ /**
+ * If you need a Session for testing but don't want to create a backend to
+ * construct one, use this.
+ * @param object $backend Object to serve as the SessionBackend
+ * @param int $index
+ * @param LoggerInterface $logger
+ * @return Session
+ */
+ public static function getDummySession( $backend = null, $index = -1, $logger = null ) {
+ $rc = new \ReflectionClass( Session::class );
+ if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) {
+ \PHPUnit_Framework_Assert::markTestSkipped(
+ 'ReflectionClass::newInstanceWithoutConstructor isn\'t available'
+ );
+ }
+
+ if ( $backend === null ) {
+ $backend = new DummySessionBackend;
+ }
+
+ $session = $rc->newInstanceWithoutConstructor();
+ $priv = TestingAccessWrapper::newFromObject( $session );
+ $priv->backend = $backend;
+ $priv->index = $index;
+ $priv->logger = $logger ?: new \TestLogger;
+ return $session;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/session/TokenTest.php b/www/wiki/tests/phpunit/includes/session/TokenTest.php
new file mode 100644
index 00000000..47976527
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/TokenTest.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Session
+ * @covers MediaWiki\Session\Token
+ */
+class TokenTest extends MediaWikiTestCase {
+
+ public function testBasics() {
+ $token = $this->getMockBuilder( Token::class )
+ ->setMethods( [ 'toStringAtTimestamp' ] )
+ ->setConstructorArgs( [ 'sekret', 'salty', true ] )
+ ->getMock();
+ $token->expects( $this->any() )->method( 'toStringAtTimestamp' )
+ ->will( $this->returnValue( 'faketoken+\\' ) );
+
+ $this->assertSame( 'faketoken+\\', $token->toString() );
+ $this->assertSame( 'faketoken+\\', (string)$token );
+ $this->assertTrue( $token->wasNew() );
+
+ $token = new Token( 'sekret', 'salty', false );
+ $this->assertFalse( $token->wasNew() );
+ }
+
+ public function testToStringAtTimestamp() {
+ $token = TestingAccessWrapper::newFromObject( new Token( 'sekret', 'salty', false ) );
+
+ $this->assertSame(
+ 'd9ade0c7d4349e9df9094e61c33a5a0d5644fde2+\\',
+ $token->toStringAtTimestamp( 1447362018 )
+ );
+ $this->assertSame(
+ 'ee2f7a2488dea9176c224cfb400d43be5644fdea+\\',
+ $token->toStringAtTimestamp( 1447362026 )
+ );
+ }
+
+ public function testGetTimestamp() {
+ $this->assertSame(
+ 1447362018, Token::getTimestamp( 'd9ade0c7d4349e9df9094e61c33a5a0d5644fde2+\\' )
+ );
+ $this->assertSame(
+ 1447362026, Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be5644fdea+\\' )
+ );
+ $this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be5644fdea-\\' ) );
+ $this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be+\\' ) );
+
+ $this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9x76c224cfb400d43be5644fdea+\\' ) );
+ }
+
+ public function testMatch() {
+ $token = TestingAccessWrapper::newFromObject( new Token( 'sekret', 'salty', false ) );
+
+ $test = $token->toStringAtTimestamp( time() - 10 );
+ $this->assertTrue( $token->match( $test ) );
+ $this->assertTrue( $token->match( $test, 12 ) );
+ $this->assertFalse( $token->match( $test, 8 ) );
+
+ $this->assertFalse( $token->match( 'ee2f7a2488dea9176c224cfb400d43be5644fdea-\\' ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/session/UserInfoTest.php b/www/wiki/tests/phpunit/includes/session/UserInfoTest.php
new file mode 100644
index 00000000..4d79a956
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/session/UserInfoTest.php
@@ -0,0 +1,186 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\UserInfo
+ */
+class UserInfoTest extends MediaWikiTestCase {
+
+ public function testNewAnonymous() {
+ $userinfo = UserInfo::newAnonymous();
+
+ $this->assertTrue( $userinfo->isAnon() );
+ $this->assertTrue( $userinfo->isVerified() );
+ $this->assertSame( 0, $userinfo->getId() );
+ $this->assertSame( null, $userinfo->getName() );
+ $this->assertSame( '', $userinfo->getToken() );
+ $this->assertNotNull( $userinfo->getUser() );
+ $this->assertSame( $userinfo, $userinfo->verified() );
+ $this->assertSame( '<anon>', (string)$userinfo );
+ }
+
+ public function testNewFromId() {
+ $id = wfGetDB( DB_MASTER )->selectField( 'user', 'MAX(user_id)' ) + 1;
+ try {
+ UserInfo::newFromId( $id );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid ID', $ex->getMessage() );
+ }
+
+ $user = User::newFromName( 'UTSysop' );
+ $userinfo = UserInfo::newFromId( $user->getId() );
+ $this->assertFalse( $userinfo->isAnon() );
+ $this->assertFalse( $userinfo->isVerified() );
+ $this->assertSame( $user->getId(), $userinfo->getId() );
+ $this->assertSame( $user->getName(), $userinfo->getName() );
+ $this->assertSame( $user->getToken( true ), $userinfo->getToken() );
+ $this->assertInstanceOf( User::class, $userinfo->getUser() );
+ $userinfo2 = $userinfo->verified();
+ $this->assertNotSame( $userinfo2, $userinfo );
+ $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+ $this->assertFalse( $userinfo2->isAnon() );
+ $this->assertTrue( $userinfo2->isVerified() );
+ $this->assertSame( $user->getId(), $userinfo2->getId() );
+ $this->assertSame( $user->getName(), $userinfo2->getName() );
+ $this->assertSame( $user->getToken( true ), $userinfo2->getToken() );
+ $this->assertInstanceOf( User::class, $userinfo2->getUser() );
+ $this->assertSame( $userinfo2, $userinfo2->verified() );
+ $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+ $userinfo = UserInfo::newFromId( $user->getId(), true );
+ $this->assertTrue( $userinfo->isVerified() );
+ $this->assertSame( $userinfo, $userinfo->verified() );
+ }
+
+ public function testNewFromName() {
+ try {
+ UserInfo::newFromName( '<bad name>' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid user name', $ex->getMessage() );
+ }
+
+ // User name that exists
+ $user = User::newFromName( 'UTSysop' );
+ $userinfo = UserInfo::newFromName( $user->getName() );
+ $this->assertFalse( $userinfo->isAnon() );
+ $this->assertFalse( $userinfo->isVerified() );
+ $this->assertSame( $user->getId(), $userinfo->getId() );
+ $this->assertSame( $user->getName(), $userinfo->getName() );
+ $this->assertSame( $user->getToken( true ), $userinfo->getToken() );
+ $this->assertInstanceOf( User::class, $userinfo->getUser() );
+ $userinfo2 = $userinfo->verified();
+ $this->assertNotSame( $userinfo2, $userinfo );
+ $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+ $this->assertFalse( $userinfo2->isAnon() );
+ $this->assertTrue( $userinfo2->isVerified() );
+ $this->assertSame( $user->getId(), $userinfo2->getId() );
+ $this->assertSame( $user->getName(), $userinfo2->getName() );
+ $this->assertSame( $user->getToken( true ), $userinfo2->getToken() );
+ $this->assertInstanceOf( User::class, $userinfo2->getUser() );
+ $this->assertSame( $userinfo2, $userinfo2->verified() );
+ $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+ $userinfo = UserInfo::newFromName( $user->getName(), true );
+ $this->assertTrue( $userinfo->isVerified() );
+ $this->assertSame( $userinfo, $userinfo->verified() );
+
+ // User name that does not exist should still be non-anon
+ $user = User::newFromName( 'DoesNotExist' );
+ $this->assertSame( 0, $user->getId(), 'sanity check' );
+ $userinfo = UserInfo::newFromName( $user->getName() );
+ $this->assertFalse( $userinfo->isAnon() );
+ $this->assertFalse( $userinfo->isVerified() );
+ $this->assertSame( $user->getId(), $userinfo->getId() );
+ $this->assertSame( $user->getName(), $userinfo->getName() );
+ $this->assertSame( '', $userinfo->getToken() );
+ $this->assertInstanceOf( User::class, $userinfo->getUser() );
+ $userinfo2 = $userinfo->verified();
+ $this->assertNotSame( $userinfo2, $userinfo );
+ $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+ $this->assertFalse( $userinfo2->isAnon() );
+ $this->assertTrue( $userinfo2->isVerified() );
+ $this->assertSame( $user->getId(), $userinfo2->getId() );
+ $this->assertSame( $user->getName(), $userinfo2->getName() );
+ $this->assertSame( '', $userinfo2->getToken() );
+ $this->assertInstanceOf( User::class, $userinfo2->getUser() );
+ $this->assertSame( $userinfo2, $userinfo2->verified() );
+ $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+ $userinfo = UserInfo::newFromName( $user->getName(), true );
+ $this->assertTrue( $userinfo->isVerified() );
+ $this->assertSame( $userinfo, $userinfo->verified() );
+ }
+
+ public function testNewFromUser() {
+ // User that exists
+ $user = User::newFromName( 'UTSysop' );
+ $userinfo = UserInfo::newFromUser( $user );
+ $this->assertFalse( $userinfo->isAnon() );
+ $this->assertFalse( $userinfo->isVerified() );
+ $this->assertSame( $user->getId(), $userinfo->getId() );
+ $this->assertSame( $user->getName(), $userinfo->getName() );
+ $this->assertSame( $user->getToken( true ), $userinfo->getToken() );
+ $this->assertSame( $user, $userinfo->getUser() );
+ $userinfo2 = $userinfo->verified();
+ $this->assertNotSame( $userinfo2, $userinfo );
+ $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+ $this->assertFalse( $userinfo2->isAnon() );
+ $this->assertTrue( $userinfo2->isVerified() );
+ $this->assertSame( $user->getId(), $userinfo2->getId() );
+ $this->assertSame( $user->getName(), $userinfo2->getName() );
+ $this->assertSame( $user->getToken( true ), $userinfo2->getToken() );
+ $this->assertSame( $user, $userinfo2->getUser() );
+ $this->assertSame( $userinfo2, $userinfo2->verified() );
+ $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+ $userinfo = UserInfo::newFromUser( $user, true );
+ $this->assertTrue( $userinfo->isVerified() );
+ $this->assertSame( $userinfo, $userinfo->verified() );
+
+ // User name that does not exist should still be non-anon
+ $user = User::newFromName( 'DoesNotExist' );
+ $this->assertSame( 0, $user->getId(), 'sanity check' );
+ $userinfo = UserInfo::newFromUser( $user );
+ $this->assertFalse( $userinfo->isAnon() );
+ $this->assertFalse( $userinfo->isVerified() );
+ $this->assertSame( $user->getId(), $userinfo->getId() );
+ $this->assertSame( $user->getName(), $userinfo->getName() );
+ $this->assertSame( '', $userinfo->getToken() );
+ $this->assertSame( $user, $userinfo->getUser() );
+ $userinfo2 = $userinfo->verified();
+ $this->assertNotSame( $userinfo2, $userinfo );
+ $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+ $this->assertFalse( $userinfo2->isAnon() );
+ $this->assertTrue( $userinfo2->isVerified() );
+ $this->assertSame( $user->getId(), $userinfo2->getId() );
+ $this->assertSame( $user->getName(), $userinfo2->getName() );
+ $this->assertSame( '', $userinfo2->getToken() );
+ $this->assertSame( $user, $userinfo2->getUser() );
+ $this->assertSame( $userinfo2, $userinfo2->verified() );
+ $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+ $userinfo = UserInfo::newFromUser( $user, true );
+ $this->assertTrue( $userinfo->isVerified() );
+ $this->assertSame( $userinfo, $userinfo->verified() );
+
+ // Anonymous user gives anon
+ $userinfo = UserInfo::newFromUser( new User, false );
+ $this->assertTrue( $userinfo->isVerified() );
+ $this->assertSame( 0, $userinfo->getId() );
+ $this->assertSame( null, $userinfo->getName() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/shell/CommandFactoryTest.php b/www/wiki/tests/phpunit/includes/shell/CommandFactoryTest.php
new file mode 100644
index 00000000..b031431a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/shell/CommandFactoryTest.php
@@ -0,0 +1,50 @@
+<?php
+
+use MediaWiki\Shell\Command;
+use MediaWiki\Shell\CommandFactory;
+use MediaWiki\Shell\FirejailCommand;
+use Psr\Log\NullLogger;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Shell
+ */
+class CommandFactoryTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers MediaWiki\Shell\CommandFactory::create
+ */
+ public function testCreate() {
+ $logger = new NullLogger();
+ $cgroup = '/sys/fs/cgroup/memory/mygroup';
+ $limits = [
+ 'filesize' => 1000,
+ 'memory' => 1000,
+ 'time' => 30,
+ 'walltime' => 40,
+ ];
+
+ $factory = new CommandFactory( $limits, $cgroup, false );
+ $factory->setLogger( $logger );
+ $factory->logStderr();
+ $command = $factory->create();
+ $this->assertInstanceOf( Command::class, $command );
+
+ $wrapper = TestingAccessWrapper::newFromObject( $command );
+ $this->assertSame( $logger, $wrapper->logger );
+ $this->assertSame( $cgroup, $wrapper->cgroup );
+ $this->assertSame( $limits, $wrapper->limits );
+ $this->assertTrue( $wrapper->doLogStderr );
+ }
+
+ /**
+ * @covers MediaWiki\Shell\CommandFactory::create
+ */
+ public function testFirejailCreate() {
+ $factory = new CommandFactory( [], false, 'firejail' );
+ $factory->setLogger( new NullLogger() );
+ $this->assertInstanceOf( FirejailCommand::class, $factory->create() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/shell/CommandTest.php b/www/wiki/tests/phpunit/includes/shell/CommandTest.php
new file mode 100644
index 00000000..2e031638
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/shell/CommandTest.php
@@ -0,0 +1,181 @@
+<?php
+
+use MediaWiki\Shell\Command;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers \MediaWiki\Shell\Command
+ * @group Shell
+ */
+class CommandTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ private function requirePosix() {
+ if ( wfIsWindows() ) {
+ $this->markTestSkipped( 'This test requires a POSIX environment.' );
+ }
+ }
+
+ /**
+ * @dataProvider provideExecute
+ */
+ public function testExecute( $commandInput, $expectedExitCode, $expectedOutput ) {
+ $this->requirePosix();
+
+ $command = new Command();
+ $result = $command
+ ->params( $commandInput )
+ ->execute();
+
+ $this->assertSame( $expectedExitCode, $result->getExitCode() );
+ $this->assertSame( $expectedOutput, $result->getStdout() );
+ }
+
+ public function provideExecute() {
+ return [
+ 'success status' => [ 'true', 0, '' ],
+ 'failure status' => [ 'false', 1, '' ],
+ 'output' => [ [ 'echo', '-n', 'x', '>', 'y' ], 0, 'x > y' ],
+ ];
+ }
+
+ public function testEnvironment() {
+ $this->requirePosix();
+
+ $command = new Command();
+ $result = $command
+ ->params( [ 'printenv', 'FOO' ] )
+ ->environment( [ 'FOO' => 'bar' ] )
+ ->execute();
+ $this->assertSame( "bar\n", $result->getStdout() );
+ }
+
+ public function testStdout() {
+ $this->requirePosix();
+
+ $command = new Command();
+
+ $result = $command
+ ->params( 'bash', '-c', 'echo ThisIsStderr 1>&2' )
+ ->execute();
+
+ $this->assertNotContains( 'ThisIsStderr', $result->getStdout() );
+ $this->assertEquals( "ThisIsStderr\n", $result->getStderr() );
+ }
+
+ public function testStdoutRedirection() {
+ $this->requirePosix();
+
+ $command = new Command();
+
+ $result = $command
+ ->params( 'bash', '-c', 'echo ThisIsStderr 1>&2' )
+ ->includeStderr( true )
+ ->execute();
+
+ $this->assertEquals( "ThisIsStderr\n", $result->getStdout() );
+ $this->assertNull( $result->getStderr() );
+ }
+
+ public function testOutput() {
+ global $IP;
+
+ $this->requirePosix();
+ chdir( $IP );
+
+ $command = new Command();
+ $result = $command
+ ->params( [ 'ls', 'index.php' ] )
+ ->execute();
+ $this->assertRegExp( '/^index.php$/m', $result->getStdout() );
+ $this->assertSame( null, $result->getStderr() );
+
+ $command = new Command();
+ $result = $command
+ ->params( [ 'ls', 'index.php', 'no-such-file' ] )
+ ->includeStderr()
+ ->execute();
+ $this->assertRegExp( '/^index.php$/m', $result->getStdout() );
+ $this->assertRegExp( '/^.+no-such-file.*$/m', $result->getStdout() );
+ $this->assertSame( null, $result->getStderr() );
+
+ $command = new Command();
+ $result = $command
+ ->params( [ 'ls', 'index.php', 'no-such-file' ] )
+ ->execute();
+ $this->assertRegExp( '/^index.php$/m', $result->getStdout() );
+ $this->assertRegExp( '/^.+no-such-file.*$/m', $result->getStderr() );
+ }
+
+ /**
+ * Test that null values are skipped by params() and unsafeParams()
+ */
+ public function testNullsAreSkipped() {
+ $command = TestingAccessWrapper::newFromObject( new Command );
+ $command->params( 'echo', 'a', null, 'b' );
+ $command->unsafeParams( 'c', null, 'd' );
+ $this->assertEquals( "'echo' 'a' 'b' c d", $command->command );
+ }
+
+ public function testT69870() {
+ $commandLine = wfIsWindows()
+ // 333 = 331 + CRLF
+ ? ( 'for /l %i in (1, 1, 1001) do @echo ' . str_repeat( '*', 331 ) )
+ : 'printf "%-333333s" "*"';
+
+ // Test several times because it involves a race condition that may randomly succeed or fail
+ for ( $i = 0; $i < 10; $i++ ) {
+ $command = new Command();
+ $output = $command->unsafeParams( $commandLine )
+ ->execute()
+ ->getStdout();
+ $this->assertEquals( 333333, strlen( $output ) );
+ }
+ }
+
+ public function testLogStderr() {
+ $this->requirePosix();
+
+ $logger = new TestLogger( true, function ( $message, $level, $context ) {
+ return $level === Psr\Log\LogLevel::ERROR ? '1' : null;
+ }, true );
+ $command = new Command();
+ $command->setLogger( $logger );
+ $command->params( 'bash', '-c', 'echo ThisIsStderr 1>&2' );
+ $command->execute();
+ $this->assertEmpty( $logger->getBuffer() );
+
+ $command = new Command();
+ $command->setLogger( $logger );
+ $command->logStderr();
+ $command->params( 'bash', '-c', 'echo ThisIsStderr 1>&2' );
+ $command->execute();
+ $this->assertSame( 1, count( $logger->getBuffer() ) );
+ $this->assertSame( trim( $logger->getBuffer()[0][2]['error'] ), 'ThisIsStderr' );
+ }
+
+ public function testInput() {
+ $this->requirePosix();
+
+ $command = new Command();
+ $command->params( 'cat' );
+ $command->input( 'abc' );
+ $result = $command->execute();
+ $this->assertSame( 'abc', $result->getStdout() );
+
+ // now try it with something that does not fit into a single block
+ $command = new Command();
+ $command->params( 'cat' );
+ $command->input( str_repeat( '!', 1000000 ) );
+ $result = $command->execute();
+ $this->assertSame( 1000000, strlen( $result->getStdout() ) );
+
+ // And try it with empty input
+ $command = new Command();
+ $command->params( 'cat' );
+ $command->input( '' );
+ $result = $command->execute();
+ $this->assertSame( '', $result->getStdout() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/shell/FirejailCommandTest.php b/www/wiki/tests/phpunit/includes/shell/FirejailCommandTest.php
new file mode 100644
index 00000000..681c3dcd
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/shell/FirejailCommandTest.php
@@ -0,0 +1,85 @@
+<?php
+
+/**
+ * Copyright (C) 2017 Kunal Mehta <legoktm@member.fsf.org>
+ *
+ * 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.
+ *
+ */
+
+use MediaWiki\Shell\FirejailCommand;
+use MediaWiki\Shell\Shell;
+use Wikimedia\TestingAccessWrapper;
+
+class FirejailCommandTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function provideBuildFinalCommand() {
+ global $IP;
+ // phpcs:ignore Generic.Files.LineLength
+ $env = "'MW_INCLUDE_STDERR=;MW_CPU_LIMIT=180; MW_CGROUP='\'''\''; MW_MEM_LIMIT=307200; MW_FILE_SIZE_LIMIT=102400; MW_WALL_CLOCK_LIMIT=180; MW_USE_LOG_PIPE=yes'";
+ $limit = "/bin/bash '$IP/includes/shell/limit.sh'";
+ $profile = "--profile=$IP/includes/shell/firejail.profile";
+ $blacklist = '--blacklist=' . realpath( MW_CONFIG_FILE );
+ $default = "$blacklist --noroot --seccomp --private-dev";
+ return [
+ [
+ 'No restrictions',
+ 'ls', 0, "$limit ''\''ls'\''' $env"
+ ],
+ [
+ 'default restriction',
+ 'ls', Shell::RESTRICT_DEFAULT,
+ "$limit 'firejail --quiet $profile $default -- '\''ls'\''' $env"
+ ],
+ [
+ 'no network',
+ 'ls', Shell::NO_NETWORK,
+ "$limit 'firejail --quiet $profile --net=none -- '\''ls'\''' $env"
+ ],
+ [
+ 'default restriction & no network',
+ 'ls', Shell::RESTRICT_DEFAULT | Shell::NO_NETWORK,
+ "$limit 'firejail --quiet $profile $default --net=none -- '\''ls'\''' $env"
+ ],
+ [
+ 'seccomp',
+ 'ls', Shell::SECCOMP,
+ "$limit 'firejail --quiet $profile --seccomp -- '\''ls'\''' $env"
+ ],
+ [
+ 'seccomp & no execve',
+ 'ls', Shell::SECCOMP | Shell::NO_EXECVE,
+ "$limit 'firejail --quiet $profile --shell=none --seccomp=execve -- '\''ls'\''' $env"
+ ],
+ ];
+ }
+
+ /**
+ * @covers \MediaWiki\Shell\FirejailCommand::buildFinalCommand()
+ * @dataProvider provideBuildFinalCommand
+ */
+ public function testBuildFinalCommand( $desc, $params, $flags, $expected ) {
+ $command = new FirejailCommand( 'firejail' );
+ $command
+ ->params( $params )
+ ->restrict( $flags );
+ $wrapper = TestingAccessWrapper::newFromObject( $command );
+ $output = $wrapper->buildFinalCommand( $wrapper->command );
+ $this->assertEquals( $expected, $output[0], $desc );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/shell/ShellTest.php b/www/wiki/tests/phpunit/includes/shell/ShellTest.php
new file mode 100644
index 00000000..bf46f44b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/shell/ShellTest.php
@@ -0,0 +1,105 @@
+<?php
+
+use MediaWiki\Shell\Command;
+use MediaWiki\Shell\Shell;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers \MediaWiki\Shell\Shell
+ * @group Shell
+ */
+class ShellTest extends MediaWikiTestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function testIsDisabled() {
+ $this->assertInternalType( 'bool', Shell::isDisabled() ); // sanity
+ }
+
+ /**
+ * @dataProvider provideEscape
+ */
+ public function testEscape( $args, $expected ) {
+ if ( wfIsWindows() ) {
+ $this->markTestSkipped( 'This test requires a POSIX environment.' );
+ }
+ $this->assertSame( $expected, call_user_func_array( [ Shell::class, 'escape' ], $args ) );
+ }
+
+ public function provideEscape() {
+ return [
+ 'simple' => [ [ 'true' ], "'true'" ],
+ 'with args' => [ [ 'convert', '-font', 'font name' ], "'convert' '-font' 'font name'" ],
+ 'array' => [ [ [ 'convert', '-font', 'font name' ] ], "'convert' '-font' 'font name'" ],
+ 'skip nulls' => [ [ 'ls', null ], "'ls'" ],
+ ];
+ }
+
+ /**
+ * @covers \MediaWiki\Shell\Shell::makeScriptCommand
+ * @dataProvider provideMakeScriptCommand
+ *
+ * @param string $expected
+ * @param string $script
+ * @param string[] $parameters
+ * @param string[] $options
+ * @param callable|null $hook
+ */
+ public function testMakeScriptCommand( $expected,
+ $script,
+ $parameters,
+ $options = [],
+ $hook = null
+ ) {
+ // Running tests under Vagrant involves MWMultiVersion that uses the below hook
+ $this->setMwGlobals( 'wgHooks', [] );
+
+ if ( $hook ) {
+ $this->setTemporaryHook( 'wfShellWikiCmd', $hook );
+ }
+
+ $command = Shell::makeScriptCommand( $script, $parameters, $options );
+ $command->params( 'safe' )
+ ->unsafeParams( 'unsafe' );
+
+ $this->assertType( Command::class, $command );
+
+ $wrapper = TestingAccessWrapper::newFromObject( $command );
+ $this->assertEquals( $expected, $wrapper->command );
+ $this->assertEquals( 0, $wrapper->restrictions & Shell::NO_LOCALSETTINGS );
+ }
+
+ public function provideMakeScriptCommand() {
+ global $wgPhpCli;
+
+ return [
+ [
+ "'$wgPhpCli' 'maintenance/foobar.php' 'bar'\\''\"baz' 'safe' unsafe",
+ 'maintenance/foobar.php',
+ [ 'bar\'"baz' ],
+ ],
+ [
+ "'$wgPhpCli' 'changed.php' '--wiki=somewiki' 'bar'\\''\"baz' 'safe' unsafe",
+ 'maintenance/foobar.php',
+ [ 'bar\'"baz' ],
+ [],
+ function ( &$script, array &$parameters ) {
+ $script = 'changed.php';
+ array_unshift( $parameters, '--wiki=somewiki' );
+ }
+ ],
+ [
+ "'/bin/perl' 'maintenance/foobar.php' 'bar'\\''\"baz' 'safe' unsafe",
+ 'maintenance/foobar.php',
+ [ 'bar\'"baz' ],
+ [ 'php' => '/bin/perl' ],
+ ],
+ [
+ "'$wgPhpCli' 'foobinize' 'maintenance/foobar.php' 'bar'\\''\"baz' 'safe' unsafe",
+ 'maintenance/foobar.php',
+ [ 'bar\'"baz' ],
+ [ 'wrapper' => 'foobinize' ],
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/site/CachingSiteStoreTest.php b/www/wiki/tests/phpunit/includes/site/CachingSiteStoreTest.php
new file mode 100644
index 00000000..0fdcf6da
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/CachingSiteStoreTest.php
@@ -0,0 +1,163 @@
+<?php
+
+/**
+ * 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
+ * @since 1.25
+ *
+ * @ingroup Site
+ * @ingroup Test
+ *
+ * @group Site
+ * @group Database
+ *
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class CachingSiteStoreTest extends MediaWikiTestCase {
+
+ /**
+ * @covers CachingSiteStore::getSites
+ */
+ public function testGetSites() {
+ $testSites = TestSites::getSites();
+
+ $store = new CachingSiteStore(
+ $this->getHashSiteStore( $testSites ),
+ wfGetMainCache()
+ );
+
+ $sites = $store->getSites();
+
+ $this->assertInstanceOf( SiteList::class, $sites );
+
+ /**
+ * @var Site $site
+ */
+ foreach ( $sites as $site ) {
+ $this->assertInstanceOf( Site::class, $site );
+ }
+
+ foreach ( $testSites as $site ) {
+ if ( $site->getGlobalId() !== null ) {
+ $this->assertTrue( $sites->hasSite( $site->getGlobalId() ) );
+ }
+ }
+ }
+
+ /**
+ * @covers CachingSiteStore::saveSites
+ */
+ public function testSaveSites() {
+ $store = new CachingSiteStore( new HashSiteStore(), wfGetMainCache() );
+
+ $sites = [];
+
+ $site = new Site();
+ $site->setGlobalId( 'ertrywuutr' );
+ $site->setLanguageCode( 'en' );
+ $sites[] = $site;
+
+ $site = new MediaWikiSite();
+ $site->setGlobalId( 'sdfhxujgkfpth' );
+ $site->setLanguageCode( 'nl' );
+ $sites[] = $site;
+
+ $this->assertTrue( $store->saveSites( $sites ) );
+
+ $site = $store->getSite( 'ertrywuutr' );
+ $this->assertInstanceOf( Site::class, $site );
+ $this->assertEquals( 'en', $site->getLanguageCode() );
+
+ $site = $store->getSite( 'sdfhxujgkfpth' );
+ $this->assertInstanceOf( Site::class, $site );
+ $this->assertEquals( 'nl', $site->getLanguageCode() );
+ }
+
+ /**
+ * @covers CachingSiteStore::reset
+ */
+ public function testReset() {
+ $dbSiteStore = $this->getMockBuilder( SiteStore::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $dbSiteStore->expects( $this->any() )
+ ->method( 'getSite' )
+ ->will( $this->returnValue( $this->getTestSite() ) );
+
+ $dbSiteStore->expects( $this->any() )
+ ->method( 'getSites' )
+ ->will( $this->returnCallback( function () {
+ $siteList = new SiteList();
+ $siteList->setSite( $this->getTestSite() );
+
+ return $siteList;
+ } ) );
+
+ $store = new CachingSiteStore( $dbSiteStore, wfGetMainCache() );
+
+ // initialize internal cache
+ $this->assertGreaterThan( 0, $store->getSites()->count(), 'count sites' );
+
+ $store->getSite( 'enwiki' )->setLanguageCode( 'en-ca' );
+
+ // sanity check: $store should have the new language code for 'enwiki'
+ $this->assertEquals( 'en-ca', $store->getSite( 'enwiki' )->getLanguageCode(), 'sanity check' );
+
+ // purge cache
+ $store->reset();
+
+ // the internal cache of $store should be updated, and now pulling
+ // the site from the 'fallback' DBSiteStore with the original language code.
+ $this->assertEquals( 'en', $store->getSite( 'enwiki' )->getLanguageCode(), 'reset' );
+ }
+
+ public function getTestSite() {
+ $enwiki = new MediaWikiSite();
+ $enwiki->setGlobalId( 'enwiki' );
+ $enwiki->setLanguageCode( 'en' );
+
+ return $enwiki;
+ }
+
+ /**
+ * @covers CachingSiteStore::clear
+ */
+ public function testClear() {
+ $store = new CachingSiteStore( new HashSiteStore(), wfGetMainCache() );
+ $this->assertTrue( $store->clear() );
+
+ $site = $store->getSite( 'enwiki' );
+ $this->assertNull( $site );
+
+ $sites = $store->getSites();
+ $this->assertEquals( 0, $sites->count() );
+ }
+
+ /**
+ * @param Site[] $sites
+ *
+ * @return SiteStore
+ */
+ private function getHashSiteStore( array $sites ) {
+ $siteStore = new HashSiteStore();
+ $siteStore->saveSites( $sites );
+
+ return $siteStore;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/site/DBSiteStoreTest.php b/www/wiki/tests/phpunit/includes/site/DBSiteStoreTest.php
new file mode 100644
index 00000000..7c16f6c5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/DBSiteStoreTest.php
@@ -0,0 +1,166 @@
+<?php
+
+/**
+ * Tests for the DBSiteStore class.
+ *
+ * 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
+ * @since 1.21
+ *
+ * @ingroup Site
+ * @ingroup Test
+ *
+ * @group Site
+ * @group Database
+ *
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class DBSiteStoreTest extends MediaWikiTestCase {
+
+ /**
+ * @return DBSiteStore
+ */
+ private function newDBSiteStore() {
+ // NOTE: Use the real DB load balancer for now. Eventually, the test framework should
+ // provide a LoadBalancer that is safe to use in unit tests.
+ return new DBSiteStore( wfGetLB() );
+ }
+
+ /**
+ * @covers DBSiteStore::getSites
+ */
+ public function testGetSites() {
+ $expectedSites = TestSites::getSites();
+ TestSites::insertIntoDb();
+
+ $store = $this->newDBSiteStore();
+
+ $sites = $store->getSites();
+
+ $this->assertInstanceOf( SiteList::class, $sites );
+
+ /**
+ * @var Site $site
+ */
+ foreach ( $sites as $site ) {
+ $this->assertInstanceOf( Site::class, $site );
+ }
+
+ foreach ( $expectedSites as $site ) {
+ if ( $site->getGlobalId() !== null ) {
+ $this->assertTrue( $sites->hasSite( $site->getGlobalId() ) );
+ }
+ }
+ }
+
+ /**
+ * @covers DBSiteStore::saveSites
+ */
+ public function testSaveSites() {
+ $store = $this->newDBSiteStore();
+
+ $sites = [];
+
+ $site = new Site();
+ $site->setGlobalId( 'ertrywuutr' );
+ $site->setLanguageCode( 'en' );
+ $sites[] = $site;
+
+ $site = new MediaWikiSite();
+ $site->setGlobalId( 'sdfhxujgkfpth' );
+ $site->setLanguageCode( 'nl' );
+ $sites[] = $site;
+
+ $this->assertTrue( $store->saveSites( $sites ) );
+
+ $site = $store->getSite( 'ertrywuutr' );
+ $this->assertInstanceOf( Site::class, $site );
+ $this->assertEquals( 'en', $site->getLanguageCode() );
+ $this->assertTrue( is_int( $site->getInternalId() ) );
+ $this->assertTrue( $site->getInternalId() >= 0 );
+
+ $site = $store->getSite( 'sdfhxujgkfpth' );
+ $this->assertInstanceOf( Site::class, $site );
+ $this->assertEquals( 'nl', $site->getLanguageCode() );
+ $this->assertTrue( is_int( $site->getInternalId() ) );
+ $this->assertTrue( $site->getInternalId() >= 0 );
+ }
+
+ /**
+ * @covers DBSiteStore::reset
+ */
+ public function testReset() {
+ $store1 = $this->newDBSiteStore();
+ $store2 = $this->newDBSiteStore();
+
+ // initialize internal cache
+ $this->assertGreaterThan( 0, $store1->getSites()->count() );
+ $this->assertGreaterThan( 0, $store2->getSites()->count() );
+
+ // Clear actual data. Will purge the external cache and reset the internal
+ // cache in $store1, but not the internal cache in store2.
+ $this->assertTrue( $store1->clear() );
+
+ // sanity check: $store2 should have a stale cache now
+ $this->assertNotNull( $store2->getSite( 'enwiki' ) );
+
+ // purge cache
+ $store2->reset();
+
+ // ...now the internal cache of $store2 should be updated and thus empty.
+ $site = $store2->getSite( 'enwiki' );
+ $this->assertNull( $site );
+ }
+
+ /**
+ * @covers DBSiteStore::clear
+ */
+ public function testClear() {
+ $store = $this->newDBSiteStore();
+ $this->assertTrue( $store->clear() );
+
+ $site = $store->getSite( 'enwiki' );
+ $this->assertNull( $site );
+
+ $sites = $store->getSites();
+ $this->assertEquals( 0, $sites->count() );
+ }
+
+ /**
+ * @covers DBSiteStore::getSites
+ */
+ public function testGetSitesDefaultOrder() {
+ $store = $this->newDBSiteStore();
+ $siteB = new Site();
+ $siteB->setGlobalId( 'B' );
+ $siteA = new Site();
+ $siteA->setGlobalId( 'A' );
+ $store->saveSites( [ $siteB, $siteA ] );
+
+ $sites = $store->getSites();
+ $siteIdentifiers = [];
+ /** @var Site $site */
+ foreach ( $sites as $site ) {
+ $siteIdentifiers[] = $site->getGlobalId();
+ }
+ $this->assertSame( [ 'A', 'B' ], $siteIdentifiers );
+
+ // Note: SiteList::getGlobalIdentifiers uses an other internal state. Iteration must be
+ // tested separately.
+ $this->assertSame( [ 'A', 'B' ], $sites->getGlobalIdentifiers() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/site/FileBasedSiteLookupTest.php b/www/wiki/tests/phpunit/includes/site/FileBasedSiteLookupTest.php
new file mode 100644
index 00000000..69e0e389
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/FileBasedSiteLookupTest.php
@@ -0,0 +1,103 @@
+<?php
+
+/**
+ * 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
+ * @since 1.25
+ *
+ * @ingroup Site
+ * @ingroup Test
+ *
+ * @covers FileBasedSiteLookup
+ * @group Site
+ *
+ * @author Katie Filbert < aude.wiki@gmail.com >
+ */
+class FileBasedSiteLookupTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected function setUp() {
+ $this->cacheFile = $this->getCacheFile();
+ }
+
+ protected function tearDown() {
+ unlink( $this->cacheFile );
+ }
+
+ public function testGetSites() {
+ $sites = $this->getSites();
+ $cacheBuilder = $this->newSitesCacheFileBuilder( $sites );
+ $cacheBuilder->build();
+
+ $cache = new FileBasedSiteLookup( $this->cacheFile );
+ $this->assertEquals( $sites, $cache->getSites() );
+ }
+
+ public function testGetSite() {
+ $sites = $this->getSites();
+ $cacheBuilder = $this->newSitesCacheFileBuilder( $sites );
+ $cacheBuilder->build();
+
+ $cache = new FileBasedSiteLookup( $this->cacheFile );
+
+ $this->assertEquals( $sites->getSite( 'enwiktionary' ), $cache->getSite( 'enwiktionary' ) );
+ }
+
+ private function newSitesCacheFileBuilder( SiteList $sites ) {
+ return new SitesCacheFileBuilder(
+ $this->getSiteLookup( $sites ),
+ $this->cacheFile
+ );
+ }
+
+ private function getSiteLookup( SiteList $sites ) {
+ $siteLookup = $this->getMockBuilder( SiteLookup::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $siteLookup->expects( $this->any() )
+ ->method( 'getSites' )
+ ->will( $this->returnValue( $sites ) );
+
+ return $siteLookup;
+ }
+
+ private function getSites() {
+ $sites = [];
+
+ $site = new Site();
+ $site->setGlobalId( 'foobar' );
+ $sites[] = $site;
+
+ $site = new MediaWikiSite();
+ $site->setGlobalId( 'enwiktionary' );
+ $site->setGroup( 'wiktionary' );
+ $site->setLanguageCode( 'en' );
+ $site->addNavigationId( 'enwiktionary' );
+ $site->setPath( MediaWikiSite::PATH_PAGE, "https://en.wiktionary.org/wiki/$1" );
+ $site->setPath( MediaWikiSite::PATH_FILE, "https://en.wiktionary.org/w/$1" );
+ $sites[] = $site;
+
+ return new SiteList( $sites );
+ }
+
+ private function getCacheFile() {
+ return tempnam( sys_get_temp_dir(), 'mw-test-sitelist' );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/site/HashSiteStoreTest.php b/www/wiki/tests/phpunit/includes/site/HashSiteStoreTest.php
new file mode 100644
index 00000000..6269fd39
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/HashSiteStoreTest.php
@@ -0,0 +1,105 @@
+<?php
+
+/**
+ * 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
+ * @since 1.25
+ *
+ * @ingroup Site
+ * @group Site
+ *
+ * @author Katie Filbert < aude.wiki@gmail.com >
+ */
+class HashSiteStoreTest extends MediaWikiTestCase {
+
+ /**
+ * @covers HashSiteStore::getSites
+ */
+ public function testGetSites() {
+ $expectedSites = [];
+
+ foreach ( TestSites::getSites() as $testSite ) {
+ $siteId = $testSite->getGlobalId();
+ $expectedSites[$siteId] = $testSite;
+ }
+
+ $siteStore = new HashSiteStore( $expectedSites );
+
+ $this->assertEquals( new SiteList( $expectedSites ), $siteStore->getSites() );
+ }
+
+ /**
+ * @covers HashSiteStore::saveSite
+ * @covers HashSiteStore::getSite
+ */
+ public function testSaveSite() {
+ $store = new HashSiteStore();
+
+ $site = new Site();
+ $site->setGlobalId( 'dewiki' );
+
+ $this->assertCount( 0, $store->getSites(), '0 sites in store' );
+
+ $store->saveSite( $site );
+
+ $this->assertCount( 1, $store->getSites(), 'Store has 1 sites' );
+ $this->assertEquals( $site, $store->getSite( 'dewiki' ), 'Store has dewiki' );
+ }
+
+ /**
+ * @covers HashSiteStore::saveSites
+ */
+ public function testSaveSites() {
+ $store = new HashSiteStore();
+
+ $sites = [];
+
+ $site = new Site();
+ $site->setGlobalId( 'enwiki' );
+ $site->setLanguageCode( 'en' );
+ $sites[] = $site;
+
+ $site = new MediaWikiSite();
+ $site->setGlobalId( 'eswiki' );
+ $site->setLanguageCode( 'es' );
+ $sites[] = $site;
+
+ $this->assertCount( 0, $store->getSites(), '0 sites in store' );
+
+ $store->saveSites( $sites );
+
+ $this->assertCount( 2, $store->getSites(), 'Store has 2 sites' );
+ $this->assertTrue( $store->getSites()->hasSite( 'enwiki' ), 'Store has enwiki' );
+ $this->assertTrue( $store->getSites()->hasSite( 'eswiki' ), 'Store has eswiki' );
+ }
+
+ /**
+ * @covers HashSiteStore::clear
+ */
+ public function testClear() {
+ $store = new HashSiteStore();
+
+ $site = new Site();
+ $site->setGlobalId( 'arwiki' );
+ $store->saveSite( $site );
+
+ $this->assertCount( 1, $store->getSites(), '1 site in store' );
+
+ $store->clear();
+ $this->assertCount( 0, $store->getSites(), '0 sites in store' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php b/www/wiki/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php
new file mode 100644
index 00000000..2ac27146
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php
@@ -0,0 +1,117 @@
+<?php
+
+use MediaWiki\Site\MediaWikiPageNameNormalizer;
+
+/**
+ * @covers MediaWiki\Site\MediaWikiPageNameNormalizer
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.27
+ *
+ * @group Site
+ * @group medium
+ *
+ * @author Marius Hoch
+ */
+class MediaWikiPageNameNormalizerTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @dataProvider normalizePageTitleProvider
+ */
+ public function testNormalizePageTitle( $expected, $pageName, $getResponse ) {
+ MediaWikiPageNameNormalizerTestMockHttp::$response = $getResponse;
+
+ $normalizer = new MediaWikiPageNameNormalizer(
+ new MediaWikiPageNameNormalizerTestMockHttp()
+ );
+
+ $this->assertSame(
+ $expected,
+ $normalizer->normalizePageName( $pageName, 'https://www.wikidata.org/w/api.php' )
+ );
+ }
+
+ public function normalizePageTitleProvider() {
+ // Response are taken from wikidata and kkwiki using the following API request
+ // api.php?action=query&prop=info&redirects=1&converttitles=1&format=json&titles=…
+ return [
+ 'universe (Q1)' => [
+ 'Q1',
+ 'Q1',
+ '{"batchcomplete":"","query":{"pages":{"129":{"pageid":129,"ns":0,'
+ . '"title":"Q1","contentmodel":"wikibase-item","pagelanguage":"en",'
+ . '"pagelanguagehtmlcode":"en","pagelanguagedir":"ltr",'
+ . '"touched":"2016-06-23T05:11:21Z","lastrevid":350004448,"length":58001}}}}'
+ ],
+ 'Q404 redirects to Q395' => [
+ 'Q395',
+ 'Q404',
+ '{"batchcomplete":"","query":{"redirects":[{"from":"Q404","to":"Q395"}],"pages"'
+ . ':{"601":{"pageid":601,"ns":0,"title":"Q395","contentmodel":"wikibase-item",'
+ . '"pagelanguage":"en","pagelanguagehtmlcode":"en","pagelanguagedir":"ltr",'
+ . '"touched":"2016-06-23T08:00:20Z","lastrevid":350021914,"length":60108}}}}'
+ ],
+ 'D converted to Д (Latin to Cyrillic) (taken from kkwiki)' => [
+ 'Д',
+ 'D',
+ '{"batchcomplete":"","query":{"converted":[{"from":"D","to":"\u0414"}],'
+ . '"pages":{"510541":{"pageid":510541,"ns":0,"title":"\u0414",'
+ . '"contentmodel":"wikitext","pagelanguage":"kk","pagelanguagehtmlcode":"kk",'
+ . '"pagelanguagedir":"ltr","touched":"2015-11-22T09:16:18Z",'
+ . '"lastrevid":2373618,"length":3501}}}}'
+ ],
+ 'there is no Q0' => [
+ false,
+ 'Q0',
+ '{"batchcomplete":"","query":{"pages":{"-1":{"ns":0,"title":"Q0",'
+ . '"missing":"","contentmodel":"wikibase-item","pagelanguage":"en",'
+ . '"pagelanguagehtmlcode":"en","pagelanguagedir":"ltr"}}}}'
+ ],
+ 'invalid title' => [
+ false,
+ '{{',
+ '{"batchcomplete":"","query":{"pages":{"-1":{"title":"{{",'
+ . '"invalidreason":"The requested page title contains invalid '
+ . 'characters: \"{\".","invalid":""}}}}'
+ ],
+ 'error on get' => [ false, 'ABC', false ]
+ ];
+ }
+
+}
+
+/**
+ * @private
+ * @see Http
+ */
+class MediaWikiPageNameNormalizerTestMockHttp extends Http {
+
+ /**
+ * @var mixed
+ */
+ public static $response;
+
+ public static function get( $url, $options = [], $caller = __METHOD__ ) {
+ PHPUnit_Framework_Assert::assertInternalType( 'string', $url );
+ PHPUnit_Framework_Assert::assertInternalType( 'array', $options );
+ PHPUnit_Framework_Assert::assertInternalType( 'string', $caller );
+
+ return self::$response;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/site/MediaWikiSiteTest.php b/www/wiki/tests/phpunit/includes/site/MediaWikiSiteTest.php
new file mode 100644
index 00000000..b3679799
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/MediaWikiSiteTest.php
@@ -0,0 +1,108 @@
+<?php
+
+/**
+ * Tests for the MediaWikiSite class.
+ *
+ * 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
+ * @since 1.21
+ *
+ * @ingroup Site
+ * @ingroup Test
+ *
+ * @group Site
+ *
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class MediaWikiSiteTest extends SiteTest {
+
+ public function testNormalizePageTitle() {
+ $this->setMwGlobals( [
+ 'wgCapitalLinks' => true,
+ ] );
+
+ $site = new MediaWikiSite();
+ $site->setGlobalId( 'enwiki' );
+
+ // NOTE: this does not actually call out to the enwiki site to perform the normalization,
+ // but uses a local Title object to do so. This is hardcoded on SiteLink::normalizePageTitle
+ // for the case that MW_PHPUNIT_TEST is set.
+ $this->assertEquals( 'Foo', $site->normalizePageName( ' foo ' ) );
+ }
+
+ public function fileUrlProvider() {
+ return [
+ // url, filepath, path arg, expected
+ [ 'https://en.wikipedia.org', '/w/$1', 'api.php', 'https://en.wikipedia.org/w/api.php' ],
+ [ 'https://en.wikipedia.org', '/w/', 'api.php', 'https://en.wikipedia.org/w/' ],
+ [
+ 'https://en.wikipedia.org',
+ '/foo/page.php?name=$1',
+ 'api.php',
+ 'https://en.wikipedia.org/foo/page.php?name=api.php'
+ ],
+ [
+ 'https://en.wikipedia.org',
+ '/w/$1',
+ '',
+ 'https://en.wikipedia.org/w/'
+ ],
+ [
+ 'https://en.wikipedia.org',
+ '/w/$1',
+ 'foo/bar/api.php',
+ 'https://en.wikipedia.org/w/foo/bar/api.php'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider fileUrlProvider
+ * @covers MediaWikiSite::getFileUrl
+ */
+ public function testGetFileUrl( $url, $filePath, $pathArgument, $expected ) {
+ $site = new MediaWikiSite();
+ $site->setFilePath( $url . $filePath );
+
+ $this->assertEquals( $expected, $site->getFileUrl( $pathArgument ) );
+ }
+
+ public static function provideGetPageUrl() {
+ return [
+ // path, page, expected substring
+ [ 'http://acme.test/wiki/$1', 'Berlin', '/wiki/Berlin' ],
+ [ 'http://acme.test/wiki/', 'Berlin', '/wiki/' ],
+ [ 'http://acme.test/w/index.php?title=$1', 'Berlin', '/w/index.php?title=Berlin' ],
+ [ 'http://acme.test/wiki/$1', '', '/wiki/' ],
+ [ 'http://acme.test/wiki/$1', 'Berlin/sub page', '/wiki/Berlin/sub_page' ],
+ [ 'http://acme.test/wiki/$1', 'Cork (city) ', '/Cork_(city)' ],
+ [ 'http://acme.test/wiki/$1', 'M&M', '/wiki/M%26M' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetPageUrl
+ * @covers MediaWikiSite::getPageUrl
+ */
+ public function testGetPageUrl( $path, $page, $expected ) {
+ $site = new MediaWikiSite();
+ $site->setLinkPath( $path );
+
+ $this->assertContains( $path, $site->getPageUrl() );
+ $this->assertContains( $expected, $site->getPageUrl( $page ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/site/SiteExporterTest.php b/www/wiki/tests/phpunit/includes/site/SiteExporterTest.php
new file mode 100644
index 00000000..db900da9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/SiteExporterTest.php
@@ -0,0 +1,150 @@
+<?php
+
+/**
+ * Tests for the SiteExporter class.
+ *
+ * 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 Site
+ * @ingroup Test
+ *
+ * @group Site
+ *
+ * @covers SiteExporter
+ *
+ * @author Daniel Kinzler
+ */
+class SiteExporterTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ public function testConstructor_InvalidArgument() {
+ $this->setExpectedException( InvalidArgumentException::class );
+
+ new SiteExporter( 'Foo' );
+ }
+
+ public function testExportSites() {
+ $foo = Site::newForType( Site::TYPE_UNKNOWN );
+ $foo->setGlobalId( 'Foo' );
+
+ $acme = Site::newForType( Site::TYPE_UNKNOWN );
+ $acme->setGlobalId( 'acme.com' );
+ $acme->setGroup( 'Test' );
+ $acme->addLocalId( Site::ID_INTERWIKI, 'acme' );
+ $acme->setPath( Site::PATH_LINK, 'http://acme.com/' );
+
+ $tmp = tmpfile();
+ $exporter = new SiteExporter( $tmp );
+
+ $exporter->exportSites( [ $foo, $acme ] );
+
+ fseek( $tmp, 0 );
+ $xml = fread( $tmp, 16 * 1024 );
+
+ $this->assertContains( '<sites ', $xml );
+ $this->assertContains( '<site>', $xml );
+ $this->assertContains( '<globalid>Foo</globalid>', $xml );
+ $this->assertContains( '</site>', $xml );
+ $this->assertContains( '<globalid>acme.com</globalid>', $xml );
+ $this->assertContains( '<group>Test</group>', $xml );
+ $this->assertContains( '<localid type="interwiki">acme</localid>', $xml );
+ $this->assertContains( '<path type="link">http://acme.com/</path>', $xml );
+ $this->assertContains( '</sites>', $xml );
+
+ // NOTE: HHVM (at least on wmf Jenkins) doesn't like file URLs.
+ $xsdFile = __DIR__ . '/../../../../docs/sitelist-1.0.xsd';
+ $xsdData = file_get_contents( $xsdFile );
+
+ $document = new DOMDocument();
+ $document->loadXML( $xml, LIBXML_NONET );
+ $document->schemaValidateSource( $xsdData );
+ }
+
+ private function newSiteStore( SiteList $sites ) {
+ $store = $this->getMockBuilder( SiteStore::class )->getMock();
+
+ $store->expects( $this->once() )
+ ->method( 'saveSites' )
+ ->will( $this->returnCallback( function ( $moreSites ) use ( $sites ) {
+ foreach ( $moreSites as $site ) {
+ $sites->setSite( $site );
+ }
+ } ) );
+
+ $store->expects( $this->any() )
+ ->method( 'getSites' )
+ ->will( $this->returnValue( new SiteList() ) );
+
+ return $store;
+ }
+
+ public function provideRoundTrip() {
+ $foo = Site::newForType( Site::TYPE_UNKNOWN );
+ $foo->setGlobalId( 'Foo' );
+
+ $acme = Site::newForType( Site::TYPE_UNKNOWN );
+ $acme->setGlobalId( 'acme.com' );
+ $acme->setGroup( 'Test' );
+ $acme->addLocalId( Site::ID_INTERWIKI, 'acme' );
+ $acme->setPath( Site::PATH_LINK, 'http://acme.com/' );
+
+ $dewiki = Site::newForType( Site::TYPE_MEDIAWIKI );
+ $dewiki->setGlobalId( 'dewiki' );
+ $dewiki->setGroup( 'wikipedia' );
+ $dewiki->setForward( true );
+ $dewiki->addLocalId( Site::ID_INTERWIKI, 'wikipedia' );
+ $dewiki->addLocalId( Site::ID_EQUIVALENT, 'de' );
+ $dewiki->setPath( Site::PATH_LINK, 'http://de.wikipedia.org/w/' );
+ $dewiki->setPath( MediaWikiSite::PATH_PAGE, 'http://de.wikipedia.org/wiki/' );
+ $dewiki->setSource( 'meta.wikimedia.org' );
+
+ return [
+ 'empty' => [
+ new SiteList()
+ ],
+
+ 'some' => [
+ new SiteList( [ $foo, $acme, $dewiki ] ),
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideRoundTrip()
+ */
+ public function testRoundTrip( SiteList $sites ) {
+ $tmp = tmpfile();
+ $exporter = new SiteExporter( $tmp );
+
+ $exporter->exportSites( $sites );
+
+ fseek( $tmp, 0 );
+ $xml = fread( $tmp, 16 * 1024 );
+
+ $actualSites = new SiteList();
+ $store = $this->newSiteStore( $actualSites );
+
+ $importer = new SiteImporter( $store );
+ $importer->importFromXML( $xml );
+
+ $this->assertEquals( $sites, $actualSites );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/site/SiteImporterTest.php b/www/wiki/tests/phpunit/includes/site/SiteImporterTest.php
new file mode 100644
index 00000000..bd95a501
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/SiteImporterTest.php
@@ -0,0 +1,202 @@
+<?php
+
+/**
+ * Tests for the SiteImporter class.
+ *
+ * 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 Site
+ * @ingroup Test
+ *
+ * @group Site
+ *
+ * @covers SiteImporter
+ *
+ * @author Daniel Kinzler
+ */
+class SiteImporterTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ private function newSiteImporter( array $expectedSites, $errorCount ) {
+ $store = $this->getMockBuilder( SiteStore::class )->getMock();
+
+ $store->expects( $this->once() )
+ ->method( 'saveSites' )
+ ->will( $this->returnCallback( function ( $sites ) use ( $expectedSites ) {
+ $this->assertSitesEqual( $expectedSites, $sites );
+ } ) );
+
+ $store->expects( $this->any() )
+ ->method( 'getSites' )
+ ->will( $this->returnValue( new SiteList() ) );
+
+ $errorHandler = $this->getMockBuilder( Psr\Log\LoggerInterface::class )->getMock();
+ $errorHandler->expects( $this->exactly( $errorCount ) )
+ ->method( 'error' );
+
+ $importer = new SiteImporter( $store );
+ $importer->setExceptionCallback( [ $errorHandler, 'error' ] );
+
+ return $importer;
+ }
+
+ public function assertSitesEqual( $expected, $actual, $message = '' ) {
+ $this->assertEquals(
+ $this->getSerializedSiteList( $expected ),
+ $this->getSerializedSiteList( $actual ),
+ $message
+ );
+ }
+
+ public function provideImportFromXML() {
+ $foo = Site::newForType( Site::TYPE_UNKNOWN );
+ $foo->setGlobalId( 'Foo' );
+
+ $acme = Site::newForType( Site::TYPE_UNKNOWN );
+ $acme->setGlobalId( 'acme.com' );
+ $acme->setGroup( 'Test' );
+ $acme->addLocalId( Site::ID_INTERWIKI, 'acme' );
+ $acme->setPath( Site::PATH_LINK, 'http://acme.com/' );
+
+ $dewiki = Site::newForType( Site::TYPE_MEDIAWIKI );
+ $dewiki->setGlobalId( 'dewiki' );
+ $dewiki->setGroup( 'wikipedia' );
+ $dewiki->setForward( true );
+ $dewiki->addLocalId( Site::ID_INTERWIKI, 'wikipedia' );
+ $dewiki->addLocalId( Site::ID_EQUIVALENT, 'de' );
+ $dewiki->setPath( Site::PATH_LINK, 'http://de.wikipedia.org/w/' );
+ $dewiki->setPath( MediaWikiSite::PATH_PAGE, 'http://de.wikipedia.org/wiki/' );
+ $dewiki->setSource( 'meta.wikimedia.org' );
+
+ return [
+ 'empty' => [
+ '<sites></sites>',
+ [],
+ ],
+ 'no sites' => [
+ '<sites><Foo><globalid>Foo</globalid></Foo><Bar><quux>Bla</quux></Bar></sites>',
+ [],
+ ],
+ 'minimal' => [
+ '<sites>' .
+ '<site><globalid>Foo</globalid></site>' .
+ '</sites>',
+ [ $foo ],
+ ],
+ 'full' => [
+ '<sites>' .
+ '<site><globalid>Foo</globalid></site>' .
+ '<site>' .
+ '<globalid>acme.com</globalid>' .
+ '<localid type="interwiki">acme</localid>' .
+ '<group>Test</group>' .
+ '<path type="link">http://acme.com/</path>' .
+ '</site>' .
+ '<site type="mediawiki">' .
+ '<source>meta.wikimedia.org</source>' .
+ '<globalid>dewiki</globalid>' .
+ '<localid type="interwiki">wikipedia</localid>' .
+ '<localid type="equivalent">de</localid>' .
+ '<group>wikipedia</group>' .
+ '<forward/>' .
+ '<path type="link">http://de.wikipedia.org/w/</path>' .
+ '<path type="page_path">http://de.wikipedia.org/wiki/</path>' .
+ '</site>' .
+ '</sites>',
+ [ $foo, $acme, $dewiki ],
+ ],
+ 'skip' => [
+ '<sites>' .
+ '<site><globalid>Foo</globalid></site>' .
+ '<site><barf>Foo</barf></site>' .
+ '<site>' .
+ '<globalid>acme.com</globalid>' .
+ '<localid type="interwiki">acme</localid>' .
+ '<silly>boop!</silly>' .
+ '<group>Test</group>' .
+ '<path type="link">http://acme.com/</path>' .
+ '</site>' .
+ '</sites>',
+ [ $foo, $acme ],
+ 1
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideImportFromXML
+ */
+ public function testImportFromXML( $xml, array $expectedSites, $errorCount = 0 ) {
+ $importer = $this->newSiteImporter( $expectedSites, $errorCount );
+ $importer->importFromXML( $xml );
+ }
+
+ public function testImportFromXML_malformed() {
+ $this->setExpectedException( Exception::class );
+
+ $store = $this->getMockBuilder( SiteStore::class )->getMock();
+ $importer = new SiteImporter( $store );
+ $importer->importFromXML( 'THIS IS NOT XML' );
+ }
+
+ public function testImportFromFile() {
+ $foo = Site::newForType( Site::TYPE_UNKNOWN );
+ $foo->setGlobalId( 'Foo' );
+
+ $acme = Site::newForType( Site::TYPE_UNKNOWN );
+ $acme->setGlobalId( 'acme.com' );
+ $acme->setGroup( 'Test' );
+ $acme->addLocalId( Site::ID_INTERWIKI, 'acme' );
+ $acme->setPath( Site::PATH_LINK, 'http://acme.com/' );
+
+ $dewiki = Site::newForType( Site::TYPE_MEDIAWIKI );
+ $dewiki->setGlobalId( 'dewiki' );
+ $dewiki->setGroup( 'wikipedia' );
+ $dewiki->setForward( true );
+ $dewiki->addLocalId( Site::ID_INTERWIKI, 'wikipedia' );
+ $dewiki->addLocalId( Site::ID_EQUIVALENT, 'de' );
+ $dewiki->setPath( Site::PATH_LINK, 'http://de.wikipedia.org/w/' );
+ $dewiki->setPath( MediaWikiSite::PATH_PAGE, 'http://de.wikipedia.org/wiki/' );
+ $dewiki->setSource( 'meta.wikimedia.org' );
+
+ $importer = $this->newSiteImporter( [ $foo, $acme, $dewiki ], 0 );
+
+ $file = __DIR__ . '/SiteImporterTest.xml';
+ $importer->importFromFile( $file );
+ }
+
+ /**
+ * @param Site[] $sites
+ *
+ * @return array[]
+ */
+ private function getSerializedSiteList( $sites ) {
+ $serialized = [];
+
+ foreach ( $sites as $site ) {
+ $key = $site->getGlobalId();
+ $data = unserialize( $site->serialize() );
+
+ $serialized[$key] = $data;
+ }
+
+ return $serialized;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/site/SiteImporterTest.xml b/www/wiki/tests/phpunit/includes/site/SiteImporterTest.xml
new file mode 100644
index 00000000..720b1faf
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/SiteImporterTest.xml
@@ -0,0 +1,19 @@
+<sites version="1.0" xmlns="http://www.mediawiki.org/xml/sitelist-1.0/">
+ <site><globalid>Foo</globalid></site>
+ <site>
+ <globalid>acme.com</globalid>
+ <localid type="interwiki">acme</localid>
+ <group>Test</group>
+ <path type="link">http://acme.com/</path>
+ </site>
+ <site type="mediawiki">
+ <source>meta.wikimedia.org</source>
+ <globalid>dewiki</globalid>
+ <localid type="interwiki">wikipedia</localid>
+ <localid type="equivalent">de</localid>
+ <group>wikipedia</group>
+ <forward/>
+ <path type="link">http://de.wikipedia.org/w/</path>
+ <path type="page_path">http://de.wikipedia.org/wiki/</path>
+ </site>
+</sites>
diff --git a/www/wiki/tests/phpunit/includes/site/SiteListTest.php b/www/wiki/tests/phpunit/includes/site/SiteListTest.php
new file mode 100644
index 00000000..a4a171c9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/SiteListTest.php
@@ -0,0 +1,239 @@
+<?php
+
+/**
+ * Tests for the SiteList class.
+ *
+ * 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
+ * @since 1.21
+ *
+ * @ingroup Site
+ * @ingroup Test
+ *
+ * @group Site
+ *
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class SiteListTest extends MediaWikiTestCase {
+
+ /**
+ * Returns instances of SiteList implementing objects.
+ * @return array
+ */
+ public function siteListProvider() {
+ $sitesArrays = $this->siteArrayProvider();
+
+ $listInstances = [];
+
+ foreach ( $sitesArrays as $sitesArray ) {
+ $listInstances[] = new SiteList( $sitesArray[0] );
+ }
+
+ return $this->arrayWrap( $listInstances );
+ }
+
+ /**
+ * Returns arrays with instances of Site implementing objects.
+ * @return array
+ */
+ public function siteArrayProvider() {
+ $sites = TestSites::getSites();
+
+ $siteArrays = [];
+
+ $siteArrays[] = $sites;
+
+ $siteArrays[] = [ array_shift( $sites ) ];
+
+ $siteArrays[] = [ array_shift( $sites ), array_shift( $sites ) ];
+
+ return $this->arrayWrap( $siteArrays );
+ }
+
+ /**
+ * @dataProvider siteListProvider
+ * @param SiteList $sites
+ * @covers SiteList::isEmpty
+ */
+ public function testIsEmpty( SiteList $sites ) {
+ $this->assertEquals( count( $sites ) === 0, $sites->isEmpty() );
+ }
+
+ /**
+ * @dataProvider siteListProvider
+ * @param SiteList $sites
+ * @covers SiteList::getSite
+ */
+ public function testGetSiteByGlobalId( SiteList $sites ) {
+ /**
+ * @var Site $site
+ */
+ foreach ( $sites as $site ) {
+ $this->assertEquals( $site, $sites->getSite( $site->getGlobalId() ) );
+ }
+
+ $this->assertTrue( true );
+ }
+
+ /**
+ * @dataProvider siteListProvider
+ * @param SiteList $sites
+ * @covers SiteList::getSiteByInternalId
+ */
+ public function testGetSiteByInternalId( $sites ) {
+ /**
+ * @var Site $site
+ */
+ foreach ( $sites as $site ) {
+ if ( is_int( $site->getInternalId() ) ) {
+ $this->assertEquals( $site, $sites->getSiteByInternalId( $site->getInternalId() ) );
+ }
+ }
+
+ $this->assertTrue( true );
+ }
+
+ /**
+ * @dataProvider siteListProvider
+ * @param SiteList $sites
+ * @covers SiteList::getSiteByNavigationId
+ */
+ public function testGetSiteByNavigationId( $sites ) {
+ /**
+ * @var Site $site
+ */
+ foreach ( $sites as $site ) {
+ $ids = $site->getNavigationIds();
+ foreach ( $ids as $navId ) {
+ $this->assertEquals( $site, $sites->getSiteByNavigationId( $navId ) );
+ }
+ }
+
+ $this->assertTrue( true );
+ }
+
+ /**
+ * @dataProvider siteListProvider
+ * @param SiteList $sites
+ * @covers SiteList::hasSite
+ */
+ public function testHasGlobalId( $sites ) {
+ $this->assertFalse( $sites->hasSite( 'non-existing-global-id' ) );
+ $this->assertFalse( $sites->hasInternalId( 720101010 ) );
+
+ if ( !$sites->isEmpty() ) {
+ /**
+ * @var Site $site
+ */
+ foreach ( $sites as $site ) {
+ $this->assertTrue( $sites->hasSite( $site->getGlobalId() ) );
+ }
+ }
+ }
+
+ /**
+ * @dataProvider siteListProvider
+ * @param SiteList $sites
+ * @covers SiteList::hasInternalId
+ */
+ public function testHasInternallId( $sites ) {
+ /**
+ * @var Site $site
+ */
+ foreach ( $sites as $site ) {
+ if ( is_int( $site->getInternalId() ) ) {
+ $this->assertTrue( $site, $sites->hasInternalId( $site->getInternalId() ) );
+ }
+ }
+
+ $this->assertFalse( $sites->hasInternalId( -1 ) );
+ }
+
+ /**
+ * @dataProvider siteListProvider
+ * @param SiteList $sites
+ * @covers SiteList::hasNavigationId
+ */
+ public function testHasNavigationId( $sites ) {
+ /**
+ * @var Site $site
+ */
+ foreach ( $sites as $site ) {
+ $ids = $site->getNavigationIds();
+ foreach ( $ids as $navId ) {
+ $this->assertTrue( $sites->hasNavigationId( $navId ) );
+ }
+ }
+
+ $this->assertFalse( $sites->hasNavigationId( 'non-existing-navigation-id' ) );
+ }
+
+ /**
+ * @dataProvider siteListProvider
+ * @param SiteList $sites
+ * @covers SiteList::getGlobalIdentifiers
+ */
+ public function testGetGlobalIdentifiers( SiteList $sites ) {
+ $identifiers = $sites->getGlobalIdentifiers();
+
+ $this->assertTrue( is_array( $identifiers ) );
+
+ $expected = [];
+
+ /**
+ * @var Site $site
+ */
+ foreach ( $sites as $site ) {
+ $expected[] = $site->getGlobalId();
+ }
+
+ $this->assertArrayEquals( $expected, $identifiers );
+ }
+
+ /**
+ * @dataProvider siteListProvider
+ *
+ * @since 1.21
+ *
+ * @param SiteList $list
+ * @covers SiteList::getSerializationData
+ * @covers SiteList::unserialize
+ */
+ public function testSerialization( SiteList $list ) {
+ $serialization = serialize( $list );
+ /**
+ * @var SiteList $copy
+ */
+ $copy = unserialize( $serialization );
+
+ $this->assertArrayEquals( $list->getGlobalIdentifiers(), $copy->getGlobalIdentifiers() );
+
+ /**
+ * @var Site $site
+ */
+ foreach ( $list as $site ) {
+ $this->assertTrue( $copy->hasInternalId( $site->getInternalId() ) );
+
+ foreach ( $site->getNavigationIds() as $navId ) {
+ $this->assertTrue(
+ $copy->hasNavigationId( $navId ),
+ 'unserialized data expects nav id ' . $navId . ' for site ' . $site->getGlobalId()
+ );
+ }
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/site/SiteTest.php b/www/wiki/tests/phpunit/includes/site/SiteTest.php
new file mode 100644
index 00000000..ac5f956e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/SiteTest.php
@@ -0,0 +1,295 @@
+<?php
+
+/**
+ * Tests for the Site class.
+ *
+ * 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
+ * @since 1.21
+ *
+ * @ingroup Site
+ * @ingroup Test
+ *
+ * @group Site
+ *
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class SiteTest extends MediaWikiTestCase {
+
+ public function instanceProvider() {
+ return $this->arrayWrap( TestSites::getSites() );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::getInterwikiIds
+ */
+ public function testGetInterwikiIds( Site $site ) {
+ $this->assertInternalType( 'array', $site->getInterwikiIds() );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::getNavigationIds
+ */
+ public function testGetNavigationIds( Site $site ) {
+ $this->assertInternalType( 'array', $site->getNavigationIds() );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::addNavigationId
+ */
+ public function testAddNavigationId( Site $site ) {
+ $site->addNavigationId( 'foobar' );
+ $this->assertTrue( in_array( 'foobar', $site->getNavigationIds(), true ) );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::addInterwikiId
+ */
+ public function testAddInterwikiId( Site $site ) {
+ $site->addInterwikiId( 'foobar' );
+ $this->assertTrue( in_array( 'foobar', $site->getInterwikiIds(), true ) );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::getLanguageCode
+ */
+ public function testGetLanguageCode( Site $site ) {
+ $this->assertTypeOrValue( 'string', $site->getLanguageCode(), null );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::setLanguageCode
+ */
+ public function testSetLanguageCode( Site $site ) {
+ $site->setLanguageCode( 'en' );
+ $this->assertEquals( 'en', $site->getLanguageCode() );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::normalizePageName
+ */
+ public function testNormalizePageName( Site $site ) {
+ $this->assertInternalType( 'string', $site->normalizePageName( 'Foobar' ) );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::getGlobalId
+ */
+ public function testGetGlobalId( Site $site ) {
+ $this->assertTypeOrValue( 'string', $site->getGlobalId(), null );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::setGlobalId
+ */
+ public function testSetGlobalId( Site $site ) {
+ $site->setGlobalId( 'foobar' );
+ $this->assertEquals( 'foobar', $site->getGlobalId() );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::getType
+ */
+ public function testGetType( Site $site ) {
+ $this->assertInternalType( 'string', $site->getType() );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::getPath
+ */
+ public function testGetPath( Site $site ) {
+ $this->assertTypeOrValue( 'string', $site->getPath( 'page_path' ), null );
+ $this->assertTypeOrValue( 'string', $site->getPath( 'file_path' ), null );
+ $this->assertTypeOrValue( 'string', $site->getPath( 'foobar' ), null );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::getAllPaths
+ */
+ public function testGetAllPaths( Site $site ) {
+ $this->assertInternalType( 'array', $site->getAllPaths() );
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::setPath
+ * @covers Site::removePath
+ */
+ public function testSetAndRemovePath( Site $site ) {
+ $count = count( $site->getAllPaths() );
+
+ $site->setPath( 'spam', 'http://www.wikidata.org/$1' );
+ $site->setPath( 'spam', 'http://www.wikidata.org/foo/$1' );
+ $site->setPath( 'foobar', 'http://www.wikidata.org/bar/$1' );
+
+ $this->assertEquals( $count + 2, count( $site->getAllPaths() ) );
+
+ $this->assertInternalType( 'string', $site->getPath( 'foobar' ) );
+ $this->assertEquals( 'http://www.wikidata.org/foo/$1', $site->getPath( 'spam' ) );
+
+ $site->removePath( 'spam' );
+ $site->removePath( 'foobar' );
+
+ $this->assertEquals( $count, count( $site->getAllPaths() ) );
+
+ $this->assertNull( $site->getPath( 'foobar' ) );
+ $this->assertNull( $site->getPath( 'spam' ) );
+ }
+
+ /**
+ * @covers Site::setLinkPath
+ */
+ public function testSetLinkPath() {
+ $site = new Site();
+ $path = "TestPath/$1";
+
+ $site->setLinkPath( $path );
+ $this->assertEquals( $path, $site->getLinkPath() );
+ }
+
+ /**
+ * @covers Site::getLinkPathType
+ */
+ public function testGetLinkPathType() {
+ $site = new Site();
+
+ $path = 'TestPath/$1';
+ $site->setLinkPath( $path );
+ $this->assertEquals( $path, $site->getPath( $site->getLinkPathType() ) );
+
+ $path = 'AnotherPath/$1';
+ $site->setPath( $site->getLinkPathType(), $path );
+ $this->assertEquals( $path, $site->getLinkPath() );
+ }
+
+ /**
+ * @covers Site::setPath
+ */
+ public function testSetPath() {
+ $site = new Site();
+
+ $path = 'TestPath/$1';
+ $site->setPath( 'foo', $path );
+
+ $this->assertEquals( $path, $site->getPath( 'foo' ) );
+ }
+
+ /**
+ * @covers Site::setPath
+ * @covers Site::getProtocol
+ */
+ public function testProtocolRelativePath() {
+ $site = new Site();
+
+ $type = $site->getLinkPathType();
+ $path = '//acme.com/'; // protocol-relative URL
+ $site->setPath( $type, $path );
+
+ $this->assertEquals( '', $site->getProtocol() );
+ }
+
+ public static function provideGetPageUrl() {
+ // NOTE: the assumption that the URL is built by replacing $1
+ // with the urlencoded version of $page
+ // is true for Site but not guaranteed for subclasses.
+ // Subclasses need to override this provider appropriately.
+
+ return [
+ [ # 0
+ 'http://acme.test/TestPath/$1',
+ 'Foo',
+ '/TestPath/Foo',
+ ],
+ [ # 1
+ 'http://acme.test/TestScript?x=$1&y=bla',
+ 'Foo',
+ 'TestScript?x=Foo&y=bla',
+ ],
+ [ # 2
+ 'http://acme.test/TestPath/$1',
+ 'foo & bar/xyzzy (quux-shmoox?)',
+ '/TestPath/foo%20%26%20bar%2Fxyzzy%20%28quux-shmoox%3F%29',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetPageUrl
+ * @covers Site::getPageUrl
+ */
+ public function testGetPageUrl( $path, $page, $expected ) {
+ $site = new Site();
+
+ // NOTE: the assumption that getPageUrl is based on getLinkPath
+ // is true for Site but not guaranteed for subclasses.
+ // Subclasses need to override this test case appropriately.
+ $site->setLinkPath( $path );
+ $this->assertContains( $path, $site->getPageUrl() );
+
+ $this->assertContains( $expected, $site->getPageUrl( $page ) );
+ }
+
+ protected function assertTypeOrFalse( $type, $value ) {
+ if ( $value === false ) {
+ $this->assertTrue( true );
+ } else {
+ $this->assertInternalType( $type, $value );
+ }
+ }
+
+ /**
+ * @dataProvider instanceProvider
+ * @param Site $site
+ * @covers Site::serialize
+ * @covers Site::unserialize
+ */
+ public function testSerialization( Site $site ) {
+ $this->assertInstanceOf( Serializable::class, $site );
+
+ $serialization = serialize( $site );
+ $newInstance = unserialize( $serialization );
+
+ $this->assertInstanceOf( Site::class, $newInstance );
+
+ $this->assertEquals( $serialization, serialize( $newInstance ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/site/SitesCacheFileBuilderTest.php b/www/wiki/tests/phpunit/includes/site/SitesCacheFileBuilderTest.php
new file mode 100644
index 00000000..8c84ce57
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/SitesCacheFileBuilderTest.php
@@ -0,0 +1,137 @@
+<?php
+
+/**
+ * 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
+ * @since 1.25
+ *
+ * @ingroup Site
+ * @ingroup Test
+ *
+ * @covers SitesCacheFileBuilder
+ * @group Site
+ *
+ * @author Katie Filbert < aude.wiki@gmail.com >
+ */
+class SitesCacheFileBuilderTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected function setUp() {
+ $this->cacheFile = $this->getCacheFile();
+ }
+
+ protected function tearDown() {
+ unlink( $this->cacheFile );
+ }
+
+ public function testBuild() {
+ $cacheBuilder = $this->newSitesCacheFileBuilder( $this->getSites() );
+ $cacheBuilder->build();
+
+ $contents = file_get_contents( $this->cacheFile );
+ $this->assertEquals( json_encode( $this->getExpectedData() ), $contents );
+ }
+
+ private function getExpectedData() {
+ return [
+ 'sites' => [
+ 'foobar' => [
+ 'globalid' => 'foobar',
+ 'type' => 'unknown',
+ 'group' => 'none',
+ 'source' => 'local',
+ 'language' => null,
+ 'localids' => [],
+ 'config' => [],
+ 'data' => [],
+ 'forward' => false,
+ 'internalid' => null,
+ 'identifiers' => []
+ ],
+ 'enwiktionary' => [
+ 'globalid' => 'enwiktionary',
+ 'type' => 'mediawiki',
+ 'group' => 'wiktionary',
+ 'source' => 'local',
+ 'language' => 'en',
+ 'localids' => [
+ 'equivalent' => [ 'enwiktionary' ]
+ ],
+ 'config' => [],
+ 'data' => [
+ 'paths' => [
+ 'page_path' => 'https://en.wiktionary.org/wiki/$1',
+ 'file_path' => 'https://en.wiktionary.org/w/$1'
+ ]
+ ],
+ 'forward' => false,
+ 'internalid' => null,
+ 'identifiers' => [
+ [
+ 'type' => 'equivalent',
+ 'key' => 'enwiktionary'
+ ]
+ ]
+ ]
+ ]
+ ];
+ }
+
+ private function newSitesCacheFileBuilder( SiteList $sites ) {
+ return new SitesCacheFileBuilder(
+ $this->getSiteLookup( $sites ),
+ $this->cacheFile
+ );
+ }
+
+ private function getSiteLookup( SiteList $sites ) {
+ $siteLookup = $this->getMockBuilder( SiteLookup::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $siteLookup->expects( $this->any() )
+ ->method( 'getSites' )
+ ->will( $this->returnValue( $sites ) );
+
+ return $siteLookup;
+ }
+
+ private function getSites() {
+ $sites = [];
+
+ $site = new Site();
+ $site->setGlobalId( 'foobar' );
+ $sites[] = $site;
+
+ $site = new MediaWikiSite();
+ $site->setGlobalId( 'enwiktionary' );
+ $site->setGroup( 'wiktionary' );
+ $site->setLanguageCode( 'en' );
+ $site->addNavigationId( 'enwiktionary' );
+ $site->setPath( MediaWikiSite::PATH_PAGE, "https://en.wiktionary.org/wiki/$1" );
+ $site->setPath( MediaWikiSite::PATH_FILE, "https://en.wiktionary.org/w/$1" );
+ $sites[] = $site;
+
+ return new SiteList( $sites );
+ }
+
+ private function getCacheFile() {
+ return tempnam( sys_get_temp_dir(), 'mw-test-sitelist' );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/site/TestSites.php b/www/wiki/tests/phpunit/includes/site/TestSites.php
new file mode 100644
index 00000000..a66fce29
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/site/TestSites.php
@@ -0,0 +1,112 @@
+<?php
+
+/**
+ * Holds sites for testing purposes.
+ *
+ * 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
+ * @since 1.21
+ *
+ * @ingroup Site
+ * @ingroup Test
+ *
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class TestSites {
+
+ /**
+ * @since 1.21
+ *
+ * @return array
+ */
+ public static function getSites() {
+ $sites = [];
+
+ $site = new Site();
+ $site->setGlobalId( 'foobar' );
+ $sites[] = $site;
+
+ $site = new MediaWikiSite();
+ $site->setGlobalId( 'enwiktionary' );
+ $site->setGroup( 'wiktionary' );
+ $site->setLanguageCode( 'en' );
+ $site->addNavigationId( 'enwiktionary' );
+ $site->setPath( MediaWikiSite::PATH_PAGE, "https://en.wiktionary.org/wiki/$1" );
+ $site->setPath( MediaWikiSite::PATH_FILE, "https://en.wiktionary.org/w/$1" );
+ $sites[] = $site;
+
+ $site = new MediaWikiSite();
+ $site->setGlobalId( 'dewiktionary' );
+ $site->setGroup( 'wiktionary' );
+ $site->setLanguageCode( 'de' );
+ $site->addInterwikiId( 'dewiktionary' );
+ $site->addInterwikiId( 'wiktionaryde' );
+ $site->setPath( MediaWikiSite::PATH_PAGE, "https://de.wiktionary.org/wiki/$1" );
+ $site->setPath( MediaWikiSite::PATH_FILE, "https://de.wiktionary.org/w/$1" );
+ $sites[] = $site;
+
+ $site = new Site();
+ $site->setGlobalId( 'spam' );
+ $site->setGroup( 'spam' );
+ $site->setLanguageCode( 'en' );
+ $site->addNavigationId( 'spam' );
+ $site->addNavigationId( 'spamz' );
+ $site->addInterwikiId( 'spamzz' );
+ $site->setLinkPath( "http://spamzz.test/testing/" );
+ $sites[] = $site;
+
+ /**
+ * Add at least one right-to-left language (current RTL languages in MediaWiki core are:
+ * aeb, ar, arc, arz, azb, bcc, bqi, ckb, dv, en_rtl, fa, glk, he, khw, kk_arab, kk_cn,
+ * ks_arab, ku_arab, lrc, mzn, pnb, ps, sd, ug_arab, ur, yi).
+ */
+ $languageCodes = [
+ 'de',
+ 'en',
+ 'fa', // right-to-left
+ 'nl',
+ 'nn',
+ 'no',
+ 'sr',
+ 'sv',
+ ];
+ foreach ( $languageCodes as $langCode ) {
+ $site = new MediaWikiSite();
+ $site->setGlobalId( $langCode . 'wiki' );
+ $site->setGroup( 'wikipedia' );
+ $site->setLanguageCode( $langCode );
+ $site->addInterwikiId( $langCode );
+ $site->addNavigationId( $langCode );
+ $site->setPath( MediaWikiSite::PATH_PAGE, "https://$langCode.wikipedia.org/wiki/$1" );
+ $site->setPath( MediaWikiSite::PATH_FILE, "https://$langCode.wikipedia.org/w/$1" );
+ $sites[] = $site;
+ }
+
+ return $sites;
+ }
+
+ /**
+ * Inserts sites into the database for the unit tests that need them.
+ *
+ * @since 0.1
+ */
+ public static function insertIntoDb() {
+ $sitesTable = \MediaWiki\MediaWikiServices::getInstance()->getSiteStore();
+ $sitesTable->clear();
+ $sitesTable->saveSites( self::getSites() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/skins/SkinFactoryTest.php b/www/wiki/tests/phpunit/includes/skins/SkinFactoryTest.php
new file mode 100644
index 00000000..4289fd91
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/skins/SkinFactoryTest.php
@@ -0,0 +1,82 @@
+<?php
+
+class SkinFactoryTest extends MediaWikiTestCase {
+
+ /**
+ * @covers SkinFactory::register
+ */
+ public function testRegister() {
+ $factory = new SkinFactory();
+ $factory->register( 'fallback', 'Fallback', function () {
+ return new SkinFallback();
+ } );
+ $this->assertTrue( true ); // No exception thrown
+ $this->setExpectedException( InvalidArgumentException::class );
+ $factory->register( 'invalid', 'Invalid', 'Invalid callback' );
+ }
+
+ /**
+ * @covers SkinFactory::makeSkin
+ */
+ public function testMakeSkinWithNoBuilders() {
+ $factory = new SkinFactory();
+ $this->setExpectedException( SkinException::class );
+ $factory->makeSkin( 'nobuilderregistered' );
+ }
+
+ /**
+ * @covers SkinFactory::makeSkin
+ */
+ public function testMakeSkinWithInvalidCallback() {
+ $factory = new SkinFactory();
+ $factory->register( 'unittest', 'Unittest', function () {
+ return true; // Not a Skin object
+ } );
+ $this->setExpectedException( UnexpectedValueException::class );
+ $factory->makeSkin( 'unittest' );
+ }
+
+ /**
+ * @covers SkinFactory::makeSkin
+ */
+ public function testMakeSkinWithValidCallback() {
+ $factory = new SkinFactory();
+ $factory->register( 'testfallback', 'TestFallback', function () {
+ return new SkinFallback();
+ } );
+
+ $skin = $factory->makeSkin( 'testfallback' );
+ $this->assertInstanceOf( Skin::class, $skin );
+ $this->assertInstanceOf( SkinFallback::class, $skin );
+ $this->assertEquals( 'fallback', $skin->getSkinName() );
+ }
+
+ /**
+ * @covers Skin::__construct
+ * @covers Skin::getSkinName
+ */
+ public function testGetSkinName() {
+ $skin = new SkinFallback();
+ $this->assertEquals( 'fallback', $skin->getSkinName(), 'Default' );
+ $skin = new SkinFallback( 'testname' );
+ $this->assertEquals( 'testname', $skin->getSkinName(), 'Constructor argument' );
+ }
+
+ /**
+ * @covers SkinFactory::getSkinNames
+ */
+ public function testGetSkinNames() {
+ $factory = new SkinFactory();
+ // A fake callback we can use that will never be called
+ $callback = function () {
+ // NOP
+ };
+ $factory->register( 'skin1', 'Skin1', $callback );
+ $factory->register( 'skin2', 'Skin2', $callback );
+ $names = $factory->getSkinNames();
+ $this->assertArrayHasKey( 'skin1', $names );
+ $this->assertArrayHasKey( 'skin2', $names );
+ $this->assertEquals( 'Skin1', $names['skin1'] );
+ $this->assertEquals( 'Skin2', $names['skin2'] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/skins/SkinTemplateTest.php b/www/wiki/tests/phpunit/includes/skins/SkinTemplateTest.php
new file mode 100644
index 00000000..06b06677
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/skins/SkinTemplateTest.php
@@ -0,0 +1,101 @@
+<?php
+
+/**
+ * @covers SkinTemplate
+ *
+ * @group Output
+ *
+ * @author Bene* < benestar.wikimedia@gmail.com >
+ */
+class SkinTemplateTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider makeListItemProvider
+ */
+ public function testMakeListItem( $expected, $key, $item, $options, $message ) {
+ $template = $this->getMockForAbstractClass( BaseTemplate::class );
+
+ $this->assertEquals(
+ $expected,
+ $template->makeListItem( $key, $item, $options ),
+ $message
+ );
+ }
+
+ public function makeListItemProvider() {
+ return [
+ [
+ '<li class="class" title="itemtitle"><a href="url" title="title">text</a></li>',
+ '',
+ [
+ 'class' => 'class',
+ 'itemtitle' => 'itemtitle',
+ 'href' => 'url',
+ 'title' => 'title',
+ 'text' => 'text'
+ ],
+ [],
+ 'Test makeListItem with normal values'
+ ]
+ ];
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|OutputPage
+ */
+ private function getMockOutputPage( $isSyndicated, $html ) {
+ $mock = $this->getMockBuilder( OutputPage::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->once() )
+ ->method( 'isSyndicated' )
+ ->will( $this->returnValue( $isSyndicated ) );
+ $mock->expects( $this->once() )
+ ->method( 'getHTML' )
+ ->will( $this->returnValue( $html ) );
+ return $mock;
+ }
+
+ public function provideSetupSkinUserCss() {
+ $defaultStyles = [
+ 'mediawiki.legacy.shared',
+ 'mediawiki.legacy.commonPrint',
+ 'mediawiki.sectionAnchor',
+ ];
+ $buttonStyle = 'mediawiki.ui.button';
+ $feedStyle = 'mediawiki.feedlink';
+ return [
+ [
+ $this->getMockOutputPage( false, '' ),
+ $defaultStyles
+ ],
+ [
+ $this->getMockOutputPage( true, '' ),
+ array_merge( $defaultStyles, [ $feedStyle ] )
+ ],
+ [
+ $this->getMockOutputPage( false, 'FOO mw-ui-button BAR' ),
+ array_merge( $defaultStyles, [ $buttonStyle ] )
+ ],
+ [
+ $this->getMockOutputPage( true, 'FOO mw-ui-button BAR' ),
+ array_merge( $defaultStyles, [ $feedStyle, $buttonStyle ] )
+ ],
+ ];
+ }
+
+ /**
+ * @param PHPUnit_Framework_MockObject_MockObject|OutputPage $outputPageMock
+ * @param string[] $expectedModuleStyles
+ *
+ * @covers SkinTemplate::setupSkinUserCss
+ * @dataProvider provideSetupSkinUserCss
+ */
+ public function testSetupSkinUserCss( $outputPageMock, $expectedModuleStyles ) {
+ $outputPageMock->expects( $this->once() )
+ ->method( 'addModuleStyles' )
+ ->with( $expectedModuleStyles );
+
+ $skinTemplate = new SkinTemplate();
+ $skinTemplate->setupSkinUserCss( $outputPageMock );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/sparql/SparqlClientTest.php b/www/wiki/tests/phpunit/includes/sparql/SparqlClientTest.php
new file mode 100644
index 00000000..b217af15
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/sparql/SparqlClientTest.php
@@ -0,0 +1,190 @@
+<?php
+namespace MediaWiki\Sparql;
+
+use Http;
+use MediaWiki\Http\HttpRequestFactory;
+use MWHttpRequest;
+use PHPUnit4And6Compat;
+
+/**
+ * @covers \MediaWiki\Sparql\SparqlClient
+ */
+class SparqlClientTest extends \PHPUnit\Framework\TestCase {
+
+ use PHPUnit4And6Compat;
+
+ private function getRequestFactory( $request ) {
+ $requestFactory = $this->getMock( HttpRequestFactory::class );
+ $requestFactory->method( 'create' )->willReturn( $request );
+ return $requestFactory;
+ }
+
+ private function getRequestMock( $content ) {
+ $request = $this->getMockBuilder( MWHttpRequest::class )->disableOriginalConstructor()->getMock();
+ $request->method( 'execute' )->willReturn( \Status::newGood( 200 ) );
+ $request->method( 'getContent' )->willReturn( $content );
+ return $request;
+ }
+
+ public function testQuery() {
+ $json = <<<JSON
+{
+ "head" : {
+ "vars" : [ "x", "y", "z" ]
+ },
+ "results" : {
+ "bindings" : [ {
+ "x" : {
+ "type" : "uri",
+ "value" : "http://wikiba.se/ontology#Dump"
+ },
+ "y" : {
+ "type" : "uri",
+ "value" : "http://creativecommons.org/ns#license"
+ },
+ "z" : {
+ "type" : "uri",
+ "value" : "http://creativecommons.org/publicdomain/zero/1.0/"
+ }
+ }, {
+ "x" : {
+ "type" : "uri",
+ "value" : "http://wikiba.se/ontology#Dump"
+ },
+ "z" : {
+ "type" : "literal",
+ "value" : "0.1.0"
+ }
+ } ]
+ }
+}
+JSON;
+
+ $request = $this->getRequestMock( $json );
+ $client = new SparqlClient( 'http://acme.test/', $this->getRequestFactory( $request ) );
+
+ // values only
+ $result = $client->query( "TEST SPARQL" );
+ $this->assertCount( 2, $result );
+ $this->assertEquals( 'http://wikiba.se/ontology#Dump', $result[0]['x'] );
+ $this->assertEquals( 'http://creativecommons.org/ns#license', $result[0]['y'] );
+ $this->assertEquals( '0.1.0', $result[1]['z'] );
+ $this->assertNull( $result[1]['y'] );
+ // raw data format
+ $result = $client->query( "TEST SPARQL 2", true );
+ $this->assertCount( 2, $result );
+ $this->assertEquals( 'uri', $result[0]['x']['type'] );
+ $this->assertEquals( 'http://wikiba.se/ontology#Dump', $result[0]['x']['value'] );
+ $this->assertEquals( 'literal', $result[1]['z']['type'] );
+ $this->assertEquals( '0.1.0', $result[1]['z']['value'] );
+ $this->assertNull( $result[1]['y'] );
+ }
+
+ /**
+ * @expectedException \Mediawiki\Sparql\SparqlException
+ */
+ public function testBadQuery() {
+ $request = $this->getMockBuilder( MWHttpRequest::class )->disableOriginalConstructor()->getMock();
+ $client = new SparqlClient( 'http://acme.test/', $this->getRequestFactory( $request ) );
+
+ $request->method( 'execute' )->willReturn( \Status::newFatal( "Bad query" ) );
+ $result = $client->query( "TEST SPARQL 3" );
+ }
+
+ public function optionsProvider() {
+ return [
+ 'defaults' => [
+ 'TEST тест SPARQL 4 ',
+ null,
+ null,
+ [
+ 'http://acme.test/',
+ 'query=TEST+%D1%82%D0%B5%D1%81%D1%82+SPARQL+4+',
+ 'format=json',
+ 'maxQueryTimeMillis=30000',
+ ],
+ [
+ 'method' => 'GET',
+ 'userAgent' => Http::userAgent() ." SparqlClient",
+ 'timeout' => 30
+ ]
+ ],
+ 'big query' => [
+ str_repeat( 'ZZ', SparqlClient::MAX_GET_SIZE ),
+ null,
+ null,
+ [
+ 'format=json',
+ 'maxQueryTimeMillis=30000',
+ ],
+ [
+ 'method' => 'POST',
+ 'postData' => 'query=' . str_repeat( 'ZZ', SparqlClient::MAX_GET_SIZE ),
+ ]
+ ],
+ 'timeout 1s' => [
+ 'TEST SPARQL 4',
+ null,
+ 1,
+ [
+ 'maxQueryTimeMillis=1000',
+ ],
+ [
+ 'timeout' => 1
+ ]
+ ],
+ 'more options' => [
+ 'TEST SPARQL 5',
+ [
+ 'userAgent' => 'My Test',
+ 'randomOption' => 'duck',
+ ],
+ null,
+ [],
+ [
+ 'userAgent' => 'My Test',
+ 'randomOption' => 'duck',
+ ]
+ ],
+
+ ];
+ }
+
+ /**
+ * @dataProvider optionsProvider
+ * @param string $sparql
+ * @param array|null $options
+ * @param int|null $timeout
+ * @param array $expectedUrl
+ * @param array $expectedOptions
+ */
+ public function testOptions( $sparql, $options, $timeout, $expectedUrl, $expectedOptions ) {
+ $requestFactory = $this->getMock( HttpRequestFactory::class );
+ $client = new SparqlClient( 'http://acme.test/', $requestFactory );
+
+ $request = $this->getRequestMock( '{}' );
+
+ $requestFactory->method( 'create' )->willReturnCallback(
+ function ( $url, $options ) use ( $request, $expectedUrl, $expectedOptions ) {
+ foreach ( $expectedUrl as $eurl ) {
+ $this->assertContains( $eurl, $url );
+ }
+ foreach ( $expectedOptions as $ekey => $evalue ) {
+ $this->assertArrayHasKey( $ekey, $options );
+ $this->assertEquals( $options[$ekey], $evalue );
+ }
+ return $request;
+ }
+ );
+
+ if ( !is_null( $options ) ) {
+ $client->setClientOptions( $options );
+ }
+ if ( !is_null( $timeout ) ) {
+ $client->setTimeout( $timeout );
+ }
+
+ $result = $client->query( $sparql );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/specialpage/AbstractChangesListSpecialPageTestCase.php b/www/wiki/tests/phpunit/includes/specialpage/AbstractChangesListSpecialPageTestCase.php
new file mode 100644
index 00000000..8b8ba0c0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specialpage/AbstractChangesListSpecialPageTestCase.php
@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * Abstract base class for shared logic when testing ChangesListSpecialPage
+ * and subclasses
+ *
+ * @group Database
+ */
+abstract class AbstractChangesListSpecialPageTestCase extends MediaWikiTestCase {
+ // Must be initialized by subclass
+ /**
+ * @var ChangesListSpecialPage
+ */
+ protected $changesListSpecialPage;
+
+ protected $oldPatrollersGroup;
+
+ protected function setUp() {
+ global $wgGroupPermissions;
+
+ parent::setUp();
+ $this->setMwGlobals( [
+ 'wgRCWatchCategoryMembership' => true,
+ 'wgUseRCPatrol' => true,
+ ] );
+
+ if ( isset( $wgGroupPermissions['patrollers'] ) ) {
+ $this->oldPatrollersGroup = $wgGroupPermissions['patrollers'];
+ }
+
+ $wgGroupPermissions['patrollers'] = [
+ 'patrol' => true,
+ ];
+
+ // Deprecated
+ $this->setTemporaryHook(
+ 'ChangesListSpecialPageFilters',
+ null
+ );
+
+ # setup the ChangesListSpecialPage (or subclass) object
+ $this->changesListSpecialPage = $this->getPage();
+ $context = $this->changesListSpecialPage->getContext();
+ $context = new DerivativeContext( $context );
+ $context->setUser( $this->getTestUser( [ 'patrollers' ] )->getUser() );
+ $this->changesListSpecialPage->setContext( $context );
+ $this->changesListSpecialPage->registerFilters();
+ }
+
+ abstract protected function getPage();
+
+ protected function tearDown() {
+ global $wgGroupPermissions;
+
+ parent::tearDown();
+
+ if ( $this->oldPatrollersGroup !== null ) {
+ $wgGroupPermissions['patrollers'] = $this->oldPatrollersGroup;
+ }
+ }
+
+ abstract public function provideParseParameters();
+
+ /**
+ * @dataProvider provideParseParameters
+ */
+ public function testParseParameters( $params, $expected ) {
+ $opts = new FormOptions();
+ foreach ( $expected as $key => $value ) {
+ // Register it as null so sets aren't rejected.
+ $opts->add(
+ $key,
+ null,
+ FormOptions::guessType( $expected )
+ );
+ }
+
+ $this->changesListSpecialPage->parseParameters(
+ $params,
+ $opts
+ );
+
+ $this->assertArrayEquals(
+ $expected,
+ $opts->getAllValues(),
+ /** ordered= */ false,
+ /** named= */ true
+ );
+ }
+
+ /**
+ * @dataProvider validateOptionsProvider
+ */
+ public function testValidateOptions( $optionsToSet, $expectedRedirect, $expectedRedirectOptions ) {
+ $redirectQuery = [];
+ $redirected = false;
+ $output = $this->getMockBuilder( OutputPage::class )
+ ->disableProxyingToOriginalMethods()
+ ->disableOriginalConstructor()
+ ->getMock();
+ $output->method( 'redirect' )->willReturnCallback(
+ function ( $url ) use ( &$redirectQuery, &$redirected ) {
+ $urlParts = wfParseUrl( $url );
+ $query = isset( $urlParts[ 'query' ] ) ? $urlParts[ 'query' ] : '';
+ parse_str( $query, $redirectQuery );
+ $redirected = true;
+ }
+ );
+ $ctx = new RequestContext();
+
+ // Give users patrol permissions so we can test that.
+ $user = $this->getTestSysop()->getUser();
+ $ctx->setUser( $user );
+
+ // Disable this hook or it could break changeType
+ // depending on which other extensions are running.
+ $this->setTemporaryHook(
+ 'ChangesListSpecialPageStructuredFilters',
+ null
+ );
+
+ $ctx->setOutput( $output );
+ $clsp = $this->changesListSpecialPage;
+ $clsp->setContext( $ctx );
+ $opts = $clsp->getDefaultOptions();
+
+ foreach ( $optionsToSet as $option => $value ) {
+ $opts->setValue( $option, $value );
+ }
+
+ $clsp->validateOptions( $opts );
+
+ $this->assertEquals( $expectedRedirect, $redirected, 'redirection' );
+
+ if ( $expectedRedirect ) {
+ if ( count( $expectedRedirectOptions ) > 0 ) {
+ $expectedRedirectOptions += [
+ 'title' => $clsp->getPageTitle()->getPrefixedText(),
+ ];
+ }
+
+ $this->assertArrayEquals(
+ $expectedRedirectOptions,
+ $redirectQuery,
+ /* $ordered= */ false,
+ /* $named= */ true,
+ 'redirection query'
+ );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php b/www/wiki/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php
new file mode 100644
index 00000000..aeaa1aee
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php
@@ -0,0 +1,1098 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * Test class for ChangesListSpecialPage class
+ *
+ * Copyright © 2011-, Antoine Musso, Stephane Bisson, Matthew Flaschen
+ *
+ * @author Antoine Musso
+ * @author Stephane Bisson
+ * @author Matthew Flaschen
+ * @group Database
+ *
+ * @covers ChangesListSpecialPage
+ */
+class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase {
+ public function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( [
+ 'wgStructuredChangeFiltersShowPreference' => true,
+ ] );
+ }
+
+ protected function getPage() {
+ $mock = $this->getMockBuilder( ChangesListSpecialPage::class )
+ ->setConstructorArgs(
+ [
+ 'ChangesListSpecialPage',
+ ''
+ ]
+ )
+ ->setMethods( [ 'getPageTitle' ] )
+ ->getMockForAbstractClass();
+
+ $mock->method( 'getPageTitle' )->willReturn(
+ Title::makeTitle( NS_SPECIAL, 'ChangesListSpecialPage' )
+ );
+
+ $mock = TestingAccessWrapper::newFromObject(
+ $mock
+ );
+
+ return $mock;
+ }
+
+ private function buildQuery(
+ $requestOptions = null,
+ $user = null
+ ) {
+ $context = new RequestContext;
+ $context->setRequest( new FauxRequest( $requestOptions ) );
+ if ( $user ) {
+ $context->setUser( $user );
+ }
+
+ $this->changesListSpecialPage->setContext( $context );
+ $this->changesListSpecialPage->filterGroups = [];
+ $formOptions = $this->changesListSpecialPage->setup( null );
+
+ #  Filter out rc_timestamp conditions which depends on the test runtime
+ # This condition is not needed as of march 2, 2011 -- hashar
+ # @todo FIXME: Find a way to generate the correct rc_timestamp
+
+ $tables = [];
+ $fields = [];
+ $queryConditions = [];
+ $query_options = [];
+ $join_conds = [];
+
+ call_user_func_array(
+ [ $this->changesListSpecialPage, 'buildQuery' ],
+ [
+ &$tables,
+ &$fields,
+ &$queryConditions,
+ &$query_options,
+ &$join_conds,
+ $formOptions
+ ]
+ );
+
+ $queryConditions = array_filter(
+ $queryConditions,
+ 'ChangesListSpecialPageTest::filterOutRcTimestampCondition'
+ );
+
+ return $queryConditions;
+ }
+
+ /** helper to test SpecialRecentchanges::buildQuery() */
+ private function assertConditions(
+ $expected,
+ $requestOptions = null,
+ $message = '',
+ $user = null
+ ) {
+ $queryConditions = $this->buildQuery( $requestOptions, $user );
+
+ $this->assertEquals(
+ self::normalizeCondition( $expected ),
+ self::normalizeCondition( $queryConditions ),
+ $message
+ );
+ }
+
+ private static function normalizeCondition( $conds ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $normalized = array_map(
+ function ( $k, $v ) use ( $dbr ) {
+ if ( is_array( $v ) ) {
+ sort( $v );
+ }
+ // (Ab)use makeList() to format only this entry
+ return $dbr->makeList( [ $k => $v ], Database::LIST_AND );
+ },
+ array_keys( $conds ),
+ $conds
+ );
+ sort( $normalized );
+ return $normalized;
+ }
+
+ /** return false if condition begins with 'rc_timestamp ' */
+ private static function filterOutRcTimestampCondition( $var ) {
+ return ( is_array( $var ) || false === strpos( $var, 'rc_timestamp ' ) );
+ }
+
+ public function testRcNsFilter() {
+ $this->assertConditions(
+ [ # expected
+ "rc_namespace = '0'",
+ ],
+ [
+ 'namespace' => NS_MAIN,
+ ],
+ "rc conditions with one namespace"
+ );
+ }
+
+ public function testRcNsFilterInversion() {
+ $this->assertConditions(
+ [ # expected
+ "rc_namespace != '0'",
+ ],
+ [
+ 'namespace' => NS_MAIN,
+ 'invert' => 1,
+ ],
+ "rc conditions with namespace inverted"
+ );
+ }
+
+ public function testRcNsFilterMultiple() {
+ $this->assertConditions(
+ [ # expected
+ "rc_namespace IN ('1','2','3')",
+ ],
+ [
+ 'namespace' => '1;2;3',
+ ],
+ "rc conditions with multiple namespaces"
+ );
+ }
+
+ public function testRcNsFilterMultipleAssociated() {
+ $this->assertConditions(
+ [ # expected
+ "rc_namespace IN ('0','1','4','5','6','7')",
+ ],
+ [
+ 'namespace' => '1;4;7',
+ 'associated' => 1,
+ ],
+ "rc conditions with multiple namespaces and associated"
+ );
+ }
+
+ public function testRcNsFilterMultipleAssociatedInvert() {
+ $this->assertConditions(
+ [ # expected
+ "rc_namespace NOT IN ('2','3','8','9')",
+ ],
+ [
+ 'namespace' => '2;3;9',
+ 'associated' => 1,
+ 'invert' => 1
+ ],
+ "rc conditions with multiple namespaces, associated and inverted"
+ );
+ }
+
+ public function testRcNsFilterMultipleInvert() {
+ $this->assertConditions(
+ [ # expected
+ "rc_namespace NOT IN ('1','2','3')",
+ ],
+ [
+ 'namespace' => '1;2;3',
+ 'invert' => 1,
+ ],
+ "rc conditions with multiple namespaces inverted"
+ );
+ }
+
+ public function testRcHidemyselfFilter() {
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->overrideMwServices();
+
+ $user = $this->getTestUser()->getUser();
+ $user->getActorId( wfGetDB( DB_MASTER ) );
+ $this->assertConditions(
+ [ # expected
+ "NOT((rc_actor = '{$user->getActorId()}') OR "
+ . "(rc_actor = '0' AND rc_user = '{$user->getId()}'))",
+ ],
+ [
+ 'hidemyself' => 1,
+ ],
+ "rc conditions: hidemyself=1 (logged in)",
+ $user
+ );
+
+ $user = User::newFromName( '10.11.12.13', false );
+ $id = $user->getActorId( wfGetDB( DB_MASTER ) );
+ $this->assertConditions(
+ [ # expected
+ "NOT((rc_actor = '$id') OR (rc_actor = '0' AND rc_user_text = '10.11.12.13'))",
+ ],
+ [
+ 'hidemyself' => 1,
+ ],
+ "rc conditions: hidemyself=1 (anon)",
+ $user
+ );
+ }
+
+ public function testRcHidebyothersFilter() {
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->overrideMwServices();
+
+ $user = $this->getTestUser()->getUser();
+ $user->getActorId( wfGetDB( DB_MASTER ) );
+ $this->assertConditions(
+ [ # expected
+ "(rc_actor = '{$user->getActorId()}') OR "
+ . "(rc_actor = '0' AND rc_user_text = '{$user->getName()}')",
+ ],
+ [
+ 'hidebyothers' => 1,
+ ],
+ "rc conditions: hidebyothers=1 (logged in)",
+ $user
+ );
+
+ $user = User::newFromName( '10.11.12.13', false );
+ $id = $user->getActorId( wfGetDB( DB_MASTER ) );
+ $this->assertConditions(
+ [ # expected
+ "(rc_actor = '$id') OR (rc_actor = '0' AND rc_user_text = '10.11.12.13')",
+ ],
+ [
+ 'hidebyothers' => 1,
+ ],
+ "rc conditions: hidebyothers=1 (anon)",
+ $user
+ );
+ }
+
+ public function testRcHidepageedits() {
+ $this->assertConditions(
+ [ # expected
+ "rc_type != '0'",
+ ],
+ [
+ 'hidepageedits' => 1,
+ ],
+ "rc conditions: hidepageedits=1"
+ );
+ }
+
+ public function testRcHidenewpages() {
+ $this->assertConditions(
+ [ # expected
+ "rc_type != '1'",
+ ],
+ [
+ 'hidenewpages' => 1,
+ ],
+ "rc conditions: hidenewpages=1"
+ );
+ }
+
+ public function testRcHidelog() {
+ $this->assertConditions(
+ [ # expected
+ "rc_type != '3'",
+ ],
+ [
+ 'hidelog' => 1,
+ ],
+ "rc conditions: hidelog=1"
+ );
+ }
+
+ public function testRcHidehumans() {
+ $this->assertConditions(
+ [ # expected
+ 'rc_bot' => 1,
+ ],
+ [
+ 'hidebots' => 0,
+ 'hidehumans' => 1,
+ ],
+ "rc conditions: hidebots=0 hidehumans=1"
+ );
+ }
+
+ public function testRcHidepatrolledDisabledFilter() {
+ $this->setMwGlobals( 'wgUseRCPatrol', false );
+ $user = $this->getTestUser()->getUser();
+ $this->assertConditions(
+ [ # expected
+ ],
+ [
+ 'hidepatrolled' => 1,
+ ],
+ "rc conditions: hidepatrolled=1 (user not allowed)",
+ $user
+ );
+ }
+
+ public function testRcHideunpatrolledDisabledFilter() {
+ $this->setMwGlobals( 'wgUseRCPatrol', false );
+ $user = $this->getTestUser()->getUser();
+ $this->assertConditions(
+ [ # expected
+ ],
+ [
+ 'hideunpatrolled' => 1,
+ ],
+ "rc conditions: hideunpatrolled=1 (user not allowed)",
+ $user
+ );
+ }
+ public function testRcHidepatrolledFilter() {
+ $user = $this->getTestSysop()->getUser();
+ $this->assertConditions(
+ [ # expected
+ 'rc_patrolled' => 0,
+ ],
+ [
+ 'hidepatrolled' => 1,
+ ],
+ "rc conditions: hidepatrolled=1",
+ $user
+ );
+ }
+
+ public function testRcHideunpatrolledFilter() {
+ $user = $this->getTestSysop()->getUser();
+ $this->assertConditions(
+ [ # expected
+ 'rc_patrolled' => [ 1, 2 ],
+ ],
+ [
+ 'hideunpatrolled' => 1,
+ ],
+ "rc conditions: hideunpatrolled=1",
+ $user
+ );
+ }
+
+ public function testRcReviewStatusFilter() {
+ $user = $this->getTestSysop()->getUser();
+ $this->assertConditions(
+ [ #expected
+ 'rc_patrolled' => 1,
+ ],
+ [
+ 'reviewStatus' => 'manual'
+ ],
+ "rc conditions: reviewStatus=manual",
+ $user
+ );
+ $this->assertConditions(
+ [ #expected
+ 'rc_patrolled' => [ 0, 2 ],
+ ],
+ [
+ 'reviewStatus' => 'unpatrolled;auto'
+ ],
+ "rc conditions: reviewStatus=unpatrolled;auto",
+ $user
+ );
+ }
+
+ public function testRcHideminorFilter() {
+ $this->assertConditions(
+ [ # expected
+ "rc_minor = 0",
+ ],
+ [
+ 'hideminor' => 1,
+ ],
+ "rc conditions: hideminor=1"
+ );
+ }
+
+ public function testRcHidemajorFilter() {
+ $this->assertConditions(
+ [ # expected
+ "rc_minor = 1",
+ ],
+ [
+ 'hidemajor' => 1,
+ ],
+ "rc conditions: hidemajor=1"
+ );
+ }
+
+ public function testHideCategorization() {
+ $this->assertConditions(
+ [
+ # expected
+ "rc_type != '6'"
+ ],
+ [
+ 'hidecategorization' => 1
+ ],
+ "rc conditions: hidecategorization=1"
+ );
+ }
+
+ public function testFilterUserExpLevelAll() {
+ $this->assertConditions(
+ [
+ # expected
+ ],
+ [
+ 'userExpLevel' => 'registered;unregistered;newcomer;learner;experienced',
+ ],
+ "rc conditions: userExpLevel=registered;unregistered;newcomer;learner;experienced"
+ );
+ }
+
+ public function testFilterUserExpLevelRegisteredUnregistered() {
+ $this->assertConditions(
+ [
+ # expected
+ ],
+ [
+ 'userExpLevel' => 'registered;unregistered',
+ ],
+ "rc conditions: userExpLevel=registered;unregistered"
+ );
+ }
+
+ public function testFilterUserExpLevelRegisteredUnregisteredLearner() {
+ $this->assertConditions(
+ [
+ # expected
+ ],
+ [
+ 'userExpLevel' => 'registered;unregistered;learner',
+ ],
+ "rc conditions: userExpLevel=registered;unregistered;learner"
+ );
+ }
+
+ public function testFilterUserExpLevelAllExperienceLevels() {
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->overrideMwServices();
+
+ $this->assertConditions(
+ [
+ # expected
+ 'COALESCE( actor_rc_user.actor_user, rc_user ) != 0',
+ ],
+ [
+ 'userExpLevel' => 'newcomer;learner;experienced',
+ ],
+ "rc conditions: userExpLevel=newcomer;learner;experienced"
+ );
+ }
+
+ public function testFilterUserExpLevelRegistrered() {
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->overrideMwServices();
+
+ $this->assertConditions(
+ [
+ # expected
+ 'COALESCE( actor_rc_user.actor_user, rc_user ) != 0',
+ ],
+ [
+ 'userExpLevel' => 'registered',
+ ],
+ "rc conditions: userExpLevel=registered"
+ );
+ }
+
+ public function testFilterUserExpLevelUnregistrered() {
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->overrideMwServices();
+
+ $this->assertConditions(
+ [
+ # expected
+ 'COALESCE( actor_rc_user.actor_user, rc_user ) = 0',
+ ],
+ [
+ 'userExpLevel' => 'unregistered',
+ ],
+ "rc conditions: userExpLevel=unregistered"
+ );
+ }
+
+ public function testFilterUserExpLevelRegistreredOrLearner() {
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->overrideMwServices();
+
+ $this->assertConditions(
+ [
+ # expected
+ 'COALESCE( actor_rc_user.actor_user, rc_user ) != 0',
+ ],
+ [
+ 'userExpLevel' => 'registered;learner',
+ ],
+ "rc conditions: userExpLevel=registered;learner"
+ );
+ }
+
+ public function testFilterUserExpLevelUnregistreredOrExperienced() {
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->overrideMwServices();
+
+ $conds = $this->buildQuery( [ 'userExpLevel' => 'unregistered;experienced' ] );
+
+ $this->assertRegExp(
+ '/\(COALESCE\( actor_rc_user.actor_user, rc_user \) = 0\) OR '
+ . '\(\(user_editcount >= 500\) AND \(user_registration <= \'[^\']+\'\)\)/',
+ reset( $conds ),
+ "rc conditions: userExpLevel=unregistered;experienced"
+ );
+ }
+
+ public function testFilterUserExpLevel() {
+ $now = time();
+ $this->setMwGlobals( [
+ 'wgLearnerEdits' => 10,
+ 'wgLearnerMemberSince' => 4,
+ 'wgExperiencedUserEdits' => 500,
+ 'wgExperiencedUserMemberSince' => 30,
+ ] );
+
+ $this->createUsers( [
+ 'Newcomer1' => [ 'edits' => 2, 'days' => 2 ],
+ 'Newcomer2' => [ 'edits' => 12, 'days' => 3 ],
+ 'Newcomer3' => [ 'edits' => 8, 'days' => 5 ],
+ 'Learner1' => [ 'edits' => 15, 'days' => 10 ],
+ 'Learner2' => [ 'edits' => 450, 'days' => 20 ],
+ 'Learner3' => [ 'edits' => 460, 'days' => 33 ],
+ 'Learner4' => [ 'edits' => 525, 'days' => 28 ],
+ 'Experienced1' => [ 'edits' => 538, 'days' => 33 ],
+ ], $now );
+
+ // newcomers only
+ $this->assertArrayEquals(
+ [ 'Newcomer1', 'Newcomer2', 'Newcomer3' ],
+ $this->fetchUsers( [ 'newcomer' ], $now )
+ );
+
+ // newcomers and learner
+ $this->assertArrayEquals(
+ [
+ 'Newcomer1', 'Newcomer2', 'Newcomer3',
+ 'Learner1', 'Learner2', 'Learner3', 'Learner4',
+ ],
+ $this->fetchUsers( [ 'newcomer', 'learner' ], $now )
+ );
+
+ // newcomers and more learner
+ $this->assertArrayEquals(
+ [
+ 'Newcomer1', 'Newcomer2', 'Newcomer3',
+ 'Experienced1',
+ ],
+ $this->fetchUsers( [ 'newcomer', 'experienced' ], $now )
+ );
+
+ // learner only
+ $this->assertArrayEquals(
+ [ 'Learner1', 'Learner2', 'Learner3', 'Learner4' ],
+ $this->fetchUsers( [ 'learner' ], $now )
+ );
+
+ // more experienced only
+ $this->assertArrayEquals(
+ [ 'Experienced1' ],
+ $this->fetchUsers( [ 'experienced' ], $now )
+ );
+
+ // learner and more experienced
+ $this->assertArrayEquals(
+ [
+ 'Learner1', 'Learner2', 'Learner3', 'Learner4',
+ 'Experienced1',
+ ],
+ $this->fetchUsers( [ 'learner', 'experienced' ], $now ),
+ 'Learner and more experienced'
+ );
+ }
+
+ private function createUsers( $specs, $now ) {
+ $dbw = wfGetDB( DB_MASTER );
+ foreach ( $specs as $name => $spec ) {
+ User::createNew(
+ $name,
+ [
+ 'editcount' => $spec['edits'],
+ 'registration' => $dbw->timestamp( $this->daysAgo( $spec['days'], $now ) ),
+ 'email' => 'ut',
+ ]
+ );
+ }
+ }
+
+ private function fetchUsers( $filters, $now ) {
+ $tables = [];
+ $conds = [];
+ $fields = [];
+ $query_options = [];
+ $join_conds = [];
+
+ sort( $filters );
+
+ call_user_func_array(
+ [ $this->changesListSpecialPage, 'filterOnUserExperienceLevel' ],
+ [
+ get_class( $this->changesListSpecialPage ),
+ $this->changesListSpecialPage->getContext(),
+ $this->changesListSpecialPage->getDB(),
+ &$tables,
+ &$fields,
+ &$conds,
+ &$query_options,
+ &$join_conds,
+ $filters,
+ $now
+ ]
+ );
+
+ // @todo: This is not at all safe or sane. It just blindly assumes
+ // nothing in $conds depends on any other tables.
+ $result = wfGetDB( DB_MASTER )->select(
+ 'user',
+ 'user_name',
+ array_filter( $conds ) + [ 'user_email' => 'ut' ]
+ );
+
+ $usernames = [];
+ foreach ( $result as $row ) {
+ $usernames[] = $row->user_name;
+ }
+
+ return $usernames;
+ }
+
+ private function daysAgo( $days, $now ) {
+ $secondsPerDay = 86400;
+ return $now - $days * $secondsPerDay;
+ }
+
+ public function testGetFilterGroupDefinitionFromLegacyCustomFilters() {
+ $customFilters = [
+ 'hidefoo' => [
+ 'msg' => 'showhidefoo',
+ 'default' => true,
+ ],
+
+ 'hidebar' => [
+ 'msg' => 'showhidebar',
+ 'default' => false,
+ ],
+ ];
+
+ $this->assertEquals(
+ [
+ 'name' => 'unstructured',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'priority' => -1,
+ 'filters' => [
+ [
+ 'name' => 'hidefoo',
+ 'showHide' => 'showhidefoo',
+ 'default' => true,
+ ],
+ [
+ 'name' => 'hidebar',
+ 'showHide' => 'showhidebar',
+ 'default' => false,
+ ]
+ ],
+ ],
+ $this->changesListSpecialPage->getFilterGroupDefinitionFromLegacyCustomFilters(
+ $customFilters
+ )
+ );
+ }
+
+ public function testGetStructuredFilterJsData() {
+ $this->changesListSpecialPage->filterGroups = [];
+
+ $definition = [
+ [
+ 'name' => 'gub-group',
+ 'title' => 'gub-group-title',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'filters' => [
+ [
+ 'name' => 'hidefoo',
+ 'label' => 'foo-label',
+ 'description' => 'foo-description',
+ 'default' => true,
+ 'showHide' => 'showhidefoo',
+ 'priority' => 2,
+ ],
+ [
+ 'name' => 'hidebar',
+ 'label' => 'bar-label',
+ 'description' => 'bar-description',
+ 'default' => false,
+ 'priority' => 4,
+ ]
+ ],
+ ],
+
+ [
+ 'name' => 'des-group',
+ 'title' => 'des-group-title',
+ 'class' => ChangesListStringOptionsFilterGroup::class,
+ 'isFullCoverage' => true,
+ 'filters' => [
+ [
+ 'name' => 'grault',
+ 'label' => 'grault-label',
+ 'description' => 'grault-description',
+ ],
+ [
+ 'name' => 'garply',
+ 'label' => 'garply-label',
+ 'description' => 'garply-description',
+ ],
+ ],
+ 'queryCallable' => function () {
+ },
+ 'default' => ChangesListStringOptionsFilterGroup::NONE,
+ ],
+
+ [
+ 'name' => 'unstructured',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'filters' => [
+ [
+ 'name' => 'hidethud',
+ 'showHide' => 'showhidethud',
+ 'default' => true,
+ ],
+
+ [
+ 'name' => 'hidemos',
+ 'showHide' => 'showhidemos',
+ 'default' => false,
+ ],
+ ],
+ ],
+
+ ];
+
+ $this->changesListSpecialPage->registerFiltersFromDefinitions( $definition );
+
+ $this->assertArrayEquals(
+ [
+ // Filters that only display in the unstructured UI are
+ // are not included, and neither are groups that would
+ // be empty due to the above.
+ 'groups' => [
+ [
+ 'name' => 'gub-group',
+ 'title' => 'gub-group-title',
+ 'type' => ChangesListBooleanFilterGroup::TYPE,
+ 'priority' => -1,
+ 'filters' => [
+ [
+ 'name' => 'hidebar',
+ 'label' => 'bar-label',
+ 'description' => 'bar-description',
+ 'default' => false,
+ 'priority' => 4,
+ 'cssClass' => null,
+ 'conflicts' => [],
+ 'subset' => [],
+ 'defaultHighlightColor' => null
+ ],
+ [
+ 'name' => 'hidefoo',
+ 'label' => 'foo-label',
+ 'description' => 'foo-description',
+ 'default' => true,
+ 'priority' => 2,
+ 'cssClass' => null,
+ 'conflicts' => [],
+ 'subset' => [],
+ 'defaultHighlightColor' => null
+ ],
+ ],
+ 'fullCoverage' => true,
+ 'conflicts' => [],
+ ],
+
+ [
+ 'name' => 'des-group',
+ 'title' => 'des-group-title',
+ 'type' => ChangesListStringOptionsFilterGroup::TYPE,
+ 'priority' => -2,
+ 'fullCoverage' => true,
+ 'filters' => [
+ [
+ 'name' => 'grault',
+ 'label' => 'grault-label',
+ 'description' => 'grault-description',
+ 'cssClass' => null,
+ 'priority' => -2,
+ 'conflicts' => [],
+ 'subset' => [],
+ 'defaultHighlightColor' => null
+ ],
+ [
+ 'name' => 'garply',
+ 'label' => 'garply-label',
+ 'description' => 'garply-description',
+ 'cssClass' => null,
+ 'priority' => -3,
+ 'conflicts' => [],
+ 'subset' => [],
+ 'defaultHighlightColor' => null
+ ],
+ ],
+ 'conflicts' => [],
+ 'separator' => ';',
+ 'default' => ChangesListStringOptionsFilterGroup::NONE,
+ ],
+ ],
+ 'messageKeys' => [
+ 'gub-group-title',
+ 'bar-label',
+ 'bar-description',
+ 'foo-label',
+ 'foo-description',
+ 'des-group-title',
+ 'grault-label',
+ 'grault-description',
+ 'garply-label',
+ 'garply-description',
+ ],
+ ],
+ $this->changesListSpecialPage->getStructuredFilterJsData(),
+ /** ordered= */ false,
+ /** named= */ true
+ );
+ }
+
+ public function provideParseParameters() {
+ return [
+ [ 'hidebots', [ 'hidebots' => true ] ],
+
+ [ 'bots', [ 'hidebots' => false ] ],
+
+ [ 'hideminor', [ 'hideminor' => true ] ],
+
+ [ 'minor', [ 'hideminor' => false ] ],
+
+ [ 'hidemajor', [ 'hidemajor' => true ] ],
+
+ [ 'hideliu', [ 'hideliu' => true ] ],
+
+ [ 'hidepatrolled', [ 'hidepatrolled' => true ] ],
+
+ [ 'hideunpatrolled', [ 'hideunpatrolled' => true ] ],
+
+ [ 'hideanons', [ 'hideanons' => true ] ],
+
+ [ 'hidemyself', [ 'hidemyself' => true ] ],
+
+ [ 'hidebyothers', [ 'hidebyothers' => true ] ],
+
+ [ 'hidehumans', [ 'hidehumans' => true ] ],
+
+ [ 'hidepageedits', [ 'hidepageedits' => true ] ],
+
+ [ 'pagedits', [ 'hidepageedits' => false ] ],
+
+ [ 'hidenewpages', [ 'hidenewpages' => true ] ],
+
+ [ 'hidecategorization', [ 'hidecategorization' => true ] ],
+
+ [ 'hidelog', [ 'hidelog' => true ] ],
+
+ [
+ 'userExpLevel=learner;experienced',
+ [
+ 'userExpLevel' => 'learner;experienced'
+ ],
+ ],
+
+ // A few random combos
+ [
+ 'bots,hideliu,hidemyself',
+ [
+ 'hidebots' => false,
+ 'hideliu' => true,
+ 'hidemyself' => true,
+ ],
+ ],
+
+ [
+ 'minor,hideanons,categorization',
+ [
+ 'hideminor' => false,
+ 'hideanons' => true,
+ 'hidecategorization' => false,
+ ]
+ ],
+
+ [
+ 'hidehumans,bots,hidecategorization',
+ [
+ 'hidehumans' => true,
+ 'hidebots' => false,
+ 'hidecategorization' => true,
+ ],
+ ],
+
+ [
+ 'hidemyself,userExpLevel=newcomer;learner,hideminor',
+ [
+ 'hidemyself' => true,
+ 'hideminor' => true,
+ 'userExpLevel' => 'newcomer;learner',
+ ],
+ ],
+ ];
+ }
+
+ public function provideGetFilterConflicts() {
+ return [
+ [
+ "parameters" => [],
+ "expectedConflicts" => false,
+ ],
+ [
+ "parameters" => [
+ "hideliu" => true,
+ "userExpLevel" => "newcomer",
+ ],
+ "expectedConflicts" => false,
+ ],
+ [
+ "parameters" => [
+ "hideanons" => true,
+ "userExpLevel" => "learner",
+ ],
+ "expectedConflicts" => false,
+ ],
+ [
+ "parameters" => [
+ "hidemajor" => true,
+ "hidenewpages" => true,
+ "hidepageedits" => true,
+ "hidecategorization" => false,
+ "hidelog" => true,
+ "hideWikidata" => true,
+ ],
+ "expectedConflicts" => true,
+ ],
+ [
+ "parameters" => [
+ "hidemajor" => true,
+ "hidenewpages" => false,
+ "hidepageedits" => true,
+ "hidecategorization" => false,
+ "hidelog" => false,
+ "hideWikidata" => true,
+ ],
+ "expectedConflicts" => true,
+ ],
+ [
+ "parameters" => [
+ "hidemajor" => true,
+ "hidenewpages" => false,
+ "hidepageedits" => false,
+ "hidecategorization" => true,
+ "hidelog" => true,
+ "hideWikidata" => true,
+ ],
+ "expectedConflicts" => false,
+ ],
+ [
+ "parameters" => [
+ "hideminor" => true,
+ "hidenewpages" => true,
+ "hidepageedits" => true,
+ "hidecategorization" => false,
+ "hidelog" => true,
+ "hideWikidata" => true,
+ ],
+ "expectedConflicts" => false,
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetFilterConflicts
+ */
+ public function testGetFilterConflicts( $parameters, $expectedConflicts ) {
+ $context = new RequestContext;
+ $context->setRequest( new FauxRequest( $parameters ) );
+ $this->changesListSpecialPage->setContext( $context );
+
+ $this->assertEquals(
+ $expectedConflicts,
+ $this->changesListSpecialPage->areFiltersInConflict()
+ );
+ }
+
+ public function validateOptionsProvider() {
+ return [
+ [
+ [ 'hideanons' => 1, 'hideliu' => 1, 'hidebots' => 1 ],
+ true,
+ [ 'userExpLevel' => 'unregistered', 'hidebots' => 1, ],
+ ],
+ [
+ [ 'hideanons' => 1, 'hideliu' => 1, 'hidebots' => 0 ],
+ true,
+ [ 'hidebots' => 0, 'hidehumans' => 1 ],
+ ],
+ [
+ [ 'hideanons' => 1 ],
+ true,
+ [ 'userExpLevel' => 'registered' ]
+ ],
+ [
+ [ 'hideliu' => 1 ],
+ true,
+ [ 'userExpLevel' => 'unregistered' ]
+ ],
+ [
+ [ 'hideanons' => 1, 'hidebots' => 1 ],
+ true,
+ [ 'userExpLevel' => 'registered', 'hidebots' => 1 ]
+ ],
+ [
+ [ 'hideliu' => 1, 'hidebots' => 0 ],
+ true,
+ [ 'userExpLevel' => 'unregistered', 'hidebots' => 0 ]
+ ],
+ [
+ [ 'hidemyself' => 1, 'hidebyothers' => 1 ],
+ true,
+ [],
+ ],
+ [
+ [ 'hidebots' => 1, 'hidehumans' => 1 ],
+ true,
+ [],
+ ],
+ [
+ [ 'hidepatrolled' => 1, 'hideunpatrolled' => 1 ],
+ true,
+ [],
+ ],
+ [
+ [ 'hideminor' => 1, 'hidemajor' => 1 ],
+ true,
+ [],
+ ],
+ [
+ // changeType
+ [ 'hidepageedits' => 1, 'hidenewpages' => 1, 'hidecategorization' => 1, 'hidelog' => 1, ],
+ true,
+ [],
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php b/www/wiki/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php
new file mode 100644
index 00000000..9ac546dc
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php
@@ -0,0 +1,285 @@
+<?php
+use Wikimedia\ScopedCallback;
+
+/**
+ * Factory for handling the special page list and generating SpecialPage objects.
+ *
+ * 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
+ *
+ * @covers SpecialPageFactory
+ * @group SpecialPage
+ */
+class SpecialPageFactoryTest extends MediaWikiTestCase {
+
+ protected function tearDown() {
+ parent::tearDown();
+
+ SpecialPageFactory::resetList();
+ }
+
+ public function testResetList() {
+ SpecialPageFactory::resetList();
+ $this->assertContains( 'Specialpages', SpecialPageFactory::getNames() );
+ }
+
+ public function testHookNotCalledTwice() {
+ $count = 0;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'SpecialPage_initList' => [
+ function () use ( &$count ) {
+ $count++;
+ }
+ ] ] );
+ SpecialPageFactory::resetList();
+ SpecialPageFactory::getNames();
+ SpecialPageFactory::getNames();
+ $this->assertEquals( 1, $count );
+ }
+
+ public function newSpecialAllPages() {
+ return new SpecialAllPages();
+ }
+
+ public function specialPageProvider() {
+ $specialPageTestHelper = new SpecialPageTestHelper();
+
+ return [
+ 'class name' => [ 'SpecialAllPages', false ],
+ 'closure' => [ function () {
+ return new SpecialAllPages();
+ }, false ],
+ 'function' => [ [ $this, 'newSpecialAllPages' ], false ],
+ 'callback string' => [ 'SpecialPageTestHelper::newSpecialAllPages', false ],
+ 'callback with object' => [
+ [ $specialPageTestHelper, 'newSpecialAllPages' ],
+ false
+ ],
+ 'callback array' => [
+ [ 'SpecialPageTestHelper', 'newSpecialAllPages' ],
+ false
+ ]
+ ];
+ }
+
+ /**
+ * @covers SpecialPageFactory::getPage
+ * @dataProvider specialPageProvider
+ */
+ public function testGetPage( $spec, $shouldReuseInstance ) {
+ $this->mergeMwGlobalArrayValue( 'wgSpecialPages', [ 'testdummy' => $spec ] );
+ SpecialPageFactory::resetList();
+
+ $page = SpecialPageFactory::getPage( 'testdummy' );
+ $this->assertInstanceOf( SpecialPage::class, $page );
+
+ $page2 = SpecialPageFactory::getPage( 'testdummy' );
+ $this->assertEquals( $shouldReuseInstance, $page2 === $page, "Should re-use instance:" );
+ }
+
+ /**
+ * @covers SpecialPageFactory::getNames
+ */
+ public function testGetNames() {
+ $this->mergeMwGlobalArrayValue( 'wgSpecialPages', [ 'testdummy' => SpecialAllPages::class ] );
+ SpecialPageFactory::resetList();
+
+ $names = SpecialPageFactory::getNames();
+ $this->assertInternalType( 'array', $names );
+ $this->assertContains( 'testdummy', $names );
+ }
+
+ /**
+ * @covers SpecialPageFactory::resolveAlias
+ */
+ public function testResolveAlias() {
+ $this->setMwGlobals( 'wgContLang', Language::factory( 'de' ) );
+ SpecialPageFactory::resetList();
+
+ list( $name, $param ) = SpecialPageFactory::resolveAlias( 'Spezialseiten/Foo' );
+ $this->assertEquals( 'Specialpages', $name );
+ $this->assertEquals( 'Foo', $param );
+ }
+
+ /**
+ * @covers SpecialPageFactory::getLocalNameFor
+ */
+ public function testGetLocalNameFor() {
+ $this->setMwGlobals( 'wgContLang', Language::factory( 'de' ) );
+ SpecialPageFactory::resetList();
+
+ $name = SpecialPageFactory::getLocalNameFor( 'Specialpages', 'Foo' );
+ $this->assertEquals( 'Spezialseiten/Foo', $name );
+ }
+
+ /**
+ * @covers SpecialPageFactory::getTitleForAlias
+ */
+ public function testGetTitleForAlias() {
+ $this->setMwGlobals( 'wgContLang', Language::factory( 'de' ) );
+ SpecialPageFactory::resetList();
+
+ $title = SpecialPageFactory::getTitleForAlias( 'Specialpages/Foo' );
+ $this->assertEquals( 'Spezialseiten/Foo', $title->getText() );
+ $this->assertEquals( NS_SPECIAL, $title->getNamespace() );
+ }
+
+ /**
+ * @dataProvider provideTestConflictResolution
+ */
+ public function testConflictResolution(
+ $test, $aliasesList, $alias, $expectedName, $expectedAlias, $expectWarnings
+ ) {
+ global $wgContLang;
+ $lang = clone $wgContLang;
+ $lang->mExtendedSpecialPageAliases = $aliasesList;
+ $this->setMwGlobals( 'wgContLang', $lang );
+ $this->setMwGlobals( 'wgSpecialPages',
+ array_combine( array_keys( $aliasesList ), array_keys( $aliasesList ) )
+ );
+ SpecialPageFactory::resetList();
+
+ // Catch the warnings we expect to be raised
+ $warnings = [];
+ $this->setMwGlobals( 'wgDevelopmentWarnings', true );
+ set_error_handler( function ( $errno, $errstr ) use ( &$warnings ) {
+ if ( preg_match( '/First alias \'[^\']*\' for .*/', $errstr ) ||
+ preg_match( '/Did not find a usable alias for special page .*/', $errstr )
+ ) {
+ $warnings[] = $errstr;
+ return true;
+ }
+ return false;
+ } );
+ $reset = new ScopedCallback( 'restore_error_handler' );
+
+ list( $name, /*...*/ ) = SpecialPageFactory::resolveAlias( $alias );
+ $this->assertEquals( $expectedName, $name, "$test: Alias to name" );
+ $result = SpecialPageFactory::getLocalNameFor( $name );
+ $this->assertEquals( $expectedAlias, $result, "$test: Alias to name to alias" );
+
+ $gotWarnings = count( $warnings );
+ if ( $gotWarnings !== $expectWarnings ) {
+ $this->fail( "Expected $expectWarnings warning(s), but got $gotWarnings:\n" .
+ implode( "\n", $warnings )
+ );
+ }
+ }
+
+ /**
+ * @dataProvider provideTestConflictResolution
+ */
+ public function testConflictResolutionReversed(
+ $test, $aliasesList, $alias, $expectedName, $expectedAlias, $expectWarnings
+ ) {
+ // Make sure order doesn't matter by reversing the list
+ $aliasesList = array_reverse( $aliasesList );
+ return $this->testConflictResolution(
+ $test, $aliasesList, $alias, $expectedName, $expectedAlias, $expectWarnings
+ );
+ }
+
+ public function provideTestConflictResolution() {
+ return [
+ [
+ 'Canonical name wins',
+ [ 'Foo' => [ 'Foo', 'Bar' ], 'Baz' => [ 'Foo', 'BazPage', 'Baz2' ] ],
+ 'Foo',
+ 'Foo',
+ 'Foo',
+ 1,
+ ],
+
+ [
+ 'Doesn\'t redirect to a different special page\'s canonical name',
+ [ 'Foo' => [ 'Foo', 'Bar' ], 'Baz' => [ 'Foo', 'BazPage', 'Baz2' ] ],
+ 'Baz',
+ 'Baz',
+ 'BazPage',
+ 1,
+ ],
+
+ [
+ 'Canonical name wins even if not aliased',
+ [ 'Foo' => [ 'FooPage' ], 'Baz' => [ 'Foo', 'BazPage', 'Baz2' ] ],
+ 'Foo',
+ 'Foo',
+ 'FooPage',
+ 1,
+ ],
+
+ [
+ 'Doesn\'t redirect to a different special page\'s canonical name even if not aliased',
+ [ 'Foo' => [ 'FooPage' ], 'Baz' => [ 'Foo', 'BazPage', 'Baz2' ] ],
+ 'Baz',
+ 'Baz',
+ 'BazPage',
+ 1,
+ ],
+
+ [
+ 'First local name beats non-first',
+ [ 'First' => [ 'Foo' ], 'NonFirst' => [ 'Bar', 'Foo' ] ],
+ 'Foo',
+ 'First',
+ 'Foo',
+ 0,
+ ],
+
+ [
+ 'Doesn\'t redirect to a different special page\'s first alias',
+ [
+ 'Foo' => [ 'Foo' ],
+ 'First' => [ 'Bar' ],
+ 'Baz' => [ 'Foo', 'Bar', 'BazPage', 'Baz2' ]
+ ],
+ 'Baz',
+ 'Baz',
+ 'BazPage',
+ 1,
+ ],
+
+ [
+ 'Doesn\'t redirect wrong even if all aliases conflict',
+ [
+ 'Foo' => [ 'Foo' ],
+ 'First' => [ 'Bar' ],
+ 'Baz' => [ 'Foo', 'Bar' ]
+ ],
+ 'Baz',
+ 'Baz',
+ 'Baz',
+ 2,
+ ],
+
+ ];
+ }
+
+ public function testGetAliasListRecursion() {
+ $called = false;
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'SpecialPage_initList' => [
+ function () use ( &$called ) {
+ SpecialPageFactory::getLocalNameFor( 'Specialpages' );
+ $called = true;
+ }
+ ],
+ ] );
+ SpecialPageFactory::resetList();
+ SpecialPageFactory::getLocalNameFor( 'Specialpages' );
+ $this->assertTrue( $called, 'Recursive call succeeded' );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/specialpage/SpecialPageTest.php b/www/wiki/tests/phpunit/includes/specialpage/SpecialPageTest.php
new file mode 100644
index 00000000..2ad39729
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specialpage/SpecialPageTest.php
@@ -0,0 +1,104 @@
+<?php
+
+/**
+ * @covers SpecialPage
+ *
+ * @group Database
+ *
+ * @author Katie Filbert < aude.wiki@gmail.com >
+ */
+class SpecialPageTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgScript' => '/index.php',
+ 'wgContLang' => Language::factory( 'en' )
+ ] );
+ }
+
+ /**
+ * @dataProvider getTitleForProvider
+ */
+ public function testGetTitleFor( $expectedName, $name ) {
+ $title = SpecialPage::getTitleFor( $name );
+ $expected = Title::makeTitle( NS_SPECIAL, $expectedName );
+ $this->assertEquals( $expected, $title );
+ }
+
+ public function getTitleForProvider() {
+ return [
+ [ 'UserLogin', 'Userlogin' ]
+ ];
+ }
+
+ /**
+ * @expectedException PHPUnit_Framework_Error_Notice
+ */
+ public function testInvalidGetTitleFor() {
+ $title = SpecialPage::getTitleFor( 'cat' );
+ $expected = Title::makeTitle( NS_SPECIAL, 'Cat' );
+ $this->assertEquals( $expected, $title );
+ }
+
+ /**
+ * @expectedException PHPUnit_Framework_Error_Notice
+ * @dataProvider getTitleForWithWarningProvider
+ */
+ public function testGetTitleForWithWarning( $expected, $name ) {
+ $title = SpecialPage::getTitleFor( $name );
+ $this->assertEquals( $expected, $title );
+ }
+
+ public function getTitleForWithWarningProvider() {
+ return [
+ [ Title::makeTitle( NS_SPECIAL, 'UserLogin' ), 'UserLogin' ]
+ ];
+ }
+
+ /**
+ * @dataProvider requireLoginAnonProvider
+ */
+ public function testRequireLoginAnon( $expected, $reason, $title ) {
+ $specialPage = new SpecialPage( 'Watchlist', 'viewmywatchlist' );
+
+ $user = User::newFromId( 0 );
+ $specialPage->getContext()->setUser( $user );
+ $specialPage->getContext()->setLanguage( Language::factory( 'en' ) );
+
+ $this->setExpectedException( UserNotLoggedIn::class, $expected );
+
+ // $specialPage->requireLogin( [ $reason [, $title ] ] )
+ call_user_func_array(
+ [ $specialPage, 'requireLogin' ],
+ array_filter( [ $reason, $title ] )
+ );
+ }
+
+ public function requireLoginAnonProvider() {
+ $lang = 'en';
+
+ $expected1 = wfMessage( 'exception-nologin-text' )->inLanguage( $lang )->text();
+ $expected2 = wfMessage( 'about' )->inLanguage( $lang )->text();
+
+ return [
+ [ $expected1, null, null ],
+ [ $expected2, 'about', null ],
+ [ $expected2, 'about', 'about' ],
+ ];
+ }
+
+ public function testRequireLoginNotAnon() {
+ $specialPage = new SpecialPage( 'Watchlist', 'viewmywatchlist' );
+
+ $user = User::newFromName( "UTSysop" );
+ $specialPage->getContext()->setUser( $user );
+
+ $specialPage->requireLogin();
+
+ // no exception thrown, logged in use can access special page
+ $this->assertTrue( true );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/specialpage/SpecialPageTestHelper.php b/www/wiki/tests/phpunit/includes/specialpage/SpecialPageTestHelper.php
new file mode 100644
index 00000000..37e29dcb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specialpage/SpecialPageTestHelper.php
@@ -0,0 +1,24 @@
+<?php
+/**
+ * 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
+ */
+class SpecialPageTestHelper {
+
+ public static function newSpecialAllPages() {
+ return new SpecialAllPages();
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/ContribsPagerTest.php b/www/wiki/tests/phpunit/includes/specials/ContribsPagerTest.php
new file mode 100644
index 00000000..1147805c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/ContribsPagerTest.php
@@ -0,0 +1,118 @@
+<?php
+
+/**
+ * @group Database
+ */
+class ContribsPagerTest extends MediaWikiTestCase {
+ /** @var ContribsPager */
+ private $pager;
+
+ function setUp() {
+ $context = new RequestContext();
+ $this->pager = new ContribsPager( $context, [
+ 'start' => '2017-01-01',
+ 'end' => '2017-02-02',
+ ] );
+
+ parent::setUp();
+ }
+
+ /**
+ * @covers ContribsPager::processDateFilter
+ * @dataProvider dateFilterOptionProcessingProvider
+ * @param array $inputOpts Input options
+ * @param array $expectedOpts Expected options
+ */
+ public function testDateFilterOptionProcessing( $inputOpts, $expectedOpts ) {
+ $this->assertArraySubset( $expectedOpts, ContribsPager::processDateFilter( $inputOpts ) );
+ }
+
+ public static function dateFilterOptionProcessingProvider() {
+ return [
+ [ [ 'start' => '2016-05-01',
+ 'end' => '2016-06-01',
+ 'year' => null,
+ 'month' => null ],
+ [ 'start' => '2016-05-01',
+ 'end' => '2016-06-01' ] ],
+ [ [ 'start' => '2016-05-01',
+ 'end' => '2016-06-01',
+ 'year' => '',
+ 'month' => '' ],
+ [ 'start' => '2016-05-01',
+ 'end' => '2016-06-01' ] ],
+ [ [ 'start' => '2016-05-01',
+ 'end' => '2016-06-01',
+ 'year' => '2012',
+ 'month' => '5' ],
+ [ 'start' => '',
+ 'end' => '2012-05-31' ] ],
+ [ [ 'start' => '',
+ 'end' => '',
+ 'year' => '2012',
+ 'month' => '5' ],
+ [ 'start' => '',
+ 'end' => '2012-05-31' ] ],
+ [ [ 'start' => '',
+ 'end' => '',
+ 'year' => '2012',
+ 'month' => '' ],
+ [ 'start' => '',
+ 'end' => '2012-12-31' ] ],
+ ];
+ }
+
+ /**
+ * @covers ContribsPager::isQueryableRange
+ * @dataProvider provideQueryableRanges
+ */
+ public function testQueryableRanges( $ipRange ) {
+ $this->setMwGlobals( [
+ 'wgRangeContributionsCIDRLimit' => [
+ 'IPv4' => 16,
+ 'IPv6' => 32,
+ ],
+ ] );
+
+ $this->assertTrue(
+ $this->pager->isQueryableRange( $ipRange ),
+ "$ipRange is a queryable IP range"
+ );
+ }
+
+ public function provideQueryableRanges() {
+ return [
+ [ '116.17.184.5/32' ],
+ [ '0.17.184.5/16' ],
+ [ '2000::/32' ],
+ [ '2001:db8::/128' ],
+ ];
+ }
+
+ /**
+ * @covers ContribsPager::isQueryableRange
+ * @dataProvider provideUnqueryableRanges
+ */
+ public function testUnqueryableRanges( $ipRange ) {
+ $this->setMwGlobals( [
+ 'wgRangeContributionsCIDRLimit' => [
+ 'IPv4' => 16,
+ 'IPv6' => 32,
+ ],
+ ] );
+
+ $this->assertFalse(
+ $this->pager->isQueryableRange( $ipRange ),
+ "$ipRange is not a queryable IP range"
+ );
+ }
+
+ public function provideUnqueryableRanges() {
+ return [
+ [ '116.17.184.5/33' ],
+ [ '0.17.184.5/15' ],
+ [ '2000::/31' ],
+ [ '2001:db8::/9999' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/ImageListPagerTest.php b/www/wiki/tests/phpunit/includes/specials/ImageListPagerTest.php
new file mode 100644
index 00000000..10c6d04c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/ImageListPagerTest.php
@@ -0,0 +1,21 @@
+<?php
+/**
+ * Test class for ImageListPagerTest class.
+ *
+ * Copyright © 2013, Antoine Musso
+ * Copyright © 2013, Siebrand Mazeland
+ * Copyright © 2013, Wikimedia Foundation Inc.
+ *
+ * @group Database
+ */
+class ImageListPagerTest extends MediaWikiTestCase {
+ /**
+ * @expectedException MWException
+ * @expectedExceptionMessage invalid_field
+ * @covers ImageListPager::formatValue
+ */
+ public function testFormatValuesThrowException() {
+ $page = new ImageListPager( RequestContext::getMain() );
+ $page->formatValue( 'invalid_field', 'invalid_value' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php b/www/wiki/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php
new file mode 100644
index 00000000..d53a9b8f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php
@@ -0,0 +1,80 @@
+<?php
+/**
+ * Test class to run the query of most of all our special pages
+ *
+ * Copyright © 2011, Antoine Musso
+ *
+ * @author Antoine Musso
+ */
+
+/**
+ * @group Database
+ * @covers QueryPage<extended>
+ */
+class QueryAllSpecialPagesTest extends MediaWikiTestCase {
+
+ /**
+ * @var SpecialPage[]
+ */
+ private $queryPages;
+
+ /** List query pages that can not be tested automatically */
+ protected $manualTest = [
+ LinkSearchPage::class
+ ];
+
+ /**
+ * Pages whose query use the same DB table more than once.
+ * This is used to skip testing those pages when run against a MySQL backend
+ * which does not support reopening a temporary table. See upstream bug:
+ * https://bugs.mysql.com/bug.php?id=10327
+ */
+ protected $reopensTempTable = [
+ BrokenRedirects::class,
+ ];
+
+ /**
+ * Initialize all query page objects
+ */
+ function __construct() {
+ parent::__construct();
+
+ foreach ( QueryPage::getPages() as $page ) {
+ $class = $page[0];
+ $name = $page[1];
+ if ( !in_array( $class, $this->manualTest ) ) {
+ $this->queryPages[$class] = SpecialPageFactory::getPage( $name );
+ }
+ }
+ }
+
+ /**
+ * Test SQL for each of our QueryPages objects
+ * @group Database
+ */
+ public function testQuerypageSqlQuery() {
+ global $wgDBtype;
+
+ foreach ( $this->queryPages as $page ) {
+ // With MySQL, skips special pages reopening a temporary table
+ // See https://bugs.mysql.com/bug.php?id=10327
+ if (
+ $wgDBtype === 'mysql'
+ && in_array( $page->getName(), $this->reopensTempTable )
+ ) {
+ $this->markTestSkipped( "SQL query for page {$page->getName()} "
+ . "can not be tested on MySQL backend (it reopens a temporary table)" );
+ continue;
+ }
+
+ $msg = "SQL query for page {$page->getName()} should give a result wrapper object";
+
+ $result = $page->reallyDoQuery( 50 );
+ if ( $result instanceof ResultWrapper ) {
+ $this->assertTrue( true, $msg );
+ } else {
+ $this->assertFalse( false, $msg );
+ }
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialBlankPageTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialBlankPageTest.php
new file mode 100644
index 00000000..e0d059fb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialBlankPageTest.php
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * @license GNU GPL v2+
+ * @author Addshore
+ *
+ * @covers SpecialBlankpage
+ */
+class SpecialBlankPageTest extends SpecialPageTestBase {
+
+ /**
+ * Returns a new instance of the special page under test.
+ *
+ * @return SpecialPage
+ */
+ protected function newSpecialPage() {
+ return new SpecialBlankpage();
+ }
+
+ public function testHasWikiMsg() {
+ list( $html, ) = $this->executeSpecialPage();
+ $this->assertContains( wfMessage( 'intentionallyblankpage' )->text(), $html );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialBooksourcesTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialBooksourcesTest.php
new file mode 100644
index 00000000..9c71261e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialBooksourcesTest.php
@@ -0,0 +1,51 @@
+<?php
+class SpecialBooksourcesTest extends SpecialPageTestBase {
+ public static function provideISBNs() {
+ return [
+ [ '978-0-300-14424-6', true ],
+ [ '0-14-020652-3', true ],
+ [ '020652-3', false ],
+ [ '9781234567897', true ],
+ [ '1-4133-0454-0', true ],
+ [ '978-1413304541', true ],
+ [ '0136091814', true ],
+ [ '0136091812', false ],
+ [ '9780136091813', true ],
+ [ '9780136091817', false ],
+ [ '123456789X', true ],
+
+ // T69021
+ [ '1413304541', false ],
+ [ '141330454X', false ],
+ [ '1413304540', true ],
+ [ '14133X4540', false ],
+ [ '97814133X4541', false ],
+ [ '978035642615X', false ],
+ [ '9781413304541', true ],
+ [ '9780356426150', true ],
+ ];
+ }
+
+ /**
+ * @covers SpecialBookSources::isValidISBN
+ * @dataProvider provideISBNs
+ */
+ public function testIsValidISBN( $isbn, $isValid ) {
+ $this->assertSame( $isValid, SpecialBookSources::isValidISBN( $isbn ) );
+ }
+
+ protected function newSpecialPage() {
+ return new SpecialBookSources();
+ }
+
+ /**
+ * @covers SpecialBookSources::execute
+ */
+ public function testExecute() {
+ list( $html, ) = $this->executeSpecialPage( 'Invalid', null, 'qqx' );
+ $this->assertContains( '(booksources-invalid-isbn)', $html );
+ list( $html, ) = $this->executeSpecialPage( '0-7475-3269-9', null, 'qqx' );
+ $this->assertNotContains( '(booksources-invalid-isbn)', $html );
+ $this->assertContains( '(booksources-text)', $html );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialEditWatchlistTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialEditWatchlistTest.php
new file mode 100644
index 00000000..05a63dbc
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialEditWatchlistTest.php
@@ -0,0 +1,50 @@
+<?php
+
+/**
+ * @author Addshore
+ *
+ * @group Database
+ *
+ * @covers SpecialEditWatchlist
+ */
+class SpecialEditWatchlistTest extends SpecialPageTestBase {
+
+ /**
+ * Returns a new instance of the special page under test.
+ *
+ * @return SpecialPage
+ */
+ protected function newSpecialPage() {
+ return new SpecialEditWatchlist();
+ }
+
+ public function testNotLoggedIn_throwsException() {
+ $this->setExpectedException( UserNotLoggedIn::class );
+ $this->executeSpecialPage();
+ }
+
+ public function testRootPage_displaysExplanationMessage() {
+ $user = new TestUser( __METHOD__ );
+ list( $html, ) = $this->executeSpecialPage( '', null, 'qqx', $user->getUser() );
+ $this->assertContains( '(watchlistedit-normal-explain)', $html );
+ }
+
+ public function testClearPage_hasClearButtonForm() {
+ $user = new TestUser( __METHOD__ );
+ list( $html, ) = $this->executeSpecialPage( 'clear', null, 'qqx', $user->getUser() );
+ $this->assertRegExp(
+ '/<form class="mw-htmlform" action=".*?Special:EditWatchlist\/clear" method="post">/',
+ $html
+ );
+ }
+
+ public function testEditRawPage_hasTitlesBox() {
+ $user = new TestUser( __METHOD__ );
+ list( $html, ) = $this->executeSpecialPage( 'raw', null, 'qqx', $user->getUser() );
+ $this->assertContains(
+ '<textarea id="mw-input-wpTitles"',
+ $html
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialMIMESearchTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialMIMESearchTest.php
new file mode 100644
index 00000000..4ecb813f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialMIMESearchTest.php
@@ -0,0 +1,49 @@
+<?php
+
+/**
+ * @group Database
+ * @covers MIMEsearchPage
+ */
+class SpecialMIMESearchTest extends MediaWikiTestCase {
+
+ /** @var MIMEsearchPage */
+ private $page;
+
+ function setUp() {
+ $this->page = new MIMEsearchPage;
+ $context = new RequestContext();
+ $context->setTitle( Title::makeTitle( NS_SPECIAL, 'MIMESearch' ) );
+ $context->setRequest( new FauxRequest() );
+ $this->page->setContext( $context );
+
+ parent::setUp();
+ }
+
+ /**
+ * @dataProvider providerMimeFiltering
+ * @param string $par Subpage for special page
+ * @param string $major Major MIME type we expect to look for
+ * @param string $minor Minor MIME type we expect to look for
+ */
+ function testMimeFiltering( $par, $major, $minor ) {
+ $this->page->run( $par );
+ $qi = $this->page->getQueryInfo();
+ $this->assertEquals( $qi['conds']['img_major_mime'], $major );
+ if ( $minor !== null ) {
+ $this->assertEquals( $qi['conds']['img_minor_mime'], $minor );
+ } else {
+ $this->assertArrayNotHasKey( 'img_minor_mime', $qi['conds'] );
+ }
+ $this->assertContains( 'image', $qi['tables'] );
+ }
+
+ function providerMimeFiltering() {
+ return [
+ [ 'image/gif', 'image', 'gif' ],
+ [ 'image/png', 'image', 'png' ],
+ [ 'application/pdf', 'application', 'pdf' ],
+ [ 'image/*', 'image', null ],
+ [ 'multipart/*', 'multipart', null ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialMyLanguageTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialMyLanguageTest.php
new file mode 100644
index 00000000..84fa71a2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialMyLanguageTest.php
@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * @group Database
+ * @covers SpecialMyLanguage
+ */
+class SpecialMyLanguageTest extends MediaWikiTestCase {
+ public function addDBDataOnce() {
+ $titles = [
+ 'Page/Another',
+ 'Page/Another/ar',
+ 'Page/Another/en',
+ 'Page/Another/ru',
+ 'Page/Another/zh-hans',
+ ];
+ foreach ( $titles as $title ) {
+ $page = WikiPage::factory( Title::newFromText( $title ) );
+ if ( $page->getId() == 0 ) {
+ $page->doEditContent(
+ new WikitextContent( 'UTContent' ),
+ 'UTPageSummary',
+ EDIT_NEW,
+ false,
+ User::newFromName( 'UTSysop' ) );
+ }
+ }
+ }
+
+ /**
+ * @covers SpecialMyLanguage::findTitle
+ * @dataProvider provideFindTitle
+ * @param string $expected
+ * @param string $subpage
+ * @param string $langCode
+ * @param string $userLang
+ */
+ public function testFindTitle( $expected, $subpage, $langCode, $userLang ) {
+ $this->setMwGlobals( 'wgLanguageCode', $langCode );
+ $special = new SpecialMyLanguage();
+ $special->getContext()->setLanguage( $userLang );
+ // Test with subpages both enabled and disabled
+ $this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', [ NS_MAIN => true ] );
+ $this->assertTitle( $expected, $special->findTitle( $subpage ) );
+ $this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', [ NS_MAIN => false ] );
+ $this->assertTitle( $expected, $special->findTitle( $subpage ) );
+ }
+
+ /**
+ * @param string $expected
+ * @param Title|null $title
+ */
+ private function assertTitle( $expected, $title ) {
+ if ( $title ) {
+ $title = $title->getPrefixedText();
+ }
+ $this->assertEquals( $expected, $title );
+ }
+
+ public static function provideFindTitle() {
+ // See addDBDataOnce() for page declarations
+ return [
+ // [ $expected, $subpage, $langCode, $userLang ]
+ [ null, '::Fail', 'en', 'en' ],
+ [ 'Page/Another', 'Page/Another/en', 'en', 'en' ],
+ [ 'Page/Another', 'Page/Another', 'en', 'en' ],
+ [ 'Page/Another/ru', 'Page/Another', 'en', 'ru' ],
+ [ 'Page/Another', 'Page/Another', 'en', 'es' ],
+ [ 'Page/Another/zh-hans', 'Page/Another', 'en', 'zh-hans' ],
+ [ 'Page/Another/zh-hans', 'Page/Another', 'en', 'zh-mo' ],
+ [ 'Page/Another/en', 'Page/Another', 'de', 'es' ],
+ [ 'Page/Another/ar', 'Page/Another', 'en', 'ar' ],
+ [ 'Page/Another/ar', 'Page/Another', 'en', 'arz' ],
+ [ 'Page/Another/ar', 'Page/Another/de', 'en', 'arz' ],
+ [ 'Page/Another/ru', 'Page/Another/ru', 'en', 'arz' ],
+ [ 'Page/Another/ar', 'Page/Another/ru', 'en', 'ar' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialPageDataTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialPageDataTest.php
new file mode 100644
index 00000000..40754063
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialPageDataTest.php
@@ -0,0 +1,146 @@
+<?php
+
+/**
+ * @covers SpecialPageData
+ * @group Database
+ * @group SpecialPage
+ *
+ * @author Daniel Kinzler
+ */
+class SpecialPageDataTest extends SpecialPageTestBase {
+
+ protected function newSpecialPage() {
+ $page = new SpecialPageData();
+
+ // why is this needed?
+ $page->getContext()->setOutput( new OutputPage( $page->getContext() ) );
+
+ $page->setRequestHandler( new PageDataRequestHandler() );
+
+ return $page;
+ }
+
+ public function provideExecute() {
+ $cases = [];
+
+ $cases['Empty request'] = [ '', [], [], '!!', 200 ];
+
+ $cases['Only title specified'] = [
+ '',
+ [ 'target' => 'Helsinki' ],
+ [],
+ '!!',
+ 303,
+ [ 'Location' => '!.+!' ]
+ ];
+
+ $cases['Accept only HTML'] = [
+ '',
+ [ 'target' => 'Helsinki' ],
+ [ 'Accept' => 'text/HTML' ],
+ '!!',
+ 303,
+ [ 'Location' => '!Helsinki$!' ]
+ ];
+
+ $cases['Accept only HTML with revid'] = [
+ '',
+ [
+ 'target' => 'Helsinki',
+ 'revision' => '4242',
+ ],
+ [ 'Accept' => 'text/HTML' ],
+ '!!',
+ 303,
+ [ 'Location' => '!Helsinki(\?|&)oldid=4242!' ]
+ ];
+
+ $cases['Nothing specified'] = [
+ 'main/Helsinki',
+ [],
+ [],
+ '!!',
+ 303,
+ [ 'Location' => '!Helsinki&action=raw!' ]
+ ];
+
+ $cases['Nothing specified'] = [
+ '/Helsinki',
+ [],
+ [],
+ '!!',
+ 303,
+ [ 'Location' => '!Helsinki&action=raw!' ]
+ ];
+
+ $cases['Invalid Accept header'] = [
+ 'main/Helsinki',
+ [],
+ [ 'Accept' => 'text/foobar' ],
+ '!!',
+ 406,
+ [],
+ ];
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider provideExecute
+ *
+ * @param string $subpage The subpage to request (or '')
+ * @param array $params Request parameters
+ * @param array $headers Request headers
+ * @param string $expRegExp Regex to match the output against.
+ * @param int $expCode Expected HTTP status code
+ * @param array $expHeaders Expected HTTP response headers
+ */
+ public function testExecute(
+ $subpage,
+ array $params,
+ array $headers,
+ $expRegExp,
+ $expCode = 200,
+ array $expHeaders = []
+ ) {
+ $request = new FauxRequest( $params );
+ $request->response()->header( 'Status: 200 OK', true, 200 ); // init/reset
+
+ foreach ( $headers as $name => $value ) {
+ $request->setHeader( strtoupper( $name ), $value );
+ }
+
+ try {
+ /* @var FauxResponse $response */
+ list( $output, $response ) = $this->executeSpecialPage( $subpage, $request );
+
+ $this->assertEquals( $expCode, $response->getStatusCode(), "status code" );
+ $this->assertRegExp( $expRegExp, $output, "output" );
+
+ foreach ( $expHeaders as $name => $exp ) {
+ $value = $response->getHeader( $name );
+ $this->assertNotNull( $value, "header: $name" );
+ $this->assertInternalType( 'string', $value, "header: $name" );
+ $this->assertRegExp( $exp, $value, "header: $name" );
+ }
+ } catch ( HttpError $e ) {
+ $this->assertEquals( $expCode, $e->getStatusCode(), "status code" );
+ $this->assertRegExp( $expRegExp, $e->getHTML(), "error output" );
+ }
+ }
+
+ public function testSpecialPageWithoutParameters() {
+ $this->setContentLang( Language::factory( 'en' ) );
+ $request = new FauxRequest();
+ $request->response()->header( 'Status: 200 OK', true, 200 ); // init/reset
+
+ list( $output, ) = $this->executeSpecialPage( '', $request );
+
+ $this->assertContains(
+ "Content negotiation applies based on your client's Accept header.",
+ $output,
+ "output"
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialPageExecutor.php b/www/wiki/tests/phpunit/includes/specials/SpecialPageExecutor.php
new file mode 100644
index 00000000..e7cfca7f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialPageExecutor.php
@@ -0,0 +1,129 @@
+<?php
+
+/**
+ * @author Addshore
+ *
+ * @since 1.27
+ */
+class SpecialPageExecutor {
+
+ /**
+ * @param SpecialPage $page The special page to execute
+ * @param string $subPage The subpage parameter to call the page with
+ * @param WebRequest|null $request Web request that may contain URL parameters, etc
+ * @param Language|string|null $language The language which should be used in the context
+ * @param User|null $user The user which should be used in the context of this special page
+ *
+ * @throws Exception
+ * @return array [ string, WebResponse ] A two-elements array containing the HTML output
+ * generated by the special page as well as the response object.
+ */
+ public function executeSpecialPage(
+ SpecialPage $page,
+ $subPage = '',
+ WebRequest $request = null,
+ $language = null,
+ User $user = null
+ ) {
+ $context = $this->newContext( $request, $language, $user );
+
+ $output = new OutputPage( $context );
+ $context->setOutput( $output );
+
+ $page->setContext( $context );
+ $output->setTitle( $page->getPageTitle() );
+
+ $html = $this->getHTMLFromSpecialPage( $page, $subPage );
+ $response = $context->getRequest()->response();
+
+ if ( $response instanceof FauxResponse ) {
+ $code = $response->getStatusCode();
+
+ if ( $code > 0 ) {
+ $response->header( 'Status: ' . $code . ' ' . HttpStatus::getMessage( $code ) );
+ }
+ }
+
+ return [ $html, $response ];
+ }
+
+ /**
+ * @param WebRequest|null $request
+ * @param Language|string|null $language
+ * @param User|null $user
+ *
+ * @return DerivativeContext
+ */
+ private function newContext(
+ WebRequest $request = null,
+ $language = null,
+ User $user = null
+ ) {
+ $context = new DerivativeContext( RequestContext::getMain() );
+
+ $context->setRequest( $request ?: new FauxRequest() );
+
+ if ( $language !== null ) {
+ $context->setLanguage( $language );
+ }
+
+ if ( $user !== null ) {
+ $context->setUser( $user );
+ }
+
+ $this->setEditTokenFromUser( $context );
+
+ return $context;
+ }
+
+ /**
+ * If we are trying to edit and no token is set, supply one.
+ *
+ * @param DerivativeContext $context
+ */
+ private function setEditTokenFromUser( DerivativeContext $context ) {
+ $request = $context->getRequest();
+
+ // Edits via GET are a security issue and should not succeed. On the other hand, not all
+ // POST requests are edits, but should ignore unused parameters.
+ if ( !$request->getCheck( 'wpEditToken' ) && $request->wasPosted() ) {
+ $request->setVal( 'wpEditToken', $context->getUser()->getEditToken() );
+ }
+ }
+
+ /**
+ * @param SpecialPage $page
+ * @param string $subPage
+ *
+ * @throws Exception
+ * @return string HTML
+ */
+ private function getHTMLFromSpecialPage( SpecialPage $page, $subPage ) {
+ ob_start();
+
+ try {
+ $page->execute( $subPage );
+
+ $output = $page->getOutput();
+
+ if ( $output->getRedirect() !== '' ) {
+ $output->output();
+ $html = ob_get_contents();
+ } elseif ( $output->isDisabled() ) {
+ $html = ob_get_contents();
+ } else {
+ $html = $output->getHTML();
+ }
+ } catch ( Exception $ex ) {
+ ob_end_clean();
+
+ // Re-throw exception after "finally" handling because PHP 5.3 doesn't have "finally".
+ throw $ex;
+ }
+
+ ob_end_clean();
+
+ return $html;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialPageTestBase.php b/www/wiki/tests/phpunit/includes/specials/SpecialPageTestBase.php
new file mode 100644
index 00000000..274a23c4
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialPageTestBase.php
@@ -0,0 +1,72 @@
+<?php
+
+/**
+ * Base class for testing special pages.
+ *
+ * @since 1.26
+ *
+ * @license GNU GPL v2+
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ * @author Daniel Kinzler
+ * @author Addshore
+ * @author Thiemo Kreuz
+ */
+abstract class SpecialPageTestBase extends MediaWikiTestCase {
+
+ private $obLevel;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->obLevel = ob_get_level();
+ }
+
+ protected function tearDown() {
+ $obLevel = ob_get_level();
+
+ while ( ob_get_level() > $this->obLevel ) {
+ ob_end_clean();
+ }
+
+ if ( $obLevel !== $this->obLevel ) {
+ $this->fail(
+ "Test changed output buffer level: was {$this->obLevel} before test, but $obLevel after test."
+ );
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Returns a new instance of the special page under test.
+ *
+ * @return SpecialPage
+ */
+ abstract protected function newSpecialPage();
+
+ /**
+ * @param string $subPage The subpage parameter to call the page with
+ * @param WebRequest|null $request Web request that may contain URL parameters, etc
+ * @param Language|string|null $language The language which should be used in the context
+ * @param User|null $user The user which should be used in the context of this special page
+ *
+ * @throws Exception
+ * @return array [ string, WebResponse ] A two-elements array containing the HTML output
+ * generated by the special page as well as the response object.
+ */
+ protected function executeSpecialPage(
+ $subPage = '',
+ WebRequest $request = null,
+ $language = null,
+ User $user = null
+ ) {
+ return ( new SpecialPageExecutor() )->executeSpecialPage(
+ $this->newSpecialPage(),
+ $subPage,
+ $request,
+ $language,
+ $user
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialPreferencesTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialPreferencesTest.php
new file mode 100644
index 00000000..bdfbb62e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialPreferencesTest.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Test class for SpecialPreferences class.
+ *
+ * Copyright © 2013, Antoine Musso
+ * Copyright © 2013, Wikimedia Foundation Inc.
+ */
+
+/**
+ * @group Preferences
+ * @group Database
+ *
+ * @covers SpecialPreferences
+ */
+class SpecialPreferencesTest extends MediaWikiTestCase {
+
+ /**
+ * Make sure a nickname which is longer than $wgMaxSigChars
+ * is not throwing a fatal error.
+ *
+ * Test specifications by Alexandre "ialex" Emsenhuber.
+ * @todo give this test a real name explaining what is being tested here
+ */
+ public function testBug41337() {
+ // Set a low limit
+ $this->setMwGlobals( 'wgMaxSigChars', 2 );
+
+ $user = $this->createMock( User::class );
+ $user->expects( $this->any() )
+ ->method( 'isAnon' )
+ ->will( $this->returnValue( false ) );
+
+ # Yeah foreach requires an array, not NULL =(
+ $user->expects( $this->any() )
+ ->method( 'getEffectiveGroups' )
+ ->will( $this->returnValue( [] ) );
+
+ # The mocked user has a long nickname
+ $user->expects( $this->any() )
+ ->method( 'getOption' )
+ ->will( $this->returnValueMap( [
+ [ 'nickname', null, false, 'superlongnickname' ],
+ ]
+ ) );
+
+ # Forge a request to call the special page
+ $context = new RequestContext();
+ $context->setRequest( new FauxRequest() );
+ $context->setUser( $user );
+ $context->setTitle( Title::newFromText( 'Test' ) );
+
+ # Do the call, should not spurt a fatal error.
+ $special = new SpecialPreferences();
+ $special->setContext( $context );
+ $this->assertNull( $special->execute( [] ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialRecentchangesTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialRecentchangesTest.php
new file mode 100644
index 00000000..0b6962d5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialRecentchangesTest.php
@@ -0,0 +1,52 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * Test class for SpecialRecentchanges class
+ *
+ * @group Database
+ *
+ * @covers SpecialRecentChanges
+ */
+class SpecialRecentchangesTest extends AbstractChangesListSpecialPageTestCase {
+ protected function getPage() {
+ return TestingAccessWrapper::newFromObject(
+ new SpecialRecentChanges
+ );
+ }
+
+ // Below providers should only be for features specific to
+ // RecentChanges. Otherwise, it should go in ChangesListSpecialPageTest
+
+ public function provideParseParameters() {
+ return [
+ [ 'limit=123', [ 'limit' => '123' ] ],
+
+ [ '234', [ 'limit' => '234' ] ],
+
+ [ 'days=3', [ 'days' => '3' ] ],
+
+ [ 'days=0.25', [ 'days' => '0.25' ] ],
+
+ [ 'namespace=5', [ 'namespace' => '5' ] ],
+
+ [ 'namespace=5|3', [ 'namespace' => '5|3' ] ],
+
+ [ 'tagfilter=foo', [ 'tagfilter' => 'foo' ] ],
+
+ [ 'tagfilter=foo;bar', [ 'tagfilter' => 'foo;bar' ] ],
+ ];
+ }
+
+ public function validateOptionsProvider() {
+ return [
+ [
+ // hidebots=1 is default for Special:RecentChanges
+ [ 'hideanons' => 1, 'hideliu' => 1 ],
+ true,
+ [ 'hideliu' => 1 ],
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialSearchTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialSearchTest.php
new file mode 100644
index 00000000..f0a57266
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialSearchTest.php
@@ -0,0 +1,296 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Test class for SpecialSearch class
+ * Copyright © 2012, Antoine Musso
+ *
+ * @author Antoine Musso
+ * @group Database
+ */
+class SpecialSearchTest extends MediaWikiTestCase {
+
+ /**
+ * @covers SpecialSearch::load
+ * @dataProvider provideSearchOptionsTests
+ * @param array $requested Request parameters. For example:
+ * array( 'ns5' => true, 'ns6' => true). Null to use default options.
+ * @param array $userOptions User options to test with. For example:
+ * array('searchNs5' => 1 );. Null to use default options.
+ * @param string $expectedProfile An expected search profile name
+ * @param array $expectedNS Expected namespaces
+ * @param string $message
+ */
+ public function testProfileAndNamespaceLoading( $requested, $userOptions,
+ $expectedProfile, $expectedNS, $message = 'Profile name and namespaces mismatches!'
+ ) {
+ $context = new RequestContext;
+ $context->setUser(
+ $this->newUserWithSearchNS( $userOptions )
+ );
+ /*
+ $context->setRequest( new FauxRequest( [
+ 'ns5'=>true,
+ 'ns6'=>true,
+ ] ));
+ */
+ $context->setRequest( new FauxRequest( $requested ) );
+ $search = new SpecialSearch();
+ $search->setContext( $context );
+ $search->load();
+
+ /**
+ * Verify profile name and namespace in the same assertion to make
+ * sure we will be able to fully compare the above code. PHPUnit stop
+ * after an assertion fail.
+ */
+ $this->assertEquals(
+ [ /** Expected: */
+ 'ProfileName' => $expectedProfile,
+ 'Namespaces' => $expectedNS,
+ ],
+ [ /** Actual: */
+ 'ProfileName' => $search->getProfile(),
+ 'Namespaces' => $search->getNamespaces(),
+ ],
+ $message
+ );
+ }
+
+ public static function provideSearchOptionsTests() {
+ $defaultNS = MediaWikiServices::getInstance()->getSearchEngineConfig()->defaultNamespaces();
+ $EMPTY_REQUEST = [];
+ $NO_USER_PREF = null;
+
+ return [
+ /**
+ * Parameters:
+ * <Web Request>, <User options>
+ * Followed by expected values:
+ * <ProfileName>, <NSList>
+ * Then an optional message.
+ */
+ [
+ $EMPTY_REQUEST, $NO_USER_PREF,
+ 'default', $defaultNS,
+ 'T35270: No request nor user preferences should give default profile'
+ ],
+ [
+ [ 'ns5' => 1 ], $NO_USER_PREF,
+ 'advanced', [ 5 ],
+ 'Web request with specific NS should override user preference'
+ ],
+ [
+ $EMPTY_REQUEST, [
+ 'searchNs2' => 1,
+ 'searchNs14' => 1,
+ ] + array_fill_keys( array_map( function ( $ns ) {
+ return "searchNs$ns";
+ }, $defaultNS ), 0 ),
+ 'advanced', [ 2, 14 ],
+ 'T35583: search with no option should honor User search preferences'
+ . ' and have all other namespace disabled'
+ ],
+ ];
+ }
+
+ /**
+ * Helper to create a new User object with given options
+ * User remains anonymous though
+ * @param array|null $opt
+ */
+ function newUserWithSearchNS( $opt = null ) {
+ $u = User::newFromId( 0 );
+ if ( $opt === null ) {
+ return $u;
+ }
+ foreach ( $opt as $name => $value ) {
+ $u->setOption( $name, $value );
+ }
+
+ return $u;
+ }
+
+ /**
+ * Verify we do not expand search term in <title> on search result page
+ * https://gerrit.wikimedia.org/r/4841
+ */
+ public function testSearchTermIsNotExpanded() {
+ $this->setMwGlobals( [
+ 'wgSearchType' => null,
+ ] );
+
+ # Initialize [[Special::Search]]
+ $ctx = new RequestContext();
+ $term = '{{SITENAME}}';
+ $ctx->setRequest( new FauxRequest( [ 'search' => $term, 'fulltext' => 1 ] ) );
+ $ctx->setTitle( Title::newFromText( 'Special:Search' ) );
+ $search = new SpecialSearch();
+ $search->setContext( $ctx );
+
+ # Simulate a user searching for a given term
+ $search->execute( '' );
+
+ # Lookup the HTML page title set for that page
+ $pageTitle = $search
+ ->getContext()
+ ->getOutput()
+ ->getHTMLTitle();
+
+ # Compare :-]
+ $this->assertRegExp(
+ '/' . preg_quote( $term, '/' ) . '/',
+ $pageTitle,
+ "Search term '{$term}' should not be expanded in Special:Search <title>"
+ );
+ }
+
+ public function provideRewriteQueryWithSuggestion() {
+ return [
+ [
+ 'With suggestion and no rewritten query shows did you mean',
+ '/Did you mean: <a[^>]+>first suggestion/',
+ 'first suggestion',
+ null,
+ [ Title::newMainPage() ]
+ ],
+
+ [
+ 'With rewritten query informs user of change',
+ '/Showing results for <a[^>]+>first suggestion/',
+ 'asdf',
+ 'first suggestion',
+ [ Title::newMainPage() ]
+ ],
+
+ [
+ 'When both queries have no results user gets no results',
+ '/There were no results matching the query/',
+ 'first suggestion',
+ 'first suggestion',
+ []
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideRewriteQueryWithSuggestion
+ */
+ public function testRewriteQueryWithSuggestion(
+ $message,
+ $expectRegex,
+ $suggestion,
+ $rewrittenQuery,
+ array $resultTitles
+ ) {
+ $results = array_map( function ( $title ) {
+ return SearchResult::newFromTitle( $title );
+ }, $resultTitles );
+
+ $searchResults = new SpecialSearchTestMockResultSet(
+ $suggestion,
+ $rewrittenQuery,
+ $results
+ );
+
+ $mockSearchEngine = $this->mockSearchEngine( $searchResults );
+ $search = $this->getMockBuilder( SpecialSearch::class )
+ ->setMethods( [ 'getSearchEngine' ] )
+ ->getMock();
+ $search->expects( $this->any() )
+ ->method( 'getSearchEngine' )
+ ->will( $this->returnValue( $mockSearchEngine ) );
+
+ $search->getContext()->setTitle( Title::makeTitle( NS_SPECIAL, 'Search' ) );
+ $search->getContext()->setLanguage( Language::factory( 'en' ) );
+ $search->load();
+ $search->showResults( 'this is a fake search' );
+
+ $html = $search->getContext()->getOutput()->getHTML();
+ foreach ( (array)$expectRegex as $regex ) {
+ $this->assertRegExp( $regex, $html, $message );
+ }
+ }
+
+ protected function mockSearchEngine( $results ) {
+ $mock = $this->getMockBuilder( SearchEngine::class )
+ ->setMethods( [ 'searchText', 'searchTitle' ] )
+ ->getMock();
+
+ $mock->expects( $this->any() )
+ ->method( 'searchText' )
+ ->will( $this->returnValue( $results ) );
+
+ return $mock;
+ }
+
+ public function testSubPageRedirect() {
+ $this->setMwGlobals( [
+ 'wgScript' => '/w/index.php',
+ ] );
+
+ $ctx = new RequestContext;
+ $sp = Title::newFromText( 'Special:Search/foo_bar' );
+ SpecialPageFactory::executePath( $sp, $ctx );
+ $url = $ctx->getOutput()->getRedirect();
+ // some older versions of hhvm have a bug that doesn't parse relative
+ // urls with a port, so help it out a little bit.
+ // https://github.com/facebook/hhvm/issues/7136
+ $url = wfExpandUrl( $url, PROTO_CURRENT );
+
+ $parts = parse_url( $url );
+ $this->assertEquals( '/w/index.php', $parts['path'] );
+ parse_str( $parts['query'], $query );
+ $this->assertEquals( 'Special:Search', $query['title'] );
+ $this->assertEquals( 'foo bar', $query['search'] );
+ }
+}
+
+class SpecialSearchTestMockResultSet extends SearchResultSet {
+ protected $results;
+ protected $suggestion;
+
+ public function __construct(
+ $suggestion = null,
+ $rewrittenQuery = null,
+ array $results = [],
+ $containedSyntax = false
+ ) {
+ $this->suggestion = $suggestion;
+ $this->rewrittenQuery = $rewrittenQuery;
+ $this->results = $results;
+ $this->containedSyntax = $containedSyntax;
+ }
+
+ public function numRows() {
+ return count( $this->results );
+ }
+
+ public function getTotalHits() {
+ return $this->numRows();
+ }
+
+ public function hasSuggestion() {
+ return $this->suggestion !== null;
+ }
+
+ public function getSuggestionQuery() {
+ return $this->suggestion;
+ }
+
+ public function getSuggestionSnippet() {
+ return $this->suggestion;
+ }
+
+ public function hasRewrittenQuery() {
+ return $this->rewrittenQuery !== null;
+ }
+
+ public function getQueryAfterRewrite() {
+ return $this->rewrittenQuery;
+ }
+
+ public function getQueryAfterRewriteSnippet() {
+ return htmlspecialchars( $this->rewrittenQuery );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialShortpagesTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialShortpagesTest.php
new file mode 100644
index 00000000..f799b115
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialShortpagesTest.php
@@ -0,0 +1,43 @@
+<?php
+
+/**
+ * Test class for SpecialShortpages class
+ *
+ * @since 1.30
+ *
+ * @license GNU GPL v2+
+ */
+class SpecialShortpagesTest extends MediaWikiTestCase {
+
+ /**
+ * @dataProvider provideGetQueryInfoRespectsContentNs
+ * @covers ShortPagesPage::getQueryInfo()
+ */
+ public function testGetQueryInfoRespectsContentNS( $contentNS, $blacklistNS, $expectedNS ) {
+ $this->setMwGlobals( [
+ 'wgShortPagesNamespaceBlacklist' => $blacklistNS,
+ 'wgContentNamespaces' => $contentNS
+ ] );
+ $this->setTemporaryHook( 'ShortPagesQuery', function () {
+ // empty hook handler
+ } );
+
+ $page = new ShortPagesPage();
+ $queryInfo = $page->getQueryInfo();
+
+ $this->assertArrayHasKey( 'conds', $queryInfo );
+ $this->assertArrayHasKey( 'page_namespace', $queryInfo[ 'conds' ] );
+ $this->assertEquals( $expectedNS, $queryInfo[ 'conds' ][ 'page_namespace' ] );
+ }
+
+ public function provideGetQueryInfoRespectsContentNs() {
+ return [
+ [ [ NS_MAIN, NS_FILE ], [], [ NS_MAIN, NS_FILE ] ],
+ [ [ NS_MAIN, NS_TALK ], [ NS_FILE ], [ NS_MAIN, NS_TALK ] ],
+ [ [ NS_MAIN, NS_FILE ], [ NS_FILE ], [ NS_MAIN ] ],
+ // NS_MAIN namespace is always forced
+ [ [], [ NS_FILE ], [ NS_MAIN ] ]
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialUncategorizedcategoriesTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialUncategorizedcategoriesTest.php
new file mode 100644
index 00000000..80bd365f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialUncategorizedcategoriesTest.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * Tests for Special:Uncategorizedcategories
+ */
+class UncategorizedCategoriesPageTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider provideTestGetQueryInfoData
+ * @covers UncategorizedCategoriesPage::getQueryInfo
+ */
+ public function testGetQueryInfo( $msgContent, $expected ) {
+ $msg = new RawMessage( $msgContent );
+ $mockContext = $this->getMockBuilder( RequestContext::class )->getMock();
+ $mockContext->method( 'msg' )->willReturn( $msg );
+ $special = new UncategorizedCategoriesPage();
+ $special->setContext( $mockContext );
+ $this->assertEquals( [
+ 'tables' => [
+ 0 => 'page',
+ 1 => 'categorylinks',
+ ],
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'page_title',
+ ],
+ 'conds' => [
+ 0 => 'cl_from IS NULL',
+ 'page_namespace' => 14,
+ 'page_is_redirect' => 0,
+ ] + $expected,
+ 'join_conds' => [
+ 'categorylinks' => [
+ 0 => 'LEFT JOIN',
+ 1 => 'cl_from = page_id',
+ ],
+ ],
+ ], $special->getQueryInfo() );
+ }
+
+ public function provideTestGetQueryInfoData() {
+ return [
+ [
+ "* Stubs\n* Test\n* *\n* * test123",
+ [ 1 => "page_title not in ( 'Stubs','Test','*','*_test123' )" ]
+ ],
+ [
+ "Stubs\n* Test\n* *\n* * test123",
+ [ 1 => "page_title not in ( 'Test','*','*_test123' )" ]
+ ],
+ [
+ "* StubsTest\n* *\n* * test123",
+ [ 1 => "page_title not in ( 'StubsTest','*','*_test123' )" ]
+ ],
+ [ "", [] ],
+ [ "\n\n\n", [] ],
+ [ "\n", [] ],
+ [ "Test\n*Test2", [ 1 => "page_title not in ( 'Test2' )" ] ],
+ [ "Test", [] ],
+ [ "*Test\nTest2", [ 1 => "page_title not in ( 'Test' )" ] ],
+ [ "Test\nTest2", [] ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialUploadTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialUploadTest.php
new file mode 100644
index 00000000..95026c18
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialUploadTest.php
@@ -0,0 +1,29 @@
+<?php
+
+class SpecialUploadTest extends MediaWikiTestCase {
+ /**
+ * @covers SpecialUpload::getInitialPageText
+ * @dataProvider provideGetInitialPageText
+ */
+ public function testGetInitialPageText( $expected, $inputParams ) {
+ $result = call_user_func_array( [ 'SpecialUpload', 'getInitialPageText' ], $inputParams );
+ $this->assertEquals( $expected, $result );
+ }
+
+ public function provideGetInitialPageText() {
+ return [
+ [
+ 'expect' => "== Summary ==\nthis is a test\n",
+ 'params' => [
+ 'this is a test'
+ ],
+ ],
+ [
+ 'expect' => "== Summary ==\nthis is a test\n",
+ 'params' => [
+ "== Summary ==\nthis is a test",
+ ],
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/specials/SpecialWatchlistTest.php b/www/wiki/tests/phpunit/includes/specials/SpecialWatchlistTest.php
new file mode 100644
index 00000000..5adbed81
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/specials/SpecialWatchlistTest.php
@@ -0,0 +1,192 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @author Addshore
+ *
+ * @group Database
+ *
+ * @covers SpecialWatchlist
+ */
+class SpecialWatchlistTest extends SpecialPageTestBase {
+ public function setUp() {
+ parent::setUp();
+
+ $this->setTemporaryHook(
+ 'ChangesListSpecialPageFilters',
+ null
+ );
+
+ $this->setTemporaryHook(
+ 'SpecialWatchlistQuery',
+ null
+ );
+
+ $this->setTemporaryHook(
+ 'ChangesListSpecialPageQuery',
+ null
+ );
+
+ $this->setMwGlobals(
+ 'wgDefaultUserOptions',
+ [
+ 'extendwatchlist' => 1,
+ 'watchlistdays' => 3.0,
+ 'watchlisthideanons' => 0,
+ 'watchlisthidebots' => 0,
+ 'watchlisthideliu' => 0,
+ 'watchlisthideminor' => 0,
+ 'watchlisthideown' => 0,
+ 'watchlisthidepatrolled' => 0,
+ 'watchlisthidecategorization' => 1,
+ 'watchlistreloadautomatically' => 0,
+ 'watchlistunwatchlinks' => 0,
+ ]
+ );
+ }
+
+ /**
+ * Returns a new instance of the special page under test.
+ *
+ * @return SpecialPage
+ */
+ protected function newSpecialPage() {
+ return new SpecialWatchlist();
+ }
+
+ public function testNotLoggedIn_throwsException() {
+ $this->setExpectedException( UserNotLoggedIn::class );
+ $this->executeSpecialPage();
+ }
+
+ public function testUserWithNoWatchedItems_displaysNoWatchlistMessage() {
+ $user = new TestUser( __METHOD__ );
+ list( $html, ) = $this->executeSpecialPage( '', null, 'qqx', $user->getUser() );
+ $this->assertContains( '(nowatchlist)', $html );
+ }
+
+ /**
+ * @dataProvider provideFetchOptionsFromRequest
+ */
+ public function testFetchOptionsFromRequest( $expectedValues, $preferences, $inputParams ) {
+ $page = TestingAccessWrapper::newFromObject(
+ $this->newSpecialPage()
+ );
+
+ $context = new DerivativeContext( $page->getContext() );
+
+ $fauxRequest = new FauxRequest( $inputParams, /* $wasPosted= */ false );
+ $user = $this->getTestUser()->getUser();
+
+ foreach ( $preferences as $key => $value ) {
+ $user->setOption( $key, $value );
+ }
+
+ $context->setRequest( $fauxRequest );
+ $context->setUser( $user );
+ $page->setContext( $context );
+
+ $page->registerFilters();
+ $formOptions = $page->getDefaultOptions();
+ $page->fetchOptionsFromRequest( $formOptions );
+
+ $this->assertArrayEquals(
+ $expectedValues,
+ $formOptions->getAllValues(),
+ /* $ordered= */ false,
+ /* $named= */ true
+ );
+ }
+
+ public function provideFetchOptionsFromRequest() {
+ // $defaults and $allFalse are just to make the expected values below
+ // shorter by hiding the background.
+
+ $page = TestingAccessWrapper::newFromObject(
+ $this->newSpecialPage()
+ );
+
+ $this->setTemporaryHook(
+ 'ChangesListSpecialPageFilters',
+ null
+ );
+
+ $page->registerFilters();
+
+ // Does not consider $preferences, just wiki's defaults
+ $wikiDefaults = $page->getDefaultOptions()->getAllValues();
+
+ $allFalse = $wikiDefaults;
+
+ foreach ( $allFalse as $key => &$value ) {
+ if ( $value === true ) {
+ $value = false;
+ }
+ }
+
+ // This is not exposed on the form (only in preferences) so it
+ // respects the preference.
+ $allFalse['extended'] = true;
+
+ return [
+ [
+ [
+ 'hideminor' => true,
+ ] + $wikiDefaults,
+ [],
+ [
+ 'hideMinor' => 1,
+ ],
+ ],
+
+ [
+ [
+ // First two same as prefs
+ 'hideminor' => true,
+ 'hidebots' => false,
+
+ // Second two overriden
+ 'hideanons' => false,
+ 'hideliu' => true,
+ 'userExpLevel' => 'registered'
+ ] + $wikiDefaults,
+ [
+ 'watchlisthideminor' => 1,
+ 'watchlisthidebots' => 0,
+
+ 'watchlisthideanons' => 1,
+ 'watchlisthideliu' => 0,
+ ],
+ [
+ 'hideanons' => 0,
+ 'hideliu' => 1,
+ ],
+ ],
+
+ // Defaults/preferences for form elements are entirely ignored for
+ // action=submit and omitted elements become false
+ [
+ [
+ 'hideminor' => false,
+ 'hidebots' => true,
+ 'hideanons' => false,
+ 'hideliu' => true,
+ 'userExpLevel' => 'unregistered'
+ ] + $allFalse,
+ [
+ 'watchlisthideminor' => 0,
+ 'watchlisthidebots' => 1,
+
+ 'watchlisthideanons' => 0,
+ 'watchlisthideliu' => 1,
+ ],
+ [
+ 'hidebots' => 1,
+ 'hideliu' => 1,
+ 'action' => 'submit',
+ ],
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/tidy/BalancerTest.php b/www/wiki/tests/phpunit/includes/tidy/BalancerTest.php
new file mode 100644
index 00000000..8a4f662a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/tidy/BalancerTest.php
@@ -0,0 +1,169 @@
+<?php
+
+class BalancerTest extends MediaWikiTestCase {
+
+ /**
+ * Anything that needs to happen before your tests should go here.
+ */
+ protected function setUp() {
+ // Be sure to do call the parent setup and teardown functions.
+ // This makes sure that all the various cleanup and restorations
+ // happen as they should (including the restoration for setMwGlobals).
+ parent::setUp();
+ }
+
+ /**
+ * @covers MediaWiki\Tidy\Balancer
+ * @covers MediaWiki\Tidy\BalanceSets
+ * @covers MediaWiki\Tidy\BalanceElement
+ * @covers MediaWiki\Tidy\BalanceStack
+ * @covers MediaWiki\Tidy\BalanceMarker
+ * @covers MediaWiki\Tidy\BalanceActiveFormattingElements
+ * @dataProvider provideBalancerTests
+ */
+ public function testBalancer( $description, $input, $expected, $useTidy ) {
+ $balancer = new MediaWiki\Tidy\Balancer( [
+ 'strict' => false, /* not strict */
+ 'allowedHtmlElements' => null, /* no sanitization */
+ 'tidyCompat' => $useTidy, /* standard parser */
+ 'allowComments' => true, /* comment parsing */
+ ] );
+ $output = $balancer->balance( $input );
+
+ // Ignore self-closing tags
+ $output = preg_replace( '/\s*\/>/', '>', $output );
+
+ $this->assertEquals( $expected, $output, $description );
+ }
+
+ public static function provideBalancerTests() {
+ // Get the tests from html5lib-tests.json
+ $json = json_decode( file_get_contents(
+ __DIR__ . '/html5lib-tests.json'
+ ), true );
+ // Munge this slightly into the format phpunit expects
+ // for providers, and filter out HTML constructs which
+ // the balancer doesn't support.
+ $tests = [];
+ $okre = "~ \A
+ (?i:<!DOCTYPE\ html>)?
+ <html><head></head><body>
+ .*
+ </body></html>
+ \z ~xs";
+ foreach ( $json as $filename => $cases ) {
+ foreach ( $cases as $case ) {
+ $html = $case['document']['html'];
+ if ( !preg_match( $okre, $html ) ) {
+ // Skip tests which involve stuff in the <head> or
+ // weird doctypes.
+ continue;
+ }
+ // We used to do this:
+ // $html = substr( $html, strlen( $start ), -strlen( $end ) );
+ // But now we use a different field in the test case,
+ // which reports how domino would parse this case in a
+ // no-quirks <body> context. (The original test case may
+ // have had a different context, or relied on quirks mode.)
+ $html = $case['document']['noQuirksBodyHtml'];
+ // Normalize case of SVG attributes.
+ $html = str_replace( 'foreignObject', 'foreignobject', $html );
+ // Normalize case of MathML attributes.
+ $html = str_replace( 'definitionURL', 'definitionurl', $html );
+
+ if (
+ isset( $case['document']['props']['comment'] ) &&
+ preg_match( ',<!--[^>]*<,', $html )
+ ) {
+ // Skip tests which include HTML comments containing
+ // the < character, which we don't support.
+ continue;
+ }
+ if ( strpos( $case['data'], '<![CDATA[' ) !== false ) {
+ // Skip tests involving <![CDATA[ ]]> quoting.
+ continue;
+ }
+ if (
+ stripos( $case['data'], '<!DOCTYPE' ) !== false &&
+ stripos( $case['data'], '<!DOCTYPE html>' ) === false
+ ) {
+ // Skip tests involving unusual doctypes.
+ continue;
+ }
+ $literalre = "~ <rdar: | < /? (
+ html | head | body | frame | frameset | plaintext
+ ) > ~xi";
+ if ( preg_match( $literalre, $case['data'] ) ) {
+ // Skip tests involving some literal tags, which are
+ // unsupported but don't show up in the expected output.
+ continue;
+ }
+ if (
+ isset( $case['document']['props']['tags']['iframe'] ) ||
+ isset( $case['document']['props']['tags']['noembed'] ) ||
+ isset( $case['document']['props']['tags']['noscript'] ) ||
+ isset( $case['document']['props']['tags']['script'] ) ||
+ isset( $case['document']['props']['tags']['svg script'] ) ||
+ isset( $case['document']['props']['tags']['svg title'] ) ||
+ isset( $case['document']['props']['tags']['title'] ) ||
+ isset( $case['document']['props']['tags']['xmp'] )
+ ) {
+ // Skip tests with unsupported tags which *do* show
+ // up in the expected output.
+ continue;
+ }
+ if (
+ $filename === 'entities01.dat' ||
+ $filename === 'entities02.dat' ||
+ preg_match( '/&([a-z]+|#x[0-9A-F]+);/i', $case['data'] ) ||
+ preg_match( '/^(&|&#|&#X|&#x|&#45|&x-test|&AMP)$/', $case['data'] )
+ ) {
+ // Skip tests involving entity encoding.
+ continue;
+ }
+ if (
+ isset( $case['document']['props']['tagWithLt'] ) ||
+ isset( $case['document']['props']['attrWithFunnyChar'] ) ||
+ preg_match( ':^(</b test|<di|<foo bar=qux/>)$:', $case['data'] ) ||
+ preg_match( ':</p<p>:', $case['data'] ) ||
+ preg_match( ':<b &=&amp>|<p/x/y/z>:', $case['data'] )
+ ) {
+ // Skip tests with funny tag or attribute names,
+ // which are really tests of the HTML tokenizer, not
+ // the tree builder.
+ continue;
+ }
+ if (
+ preg_match( ':encoding=" text/html "|type=" hidden":', $case['data'] )
+ ) {
+ // The Sanitizer normalizes whitespace in attribute
+ // values, which makes this test case invalid.
+ continue;
+ }
+ if ( $filename === 'plain-text-unsafe.dat' ) {
+ // Skip tests with ASCII null, etc.
+ continue;
+ }
+ $data = preg_replace(
+ '~<!DOCTYPE html>~i', '', $case['data']
+ );
+ $tests[] = [
+ $filename, # use better description?
+ $data,
+ $html,
+ false # strict HTML5 compat mode, no tidy
+ ];
+ }
+ }
+
+ # Some additional tests for mediawiki-specific features
+ $tests[] = [
+ 'Round-trip serialization for <pre>/<listing>/<textarea>',
+ "<pre>\n\na</pre><listing>\n\nb</listing><textarea>\n\nc</textarea>",
+ "<pre>\n\na</pre><listing>\n\nb</listing><textarea>\n\nc</textarea>",
+ true # use the tidy-compatible mode
+ ];
+
+ return $tests;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/tidy/RemexDriverTest.php b/www/wiki/tests/phpunit/includes/tidy/RemexDriverTest.php
new file mode 100644
index 00000000..a5ebaa5d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/tidy/RemexDriverTest.php
@@ -0,0 +1,307 @@
+<?php
+
+class RemexDriverTest extends MediaWikiTestCase {
+ static private $remexTidyTestData = [
+ // Tests from Html5Depurate
+ [
+ 'Empty string',
+ "",
+ ""
+ ],
+ [
+ 'Simple p-wrap',
+ "x",
+ "<p>x</p>"
+ ],
+ [
+ 'No p-wrap of blank node',
+ " ",
+ " "
+ ],
+ [
+ 'p-wrap terminated by div',
+ "x<div></div>",
+ "<p>x</p><div></div>"
+ ],
+ [
+ 'p-wrap not terminated by span',
+ "x<span></span>",
+ "<p>x<span></span></p>"
+ ],
+ [
+ 'An element is non-blank and so gets p-wrapped',
+ "<span></span>",
+ "<p><span></span></p>"
+ ],
+ [
+ 'The blank flag is set after a block-level element',
+ "<div></div> ",
+ "<div></div> "
+ ],
+ [
+ 'Blank detection between two block-level elements',
+ "<div></div> <div></div>",
+ "<div></div> <div></div>"
+ ],
+ [
+ 'But p-wrapping of non-blank content works after an element',
+ "<div></div>x",
+ "<div></div><p>x</p>"
+ ],
+ [
+ 'p-wrapping between two block-level elements',
+ "<div></div>x<div></div>",
+ "<div></div><p>x</p><div></div>"
+ ],
+ [
+ 'p-wrap inside blockquote',
+ "<blockquote>x</blockquote>",
+ "<blockquote><p>x</p></blockquote>"
+ ],
+ [
+ 'A comment is blank for p-wrapping purposes',
+ "<!-- x -->",
+ "<!-- x -->"
+ ],
+ [
+ 'A comment is blank even when a p-wrap was opened by a text node',
+ " <!-- x -->",
+ " <!-- x -->"
+ ],
+ [
+ 'A comment does not open a p-wrap',
+ "<!-- x -->x",
+ "<!-- x --><p>x</p>"
+ ],
+ [
+ 'A comment does not close a p-wrap',
+ "x<!-- x -->",
+ "<p>x<!-- x --></p>"
+ ],
+ [
+ 'Empty li',
+ "<ul><li></li></ul>",
+ "<ul><li class=\"mw-empty-elt\"></li></ul>"
+ ],
+ [
+ 'li with element',
+ "<ul><li><span></span></li></ul>",
+ "<ul><li><span></span></li></ul>"
+ ],
+ [
+ 'li with text',
+ "<ul><li>x</li></ul>",
+ "<ul><li>x</li></ul>"
+ ],
+ [
+ 'Empty tr',
+ "<table><tbody><tr></tr></tbody></table>",
+ "<table><tbody><tr class=\"mw-empty-elt\"></tr></tbody></table>"
+ ],
+ [
+ 'Empty p',
+ "<p>\n</p>",
+ "<p class=\"mw-empty-elt\">\n</p>"
+ ],
+ [
+ 'No p-wrapping of an inline element which contains a block element (T150317)',
+ "<small><div>x</div></small>",
+ "<small><div>x</div></small>"
+ ],
+ [
+ 'p-wrapping of an inline element which contains an inline element',
+ "<small><b>x</b></small>",
+ "<p><small><b>x</b></small></p>"
+ ],
+ [
+ 'p-wrapping is enabled in a blockquote in an inline element',
+ "<small><blockquote>x</blockquote></small>",
+ "<small><blockquote><p>x</p></blockquote></small>"
+ ],
+ [
+ 'All bare text should be p-wrapped even when surrounded by block tags',
+ "<small><blockquote>x</blockquote></small>y<div></div>z",
+ "<small><blockquote><p>x</p></blockquote></small><p>y</p><div></div><p>z</p>"
+ ],
+ [
+ 'Split tag stack 1',
+ "<small>x<div>y</div>z</small>",
+ "<p><small>x</small></p><small><div>y</div></small><p><small>z</small></p>"
+ ],
+ [
+ 'Split tag stack 2',
+ "<small><div>y</div>z</small>",
+ "<small><div>y</div></small><p><small>z</small></p>"
+ ],
+ [
+ 'Split tag stack 3',
+ "<small>x<div>y</div></small>",
+ "<p><small>x</small></p><small><div>y</div></small>"
+ ],
+ [
+ 'Split tag stack 4 (modified to use splittable tag)',
+ "a<code>b<i>c<div>d</div></i>e</code>",
+ "<p>a<code>b<i>c</i></code></p><code><i><div>d</div></i></code><p><code>e</code></p>"
+ ],
+ [
+ "Split tag stack regression check 1",
+ "x<span><div>y</div></span>",
+ "<p>x</p><span><div>y</div></span>"
+ ],
+ [
+ "Split tag stack regression check 2 (modified to use splittable tag)",
+ "a<code><i><div>d</div></i>e</code>",
+ "<p>a</p><code><i><div>d</div></i></code><p><code>e</code></p>"
+ ],
+ // Simple tests from pwrap.js
+ [
+ 'Simple pwrap test 1',
+ 'a',
+ '<p>a</p>'
+ ],
+ [
+ '<span> is not a splittable tag, but gets p-wrapped in simple wrapping scenarios',
+ '<span>a</span>',
+ '<p><span>a</span></p>'
+ ],
+ [
+ 'Simple pwrap test 3',
+ 'x <div>a</div> <div>b</div> y',
+ '<p>x </p><div>a</div> <div>b</div><p> y</p>'
+ ],
+ [
+ 'Simple pwrap test 4',
+ 'x<!--c--> <div>a</div> <div>b</div> <!--c-->y',
+ '<p>x<!--c--> </p><div>a</div> <div>b</div> <!--c--><p>y</p>'
+ ],
+ // Complex tests from pwrap.js
+ [
+ 'Complex pwrap test 1',
+ '<i>x<div>a</div>y</i>',
+ '<p><i>x</i></p><i><div>a</div></i><p><i>y</i></p>'
+ ],
+ [
+ 'Complex pwrap test 2',
+ 'a<small>b</small><i>c<div>d</div>e</i>f',
+ '<p>a<small>b</small><i>c</i></p><i><div>d</div></i><p><i>e</i>f</p>'
+ ],
+ [
+ 'Complex pwrap test 3',
+ 'a<small>b<i>c<div>d</div></i>e</small>',
+ '<p>a<small>b<i>c</i></small></p><small><i><div>d</div></i></small><p><small>e</small></p>'
+ ],
+ [
+ 'Complex pwrap test 4',
+ 'x<small><div>y</div></small>',
+ '<p>x</p><small><div>y</div></small>'
+ ],
+ [
+ 'Complex pwrap test 5',
+ 'a<small><i><div>d</div></i>e</small>',
+ '<p>a</p><small><i><div>d</div></i></small><p><small>e</small></p>'
+ ],
+ // phpcs:disable Generic.Files.LineLength
+ [
+ 'Complex pwrap test 6',
+ '<i>a<div>b</div>c<b>d<div>e</div>f</b>g</i>',
+ // PHP 5 does not allow concatenation in initialisation of a class static variable
+ '<p><i>a</i></p><i><div>b</div></i><p><i>c<b>d</b></i></p><i><b><div>e</div></b></i><p><i><b>f</b>g</i></p>'
+ ],
+ // phpcs:enable
+ /* FIXME the second <b> causes a stack split which clones the <i> even
+ * though no <p> is actually generated
+ [
+ 'Complex pwrap test 7',
+ '<i><b><font><div>x</div></font></b><div>y</div><b><font><div>z</div></font></b></i>',
+ '<i><b><font><div>x</div></font></b><div>y</div><b><font><div>z</div></font></b></i>'
+ ],
+ */
+ // New local tests
+ [
+ 'Blank text node after block end',
+ '<small>x<div>y</div> <b>z</b></small>',
+ '<p><small>x</small></p><small><div>y</div></small><p><small> <b>z</b></small></p>'
+ ],
+ [
+ 'Text node fostering (FIXME: wrap missing)',
+ '<table>x</table>',
+ 'x<table></table>'
+ ],
+ [
+ 'Blockquote fostering',
+ '<table><blockquote>x</blockquote></table>',
+ '<blockquote><p>x</p></blockquote><table></table>'
+ ],
+ [
+ 'Block element fostering',
+ '<table><div>x',
+ '<div>x</div><table></table>'
+ ],
+ [
+ 'Formatting element fostering (FIXME: wrap missing)',
+ '<table><b>x',
+ '<b>x</b><table></table>'
+ ],
+ [
+ 'AAA clone of p-wrapped element (FIXME: empty b)',
+ '<b>x<p>y</b>z</p>',
+ '<p><b>x</b></p><b></b><p><b>y</b>z</p>',
+ ],
+ [
+ 'AAA with fostering (FIXME: wrap missing)',
+ '<table><b>1<p>2</b>3</p>',
+ '<b>1</b><p><b>2</b>3</p><table></table>'
+ ],
+ [
+ 'AAA causes reparent of p-wrapped text node (T178632)',
+ '<i><blockquote>x</i></blockquote>',
+ '<i></i><blockquote><p><i>x</i></p></blockquote>',
+ ],
+ [
+ 'p-wrap ended by reparenting (T200827)',
+ '<i><blockquote><p></i>',
+ '<i></i><blockquote><p><i></i></p><p><i></i></p></blockquote>',
+ ],
+ ];
+
+ public function provider() {
+ return self::$remexTidyTestData;
+ }
+
+ /**
+ * @dataProvider provider
+ * @covers MediaWiki\Tidy\RemexCompatFormatter
+ * @covers MediaWiki\Tidy\RemexCompatMunger
+ * @covers MediaWiki\Tidy\RemexDriver
+ * @covers MediaWiki\Tidy\RemexMungerData
+ */
+ public function testTidy( $desc, $input, $expected ) {
+ $r = new MediaWiki\Tidy\RemexDriver( [] );
+ $result = $r->tidy( $input );
+ $this->assertEquals( $expected, $result, $desc );
+ }
+
+ public function html5libProvider() {
+ $files = json_decode( file_get_contents( __DIR__ . '/html5lib-tests.json' ), true );
+ $tests = [];
+ foreach ( $files as $file => $fileTests ) {
+ foreach ( $fileTests as $i => $test ) {
+ $tests[] = [ "$file:$i", $test['data'] ];
+ }
+ }
+ return $tests;
+ }
+
+ /**
+ * This is a quick and dirty test to make sure none of the html5lib tests
+ * generate exceptions. We don't really know what the expected output is.
+ *
+ * @dataProvider html5libProvider
+ * @coversNothing
+ */
+ public function testHtml5Lib( $desc, $input ) {
+ $r = new MediaWiki\Tidy\RemexDriver( [] );
+ $result = $r->tidy( $input );
+ $this->assertTrue( true, $desc );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/tidy/html5lib-tests.json b/www/wiki/tests/phpunit/includes/tidy/html5lib-tests.json
new file mode 100644
index 00000000..2b1c3e8c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/tidy/html5lib-tests.json
@@ -0,0 +1,80692 @@
+{
+ "adoption01.dat": [
+ {
+ "data": "<a><p></a></p>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,10): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a></a><p><a></a></p></body></html>",
+ "noQuirksBodyHtml": "<a></a><p><a></a></p>"
+ }
+ },
+ {
+ "data": "<a>1<p>2</a>3</p>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,12): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ },
+ {
+ "text": "3"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a>1</a><p><a>2</a>3</p></body></html>",
+ "noQuirksBodyHtml": "<a>1</a><p><a>2</a>3</p>"
+ }
+ },
+ {
+ "data": "<a>1<button>2</a>3</button>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,17): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "button": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ },
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ },
+ {
+ "text": "3"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a>1</a><button><a>2</a>3</button></body></html>",
+ "noQuirksBodyHtml": "<a>1</a><button><a>2</a>3</button>"
+ }
+ },
+ {
+ "data": "<a>1<b>2</a>3</b>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,12): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "1"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "3"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a>1<b>2</b></a><b>3</b></body></html>",
+ "noQuirksBodyHtml": "<a>1<b>2</b></a><b>3</b>"
+ }
+ },
+ {
+ "data": "<a>1<div>2<div>3</a>4</div>5</div>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,20): adoption-agency-1.3",
+ "(1,20): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "3"
+ }
+ ]
+ },
+ {
+ "text": "4"
+ }
+ ]
+ },
+ {
+ "text": "5"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a>1</a><div><a>2</a><div><a>3</a>4</div>5</div></body></html>",
+ "noQuirksBodyHtml": "<a>1</a><div><a>2</a><div><a>3</a>4</div>5</div>"
+ }
+ },
+ {
+ "data": "<table><a>1<p>2</a>3</p>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,10): unexpected-start-tag-implies-table-voodoo",
+ "(1,11): unexpected-character-implies-table-voodoo",
+ "(1,14): unexpected-start-tag-implies-table-voodoo",
+ "(1,15): unexpected-character-implies-table-voodoo",
+ "(1,19): unexpected-end-tag-implies-table-voodoo",
+ "(1,19): adoption-agency-1.3",
+ "(1,20): unexpected-character-implies-table-voodoo",
+ "(1,24): unexpected-end-tag-implies-table-voodoo",
+ "(1,24): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "p": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ },
+ {
+ "text": "3"
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a>1</a><p><a>2</a>3</p><table></table></body></html>",
+ "noQuirksBodyHtml": "<a>1</a><p><a>2</a>3</p><table></table>"
+ }
+ },
+ {
+ "data": "<b><b><a><p></a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,16): adoption-agency-1.3",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "a": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b><b><a></a><p><a></a></p></b></b></body></html>",
+ "noQuirksBodyHtml": "<b><b><a></a><p><a></a></p></b></b>"
+ }
+ },
+ {
+ "data": "<b><a><b><p></a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,16): adoption-agency-1.3",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "a": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b><a><b></b></a><b><p><a></a></p></b></b></body></html>",
+ "noQuirksBodyHtml": "<b><a><b></b></a><b><p><a></a></p></b></b>"
+ }
+ },
+ {
+ "data": "<a><b><b><p></a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,16): adoption-agency-1.3",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "b": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a><b><b></b></b></a><b><b><p><a></a></p></b></b></body></html>",
+ "noQuirksBodyHtml": "<a><b><b></b></b></a><b><b><p><a></a></p></b></b>"
+ }
+ },
+ {
+ "data": "<p>1<s id=\"A\">2<b id=\"B\">3</p>4</s>5</b>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,30): unexpected-end-tag",
+ "(1,35): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "s": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "1"
+ },
+ {
+ "tag": "s",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "A"
+ }
+ ],
+ "children": [
+ {
+ "text": "2"
+ },
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "B"
+ }
+ ],
+ "children": [
+ {
+ "text": "3"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "s",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "A"
+ }
+ ],
+ "children": [
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "B"
+ }
+ ],
+ "children": [
+ {
+ "text": "4"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "B"
+ }
+ ],
+ "children": [
+ {
+ "text": "5"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p>1<s id=\"A\">2<b id=\"B\">3</b></s></p><s id=\"A\"><b id=\"B\">4</b></s><b id=\"B\">5</b></body></html>",
+ "noQuirksBodyHtml": "<p>1<s id=\"A\">2<b id=\"B\">3</b></s></p><s id=\"A\"><b id=\"B\">4</b></s><b id=\"B\">5</b>"
+ }
+ },
+ {
+ "data": "<table><a>1<td>2</td>3</table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,10): unexpected-start-tag-implies-table-voodoo",
+ "(1,11): unexpected-character-implies-table-voodoo",
+ "(1,15): unexpected-cell-in-table-body",
+ "(1,30): unexpected-implied-end-tag-in-table-view"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "3"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a>1</a><a>3</a><table><tbody><tr><td>2</td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<a>1</a><a>3</a><table><tbody><tr><td>2</td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table>A<td>B</td>C</table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,8): unexpected-character-implies-table-voodoo",
+ "(1,12): unexpected-cell-in-table-body",
+ "(1,22): unexpected-character-implies-table-voodoo"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "AC"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "B"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>AC<table><tbody><tr><td>B</td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "AC<table><tbody><tr><td>B</td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<a><svg><tr><input></a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,23): unexpected-end-tag",
+ "(1,23): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "svg svg": true,
+ "svg tr": true,
+ "svg input": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "tr",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "input",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a><svg><tr><input></input></tr></svg></a></body></html>",
+ "noQuirksBodyHtml": "<a><svg><tr><input></input></tr></svg></a>"
+ }
+ },
+ {
+ "data": "<div><a><b><div><div><div><div><div><div><div><div><div><div></a>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,65): adoption-agency-1.3",
+ "(1,65): adoption-agency-1.3",
+ "(1,65): adoption-agency-1.3",
+ "(1,65): adoption-agency-1.3",
+ "(1,65): adoption-agency-1.3",
+ "(1,65): adoption-agency-1.3",
+ "(1,65): adoption-agency-1.3",
+ "(1,65): adoption-agency-1.3",
+ "(1,65): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "a": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><a><b></b></a><b><div><a></a><div><a></a><div><a></a><div><a></a><div><a></a><div><a></a><div><a></a><div><a><div><div></div></div></a></div></div></div></div></div></div></div></div></b></div></body></html>",
+ "noQuirksBodyHtml": "<div><a><b></b></a><b><div><a></a><div><a></a><div><a></a><div><a></a><div><a></a><div><a></a><div><a></a><div><a><div><div></div></div></a></div></div></div></div></div></div></div></div></b></div>"
+ }
+ },
+ {
+ "data": "<div><a><b><u><i><code><div></a>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,32): adoption-agency-1.3",
+ "(1,32): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "a": true,
+ "b": true,
+ "u": true,
+ "i": true,
+ "code": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "u",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "code"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "u",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "code",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><a><b><u><i><code></code></i></u></b></a><u><i><code><div><a></a></div></code></i></u></div></body></html>",
+ "noQuirksBodyHtml": "<div><a><b><u><i><code></code></i></u></b></a><u><i><code><div><a></a></div></code></i></u></div>"
+ }
+ },
+ {
+ "data": "<b><b><b><b>x</b></b></b></b>y",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "y"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b><b><b><b>x</b></b></b></b>y</body></html>",
+ "noQuirksBodyHtml": "<b><b><b><b>x</b></b></b></b>y"
+ }
+ },
+ {
+ "data": "<p><b><b><b><b><p>x",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,18): unexpected-end-tag",
+ "(1,19): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><b><b><b><b></b></b></b></b></p><p><b><b><b>x</b></b></b></p></body></html>",
+ "noQuirksBodyHtml": "<p><b><b><b><b></b></b></b></b></p><p><b><b><b>x</b></b></b></p>"
+ }
+ },
+ {
+ "data": "<b><em><foo><foob><fooc><aside></b></em>",
+ "errors": [
+ "(1,35): adoption-agency-1.3",
+ "(1,40): adoption-agency-1.3",
+ "(1,40): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "div"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "b": true,
+ "em": true,
+ "foo": true,
+ "foob": true,
+ "fooc": true,
+ "aside": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "em",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foob",
+ "children": [
+ {
+ "tag": "fooc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "aside",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ],
+ "html": "<b><em><foo><foob><fooc></fooc></foob></foo></em></b><aside><b></b></aside>",
+ "noQuirksBodyHtml": "<b><em><foo><foob><fooc></fooc></foob></foo></em></b><aside><b></b></aside>"
+ }
+ }
+ ],
+ "adoption02.dat": [
+ {
+ "data": "<b>1<i>2<p>3</b>4",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,16): adoption-agency-1.3",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "i": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "1"
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "3"
+ }
+ ]
+ },
+ {
+ "text": "4"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b>1<i>2</i></b><i><p><b>3</b>4</p></i></body></html>",
+ "noQuirksBodyHtml": "<b>1<i>2</i></b><i><p><b>3</b>4</p></i>"
+ }
+ },
+ {
+ "data": "<a><div><style></style><address><a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,35): unexpected-start-tag-implies-end-tag",
+ "(1,35): adoption-agency-1.3",
+ "(1,35): adoption-agency-1.3",
+ "(1,35): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "div": true,
+ "style": true,
+ "address": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "style"
+ }
+ ]
+ },
+ {
+ "tag": "address",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a></a><div><a><style></style></a><address><a></a><a></a></address></div></body></html>",
+ "noQuirksBodyHtml": "<a></a><div><a><style></style></a><address><a></a><a></a></address></div>"
+ }
+ }
+ ],
+ "comments01.dat": [
+ {
+ "data": "FOO<!-- BAR -->BAZ",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "comment": " BAR "
+ },
+ {
+ "text": "BAZ"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<!-- BAR -->BAZ</body></html>",
+ "noQuirksBodyHtml": "FOO<!-- BAR -->BAZ"
+ }
+ },
+ {
+ "data": "FOO<!-- BAR --!>BAZ",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,15): unexpected-bang-after-double-dash-in-comment"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "comment": " BAR "
+ },
+ {
+ "text": "BAZ"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<!-- BAR -->BAZ</body></html>",
+ "noQuirksBodyHtml": "FOO<!-- BAR -->BAZ"
+ }
+ },
+ {
+ "data": "FOO<!-- BAR -- >BAZ",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,15): unexpected-char-in-comment",
+ "(1,21): eof-in-comment"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "comment": " BAR -- >BAZ"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<!-- BAR -- >BAZ--></body></html>",
+ "noQuirksBodyHtml": "FOO<!-- BAR -- >BAZ-->"
+ }
+ },
+ {
+ "data": "FOO<!-- BAR -- <QUX> -- MUX -->BAZ",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,15): unexpected-char-in-comment",
+ "(1,24): unexpected-char-in-comment"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "comment": " BAR -- <QUX> -- MUX "
+ },
+ {
+ "text": "BAZ"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<!-- BAR -- <QUX> -- MUX -->BAZ</body></html>",
+ "noQuirksBodyHtml": "FOO<!-- BAR -- <QUX> -- MUX -->BAZ"
+ }
+ },
+ {
+ "data": "FOO<!-- BAR -- <QUX> -- MUX --!>BAZ",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,15): unexpected-char-in-comment",
+ "(1,24): unexpected-char-in-comment",
+ "(1,31): unexpected-bang-after-double-dash-in-comment"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "comment": " BAR -- <QUX> -- MUX "
+ },
+ {
+ "text": "BAZ"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<!-- BAR -- <QUX> -- MUX -->BAZ</body></html>",
+ "noQuirksBodyHtml": "FOO<!-- BAR -- <QUX> -- MUX -->BAZ"
+ }
+ },
+ {
+ "data": "FOO<!-- BAR -- <QUX> -- MUX -- >BAZ",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,15): unexpected-char-in-comment",
+ "(1,24): unexpected-char-in-comment",
+ "(1,31): unexpected-char-in-comment",
+ "(1,35): eof-in-comment"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "comment": " BAR -- <QUX> -- MUX -- >BAZ"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<!-- BAR -- <QUX> -- MUX -- >BAZ--></body></html>",
+ "noQuirksBodyHtml": "FOO<!-- BAR -- <QUX> -- MUX -- >BAZ-->"
+ }
+ },
+ {
+ "data": "FOO<!---->BAZ",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "comment": ""
+ },
+ {
+ "text": "BAZ"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<!---->BAZ</body></html>",
+ "noQuirksBodyHtml": "FOO<!---->BAZ"
+ }
+ },
+ {
+ "data": "FOO<!--->BAZ",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,9): incorrect-comment"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "comment": ""
+ },
+ {
+ "text": "BAZ"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<!---->BAZ</body></html>",
+ "noQuirksBodyHtml": "FOO<!---->BAZ"
+ }
+ },
+ {
+ "data": "FOO<!-->BAZ",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,8): incorrect-comment"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "comment": ""
+ },
+ {
+ "text": "BAZ"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<!---->BAZ</body></html>",
+ "noQuirksBodyHtml": "FOO<!---->BAZ"
+ }
+ },
+ {
+ "data": "<?xml version=\"1.0\">Hi",
+ "errors": [
+ "(1,1): expected-tag-name-but-got-question-mark",
+ "(1,22): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "?xml version=\"1.0\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hi"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!--?xml version=\"1.0\"--><html><head></head><body>Hi</body></html>",
+ "noQuirksBodyHtml": "<!--?xml version=\"1.0\"-->Hi"
+ }
+ },
+ {
+ "data": "<?xml version=\"1.0\">",
+ "errors": [
+ "(1,1): expected-tag-name-but-got-question-mark",
+ "(1,20): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "?xml version=\"1.0\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--?xml version=\"1.0\"--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--?xml version=\"1.0\"-->"
+ }
+ },
+ {
+ "data": "<?xml version",
+ "errors": [
+ "(1,1): expected-tag-name-but-got-question-mark",
+ "(1,13): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "?xml version"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--?xml version--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--?xml version-->"
+ }
+ },
+ {
+ "data": "FOO<!----->BAZ",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,10): unexpected-dash-after-double-dash-in-comment"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "comment": "-"
+ },
+ {
+ "text": "BAZ"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<!----->BAZ</body></html>",
+ "noQuirksBodyHtml": "FOO<!----->BAZ"
+ }
+ },
+ {
+ "data": "<html><!-- comment --><title>Comment before head</title>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "comment": " comment "
+ },
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "Comment before head"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><!-- comment --><head><title>Comment before head</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<!-- comment --><title>Comment before head</title>"
+ }
+ }
+ ],
+ "doctype01.dat": [
+ {
+ "data": "<!DOCTYPE html>Hello",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!dOctYpE HtMl>Hello",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPEhtml>Hello",
+ "errors": [
+ "(1,9): need-space-after-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE>Hello",
+ "errors": [
+ "(1,9): need-space-after-doctype",
+ "(1,10): expected-doctype-name-but-got-right-bracket",
+ "(1,10): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": ""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE ><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE >Hello",
+ "errors": [
+ "(1,11): expected-doctype-name-but-got-right-bracket",
+ "(1,11): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": ""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE ><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato>Hello",
+ "errors": [
+ "(1,17): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato >Hello",
+ "errors": [
+ "(1,18): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato taco>Hello",
+ "errors": [
+ "(1,17): expected-space-or-right-bracket-in-doctype",
+ "(1,22): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato taco \"ddd>Hello",
+ "errors": [
+ "(1,17): expected-space-or-right-bracket-in-doctype",
+ "(1,27): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato sYstEM>Hello",
+ "errors": [
+ "(1,24): unexpected-char-in-doctype",
+ "(1,24): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato sYstEM >Hello",
+ "errors": [
+ "(1,28): unexpected-char-in-doctype",
+ "(1,28): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato sYstEM ggg>Hello",
+ "errors": [
+ "(1,34): unexpected-char-in-doctype",
+ "(1,37): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato SYSTEM taco >Hello",
+ "errors": [
+ "(1,25): unexpected-char-in-doctype",
+ "(1,31): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato SYSTEM 'taco\"'>Hello",
+ "errors": [
+ "(1,32): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato \"\" \"taco\"\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato SYSTEM \"taco\">Hello",
+ "errors": [
+ "(1,31): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato \"\" \"taco\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato SYSTEM \"tai'co\">Hello",
+ "errors": [
+ "(1,33): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato \"\" \"tai'co\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato SYSTEMtaco \"ddd\">Hello",
+ "errors": [
+ "(1,24): unexpected-char-in-doctype",
+ "(1,34): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato grass SYSTEM taco>Hello",
+ "errors": [
+ "(1,17): expected-space-or-right-bracket-in-doctype",
+ "(1,35): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato pUbLIc>Hello",
+ "errors": [
+ "(1,24): unexpected-end-of-doctype",
+ "(1,24): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato pUbLIc >Hello",
+ "errors": [
+ "(1,25): unexpected-end-of-doctype",
+ "(1,25): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato pUbLIcgoof>Hello",
+ "errors": [
+ "(1,24): unexpected-char-in-doctype",
+ "(1,28): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato PUBLIC goof>Hello",
+ "errors": [
+ "(1,25): unexpected-char-in-doctype",
+ "(1,29): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato PUBLIC \"go'of\">Hello",
+ "errors": [
+ "(1,32): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato \"go'of\" \"\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato PUBLIC 'go'of'>Hello",
+ "errors": [
+ "(1,29): unexpected-char-in-doctype",
+ "(1,32): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato \"go\" \"\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato PUBLIC 'go:hh of' >Hello",
+ "errors": [
+ "(1,38): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato \"go:hh of\" \"\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE potato PUBLIC \"W3C-//dfdf\" SYSTEM ggg>Hello",
+ "errors": [
+ "(1,38): unexpected-char-in-doctype",
+ "(1,48): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "potato \"W3C-//dfdf\" \"\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE potato><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\"\n \"http://www.w3.org/TR/html4/strict.dtd\">Hello",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE ...>Hello",
+ "errors": [
+ "(1,14): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "..."
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE ...><html><head></head><body>Hello</body></html>",
+ "noQuirksBodyHtml": "Hello"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\"\n\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">",
+ "errors": [
+ "(2,58): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Frameset//EN\"\n\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd\">",
+ "errors": [
+ "(2,54): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html \"-//W3C//DTD XHTML 1.0 Frameset//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE root-element [SYSTEM OR PUBLIC FPI] \"uri\" [ \n<!-- internal declarations -->\n]>",
+ "errors": [
+ "(1,23): expected-space-or-right-bracket-in-doctype",
+ "(2,30): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "root-element"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "]>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE root-element><html><head></head><body>]&gt;</body></html>",
+ "noQuirksBodyHtml": "\n]&gt;"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html PUBLIC\n \"-//WAPFORUM//DTD XHTML Mobile 1.0//EN\"\n \"http://www.wapforum.org/DTD/xhtml-mobile10.dtd\">",
+ "errors": [
+ "(3,53): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html \"-//WAPFORUM//DTD XHTML Mobile 1.0//EN\" \"http://www.wapforum.org/DTD/xhtml-mobile10.dtd\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE HTML SYSTEM \"http://www.w3.org/DTD/HTML4-strict.dtd\"><body><b>Mine!</b></body>",
+ "errors": [
+ "(1,63): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html \"\" \"http://www.w3.org/DTD/HTML4-strict.dtd\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "Mine!"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><b>Mine!</b></body></html>",
+ "noQuirksBodyHtml": "<b>Mine!</b>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\"\"http://www.w3.org/TR/html4/strict.dtd\">",
+ "errors": [
+ "(1,50): unexpected-char-in-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\"'http://www.w3.org/TR/html4/strict.dtd'>",
+ "errors": [
+ "(1,50): unexpected-char-in-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE HTML PUBLIC\"-//W3C//DTD HTML 4.01//EN\"'http://www.w3.org/TR/html4/strict.dtd'>",
+ "errors": [
+ "(1,21): unexpected-char-in-doctype",
+ "(1,49): unexpected-char-in-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE HTML PUBLIC'-//W3C//DTD HTML 4.01//EN''http://www.w3.org/TR/html4/strict.dtd'>",
+ "errors": [
+ "(1,21): unexpected-char-in-doctype",
+ "(1,49): unexpected-char-in-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ }
+ ],
+ "domjs-unsafe.dat": [
+ {
+ "data": "<svg><![CDATA[foo\nbar]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(2,6): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo\nbar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>foo\nbar</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>foo\nbar</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[foo\rbar]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(2,6): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo\nbar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>foo\nbar</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>foo\nbar</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[foo\r\nbar]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(2,6): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo\nbar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>foo\nbar</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>foo\nbar</svg>"
+ }
+ },
+ {
+ "data": "<script>a='\u0000'</script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,12): invalid-codepoint"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "a='�'",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script>a='�'</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script>a='�'</script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--\u0000</script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag",
+ "(1,25): invalid-codepoint"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--�",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--�</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--�</script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--foo\u0000</script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag",
+ "(1,28): invalid-codepoint"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--foo�",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--foo�</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--foo�</script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!-- foo-\u0000</script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag",
+ "(1,30): invalid-codepoint"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!-- foo-�",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!-- foo-�</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!-- foo-�</script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!-- foo--\u0000</script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag",
+ "(1,31): invalid-codepoint"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!-- foo--�",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!-- foo--�</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!-- foo--�</script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!-- foo-",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag",
+ "(1,29): expected-script-data-but-got-eof",
+ "(1,29): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!-- foo-",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!-- foo-</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!-- foo-</script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!-- foo-<</script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!-- foo-<",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!-- foo-<</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!-- foo-<</script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!-- foo-<S",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag",
+ "(1,31): expected-script-data-but-got-eof",
+ "(1,31): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!-- foo-<S",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!-- foo-<S</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!-- foo-<S</script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!-- foo-</SCRIPT>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!-- foo-",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!-- foo-</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!-- foo-</script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--<p></script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--<p>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--<p></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--<p></script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--<script></script></script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--<script></script>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--<script></script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--<script></script></script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--<script>\u0000</script></script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag",
+ "(1,33): invalid-codepoint"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--<script>�</script>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--<script>�</script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--<script>�</script></script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--<script>-\u0000</script></script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag",
+ "(1,34): invalid-codepoint"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--<script>-�</script>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--<script>-�</script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--<script>-�</script></script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--<script>--\u0000</script></script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag",
+ "(1,35): invalid-codepoint"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--<script>--�</script>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--<script>--�</script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--<script>--�</script></script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--<script>---</script></script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--<script>---</script>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--<script>---</script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--<script>---</script></script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--<script></scrip></SCRIPT></script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--<script></scrip></SCRIPT>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--<script></scrip></SCRIPT></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--<script></scrip></SCRIPT></script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--<script></scrip </SCRIPT></script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--<script></scrip </SCRIPT>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--<script></scrip </SCRIPT></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--<script></scrip </SCRIPT></script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--<script></scrip/</SCRIPT></script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--<script></scrip/</SCRIPT>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--<script></scrip/</SCRIPT></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--<script></scrip/</SCRIPT></script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"></scrip/></script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "</scrip/>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"></scrip/></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"></scrip/></script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"></scrip ></script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "</scrip >",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"></scrip ></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"></scrip ></script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--</scrip></script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--</scrip>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--</scrip></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--</scrip></script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--</scrip </script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--</scrip ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--</scrip </script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--</scrip </script>"
+ }
+ },
+ {
+ "data": "<script type=\"data\"><!--</scrip/</script>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "data"
+ }
+ ],
+ "children": [
+ {
+ "text": "<!--</scrip/",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script type=\"data\"><!--</scrip/</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"data\"><!--</scrip/</script>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><!DOCTYPE html>",
+ "errors": [
+ "(1,30): unexpected-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><!DOCTYPE html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,21): unexpected-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><head><!DOCTYPE html></head>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,27): unexpected-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><head></head><!DOCTYPE html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,34): unexpected-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<body></body><!DOCTYPE html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,28): unexpected-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<table><!DOCTYPE html></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,22): unexpected-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table></table></body></html>",
+ "noQuirksBodyHtml": "<table></table>"
+ }
+ },
+ {
+ "data": "<select><!DOCTYPE html></select>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,23): unexpected-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<table><colgroup><!DOCTYPE html></colgroup></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,32): unexpected-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><colgroup></colgroup></table></body></html>",
+ "noQuirksBodyHtml": "<table><colgroup></colgroup></table>"
+ }
+ },
+ {
+ "data": "<table><colgroup><!--test--></colgroup></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "comment": "test"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><colgroup><!--test--></colgroup></table></body></html>",
+ "noQuirksBodyHtml": "<table><colgroup><!--test--></colgroup></table>"
+ }
+ },
+ {
+ "data": "<table><colgroup><html></colgroup></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,23): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><colgroup></colgroup></table></body></html>",
+ "noQuirksBodyHtml": "<table><colgroup></colgroup></table>"
+ }
+ },
+ {
+ "data": "<table><colgroup> foo</colgroup></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,32): foster-parenting-character-in-table",
+ "(1,32): foster-parenting-character-in-table",
+ "(1,32): foster-parenting-character-in-table",
+ "(1,32): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "foo"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "text": " "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>foo<table><colgroup> </colgroup></table></body></html>",
+ "noQuirksBodyHtml": "foo<table><colgroup> </colgroup></table>"
+ }
+ },
+ {
+ "data": "<select><!--test--></select>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "comment": "test"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><!--test--></select></body></html>",
+ "noQuirksBodyHtml": "<select><!--test--></select>"
+ }
+ },
+ {
+ "data": "<select><html></select>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,14): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<frameset><html></frameset>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,16): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<frameset></frameset><html>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,27): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<frameset></frameset><!DOCTYPE html>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,36): unexpected-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><body></body></html><!DOCTYPE html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,41): unexpected-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<svg><!DOCTYPE html></svg>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,20): unexpected-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg></svg></body></html>",
+ "noQuirksBodyHtml": "<svg></svg>"
+ }
+ },
+ {
+ "data": "<svg><font></font></svg>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg font": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "font",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg><font></font></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><font></font></svg>"
+ }
+ },
+ {
+ "data": "<svg><font id=foo></font></svg>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg font": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "font",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg><font id=\"foo\"></font></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><font id=\"foo\"></font></svg>"
+ }
+ },
+ {
+ "data": "<svg><font size=4></font></svg>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,18): unexpected-html-element-in-foreign-content",
+ "(1,31): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "font": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg></svg><font size=\"4\"></font></body></html>",
+ "noQuirksBodyHtml": "<svg><font size=\"4\"></font></svg>"
+ }
+ },
+ {
+ "data": "<svg><font color=red></font></svg>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,21): unexpected-html-element-in-foreign-content",
+ "(1,34): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "font": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "color",
+ "value": "red"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg></svg><font color=\"red\"></font></body></html>",
+ "noQuirksBodyHtml": "<svg><font color=\"red\"></font></svg>"
+ }
+ },
+ {
+ "data": "<svg><font font=sans></font></svg>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg font": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "font",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "font",
+ "value": "sans"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg><font font=\"sans\"></font></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><font font=\"sans\"></font></svg>"
+ }
+ }
+ ],
+ "entities01.dat": [
+ {
+ "data": "FOO&gt;BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO>BAR",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO&gt;BAR</body></html>",
+ "noQuirksBodyHtml": "FOO&gt;BAR"
+ }
+ },
+ {
+ "data": "FOO&gtBAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,6): named-entity-without-semicolon"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO>BAR",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO&gt;BAR</body></html>",
+ "noQuirksBodyHtml": "FOO&gt;BAR"
+ }
+ },
+ {
+ "data": "FOO&gt BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,6): named-entity-without-semicolon"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO> BAR",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO&gt; BAR</body></html>",
+ "noQuirksBodyHtml": "FOO&gt; BAR"
+ }
+ },
+ {
+ "data": "FOO&gt;;;BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO>;;BAR",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO&gt;;;BAR</body></html>",
+ "noQuirksBodyHtml": "FOO&gt;;;BAR"
+ }
+ },
+ {
+ "data": "I'm &notit; I tell you",
+ "errors": [
+ "(1,4): expected-doctype-but-got-chars",
+ "(1,9): named-entity-without-semicolon"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "I'm ¬it; I tell you"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>I'm ¬it; I tell you</body></html>",
+ "noQuirksBodyHtml": "I'm ¬it; I tell you"
+ }
+ },
+ {
+ "data": "I'm &notin; I tell you",
+ "errors": [
+ "(1,4): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "I'm ∉ I tell you"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>I'm ∉ I tell you</body></html>",
+ "noQuirksBodyHtml": "I'm ∉ I tell you"
+ }
+ },
+ {
+ "data": "FOO& BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO& BAR",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO&amp; BAR</body></html>",
+ "noQuirksBodyHtml": "FOO&amp; BAR"
+ }
+ },
+ {
+ "data": "FOO&<BAR>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,9): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "bar": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO&",
+ "escaped": true
+ },
+ {
+ "tag": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO&amp;<bar></bar></body></html>",
+ "noQuirksBodyHtml": "FOO&amp;<bar></bar>"
+ }
+ },
+ {
+ "data": "FOO&&&&gt;BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO&&&>BAR",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO&amp;&amp;&amp;&gt;BAR</body></html>",
+ "noQuirksBodyHtml": "FOO&amp;&amp;&amp;&gt;BAR"
+ }
+ },
+ {
+ "data": "FOO&#41;BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO)BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO)BAR</body></html>",
+ "noQuirksBodyHtml": "FOO)BAR"
+ }
+ },
+ {
+ "data": "FOO&#x41;BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOABAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOABAR</body></html>",
+ "noQuirksBodyHtml": "FOOABAR"
+ }
+ },
+ {
+ "data": "FOO&#X41;BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOABAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOABAR</body></html>",
+ "noQuirksBodyHtml": "FOOABAR"
+ }
+ },
+ {
+ "data": "FOO&#BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,5): expected-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO&#BAR",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO&amp;#BAR</body></html>",
+ "noQuirksBodyHtml": "FOO&amp;#BAR"
+ }
+ },
+ {
+ "data": "FOO&#ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,5): expected-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO&#ZOO",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO&amp;#ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO&amp;#ZOO"
+ }
+ },
+ {
+ "data": "FOO&#xBAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,7): expected-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOºR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOºR</body></html>",
+ "noQuirksBodyHtml": "FOOºR"
+ }
+ },
+ {
+ "data": "FOO&#xZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,6): expected-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO&#xZOO",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO&amp;#xZOO</body></html>",
+ "noQuirksBodyHtml": "FOO&amp;#xZOO"
+ }
+ },
+ {
+ "data": "FOO&#XZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,6): expected-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO&#XZOO",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO&amp;#XZOO</body></html>",
+ "noQuirksBodyHtml": "FOO&amp;#XZOO"
+ }
+ },
+ {
+ "data": "FOO&#41BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,7): numeric-entity-without-semicolon"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO)BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO)BAR</body></html>",
+ "noQuirksBodyHtml": "FOO)BAR"
+ }
+ },
+ {
+ "data": "FOO&#x41BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,10): numeric-entity-without-semicolon"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO䆺R"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO䆺R</body></html>",
+ "noQuirksBodyHtml": "FOO䆺R"
+ }
+ },
+ {
+ "data": "FOO&#x41ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,8): numeric-entity-without-semicolon"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOAZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOAZOO</body></html>",
+ "noQuirksBodyHtml": "FOOAZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0000;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO�ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0078;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOxZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOxZOO</body></html>",
+ "noQuirksBodyHtml": "FOOxZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0079;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOyZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOyZOO</body></html>",
+ "noQuirksBodyHtml": "FOOyZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0080;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO€ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO€ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO€ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0081;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOZOO</body></html>",
+ "noQuirksBodyHtml": "FOOZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0082;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO‚ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO‚ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO‚ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0083;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOƒZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOƒZOO</body></html>",
+ "noQuirksBodyHtml": "FOOƒZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0084;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO„ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO„ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO„ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0085;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO…ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO…ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO…ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0086;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO†ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO†ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO†ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0087;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO‡ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO‡ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO‡ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0088;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOˆZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOˆZOO</body></html>",
+ "noQuirksBodyHtml": "FOOˆZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0089;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO‰ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO‰ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO‰ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x008A;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOŠZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOŠZOO</body></html>",
+ "noQuirksBodyHtml": "FOOŠZOO"
+ }
+ },
+ {
+ "data": "FOO&#x008B;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO‹ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO‹ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO‹ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x008C;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOŒZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOŒZOO</body></html>",
+ "noQuirksBodyHtml": "FOOŒZOO"
+ }
+ },
+ {
+ "data": "FOO&#x008D;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOZOO</body></html>",
+ "noQuirksBodyHtml": "FOOZOO"
+ }
+ },
+ {
+ "data": "FOO&#x008E;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOŽZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOŽZOO</body></html>",
+ "noQuirksBodyHtml": "FOOŽZOO"
+ }
+ },
+ {
+ "data": "FOO&#x008F;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOZOO</body></html>",
+ "noQuirksBodyHtml": "FOOZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0090;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOZOO</body></html>",
+ "noQuirksBodyHtml": "FOOZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0091;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO‘ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO‘ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO‘ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0092;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO’ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO’ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO’ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0093;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO“ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO“ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO“ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0094;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO”ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO”ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO”ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0095;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO•ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO•ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO•ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0096;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO–ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO–ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO–ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0097;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO—ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO—ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO—ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0098;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO˜ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO˜ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO˜ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x0099;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO™ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO™ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO™ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x009A;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOšZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOšZOO</body></html>",
+ "noQuirksBodyHtml": "FOOšZOO"
+ }
+ },
+ {
+ "data": "FOO&#x009B;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO›ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO›ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO›ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x009C;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOœZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOœZOO</body></html>",
+ "noQuirksBodyHtml": "FOOœZOO"
+ }
+ },
+ {
+ "data": "FOO&#x009D;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOZOO</body></html>",
+ "noQuirksBodyHtml": "FOOZOO"
+ }
+ },
+ {
+ "data": "FOO&#x009E;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOžZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOžZOO</body></html>",
+ "noQuirksBodyHtml": "FOOžZOO"
+ }
+ },
+ {
+ "data": "FOO&#x009F;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOŸZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOŸZOO</body></html>",
+ "noQuirksBodyHtml": "FOOŸZOO"
+ }
+ },
+ {
+ "data": "FOO&#x00A0;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO ZOO",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO&nbsp;ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO&nbsp;ZOO"
+ }
+ },
+ {
+ "data": "FOO&#xD7FF;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO퟿ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO퟿ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO퟿ZOO"
+ }
+ },
+ {
+ "data": "FOO&#xD800;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO�ZOO"
+ }
+ },
+ {
+ "data": "FOO&#xD801;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO�ZOO"
+ }
+ },
+ {
+ "data": "FOO&#xDFFE;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO�ZOO"
+ }
+ },
+ {
+ "data": "FOO&#xDFFF;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO�ZOO"
+ }
+ },
+ {
+ "data": "FOO&#xE000;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOOZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOOZOO</body></html>",
+ "noQuirksBodyHtml": "FOOZOO"
+ }
+ },
+ {
+ "data": "FOO&#x10FFFE;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,13): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO􏿾ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO􏿾ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO􏿾ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x1087D4;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO􈟔ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO􈟔ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO􈟔ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x10FFFF;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,13): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO􏿿ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO􏿿ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO􏿿ZOO"
+ }
+ },
+ {
+ "data": "FOO&#x110000;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,13): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO�ZOO"
+ }
+ },
+ {
+ "data": "FOO&#xFFFFFF;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,13): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO�ZOO"
+ }
+ },
+ {
+ "data": "FOO&#11111111111",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,13): illegal-codepoint-for-numeric-entity",
+ "(1,13): eof-in-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�</body></html>",
+ "noQuirksBodyHtml": "FOO�"
+ }
+ },
+ {
+ "data": "FOO&#1111111111",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,13): illegal-codepoint-for-numeric-entity",
+ "(1,13): eof-in-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�</body></html>",
+ "noQuirksBodyHtml": "FOO�"
+ }
+ },
+ {
+ "data": "FOO&#111111111111",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,13): illegal-codepoint-for-numeric-entity",
+ "(1,13): eof-in-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�</body></html>",
+ "noQuirksBodyHtml": "FOO�"
+ }
+ },
+ {
+ "data": "FOO&#11111111111ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,16): numeric-entity-without-semicolon",
+ "(1,16): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO�ZOO"
+ }
+ },
+ {
+ "data": "FOO&#1111111111ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,15): numeric-entity-without-semicolon",
+ "(1,15): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO�ZOO"
+ }
+ },
+ {
+ "data": "FOO&#111111111111ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,17): numeric-entity-without-semicolon",
+ "(1,17): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO�ZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO�ZOO</body></html>",
+ "noQuirksBodyHtml": "FOO�ZOO"
+ }
+ }
+ ],
+ "entities02.dat": [
+ {
+ "data": "<div bar=\"ZZ&gt;YY\"></div>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ>YY"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ>YY\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ>YY\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&\"></div>",
+ "errors": [
+ "(1,15): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ&",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ&amp;\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ&amp;\"></div>"
+ }
+ },
+ {
+ "data": "<div bar='ZZ&'></div>",
+ "errors": [
+ "(1,15): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ&",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ&amp;\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ&amp;\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=ZZ&></div>",
+ "errors": [
+ "(1,13): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ&",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ&amp;\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ&amp;\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&gt=YY\"></div>",
+ "errors": [
+ "(1,15): named-entity-without-semicolon",
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ&gt=YY",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ&amp;gt=YY\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ&amp;gt=YY\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&gt0YY\"></div>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ&gt0YY",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ&amp;gt0YY\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ&amp;gt0YY\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&gt9YY\"></div>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ&gt9YY",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ&amp;gt9YY\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ&amp;gt9YY\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&gtaYY\"></div>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ&gtaYY",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ&amp;gtaYY\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ&amp;gtaYY\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&gtZYY\"></div>",
+ "errors": [
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ&gtZYY",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ&amp;gtZYY\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ&amp;gtZYY\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&gt YY\"></div>",
+ "errors": [
+ "(1,15): named-entity-without-semicolon",
+ "(1,20): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ> YY"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ> YY\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ> YY\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&gt\"></div>",
+ "errors": [
+ "(1,15): named-entity-without-semicolon",
+ "(1,17): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ>"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ>\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ>\"></div>"
+ }
+ },
+ {
+ "data": "<div bar='ZZ&gt'></div>",
+ "errors": [
+ "(1,15): named-entity-without-semicolon",
+ "(1,17): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ>"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ>\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ>\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=ZZ&gt></div>",
+ "errors": [
+ "(1,14): named-entity-without-semicolon",
+ "(1,15): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ>"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ>\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ>\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&pound_id=23\"></div>",
+ "errors": [
+ "(1,18): named-entity-without-semicolon",
+ "(1,26): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ£_id=23"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ£_id=23\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ£_id=23\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&prod_id=23\"></div>",
+ "errors": [
+ "(1,25): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ&prod_id=23",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ&amp;prod_id=23\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ&amp;prod_id=23\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&pound;_id=23\"></div>",
+ "errors": [
+ "(1,27): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ£_id=23"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ£_id=23\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ£_id=23\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&prod;_id=23\"></div>",
+ "errors": [
+ "(1,26): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ∏_id=23"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ∏_id=23\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ∏_id=23\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&pound=23\"></div>",
+ "errors": [
+ "(1,18): named-entity-without-semicolon",
+ "(1,23): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ&pound=23",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ&amp;pound=23\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ&amp;pound=23\"></div>"
+ }
+ },
+ {
+ "data": "<div bar=\"ZZ&prod=23\"></div>",
+ "errors": [
+ "(1,22): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "ZZ&prod=23",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div bar=\"ZZ&amp;prod=23\"></div></body></html>",
+ "noQuirksBodyHtml": "<div bar=\"ZZ&amp;prod=23\"></div>"
+ }
+ },
+ {
+ "data": "<div>ZZ&pound_id=23</div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,13): named-entity-without-semicolon"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "ZZ£_id=23"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>ZZ£_id=23</div></body></html>",
+ "noQuirksBodyHtml": "<div>ZZ£_id=23</div>"
+ }
+ },
+ {
+ "data": "<div>ZZ&prod_id=23</div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "ZZ&prod_id=23",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>ZZ&amp;prod_id=23</div></body></html>",
+ "noQuirksBodyHtml": "<div>ZZ&amp;prod_id=23</div>"
+ }
+ },
+ {
+ "data": "<div>ZZ&pound;_id=23</div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "ZZ£_id=23"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>ZZ£_id=23</div></body></html>",
+ "noQuirksBodyHtml": "<div>ZZ£_id=23</div>"
+ }
+ },
+ {
+ "data": "<div>ZZ&prod;_id=23</div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "ZZ∏_id=23"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>ZZ∏_id=23</div></body></html>",
+ "noQuirksBodyHtml": "<div>ZZ∏_id=23</div>"
+ }
+ },
+ {
+ "data": "<div>ZZ&pound=23</div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,13): named-entity-without-semicolon"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "ZZ£=23"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>ZZ£=23</div></body></html>",
+ "noQuirksBodyHtml": "<div>ZZ£=23</div>"
+ }
+ },
+ {
+ "data": "<div>ZZ&prod=23</div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "ZZ&prod=23",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>ZZ&amp;prod=23</div></body></html>",
+ "noQuirksBodyHtml": "<div>ZZ&amp;prod=23</div>"
+ }
+ },
+ {
+ "data": "<div>ZZ&AElig=</div>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "ZZÆ="
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>ZZÆ=</div></body></html>",
+ "noQuirksBodyHtml": "<div>ZZÆ=</div>"
+ }
+ }
+ ],
+ "foreign-fragment.dat": [
+ {
+ "data": "<nobr>X",
+ "errors": [
+ "6: HTML start tag “nobr” in a foreign namespace context.",
+ "7: End of file seen and there were open elements.",
+ "6: Unclosed element “nobr”."
+ ],
+ "fragment": {
+ "name": "path",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "svg nobr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "nobr",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ],
+ "html": "<nobr>X</nobr>",
+ "noQuirksBodyHtml": "<nobr>X</nobr>"
+ }
+ },
+ {
+ "data": "<font color></font>X",
+ "errors": [
+ "12: HTML start tag “font” in a foreign namespace context."
+ ],
+ "fragment": {
+ "name": "path",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "svg font": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "font",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "color",
+ "value": ""
+ }
+ ]
+ },
+ {
+ "text": "X"
+ }
+ ],
+ "html": "<font color=\"\"></font>X",
+ "noQuirksBodyHtml": "<font color=\"\"></font>X"
+ }
+ },
+ {
+ "data": "<font></font>X",
+ "errors": [],
+ "fragment": {
+ "name": "path",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "svg font": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "font",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "text": "X"
+ }
+ ],
+ "html": "<font></font>X",
+ "noQuirksBodyHtml": "<font></font>X"
+ }
+ },
+ {
+ "data": "<g></path>X",
+ "errors": [
+ "10: End tag “path” did not match the name of the current open element (“g”).",
+ "11: End of file seen and there were open elements.",
+ "3: Unclosed element “g”."
+ ],
+ "fragment": {
+ "name": "path",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "svg g": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ],
+ "html": "<g>X</g>",
+ "noQuirksBodyHtml": "<g>X</g>"
+ }
+ },
+ {
+ "data": "</path>X",
+ "errors": [
+ "5: Stray end tag “path”."
+ ],
+ "fragment": {
+ "name": "path",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</foreignObject>X",
+ "errors": [
+ "5: Stray end tag “foreignobject”."
+ ],
+ "fragment": {
+ "name": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</desc>X",
+ "errors": [
+ "5: Stray end tag “desc”."
+ ],
+ "fragment": {
+ "name": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</title>X",
+ "errors": [
+ "5: Stray end tag “title”."
+ ],
+ "fragment": {
+ "name": "title",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</svg>X",
+ "errors": [
+ "5: Stray end tag “svg”."
+ ],
+ "fragment": {
+ "name": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</mfenced>X",
+ "errors": [
+ "5: Stray end tag “mfenced”."
+ ],
+ "fragment": {
+ "name": "mfenced",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</malignmark>X",
+ "errors": [
+ "5: Stray end tag “malignmark”."
+ ],
+ "fragment": {
+ "name": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</math>X",
+ "errors": [
+ "5: Stray end tag “math”."
+ ],
+ "fragment": {
+ "name": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</annotation-xml>X",
+ "errors": [
+ "5: Stray end tag “annotation-xml”."
+ ],
+ "fragment": {
+ "name": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</mtext>X",
+ "errors": [
+ "5: Stray end tag “mtext”."
+ ],
+ "fragment": {
+ "name": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</mi>X",
+ "errors": [
+ "5: Stray end tag “mi”."
+ ],
+ "fragment": {
+ "name": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</mo>X",
+ "errors": [
+ "5: Stray end tag “mo”."
+ ],
+ "fragment": {
+ "name": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</mn>X",
+ "errors": [
+ "5: Stray end tag “mn”."
+ ],
+ "fragment": {
+ "name": "mn",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "</ms>X",
+ "errors": [
+ "5: Stray end tag “ms”."
+ ],
+ "fragment": {
+ "name": "ms",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "<b></b><mglyph/><i></i><malignmark/><u></u><ms/>X",
+ "errors": [
+ "51: Self-closing syntax (“/>”) used on a non-void HTML element. Ignoring the slash and treating as a start tag.",
+ "52: End of file seen and there were open elements.",
+ "51: Unclosed element “ms”."
+ ],
+ "fragment": {
+ "name": "ms",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "b": true,
+ "math mglyph": true,
+ "i": true,
+ "math malignmark": true,
+ "u": true,
+ "ms": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "mglyph",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "i"
+ },
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "u"
+ },
+ {
+ "tag": "ms",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ],
+ "html": "<b></b><mglyph></mglyph><i></i><malignmark></malignmark><u></u><ms>X</ms>",
+ "noQuirksBodyHtml": "<b></b><mglyph><i></i><malignmark><u></u><ms>X</ms></malignmark></mglyph>"
+ }
+ },
+ {
+ "data": "<malignmark></malignmark>",
+ "errors": [],
+ "fragment": {
+ "name": "ms",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math malignmark": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ],
+ "html": "<malignmark></malignmark>",
+ "noQuirksBodyHtml": "<malignmark></malignmark>"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [],
+ "fragment": {
+ "name": "ms",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<figure></figure>",
+ "errors": [],
+ "fragment": {
+ "name": "ms",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "figure": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "figure"
+ }
+ ],
+ "html": "<figure></figure>",
+ "noQuirksBodyHtml": "<figure></figure>"
+ }
+ },
+ {
+ "data": "<b></b><mglyph/><i></i><malignmark/><u></u><mn/>X",
+ "errors": [
+ "51: Self-closing syntax (“/>”) used on a non-void HTML element. Ignoring the slash and treating as a start tag.",
+ "52: End of file seen and there were open elements.",
+ "51: Unclosed element “mn”."
+ ],
+ "fragment": {
+ "name": "mn",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "b": true,
+ "math mglyph": true,
+ "i": true,
+ "math malignmark": true,
+ "u": true,
+ "mn": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "mglyph",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "i"
+ },
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "u"
+ },
+ {
+ "tag": "mn",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ],
+ "html": "<b></b><mglyph></mglyph><i></i><malignmark></malignmark><u></u><mn>X</mn>",
+ "noQuirksBodyHtml": "<b></b><mglyph><i></i><malignmark><u></u><mn>X</mn></malignmark></mglyph>"
+ }
+ },
+ {
+ "data": "<malignmark></malignmark>",
+ "errors": [],
+ "fragment": {
+ "name": "mn",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math malignmark": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ],
+ "html": "<malignmark></malignmark>",
+ "noQuirksBodyHtml": "<malignmark></malignmark>"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [],
+ "fragment": {
+ "name": "mn",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<figure></figure>",
+ "errors": [],
+ "fragment": {
+ "name": "mn",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "figure": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "figure"
+ }
+ ],
+ "html": "<figure></figure>",
+ "noQuirksBodyHtml": "<figure></figure>"
+ }
+ },
+ {
+ "data": "<b></b><mglyph/><i></i><malignmark/><u></u><mo/>X",
+ "errors": [
+ "51: Self-closing syntax (“/>”) used on a non-void HTML element. Ignoring the slash and treating as a start tag.",
+ "52: End of file seen and there were open elements.",
+ "51: Unclosed element “mo”."
+ ],
+ "fragment": {
+ "name": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "b": true,
+ "math mglyph": true,
+ "i": true,
+ "math malignmark": true,
+ "u": true,
+ "mo": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "mglyph",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "i"
+ },
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "u"
+ },
+ {
+ "tag": "mo",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ],
+ "html": "<b></b><mglyph></mglyph><i></i><malignmark></malignmark><u></u><mo>X</mo>",
+ "noQuirksBodyHtml": "<b></b><mglyph><i></i><malignmark><u></u><mo>X</mo></malignmark></mglyph>"
+ }
+ },
+ {
+ "data": "<malignmark></malignmark>",
+ "errors": [],
+ "fragment": {
+ "name": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math malignmark": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ],
+ "html": "<malignmark></malignmark>",
+ "noQuirksBodyHtml": "<malignmark></malignmark>"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [],
+ "fragment": {
+ "name": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<figure></figure>",
+ "errors": [],
+ "fragment": {
+ "name": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "figure": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "figure"
+ }
+ ],
+ "html": "<figure></figure>",
+ "noQuirksBodyHtml": "<figure></figure>"
+ }
+ },
+ {
+ "data": "<b></b><mglyph/><i></i><malignmark/><u></u><mi/>X",
+ "errors": [
+ "51: Self-closing syntax (“/>”) used on a non-void HTML element. Ignoring the slash and treating as a start tag.",
+ "52: End of file seen and there were open elements.",
+ "51: Unclosed element “mi”."
+ ],
+ "fragment": {
+ "name": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "b": true,
+ "math mglyph": true,
+ "i": true,
+ "math malignmark": true,
+ "u": true,
+ "mi": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "mglyph",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "i"
+ },
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "u"
+ },
+ {
+ "tag": "mi",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ],
+ "html": "<b></b><mglyph></mglyph><i></i><malignmark></malignmark><u></u><mi>X</mi>",
+ "noQuirksBodyHtml": "<b></b><mglyph><i></i><malignmark><u></u><mi>X</mi></malignmark></mglyph>"
+ }
+ },
+ {
+ "data": "<malignmark></malignmark>",
+ "errors": [],
+ "fragment": {
+ "name": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math malignmark": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ],
+ "html": "<malignmark></malignmark>",
+ "noQuirksBodyHtml": "<malignmark></malignmark>"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [],
+ "fragment": {
+ "name": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<figure></figure>",
+ "errors": [],
+ "fragment": {
+ "name": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "figure": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "figure"
+ }
+ ],
+ "html": "<figure></figure>",
+ "noQuirksBodyHtml": "<figure></figure>"
+ }
+ },
+ {
+ "data": "<b></b><mglyph/><i></i><malignmark/><u></u><mtext/>X",
+ "errors": [
+ "51: Self-closing syntax (“/>”) used on a non-void HTML element. Ignoring the slash and treating as a start tag.",
+ "52: End of file seen and there were open elements.",
+ "51: Unclosed element “mtext”."
+ ],
+ "fragment": {
+ "name": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "b": true,
+ "math mglyph": true,
+ "i": true,
+ "math malignmark": true,
+ "u": true,
+ "mtext": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "mglyph",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "i"
+ },
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "u"
+ },
+ {
+ "tag": "mtext",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ],
+ "html": "<b></b><mglyph></mglyph><i></i><malignmark></malignmark><u></u><mtext>X</mtext>",
+ "noQuirksBodyHtml": "<b></b><mglyph><i></i><malignmark><u></u><mtext>X</mtext></malignmark></mglyph>"
+ }
+ },
+ {
+ "data": "<malignmark></malignmark>",
+ "errors": [],
+ "fragment": {
+ "name": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math malignmark": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ],
+ "html": "<malignmark></malignmark>",
+ "noQuirksBodyHtml": "<malignmark></malignmark>"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [],
+ "fragment": {
+ "name": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<figure></figure>",
+ "errors": [],
+ "fragment": {
+ "name": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "figure": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "figure"
+ }
+ ],
+ "html": "<figure></figure>",
+ "noQuirksBodyHtml": "<figure></figure>"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [
+ "5: HTML start tag “div” in a foreign namespace context."
+ ],
+ "fragment": {
+ "name": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<figure></figure>",
+ "errors": [],
+ "fragment": {
+ "name": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math figure": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "figure",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ],
+ "html": "<figure></figure>",
+ "noQuirksBodyHtml": "<figure></figure>"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [
+ "5: HTML start tag “div” in a foreign namespace context."
+ ],
+ "fragment": {
+ "name": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<figure></figure>",
+ "errors": [],
+ "fragment": {
+ "name": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math figure": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "figure",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ],
+ "html": "<figure></figure>",
+ "noQuirksBodyHtml": "<figure></figure>"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [],
+ "fragment": {
+ "name": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<figure></figure>",
+ "errors": [],
+ "fragment": {
+ "name": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "figure": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "figure"
+ }
+ ],
+ "html": "<figure></figure>",
+ "noQuirksBodyHtml": "<figure></figure>"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [],
+ "fragment": {
+ "name": "title",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<figure></figure>",
+ "errors": [],
+ "fragment": {
+ "name": "title",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "figure": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "figure"
+ }
+ ],
+ "html": "<figure></figure>",
+ "noQuirksBodyHtml": "<figure></figure>"
+ }
+ },
+ {
+ "data": "<figure></figure>",
+ "errors": [],
+ "fragment": {
+ "name": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "figure": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "figure"
+ }
+ ],
+ "html": "<figure></figure>",
+ "noQuirksBodyHtml": "<figure></figure>"
+ }
+ },
+ {
+ "data": "<div><h1>X</h1></div>",
+ "errors": [
+ "5: HTML start tag “div” in a foreign namespace context.",
+ "9: HTML start tag “h1” in a foreign namespace context."
+ ],
+ "fragment": {
+ "name": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "svg div": true,
+ "svg h1": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "h1",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<div><h1>X</h1></div>",
+ "noQuirksBodyHtml": "<div><h1>X</h1></div>"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [
+ "5: HTML start tag “div” in a foreign namespace context."
+ ],
+ "fragment": {
+ "name": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "svg div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [],
+ "fragment": {
+ "name": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<figure></figure>",
+ "errors": [],
+ "fragment": {
+ "name": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "figure": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "figure"
+ }
+ ],
+ "html": "<figure></figure>",
+ "noQuirksBodyHtml": "<figure></figure>"
+ }
+ },
+ {
+ "data": "<plaintext><foo>",
+ "errors": [
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "plaintext": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "text": "<foo>",
+ "no_escape": true
+ }
+ ]
+ }
+ ],
+ "html": "<plaintext><foo></plaintext>",
+ "noQuirksBodyHtml": "<plaintext><foo></plaintext>"
+ }
+ },
+ {
+ "data": "<frameset>X",
+ "errors": [
+ "6: Stray start tag “frameset”."
+ ],
+ "fragment": {
+ "name": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "<head>X",
+ "errors": [
+ "6: Stray start tag “head”."
+ ],
+ "fragment": {
+ "name": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "<body>X",
+ "errors": [
+ "6: Stray start tag “body”."
+ ],
+ "fragment": {
+ "name": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "<html>X",
+ "errors": [
+ "6: Stray start tag “html”."
+ ],
+ "fragment": {
+ "name": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "<html class=\"foo\">X",
+ "errors": [
+ "6: Stray start tag “html”."
+ ],
+ "fragment": {
+ "name": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "<body class=\"foo\">X",
+ "errors": [
+ "6: Stray start tag “body”."
+ ],
+ "fragment": {
+ "name": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "X"
+ }
+ ],
+ "html": "X",
+ "noQuirksBodyHtml": "X"
+ }
+ }
+ ],
+ "html5test-com.dat": [
+ {
+ "data": "<div<div>",
+ "errors": [
+ "(1,9): expected-doctype-but-got-start-tag",
+ "(1,9): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div<div": true
+ },
+ "tagWithLt": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div<div"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div<div></div<div></body></html>",
+ "noQuirksBodyHtml": "<div<div></div<div>"
+ }
+ },
+ {
+ "data": "<div foo<bar=''>",
+ "errors": [
+ "(1,9): invalid-character-in-attribute-name",
+ "(1,16): expected-doctype-but-got-start-tag",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "attrWithFunnyChar": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "foo<bar",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div foo<bar=\"\"></div></body></html>",
+ "noQuirksBodyHtml": "<div foo<bar=\"\"></div>"
+ }
+ },
+ {
+ "data": "<div foo=`bar`>",
+ "errors": [
+ "(1,10): equals-in-unquoted-attribute-value",
+ "(1,14): unexpected-character-in-unquoted-attribute-value",
+ "(1,15): expected-doctype-but-got-start-tag",
+ "(1,15): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "foo",
+ "value": "`bar`"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div foo=\"`bar`\"></div></body></html>",
+ "noQuirksBodyHtml": "<div foo=\"`bar`\"></div>"
+ }
+ },
+ {
+ "data": "<div \\\"foo=''>",
+ "errors": [
+ "(1,7): invalid-character-in-attribute-name",
+ "(1,14): expected-doctype-but-got-start-tag",
+ "(1,14): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "attrWithFunnyChar": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "\\\"foo",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div \\\"foo=\"\"></div></body></html>",
+ "noQuirksBodyHtml": "<div \\\"foo=\"\"></div>"
+ }
+ },
+ {
+ "data": "<a href='\\nbar'></a>",
+ "errors": [
+ "(1,16): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "\\nbar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a href=\"\\nbar\"></a></body></html>",
+ "noQuirksBodyHtml": "<a href=\"\\nbar\"></a>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "&lang;&rang;",
+ "errors": [
+ "(1,6): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "⟨⟩"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>⟨⟩</body></html>",
+ "noQuirksBodyHtml": "⟨⟩"
+ }
+ },
+ {
+ "data": "&apos;",
+ "errors": [
+ "(1,6): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "'"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>'</body></html>",
+ "noQuirksBodyHtml": "'"
+ }
+ },
+ {
+ "data": "&ImaginaryI;",
+ "errors": [
+ "(1,12): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "ⅈ"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>ⅈ</body></html>",
+ "noQuirksBodyHtml": "ⅈ"
+ }
+ },
+ {
+ "data": "&Kopf;",
+ "errors": [
+ "(1,6): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "𝕂"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>𝕂</body></html>",
+ "noQuirksBodyHtml": "𝕂"
+ }
+ },
+ {
+ "data": "&notinva;",
+ "errors": [
+ "(1,9): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "∉"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>∉</body></html>",
+ "noQuirksBodyHtml": "∉"
+ }
+ },
+ {
+ "data": "<?import namespace=\"foo\" implementation=\"#bar\">",
+ "errors": [
+ "(1,1): expected-tag-name-but-got-question-mark",
+ "(1,47): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "?import namespace=\"foo\" implementation=\"#bar\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--?import namespace=\"foo\" implementation=\"#bar\"--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--?import namespace=\"foo\" implementation=\"#bar\"-->"
+ }
+ },
+ {
+ "data": "<!--foo--bar-->",
+ "errors": [
+ "(1,10): unexpected-char-in-comment",
+ "(1,15): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "foo--bar"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--foo--bar--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--foo--bar-->"
+ }
+ },
+ {
+ "data": "<![CDATA[x]]>",
+ "errors": [
+ "(1,2): expected-dashes-or-doctype",
+ "(1,13): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "[CDATA[x]]"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--[CDATA[x]]--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--[CDATA[x]]-->"
+ }
+ },
+ {
+ "data": "<textarea><!--</textarea>--></textarea>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,39): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "<!--",
+ "escaped": true
+ }
+ ]
+ },
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><textarea>&lt;!--</textarea>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<textarea>&lt;!--</textarea>--&gt;"
+ }
+ },
+ {
+ "data": "<textarea><!--</textarea>-->",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "<!--",
+ "escaped": true
+ }
+ ]
+ },
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><textarea>&lt;!--</textarea>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<textarea>&lt;!--</textarea>--&gt;"
+ }
+ },
+ {
+ "data": "<style><!--</style>--></style>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,30): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style><!--</style></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<style><!--</style>--&gt;"
+ }
+ },
+ {
+ "data": "<style><!--</style>-->",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style><!--</style></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<style><!--</style>--&gt;"
+ }
+ },
+ {
+ "data": "<ul><li>A </li> <li>B</li></ul>",
+ "errors": [
+ "(1,4): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ul": true,
+ "li": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ul",
+ "children": [
+ {
+ "tag": "li",
+ "children": [
+ {
+ "text": "A "
+ }
+ ]
+ },
+ {
+ "text": " "
+ },
+ {
+ "tag": "li",
+ "children": [
+ {
+ "text": "B"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ul><li>A </li> <li>B</li></ul></body></html>",
+ "noQuirksBodyHtml": "<ul><li>A </li> <li>B</li></ul>"
+ }
+ },
+ {
+ "data": "<table><form><input type=hidden><input></form><div></div></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,13): unexpected-form-in-table",
+ "(1,32): unexpected-hidden-input-in-table",
+ "(1,39): unexpected-start-tag-implies-table-voodoo",
+ "(1,46): unexpected-end-tag-implies-table-voodoo",
+ "(1,46): unexpected-end-tag",
+ "(1,51): unexpected-start-tag-implies-table-voodoo",
+ "(1,57): unexpected-end-tag-implies-table-voodoo"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "input": true,
+ "div": true,
+ "table": true,
+ "form": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "input"
+ },
+ {
+ "tag": "div"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "form"
+ },
+ {
+ "tag": "input",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "hidden"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><input><div></div><table><form></form><input type=\"hidden\"></table></body></html>",
+ "noQuirksBodyHtml": "<input><div></div><table><form></form><input type=\"hidden\"></table>"
+ }
+ },
+ {
+ "data": "<i>A<b>B<p></i>C</b>D",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,15): adoption-agency-1.3",
+ "(1,20): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "i": true,
+ "b": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "A"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "B"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "i"
+ },
+ {
+ "text": "C"
+ }
+ ]
+ },
+ {
+ "text": "D"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><i>A<b>B</b></i><b></b><p><b><i></i>C</b>D</p></body></html>",
+ "noQuirksBodyHtml": "<i>A<b>B</b></i><b></b><p><b><i></i>C</b>D</p>"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div></div></body></html>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<svg></svg>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg></svg></body></html>",
+ "noQuirksBodyHtml": "<svg></svg>"
+ }
+ },
+ {
+ "data": "<math></math>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math></math></body></html>",
+ "noQuirksBodyHtml": "<math></math>"
+ }
+ }
+ ],
+ "inbody01.dat": [
+ {
+ "data": "<button>1</foo>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,15): unexpected-end-tag",
+ "(1,15): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "button": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><button>1</button></body></html>",
+ "noQuirksBodyHtml": "<button>1</button>"
+ }
+ },
+ {
+ "data": "<foo>1<p>2</foo>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,16): unexpected-end-tag",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "foo": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "text": "1"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><foo>1<p>2</p></foo></body></html>",
+ "noQuirksBodyHtml": "<foo>1<p>2</p></foo>"
+ }
+ },
+ {
+ "data": "<dd>1</foo>",
+ "errors": [
+ "(1,4): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "dd": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "dd",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><dd>1</dd></body></html>",
+ "noQuirksBodyHtml": "<dd>1</dd>"
+ }
+ },
+ {
+ "data": "<foo>1<dd>2</foo>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,17): unexpected-end-tag",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "foo": true,
+ "dd": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "text": "1"
+ },
+ {
+ "tag": "dd",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><foo>1<dd>2</dd></foo></body></html>",
+ "noQuirksBodyHtml": "<foo>1<dd>2</dd></foo>"
+ }
+ }
+ ],
+ "isindex.dat": [
+ {
+ "data": "<isindex>",
+ "errors": [
+ "(1,9): expected-doctype-but-got-start-tag",
+ "(1,9): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "isindex": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "isindex"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><isindex></isindex></body></html>",
+ "noQuirksBodyHtml": "<isindex></isindex>"
+ }
+ },
+ {
+ "data": "<isindex name=\"A\" action=\"B\" prompt=\"C\" foo=\"D\">",
+ "errors": [
+ "(1,48): expected-doctype-but-got-start-tag",
+ "(1,48): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "isindex": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "isindex",
+ "attrs": [
+ {
+ "name": "action",
+ "value": "B"
+ },
+ {
+ "name": "foo",
+ "value": "D"
+ },
+ {
+ "name": "name",
+ "value": "A"
+ },
+ {
+ "name": "prompt",
+ "value": "C"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><isindex name=\"A\" action=\"B\" prompt=\"C\" foo=\"D\"></isindex></body></html>",
+ "noQuirksBodyHtml": "<isindex name=\"A\" action=\"B\" prompt=\"C\" foo=\"D\"></isindex>"
+ }
+ },
+ {
+ "data": "<form><isindex>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,15): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "form": true,
+ "isindex": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "form",
+ "children": [
+ {
+ "tag": "isindex"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><form><isindex></isindex></form></body></html>",
+ "noQuirksBodyHtml": "<form><isindex></isindex></form>"
+ }
+ },
+ {
+ "data": "<!doctype html><isindex>x</isindex>x",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "isindex": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "isindex",
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ },
+ {
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><isindex>x</isindex>x</body></html>",
+ "noQuirksBodyHtml": "<isindex>x</isindex>x"
+ }
+ }
+ ],
+ "main-element.dat": [
+ {
+ "data": "<!doctype html><p>foo<main>bar<p>baz",
+ "errors": [
+ "(1,36): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "main": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "main",
+ "children": [
+ {
+ "text": "bar"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p>foo</p><main>bar<p>baz</p></main></body></html>",
+ "noQuirksBodyHtml": "<p>foo</p><main>bar<p>baz</p></main>"
+ }
+ },
+ {
+ "data": "<!doctype html><main><p>foo</main>bar",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "main": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "main",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><main><p>foo</p></main>bar</body></html>",
+ "noQuirksBodyHtml": "<main><p>foo</p></main>bar"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>xxx<svg><x><g><a><main><b>",
+ "errors": [
+ " * (1,42) unexpected HTML-like start tag token in foreign content",
+ " * (1,42) unexpected end of file"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg x": true,
+ "svg g": true,
+ "svg a": true,
+ "svg main": true,
+ "b": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "xxx"
+ },
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "x",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "a",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "main",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>xxx<svg><x><g><a><main></main></a></g></x></svg><b></b></body></html>",
+ "noQuirksBodyHtml": "xxx<svg><x><g><a><main><b></b></main></a></g></x></svg>"
+ }
+ }
+ ],
+ "math.dat": [
+ {
+ "data": "<math><tr><td><mo><tr>",
+ "errors": [],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math math": true,
+ "math tr": true,
+ "math td": true,
+ "math mo": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "tr",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "td",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<math><tr><td><mo></mo></td></tr></math>",
+ "noQuirksBodyHtml": "<math><tr><td><mo></mo></td></tr></math>"
+ }
+ },
+ {
+ "data": "<math><tr><td><mo><tr>",
+ "errors": [],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math math": true,
+ "math tr": true,
+ "math td": true,
+ "math mo": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "tr",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "td",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<math><tr><td><mo></mo></td></tr></math>",
+ "noQuirksBodyHtml": "<math><tr><td><mo></mo></td></tr></math>"
+ }
+ },
+ {
+ "data": "<math><thead><mo><tbody>",
+ "errors": [],
+ "fragment": {
+ "name": "thead"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math math": true,
+ "math thead": true,
+ "math mo": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "thead",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<math><thead><mo></mo></thead></math>",
+ "noQuirksBodyHtml": "<math><thead><mo></mo></thead></math>"
+ }
+ },
+ {
+ "data": "<math><tfoot><mo><tbody>",
+ "errors": [],
+ "fragment": {
+ "name": "tfoot"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math math": true,
+ "math tfoot": true,
+ "math mo": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "tfoot",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<math><tfoot><mo></mo></tfoot></math>",
+ "noQuirksBodyHtml": "<math><tfoot><mo></mo></tfoot></math>"
+ }
+ },
+ {
+ "data": "<math><tbody><mo><tfoot>",
+ "errors": [],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math math": true,
+ "math tbody": true,
+ "math mo": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "tbody",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<math><tbody><mo></mo></tbody></math>",
+ "noQuirksBodyHtml": "<math><tbody><mo></mo></tbody></math>"
+ }
+ },
+ {
+ "data": "<math><tbody><mo></table>",
+ "errors": [],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math math": true,
+ "math tbody": true,
+ "math mo": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "tbody",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<math><tbody><mo></mo></tbody></math>",
+ "noQuirksBodyHtml": "<math><tbody><mo></mo></tbody></math>"
+ }
+ },
+ {
+ "data": "<math><thead><mo></table>",
+ "errors": [],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math math": true,
+ "math thead": true,
+ "math mo": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "thead",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<math><thead><mo></mo></thead></math>",
+ "noQuirksBodyHtml": "<math><thead><mo></mo></thead></math>"
+ }
+ },
+ {
+ "data": "<math><tfoot><mo></table>",
+ "errors": [],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "math math": true,
+ "math tfoot": true,
+ "math mo": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "tfoot",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<math><tfoot><mo></mo></tfoot></math>",
+ "noQuirksBodyHtml": "<math><tfoot><mo></mo></tfoot></math>"
+ }
+ }
+ ],
+ "menuitem-element.dat": [
+ {
+ "data": "<menuitem>",
+ "errors": [
+ "10: Start tag seen without seeing a doctype first. Expected “<!DOCTYPE html>”."
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "menuitem": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "menuitem"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><menuitem></menuitem></body></html>",
+ "noQuirksBodyHtml": "<menuitem></menuitem>"
+ }
+ },
+ {
+ "data": "</menuitem>",
+ "errors": [
+ "11: End tag seen without seeing a doctype first. Expected “<!DOCTYPE html>”.",
+ "11: Stray end tag “menuitem”."
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><menuitem>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "menuitem": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "menuitem",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><menuitem>A</menuitem></body></html>",
+ "noQuirksBodyHtml": "<menuitem>A</menuitem>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><menuitem>A<menuitem>B",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "menuitem": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "menuitem",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ },
+ {
+ "tag": "menuitem",
+ "children": [
+ {
+ "text": "B"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><menuitem>A</menuitem><menuitem>B</menuitem></body></html>",
+ "noQuirksBodyHtml": "<menuitem>A</menuitem><menuitem>B</menuitem>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><menuitem>A<menu>B</menu>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "menuitem": true,
+ "menu": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "menuitem",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ },
+ {
+ "tag": "menu",
+ "children": [
+ {
+ "text": "B"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><menuitem>A</menuitem><menu>B</menu></body></html>",
+ "noQuirksBodyHtml": "<menuitem>A</menuitem><menu>B</menu>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><menuitem>A<hr>B",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "menuitem": true,
+ "hr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "menuitem",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ },
+ {
+ "tag": "hr"
+ },
+ {
+ "text": "B"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><menuitem>A</menuitem><hr>B</body></html>",
+ "noQuirksBodyHtml": "<menuitem>A</menuitem><hr>B"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><li><menuitem><li>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "li": true,
+ "menuitem": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "li",
+ "children": [
+ {
+ "tag": "menuitem"
+ }
+ ]
+ },
+ {
+ "tag": "li"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><li><menuitem></menuitem></li><li></li></body></html>",
+ "noQuirksBodyHtml": "<li><menuitem></menuitem></li><li></li>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><menuitem><p></menuitem>x",
+ "errors": [
+ "39: Stray end tag “menuitem”."
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "menuitem": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "menuitem",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><menuitem><p>x</p></menuitem></body></html>",
+ "noQuirksBodyHtml": "<menuitem><p>x</p></menuitem>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><p><b></p><menuitem>",
+ "errors": [
+ "25: End tag “p” seen, but there were open elements.",
+ "21: Unclosed element “b”.",
+ "35: End of file seen and there were open elements."
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "b": true,
+ "menuitem": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "menuitem"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><b></b></p><b><menuitem></menuitem></b></body></html>",
+ "noQuirksBodyHtml": "<p><b></b></p><b><menuitem></menuitem></b>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><menuitem><asdf></menuitem>x",
+ "errors": [
+ "40: End tag “menuitem” seen, but there were open elements.",
+ "31: Unclosed element “asdf”."
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "menuitem": true,
+ "asdf": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "menuitem",
+ "children": [
+ {
+ "tag": "asdf"
+ }
+ ]
+ },
+ {
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><menuitem><asdf></asdf></menuitem>x</body></html>",
+ "noQuirksBodyHtml": "<menuitem><asdf></asdf></menuitem>x"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html></menuitem>",
+ "errors": [
+ "26: Stray end tag “menuitem”."
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html></menuitem>",
+ "errors": [
+ "26: Stray end tag “menuitem”."
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><head></menuitem>",
+ "errors": [
+ "26: Stray end tag “menuitem”."
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><select><menuitem></select>",
+ "errors": [
+ "33: Stray start tag “menuitem”."
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><option><menuitem>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "option": true,
+ "menuitem": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "option",
+ "children": [
+ {
+ "tag": "menuitem"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><option><menuitem></menuitem></option></body></html>",
+ "noQuirksBodyHtml": "<option><menuitem></menuitem></option>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><menuitem><option>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "menuitem": true,
+ "option": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "menuitem",
+ "children": [
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><menuitem><option></option></menuitem></body></html>",
+ "noQuirksBodyHtml": "<menuitem><option></option></menuitem>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><menuitem></body>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "menuitem": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "menuitem"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><menuitem></menuitem></body></html>",
+ "noQuirksBodyHtml": "<menuitem></menuitem>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><menuitem></html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "menuitem": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "menuitem"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><menuitem></menuitem></body></html>",
+ "noQuirksBodyHtml": "<menuitem></menuitem>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><menuitem><p>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "menuitem": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "menuitem",
+ "children": [
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><menuitem><p></p></menuitem></body></html>",
+ "noQuirksBodyHtml": "<menuitem><p></p></menuitem>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><menuitem><li>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "menuitem": true,
+ "li": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "menuitem",
+ "children": [
+ {
+ "tag": "li"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><menuitem><li></li></menuitem></body></html>",
+ "noQuirksBodyHtml": "<menuitem><li></li></menuitem>"
+ }
+ }
+ ],
+ "namespace-sensitivity.dat": [
+ {
+ "data": "<body><table><tr><td><svg><td><foreignObject><span></td>Foo",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "svg svg": true,
+ "svg td": true,
+ "svg foreignObject": true,
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Foo"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "td",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>Foo<table><tbody><tr><td><svg><td><foreignObject><span></span></foreignObject></td></svg></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "Foo<table><tbody><tr><td><svg><td><foreignObject><span></span></foreignObject></td></svg></td></tr></tbody></table>"
+ }
+ }
+ ],
+ "noscript01.dat": [
+ {
+ "data": "<head><noscript><!doctype html><!--foo--></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE.",
+ "Line: 1 Col: 31 Unexpected DOCTYPE. Ignored."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><!--foo--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><!--foo--></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript><html class=\"foo\"><!--foo--></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE.",
+ "Line: 1 Col: 34 html needs to be the first start tag."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "class",
+ "value": "foo"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html class=\"foo\"><head><noscript><!--foo--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><!--foo--></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript></noscript>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-tag"
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript> </noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "text": " ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript> </noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript> </noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript><!--foo--></noscript>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-tag"
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><!--foo--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><!--foo--></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript><basefont><!--foo--></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "basefont": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "tag": "basefont"
+ },
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><basefont><!--foo--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><basefont><!--foo--></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript><bgsound><!--foo--></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "bgsound": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "tag": "bgsound"
+ },
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><bgsound><!--foo--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><bgsound><!--foo--></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript><link><!--foo--></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "link": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "tag": "link"
+ },
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><link><!--foo--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><link><!--foo--></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript><meta><!--foo--></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "meta": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "tag": "meta"
+ },
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><meta><!--foo--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><meta><!--foo--></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript><noframes>XXX</noscript></noframes></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "noframes": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "XXX</noscript>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><noframes>XXX</noscript></noframes></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><noframes>XXX</noscript></noframes></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript><style>XXX</style></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "XXX",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><style>XXX</style></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><style>XXX</style></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript></br><!--foo--></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE.",
+ "Line: 1 Col: 21 Element br not allowed in a inhead-noscript context",
+ "Line: 1 Col: 21 Unexpected end tag (br). Treated as br element.",
+ "Line: 1 Col: 42 Unexpected end tag (noscript). Ignored."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true,
+ "br": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript"
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "br"
+ },
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript></noscript></head><body><br><!--foo--></body></html>",
+ "noQuirksBodyHtml": "<noscript><br><!--foo--></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript><head class=\"foo\"><!--foo--></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE.",
+ "Line: 1 Col: 34 Unexpected start tag (head)."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><!--foo--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><!--foo--></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript><noscript class=\"foo\"><!--foo--></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE.",
+ "Line: 1 Col: 34 Unexpected start tag (noscript)."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><!--foo--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><noscript class=\"foo\"><!--foo--></noscript></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript></p><!--foo--></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE.",
+ "Line: 1 Col: 20 Unexpected end tag (p). Ignored."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><!--foo--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><p></p><!--foo--></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript><p><!--foo--></noscript>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE.",
+ "Line: 1 Col: 19 Element p not allowed in a inhead-noscript context",
+ "Line: 1 Col: 40 Unexpected end tag (noscript). Ignored."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true,
+ "p": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript"
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript></noscript></head><body><p><!--foo--></p></body></html>",
+ "noQuirksBodyHtml": "<noscript><p><!--foo--></p></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript>XXX<!--foo--></noscript></head>",
+ "errors": [
+ "Line: 1 Col: 6 Unexpected start tag (head). Expected DOCTYPE.",
+ "Line: 1 Col: 19 Unexpected non-space character. Expected inhead-noscript content",
+ "Line: 1 Col: 30 Unexpected end tag (noscript). Ignored.",
+ "Line: 1 Col: 37 Unexpected end tag (head). Ignored."
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript"
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "XXX"
+ },
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript></noscript></head><body>XXX<!--foo--></body></html>",
+ "noQuirksBodyHtml": "<noscript>XXX<!--foo--></noscript>"
+ }
+ },
+ {
+ "data": "<head><noscript>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-tag",
+ "(1,6): eof-in-head-noscript"
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript></noscript>"
+ }
+ }
+ ],
+ "pending-spec-changes-plain-text-unsafe.dat": [
+ {
+ "data": "<body><table>\u0000filler\u0000text\u0000",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,14): invalid-codepoint",
+ "(1,14): invalid-codepoint-in-table-text",
+ "(1,21): invalid-codepoint",
+ "(1,21): invalid-codepoint-in-table-text",
+ "(1,26): invalid-codepoint",
+ "(1,26): invalid-codepoint-in-table-text",
+ "(1,26): foster-parenting-character-in-table",
+ "(1,26): foster-parenting-character-in-table",
+ "(1,26): foster-parenting-character-in-table",
+ "(1,26): foster-parenting-character-in-table",
+ "(1,26): foster-parenting-character-in-table",
+ "(1,26): foster-parenting-character-in-table",
+ "(1,26): foster-parenting-character-in-table",
+ "(1,26): foster-parenting-character-in-table",
+ "(1,26): foster-parenting-character-in-table",
+ "(1,26): foster-parenting-character-in-table",
+ "(1,26): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "fillertext"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>fillertext<table></table></body></html>",
+ "noQuirksBodyHtml": "fillertext<table></table>"
+ }
+ }
+ ],
+ "pending-spec-changes.dat": [
+ {
+ "data": "<input type=\"hidden\"><frameset>",
+ "errors": [
+ "(1,21): expected-doctype-but-got-start-tag",
+ "(1,31): unexpected-start-tag",
+ "(1,31): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<input type=\"hidden\">"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><table><caption><svg>foo</table>bar",
+ "errors": [
+ "(1,47): unexpected-end-tag",
+ "(1,47): end-table-tag-in-caption"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><caption><svg>foo</svg></caption></table>bar</body></html>",
+ "noQuirksBodyHtml": "<table><caption><svg>foo</svg></caption></table>bar"
+ }
+ },
+ {
+ "data": "<table><tr><td><svg><desc><td></desc><circle>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,30): unexpected-cell-end-tag",
+ "(1,37): unexpected-end-tag",
+ "(1,45): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "svg svg": true,
+ "svg desc": true,
+ "circle": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "circle"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td><svg><desc></desc></svg></td><td><circle></circle></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><svg><desc></desc></svg></td><td><circle></circle></td></tr></tbody></table>"
+ }
+ }
+ ],
+ "plain-text-unsafe.dat": [
+ {
+ "data": "FOO&#x000D;ZOO",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,11): illegal-codepoint-for-numeric-entity"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO\rZOO"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO\rZOO</body></html>",
+ "noQuirksBodyHtml": "FOO\rZOO"
+ }
+ },
+ {
+ "data": "<html>\u0000<frameset></frameset>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,7): invalid-codepoint",
+ "(1,7): invalid-codepoint-in-body",
+ "(1,17): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html> \u0000 <frameset></frameset>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,8): invalid-codepoint",
+ "(1,8): invalid-codepoint-in-body",
+ "(1,19): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": " "
+ }
+ },
+ {
+ "data": "<html>a\u0000a<frameset></frameset>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,8): invalid-codepoint",
+ "(1,8): invalid-codepoint-in-body",
+ "(1,19): unexpected-start-tag",
+ "(1,30): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "aa"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>aa</body></html>",
+ "noQuirksBodyHtml": "aa"
+ }
+ },
+ {
+ "data": "<html>\u0000\u0000<frameset></frameset>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,7): invalid-codepoint",
+ "(1,7): invalid-codepoint-in-body",
+ "(1,8): invalid-codepoint",
+ "(1,8): invalid-codepoint-in-body",
+ "(1,18): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html>\u0000\n <frameset></frameset>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,7): invalid-codepoint",
+ "(1,7): invalid-codepoint-in-body",
+ "(2,11): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "\n "
+ }
+ },
+ {
+ "data": "<html><select>\u0000",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,15): invalid-codepoint",
+ "(1,15): invalid-codepoint-in-select",
+ "(1,15): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "\u0000",
+ "errors": [
+ "(1,1): invalid-codepoint",
+ "(1,1): expected-doctype-but-got-chars",
+ "(1,1): invalid-codepoint-in-body"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<body>\u0000",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,7): invalid-codepoint",
+ "(1,7): invalid-codepoint-in-body"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<plaintext>\u0000filler\u0000text\u0000",
+ "errors": [
+ "(1,11): expected-doctype-but-got-start-tag",
+ "(1,12): invalid-codepoint",
+ "(1,19): invalid-codepoint",
+ "(1,24): invalid-codepoint",
+ "(1,24): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "plaintext": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "text": "�filler�text�",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><plaintext>�filler�text�</plaintext></body></html>",
+ "noQuirksBodyHtml": "<plaintext>�filler�text�</plaintext>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[\u0000filler\u0000text\u0000]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,30): invalid-codepoint",
+ "(1,30): invalid-codepoint",
+ "(1,30): invalid-codepoint",
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "�filler�text�"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>�filler�text�</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>�filler�text�</svg>"
+ }
+ },
+ {
+ "data": "<body><!\u0000>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,8): expected-dashes-or-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "comment": "�"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><!--�--></body></html>",
+ "noQuirksBodyHtml": "<!--�-->"
+ }
+ },
+ {
+ "data": "<body><!\u0000filler\u0000text>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,8): expected-dashes-or-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "comment": "�filler�text"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><!--�filler�text--></body></html>",
+ "noQuirksBodyHtml": "<!--�filler�text-->"
+ }
+ },
+ {
+ "data": "<body><svg><foreignObject>\u0000filler\u0000text",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,27): invalid-codepoint",
+ "(1,27): invalid-codepoint-in-body",
+ "(1,34): invalid-codepoint",
+ "(1,34): invalid-codepoint-in-body",
+ "(1,38): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg foreignObject": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "fillertext"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg><foreignObject>fillertext</foreignObject></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><foreignObject>fillertext</foreignObject></svg>"
+ }
+ },
+ {
+ "data": "<svg>\u0000filler\u0000text",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,6): invalid-codepoint",
+ "(1,6): invalid-codepoint-in-foreign-content",
+ "(1,13): invalid-codepoint",
+ "(1,13): invalid-codepoint-in-foreign-content",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "�filler�text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>�filler�text</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>�filler�text</svg>"
+ }
+ },
+ {
+ "data": "<svg>\u0000<frameset>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,6): invalid-codepoint",
+ "(1,6): invalid-codepoint-in-foreign-content",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "�"
+ },
+ {
+ "tag": "frameset",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>�<frameset></frameset></svg></body></html>",
+ "noQuirksBodyHtml": "<svg>�<frameset></frameset></svg>"
+ }
+ },
+ {
+ "data": "<svg>\u0000 <frameset>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,6): invalid-codepoint",
+ "(1,6): invalid-codepoint-in-foreign-content",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "� "
+ },
+ {
+ "tag": "frameset",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>� <frameset></frameset></svg></body></html>",
+ "noQuirksBodyHtml": "<svg>� <frameset></frameset></svg>"
+ }
+ },
+ {
+ "data": "<svg>\u0000a<frameset>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,6): invalid-codepoint",
+ "(1,6): invalid-codepoint-in-foreign-content",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "�a"
+ },
+ {
+ "tag": "frameset",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>�a<frameset></frameset></svg></body></html>",
+ "noQuirksBodyHtml": "<svg>�a<frameset></frameset></svg>"
+ }
+ },
+ {
+ "data": "<svg>\u0000</svg><frameset>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,6): invalid-codepoint",
+ "(1,6): invalid-codepoint-in-foreign-content",
+ "(1,22): unexpected-start-tag",
+ "(1,22): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<svg>�</svg>"
+ }
+ },
+ {
+ "data": "<svg>\u0000 </svg><frameset>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,6): invalid-codepoint",
+ "(1,6): invalid-codepoint-in-foreign-content",
+ "(1,23): unexpected-start-tag",
+ "(1,23): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<svg>� </svg>"
+ }
+ },
+ {
+ "data": "<svg>\u0000a</svg><frameset>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,6): invalid-codepoint",
+ "(1,6): invalid-codepoint-in-foreign-content",
+ "(1,23): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "�a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>�a</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>�a</svg>"
+ }
+ },
+ {
+ "data": "<svg><path></path></svg><frameset>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,34): unexpected-start-tag",
+ "(1,34): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<svg><path></path></svg>"
+ }
+ },
+ {
+ "data": "<svg><p><frameset>",
+ "errors": [
+ "(1, 5) expected-doctype-but-got-start-tag",
+ "(1, 8) unexpected-html-element-in-foreign-content",
+ "(1, 18) unexpected-start-tag",
+ "(1, 18) eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<svg><p><frameset></frameset></p></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><pre>\r\n\r\nA</pre>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "pre": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "pre",
+ "children": [
+ {
+ "text": "\nA"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><pre>\nA</pre></body></html>",
+ "noQuirksBodyHtml": "<pre>\nA</pre>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><pre>\r\rA</pre>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "pre": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "pre",
+ "children": [
+ {
+ "text": "\nA"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><pre>\nA</pre></body></html>",
+ "noQuirksBodyHtml": "<pre>\nA</pre>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><pre>\rA</pre>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "pre": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "pre",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><pre>A</pre></body></html>",
+ "noQuirksBodyHtml": "<pre>A</pre>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><table><tr><td><math><mtext>\u0000a",
+ "errors": [
+ "(1,44): invalid-codepoint",
+ "(1,44): invalid-codepoint-in-body",
+ "(1,45): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "math math": true,
+ "math mtext": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><math><mtext>a</mtext></math></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><math><mtext>a</mtext></math></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><table><tr><td><svg><foreignObject>\u0000a",
+ "errors": [
+ "(1,51): invalid-codepoint",
+ "(1,51): invalid-codepoint-in-body",
+ "(1,52): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "svg svg": true,
+ "svg foreignObject": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><svg><foreignObject>a</foreignObject></svg></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><svg><foreignObject>a</foreignObject></svg></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><math><mi>a\u0000b",
+ "errors": [
+ "(1,27): invalid-codepoint",
+ "(1,27): invalid-codepoint-in-body",
+ "(1,28): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "ab"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mi>ab</mi></math></body></html>",
+ "noQuirksBodyHtml": "<math><mi>ab</mi></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><math><mo>a\u0000b",
+ "errors": [
+ "(1,27): invalid-codepoint",
+ "(1,27): invalid-codepoint-in-body",
+ "(1,28): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mo": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "ab"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mo>ab</mo></math></body></html>",
+ "noQuirksBodyHtml": "<math><mo>ab</mo></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><math><mn>a\u0000b",
+ "errors": [
+ "(1,27): invalid-codepoint",
+ "(1,27): invalid-codepoint-in-body",
+ "(1,28): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mn": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mn",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "ab"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mn>ab</mn></math></body></html>",
+ "noQuirksBodyHtml": "<math><mn>ab</mn></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><math><ms>a\u0000b",
+ "errors": [
+ "(1,27): invalid-codepoint",
+ "(1,27): invalid-codepoint-in-body",
+ "(1,28): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math ms": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "ms",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "ab"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><ms>ab</ms></math></body></html>",
+ "noQuirksBodyHtml": "<math><ms>ab</ms></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><math><mtext>a\u0000b",
+ "errors": [
+ "(1,30): invalid-codepoint",
+ "(1,30): invalid-codepoint-in-body",
+ "(1,31): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mtext": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "ab"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mtext>ab</mtext></math></body></html>",
+ "noQuirksBodyHtml": "<math><mtext>ab</mtext></math>"
+ }
+ }
+ ],
+ "ruby.dat": [
+ {
+ "data": "<html><ruby>a<rb>b<rb></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rb": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rb",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rb"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rb>b</rb><rb></rb></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rb>b</rb><rb></rb></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rb>b<rt></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rb": true,
+ "rt": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rb",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rt"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rb>b</rb><rt></rt></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rb>b</rb><rt></rt></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rb>b<rtc></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rb": true,
+ "rtc": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rb",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rtc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rb>b</rb><rtc></rtc></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rb>b</rb><rtc></rtc></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rb>b<rp></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rb": true,
+ "rp": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rb",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rp"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rb>b</rb><rp></rp></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rb>b</rb><rp></rp></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rb>b<span></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,31): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rb": true,
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rb",
+ "children": [
+ {
+ "text": "b"
+ },
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rb>b<span></span></rb></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rb>b<span></span></rb></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rt>b<rb></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rt": true,
+ "rb": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rt",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rb"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rt>b</rt><rb></rb></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rt>b</rt><rb></rb></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rt>b<rt></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rt": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rt",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rt"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rt>b</rt><rt></rt></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rt>b</rt><rt></rt></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rt>b<rtc></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rt": true,
+ "rtc": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rt",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rtc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rt>b</rt><rtc></rtc></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rt>b</rt><rtc></rtc></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rt>b<rp></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rt": true,
+ "rp": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rt",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rp"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rt>b</rt><rp></rp></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rt>b</rt><rp></rp></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rt>b<span></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,31): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rt": true,
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rt",
+ "children": [
+ {
+ "text": "b"
+ },
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rt>b<span></span></rt></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rt>b<span></span></rt></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rtc>b<rb></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rtc": true,
+ "rb": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rtc",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rb"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rtc>b</rtc><rb></rb></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rtc>b</rtc><rb></rb></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rtc>b<rt>c<rt>d</ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rtc": true,
+ "rt": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rtc",
+ "children": [
+ {
+ "text": "b"
+ },
+ {
+ "tag": "rt",
+ "children": [
+ {
+ "text": "c"
+ }
+ ]
+ },
+ {
+ "tag": "rt",
+ "children": [
+ {
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rtc>b<rt>c</rt><rt>d</rt></rtc></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rtc>b<rt>c</rt><rt>d</rt></rtc></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rtc>b<rtc></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rtc": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rtc",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rtc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rtc>b</rtc><rtc></rtc></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rtc>b</rtc><rtc></rtc></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rtc>b<rp></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rtc": true,
+ "rp": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rtc",
+ "children": [
+ {
+ "text": "b"
+ },
+ {
+ "tag": "rp"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rtc>b<rp></rp></rtc></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rtc>b<rp></rp></rtc></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rtc>b<span></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rtc": true,
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rtc",
+ "children": [
+ {
+ "text": "b"
+ },
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rtc>b<span></span></rtc></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rtc>b<span></span></rtc></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rp>b<rb></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rp": true,
+ "rb": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rp",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rb"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rp>b</rp><rb></rb></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rp>b</rp><rb></rb></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rp>b<rt></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rp": true,
+ "rt": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rp",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rt"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rp>b</rp><rt></rt></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rp>b</rp><rt></rt></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rp>b<rtc></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rp": true,
+ "rtc": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rp",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rtc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rp>b</rp><rtc></rtc></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rp>b</rp><rtc></rtc></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rp>b<rp></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rp": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rp",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rp"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rp>b</rp><rp></rp></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rp>b</rp><rp></rp></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rp>b<span></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,31): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rp": true,
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rp",
+ "children": [
+ {
+ "text": "b"
+ },
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rp>b<span></span></rp></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rp>b<span></span></rp></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby><rtc><ruby>a<rb>b<rt></ruby></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rtc": true,
+ "rb": true,
+ "rt": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "tag": "rtc",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rb",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rt"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby><rtc><ruby>a<rb>b</rb><rt></rt></ruby></rtc></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby><rtc><ruby>a<rb>b</rb><rt></rt></ruby></rtc></ruby>"
+ }
+ }
+ ],
+ "scriptdata01.dat": [
+ {
+ "data": "FOO<script>'Hello'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "'Hello'",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script>'Hello'</script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script>'Hello'</script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script></script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script"
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script></script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script></script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script></script >BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script"
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script></script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script></script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script></script/>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,21): self-closing-flag-on-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script"
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script></script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script></script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script></script/ >BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,20): unexpected-character-after-solidus-in-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script"
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script></script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script></script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script type=\"text/plain\"></scriptx>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,42): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "text/plain"
+ }
+ ],
+ "children": [
+ {
+ "text": "</scriptx>BAR",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script type=\"text/plain\"></scriptx>BAR</script></body></html>",
+ "noQuirksBodyHtml": "FOO<script type=\"text/plain\"></scriptx>BAR</script>"
+ }
+ },
+ {
+ "data": "FOO<script></script foo=\">\" dd>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,31): attributes-in-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script"
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script></script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script></script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script>'<'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "'<'",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script>'<'</script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script>'<'</script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script>'<!'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "'<!'",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script>'<!'</script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script>'<!'</script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script>'<!-'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "'<!-'",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script>'<!-'</script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script>'<!-'</script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script>'<!--'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "'<!--'",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script>'<!--'</script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script>'<!--'</script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script>'<!---'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "'<!---'",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script>'<!---'</script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script>'<!---'</script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script>'<!-->'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "'<!-->'",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script>'<!-->'</script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script>'<!-->'</script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script>'<!-->'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "'<!-->'",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script>'<!-->'</script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script>'<!-->'</script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script>'<!-- potato'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "'<!-- potato'",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script>'<!-- potato'</script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script>'<!-- potato'</script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script>'<!-- <sCrIpt'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "'<!-- <sCrIpt'",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script>'<!-- <sCrIpt'</script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script>'<!-- <sCrIpt'</script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt>'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,56): expected-script-data-but-got-eof",
+ "(1,56): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "text/plain"
+ }
+ ],
+ "children": [
+ {
+ "text": "'<!-- <sCrIpt>'</script>BAR",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt>'</script>BAR</script></body></html>",
+ "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt>'</script>BAR</script>"
+ }
+ },
+ {
+ "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> -'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,58): expected-script-data-but-got-eof",
+ "(1,58): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "text/plain"
+ }
+ ],
+ "children": [
+ {
+ "text": "'<!-- <sCrIpt> -'</script>BAR",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt> -'</script>BAR</script></body></html>",
+ "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> -'</script>BAR</script>"
+ }
+ },
+ {
+ "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> --'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,59): expected-script-data-but-got-eof",
+ "(1,59): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "text/plain"
+ }
+ ],
+ "children": [
+ {
+ "text": "'<!-- <sCrIpt> --'</script>BAR",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt> --'</script>BAR</script></body></html>",
+ "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> --'</script>BAR</script>"
+ }
+ },
+ {
+ "data": "FOO<script>'<!-- <sCrIpt> -->'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "'<!-- <sCrIpt> -->'",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script>'<!-- <sCrIpt> -->'</script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script>'<!-- <sCrIpt> -->'</script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> --!>'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,61): expected-script-data-but-got-eof",
+ "(1,61): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "text/plain"
+ }
+ ],
+ "children": [
+ {
+ "text": "'<!-- <sCrIpt> --!>'</script>BAR",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt> --!>'</script>BAR</script></body></html>",
+ "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> --!>'</script>BAR</script>"
+ }
+ },
+ {
+ "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> -- >'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,61): expected-script-data-but-got-eof",
+ "(1,61): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "text/plain"
+ }
+ ],
+ "children": [
+ {
+ "text": "'<!-- <sCrIpt> -- >'</script>BAR",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt> -- >'</script>BAR</script></body></html>",
+ "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt> -- >'</script>BAR</script>"
+ }
+ },
+ {
+ "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt '</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,56): expected-script-data-but-got-eof",
+ "(1,56): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "text/plain"
+ }
+ ],
+ "children": [
+ {
+ "text": "'<!-- <sCrIpt '</script>BAR",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt '</script>BAR</script></body></html>",
+ "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt '</script>BAR</script>"
+ }
+ },
+ {
+ "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt/'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars",
+ "(1,56): expected-script-data-but-got-eof",
+ "(1,56): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "text/plain"
+ }
+ ],
+ "children": [
+ {
+ "text": "'<!-- <sCrIpt/'</script>BAR",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt/'</script>BAR</script></body></html>",
+ "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt/'</script>BAR</script>"
+ }
+ },
+ {
+ "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt\\'</script>BAR",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "text/plain"
+ }
+ ],
+ "children": [
+ {
+ "text": "'<!-- <sCrIpt\\'",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "BAR"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt\\'</script>BAR</body></html>",
+ "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt\\'</script>BAR"
+ }
+ },
+ {
+ "data": "FOO<script type=\"text/plain\">'<!-- <sCrIpt/'</script>BAR</script>QUX",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "text/plain"
+ }
+ ],
+ "children": [
+ {
+ "text": "'<!-- <sCrIpt/'</script>BAR",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "QUX"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script type=\"text/plain\">'<!-- <sCrIpt/'</script>BAR</script>QUX</body></html>",
+ "noQuirksBodyHtml": "FOO<script type=\"text/plain\">'<!-- <sCrIpt/'</script>BAR</script>QUX"
+ }
+ },
+ {
+ "data": "FOO<script><!--<script>-></script>--></script>QUX",
+ "errors": [
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "FOO"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script>-></script>-->",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "QUX"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>FOO<script><!--<script>-></script>--></script>QUX</body></html>",
+ "noQuirksBodyHtml": "FOO<script><!--<script>-></script>--></script>QUX"
+ }
+ }
+ ],
+ "tables01.dat": [
+ {
+ "data": "<table><th>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-cell-in-table-body",
+ "(1,11): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "th": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "th"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><th></th></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><th></th></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><td>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-cell-in-table-body",
+ "(1,11): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><col foo='bar'>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,22): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true,
+ "col": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "col",
+ "attrs": [
+ {
+ "name": "foo",
+ "value": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><colgroup><col foo=\"bar\"></colgroup></table></body></html>",
+ "noQuirksBodyHtml": "<table><colgroup><col foo=\"bar\"></colgroup></table>"
+ }
+ },
+ {
+ "data": "<table><colgroup></html>foo",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,24): unexpected-end-tag",
+ "(1,27): foster-parenting-character-in-table",
+ "(1,27): foster-parenting-character-in-table",
+ "(1,27): foster-parenting-character-in-table",
+ "(1,27): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "foo"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>foo<table><colgroup></colgroup></table></body></html>",
+ "noQuirksBodyHtml": "foo<table><colgroup></colgroup></table>"
+ }
+ },
+ {
+ "data": "<table></table><p>foo",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table></table><p>foo</p></body></html>",
+ "noQuirksBodyHtml": "<table></table><p>foo</p>"
+ }
+ },
+ {
+ "data": "<table></body></caption></col></colgroup></html></tbody></td></tfoot></th></thead></tr><td>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,14): unexpected-end-tag",
+ "(1,24): unexpected-end-tag",
+ "(1,30): unexpected-end-tag",
+ "(1,41): unexpected-end-tag",
+ "(1,48): unexpected-end-tag",
+ "(1,56): unexpected-end-tag",
+ "(1,61): unexpected-end-tag",
+ "(1,69): unexpected-end-tag",
+ "(1,74): unexpected-end-tag",
+ "(1,82): unexpected-end-tag",
+ "(1,87): unexpected-end-tag",
+ "(1,91): unexpected-cell-in-table-body",
+ "(1,91): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><select><option>3</select></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,15): unexpected-start-tag-implies-table-voodoo"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option",
+ "children": [
+ {
+ "text": "3"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><option>3</option></select><table></table></body></html>",
+ "noQuirksBodyHtml": "<select><option>3</option></select><table></table>"
+ }
+ },
+ {
+ "data": "<table><select><table></table></select></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,15): unexpected-start-tag-implies-table-voodoo",
+ "(1,22): unexpected-table-element-start-tag-in-select-in-table",
+ "(1,22): unexpected-start-tag-implies-end-tag",
+ "(1,39): unexpected-end-tag",
+ "(1,47): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ },
+ {
+ "tag": "table"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select></select><table></table><table></table></body></html>",
+ "noQuirksBodyHtml": "<select></select><table></table><table></table>"
+ }
+ },
+ {
+ "data": "<table><select></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,15): unexpected-start-tag-implies-table-voodoo",
+ "(1,23): unexpected-table-element-end-tag-in-select-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select></select><table></table></body></html>",
+ "noQuirksBodyHtml": "<select></select><table></table>"
+ }
+ },
+ {
+ "data": "<table><select><option>A<tr><td>B</td></tr></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,15): unexpected-start-tag-implies-table-voodoo",
+ "(1,28): unexpected-table-element-start-tag-in-select-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "B"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><option>A</option></select><table><tbody><tr><td>B</td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<select><option>A</option></select><table><tbody><tr><td>B</td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><td></body></caption></col></colgroup></html>foo",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-cell-in-table-body",
+ "(1,18): unexpected-end-tag",
+ "(1,28): unexpected-end-tag",
+ "(1,34): unexpected-end-tag",
+ "(1,45): unexpected-end-tag",
+ "(1,52): unexpected-end-tag",
+ "(1,55): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td>foo</td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td>foo</td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><td>A</table>B",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-cell-in-table-body"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "B"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td>A</td></tr></tbody></table>B</body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td>A</td></tr></tbody></table>B"
+ }
+ },
+ {
+ "data": "<table><tr><caption>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,20): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "caption": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ },
+ {
+ "tag": "caption"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr></tr></tbody><caption></caption></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr></tr></tbody><caption></caption></table>"
+ }
+ },
+ {
+ "data": "<table><tr></body></caption></col></colgroup></html></td></th><td>foo",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,18): unexpected-end-tag-in-table-row",
+ "(1,28): unexpected-end-tag-in-table-row",
+ "(1,34): unexpected-end-tag-in-table-row",
+ "(1,45): unexpected-end-tag-in-table-row",
+ "(1,52): unexpected-end-tag-in-table-row",
+ "(1,57): unexpected-end-tag-in-table-row",
+ "(1,62): unexpected-end-tag-in-table-row",
+ "(1,69): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td>foo</td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td>foo</td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><td><tr>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-cell-in-table-body",
+ "(1,15): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ },
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td></td></tr><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td></td></tr><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><td><button><td>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-cell-in-table-body",
+ "(1,23): unexpected-cell-end-tag",
+ "(1,23): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "button": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "button"
+ }
+ ]
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td><button></button></td><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><button></button></td><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><tr><td><svg><desc><td>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,30): unexpected-cell-end-tag",
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "svg svg": true,
+ "svg desc": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td><svg><desc></desc></svg></td><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><svg><desc></desc></svg></td><td></td></tr></tbody></table>"
+ }
+ }
+ ],
+ "template.dat": [
+ {
+ "data": "<body><template>Hello</template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template>Hello</template></body></html>",
+ "noQuirksBodyHtml": "<template>Hello</template>"
+ }
+ },
+ {
+ "data": "<template>Hello</template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template>Hello</template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template>Hello</template>"
+ }
+ },
+ {
+ "data": "<template></template><div></div>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "body": true,
+ "div": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template></template></head><body><div></div></body></html>",
+ "noQuirksBodyHtml": "<template></template><div></div>"
+ }
+ },
+ {
+ "data": "<html><template>Hello</template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template>Hello</template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template>Hello</template>"
+ }
+ },
+ {
+ "data": "<head><template><div></div></template></head>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "div": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><div></div></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><div></div></template>"
+ }
+ },
+ {
+ "data": "<div><template><div><span></template><b>",
+ "errors": [
+ " * (1,6) missing DOCTYPE",
+ " * (1,38) mismatched template end tag",
+ " * (1,41) unexpected end of file"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "template": true,
+ "span": true,
+ "b": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><template><div><span></span></div></template><b></b></div></body></html>",
+ "noQuirksBodyHtml": "<div><template><div><span></span></div></template><b></b></div>"
+ }
+ },
+ {
+ "data": "<div><template></div>Hello",
+ "errors": [
+ " * (1,6) missing DOCTYPE",
+ " * (1,22) unexpected token in template",
+ " * (1,27) unexpected end of file in template",
+ " * (1,27) unexpected end of file"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><template>Hello</template></div></body></html>",
+ "noQuirksBodyHtml": "<div><template>Hello</template></div>"
+ }
+ },
+ {
+ "data": "<div></template></div>",
+ "errors": [
+ " * (1,6) missing DOCTYPE",
+ " * (1,17) unexpected template end tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div></div></body></html>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<table><template></template></table>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><template></template></table></body></html>",
+ "noQuirksBodyHtml": "<table><template></template></table>"
+ }
+ },
+ {
+ "data": "<table><template></template></div>",
+ "errors": [
+ " * (1,8) missing DOCTYPE",
+ " * (1,35) unexpected token in table - foster parenting",
+ " * (1,35) unexpected end tag",
+ " * (1,35) unexpected end of file"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><template></template></table></body></html>",
+ "noQuirksBodyHtml": "<table><template></template></table>"
+ }
+ },
+ {
+ "data": "<table><div><template></template></div>",
+ "errors": [
+ " * (1,8) missing DOCTYPE",
+ " * (1,13) unexpected token in table - foster parenting",
+ " * (1,40) unexpected token in table - foster parenting",
+ " * (1,40) unexpected end of file"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "template": true,
+ "table": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><template></template></div><table></table></body></html>",
+ "noQuirksBodyHtml": "<div><template></template></div><table></table>"
+ }
+ },
+ {
+ "data": "<table><template></template><div></div>",
+ "errors": [
+ "no doctype",
+ "bad div in table",
+ "bad /div in table",
+ "eof in table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "table": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div></div><table><template></template></table></body></html>",
+ "noQuirksBodyHtml": "<div></div><table><template></template></table>"
+ }
+ },
+ {
+ "data": "<table> <template></template></table>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "text": " "
+ },
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table> <template></template></table></body></html>",
+ "noQuirksBodyHtml": "<table> <template></template></table>"
+ }
+ },
+ {
+ "data": "<table><tbody><template></template></tbody>",
+ "errors": [
+ "no doctype",
+ "eof in table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><template></template></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><template></template></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><tbody><template></tbody></template>",
+ "errors": [
+ "no doctype",
+ "bad /tbody",
+ "eof in table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><template></template></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><template></template></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><tbody><template></template></tbody></table>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><template></template></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><template></template></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><thead><template></template></thead>",
+ "errors": [
+ "no doctype",
+ "eof in table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "thead": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "thead",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><thead><template></template></thead></table></body></html>",
+ "noQuirksBodyHtml": "<table><thead><template></template></thead></table>"
+ }
+ },
+ {
+ "data": "<table><tfoot><template></template></tfoot>",
+ "errors": [
+ "no doctype",
+ "eof in table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tfoot": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tfoot",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tfoot><template></template></tfoot></table></body></html>",
+ "noQuirksBodyHtml": "<table><tfoot><template></template></tfoot></table>"
+ }
+ },
+ {
+ "data": "<select><template></template></select>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><template></template></select></body></html>",
+ "noQuirksBodyHtml": "<select><template></template></select>"
+ }
+ },
+ {
+ "data": "<select><template><option></option></template></select>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "template": true,
+ "option": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><template><option></option></template></select></body></html>",
+ "noQuirksBodyHtml": "<select><template><option></option></template></select>"
+ }
+ },
+ {
+ "data": "<template><option></option></select><option></option></template>",
+ "errors": [
+ "no doctype",
+ "bad /select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "option": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "option"
+ },
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><option></option><option></option></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><option></option><option></option></template>"
+ }
+ },
+ {
+ "data": "<select><template></template><option></select>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "template": true,
+ "option": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ },
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><template></template><option></option></select></body></html>",
+ "noQuirksBodyHtml": "<select><template></template><option></option></select>"
+ }
+ },
+ {
+ "data": "<select><option><template></template></select>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><option><template></template></option></select></body></html>",
+ "noQuirksBodyHtml": "<select><option><template></template></option></select>"
+ }
+ },
+ {
+ "data": "<select><template>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><template></template></select></body></html>",
+ "noQuirksBodyHtml": "<select><template></template></select>"
+ }
+ },
+ {
+ "data": "<select><option></option><template>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option"
+ },
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><option></option><template></template></select></body></html>",
+ "noQuirksBodyHtml": "<select><option></option><template></template></select>"
+ }
+ },
+ {
+ "data": "<select><option></option><template><option>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option"
+ },
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><option></option><template><option></option></template></select></body></html>",
+ "noQuirksBodyHtml": "<select><option></option><template><option></option></template></select>"
+ }
+ },
+ {
+ "data": "<table><thead><template><td></template></table>",
+ "errors": [
+ " * (1,8) missing DOCTYPE"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "thead": true,
+ "template": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "thead",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><thead><template><td></td></template></thead></table></body></html>",
+ "noQuirksBodyHtml": "<table><thead><template><td></td></template></thead></table>"
+ }
+ },
+ {
+ "data": "<table><template><thead></template></table>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "template": true,
+ "thead": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "thead"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><template><thead></thead></template></table></body></html>",
+ "noQuirksBodyHtml": "<table><template><thead></thead></template></table>"
+ }
+ },
+ {
+ "data": "<body><table><template><td></tr><div></template></table>",
+ "errors": [
+ "no doctype",
+ "bad </tr>",
+ "missing </div>"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "template": true,
+ "td": true,
+ "div": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><template><td><div></div></td></template></table></body></html>",
+ "noQuirksBodyHtml": "<table><template><td><div></div></td></template></table>"
+ }
+ },
+ {
+ "data": "<table><template><thead></template></thead></table>",
+ "errors": [
+ "no doctype",
+ "bad /thead after /template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "template": true,
+ "thead": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "thead"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><template><thead></thead></template></table></body></html>",
+ "noQuirksBodyHtml": "<table><template><thead></thead></template></table>"
+ }
+ },
+ {
+ "data": "<table><thead><template><tr></template></table>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "thead": true,
+ "template": true,
+ "tr": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "thead",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><thead><template><tr></tr></template></thead></table></body></html>",
+ "noQuirksBodyHtml": "<table><thead><template><tr></tr></template></thead></table>"
+ }
+ },
+ {
+ "data": "<table><template><tr></template></table>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "template": true,
+ "tr": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><template><tr></tr></template></table></body></html>",
+ "noQuirksBodyHtml": "<table><template><tr></tr></template></table>"
+ }
+ },
+ {
+ "data": "<table><tr><template><td>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "template": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><template><td></td></template></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><template><td></td></template></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><template><tr><template><td></template></tr></template></table>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "template": true,
+ "tr": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><template><tr><template><td></td></template></tr></template></table></body></html>",
+ "noQuirksBodyHtml": "<table><template><tr><template><td></td></template></tr></template></table>"
+ }
+ },
+ {
+ "data": "<table><template><tr><template><td></td></template></tr></template></table>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "template": true,
+ "tr": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><template><tr><template><td></td></template></tr></template></table></body></html>",
+ "noQuirksBodyHtml": "<table><template><tr><template><td></td></template></tr></template></table>"
+ }
+ },
+ {
+ "data": "<table><template><td></template>",
+ "errors": [
+ "no doctype",
+ "eof in table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "template": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><template><td></td></template></table></body></html>",
+ "noQuirksBodyHtml": "<table><template><td></td></template></table>"
+ }
+ },
+ {
+ "data": "<body><template><td></td></template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><td></td></template></body></html>",
+ "noQuirksBodyHtml": "<template><td></td></template>"
+ }
+ },
+ {
+ "data": "<body><template><template><tr></tr></template><td></td></template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "tr": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><template><tr></tr></template><td></td></template></body></html>",
+ "noQuirksBodyHtml": "<template><template><tr></tr></template><td></td></template>"
+ }
+ },
+ {
+ "data": "<table><colgroup><template><col>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true,
+ "template": true,
+ "col": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><colgroup><template><col></template></colgroup></table></body></html>",
+ "noQuirksBodyHtml": "<table><colgroup><template><col></template></colgroup></table>"
+ }
+ },
+ {
+ "data": "<frameset><template><frame></frame></template></frameset>",
+ "errors": [
+ " * (1,11) missing DOCTYPE",
+ " * (1,21) unexpected start tag token",
+ " * (1,36) unexpected end tag token",
+ " * (1,47) unexpected end tag token"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "frame": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "tag": "frame"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset><frame></frameset></html>",
+ "noQuirksBodyHtml": "<template></template>"
+ }
+ },
+ {
+ "data": "<template><frame></frame></frameset><frame></frame></template>",
+ "errors": [
+ " * (1,11) missing DOCTYPE",
+ " * (1,18) unexpected start tag",
+ " * (1,26) unexpected end tag",
+ " * (1,37) unexpected end tag",
+ " * (1,44) unexpected start tag",
+ " * (1,52) unexpected end tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template></template>"
+ }
+ },
+ {
+ "data": "<template><div><frameset><span></span></div><span></span></template>",
+ "errors": [
+ "no doctype",
+ "bad frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "div": true,
+ "span": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ },
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><div><span></span></div><span></span></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><div><span></span></div><span></span></template>"
+ }
+ },
+ {
+ "data": "<body><template><div><frameset><span></span></div><span></span></template></body>",
+ "errors": [
+ "no doctype",
+ "bad frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "div": true,
+ "span": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ },
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><div><span></span></div><span></span></template></body></html>",
+ "noQuirksBodyHtml": "<template><div><span></span></div><span></span></template>"
+ }
+ },
+ {
+ "data": "<body><template><script>var i = 1;</script><td></td></template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "script": true,
+ "td": true
+ },
+ "template": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "var i = 1;",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><script>var i = 1;</script><td></td></template></body></html>",
+ "noQuirksBodyHtml": "<template><script>var i = 1;</script><td></td></template>"
+ }
+ },
+ {
+ "data": "<body><template><tr><div></div></tr></template>",
+ "errors": [
+ "no doctype",
+ "foster-parented div",
+ "foster-parented /div"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "tr": true,
+ "div": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr"
+ },
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><tr></tr><div></div></template></body></html>",
+ "noQuirksBodyHtml": "<template><tr></tr><div></div></template>"
+ }
+ },
+ {
+ "data": "<body><template><tr></tr><td></td></template>",
+ "errors": [
+ "no doctype",
+ "unexpected <td>"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "tr": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr"
+ },
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><tr></tr><tr><td></td></tr></template></body></html>",
+ "noQuirksBodyHtml": "<template><tr></tr><tr><td></td></tr></template>"
+ }
+ },
+ {
+ "data": "<body><template><td></td></tr><td></td></template>",
+ "errors": [
+ "no doctype",
+ "bad </tr>"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><td></td><td></td></template></body></html>",
+ "noQuirksBodyHtml": "<template><td></td><td></td></template>"
+ }
+ },
+ {
+ "data": "<body><template><td></td><tbody><td></td></template>",
+ "errors": [
+ "no doctype",
+ "bad <tbody>"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><td></td><td></td></template></body></html>",
+ "noQuirksBodyHtml": "<template><td></td><td></td></template>"
+ }
+ },
+ {
+ "data": "<body><template><td></td><caption></caption><td></td></template>",
+ "errors": [
+ " * (1,7) missing DOCTYPE",
+ " * (1,35) unexpected start tag in table row",
+ " * (1,45) unexpected end tag in table row"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><td></td><td></td></template></body></html>",
+ "noQuirksBodyHtml": "<template><td></td><td></td></template>"
+ }
+ },
+ {
+ "data": "<body><template><td></td><colgroup></caption><td></td></template>",
+ "errors": [
+ " * (1,7) missing DOCTYPE",
+ " * (1,36) unexpected start tag in table row",
+ " * (1,46) unexpected end tag in table row"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><td></td><td></td></template></body></html>",
+ "noQuirksBodyHtml": "<template><td></td><td></td></template>"
+ }
+ },
+ {
+ "data": "<body><template><td></td></table><td></td></template>",
+ "errors": [
+ "no doctype",
+ "bad </table>"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><td></td><td></td></template></body></html>",
+ "noQuirksBodyHtml": "<template><td></td><td></td></template>"
+ }
+ },
+ {
+ "data": "<body><template><tr></tr><tbody><tr></tr></template>",
+ "errors": [
+ "no doctype",
+ "bad <tbody>"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "tr": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr"
+ },
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><tr></tr><tr></tr></template></body></html>",
+ "noQuirksBodyHtml": "<template><tr></tr><tr></tr></template>"
+ }
+ },
+ {
+ "data": "<body><template><tr></tr><caption><tr></tr></template>",
+ "errors": [
+ "no doctype",
+ "bad <caption>"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "tr": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr"
+ },
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><tr></tr><tr></tr></template></body></html>",
+ "noQuirksBodyHtml": "<template><tr></tr><tr></tr></template>"
+ }
+ },
+ {
+ "data": "<body><template><tr></tr></table><tr></tr></template>",
+ "errors": [
+ "no doctype",
+ "bad </table>"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "tr": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr"
+ },
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><tr></tr><tr></tr></template></body></html>",
+ "noQuirksBodyHtml": "<template><tr></tr><tr></tr></template>"
+ }
+ },
+ {
+ "data": "<body><template><thead></thead><caption></caption><tbody></tbody></template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "thead": true,
+ "caption": true,
+ "tbody": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "thead"
+ },
+ {
+ "tag": "caption"
+ },
+ {
+ "tag": "tbody"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><thead></thead><caption></caption><tbody></tbody></template></body></html>",
+ "noQuirksBodyHtml": "<template><thead></thead><caption></caption><tbody></tbody></template>"
+ }
+ },
+ {
+ "data": "<body><template><thead></thead></table><tbody></tbody></template></body>",
+ "errors": [
+ "no doctype",
+ "bad </table>"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "thead": true,
+ "tbody": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "thead"
+ },
+ {
+ "tag": "tbody"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><thead></thead><tbody></tbody></template></body></html>",
+ "noQuirksBodyHtml": "<template><thead></thead><tbody></tbody></template>"
+ }
+ },
+ {
+ "data": "<body><template><div><tr></tr></div></template>",
+ "errors": [
+ "no doctype",
+ "bad tr",
+ "bad /tr"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "div": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><div></div></template></body></html>",
+ "noQuirksBodyHtml": "<template><div></div></template>"
+ }
+ },
+ {
+ "data": "<body><template><em>Hello</em></template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "em": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "em",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><em>Hello</em></template></body></html>",
+ "noQuirksBodyHtml": "<template><em>Hello</em></template>"
+ }
+ },
+ {
+ "data": "<body><template><!--comment--></template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true
+ },
+ "template": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "comment": "comment"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><!--comment--></template></body></html>",
+ "noQuirksBodyHtml": "<template><!--comment--></template>"
+ }
+ },
+ {
+ "data": "<body><template><style></style><td></td></template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "style": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "style"
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><style></style><td></td></template></body></html>",
+ "noQuirksBodyHtml": "<template><style></style><td></td></template>"
+ }
+ },
+ {
+ "data": "<body><template><meta><td></td></template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "meta": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "meta"
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><meta><td></td></template></body></html>",
+ "noQuirksBodyHtml": "<template><meta><td></td></template>"
+ }
+ },
+ {
+ "data": "<body><template><link><td></td></template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "link": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "link"
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><link><td></td></template></body></html>",
+ "noQuirksBodyHtml": "<template><link><td></td></template>"
+ }
+ },
+ {
+ "data": "<body><template><template><tr></tr></template><td></td></template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "tr": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><template><tr></tr></template><td></td></template></body></html>",
+ "noQuirksBodyHtml": "<template><template><tr></tr></template><td></td></template>"
+ }
+ },
+ {
+ "data": "<body><table><colgroup><template><col></col></template></colgroup></table></body>",
+ "errors": [
+ "no doctype",
+ "bad /col"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true,
+ "template": true,
+ "col": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><colgroup><template><col></template></colgroup></table></body></html>",
+ "noQuirksBodyHtml": "<table><colgroup><template><col></template></colgroup></table>"
+ }
+ },
+ {
+ "data": "<body a=b><template><div></div><body c=d><div></div></body></template></body>",
+ "errors": [
+ "no doctype",
+ "bad <body>",
+ "bad </body>"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "div": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "attrs": [
+ {
+ "name": "a",
+ "value": "b"
+ }
+ ],
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "div"
+ },
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body a=\"b\"><template><div></div><div></div></template></body></html>",
+ "noQuirksBodyHtml": "<template><div></div><div></div></template>"
+ }
+ },
+ {
+ "data": "<html a=b><template><div><html b=c><span></template>",
+ "errors": [
+ "no doctype",
+ "bad <html>",
+ "missing end tags in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "div": true,
+ "span": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "a",
+ "value": "b"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html a=\"b\"><head><template><div><span></span></div></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><div><span></span></div></template>"
+ }
+ },
+ {
+ "data": "<html a=b><template><col></col><html b=c><col></col></template>",
+ "errors": [
+ "no doctype",
+ "bad /col",
+ "bad html",
+ "bad /col"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "col": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "a",
+ "value": "b"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "col"
+ },
+ {
+ "tag": "col"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html a=\"b\"><head><template><col><col></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><col><col></template>"
+ }
+ },
+ {
+ "data": "<html a=b><template><frame></frame><html b=c><frame></frame></template>",
+ "errors": [
+ "no doctype",
+ "bad frame",
+ "bad /frame",
+ "bad html",
+ "bad frame",
+ "bad /frame"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "a",
+ "value": "b"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html a=\"b\"><head><template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template></template>"
+ }
+ },
+ {
+ "data": "<body><template><tr></tr><template></template><td></td></template>",
+ "errors": [
+ "no doctype",
+ "unexpected <td>"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "tr": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr"
+ },
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ },
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><tr></tr><template></template><tr><td></td></tr></template></body></html>",
+ "noQuirksBodyHtml": "<template><tr></tr><template></template><tr><td></td></tr></template>"
+ }
+ },
+ {
+ "data": "<body><template><thead></thead><template><tr></tr></template><tr></tr><tfoot></tfoot></template>",
+ "errors": [
+ "no doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "thead": true,
+ "tr": true,
+ "tbody": true,
+ "tfoot": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "thead"
+ },
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ },
+ {
+ "tag": "tfoot"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><thead></thead><template><tr></tr></template><tbody><tr></tr></tbody><tfoot></tfoot></template></body></html>",
+ "noQuirksBodyHtml": "<template><thead></thead><template><tr></tr></template><tbody><tr></tr></tbody><tfoot></tfoot></template>"
+ }
+ },
+ {
+ "data": "<body><template><template><b><template></template></template>text</template>",
+ "errors": [
+ "no doctype",
+ "missing </b>"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "b": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><template><b><template></template></b></template>text</template></body></html>",
+ "noQuirksBodyHtml": "<template><template><b><template></template></b></template>text</template>"
+ }
+ },
+ {
+ "data": "<body><template><col><colgroup>",
+ "errors": [
+ "no doctype",
+ "bad colgroup",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "col": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><col></template></body></html>",
+ "noQuirksBodyHtml": "<template><col></template>"
+ }
+ },
+ {
+ "data": "<body><template><col></colgroup>",
+ "errors": [
+ "no doctype",
+ "bogus /colgroup",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "col": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><col></template></body></html>",
+ "noQuirksBodyHtml": "<template><col></template>"
+ }
+ },
+ {
+ "data": "<body><template><col><colgroup></template></body>",
+ "errors": [
+ "no doctype",
+ "bad colgroup"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "col": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><col></template></body></html>",
+ "noQuirksBodyHtml": "<template><col></template>"
+ }
+ },
+ {
+ "data": "<body><template><col><div>",
+ "errors": [
+ " * (1,7) missing DOCTYPE",
+ " * (1,27) unexpected token",
+ " * (1,27) unexpected end of file in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "col": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><col></template></body></html>",
+ "noQuirksBodyHtml": "<template><col></template>"
+ }
+ },
+ {
+ "data": "<body><template><col></div>",
+ "errors": [
+ "no doctype",
+ "bad /div",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "col": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><col></template></body></html>",
+ "noQuirksBodyHtml": "<template><col></template>"
+ }
+ },
+ {
+ "data": "<body><template><col>Hello",
+ "errors": [
+ "no doctype",
+ "unexpected text",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "col": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><col></template></body></html>",
+ "noQuirksBodyHtml": "<template><col></template>"
+ }
+ },
+ {
+ "data": "<body><template><i><menu>Foo</i>",
+ "errors": [
+ "no doctype",
+ "mising /menu",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "i": true,
+ "menu": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "i"
+ },
+ {
+ "tag": "menu",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><i></i><menu><i>Foo</i></menu></template></body></html>",
+ "noQuirksBodyHtml": "<template><i></i><menu><i>Foo</i></menu></template>"
+ }
+ },
+ {
+ "data": "<body><template></div><div>Foo</div><template></template><tr></tr>",
+ "errors": [
+ "no doctype",
+ "bogus /div",
+ "bogus tr",
+ "bogus /tr",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true,
+ "div": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template><div>Foo</div><template></template></template></body></html>",
+ "noQuirksBodyHtml": "<template><div>Foo</div><template></template></template>"
+ }
+ },
+ {
+ "data": "<body><div><template></div><tr><td>Foo</td></tr></template>",
+ "errors": [
+ " * (1,7) missing DOCTYPE",
+ " * (1,28) unexpected token in template",
+ " * (1,60) unexpected end of file"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "template": true,
+ "tr": true,
+ "td": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><template><tr><td>Foo</td></tr></template></div></body></html>",
+ "noQuirksBodyHtml": "<div><template><tr><td>Foo</td></tr></template></div>"
+ }
+ },
+ {
+ "data": "<template></figcaption><sub><table></table>",
+ "errors": [
+ "no doctype",
+ "bad /figcaption",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "sub": true,
+ "table": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "sub",
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><sub><table></table></sub></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><sub><table></table></sub></template>"
+ }
+ },
+ {
+ "data": "<template><template>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template></template></template>"
+ }
+ },
+ {
+ "data": "<template><div>",
+ "errors": [
+ "no doctype",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "div": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><div></div></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><div></div></template>"
+ }
+ },
+ {
+ "data": "<template><template><div>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "div": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template><div></div></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template><div></div></template></template>"
+ }
+ },
+ {
+ "data": "<template><template><table>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "table": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template><table></table></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template><table></table></template></template>"
+ }
+ },
+ {
+ "data": "<template><template><tbody>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "tbody": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tbody"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template><tbody></tbody></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template><tbody></tbody></template></template>"
+ }
+ },
+ {
+ "data": "<template><template><tr>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "tr": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template><tr></tr></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template><tr></tr></template></template>"
+ }
+ },
+ {
+ "data": "<template><template><td>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "td": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template><td></td></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template><td></td></template></template>"
+ }
+ },
+ {
+ "data": "<template><template><caption>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "caption": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "caption"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template><caption></caption></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template><caption></caption></template></template>"
+ }
+ },
+ {
+ "data": "<template><template><colgroup>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "colgroup": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "colgroup"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template><colgroup></colgroup></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template><colgroup></colgroup></template></template>"
+ }
+ },
+ {
+ "data": "<template><template><col>",
+ "errors": [
+ "no doctype",
+ "eof in template",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "col": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template><col></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template><col></template></template>"
+ }
+ },
+ {
+ "data": "<template><template><tbody><select>",
+ "errors": [
+ " * (1,11) missing DOCTYPE",
+ " * (1,36) unexpected token in table - foster parenting",
+ " * (1,36) unexpected end of file in template",
+ " * (1,36) unexpected end of file in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "tbody": true,
+ "select": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "tbody"
+ },
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template><tbody></tbody><select></select></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template><tbody></tbody><select></select></template></template>"
+ }
+ },
+ {
+ "data": "<template><template><table>Foo",
+ "errors": [
+ "no doctype",
+ "foster-parenting text F",
+ "foster-parenting text o",
+ "foster-parenting text o",
+ "eof",
+ "eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "table": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "text": "Foo"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template>Foo<table></table></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template>Foo<table></table></template></template>"
+ }
+ },
+ {
+ "data": "<template><template><frame>",
+ "errors": [
+ "no doctype",
+ "bad tag",
+ "eof",
+ "eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template></template></template>"
+ }
+ },
+ {
+ "data": "<template><template><script>var i",
+ "errors": [
+ "no doctype",
+ "eof in script",
+ "eof in template",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "script": true,
+ "body": true
+ },
+ "template": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "var i",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template><script>var i</script></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template><script>var i</script></template></template>"
+ }
+ },
+ {
+ "data": "<template><template><style>var i",
+ "errors": [
+ "no doctype",
+ "eof in style",
+ "eof in template",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "style": true,
+ "body": true
+ },
+ "template": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "var i",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><template><style>var i</style></template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><template><style>var i</style></template></template>"
+ }
+ },
+ {
+ "data": "<template><table></template><body><span>Foo",
+ "errors": [
+ "no doctype",
+ "missing /table",
+ "bad eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "table": true,
+ "body": true,
+ "span": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><table></table></template></head><body><span>Foo</span></body></html>",
+ "noQuirksBodyHtml": "<template><table></table></template><span>Foo</span>"
+ }
+ },
+ {
+ "data": "<template><td></template><body><span>Foo",
+ "errors": [
+ "no doctype",
+ "bad eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "td": true,
+ "body": true,
+ "span": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><td></td></template></head><body><span>Foo</span></body></html>",
+ "noQuirksBodyHtml": "<template><td></td></template><span>Foo</span>"
+ }
+ },
+ {
+ "data": "<template><object></template><body><span>Foo",
+ "errors": [
+ "no doctype",
+ "missing /object",
+ "bad eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "object": true,
+ "body": true,
+ "span": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "object"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><object></object></template></head><body><span>Foo</span></body></html>",
+ "noQuirksBodyHtml": "<template><object></object></template><span>Foo</span>"
+ }
+ },
+ {
+ "data": "<template><svg><template>",
+ "errors": [
+ "no doctype",
+ "eof in template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "svg svg": true,
+ "svg template": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "template",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><svg><template></template></svg></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><svg><template></template></svg></template>"
+ }
+ },
+ {
+ "data": "<template><svg><foo><template><foreignObject><div></template><div>",
+ "errors": [
+ "no doctype",
+ "ugly template closure",
+ "bad eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "svg svg": true,
+ "svg foo": true,
+ "svg template": true,
+ "svg foreignObject": true,
+ "div": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foo",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "template",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><svg><foo><template><foreignObject><div></div></foreignObject></template></foo></svg></template></head><body><div></div></body></html>",
+ "noQuirksBodyHtml": "<template><svg><foo><template><foreignObject><div></div></foreignObject></template></foo></svg></template><div></div>"
+ }
+ },
+ {
+ "data": "<dummy><template><span></dummy>",
+ "errors": [
+ "no doctype",
+ "bad end tag </dummy>",
+ "eof in template",
+ "eof in dummy"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "dummy": true,
+ "template": true,
+ "span": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "dummy",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><dummy><template><span></span></template></dummy></body></html>",
+ "noQuirksBodyHtml": "<dummy><template><span></span></template></dummy>"
+ }
+ },
+ {
+ "data": "<body><table><tr><td><select><template>Foo</template><caption>A</table>",
+ "errors": [
+ "no doctype",
+ "(1,62): unexpected-caption-in-select-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "select": true,
+ "template": true,
+ "caption": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td><select><template>Foo</template></select></td></tr></tbody><caption>A</caption></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><select><template>Foo</template></select></td></tr></tbody><caption>A</caption></table>"
+ }
+ },
+ {
+ "data": "<body></body><template>",
+ "errors": [
+ "no doctype",
+ "(1,23): template-after-body",
+ "(1,24): eof-in-template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "template": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><template></template></body></html>",
+ "noQuirksBodyHtml": "<template></template>"
+ }
+ },
+ {
+ "data": "<head></head><template>",
+ "errors": [
+ "no doctype",
+ "(1,23): template-after-head",
+ "(1,24): eof-in-template"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template></template>"
+ }
+ },
+ {
+ "data": "<head></head><template>Foo</template>",
+ "errors": [
+ "no doctype",
+ "(1,23): template-after-head"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template>Foo</template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template>Foo</template>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE HTML><dummy><table><template><table><template><table><script>",
+ "errors": [
+ "eof script",
+ "eof template",
+ "eof template",
+ "eof table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "dummy": true,
+ "table": true,
+ "template": true,
+ "script": true
+ },
+ "doctype": true,
+ "template": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "dummy",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "script"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><dummy><table><template><table><template><table><script></script></table></template></table></template></table></dummy></body></html>",
+ "noQuirksBodyHtml": "<dummy><table><template><table><template><table><script></script></table></template></table></template></table></dummy>"
+ }
+ },
+ {
+ "data": "<template><a><table><a>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "template": true,
+ "a": true,
+ "table": true,
+ "body": true
+ },
+ "template": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "template",
+ "children": [
+ {
+ "content": true,
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><template><a><a></a><table></table></a></template></head><body></body></html>",
+ "noQuirksBodyHtml": "<template><a><a></a><table></table></a></template>"
+ }
+ }
+ ],
+ "tests1.dat": [
+ {
+ "data": "Test",
+ "errors": [
+ "(1,0): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Test"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>Test</body></html>",
+ "noQuirksBodyHtml": "Test"
+ }
+ },
+ {
+ "data": "<p>One<p>Two",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "One"
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "Two"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p>One</p><p>Two</p></body></html>",
+ "noQuirksBodyHtml": "<p>One</p><p>Two</p>"
+ }
+ },
+ {
+ "data": "Line1<br>Line2<br>Line3<br>Line4",
+ "errors": [
+ "(1,0): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "br": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Line1"
+ },
+ {
+ "tag": "br"
+ },
+ {
+ "text": "Line2"
+ },
+ {
+ "tag": "br"
+ },
+ {
+ "text": "Line3"
+ },
+ {
+ "tag": "br"
+ },
+ {
+ "text": "Line4"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>Line1<br>Line2<br>Line3<br>Line4</body></html>",
+ "noQuirksBodyHtml": "Line1<br>Line2<br>Line3<br>Line4"
+ }
+ },
+ {
+ "data": "<html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<head>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<body>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><head>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><head></head>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><head></head><body>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><head></head><body></body>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><head><body></body></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><head></body></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><head><body></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><body></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<body></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<head></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "</head>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "</body>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-end-tag element."
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "</html>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-end-tag element."
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<b><table><td><i></table>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,14): unexpected-cell-in-table-body",
+ "(1,25): unexpected-cell-end-tag",
+ "(1,25): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "i": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b><table><tbody><tr><td><i></i></td></tr></tbody></table></b></body></html>",
+ "noQuirksBodyHtml": "<b><table><tbody><tr><td><i></i></td></tr></tbody></table></b>"
+ }
+ },
+ {
+ "data": "<b><table><td></b><i></table>X",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,14): unexpected-cell-in-table-body",
+ "(1,18): unexpected-end-tag",
+ "(1,29): unexpected-cell-end-tag",
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "i": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b><table><tbody><tr><td><i></i></td></tr></tbody></table>X</b></body></html>",
+ "noQuirksBodyHtml": "<b><table><tbody><tr><td><i></i></td></tr></tbody></table>X</b>"
+ }
+ },
+ {
+ "data": "<h1>Hello<h2>World",
+ "errors": [
+ "(1,4): expected-doctype-but-got-start-tag",
+ "(1,13): unexpected-start-tag",
+ "(1,18): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "h1": true,
+ "h2": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "h1",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ },
+ {
+ "tag": "h2",
+ "children": [
+ {
+ "text": "World"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><h1>Hello</h1><h2>World</h2></body></html>",
+ "noQuirksBodyHtml": "<h1>Hello</h1><h2>World</h2>"
+ }
+ },
+ {
+ "data": "<a><p>X<a>Y</a>Z</p></a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,10): unexpected-start-tag-implies-end-tag",
+ "(1,10): adoption-agency-1.3",
+ "(1,24): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "Y"
+ }
+ ]
+ },
+ {
+ "text": "Z"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a></a><p><a>X</a><a>Y</a>Z</p></body></html>",
+ "noQuirksBodyHtml": "<a></a><p><a>X</a><a>Y</a>Z</p>"
+ }
+ },
+ {
+ "data": "<b><button>foo</b>bar",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,18): adoption-agency-1.3",
+ "(1,21): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "button": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b></b><button><b>foo</b>bar</button></body></html>",
+ "noQuirksBodyHtml": "<b></b><button><b>foo</b>bar</button>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><span><button>foo</span>bar",
+ "errors": [
+ "(1,39): unexpected-end-tag",
+ "(1,42): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "span": true,
+ "button": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "text": "foobar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><span><button>foobar</button></span></body></html>",
+ "noQuirksBodyHtml": "<span><button>foobar</button></span>"
+ }
+ },
+ {
+ "data": "<p><b><div><marquee></p></b></div>X",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-end-tag",
+ "(1,24): unexpected-end-tag",
+ "(1,28): unexpected-end-tag",
+ "(1,34): end-tag-too-early",
+ "(1,35): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "b": true,
+ "div": true,
+ "marquee": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "marquee",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><b></b></p><div><b><marquee><p></p>X</marquee></b></div></body></html>",
+ "noQuirksBodyHtml": "<p><b></b></p><div><b><marquee><p></p>X</marquee></b></div>"
+ }
+ },
+ {
+ "data": "<script><div></script></div><title><p></title><p><p>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,28): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "title": true,
+ "body": true,
+ "p": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<div>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "<p>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><div></script><title>&lt;p&gt;</title></head><body><p></p><p></p></body></html>",
+ "noQuirksBodyHtml": "<script><div></script><title>&lt;p&gt;</title><p></p><p></p>"
+ }
+ },
+ {
+ "data": "<!--><div>--<!-->",
+ "errors": [
+ "(1,5): incorrect-comment",
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,17): incorrect-comment",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": ""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "--"
+ },
+ {
+ "comment": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!----><html><head></head><body><div>--<!----></div></body></html>",
+ "noQuirksBodyHtml": "<!----><div>--<!----></div>"
+ }
+ },
+ {
+ "data": "<p><hr></p>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "hr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "hr"
+ },
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p></p><hr><p></p></body></html>",
+ "noQuirksBodyHtml": "<p></p><hr><p></p>"
+ }
+ },
+ {
+ "data": "<select><b><option><select><option></b></select>X",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-start-tag-in-select",
+ "(1,27): unexpected-select-in-select",
+ "(1,39): unexpected-end-tag",
+ "(1,48): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option"
+ }
+ ]
+ },
+ {
+ "tag": "option",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><option></option></select><option>X</option></body></html>",
+ "noQuirksBodyHtml": "<select><option></option></select><option>X</option>"
+ }
+ },
+ {
+ "data": "<a><table><td><a><table></table><a></tr><a></table><b>X</b>C<a>Y",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,14): unexpected-cell-in-table-body",
+ "(1,35): unexpected-start-tag-implies-end-tag",
+ "(1,40): unexpected-cell-end-tag",
+ "(1,43): unexpected-start-tag-implies-table-voodoo",
+ "(1,43): unexpected-start-tag-implies-end-tag",
+ "(1,43): unexpected-end-tag",
+ "(1,63): unexpected-start-tag-implies-end-tag",
+ "(1,64): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ },
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ },
+ {
+ "text": "C"
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "Y"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a><a></a><table><tbody><tr><td><a><table></table></a><a></a></td></tr></tbody></table></a><a><b>X</b>C</a><a>Y</a></body></html>",
+ "noQuirksBodyHtml": "<a><a></a><table><tbody><tr><td><a><table></table></a><a></a></td></tr></tbody></table></a><a><b>X</b>C</a><a>Y</a>"
+ }
+ },
+ {
+ "data": "<a X>0<b>1<a Y>2",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,15): unexpected-start-tag-implies-end-tag",
+ "(1,15): adoption-agency-1.3",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "x",
+ "value": ""
+ }
+ ],
+ "children": [
+ {
+ "text": "0"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "y",
+ "value": ""
+ }
+ ],
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a x=\"\">0<b>1</b></a><b><a y=\"\">2</a></b></body></html>",
+ "noQuirksBodyHtml": "<a x=\"\">0<b>1</b></a><b><a y=\"\">2</a></b>"
+ }
+ },
+ {
+ "data": "<!-----><font><div>hello<table>excite!<b>me!<th><i>please!</tr><!--X-->",
+ "errors": [
+ "(1,7): unexpected-dash-after-double-dash-in-comment",
+ "(1,14): expected-doctype-but-got-start-tag",
+ "(1,41): unexpected-start-tag-implies-table-voodoo",
+ "(1,48): foster-parenting-character-in-table",
+ "(1,48): foster-parenting-character-in-table",
+ "(1,48): foster-parenting-character-in-table",
+ "(1,48): foster-parenting-character-in-table",
+ "(1,48): foster-parenting-character-in-table",
+ "(1,48): foster-parenting-character-in-table",
+ "(1,48): foster-parenting-character-in-table",
+ "(1,48): foster-parenting-character-in-table",
+ "(1,48): foster-parenting-character-in-table",
+ "(1,48): foster-parenting-character-in-table",
+ "(1,48): unexpected-cell-in-table-body",
+ "(1,63): unexpected-cell-end-tag",
+ "(1,71): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "font": true,
+ "div": true,
+ "b": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "th": true,
+ "i": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "-"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "font",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "helloexcite!"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "me!"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "th",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "please!"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!-----><html><head></head><body><font><div>helloexcite!<b>me!</b><table><tbody><tr><th><i>please!</i></th></tr><!--X--></tbody></table></div></font></body></html>",
+ "noQuirksBodyHtml": "<!-----><font><div>helloexcite!<b>me!</b><table><tbody><tr><th><i>please!</i></th></tr><!--X--></tbody></table></div></font>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><li>hello<li>world<ul>how<li>do</ul>you</body><!--do-->",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "li": true,
+ "ul": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "li",
+ "children": [
+ {
+ "text": "hello"
+ }
+ ]
+ },
+ {
+ "tag": "li",
+ "children": [
+ {
+ "text": "world"
+ },
+ {
+ "tag": "ul",
+ "children": [
+ {
+ "text": "how"
+ },
+ {
+ "tag": "li",
+ "children": [
+ {
+ "text": "do"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "you"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "do"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><li>hello</li><li>world<ul>how<li>do</li></ul>you</li></body><!--do--></html>",
+ "noQuirksBodyHtml": "<li>hello</li><li>world<ul>how<li>do</li></ul>you<!--do--></li>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>A<option>B<optgroup>C<select>D</option>E",
+ "errors": [
+ "(1,54): unexpected-end-tag-in-select",
+ "(1,55): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "option": true,
+ "optgroup": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "A"
+ },
+ {
+ "tag": "option",
+ "children": [
+ {
+ "text": "B"
+ }
+ ]
+ },
+ {
+ "tag": "optgroup",
+ "children": [
+ {
+ "text": "C"
+ },
+ {
+ "tag": "select",
+ "children": [
+ {
+ "text": "DE"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>A<option>B</option><optgroup>C<select>DE</select></optgroup></body></html>",
+ "noQuirksBodyHtml": "A<option>B</option><optgroup>C<select>DE</select></optgroup>"
+ }
+ },
+ {
+ "data": "<",
+ "errors": [
+ "(1,1): expected-tag-name",
+ "(1,1): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "<",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>&lt;</body></html>",
+ "noQuirksBodyHtml": "&lt;"
+ }
+ },
+ {
+ "data": "<#",
+ "errors": [
+ "(1,1): expected-tag-name",
+ "(1,1): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "<#",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>&lt;#</body></html>",
+ "noQuirksBodyHtml": "&lt;#"
+ }
+ },
+ {
+ "data": "</",
+ "errors": [
+ "(1,2): expected-closing-tag-but-got-eof",
+ "(1,2): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "</",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>&lt;/</body></html>",
+ "noQuirksBodyHtml": "&lt;/"
+ }
+ },
+ {
+ "data": "</#",
+ "errors": [
+ "(1,2): expected-closing-tag-but-got-char",
+ "(1,3): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "#"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--#--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--#-->"
+ }
+ },
+ {
+ "data": "<?",
+ "errors": [
+ "(1,1): expected-tag-name-but-got-question-mark",
+ "(1,2): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "?"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--?--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--?-->"
+ }
+ },
+ {
+ "data": "<?#",
+ "errors": [
+ "(1,1): expected-tag-name-but-got-question-mark",
+ "(1,3): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "?#"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--?#--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--?#-->"
+ }
+ },
+ {
+ "data": "<!",
+ "errors": [
+ "(1,2): expected-dashes-or-doctype",
+ "(1,2): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": ""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!----><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!---->"
+ }
+ },
+ {
+ "data": "<!#",
+ "errors": [
+ "(1,2): expected-dashes-or-doctype",
+ "(1,3): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "#"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--#--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--#-->"
+ }
+ },
+ {
+ "data": "<?COMMENT?>",
+ "errors": [
+ "(1,1): expected-tag-name-but-got-question-mark",
+ "(1,11): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "?COMMENT?"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--?COMMENT?--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--?COMMENT?-->"
+ }
+ },
+ {
+ "data": "<!COMMENT>",
+ "errors": [
+ "(1,2): expected-dashes-or-doctype",
+ "(1,10): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "COMMENT"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--COMMENT--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--COMMENT-->"
+ }
+ },
+ {
+ "data": "</ COMMENT >",
+ "errors": [
+ "(1,2): expected-closing-tag-but-got-char",
+ "(1,12): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": " COMMENT "
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!-- COMMENT --><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!-- COMMENT -->"
+ }
+ },
+ {
+ "data": "<?COM--MENT?>",
+ "errors": [
+ "(1,1): expected-tag-name-but-got-question-mark",
+ "(1,13): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "?COM--MENT?"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--?COM--MENT?--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--?COM--MENT?-->"
+ }
+ },
+ {
+ "data": "<!COM--MENT>",
+ "errors": [
+ "(1,2): expected-dashes-or-doctype",
+ "(1,12): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "COM--MENT"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!--COM--MENT--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--COM--MENT-->"
+ }
+ },
+ {
+ "data": "</ COM--MENT >",
+ "errors": [
+ "(1,2): expected-closing-tag-but-got-char",
+ "(1,14): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": " COM--MENT "
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!-- COM--MENT --><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!-- COM--MENT -->"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><style> EOF",
+ "errors": [
+ "(1,26): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": " EOF",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><style> EOF</style></head><body></body></html>",
+ "noQuirksBodyHtml": "<style> EOF</style>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><script> <!-- </script> --> </script> EOF",
+ "errors": [
+ "(1,52): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": " <!-- ",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": " "
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "--> EOF",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script> <!-- </script> </head><body>--&gt; EOF</body></html>",
+ "noQuirksBodyHtml": "<script> <!-- </script> --&gt; EOF"
+ }
+ },
+ {
+ "data": "<b><p></b>TEST",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,10): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b"
+ },
+ {
+ "text": "TEST"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b></b><p><b></b>TEST</p></body></html>",
+ "noQuirksBodyHtml": "<b></b><p><b></b>TEST</p>"
+ }
+ },
+ {
+ "data": "<p id=a><b><p id=b></b>TEST",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,19): unexpected-end-tag",
+ "(1,23): adoption-agency-1.2"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "a"
+ }
+ ],
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "b"
+ }
+ ],
+ "children": [
+ {
+ "text": "TEST"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p id=\"a\"><b></b></p><p id=\"b\">TEST</p></body></html>",
+ "noQuirksBodyHtml": "<p id=\"a\"><b></b></p><p id=\"b\">TEST</p>"
+ }
+ },
+ {
+ "data": "<b id=a><p><b id=b></p></b>TEST",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,23): unexpected-end-tag",
+ "(1,27): adoption-agency-1.2",
+ "(1,31): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "a"
+ }
+ ],
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "TEST"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b id=\"a\"><p><b id=\"b\"></b></p>TEST</b></body></html>",
+ "noQuirksBodyHtml": "<b id=\"a\"><p><b id=\"b\"></b></p>TEST</b>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><title>U-test</title><body><div><p>Test<u></p></div></body>",
+ "errors": [
+ "(1,61): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true,
+ "div": true,
+ "p": true,
+ "u": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "U-test"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "Test"
+ },
+ {
+ "tag": "u"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><title>U-test</title></head><body><div><p>Test<u></u></p></div></body></html>",
+ "noQuirksBodyHtml": "<title>U-test</title><div><p>Test<u></u></p></div>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><font><table></font></table></font>",
+ "errors": [
+ "(1,35): unexpected-end-tag-implies-table-voodoo",
+ "(1,35): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "font": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "font",
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><font><table></table></font></body></html>",
+ "noQuirksBodyHtml": "<font><table></table></font>"
+ }
+ },
+ {
+ "data": "<font><p>hello<b>cruel</font>world",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,29): adoption-agency-1.3",
+ "(1,29): adoption-agency-1.3",
+ "(1,34): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "font": true,
+ "p": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "font"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "children": [
+ {
+ "text": "hello"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "cruel"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "world"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><font></font><p><font>hello<b>cruel</b></font><b>world</b></p></body></html>",
+ "noQuirksBodyHtml": "<font></font><p><font>hello<b>cruel</b></font><b>world</b></p>"
+ }
+ },
+ {
+ "data": "<b>Test</i>Test",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-end-tag",
+ "(1,15): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "TestTest"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b>TestTest</b></body></html>",
+ "noQuirksBodyHtml": "<b>TestTest</b>"
+ }
+ },
+ {
+ "data": "<b>A<cite>B<div>C",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "cite": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "A"
+ },
+ {
+ "tag": "cite",
+ "children": [
+ {
+ "text": "B"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "C"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b>A<cite>B<div>C</div></cite></b></body></html>",
+ "noQuirksBodyHtml": "<b>A<cite>B<div>C</div></cite></b>"
+ }
+ },
+ {
+ "data": "<b>A<cite>B<div>C</cite>D",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,24): unexpected-end-tag",
+ "(1,25): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "cite": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "A"
+ },
+ {
+ "tag": "cite",
+ "children": [
+ {
+ "text": "B"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "CD"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b>A<cite>B<div>CD</div></cite></b></body></html>",
+ "noQuirksBodyHtml": "<b>A<cite>B<div>CD</div></cite></b>"
+ }
+ },
+ {
+ "data": "<b>A<cite>B<div>C</b>D",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,21): adoption-agency-1.3",
+ "(1,22): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "cite": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "A"
+ },
+ {
+ "tag": "cite",
+ "children": [
+ {
+ "text": "B"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "C"
+ }
+ ]
+ },
+ {
+ "text": "D"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b>A<cite>B</cite></b><div><b>C</b>D</div></body></html>",
+ "noQuirksBodyHtml": "<b>A<cite>B</cite></b><div><b>C</b>D</div>"
+ }
+ },
+ {
+ "data": "",
+ "errors": [
+ "(1,0): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<DIV>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,5): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div></div></body></html>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<DIV> abc",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,9): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc</div></body></html>",
+ "noQuirksBodyHtml": "<div> abc</div>"
+ }
+ },
+ {
+ "data": "<DIV> abc <B>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,13): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc "
+ },
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc <b></b></div></body></html>",
+ "noQuirksBodyHtml": "<div> abc <b></b></div>"
+ }
+ },
+ {
+ "data": "<DIV> abc <B> def",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc "
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " def"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc <b> def</b></div></body></html>",
+ "noQuirksBodyHtml": "<div> abc <b> def</b></div>"
+ }
+ },
+ {
+ "data": "<DIV> abc <B> def <I>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,21): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true,
+ "i": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc "
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " def "
+ },
+ {
+ "tag": "i"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc <b> def <i></i></b></div></body></html>",
+ "noQuirksBodyHtml": "<div> abc <b> def <i></i></b></div>"
+ }
+ },
+ {
+ "data": "<DIV> abc <B> def <I> ghi",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,25): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true,
+ "i": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc "
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " def "
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": " ghi"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc <b> def <i> ghi</i></b></div></body></html>",
+ "noQuirksBodyHtml": "<div> abc <b> def <i> ghi</i></b></div>"
+ }
+ },
+ {
+ "data": "<DIV> abc <B> def <I> ghi <P>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,29): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true,
+ "i": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc "
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " def "
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": " ghi "
+ },
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc <b> def <i> ghi <p></p></i></b></div></body></html>",
+ "noQuirksBodyHtml": "<div> abc <b> def <i> ghi <p></p></i></b></div>"
+ }
+ },
+ {
+ "data": "<DIV> abc <B> def <I> ghi <P> jkl",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,33): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true,
+ "i": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc "
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " def "
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": " ghi "
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": " jkl"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc <b> def <i> ghi <p> jkl</p></i></b></div></body></html>",
+ "noQuirksBodyHtml": "<div> abc <b> def <i> ghi <p> jkl</p></i></b></div>"
+ }
+ },
+ {
+ "data": "<DIV> abc <B> def <I> ghi <P> jkl </B>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,38): adoption-agency-1.3",
+ "(1,38): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true,
+ "i": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc "
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " def "
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": " ghi "
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " jkl "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc <b> def <i> ghi </i></b><i><p><b> jkl </b></p></i></div></body></html>",
+ "noQuirksBodyHtml": "<div> abc <b> def <i> ghi </i></b><i><p><b> jkl </b></p></i></div>"
+ }
+ },
+ {
+ "data": "<DIV> abc <B> def <I> ghi <P> jkl </B> mno",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,38): adoption-agency-1.3",
+ "(1,42): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true,
+ "i": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc "
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " def "
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": " ghi "
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " jkl "
+ }
+ ]
+ },
+ {
+ "text": " mno"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc <b> def <i> ghi </i></b><i><p><b> jkl </b> mno</p></i></div></body></html>",
+ "noQuirksBodyHtml": "<div> abc <b> def <i> ghi </i></b><i><p><b> jkl </b> mno</p></i></div>"
+ }
+ },
+ {
+ "data": "<DIV> abc <B> def <I> ghi <P> jkl </B> mno </I>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,38): adoption-agency-1.3",
+ "(1,47): adoption-agency-1.3",
+ "(1,47): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true,
+ "i": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc "
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " def "
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": " ghi "
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "i"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " jkl "
+ }
+ ]
+ },
+ {
+ "text": " mno "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i></p></div></body></html>",
+ "noQuirksBodyHtml": "<div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i></p></div>"
+ }
+ },
+ {
+ "data": "<DIV> abc <B> def <I> ghi <P> jkl </B> mno </I> pqr",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,38): adoption-agency-1.3",
+ "(1,47): adoption-agency-1.3",
+ "(1,51): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true,
+ "i": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc "
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " def "
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": " ghi "
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "i"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " jkl "
+ }
+ ]
+ },
+ {
+ "text": " mno "
+ }
+ ]
+ },
+ {
+ "text": " pqr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i> pqr</p></div></body></html>",
+ "noQuirksBodyHtml": "<div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i> pqr</p></div>"
+ }
+ },
+ {
+ "data": "<DIV> abc <B> def <I> ghi <P> jkl </B> mno </I> pqr </P>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,38): adoption-agency-1.3",
+ "(1,47): adoption-agency-1.3",
+ "(1,56): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true,
+ "i": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc "
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " def "
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": " ghi "
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "i"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " jkl "
+ }
+ ]
+ },
+ {
+ "text": " mno "
+ }
+ ]
+ },
+ {
+ "text": " pqr "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i> pqr </p></div></body></html>",
+ "noQuirksBodyHtml": "<div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i> pqr </p></div>"
+ }
+ },
+ {
+ "data": "<DIV> abc <B> def <I> ghi <P> jkl </B> mno </I> pqr </P> stu",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,38): adoption-agency-1.3",
+ "(1,47): adoption-agency-1.3",
+ "(1,60): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true,
+ "i": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": " abc "
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " def "
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": " ghi "
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "i"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": " jkl "
+ }
+ ]
+ },
+ {
+ "text": " mno "
+ }
+ ]
+ },
+ {
+ "text": " pqr "
+ }
+ ]
+ },
+ {
+ "text": " stu"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i> pqr </p> stu</div></body></html>",
+ "noQuirksBodyHtml": "<div> abc <b> def <i> ghi </i></b><i></i><p><i><b> jkl </b> mno </i> pqr </p> stu</div>"
+ }
+ },
+ {
+ "data": "<test attribute---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------->",
+ "errors": [
+ "(1,1040): expected-doctype-but-got-start-tag",
+ "(1,1040): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "test": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "test",
+ "attrs": [
+ {
+ "name": "attribute----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><test attribute----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------=\"\"></test></body></html>",
+ "noQuirksBodyHtml": "<test attribute----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------=\"\"></test>"
+ }
+ },
+ {
+ "data": "<a href=\"blah\">aba<table><a href=\"foo\">br<tr><td></td></tr>x</table>aoe",
+ "errors": [
+ "(1,15): expected-doctype-but-got-start-tag",
+ "(1,39): unexpected-start-tag-implies-table-voodoo",
+ "(1,39): unexpected-start-tag-implies-end-tag",
+ "(1,39): unexpected-end-tag",
+ "(1,45): foster-parenting-character-in-table",
+ "(1,45): foster-parenting-character-in-table",
+ "(1,68): foster-parenting-character-in-table",
+ "(1,71): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "blah"
+ }
+ ],
+ "children": [
+ {
+ "text": "aba"
+ },
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "foo"
+ }
+ ],
+ "children": [
+ {
+ "text": "br"
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "foo"
+ }
+ ],
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "foo"
+ }
+ ],
+ "children": [
+ {
+ "text": "aoe"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a href=\"blah\">aba<a href=\"foo\">br</a><a href=\"foo\">x</a><table><tbody><tr><td></td></tr></tbody></table></a><a href=\"foo\">aoe</a></body></html>",
+ "noQuirksBodyHtml": "<a href=\"blah\">aba<a href=\"foo\">br</a><a href=\"foo\">x</a><table><tbody><tr><td></td></tr></tbody></table></a><a href=\"foo\">aoe</a>"
+ }
+ },
+ {
+ "data": "<a href=\"blah\">aba<table><tr><td><a href=\"foo\">br</td></tr>x</table>aoe",
+ "errors": [
+ "(1,15): expected-doctype-but-got-start-tag",
+ "(1,54): unexpected-cell-end-tag",
+ "(1,68): unexpected text in table",
+ "(1,71): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "blah"
+ }
+ ],
+ "children": [
+ {
+ "text": "abax"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "foo"
+ }
+ ],
+ "children": [
+ {
+ "text": "br"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "aoe"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a href=\"blah\">abax<table><tbody><tr><td><a href=\"foo\">br</a></td></tr></tbody></table>aoe</a></body></html>",
+ "noQuirksBodyHtml": "<a href=\"blah\">abax<table><tbody><tr><td><a href=\"foo\">br</a></td></tr></tbody></table>aoe</a>"
+ }
+ },
+ {
+ "data": "<table><a href=\"blah\">aba<tr><td><a href=\"foo\">br</td></tr>x</table>aoe",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,22): unexpected-start-tag-implies-table-voodoo",
+ "(1,29): foster-parenting-character-in-table",
+ "(1,29): foster-parenting-character-in-table",
+ "(1,29): foster-parenting-character-in-table",
+ "(1,54): unexpected-cell-end-tag",
+ "(1,68): foster-parenting-character-in-table",
+ "(1,71): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "blah"
+ }
+ ],
+ "children": [
+ {
+ "text": "aba"
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "blah"
+ }
+ ],
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "foo"
+ }
+ ],
+ "children": [
+ {
+ "text": "br"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "blah"
+ }
+ ],
+ "children": [
+ {
+ "text": "aoe"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a href=\"blah\">aba</a><a href=\"blah\">x</a><table><tbody><tr><td><a href=\"foo\">br</a></td></tr></tbody></table><a href=\"blah\">aoe</a></body></html>",
+ "noQuirksBodyHtml": "<a href=\"blah\">aba</a><a href=\"blah\">x</a><table><tbody><tr><td><a href=\"foo\">br</a></td></tr></tbody></table><a href=\"blah\">aoe</a>"
+ }
+ },
+ {
+ "data": "<a href=a>aa<marquee>aa<a href=b>bb</marquee>aa",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,45): end-tag-too-early",
+ "(1,47): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "marquee": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "a"
+ }
+ ],
+ "children": [
+ {
+ "text": "aa"
+ },
+ {
+ "tag": "marquee",
+ "children": [
+ {
+ "text": "aa"
+ },
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "b"
+ }
+ ],
+ "children": [
+ {
+ "text": "bb"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "aa"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a href=\"a\">aa<marquee>aa<a href=\"b\">bb</a></marquee>aa</a></body></html>",
+ "noQuirksBodyHtml": "<a href=\"a\">aa<marquee>aa<a href=\"b\">bb</a></marquee>aa</a>"
+ }
+ },
+ {
+ "data": "<wbr><strike><code></strike><code><strike></code>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,28): adoption-agency-1.3",
+ "(1,49): adoption-agency-1.3",
+ "(1,49): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "wbr": true,
+ "strike": true,
+ "code": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "wbr"
+ },
+ {
+ "tag": "strike",
+ "children": [
+ {
+ "tag": "code"
+ }
+ ]
+ },
+ {
+ "tag": "code",
+ "children": [
+ {
+ "tag": "code",
+ "children": [
+ {
+ "tag": "strike"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><wbr><strike><code></code></strike><code><code><strike></strike></code></code></body></html>",
+ "noQuirksBodyHtml": "<wbr><strike><code></code></strike><code><code><strike></strike></code></code>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><spacer>foo",
+ "errors": [
+ "(1,26): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "spacer": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "spacer",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><spacer>foo</spacer></body></html>",
+ "noQuirksBodyHtml": "<spacer>foo</spacer>"
+ }
+ },
+ {
+ "data": "<title><meta></title><link><title><meta></title>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "link": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "<meta>",
+ "escaped": true
+ }
+ ]
+ },
+ {
+ "tag": "link"
+ },
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "<meta>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><title>&lt;meta&gt;</title><link><title>&lt;meta&gt;</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>&lt;meta&gt;</title><link><title>&lt;meta&gt;</title>"
+ }
+ },
+ {
+ "data": "<style><!--</style><meta><script>--><link></script>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "meta": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "tag": "meta"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "--><link>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style><!--</style><meta><script>--><link></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<style><!--</style><meta><script>--><link></script>"
+ }
+ },
+ {
+ "data": "<head><meta></head><link>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,25): unexpected-start-tag-out-of-my-head"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "meta": true,
+ "link": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "meta"
+ },
+ {
+ "tag": "link"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><meta><link></head><body></body></html>",
+ "noQuirksBodyHtml": "<meta><link>"
+ }
+ },
+ {
+ "data": "<table><tr><tr><td><td><span><th><span>X</table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,33): unexpected-cell-end-tag",
+ "(1,48): unexpected-cell-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "span": true,
+ "th": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ },
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ },
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ },
+ {
+ "tag": "th",
+ "children": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr></tr><tr><td></td><td><span></span></td><th><span>X</span></th></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr></tr><tr><td></td><td><span></span></td><th><span>X</span></th></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<body><body><base><link><meta><title><p></title><body><p></body>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,12): unexpected-start-tag",
+ "(1,54): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "base": true,
+ "link": true,
+ "meta": true,
+ "title": true,
+ "p": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "base"
+ },
+ {
+ "tag": "link"
+ },
+ {
+ "tag": "meta"
+ },
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "<p>",
+ "escaped": true
+ }
+ ]
+ },
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><base><link><meta><title>&lt;p&gt;</title><p></p></body></html>",
+ "noQuirksBodyHtml": "<base><link><meta><title>&lt;p&gt;</title><p></p>"
+ }
+ },
+ {
+ "data": "<textarea><p></textarea>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "<p>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><textarea>&lt;p&gt;</textarea></body></html>",
+ "noQuirksBodyHtml": "<textarea>&lt;p&gt;</textarea>"
+ }
+ },
+ {
+ "data": "<p><image></p>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,10): unexpected-start-tag-treated-as"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "img": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "img"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><img></p></body></html>",
+ "noQuirksBodyHtml": "<p><img></p>"
+ }
+ },
+ {
+ "data": "<a><table><a></table><p><a><div><a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,13): unexpected-start-tag-implies-table-voodoo",
+ "(1,13): unexpected-start-tag-implies-end-tag",
+ "(1,13): adoption-agency-1.3",
+ "(1,27): unexpected-start-tag-implies-end-tag",
+ "(1,27): adoption-agency-1.2",
+ "(1,32): unexpected-end-tag",
+ "(1,35): unexpected-start-tag-implies-end-tag",
+ "(1,35): adoption-agency-1.2",
+ "(1,35): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "table": true,
+ "p": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a"
+ }
+ ]
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a><a></a><table></table></a><p><a></a></p><div><a></a></div></body></html>",
+ "noQuirksBodyHtml": "<a><a></a><table></table></a><p><a></a></p><div><a></a></div>"
+ }
+ },
+ {
+ "data": "<head></p><meta><p>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,10): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "meta": true,
+ "body": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "meta"
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><meta></head><body><p></p></body></html>",
+ "noQuirksBodyHtml": "<p></p><meta><p></p>"
+ }
+ },
+ {
+ "data": "<head></html><meta><p>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,19): expected-eof-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "meta": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "meta"
+ },
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><meta><p></p></body></html>",
+ "noQuirksBodyHtml": "<meta><p></p>"
+ }
+ },
+ {
+ "data": "<b><table><td><i></table>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,14): unexpected-cell-in-table-body",
+ "(1,25): unexpected-cell-end-tag",
+ "(1,25): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "i": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b><table><tbody><tr><td><i></i></td></tr></tbody></table></b></body></html>",
+ "noQuirksBodyHtml": "<b><table><tbody><tr><td><i></i></td></tr></tbody></table></b>"
+ }
+ },
+ {
+ "data": "<b><table><td></b><i></table>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,14): unexpected-cell-in-table-body",
+ "(1,18): unexpected-end-tag",
+ "(1,29): unexpected-cell-end-tag",
+ "(1,29): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "i": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b><table><tbody><tr><td><i></i></td></tr></tbody></table></b></body></html>",
+ "noQuirksBodyHtml": "<b><table><tbody><tr><td><i></i></td></tr></tbody></table></b>"
+ }
+ },
+ {
+ "data": "<h1><h2>",
+ "errors": [
+ "(1,4): expected-doctype-but-got-start-tag",
+ "(1,8): unexpected-start-tag",
+ "(1,8): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "h1": true,
+ "h2": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "h1"
+ },
+ {
+ "tag": "h2"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><h1></h1><h2></h2></body></html>",
+ "noQuirksBodyHtml": "<h1></h1><h2></h2>"
+ }
+ },
+ {
+ "data": "<a><p><a></a></p></a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,9): unexpected-start-tag-implies-end-tag",
+ "(1,9): adoption-agency-1.3",
+ "(1,21): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a></a><p><a></a><a></a></p></body></html>",
+ "noQuirksBodyHtml": "<a></a><p><a></a><a></a></p>"
+ }
+ },
+ {
+ "data": "<b><button></b></button></b>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,15): adoption-agency-1.3",
+ "(1,28): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "button": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b></b><button><b></b></button></body></html>",
+ "noQuirksBodyHtml": "<b></b><button><b></b></button>"
+ }
+ },
+ {
+ "data": "<p><b><div><marquee></p></b></div>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-end-tag",
+ "(1,24): unexpected-end-tag",
+ "(1,28): unexpected-end-tag",
+ "(1,34): end-tag-too-early",
+ "(1,34): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "b": true,
+ "div": true,
+ "marquee": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "marquee",
+ "children": [
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><b></b></p><div><b><marquee><p></p></marquee></b></div></body></html>",
+ "noQuirksBodyHtml": "<p><b></b></p><div><b><marquee><p></p></marquee></b></div>"
+ }
+ },
+ {
+ "data": "<script></script></div><title></title><p><p>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,23): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "title": true,
+ "body": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script"
+ },
+ {
+ "tag": "title"
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></script><title></title></head><body><p></p><p></p></body></html>",
+ "noQuirksBodyHtml": "<script></script><title></title><p></p><p></p>"
+ }
+ },
+ {
+ "data": "<p><hr></p>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "hr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "hr"
+ },
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p></p><hr><p></p></body></html>",
+ "noQuirksBodyHtml": "<p></p><hr><p></p>"
+ }
+ },
+ {
+ "data": "<select><b><option><select><option></b></select>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-start-tag-in-select",
+ "(1,27): unexpected-select-in-select",
+ "(1,39): unexpected-end-tag",
+ "(1,48): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option"
+ }
+ ]
+ },
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><option></option></select><option></option></body></html>",
+ "noQuirksBodyHtml": "<select><option></option></select><option></option>"
+ }
+ },
+ {
+ "data": "<html><head><title></title><body></body></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><title></title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title></title>"
+ }
+ },
+ {
+ "data": "<a><table><td><a><table></table><a></tr><a></table><a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,14): unexpected-cell-in-table-body",
+ "(1,35): unexpected-start-tag-implies-end-tag",
+ "(1,40): unexpected-cell-end-tag",
+ "(1,43): unexpected-start-tag-implies-table-voodoo",
+ "(1,43): unexpected-start-tag-implies-end-tag",
+ "(1,43): unexpected-end-tag",
+ "(1,54): unexpected-start-tag-implies-end-tag",
+ "(1,54): adoption-agency-1.2",
+ "(1,54): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ },
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a><a></a><table><tbody><tr><td><a><table></table></a><a></a></td></tr></tbody></table></a><a></a></body></html>",
+ "noQuirksBodyHtml": "<a><a></a><table><tbody><tr><td><a><table></table></a><a></a></td></tr></tbody></table></a><a></a>"
+ }
+ },
+ {
+ "data": "<ul><li></li><div><li></div><li><li><div><li><address><li><b><em></b><li></ul>",
+ "errors": [
+ "(1,4): expected-doctype-but-got-start-tag",
+ "(1,45): end-tag-too-early",
+ "(1,58): end-tag-too-early",
+ "(1,69): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ul": true,
+ "li": true,
+ "div": true,
+ "address": true,
+ "b": true,
+ "em": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ul",
+ "children": [
+ {
+ "tag": "li"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "li"
+ }
+ ]
+ },
+ {
+ "tag": "li"
+ },
+ {
+ "tag": "li",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ },
+ {
+ "tag": "li",
+ "children": [
+ {
+ "tag": "address"
+ }
+ ]
+ },
+ {
+ "tag": "li",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "em"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "li"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ul><li></li><div><li></li></div><li></li><li><div></div></li><li><address></address></li><li><b><em></em></b></li><li></li></ul></body></html>",
+ "noQuirksBodyHtml": "<ul><li></li><div><li></li></div><li></li><li><div></div></li><li><address></address></li><li><b><em></em></b></li><li></li></ul>"
+ }
+ },
+ {
+ "data": "<ul><li><ul></li><li>a</li></ul></li></ul>",
+ "errors": [
+ "(1,4): expected-doctype-but-got-start-tag",
+ "(1,17): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ul": true,
+ "li": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ul",
+ "children": [
+ {
+ "tag": "li",
+ "children": [
+ {
+ "tag": "ul",
+ "children": [
+ {
+ "tag": "li",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ul><li><ul><li>a</li></ul></li></ul></body></html>",
+ "noQuirksBodyHtml": "<ul><li><ul><li>a</li></ul></li></ul>"
+ }
+ },
+ {
+ "data": "<frameset><frame><frameset><frame></frameset><noframes></noframes></frameset>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "frame": true,
+ "noframes": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "tag": "frame"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "tag": "frame"
+ }
+ ]
+ },
+ {
+ "tag": "noframes"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset><frame><frameset><frame></frameset><noframes></noframes></frameset></html>",
+ "noQuirksBodyHtml": "<noframes></noframes>"
+ }
+ },
+ {
+ "data": "<h1><table><td><h3></table><h3></h1>",
+ "errors": [
+ "(1,4): expected-doctype-but-got-start-tag",
+ "(1,15): unexpected-cell-in-table-body",
+ "(1,27): unexpected-cell-end-tag",
+ "(1,31): unexpected-start-tag",
+ "(1,36): end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "h1": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "h3": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "h1",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "h3"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "h3"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><h1><table><tbody><tr><td><h3></h3></td></tr></tbody></table></h1><h3></h3></body></html>",
+ "noQuirksBodyHtml": "<h1><table><tbody><tr><td><h3></h3></td></tr></tbody></table></h1><h3></h3>"
+ }
+ },
+ {
+ "data": "<table><colgroup><col><colgroup><col><col><col><colgroup><col><col><thead><tr><td></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true,
+ "col": true,
+ "thead": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ },
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "col"
+ },
+ {
+ "tag": "col"
+ },
+ {
+ "tag": "col"
+ }
+ ]
+ },
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "col"
+ },
+ {
+ "tag": "col"
+ }
+ ]
+ },
+ {
+ "tag": "thead",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><colgroup><col></colgroup><colgroup><col><col><col></colgroup><colgroup><col><col></colgroup><thead><tr><td></td></tr></thead></table></body></html>",
+ "noQuirksBodyHtml": "<table><colgroup><col></colgroup><colgroup><col><col><col></colgroup><colgroup><col><col></colgroup><thead><tr><td></td></tr></thead></table>"
+ }
+ },
+ {
+ "data": "<table><col><tbody><col><tr><col><td><col></table><col>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,37): unexpected-cell-in-table-body",
+ "(1,55): unexpected-start-tag-ignored"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true,
+ "col": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ },
+ {
+ "tag": "tbody"
+ },
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ },
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><colgroup><col></colgroup><tbody></tbody><colgroup><col></colgroup><tbody><tr></tr></tbody><colgroup><col></colgroup><tbody><tr><td></td></tr></tbody><colgroup><col></colgroup></table></body></html>",
+ "noQuirksBodyHtml": "<table><colgroup><col></colgroup><tbody></tbody><colgroup><col></colgroup><tbody><tr></tr></tbody><colgroup><col></colgroup><tbody><tr><td></td></tr></tbody><colgroup><col></colgroup></table>"
+ }
+ },
+ {
+ "data": "<table><colgroup><tbody><colgroup><tr><colgroup><td><colgroup></table><colgroup>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,52): unexpected-cell-in-table-body",
+ "(1,80): unexpected-start-tag-ignored"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup"
+ },
+ {
+ "tag": "tbody"
+ },
+ {
+ "tag": "colgroup"
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ },
+ {
+ "tag": "colgroup"
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "colgroup"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><colgroup></colgroup><tbody></tbody><colgroup></colgroup><tbody><tr></tr></tbody><colgroup></colgroup><tbody><tr><td></td></tr></tbody><colgroup></colgroup></table></body></html>",
+ "noQuirksBodyHtml": "<table><colgroup></colgroup><tbody></tbody><colgroup></colgroup><tbody><tr></tr></tbody><colgroup></colgroup><tbody><tr><td></td></tr></tbody><colgroup></colgroup></table>"
+ }
+ },
+ {
+ "data": "</strong></b></em></i></u></strike></s></blink></tt></pre></big></small></font></select></h1></h2></h3></h4></h5></h6></body></br></a></img></title></span></style></script></table></th></td></tr></frame></area></link></param></hr></input></col></base></meta></basefont></bgsound></embed></spacer></p></dd></dt></caption></colgroup></tbody></tfoot></thead></address></blockquote></center></dir></div></dl></fieldset></listing></menu></ol></ul></li></nobr></wbr></form></button></marquee></object></html></frameset></head></iframe></image></isindex></noembed></noframes></noscript></optgroup></option></plaintext></textarea>",
+ "errors": [
+ "(1,9): expected-doctype-but-got-end-tag",
+ "(1,9): unexpected-end-tag-before-html",
+ "(1,13): unexpected-end-tag-before-html",
+ "(1,18): unexpected-end-tag-before-html",
+ "(1,22): unexpected-end-tag-before-html",
+ "(1,26): unexpected-end-tag-before-html",
+ "(1,35): unexpected-end-tag-before-html",
+ "(1,39): unexpected-end-tag-before-html",
+ "(1,47): unexpected-end-tag-before-html",
+ "(1,52): unexpected-end-tag-before-html",
+ "(1,58): unexpected-end-tag-before-html",
+ "(1,64): unexpected-end-tag-before-html",
+ "(1,72): unexpected-end-tag-before-html",
+ "(1,79): unexpected-end-tag-before-html",
+ "(1,88): unexpected-end-tag-before-html",
+ "(1,93): unexpected-end-tag-before-html",
+ "(1,98): unexpected-end-tag-before-html",
+ "(1,103): unexpected-end-tag-before-html",
+ "(1,108): unexpected-end-tag-before-html",
+ "(1,113): unexpected-end-tag-before-html",
+ "(1,118): unexpected-end-tag-before-html",
+ "(1,130): unexpected-end-tag-after-body",
+ "(1,130): unexpected-end-tag-treated-as",
+ "(1,134): unexpected-end-tag",
+ "(1,140): unexpected-end-tag",
+ "(1,148): unexpected-end-tag",
+ "(1,155): unexpected-end-tag",
+ "(1,163): unexpected-end-tag",
+ "(1,172): unexpected-end-tag",
+ "(1,180): unexpected-end-tag",
+ "(1,185): unexpected-end-tag",
+ "(1,190): unexpected-end-tag",
+ "(1,195): unexpected-end-tag",
+ "(1,203): unexpected-end-tag",
+ "(1,210): unexpected-end-tag",
+ "(1,217): unexpected-end-tag",
+ "(1,225): unexpected-end-tag",
+ "(1,230): unexpected-end-tag",
+ "(1,238): unexpected-end-tag",
+ "(1,244): unexpected-end-tag",
+ "(1,251): unexpected-end-tag",
+ "(1,258): unexpected-end-tag",
+ "(1,269): unexpected-end-tag",
+ "(1,279): unexpected-end-tag",
+ "(1,287): unexpected-end-tag",
+ "(1,296): unexpected-end-tag",
+ "(1,300): unexpected-end-tag",
+ "(1,305): unexpected-end-tag",
+ "(1,310): unexpected-end-tag",
+ "(1,320): unexpected-end-tag",
+ "(1,331): unexpected-end-tag",
+ "(1,339): unexpected-end-tag",
+ "(1,347): unexpected-end-tag",
+ "(1,355): unexpected-end-tag",
+ "(1,365): end-tag-too-early",
+ "(1,378): end-tag-too-early",
+ "(1,387): end-tag-too-early",
+ "(1,393): end-tag-too-early",
+ "(1,399): end-tag-too-early",
+ "(1,404): end-tag-too-early",
+ "(1,415): end-tag-too-early",
+ "(1,425): end-tag-too-early",
+ "(1,432): end-tag-too-early",
+ "(1,437): end-tag-too-early",
+ "(1,442): end-tag-too-early",
+ "(1,447): unexpected-end-tag",
+ "(1,454): unexpected-end-tag",
+ "(1,460): unexpected-end-tag",
+ "(1,467): unexpected-end-tag",
+ "(1,476): end-tag-too-early",
+ "(1,486): end-tag-too-early",
+ "(1,495): end-tag-too-early",
+ "(1,513): expected-eof-but-got-end-tag",
+ "(1,513): unexpected-end-tag",
+ "(1,520): unexpected-end-tag",
+ "(1,529): unexpected-end-tag",
+ "(1,537): unexpected-end-tag",
+ "(1,547): unexpected-end-tag",
+ "(1,557): unexpected-end-tag",
+ "(1,568): unexpected-end-tag",
+ "(1,579): unexpected-end-tag",
+ "(1,590): unexpected-end-tag",
+ "(1,599): unexpected-end-tag",
+ "(1,611): unexpected-end-tag",
+ "(1,622): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "br": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "br"
+ },
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><br><p></p></body></html>",
+ "noQuirksBodyHtml": "<br><p></p>"
+ }
+ },
+ {
+ "data": "<table><tr></strong></b></em></i></u></strike></s></blink></tt></pre></big></small></font></select></h1></h2></h3></h4></h5></h6></body></br></a></img></title></span></style></script></table></th></td></tr></frame></area></link></param></hr></input></col></base></meta></basefont></bgsound></embed></spacer></p></dd></dt></caption></colgroup></tbody></tfoot></thead></address></blockquote></center></dir></div></dl></fieldset></listing></menu></ol></ul></li></nobr></wbr></form></button></marquee></object></html></frameset></head></iframe></image></isindex></noembed></noframes></noscript></optgroup></option></plaintext></textarea>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,20): unexpected-end-tag-implies-table-voodoo",
+ "(1,20): unexpected-end-tag",
+ "(1,24): unexpected-end-tag-implies-table-voodoo",
+ "(1,24): unexpected-end-tag",
+ "(1,29): unexpected-end-tag-implies-table-voodoo",
+ "(1,29): unexpected-end-tag",
+ "(1,33): unexpected-end-tag-implies-table-voodoo",
+ "(1,33): unexpected-end-tag",
+ "(1,37): unexpected-end-tag-implies-table-voodoo",
+ "(1,37): unexpected-end-tag",
+ "(1,46): unexpected-end-tag-implies-table-voodoo",
+ "(1,46): unexpected-end-tag",
+ "(1,50): unexpected-end-tag-implies-table-voodoo",
+ "(1,50): unexpected-end-tag",
+ "(1,58): unexpected-end-tag-implies-table-voodoo",
+ "(1,58): unexpected-end-tag",
+ "(1,63): unexpected-end-tag-implies-table-voodoo",
+ "(1,63): unexpected-end-tag",
+ "(1,69): unexpected-end-tag-implies-table-voodoo",
+ "(1,69): end-tag-too-early",
+ "(1,75): unexpected-end-tag-implies-table-voodoo",
+ "(1,75): unexpected-end-tag",
+ "(1,83): unexpected-end-tag-implies-table-voodoo",
+ "(1,83): unexpected-end-tag",
+ "(1,90): unexpected-end-tag-implies-table-voodoo",
+ "(1,90): unexpected-end-tag",
+ "(1,99): unexpected-end-tag-implies-table-voodoo",
+ "(1,99): unexpected-end-tag",
+ "(1,104): unexpected-end-tag-implies-table-voodoo",
+ "(1,104): end-tag-too-early",
+ "(1,109): unexpected-end-tag-implies-table-voodoo",
+ "(1,109): end-tag-too-early",
+ "(1,114): unexpected-end-tag-implies-table-voodoo",
+ "(1,114): end-tag-too-early",
+ "(1,119): unexpected-end-tag-implies-table-voodoo",
+ "(1,119): end-tag-too-early",
+ "(1,124): unexpected-end-tag-implies-table-voodoo",
+ "(1,124): end-tag-too-early",
+ "(1,129): unexpected-end-tag-implies-table-voodoo",
+ "(1,129): end-tag-too-early",
+ "(1,136): unexpected-end-tag-in-table-row",
+ "(1,141): unexpected-end-tag-implies-table-voodoo",
+ "(1,141): unexpected-end-tag-treated-as",
+ "(1,145): unexpected-end-tag-implies-table-voodoo",
+ "(1,145): unexpected-end-tag",
+ "(1,151): unexpected-end-tag-implies-table-voodoo",
+ "(1,151): unexpected-end-tag",
+ "(1,159): unexpected-end-tag-implies-table-voodoo",
+ "(1,159): unexpected-end-tag",
+ "(1,166): unexpected-end-tag-implies-table-voodoo",
+ "(1,166): unexpected-end-tag",
+ "(1,174): unexpected-end-tag-implies-table-voodoo",
+ "(1,174): unexpected-end-tag",
+ "(1,183): unexpected-end-tag-implies-table-voodoo",
+ "(1,183): unexpected-end-tag",
+ "(1,196): unexpected-end-tag",
+ "(1,201): unexpected-end-tag",
+ "(1,206): unexpected-end-tag",
+ "(1,214): unexpected-end-tag",
+ "(1,221): unexpected-end-tag",
+ "(1,228): unexpected-end-tag",
+ "(1,236): unexpected-end-tag",
+ "(1,241): unexpected-end-tag",
+ "(1,249): unexpected-end-tag",
+ "(1,255): unexpected-end-tag",
+ "(1,262): unexpected-end-tag",
+ "(1,269): unexpected-end-tag",
+ "(1,280): unexpected-end-tag",
+ "(1,290): unexpected-end-tag",
+ "(1,298): unexpected-end-tag",
+ "(1,307): unexpected-end-tag",
+ "(1,311): unexpected-end-tag",
+ "(1,316): unexpected-end-tag",
+ "(1,321): unexpected-end-tag",
+ "(1,331): unexpected-end-tag",
+ "(1,342): unexpected-end-tag",
+ "(1,350): unexpected-end-tag",
+ "(1,358): unexpected-end-tag",
+ "(1,366): unexpected-end-tag",
+ "(1,376): end-tag-too-early",
+ "(1,389): end-tag-too-early",
+ "(1,398): end-tag-too-early",
+ "(1,404): end-tag-too-early",
+ "(1,410): end-tag-too-early",
+ "(1,415): end-tag-too-early",
+ "(1,426): end-tag-too-early",
+ "(1,436): end-tag-too-early",
+ "(1,443): end-tag-too-early",
+ "(1,448): end-tag-too-early",
+ "(1,453): end-tag-too-early",
+ "(1,458): unexpected-end-tag",
+ "(1,465): unexpected-end-tag",
+ "(1,471): unexpected-end-tag",
+ "(1,478): unexpected-end-tag",
+ "(1,487): end-tag-too-early",
+ "(1,497): end-tag-too-early",
+ "(1,506): end-tag-too-early",
+ "(1,524): expected-eof-but-got-end-tag",
+ "(1,524): unexpected-end-tag",
+ "(1,531): unexpected-end-tag",
+ "(1,540): unexpected-end-tag",
+ "(1,548): unexpected-end-tag",
+ "(1,558): unexpected-end-tag",
+ "(1,568): unexpected-end-tag",
+ "(1,579): unexpected-end-tag",
+ "(1,590): unexpected-end-tag",
+ "(1,601): unexpected-end-tag",
+ "(1,610): unexpected-end-tag",
+ "(1,622): unexpected-end-tag",
+ "(1,633): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "br": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "br"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><br><table><tbody><tr></tr></tbody></table><p></p></body></html>",
+ "noQuirksBodyHtml": "<br><table><tbody><tr></tr></tbody></table><p></p>"
+ }
+ },
+ {
+ "data": "<frameset>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,10): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ }
+ ],
+ "tests10.dat": [
+ {
+ "data": "<!DOCTYPE html><svg></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg></svg></body></html>",
+ "noQuirksBodyHtml": "<svg></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><svg></svg><![CDATA[a]]>",
+ "errors": [
+ "(1,28) expected-dashes-or-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "comment": "[CDATA[a]]"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg></svg><!--[CDATA[a]]--></body></html>",
+ "noQuirksBodyHtml": "<svg></svg><!--[CDATA[a]]-->"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><svg></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg></svg></body></html>",
+ "noQuirksBodyHtml": "<svg></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><select><svg></svg></select>",
+ "errors": [
+ "(1,34) unexpected-start-tag-in-select",
+ "(1,40) unexpected-end-tag-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><select><option><svg></svg></option></select>",
+ "errors": [
+ "(1,42) unexpected-start-tag-in-select",
+ "(1,48) unexpected-end-tag-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select><option></option></select></body></html>",
+ "noQuirksBodyHtml": "<select><option></option></select>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><svg></svg></table>",
+ "errors": [
+ "(1,33) foster-parenting-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg></svg><table></table></body></html>",
+ "noQuirksBodyHtml": "<svg></svg><table></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><svg><g>foo</g></svg></table>",
+ "errors": [
+ "(1,33) foster-parenting-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg g": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g></svg><table></table></body></html>",
+ "noQuirksBodyHtml": "<svg><g>foo</g></svg><table></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><svg><g>foo</g><g>bar</g></svg></table>",
+ "errors": [
+ "(1,33) foster-parenting-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg g": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g><g>bar</g></svg><table></table></body></html>",
+ "noQuirksBodyHtml": "<svg><g>foo</g><g>bar</g></svg><table></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><tbody><svg><g>foo</g><g>bar</g></svg></tbody></table>",
+ "errors": [
+ "(1,40) foster-parenting-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg g": true,
+ "table": true,
+ "tbody": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g><g>bar</g></svg><table><tbody></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<svg><g>foo</g><g>bar</g></svg><table><tbody></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><tbody><tr><svg><g>foo</g><g>bar</g></svg></tr></tbody></table>",
+ "errors": [
+ "(1,44) foster-parenting-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg g": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g><g>bar</g></svg><table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<svg><g>foo</g><g>bar</g></svg><table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><tbody><tr><td><svg><g>foo</g><g>bar</g></svg></td></tr></tbody></table>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "svg svg": true,
+ "svg g": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><svg><g>foo</g><g>bar</g></svg></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><svg><g>foo</g><g>bar</g></svg></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><tbody><tr><td><svg><g>foo</g><g>bar</g></svg><p>baz</td></tr></tbody></table>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "svg svg": true,
+ "svg g": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><svg><g>foo</g><g>bar</g></svg><p>baz</p></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><svg><g>foo</g><g>bar</g></svg><p>baz</p></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><caption><svg><g>foo</g><g>bar</g></svg><p>baz</caption></table>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "svg svg": true,
+ "svg g": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><caption><svg><g>foo</g><g>bar</g></svg><p>baz</p></caption></table></body></html>",
+ "noQuirksBodyHtml": "<table><caption><svg><g>foo</g><g>bar</g></svg><p>baz</p></caption></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><caption><svg><g>foo</g><g>bar</g><p>baz</table><p>quux",
+ "errors": [
+ "(1,65) unexpected-html-element-in-foreign-content"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "svg svg": true,
+ "svg g": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "quux"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><caption><svg><g>foo</g><g>bar</g></svg><p>baz</p></caption></table><p>quux</p></body></html>",
+ "noQuirksBodyHtml": "<table><caption><svg><g>foo</g><g>bar</g><p>baz</p></svg></caption></table><p>quux</p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><caption><svg><g>foo</g><g>bar</g>baz</table><p>quux",
+ "errors": [
+ "(1,73) unexpected-end-tag",
+ "(1,73) expected-one-end-tag-but-got-another"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "svg svg": true,
+ "svg g": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ },
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "quux"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><caption><svg><g>foo</g><g>bar</g>baz</svg></caption></table><p>quux</p></body></html>",
+ "noQuirksBodyHtml": "<table><caption><svg><g>foo</g><g>bar</g>baz</svg></caption></table><p>quux</p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><colgroup><svg><g>foo</g><g>bar</g><p>baz</table><p>quux",
+ "errors": [
+ "(1,43) foster-parenting-start-tag svg",
+ "(1,66) unexpected HTML-like start tag token in foreign content",
+ "(1,66) foster-parenting-start-tag",
+ "(1,67) foster-parenting-character",
+ "(1,68) foster-parenting-character",
+ "(1,69) foster-parenting-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg g": true,
+ "p": true,
+ "table": true,
+ "colgroup": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup"
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "quux"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g><g>bar</g></svg><p>baz</p><table><colgroup></colgroup></table><p>quux</p></body></html>",
+ "noQuirksBodyHtml": "<svg><g>foo</g><g>bar</g><p>baz</p></svg><table><colgroup></colgroup></table><p>quux</p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><tr><td><select><svg><g>foo</g><g>bar</g><p>baz</table><p>quux",
+ "errors": [
+ "(1,49) unexpected-start-tag-in-select",
+ "(1,52) unexpected-start-tag-in-select",
+ "(1,59) unexpected-end-tag-in-select",
+ "(1,62) unexpected-start-tag-in-select",
+ "(1,69) unexpected-end-tag-in-select",
+ "(1,72) unexpected-start-tag-in-select",
+ "(1,83) unexpected-table-element-end-tag-in-select-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "select": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "text": "foobarbaz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "quux"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><select>foobarbaz</select></td></tr></tbody></table><p>quux</p></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><select>foobarbaz</select></td></tr></tbody></table><p>quux</p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><select><svg><g>foo</g><g>bar</g><p>baz</table><p>quux",
+ "errors": [
+ "(1,36) unexpected-start-tag-implies-table-voodoo",
+ "(1,41) unexpected-start-tag-in-select",
+ "(1,44) unexpected-start-tag-in-select",
+ "(1,51) unexpected-end-tag-in-select",
+ "(1,54) unexpected-start-tag-in-select",
+ "(1,61) unexpected-end-tag-in-select",
+ "(1,64) unexpected-start-tag-in-select",
+ "(1,75) unexpected-table-element-end-tag-in-select-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "table": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "text": "foobarbaz"
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "quux"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select>foobarbaz</select><table></table><p>quux</p></body></html>",
+ "noQuirksBodyHtml": "<select>foobarbaz</select><table></table><p>quux</p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body></body></html><svg><g>foo</g><g>bar</g><p>baz",
+ "errors": [
+ "(1,40) expected-eof-but-got-start-tag",
+ "(1,63) unexpected-html-element-in-foreign-content"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg g": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g><g>bar</g></svg><p>baz</p></body></html>",
+ "noQuirksBodyHtml": "<svg><g>foo</g><g>bar</g><p>baz</p></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body></body><svg><g>foo</g><g>bar</g><p>baz",
+ "errors": [
+ "(1,33) unexpected-start-tag-after-body",
+ "(1,56) unexpected-html-element-in-foreign-content"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg g": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><g>foo</g><g>bar</g></svg><p>baz</p></body></html>",
+ "noQuirksBodyHtml": "<svg><g>foo</g><g>bar</g><p>baz</p></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><frameset><svg><g></g><g></g><p><span>",
+ "errors": [
+ "(1,30) unexpected-start-tag-in-frameset",
+ "(1,33) unexpected-start-tag-in-frameset",
+ "(1,37) unexpected-end-tag-in-frameset",
+ "(1,40) unexpected-start-tag-in-frameset",
+ "(1,44) unexpected-end-tag-in-frameset",
+ "(1,47) unexpected-start-tag-in-frameset",
+ "(1,53) unexpected-start-tag-in-frameset",
+ "(1,53) eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<svg><g></g><g></g><p><span></span></p></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><frameset></frameset><svg><g></g><g></g><p><span>",
+ "errors": [
+ "(1,41) unexpected-start-tag-after-frameset",
+ "(1,44) unexpected-start-tag-after-frameset",
+ "(1,48) unexpected-end-tag-after-frameset",
+ "(1,51) unexpected-start-tag-after-frameset",
+ "(1,55) unexpected-end-tag-after-frameset",
+ "(1,58) unexpected-start-tag-after-frameset",
+ "(1,64) unexpected-start-tag-after-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<svg><g></g><g></g><p><span></span></p></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body xlink:href=foo><svg xlink:href=foo></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "attrs": [
+ {
+ "name": "xlink:href",
+ "value": "foo"
+ }
+ ],
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "href",
+ "ns": "http://www.w3.org/1999/xlink",
+ "value": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\"><svg xlink:href=\"foo\"></svg></body></html>",
+ "noQuirksBodyHtml": "<svg xlink:href=\"foo\"></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body xlink:href=foo xml:lang=en><svg><g xml:lang=en xlink:href=foo></g></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg g": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "attrs": [
+ {
+ "name": "xlink:href",
+ "value": "foo"
+ },
+ {
+ "name": "xml:lang",
+ "value": "en"
+ }
+ ],
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "href",
+ "ns": "http://www.w3.org/1999/xlink",
+ "value": "foo"
+ },
+ {
+ "name": "lang",
+ "ns": "http://www.w3.org/XML/1998/namespace",
+ "value": "en"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\" xml:lang=\"en\"><svg><g xml:lang=\"en\" xlink:href=\"foo\"></g></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><g xml:lang=\"en\" xlink:href=\"foo\"></g></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body xlink:href=foo xml:lang=en><svg><g xml:lang=en xlink:href=foo /></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg g": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "attrs": [
+ {
+ "name": "xlink:href",
+ "value": "foo"
+ },
+ {
+ "name": "xml:lang",
+ "value": "en"
+ }
+ ],
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "href",
+ "ns": "http://www.w3.org/1999/xlink",
+ "value": "foo"
+ },
+ {
+ "name": "lang",
+ "ns": "http://www.w3.org/XML/1998/namespace",
+ "value": "en"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\" xml:lang=\"en\"><svg><g xml:lang=\"en\" xlink:href=\"foo\"></g></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><g xml:lang=\"en\" xlink:href=\"foo\"></g></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body xlink:href=foo xml:lang=en><svg><g xml:lang=en xlink:href=foo />bar</svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg g": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "attrs": [
+ {
+ "name": "xlink:href",
+ "value": "foo"
+ },
+ {
+ "name": "xml:lang",
+ "value": "en"
+ }
+ ],
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "href",
+ "ns": "http://www.w3.org/1999/xlink",
+ "value": "foo"
+ },
+ {
+ "name": "lang",
+ "ns": "http://www.w3.org/XML/1998/namespace",
+ "value": "en"
+ }
+ ]
+ },
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\" xml:lang=\"en\"><svg><g xml:lang=\"en\" xlink:href=\"foo\"></g>bar</svg></body></html>",
+ "noQuirksBodyHtml": "<svg><g xml:lang=\"en\" xlink:href=\"foo\"></g>bar</svg>"
+ }
+ },
+ {
+ "data": "<svg></path>",
+ "errors": [
+ "(1,5) expected-doctype-but-got-start-tag",
+ "(1,12) unexpected-end-tag",
+ "(1,12) unexpected-end-tag",
+ "(1,12) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg></svg></body></html>",
+ "noQuirksBodyHtml": "<svg></svg>"
+ }
+ },
+ {
+ "data": "<div><svg></div>a",
+ "errors": [
+ "(1,5) expected-doctype-but-got-start-tag",
+ "(1,16) unexpected-end-tag",
+ "(1,16) end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ },
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><svg></svg></div>a</body></html>",
+ "noQuirksBodyHtml": "<div><svg></svg></div>a"
+ }
+ },
+ {
+ "data": "<div><svg><path></div>a",
+ "errors": [
+ "(1,5) expected-doctype-but-got-start-tag",
+ "(1,22) unexpected-end-tag",
+ "(1,22) end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "svg svg": true,
+ "svg path": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "path",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><svg><path></path></svg></div>a</body></html>",
+ "noQuirksBodyHtml": "<div><svg><path></path></svg></div>a"
+ }
+ },
+ {
+ "data": "<div><svg><path></svg><path>",
+ "errors": [
+ "(1,5) expected-doctype-but-got-start-tag",
+ "(1,22) unexpected-end-tag",
+ "(1,28) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "svg svg": true,
+ "svg path": true,
+ "path": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "path",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ },
+ {
+ "tag": "path"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><svg><path></path></svg><path></path></div></body></html>",
+ "noQuirksBodyHtml": "<div><svg><path></path></svg><path></path></div>"
+ }
+ },
+ {
+ "data": "<div><svg><path><foreignObject><math></div>a",
+ "errors": [
+ "(1,5) expected-doctype-but-got-start-tag",
+ "(1,43) unexpected-end-tag",
+ "(1,43) end-tag-too-early",
+ "(1,44) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "svg svg": true,
+ "svg path": true,
+ "svg foreignObject": true,
+ "math math": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "path",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><svg><path><foreignObject><math>a</math></foreignObject></path></svg></div></body></html>",
+ "noQuirksBodyHtml": "<div><svg><path><foreignObject><math>a</math></foreignObject></path></svg></div>"
+ }
+ },
+ {
+ "data": "<div><svg><path><foreignObject><p></div>a",
+ "errors": [
+ "(1,5) expected-doctype-but-got-start-tag",
+ "(1,40) end-tag-too-early",
+ "(1,41) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "svg svg": true,
+ "svg path": true,
+ "svg foreignObject": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "path",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><svg><path><foreignObject><p>a</p></foreignObject></path></svg></div></body></html>",
+ "noQuirksBodyHtml": "<div><svg><path><foreignObject><p>a</p></foreignObject></path></svg></div>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><svg><desc><div><svg><ul>a",
+ "errors": [
+ "(1,40) unexpected-html-element-in-foreign-content",
+ "(1,41) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg desc": true,
+ "div": true,
+ "ul": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "desc",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "ul",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><desc><div><svg></svg><ul>a</ul></div></desc></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><desc><div><svg><ul>a</ul></svg></div></desc></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><svg><desc><svg><ul>a",
+ "errors": [
+ "(1,35) unexpected-html-element-in-foreign-content",
+ "(1,36) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg desc": true,
+ "ul": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "desc",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "ul",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><desc><svg></svg><ul>a</ul></desc></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><desc><svg><ul>a</ul></svg></desc></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><p><svg><desc><p>",
+ "errors": [
+ "(1,32) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "svg svg": true,
+ "svg desc": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "desc",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><svg><desc><p></p></desc></svg></p></body></html>",
+ "noQuirksBodyHtml": "<p><svg><desc><p></p></desc></svg></p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><p><svg><title><p>",
+ "errors": [
+ "(1,33) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "svg svg": true,
+ "svg title": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "title",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><svg><title><p></p></title></svg></p></body></html>",
+ "noQuirksBodyHtml": "<p><svg><title><p></p></title></svg></p>"
+ }
+ },
+ {
+ "data": "<div><svg><path><foreignObject><p></foreignObject><p>",
+ "errors": [
+ "(1,5) expected-doctype-but-got-start-tag",
+ "(1,50) unexpected-end-tag",
+ "(1,53) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "svg svg": true,
+ "svg path": true,
+ "svg foreignObject": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "path",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><svg><path><foreignObject><p></p><p></p></foreignObject></path></svg></div></body></html>",
+ "noQuirksBodyHtml": "<div><svg><path><foreignObject><p></p><p></p></foreignObject></path></svg></div>"
+ }
+ },
+ {
+ "data": "<math><mi><div><object><div><span></span></div></object></div></mi><mi>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,71) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true,
+ "div": true,
+ "object": true,
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "object",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><mi><div><object><div><span></span></div></object></div></mi><mi></mi></math></body></html>",
+ "noQuirksBodyHtml": "<math><mi><div><object><div><span></span></div></object></div></mi><mi></mi></math>"
+ }
+ },
+ {
+ "data": "<math><mi><svg><foreignObject><div><div></div></div></foreignObject></svg></mi><mi>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,83) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true,
+ "svg svg": true,
+ "svg foreignObject": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><mi><svg><foreignObject><div><div></div></div></foreignObject></svg></mi><mi></mi></math></body></html>",
+ "noQuirksBodyHtml": "<math><mi><svg><foreignObject><div><div></div></div></foreignObject></svg></mi><mi></mi></math>"
+ }
+ },
+ {
+ "data": "<svg><script></script><path>",
+ "errors": [
+ "(1,5) expected-doctype-but-got-start-tag",
+ "(1,28) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg script": true,
+ "svg path": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "script",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "path",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg><script></script><path></path></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><script></script><path></path></svg>"
+ }
+ },
+ {
+ "data": "<table><svg></svg><tr>",
+ "errors": [
+ "(1,7) expected-doctype-but-got-start-tag",
+ "(1,12) unexpected-start-tag-implies-table-voodoo",
+ "(1,22) eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg></svg><table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<svg></svg><table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<math><mi><mglyph>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,18) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true,
+ "math mglyph": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mglyph",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><mi><mglyph></mglyph></mi></math></body></html>",
+ "noQuirksBodyHtml": "<math><mi><mglyph></mglyph></mi></math>"
+ }
+ },
+ {
+ "data": "<math><mi><malignmark>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,22) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true,
+ "math malignmark": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><mi><malignmark></malignmark></mi></math></body></html>",
+ "noQuirksBodyHtml": "<math><mi><malignmark></malignmark></mi></math>"
+ }
+ },
+ {
+ "data": "<math><mo><mglyph>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,18) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mo": true,
+ "math mglyph": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mglyph",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><mo><mglyph></mglyph></mo></math></body></html>",
+ "noQuirksBodyHtml": "<math><mo><mglyph></mglyph></mo></math>"
+ }
+ },
+ {
+ "data": "<math><mo><malignmark>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,22) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mo": true,
+ "math malignmark": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><mo><malignmark></malignmark></mo></math></body></html>",
+ "noQuirksBodyHtml": "<math><mo><malignmark></malignmark></mo></math>"
+ }
+ },
+ {
+ "data": "<math><mn><mglyph>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,18) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mn": true,
+ "math mglyph": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mn",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mglyph",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><mn><mglyph></mglyph></mn></math></body></html>",
+ "noQuirksBodyHtml": "<math><mn><mglyph></mglyph></mn></math>"
+ }
+ },
+ {
+ "data": "<math><mn><malignmark>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,22) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mn": true,
+ "math malignmark": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mn",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><mn><malignmark></malignmark></mn></math></body></html>",
+ "noQuirksBodyHtml": "<math><mn><malignmark></malignmark></mn></math>"
+ }
+ },
+ {
+ "data": "<math><ms><mglyph>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,18) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math ms": true,
+ "math mglyph": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "ms",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mglyph",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><ms><mglyph></mglyph></ms></math></body></html>",
+ "noQuirksBodyHtml": "<math><ms><mglyph></mglyph></ms></math>"
+ }
+ },
+ {
+ "data": "<math><ms><malignmark>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,22) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math ms": true,
+ "math malignmark": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "ms",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><ms><malignmark></malignmark></ms></math></body></html>",
+ "noQuirksBodyHtml": "<math><ms><malignmark></malignmark></ms></math>"
+ }
+ },
+ {
+ "data": "<math><mtext><mglyph>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,21) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mtext": true,
+ "math mglyph": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mglyph",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><mtext><mglyph></mglyph></mtext></math></body></html>",
+ "noQuirksBodyHtml": "<math><mtext><mglyph></mglyph></mtext></math>"
+ }
+ },
+ {
+ "data": "<math><mtext><malignmark>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,25) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mtext": true,
+ "math malignmark": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "malignmark",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><mtext><malignmark></malignmark></mtext></math></body></html>",
+ "noQuirksBodyHtml": "<math><mtext><malignmark></malignmark></mtext></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml><svg></svg></annotation-xml><mi>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,54) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true,
+ "svg svg": true,
+ "math mi": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml><svg></svg></annotation-xml><mi></mi></math></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml><svg></svg></annotation-xml><mi></mi></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml><svg><foreignObject><div><math><mi></mi></math><span></span></div></foreignObject><path></path></svg></annotation-xml><mi>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,144) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true,
+ "svg svg": true,
+ "svg foreignObject": true,
+ "div": true,
+ "math mi": true,
+ "span": true,
+ "svg path": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ },
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "path",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml><svg><foreignObject><div><math><mi></mi></math><span></span></div></foreignObject><path></path></svg></annotation-xml><mi></mi></math></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml><svg><foreignObject><div><math><mi></mi></math><span></span></div></foreignObject><path></path></svg></annotation-xml><mi></mi></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml><svg><foreignObject><math><mi><svg></svg></mi><mo></mo></math><span></span></foreignObject><path></path></svg></annotation-xml><mi>",
+ "errors": [
+ "(1,6) expected-doctype-but-got-start-tag",
+ "(1,153) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true,
+ "svg svg": true,
+ "svg foreignObject": true,
+ "math mi": true,
+ "math mo": true,
+ "span": true,
+ "svg path": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ },
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ },
+ {
+ "tag": "span"
+ }
+ ]
+ },
+ {
+ "tag": "path",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml><svg><foreignObject><math><mi><svg></svg></mi><mo></mo></math><span></span></foreignObject><path></path></svg></annotation-xml><mi></mi></math></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml><svg><foreignObject><math><mi><svg></svg></mi><mo></mo></math><span></span></foreignObject><path></path></svg></annotation-xml><mi></mi></math>"
+ }
+ }
+ ],
+ "tests11.dat": [
+ {
+ "data": "<!DOCTYPE html><body><svg attributeName='' attributeType='' baseFrequency='' baseProfile='' calcMode='' clipPathUnits='' diffuseConstant='' edgeMode='' filterUnits='' glyphRef='' gradientTransform='' gradientUnits='' kernelMatrix='' kernelUnitLength='' keyPoints='' keySplines='' keyTimes='' lengthAdjust='' limitingConeAngle='' markerHeight='' markerUnits='' markerWidth='' maskContentUnits='' maskUnits='' numOctaves='' pathLength='' patternContentUnits='' patternTransform='' patternUnits='' pointsAtX='' pointsAtY='' pointsAtZ='' preserveAlpha='' preserveAspectRatio='' primitiveUnits='' refX='' refY='' repeatCount='' repeatDur='' requiredExtensions='' requiredFeatures='' specularConstant='' specularExponent='' spreadMethod='' startOffset='' stdDeviation='' stitchTiles='' surfaceScale='' systemLanguage='' tableValues='' targetX='' targetY='' textLength='' viewBox='' viewTarget='' xChannelSelector='' yChannelSelector='' zoomAndPan=''></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "attributeName",
+ "value": ""
+ },
+ {
+ "name": "attributeType",
+ "value": ""
+ },
+ {
+ "name": "baseFrequency",
+ "value": ""
+ },
+ {
+ "name": "baseProfile",
+ "value": ""
+ },
+ {
+ "name": "calcMode",
+ "value": ""
+ },
+ {
+ "name": "clipPathUnits",
+ "value": ""
+ },
+ {
+ "name": "diffuseConstant",
+ "value": ""
+ },
+ {
+ "name": "edgeMode",
+ "value": ""
+ },
+ {
+ "name": "filterUnits",
+ "value": ""
+ },
+ {
+ "name": "glyphRef",
+ "value": ""
+ },
+ {
+ "name": "gradientTransform",
+ "value": ""
+ },
+ {
+ "name": "gradientUnits",
+ "value": ""
+ },
+ {
+ "name": "kernelMatrix",
+ "value": ""
+ },
+ {
+ "name": "kernelUnitLength",
+ "value": ""
+ },
+ {
+ "name": "keyPoints",
+ "value": ""
+ },
+ {
+ "name": "keySplines",
+ "value": ""
+ },
+ {
+ "name": "keyTimes",
+ "value": ""
+ },
+ {
+ "name": "lengthAdjust",
+ "value": ""
+ },
+ {
+ "name": "limitingConeAngle",
+ "value": ""
+ },
+ {
+ "name": "markerHeight",
+ "value": ""
+ },
+ {
+ "name": "markerUnits",
+ "value": ""
+ },
+ {
+ "name": "markerWidth",
+ "value": ""
+ },
+ {
+ "name": "maskContentUnits",
+ "value": ""
+ },
+ {
+ "name": "maskUnits",
+ "value": ""
+ },
+ {
+ "name": "numOctaves",
+ "value": ""
+ },
+ {
+ "name": "pathLength",
+ "value": ""
+ },
+ {
+ "name": "patternContentUnits",
+ "value": ""
+ },
+ {
+ "name": "patternTransform",
+ "value": ""
+ },
+ {
+ "name": "patternUnits",
+ "value": ""
+ },
+ {
+ "name": "pointsAtX",
+ "value": ""
+ },
+ {
+ "name": "pointsAtY",
+ "value": ""
+ },
+ {
+ "name": "pointsAtZ",
+ "value": ""
+ },
+ {
+ "name": "preserveAlpha",
+ "value": ""
+ },
+ {
+ "name": "preserveAspectRatio",
+ "value": ""
+ },
+ {
+ "name": "primitiveUnits",
+ "value": ""
+ },
+ {
+ "name": "refX",
+ "value": ""
+ },
+ {
+ "name": "refY",
+ "value": ""
+ },
+ {
+ "name": "repeatCount",
+ "value": ""
+ },
+ {
+ "name": "repeatDur",
+ "value": ""
+ },
+ {
+ "name": "requiredExtensions",
+ "value": ""
+ },
+ {
+ "name": "requiredFeatures",
+ "value": ""
+ },
+ {
+ "name": "specularConstant",
+ "value": ""
+ },
+ {
+ "name": "specularExponent",
+ "value": ""
+ },
+ {
+ "name": "spreadMethod",
+ "value": ""
+ },
+ {
+ "name": "startOffset",
+ "value": ""
+ },
+ {
+ "name": "stdDeviation",
+ "value": ""
+ },
+ {
+ "name": "stitchTiles",
+ "value": ""
+ },
+ {
+ "name": "surfaceScale",
+ "value": ""
+ },
+ {
+ "name": "systemLanguage",
+ "value": ""
+ },
+ {
+ "name": "tableValues",
+ "value": ""
+ },
+ {
+ "name": "targetX",
+ "value": ""
+ },
+ {
+ "name": "targetY",
+ "value": ""
+ },
+ {
+ "name": "textLength",
+ "value": ""
+ },
+ {
+ "name": "viewBox",
+ "value": ""
+ },
+ {
+ "name": "viewTarget",
+ "value": ""
+ },
+ {
+ "name": "xChannelSelector",
+ "value": ""
+ },
+ {
+ "name": "yChannelSelector",
+ "value": ""
+ },
+ {
+ "name": "zoomAndPan",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg attributeName=\"\" attributeType=\"\" baseFrequency=\"\" baseProfile=\"\" calcMode=\"\" clipPathUnits=\"\" diffuseConstant=\"\" edgeMode=\"\" filterUnits=\"\" glyphRef=\"\" gradientTransform=\"\" gradientUnits=\"\" kernelMatrix=\"\" kernelUnitLength=\"\" keyPoints=\"\" keySplines=\"\" keyTimes=\"\" lengthAdjust=\"\" limitingConeAngle=\"\" markerHeight=\"\" markerUnits=\"\" markerWidth=\"\" maskContentUnits=\"\" maskUnits=\"\" numOctaves=\"\" pathLength=\"\" patternContentUnits=\"\" patternTransform=\"\" patternUnits=\"\" pointsAtX=\"\" pointsAtY=\"\" pointsAtZ=\"\" preserveAlpha=\"\" preserveAspectRatio=\"\" primitiveUnits=\"\" refX=\"\" refY=\"\" repeatCount=\"\" repeatDur=\"\" requiredExtensions=\"\" requiredFeatures=\"\" specularConstant=\"\" specularExponent=\"\" spreadMethod=\"\" startOffset=\"\" stdDeviation=\"\" stitchTiles=\"\" surfaceScale=\"\" systemLanguage=\"\" tableValues=\"\" targetX=\"\" targetY=\"\" textLength=\"\" viewBox=\"\" viewTarget=\"\" xChannelSelector=\"\" yChannelSelector=\"\" zoomAndPan=\"\"></svg></body></html>",
+ "noQuirksBodyHtml": "<svg attributeName=\"\" attributeType=\"\" baseFrequency=\"\" baseProfile=\"\" calcMode=\"\" clipPathUnits=\"\" diffuseConstant=\"\" edgeMode=\"\" filterUnits=\"\" glyphRef=\"\" gradientTransform=\"\" gradientUnits=\"\" kernelMatrix=\"\" kernelUnitLength=\"\" keyPoints=\"\" keySplines=\"\" keyTimes=\"\" lengthAdjust=\"\" limitingConeAngle=\"\" markerHeight=\"\" markerUnits=\"\" markerWidth=\"\" maskContentUnits=\"\" maskUnits=\"\" numOctaves=\"\" pathLength=\"\" patternContentUnits=\"\" patternTransform=\"\" patternUnits=\"\" pointsAtX=\"\" pointsAtY=\"\" pointsAtZ=\"\" preserveAlpha=\"\" preserveAspectRatio=\"\" primitiveUnits=\"\" refX=\"\" refY=\"\" repeatCount=\"\" repeatDur=\"\" requiredExtensions=\"\" requiredFeatures=\"\" specularConstant=\"\" specularExponent=\"\" spreadMethod=\"\" startOffset=\"\" stdDeviation=\"\" stitchTiles=\"\" surfaceScale=\"\" systemLanguage=\"\" tableValues=\"\" targetX=\"\" targetY=\"\" textLength=\"\" viewBox=\"\" viewTarget=\"\" xChannelSelector=\"\" yChannelSelector=\"\" zoomAndPan=\"\"></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><BODY><SVG ATTRIBUTENAME='' ATTRIBUTETYPE='' BASEFREQUENCY='' BASEPROFILE='' CALCMODE='' CLIPPATHUNITS='' DIFFUSECONSTANT='' EDGEMODE='' FILTERUNITS='' GLYPHREF='' GRADIENTTRANSFORM='' GRADIENTUNITS='' KERNELMATRIX='' KERNELUNITLENGTH='' KEYPOINTS='' KEYSPLINES='' KEYTIMES='' LENGTHADJUST='' LIMITINGCONEANGLE='' MARKERHEIGHT='' MARKERUNITS='' MARKERWIDTH='' MASKCONTENTUNITS='' MASKUNITS='' NUMOCTAVES='' PATHLENGTH='' PATTERNCONTENTUNITS='' PATTERNTRANSFORM='' PATTERNUNITS='' POINTSATX='' POINTSATY='' POINTSATZ='' PRESERVEALPHA='' PRESERVEASPECTRATIO='' PRIMITIVEUNITS='' REFX='' REFY='' REPEATCOUNT='' REPEATDUR='' REQUIREDEXTENSIONS='' REQUIREDFEATURES='' SPECULARCONSTANT='' SPECULAREXPONENT='' SPREADMETHOD='' STARTOFFSET='' STDDEVIATION='' STITCHTILES='' SURFACESCALE='' SYSTEMLANGUAGE='' TABLEVALUES='' TARGETX='' TARGETY='' TEXTLENGTH='' VIEWBOX='' VIEWTARGET='' XCHANNELSELECTOR='' YCHANNELSELECTOR='' ZOOMANDPAN=''></SVG>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "attributeName",
+ "value": ""
+ },
+ {
+ "name": "attributeType",
+ "value": ""
+ },
+ {
+ "name": "baseFrequency",
+ "value": ""
+ },
+ {
+ "name": "baseProfile",
+ "value": ""
+ },
+ {
+ "name": "calcMode",
+ "value": ""
+ },
+ {
+ "name": "clipPathUnits",
+ "value": ""
+ },
+ {
+ "name": "diffuseConstant",
+ "value": ""
+ },
+ {
+ "name": "edgeMode",
+ "value": ""
+ },
+ {
+ "name": "filterUnits",
+ "value": ""
+ },
+ {
+ "name": "glyphRef",
+ "value": ""
+ },
+ {
+ "name": "gradientTransform",
+ "value": ""
+ },
+ {
+ "name": "gradientUnits",
+ "value": ""
+ },
+ {
+ "name": "kernelMatrix",
+ "value": ""
+ },
+ {
+ "name": "kernelUnitLength",
+ "value": ""
+ },
+ {
+ "name": "keyPoints",
+ "value": ""
+ },
+ {
+ "name": "keySplines",
+ "value": ""
+ },
+ {
+ "name": "keyTimes",
+ "value": ""
+ },
+ {
+ "name": "lengthAdjust",
+ "value": ""
+ },
+ {
+ "name": "limitingConeAngle",
+ "value": ""
+ },
+ {
+ "name": "markerHeight",
+ "value": ""
+ },
+ {
+ "name": "markerUnits",
+ "value": ""
+ },
+ {
+ "name": "markerWidth",
+ "value": ""
+ },
+ {
+ "name": "maskContentUnits",
+ "value": ""
+ },
+ {
+ "name": "maskUnits",
+ "value": ""
+ },
+ {
+ "name": "numOctaves",
+ "value": ""
+ },
+ {
+ "name": "pathLength",
+ "value": ""
+ },
+ {
+ "name": "patternContentUnits",
+ "value": ""
+ },
+ {
+ "name": "patternTransform",
+ "value": ""
+ },
+ {
+ "name": "patternUnits",
+ "value": ""
+ },
+ {
+ "name": "pointsAtX",
+ "value": ""
+ },
+ {
+ "name": "pointsAtY",
+ "value": ""
+ },
+ {
+ "name": "pointsAtZ",
+ "value": ""
+ },
+ {
+ "name": "preserveAlpha",
+ "value": ""
+ },
+ {
+ "name": "preserveAspectRatio",
+ "value": ""
+ },
+ {
+ "name": "primitiveUnits",
+ "value": ""
+ },
+ {
+ "name": "refX",
+ "value": ""
+ },
+ {
+ "name": "refY",
+ "value": ""
+ },
+ {
+ "name": "repeatCount",
+ "value": ""
+ },
+ {
+ "name": "repeatDur",
+ "value": ""
+ },
+ {
+ "name": "requiredExtensions",
+ "value": ""
+ },
+ {
+ "name": "requiredFeatures",
+ "value": ""
+ },
+ {
+ "name": "specularConstant",
+ "value": ""
+ },
+ {
+ "name": "specularExponent",
+ "value": ""
+ },
+ {
+ "name": "spreadMethod",
+ "value": ""
+ },
+ {
+ "name": "startOffset",
+ "value": ""
+ },
+ {
+ "name": "stdDeviation",
+ "value": ""
+ },
+ {
+ "name": "stitchTiles",
+ "value": ""
+ },
+ {
+ "name": "surfaceScale",
+ "value": ""
+ },
+ {
+ "name": "systemLanguage",
+ "value": ""
+ },
+ {
+ "name": "tableValues",
+ "value": ""
+ },
+ {
+ "name": "targetX",
+ "value": ""
+ },
+ {
+ "name": "targetY",
+ "value": ""
+ },
+ {
+ "name": "textLength",
+ "value": ""
+ },
+ {
+ "name": "viewBox",
+ "value": ""
+ },
+ {
+ "name": "viewTarget",
+ "value": ""
+ },
+ {
+ "name": "xChannelSelector",
+ "value": ""
+ },
+ {
+ "name": "yChannelSelector",
+ "value": ""
+ },
+ {
+ "name": "zoomAndPan",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg attributeName=\"\" attributeType=\"\" baseFrequency=\"\" baseProfile=\"\" calcMode=\"\" clipPathUnits=\"\" diffuseConstant=\"\" edgeMode=\"\" filterUnits=\"\" glyphRef=\"\" gradientTransform=\"\" gradientUnits=\"\" kernelMatrix=\"\" kernelUnitLength=\"\" keyPoints=\"\" keySplines=\"\" keyTimes=\"\" lengthAdjust=\"\" limitingConeAngle=\"\" markerHeight=\"\" markerUnits=\"\" markerWidth=\"\" maskContentUnits=\"\" maskUnits=\"\" numOctaves=\"\" pathLength=\"\" patternContentUnits=\"\" patternTransform=\"\" patternUnits=\"\" pointsAtX=\"\" pointsAtY=\"\" pointsAtZ=\"\" preserveAlpha=\"\" preserveAspectRatio=\"\" primitiveUnits=\"\" refX=\"\" refY=\"\" repeatCount=\"\" repeatDur=\"\" requiredExtensions=\"\" requiredFeatures=\"\" specularConstant=\"\" specularExponent=\"\" spreadMethod=\"\" startOffset=\"\" stdDeviation=\"\" stitchTiles=\"\" surfaceScale=\"\" systemLanguage=\"\" tableValues=\"\" targetX=\"\" targetY=\"\" textLength=\"\" viewBox=\"\" viewTarget=\"\" xChannelSelector=\"\" yChannelSelector=\"\" zoomAndPan=\"\"></svg></body></html>",
+ "noQuirksBodyHtml": "<svg attributeName=\"\" attributeType=\"\" baseFrequency=\"\" baseProfile=\"\" calcMode=\"\" clipPathUnits=\"\" diffuseConstant=\"\" edgeMode=\"\" filterUnits=\"\" glyphRef=\"\" gradientTransform=\"\" gradientUnits=\"\" kernelMatrix=\"\" kernelUnitLength=\"\" keyPoints=\"\" keySplines=\"\" keyTimes=\"\" lengthAdjust=\"\" limitingConeAngle=\"\" markerHeight=\"\" markerUnits=\"\" markerWidth=\"\" maskContentUnits=\"\" maskUnits=\"\" numOctaves=\"\" pathLength=\"\" patternContentUnits=\"\" patternTransform=\"\" patternUnits=\"\" pointsAtX=\"\" pointsAtY=\"\" pointsAtZ=\"\" preserveAlpha=\"\" preserveAspectRatio=\"\" primitiveUnits=\"\" refX=\"\" refY=\"\" repeatCount=\"\" repeatDur=\"\" requiredExtensions=\"\" requiredFeatures=\"\" specularConstant=\"\" specularExponent=\"\" spreadMethod=\"\" startOffset=\"\" stdDeviation=\"\" stitchTiles=\"\" surfaceScale=\"\" systemLanguage=\"\" tableValues=\"\" targetX=\"\" targetY=\"\" textLength=\"\" viewBox=\"\" viewTarget=\"\" xChannelSelector=\"\" yChannelSelector=\"\" zoomAndPan=\"\"></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><svg attributename='' attributetype='' basefrequency='' baseprofile='' calcmode='' clippathunits='' diffuseconstant='' edgemode='' filterunits='' filterres='' glyphref='' gradienttransform='' gradientunits='' kernelmatrix='' kernelunitlength='' keypoints='' keysplines='' keytimes='' lengthadjust='' limitingconeangle='' markerheight='' markerunits='' markerwidth='' maskcontentunits='' maskunits='' numoctaves='' pathlength='' patterncontentunits='' patterntransform='' patternunits='' pointsatx='' pointsaty='' pointsatz='' preservealpha='' preserveaspectratio='' primitiveunits='' refx='' refy='' repeatcount='' repeatdur='' requiredextensions='' requiredfeatures='' specularconstant='' specularexponent='' spreadmethod='' startoffset='' stddeviation='' stitchtiles='' surfacescale='' systemlanguage='' tablevalues='' targetx='' targety='' textlength='' viewbox='' viewtarget='' xchannelselector='' ychannelselector='' zoomandpan=''></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "attributeName",
+ "value": ""
+ },
+ {
+ "name": "attributeType",
+ "value": ""
+ },
+ {
+ "name": "baseFrequency",
+ "value": ""
+ },
+ {
+ "name": "baseProfile",
+ "value": ""
+ },
+ {
+ "name": "calcMode",
+ "value": ""
+ },
+ {
+ "name": "clipPathUnits",
+ "value": ""
+ },
+ {
+ "name": "diffuseConstant",
+ "value": ""
+ },
+ {
+ "name": "edgeMode",
+ "value": ""
+ },
+ {
+ "name": "filterUnits",
+ "value": ""
+ },
+ {
+ "name": "filterres",
+ "value": ""
+ },
+ {
+ "name": "glyphRef",
+ "value": ""
+ },
+ {
+ "name": "gradientTransform",
+ "value": ""
+ },
+ {
+ "name": "gradientUnits",
+ "value": ""
+ },
+ {
+ "name": "kernelMatrix",
+ "value": ""
+ },
+ {
+ "name": "kernelUnitLength",
+ "value": ""
+ },
+ {
+ "name": "keyPoints",
+ "value": ""
+ },
+ {
+ "name": "keySplines",
+ "value": ""
+ },
+ {
+ "name": "keyTimes",
+ "value": ""
+ },
+ {
+ "name": "lengthAdjust",
+ "value": ""
+ },
+ {
+ "name": "limitingConeAngle",
+ "value": ""
+ },
+ {
+ "name": "markerHeight",
+ "value": ""
+ },
+ {
+ "name": "markerUnits",
+ "value": ""
+ },
+ {
+ "name": "markerWidth",
+ "value": ""
+ },
+ {
+ "name": "maskContentUnits",
+ "value": ""
+ },
+ {
+ "name": "maskUnits",
+ "value": ""
+ },
+ {
+ "name": "numOctaves",
+ "value": ""
+ },
+ {
+ "name": "pathLength",
+ "value": ""
+ },
+ {
+ "name": "patternContentUnits",
+ "value": ""
+ },
+ {
+ "name": "patternTransform",
+ "value": ""
+ },
+ {
+ "name": "patternUnits",
+ "value": ""
+ },
+ {
+ "name": "pointsAtX",
+ "value": ""
+ },
+ {
+ "name": "pointsAtY",
+ "value": ""
+ },
+ {
+ "name": "pointsAtZ",
+ "value": ""
+ },
+ {
+ "name": "preserveAlpha",
+ "value": ""
+ },
+ {
+ "name": "preserveAspectRatio",
+ "value": ""
+ },
+ {
+ "name": "primitiveUnits",
+ "value": ""
+ },
+ {
+ "name": "refX",
+ "value": ""
+ },
+ {
+ "name": "refY",
+ "value": ""
+ },
+ {
+ "name": "repeatCount",
+ "value": ""
+ },
+ {
+ "name": "repeatDur",
+ "value": ""
+ },
+ {
+ "name": "requiredExtensions",
+ "value": ""
+ },
+ {
+ "name": "requiredFeatures",
+ "value": ""
+ },
+ {
+ "name": "specularConstant",
+ "value": ""
+ },
+ {
+ "name": "specularExponent",
+ "value": ""
+ },
+ {
+ "name": "spreadMethod",
+ "value": ""
+ },
+ {
+ "name": "startOffset",
+ "value": ""
+ },
+ {
+ "name": "stdDeviation",
+ "value": ""
+ },
+ {
+ "name": "stitchTiles",
+ "value": ""
+ },
+ {
+ "name": "surfaceScale",
+ "value": ""
+ },
+ {
+ "name": "systemLanguage",
+ "value": ""
+ },
+ {
+ "name": "tableValues",
+ "value": ""
+ },
+ {
+ "name": "targetX",
+ "value": ""
+ },
+ {
+ "name": "targetY",
+ "value": ""
+ },
+ {
+ "name": "textLength",
+ "value": ""
+ },
+ {
+ "name": "viewBox",
+ "value": ""
+ },
+ {
+ "name": "viewTarget",
+ "value": ""
+ },
+ {
+ "name": "xChannelSelector",
+ "value": ""
+ },
+ {
+ "name": "yChannelSelector",
+ "value": ""
+ },
+ {
+ "name": "zoomAndPan",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg attributeName=\"\" attributeType=\"\" baseFrequency=\"\" baseProfile=\"\" calcMode=\"\" clipPathUnits=\"\" diffuseConstant=\"\" edgeMode=\"\" filterUnits=\"\" filterres=\"\" glyphRef=\"\" gradientTransform=\"\" gradientUnits=\"\" kernelMatrix=\"\" kernelUnitLength=\"\" keyPoints=\"\" keySplines=\"\" keyTimes=\"\" lengthAdjust=\"\" limitingConeAngle=\"\" markerHeight=\"\" markerUnits=\"\" markerWidth=\"\" maskContentUnits=\"\" maskUnits=\"\" numOctaves=\"\" pathLength=\"\" patternContentUnits=\"\" patternTransform=\"\" patternUnits=\"\" pointsAtX=\"\" pointsAtY=\"\" pointsAtZ=\"\" preserveAlpha=\"\" preserveAspectRatio=\"\" primitiveUnits=\"\" refX=\"\" refY=\"\" repeatCount=\"\" repeatDur=\"\" requiredExtensions=\"\" requiredFeatures=\"\" specularConstant=\"\" specularExponent=\"\" spreadMethod=\"\" startOffset=\"\" stdDeviation=\"\" stitchTiles=\"\" surfaceScale=\"\" systemLanguage=\"\" tableValues=\"\" targetX=\"\" targetY=\"\" textLength=\"\" viewBox=\"\" viewTarget=\"\" xChannelSelector=\"\" yChannelSelector=\"\" zoomAndPan=\"\"></svg></body></html>",
+ "noQuirksBodyHtml": "<svg attributeName=\"\" attributeType=\"\" baseFrequency=\"\" baseProfile=\"\" calcMode=\"\" clipPathUnits=\"\" diffuseConstant=\"\" edgeMode=\"\" filterUnits=\"\" filterres=\"\" glyphRef=\"\" gradientTransform=\"\" gradientUnits=\"\" kernelMatrix=\"\" kernelUnitLength=\"\" keyPoints=\"\" keySplines=\"\" keyTimes=\"\" lengthAdjust=\"\" limitingConeAngle=\"\" markerHeight=\"\" markerUnits=\"\" markerWidth=\"\" maskContentUnits=\"\" maskUnits=\"\" numOctaves=\"\" pathLength=\"\" patternContentUnits=\"\" patternTransform=\"\" patternUnits=\"\" pointsAtX=\"\" pointsAtY=\"\" pointsAtZ=\"\" preserveAlpha=\"\" preserveAspectRatio=\"\" primitiveUnits=\"\" refX=\"\" refY=\"\" repeatCount=\"\" repeatDur=\"\" requiredExtensions=\"\" requiredFeatures=\"\" specularConstant=\"\" specularExponent=\"\" spreadMethod=\"\" startOffset=\"\" stdDeviation=\"\" stitchTiles=\"\" surfaceScale=\"\" systemLanguage=\"\" tableValues=\"\" targetX=\"\" targetY=\"\" textLength=\"\" viewBox=\"\" viewTarget=\"\" xChannelSelector=\"\" yChannelSelector=\"\" zoomAndPan=\"\"></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><math attributeName='' attributeType='' baseFrequency='' baseProfile='' calcMode='' clipPathUnits='' diffuseConstant='' edgeMode='' filterUnits='' glyphRef='' gradientTransform='' gradientUnits='' kernelMatrix='' kernelUnitLength='' keyPoints='' keySplines='' keyTimes='' lengthAdjust='' limitingConeAngle='' markerHeight='' markerUnits='' markerWidth='' maskContentUnits='' maskUnits='' numOctaves='' pathLength='' patternContentUnits='' patternTransform='' patternUnits='' pointsAtX='' pointsAtY='' pointsAtZ='' preserveAlpha='' preserveAspectRatio='' primitiveUnits='' refX='' refY='' repeatCount='' repeatDur='' requiredExtensions='' requiredFeatures='' specularConstant='' specularExponent='' spreadMethod='' startOffset='' stdDeviation='' stitchTiles='' surfaceScale='' systemLanguage='' tableValues='' targetX='' targetY='' textLength='' viewBox='' viewTarget='' xChannelSelector='' yChannelSelector='' zoomAndPan=''></math>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "attributename",
+ "value": ""
+ },
+ {
+ "name": "attributetype",
+ "value": ""
+ },
+ {
+ "name": "basefrequency",
+ "value": ""
+ },
+ {
+ "name": "baseprofile",
+ "value": ""
+ },
+ {
+ "name": "calcmode",
+ "value": ""
+ },
+ {
+ "name": "clippathunits",
+ "value": ""
+ },
+ {
+ "name": "diffuseconstant",
+ "value": ""
+ },
+ {
+ "name": "edgemode",
+ "value": ""
+ },
+ {
+ "name": "filterunits",
+ "value": ""
+ },
+ {
+ "name": "glyphref",
+ "value": ""
+ },
+ {
+ "name": "gradienttransform",
+ "value": ""
+ },
+ {
+ "name": "gradientunits",
+ "value": ""
+ },
+ {
+ "name": "kernelmatrix",
+ "value": ""
+ },
+ {
+ "name": "kernelunitlength",
+ "value": ""
+ },
+ {
+ "name": "keypoints",
+ "value": ""
+ },
+ {
+ "name": "keysplines",
+ "value": ""
+ },
+ {
+ "name": "keytimes",
+ "value": ""
+ },
+ {
+ "name": "lengthadjust",
+ "value": ""
+ },
+ {
+ "name": "limitingconeangle",
+ "value": ""
+ },
+ {
+ "name": "markerheight",
+ "value": ""
+ },
+ {
+ "name": "markerunits",
+ "value": ""
+ },
+ {
+ "name": "markerwidth",
+ "value": ""
+ },
+ {
+ "name": "maskcontentunits",
+ "value": ""
+ },
+ {
+ "name": "maskunits",
+ "value": ""
+ },
+ {
+ "name": "numoctaves",
+ "value": ""
+ },
+ {
+ "name": "pathlength",
+ "value": ""
+ },
+ {
+ "name": "patterncontentunits",
+ "value": ""
+ },
+ {
+ "name": "patterntransform",
+ "value": ""
+ },
+ {
+ "name": "patternunits",
+ "value": ""
+ },
+ {
+ "name": "pointsatx",
+ "value": ""
+ },
+ {
+ "name": "pointsaty",
+ "value": ""
+ },
+ {
+ "name": "pointsatz",
+ "value": ""
+ },
+ {
+ "name": "preservealpha",
+ "value": ""
+ },
+ {
+ "name": "preserveaspectratio",
+ "value": ""
+ },
+ {
+ "name": "primitiveunits",
+ "value": ""
+ },
+ {
+ "name": "refx",
+ "value": ""
+ },
+ {
+ "name": "refy",
+ "value": ""
+ },
+ {
+ "name": "repeatcount",
+ "value": ""
+ },
+ {
+ "name": "repeatdur",
+ "value": ""
+ },
+ {
+ "name": "requiredextensions",
+ "value": ""
+ },
+ {
+ "name": "requiredfeatures",
+ "value": ""
+ },
+ {
+ "name": "specularconstant",
+ "value": ""
+ },
+ {
+ "name": "specularexponent",
+ "value": ""
+ },
+ {
+ "name": "spreadmethod",
+ "value": ""
+ },
+ {
+ "name": "startoffset",
+ "value": ""
+ },
+ {
+ "name": "stddeviation",
+ "value": ""
+ },
+ {
+ "name": "stitchtiles",
+ "value": ""
+ },
+ {
+ "name": "surfacescale",
+ "value": ""
+ },
+ {
+ "name": "systemlanguage",
+ "value": ""
+ },
+ {
+ "name": "tablevalues",
+ "value": ""
+ },
+ {
+ "name": "targetx",
+ "value": ""
+ },
+ {
+ "name": "targety",
+ "value": ""
+ },
+ {
+ "name": "textlength",
+ "value": ""
+ },
+ {
+ "name": "viewbox",
+ "value": ""
+ },
+ {
+ "name": "viewtarget",
+ "value": ""
+ },
+ {
+ "name": "xchannelselector",
+ "value": ""
+ },
+ {
+ "name": "ychannelselector",
+ "value": ""
+ },
+ {
+ "name": "zoomandpan",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math attributename=\"\" attributetype=\"\" basefrequency=\"\" baseprofile=\"\" calcmode=\"\" clippathunits=\"\" diffuseconstant=\"\" edgemode=\"\" filterunits=\"\" glyphref=\"\" gradienttransform=\"\" gradientunits=\"\" kernelmatrix=\"\" kernelunitlength=\"\" keypoints=\"\" keysplines=\"\" keytimes=\"\" lengthadjust=\"\" limitingconeangle=\"\" markerheight=\"\" markerunits=\"\" markerwidth=\"\" maskcontentunits=\"\" maskunits=\"\" numoctaves=\"\" pathlength=\"\" patterncontentunits=\"\" patterntransform=\"\" patternunits=\"\" pointsatx=\"\" pointsaty=\"\" pointsatz=\"\" preservealpha=\"\" preserveaspectratio=\"\" primitiveunits=\"\" refx=\"\" refy=\"\" repeatcount=\"\" repeatdur=\"\" requiredextensions=\"\" requiredfeatures=\"\" specularconstant=\"\" specularexponent=\"\" spreadmethod=\"\" startoffset=\"\" stddeviation=\"\" stitchtiles=\"\" surfacescale=\"\" systemlanguage=\"\" tablevalues=\"\" targetx=\"\" targety=\"\" textlength=\"\" viewbox=\"\" viewtarget=\"\" xchannelselector=\"\" ychannelselector=\"\" zoomandpan=\"\"></math></body></html>",
+ "noQuirksBodyHtml": "<math attributename=\"\" attributetype=\"\" basefrequency=\"\" baseprofile=\"\" calcmode=\"\" clippathunits=\"\" diffuseconstant=\"\" edgemode=\"\" filterunits=\"\" glyphref=\"\" gradienttransform=\"\" gradientunits=\"\" kernelmatrix=\"\" kernelunitlength=\"\" keypoints=\"\" keysplines=\"\" keytimes=\"\" lengthadjust=\"\" limitingconeangle=\"\" markerheight=\"\" markerunits=\"\" markerwidth=\"\" maskcontentunits=\"\" maskunits=\"\" numoctaves=\"\" pathlength=\"\" patterncontentunits=\"\" patterntransform=\"\" patternunits=\"\" pointsatx=\"\" pointsaty=\"\" pointsatz=\"\" preservealpha=\"\" preserveaspectratio=\"\" primitiveunits=\"\" refx=\"\" refy=\"\" repeatcount=\"\" repeatdur=\"\" requiredextensions=\"\" requiredfeatures=\"\" specularconstant=\"\" specularexponent=\"\" spreadmethod=\"\" startoffset=\"\" stddeviation=\"\" stitchtiles=\"\" surfacescale=\"\" systemlanguage=\"\" tablevalues=\"\" targetx=\"\" targety=\"\" textlength=\"\" viewbox=\"\" viewtarget=\"\" xchannelselector=\"\" ychannelselector=\"\" zoomandpan=\"\"></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><svg contentScriptType='' contentStyleType='' externalResourcesRequired='' filterRes=''></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "contentscripttype",
+ "value": ""
+ },
+ {
+ "name": "contentstyletype",
+ "value": ""
+ },
+ {
+ "name": "externalresourcesrequired",
+ "value": ""
+ },
+ {
+ "name": "filterres",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg contentscripttype=\"\" contentstyletype=\"\" externalresourcesrequired=\"\" filterres=\"\"></svg></body></html>",
+ "noQuirksBodyHtml": "<svg contentScriptType=\"\" contentStyleType=\"\" externalResourcesRequired=\"\" filterres=\"\"></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><svg CONTENTSCRIPTTYPE='' CONTENTSTYLETYPE='' EXTERNALRESOURCESREQUIRED='' FILTERRES=''></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "contentscripttype",
+ "value": ""
+ },
+ {
+ "name": "contentstyletype",
+ "value": ""
+ },
+ {
+ "name": "externalresourcesrequired",
+ "value": ""
+ },
+ {
+ "name": "filterres",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg contentscripttype=\"\" contentstyletype=\"\" externalresourcesrequired=\"\" filterres=\"\"></svg></body></html>",
+ "noQuirksBodyHtml": "<svg contentScriptType=\"\" contentStyleType=\"\" externalResourcesRequired=\"\" filterres=\"\"></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><svg contentscripttype='' contentstyletype='' externalresourcesrequired='' filterres=''></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "attrs": [
+ {
+ "name": "contentscripttype",
+ "value": ""
+ },
+ {
+ "name": "contentstyletype",
+ "value": ""
+ },
+ {
+ "name": "externalresourcesrequired",
+ "value": ""
+ },
+ {
+ "name": "filterres",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg contentscripttype=\"\" contentstyletype=\"\" externalresourcesrequired=\"\" filterres=\"\"></svg></body></html>",
+ "noQuirksBodyHtml": "<svg contentScriptType=\"\" contentStyleType=\"\" externalResourcesRequired=\"\" filterres=\"\"></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><math contentScriptType='' contentStyleType='' externalResourcesRequired='' filterRes=''></math>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "contentscripttype",
+ "value": ""
+ },
+ {
+ "name": "contentstyletype",
+ "value": ""
+ },
+ {
+ "name": "externalresourcesrequired",
+ "value": ""
+ },
+ {
+ "name": "filterres",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math contentscripttype=\"\" contentstyletype=\"\" externalresourcesrequired=\"\" filterres=\"\"></math></body></html>",
+ "noQuirksBodyHtml": "<math contentscripttype=\"\" contentstyletype=\"\" externalresourcesrequired=\"\" filterres=\"\"></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><svg><altGlyph /><altGlyphDef /><altGlyphItem /><animateColor /><animateMotion /><animateTransform /><clipPath /><feBlend /><feColorMatrix /><feComponentTransfer /><feComposite /><feConvolveMatrix /><feDiffuseLighting /><feDisplacementMap /><feDistantLight /><feFlood /><feFuncA /><feFuncB /><feFuncG /><feFuncR /><feGaussianBlur /><feImage /><feMerge /><feMergeNode /><feMorphology /><feOffset /><fePointLight /><feSpecularLighting /><feSpotLight /><feTile /><feTurbulence /><foreignObject /><glyphRef /><linearGradient /><radialGradient /><textPath /></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg altGlyph": true,
+ "svg altGlyphDef": true,
+ "svg altGlyphItem": true,
+ "svg animateColor": true,
+ "svg animateMotion": true,
+ "svg animateTransform": true,
+ "svg clipPath": true,
+ "svg feBlend": true,
+ "svg feColorMatrix": true,
+ "svg feComponentTransfer": true,
+ "svg feComposite": true,
+ "svg feConvolveMatrix": true,
+ "svg feDiffuseLighting": true,
+ "svg feDisplacementMap": true,
+ "svg feDistantLight": true,
+ "svg feFlood": true,
+ "svg feFuncA": true,
+ "svg feFuncB": true,
+ "svg feFuncG": true,
+ "svg feFuncR": true,
+ "svg feGaussianBlur": true,
+ "svg feImage": true,
+ "svg feMerge": true,
+ "svg feMergeNode": true,
+ "svg feMorphology": true,
+ "svg feOffset": true,
+ "svg fePointLight": true,
+ "svg feSpecularLighting": true,
+ "svg feSpotLight": true,
+ "svg feTile": true,
+ "svg feTurbulence": true,
+ "svg foreignObject": true,
+ "svg glyphRef": true,
+ "svg linearGradient": true,
+ "svg radialGradient": true,
+ "svg textPath": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "altGlyph",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "altGlyphDef",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "altGlyphItem",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "animateColor",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "animateMotion",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "animateTransform",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "clipPath",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feBlend",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feColorMatrix",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feComponentTransfer",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feComposite",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feConvolveMatrix",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feDiffuseLighting",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feDisplacementMap",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feDistantLight",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFlood",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFuncA",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFuncB",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFuncG",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFuncR",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feGaussianBlur",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feImage",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feMerge",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feMergeNode",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feMorphology",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feOffset",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "fePointLight",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feSpecularLighting",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feSpotLight",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feTile",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feTurbulence",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "glyphRef",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "linearGradient",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "radialGradient",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "textPath",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><altGlyph></altGlyph><altGlyphDef></altGlyphDef><altGlyphItem></altGlyphItem><animateColor></animateColor><animateMotion></animateMotion><animateTransform></animateTransform><clipPath></clipPath><feBlend></feBlend><feColorMatrix></feColorMatrix><feComponentTransfer></feComponentTransfer><feComposite></feComposite><feConvolveMatrix></feConvolveMatrix><feDiffuseLighting></feDiffuseLighting><feDisplacementMap></feDisplacementMap><feDistantLight></feDistantLight><feFlood></feFlood><feFuncA></feFuncA><feFuncB></feFuncB><feFuncG></feFuncG><feFuncR></feFuncR><feGaussianBlur></feGaussianBlur><feImage></feImage><feMerge></feMerge><feMergeNode></feMergeNode><feMorphology></feMorphology><feOffset></feOffset><fePointLight></fePointLight><feSpecularLighting></feSpecularLighting><feSpotLight></feSpotLight><feTile></feTile><feTurbulence></feTurbulence><foreignObject></foreignObject><glyphRef></glyphRef><linearGradient></linearGradient><radialGradient></radialGradient><textPath></textPath></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><altGlyph></altGlyph><altGlyphDef></altGlyphDef><altGlyphItem></altGlyphItem><animateColor></animateColor><animateMotion></animateMotion><animateTransform></animateTransform><clipPath></clipPath><feBlend></feBlend><feColorMatrix></feColorMatrix><feComponentTransfer></feComponentTransfer><feComposite></feComposite><feConvolveMatrix></feConvolveMatrix><feDiffuseLighting></feDiffuseLighting><feDisplacementMap></feDisplacementMap><feDistantLight></feDistantLight><feFlood></feFlood><feFuncA></feFuncA><feFuncB></feFuncB><feFuncG></feFuncG><feFuncR></feFuncR><feGaussianBlur></feGaussianBlur><feImage></feImage><feMerge></feMerge><feMergeNode></feMergeNode><feMorphology></feMorphology><feOffset></feOffset><fePointLight></fePointLight><feSpecularLighting></feSpecularLighting><feSpotLight></feSpotLight><feTile></feTile><feTurbulence></feTurbulence><foreignObject></foreignObject><glyphRef></glyphRef><linearGradient></linearGradient><radialGradient></radialGradient><textPath></textPath></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><svg><altglyph /><altglyphdef /><altglyphitem /><animatecolor /><animatemotion /><animatetransform /><clippath /><feblend /><fecolormatrix /><fecomponenttransfer /><fecomposite /><feconvolvematrix /><fediffuselighting /><fedisplacementmap /><fedistantlight /><feflood /><fefunca /><fefuncb /><fefuncg /><fefuncr /><fegaussianblur /><feimage /><femerge /><femergenode /><femorphology /><feoffset /><fepointlight /><fespecularlighting /><fespotlight /><fetile /><feturbulence /><foreignobject /><glyphref /><lineargradient /><radialgradient /><textpath /></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg altGlyph": true,
+ "svg altGlyphDef": true,
+ "svg altGlyphItem": true,
+ "svg animateColor": true,
+ "svg animateMotion": true,
+ "svg animateTransform": true,
+ "svg clipPath": true,
+ "svg feBlend": true,
+ "svg feColorMatrix": true,
+ "svg feComponentTransfer": true,
+ "svg feComposite": true,
+ "svg feConvolveMatrix": true,
+ "svg feDiffuseLighting": true,
+ "svg feDisplacementMap": true,
+ "svg feDistantLight": true,
+ "svg feFlood": true,
+ "svg feFuncA": true,
+ "svg feFuncB": true,
+ "svg feFuncG": true,
+ "svg feFuncR": true,
+ "svg feGaussianBlur": true,
+ "svg feImage": true,
+ "svg feMerge": true,
+ "svg feMergeNode": true,
+ "svg feMorphology": true,
+ "svg feOffset": true,
+ "svg fePointLight": true,
+ "svg feSpecularLighting": true,
+ "svg feSpotLight": true,
+ "svg feTile": true,
+ "svg feTurbulence": true,
+ "svg foreignObject": true,
+ "svg glyphRef": true,
+ "svg linearGradient": true,
+ "svg radialGradient": true,
+ "svg textPath": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "altGlyph",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "altGlyphDef",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "altGlyphItem",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "animateColor",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "animateMotion",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "animateTransform",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "clipPath",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feBlend",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feColorMatrix",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feComponentTransfer",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feComposite",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feConvolveMatrix",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feDiffuseLighting",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feDisplacementMap",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feDistantLight",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFlood",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFuncA",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFuncB",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFuncG",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFuncR",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feGaussianBlur",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feImage",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feMerge",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feMergeNode",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feMorphology",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feOffset",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "fePointLight",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feSpecularLighting",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feSpotLight",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feTile",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feTurbulence",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "glyphRef",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "linearGradient",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "radialGradient",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "textPath",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><altGlyph></altGlyph><altGlyphDef></altGlyphDef><altGlyphItem></altGlyphItem><animateColor></animateColor><animateMotion></animateMotion><animateTransform></animateTransform><clipPath></clipPath><feBlend></feBlend><feColorMatrix></feColorMatrix><feComponentTransfer></feComponentTransfer><feComposite></feComposite><feConvolveMatrix></feConvolveMatrix><feDiffuseLighting></feDiffuseLighting><feDisplacementMap></feDisplacementMap><feDistantLight></feDistantLight><feFlood></feFlood><feFuncA></feFuncA><feFuncB></feFuncB><feFuncG></feFuncG><feFuncR></feFuncR><feGaussianBlur></feGaussianBlur><feImage></feImage><feMerge></feMerge><feMergeNode></feMergeNode><feMorphology></feMorphology><feOffset></feOffset><fePointLight></fePointLight><feSpecularLighting></feSpecularLighting><feSpotLight></feSpotLight><feTile></feTile><feTurbulence></feTurbulence><foreignObject></foreignObject><glyphRef></glyphRef><linearGradient></linearGradient><radialGradient></radialGradient><textPath></textPath></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><altGlyph></altGlyph><altGlyphDef></altGlyphDef><altGlyphItem></altGlyphItem><animateColor></animateColor><animateMotion></animateMotion><animateTransform></animateTransform><clipPath></clipPath><feBlend></feBlend><feColorMatrix></feColorMatrix><feComponentTransfer></feComponentTransfer><feComposite></feComposite><feConvolveMatrix></feConvolveMatrix><feDiffuseLighting></feDiffuseLighting><feDisplacementMap></feDisplacementMap><feDistantLight></feDistantLight><feFlood></feFlood><feFuncA></feFuncA><feFuncB></feFuncB><feFuncG></feFuncG><feFuncR></feFuncR><feGaussianBlur></feGaussianBlur><feImage></feImage><feMerge></feMerge><feMergeNode></feMergeNode><feMorphology></feMorphology><feOffset></feOffset><fePointLight></fePointLight><feSpecularLighting></feSpecularLighting><feSpotLight></feSpotLight><feTile></feTile><feTurbulence></feTurbulence><foreignObject></foreignObject><glyphRef></glyphRef><linearGradient></linearGradient><radialGradient></radialGradient><textPath></textPath></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><BODY><SVG><ALTGLYPH /><ALTGLYPHDEF /><ALTGLYPHITEM /><ANIMATECOLOR /><ANIMATEMOTION /><ANIMATETRANSFORM /><CLIPPATH /><FEBLEND /><FECOLORMATRIX /><FECOMPONENTTRANSFER /><FECOMPOSITE /><FECONVOLVEMATRIX /><FEDIFFUSELIGHTING /><FEDISPLACEMENTMAP /><FEDISTANTLIGHT /><FEFLOOD /><FEFUNCA /><FEFUNCB /><FEFUNCG /><FEFUNCR /><FEGAUSSIANBLUR /><FEIMAGE /><FEMERGE /><FEMERGENODE /><FEMORPHOLOGY /><FEOFFSET /><FEPOINTLIGHT /><FESPECULARLIGHTING /><FESPOTLIGHT /><FETILE /><FETURBULENCE /><FOREIGNOBJECT /><GLYPHREF /><LINEARGRADIENT /><RADIALGRADIENT /><TEXTPATH /></SVG>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg altGlyph": true,
+ "svg altGlyphDef": true,
+ "svg altGlyphItem": true,
+ "svg animateColor": true,
+ "svg animateMotion": true,
+ "svg animateTransform": true,
+ "svg clipPath": true,
+ "svg feBlend": true,
+ "svg feColorMatrix": true,
+ "svg feComponentTransfer": true,
+ "svg feComposite": true,
+ "svg feConvolveMatrix": true,
+ "svg feDiffuseLighting": true,
+ "svg feDisplacementMap": true,
+ "svg feDistantLight": true,
+ "svg feFlood": true,
+ "svg feFuncA": true,
+ "svg feFuncB": true,
+ "svg feFuncG": true,
+ "svg feFuncR": true,
+ "svg feGaussianBlur": true,
+ "svg feImage": true,
+ "svg feMerge": true,
+ "svg feMergeNode": true,
+ "svg feMorphology": true,
+ "svg feOffset": true,
+ "svg fePointLight": true,
+ "svg feSpecularLighting": true,
+ "svg feSpotLight": true,
+ "svg feTile": true,
+ "svg feTurbulence": true,
+ "svg foreignObject": true,
+ "svg glyphRef": true,
+ "svg linearGradient": true,
+ "svg radialGradient": true,
+ "svg textPath": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "altGlyph",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "altGlyphDef",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "altGlyphItem",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "animateColor",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "animateMotion",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "animateTransform",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "clipPath",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feBlend",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feColorMatrix",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feComponentTransfer",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feComposite",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feConvolveMatrix",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feDiffuseLighting",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feDisplacementMap",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feDistantLight",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFlood",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFuncA",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFuncB",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFuncG",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feFuncR",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feGaussianBlur",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feImage",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feMerge",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feMergeNode",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feMorphology",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feOffset",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "fePointLight",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feSpecularLighting",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feSpotLight",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feTile",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "feTurbulence",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "glyphRef",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "linearGradient",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "radialGradient",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "textPath",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><altGlyph></altGlyph><altGlyphDef></altGlyphDef><altGlyphItem></altGlyphItem><animateColor></animateColor><animateMotion></animateMotion><animateTransform></animateTransform><clipPath></clipPath><feBlend></feBlend><feColorMatrix></feColorMatrix><feComponentTransfer></feComponentTransfer><feComposite></feComposite><feConvolveMatrix></feConvolveMatrix><feDiffuseLighting></feDiffuseLighting><feDisplacementMap></feDisplacementMap><feDistantLight></feDistantLight><feFlood></feFlood><feFuncA></feFuncA><feFuncB></feFuncB><feFuncG></feFuncG><feFuncR></feFuncR><feGaussianBlur></feGaussianBlur><feImage></feImage><feMerge></feMerge><feMergeNode></feMergeNode><feMorphology></feMorphology><feOffset></feOffset><fePointLight></fePointLight><feSpecularLighting></feSpecularLighting><feSpotLight></feSpotLight><feTile></feTile><feTurbulence></feTurbulence><foreignObject></foreignObject><glyphRef></glyphRef><linearGradient></linearGradient><radialGradient></radialGradient><textPath></textPath></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><altGlyph></altGlyph><altGlyphDef></altGlyphDef><altGlyphItem></altGlyphItem><animateColor></animateColor><animateMotion></animateMotion><animateTransform></animateTransform><clipPath></clipPath><feBlend></feBlend><feColorMatrix></feColorMatrix><feComponentTransfer></feComponentTransfer><feComposite></feComposite><feConvolveMatrix></feConvolveMatrix><feDiffuseLighting></feDiffuseLighting><feDisplacementMap></feDisplacementMap><feDistantLight></feDistantLight><feFlood></feFlood><feFuncA></feFuncA><feFuncB></feFuncB><feFuncG></feFuncG><feFuncR></feFuncR><feGaussianBlur></feGaussianBlur><feImage></feImage><feMerge></feMerge><feMergeNode></feMergeNode><feMorphology></feMorphology><feOffset></feOffset><fePointLight></fePointLight><feSpecularLighting></feSpecularLighting><feSpotLight></feSpotLight><feTile></feTile><feTurbulence></feTurbulence><foreignObject></foreignObject><glyphRef></glyphRef><linearGradient></linearGradient><radialGradient></radialGradient><textPath></textPath></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><math><altGlyph /><altGlyphDef /><altGlyphItem /><animateColor /><animateMotion /><animateTransform /><clipPath /><feBlend /><feColorMatrix /><feComponentTransfer /><feComposite /><feConvolveMatrix /><feDiffuseLighting /><feDisplacementMap /><feDistantLight /><feFlood /><feFuncA /><feFuncB /><feFuncG /><feFuncR /><feGaussianBlur /><feImage /><feMerge /><feMergeNode /><feMorphology /><feOffset /><fePointLight /><feSpecularLighting /><feSpotLight /><feTile /><feTurbulence /><foreignObject /><glyphRef /><linearGradient /><radialGradient /><textPath /></math>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math altglyph": true,
+ "math altglyphdef": true,
+ "math altglyphitem": true,
+ "math animatecolor": true,
+ "math animatemotion": true,
+ "math animatetransform": true,
+ "math clippath": true,
+ "math feblend": true,
+ "math fecolormatrix": true,
+ "math fecomponenttransfer": true,
+ "math fecomposite": true,
+ "math feconvolvematrix": true,
+ "math fediffuselighting": true,
+ "math fedisplacementmap": true,
+ "math fedistantlight": true,
+ "math feflood": true,
+ "math fefunca": true,
+ "math fefuncb": true,
+ "math fefuncg": true,
+ "math fefuncr": true,
+ "math fegaussianblur": true,
+ "math feimage": true,
+ "math femerge": true,
+ "math femergenode": true,
+ "math femorphology": true,
+ "math feoffset": true,
+ "math fepointlight": true,
+ "math fespecularlighting": true,
+ "math fespotlight": true,
+ "math fetile": true,
+ "math feturbulence": true,
+ "math foreignobject": true,
+ "math glyphref": true,
+ "math lineargradient": true,
+ "math radialgradient": true,
+ "math textpath": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "altglyph",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "altglyphdef",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "altglyphitem",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "animatecolor",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "animatemotion",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "animatetransform",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "clippath",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "feblend",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fecolormatrix",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fecomponenttransfer",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fecomposite",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "feconvolvematrix",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fediffuselighting",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fedisplacementmap",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fedistantlight",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "feflood",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fefunca",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fefuncb",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fefuncg",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fefuncr",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fegaussianblur",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "feimage",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "femerge",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "femergenode",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "femorphology",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "feoffset",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fepointlight",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fespecularlighting",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fespotlight",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "fetile",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "feturbulence",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "foreignobject",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "glyphref",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "lineargradient",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "radialgradient",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "textpath",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><altglyph></altglyph><altglyphdef></altglyphdef><altglyphitem></altglyphitem><animatecolor></animatecolor><animatemotion></animatemotion><animatetransform></animatetransform><clippath></clippath><feblend></feblend><fecolormatrix></fecolormatrix><fecomponenttransfer></fecomponenttransfer><fecomposite></fecomposite><feconvolvematrix></feconvolvematrix><fediffuselighting></fediffuselighting><fedisplacementmap></fedisplacementmap><fedistantlight></fedistantlight><feflood></feflood><fefunca></fefunca><fefuncb></fefuncb><fefuncg></fefuncg><fefuncr></fefuncr><fegaussianblur></fegaussianblur><feimage></feimage><femerge></femerge><femergenode></femergenode><femorphology></femorphology><feoffset></feoffset><fepointlight></fepointlight><fespecularlighting></fespecularlighting><fespotlight></fespotlight><fetile></fetile><feturbulence></feturbulence><foreignobject></foreignobject><glyphref></glyphref><lineargradient></lineargradient><radialgradient></radialgradient><textpath></textpath></math></body></html>",
+ "noQuirksBodyHtml": "<math><altglyph></altglyph><altglyphdef></altglyphdef><altglyphitem></altglyphitem><animatecolor></animatecolor><animatemotion></animatemotion><animatetransform></animatetransform><clippath></clippath><feblend></feblend><fecolormatrix></fecolormatrix><fecomponenttransfer></fecomponenttransfer><fecomposite></fecomposite><feconvolvematrix></feconvolvematrix><fediffuselighting></fediffuselighting><fedisplacementmap></fedisplacementmap><fedistantlight></fedistantlight><feflood></feflood><fefunca></fefunca><fefuncb></fefuncb><fefuncg></fefuncg><fefuncr></fefuncr><fegaussianblur></fegaussianblur><feimage></feimage><femerge></femerge><femergenode></femergenode><femorphology></femorphology><feoffset></feoffset><fepointlight></fepointlight><fespecularlighting></fespecularlighting><fespotlight></fespotlight><fetile></fetile><feturbulence></feturbulence><foreignobject></foreignobject><glyphref></glyphref><lineargradient></lineargradient><radialgradient></radialgradient><textpath></textpath></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><svg><solidColor /></svg>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg solidcolor": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "solidcolor",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><solidcolor></solidcolor></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><solidcolor></solidcolor></svg>"
+ }
+ }
+ ],
+ "tests12.dat": [
+ {
+ "data": "<!DOCTYPE html><body><p>foo<math><mtext><i>baz</i></mtext><annotation-xml><svg><desc><b>eggs</b></desc><g><foreignObject><P>spam<TABLE><tr><td><img></td></table></foreignObject></g><g>quux</g></svg></annotation-xml></math>bar",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "math math": true,
+ "math mtext": true,
+ "i": true,
+ "math annotation-xml": true,
+ "svg svg": true,
+ "svg desc": true,
+ "b": true,
+ "svg g": true,
+ "svg foreignObject": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "img": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "foo"
+ },
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "desc",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "eggs"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "spam"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "img"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "quux"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p>foo<math><mtext><i>baz</i></mtext><annotation-xml><svg><desc><b>eggs</b></desc><g><foreignObject><p>spam</p><table><tbody><tr><td><img></td></tr></tbody></table></foreignObject></g><g>quux</g></svg></annotation-xml></math>bar</p></body></html>",
+ "noQuirksBodyHtml": "<p>foo<math><mtext><i>baz</i></mtext><annotation-xml><svg><desc><b>eggs</b></desc><g><foreignObject><p>spam</p><table><tbody><tr><td><img></td></tr></tbody></table></foreignObject></g><g>quux</g></svg></annotation-xml></math>bar</p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body>foo<math><mtext><i>baz</i></mtext><annotation-xml><svg><desc><b>eggs</b></desc><g><foreignObject><P>spam<TABLE><tr><td><img></td></table></foreignObject></g><g>quux</g></svg></annotation-xml></math>bar",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mtext": true,
+ "i": true,
+ "math annotation-xml": true,
+ "svg svg": true,
+ "svg desc": true,
+ "b": true,
+ "svg g": true,
+ "svg foreignObject": true,
+ "p": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "img": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "foo"
+ },
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "desc",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "eggs"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "spam"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "img"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "g",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "quux"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>foo<math><mtext><i>baz</i></mtext><annotation-xml><svg><desc><b>eggs</b></desc><g><foreignObject><p>spam</p><table><tbody><tr><td><img></td></tr></tbody></table></foreignObject></g><g>quux</g></svg></annotation-xml></math>bar</body></html>",
+ "noQuirksBodyHtml": "foo<math><mtext><i>baz</i></mtext><annotation-xml><svg><desc><b>eggs</b></desc><g><foreignObject><p>spam</p><table><tbody><tr><td><img></td></tr></tbody></table></foreignObject></g><g>quux</g></svg></annotation-xml></math>bar"
+ }
+ }
+ ],
+ "tests14.dat": [
+ {
+ "data": "<!DOCTYPE html><html><body><xyz:abc></xyz:abc>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "xyz:abc": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "xyz:abc"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><xyz:abc></xyz:abc></body></html>",
+ "noQuirksBodyHtml": "<xyz:abc></xyz:abc>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><body><xyz:abc></xyz:abc><span></span>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "xyz:abc": true,
+ "span": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "xyz:abc"
+ },
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><xyz:abc></xyz:abc><span></span></body></html>",
+ "noQuirksBodyHtml": "<xyz:abc></xyz:abc><span></span>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><html abc:def=gh><xyz:abc></xyz:abc>",
+ "errors": [
+ "(1,38): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "xyz:abc": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "abc:def",
+ "value": "gh"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "xyz:abc"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html abc:def=\"gh\"><head></head><body><xyz:abc></xyz:abc></body></html>",
+ "noQuirksBodyHtml": "<xyz:abc></xyz:abc>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html xml:lang=bar><html xml:lang=foo>",
+ "errors": [
+ "(1,53): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "xml:lang",
+ "value": "bar"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html xml:lang=\"bar\"><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html 123=456>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "123",
+ "value": "456"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html 123=\"456\"><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html 123=456><html 789=012>",
+ "errors": [
+ "(1,43): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "123",
+ "value": "456"
+ },
+ {
+ "name": "789",
+ "value": "012"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html 123=\"456\" 789=\"012\"><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><body 789=012>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "attrs": [
+ {
+ "name": "789",
+ "value": "012"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body 789=\"012\"></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ }
+ ],
+ "tests15.dat": [
+ {
+ "data": "<!DOCTYPE html><p><b><i><u></p> <p>X",
+ "errors": [
+ "(1,31): unexpected-end-tag",
+ "(1,36): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "b": true,
+ "i": true,
+ "u": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "u"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "u",
+ "children": [
+ {
+ "text": " "
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><b><i><u></u></i></b></p><b><i><u> <p>X</p></u></i></b></body></html>",
+ "noQuirksBodyHtml": "<p><b><i><u></u></i></b></p><b><i><u> <p>X</p></u></i></b>"
+ }
+ },
+ {
+ "data": "<p><b><i><u></p>\n<p>X",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,16): unexpected-end-tag",
+ "(2,4): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "b": true,
+ "i": true,
+ "u": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "u"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "u",
+ "children": [
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><b><i><u></u></i></b></p><b><i><u>\n<p>X</p></u></i></b></body></html>",
+ "noQuirksBodyHtml": "<p><b><i><u></u></i></b></p><b><i><u>\n<p>X</p></u></i></b>"
+ }
+ },
+ {
+ "data": "<!doctype html></html> <head>",
+ "errors": [
+ "(1,29): expected-eof-but-got-start-tag",
+ "(1,29): unexpected-start-tag-ignored"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": " "
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body> </body></html>",
+ "noQuirksBodyHtml": " "
+ }
+ },
+ {
+ "data": "<!doctype html></body><meta>",
+ "errors": [
+ "(1,28): unexpected-start-tag-after-body"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "meta": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "meta"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><meta></body></html>",
+ "noQuirksBodyHtml": "<meta>"
+ }
+ },
+ {
+ "data": "<html></html><!-- foo -->",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ },
+ {
+ "comment": " foo "
+ }
+ ],
+ "html": "<html><head></head><body></body></html><!-- foo -->",
+ "noQuirksBodyHtml": "<!-- foo -->"
+ }
+ },
+ {
+ "data": "<!doctype html></body><title>X</title>",
+ "errors": [
+ "(1,29): unexpected-start-tag-after-body"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "title": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><title>X</title></body></html>",
+ "noQuirksBodyHtml": "<title>X</title>"
+ }
+ },
+ {
+ "data": "<!doctype html><table> X<meta></table>",
+ "errors": [
+ "(1,23): foster-parenting-character",
+ "(1,24): foster-parenting-character",
+ "(1,30): foster-parenting-start-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "meta": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": " X"
+ },
+ {
+ "tag": "meta"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body> X<meta><table></table></body></html>",
+ "noQuirksBodyHtml": " X<meta><table></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table> x</table>",
+ "errors": [
+ "(1,23): foster-parenting-character",
+ "(1,24): foster-parenting-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": " x"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body> x<table></table></body></html>",
+ "noQuirksBodyHtml": " x<table></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table> x </table>",
+ "errors": [
+ "(1,23): foster-parenting-character",
+ "(1,24): foster-parenting-character",
+ "(1,25): foster-parenting-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": " x "
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body> x <table></table></body></html>",
+ "noQuirksBodyHtml": " x <table></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><tr> x</table>",
+ "errors": [
+ "(1,27): foster-parenting-character",
+ "(1,28): foster-parenting-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": " x"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body> x<table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": " x<table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table>X<style> <tr>x </style> </table>",
+ "errors": [
+ "(1,23): foster-parenting-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "style": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": " <tr>x ",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": " "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>X<table><style> <tr>x </style> </table></body></html>",
+ "noQuirksBodyHtml": "X<table><style> <tr>x </style> </table>"
+ }
+ },
+ {
+ "data": "<!doctype html><div><table><a>foo</a> <tr><td>bar</td> </tr></table></div>",
+ "errors": [
+ "(1,30): foster-parenting-start-tag",
+ "(1,31): foster-parenting-character",
+ "(1,32): foster-parenting-character",
+ "(1,33): foster-parenting-character",
+ "(1,37): foster-parenting-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "a": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "text": " "
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ },
+ {
+ "text": " "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><div><a>foo</a><table> <tbody><tr><td>bar</td> </tr></tbody></table></div></body></html>",
+ "noQuirksBodyHtml": "<div><a>foo</a><table> <tbody><tr><td>bar</td> </tr></tbody></table></div>"
+ }
+ },
+ {
+ "data": "<frame></frame></frame><frameset><frame><frameset><frame></frameset><noframes></frameset><noframes>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,7): unexpected-start-tag-ignored",
+ "(1,15): unexpected-end-tag",
+ "(1,23): unexpected-end-tag",
+ "(1,33): unexpected-start-tag",
+ "(1,99): expected-named-closing-tag-but-got-eof",
+ "(1,99): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "frame": true,
+ "noframes": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "tag": "frame"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "tag": "frame"
+ }
+ ]
+ },
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "</frameset><noframes>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset><frame><frameset><frame></frameset><noframes></frameset><noframes></noframes></frameset></html>",
+ "noQuirksBodyHtml": "<noframes></frameset><noframes></noframes>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><object></html>",
+ "errors": [
+ "(1,30): expected-body-in-scope",
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "object": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "object"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><object></object></body></html>",
+ "noQuirksBodyHtml": "<object></object>"
+ }
+ }
+ ],
+ "tests16.dat": [
+ {
+ "data": "<!doctype html><script>",
+ "errors": [
+ "(1,23): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script>a",
+ "errors": [
+ "(1,24): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script>a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script>a</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><",
+ "errors": [
+ "(1,24): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></",
+ "errors": [
+ "(1,25): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></S",
+ "errors": [
+ "(1,26): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</S",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></S</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></S</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></SC",
+ "errors": [
+ "(1,27): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</SC",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></SC</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></SC</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></SCR",
+ "errors": [
+ "(1,28): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</SCR",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></SCR</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></SCR</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></SCRI",
+ "errors": [
+ "(1,29): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</SCRI",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></SCRI</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></SCRI</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></SCRIP",
+ "errors": [
+ "(1,30): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</SCRIP",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></SCRIP</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></SCRIP</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></SCRIPT",
+ "errors": [
+ "(1,31): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</SCRIPT",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></SCRIPT</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></SCRIPT</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></SCRIPT ",
+ "errors": [
+ "(1,32): expected-attribute-name-but-got-eof",
+ "(1,32): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></s",
+ "errors": [
+ "(1,26): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</s",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></s</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></s</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></sc",
+ "errors": [
+ "(1,27): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</sc",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></sc</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></sc</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></scr",
+ "errors": [
+ "(1,28): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</scr",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></scr</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></scr</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></scri",
+ "errors": [
+ "(1,29): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</scri",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></scri</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></scri</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></scrip",
+ "errors": [
+ "(1,30): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</scrip",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></scrip</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></scrip</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></script",
+ "errors": [
+ "(1,31): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</script",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></script</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></script</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script></script ",
+ "errors": [
+ "(1,32): expected-attribute-name-but-got-eof",
+ "(1,32): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!",
+ "errors": [
+ "(1,25): expected-script-data-but-got-eof",
+ "(1,25): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!a",
+ "errors": [
+ "(1,26): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!a</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!-",
+ "errors": [
+ "(1,26): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!-",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!-</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!-</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!-a",
+ "errors": [
+ "(1,27): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!-a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!-a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!-a</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--",
+ "errors": [
+ "(1,27): expected-named-closing-tag-but-got-eof",
+ "(1,27): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--a",
+ "errors": [
+ "(1,28): expected-named-closing-tag-but-got-eof",
+ "(1,28): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--a</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<",
+ "errors": [
+ "(1,28): expected-named-closing-tag-but-got-eof",
+ "(1,28): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<a",
+ "errors": [
+ "(1,29): expected-named-closing-tag-but-got-eof",
+ "(1,29): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<a</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--</",
+ "errors": [
+ "(1,29): expected-named-closing-tag-but-got-eof",
+ "(1,29): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--</",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--</</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--</</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--</script",
+ "errors": [
+ "(1,35): expected-named-closing-tag-but-got-eof",
+ "(1,35): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--</script",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--</script</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--</script</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--</script ",
+ "errors": [
+ "(1,36): expected-attribute-name-but-got-eof",
+ "(1,36): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<s",
+ "errors": [
+ "(1,29): expected-named-closing-tag-but-got-eof",
+ "(1,29): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<s",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<s</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<s</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script",
+ "errors": [
+ "(1,34): expected-named-closing-tag-but-got-eof",
+ "(1,34): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script ",
+ "errors": [
+ "(1,35): eof-in-script-in-script",
+ "(1,35): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script <",
+ "errors": [
+ "(1,36): eof-in-script-in-script",
+ "(1,36): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script <",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script <</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script <</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script <a",
+ "errors": [
+ "(1,37): eof-in-script-in-script",
+ "(1,37): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script <a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script <a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script <a</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </",
+ "errors": [
+ "(1,37): eof-in-script-in-script",
+ "(1,37): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </s",
+ "errors": [
+ "(1,38): eof-in-script-in-script",
+ "(1,38): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </s",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </s</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </s</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </script",
+ "errors": [
+ "(1,43): eof-in-script-in-script",
+ "(1,43): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </script</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </scripta",
+ "errors": [
+ "(1,44): eof-in-script-in-script",
+ "(1,44): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </scripta",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </scripta</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </scripta</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </script ",
+ "errors": [
+ "(1,44): expected-named-closing-tag-but-got-eof",
+ "(1,44): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </script </script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script </script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </script>",
+ "errors": [
+ "(1,44): expected-named-closing-tag-but-got-eof",
+ "(1,44): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </script/",
+ "errors": [
+ "(1,44): expected-named-closing-tag-but-got-eof",
+ "(1,44): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script/",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </script/</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script/</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </script <",
+ "errors": [
+ "(1,45): expected-named-closing-tag-but-got-eof",
+ "(1,45): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script <",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </script <</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script <</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </script <a",
+ "errors": [
+ "(1,46): expected-named-closing-tag-but-got-eof",
+ "(1,46): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script <a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </script <a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script <a</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </script </",
+ "errors": [
+ "(1,46): expected-named-closing-tag-but-got-eof",
+ "(1,46): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script </",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </script </</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script </</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </script </script",
+ "errors": [
+ "(1,52): expected-named-closing-tag-but-got-eof",
+ "(1,52): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script </script",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </script </script</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script </script</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </script </script ",
+ "errors": [
+ "(1,53): expected-attribute-name-but-got-eof",
+ "(1,53): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </script </script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script </script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </script </script/",
+ "errors": [
+ "(1,53): unexpected-EOF-after-solidus-in-tag",
+ "(1,53): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </script </script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script </script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script </script </script>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script </script </script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script </script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script -",
+ "errors": [
+ "(1,36): eof-in-script-in-script",
+ "(1,36): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script -</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script -</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script -a",
+ "errors": [
+ "(1,37): eof-in-script-in-script",
+ "(1,37): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script -a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script -a</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script -<",
+ "errors": [
+ "(1,37): eof-in-script-in-script",
+ "(1,37): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -<",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script -<</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script -<</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script --",
+ "errors": [
+ "(1,37): eof-in-script-in-script",
+ "(1,37): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script --",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script --</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script --a",
+ "errors": [
+ "(1,38): eof-in-script-in-script",
+ "(1,38): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script --a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script --a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --a</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script --<",
+ "errors": [
+ "(1,38): eof-in-script-in-script",
+ "(1,38): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script --<",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script --<</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --<</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script -->",
+ "errors": [
+ "(1,38): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script --></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script --><",
+ "errors": [
+ "(1,39): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script --><",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script --><</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --><</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script --></",
+ "errors": [
+ "(1,40): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script --></",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script --></</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --></</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script --></script",
+ "errors": [
+ "(1,46): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script --></script",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script --></script</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --></script</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script --></script ",
+ "errors": [
+ "(1,47): expected-attribute-name-but-got-eof",
+ "(1,47): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script --></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script --></script/",
+ "errors": [
+ "(1,47): unexpected-EOF-after-solidus-in-tag",
+ "(1,47): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script --></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script --></script>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script --></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script><\\/script>--></script>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script><\\/script>-->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script><\\/script>--></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script><\\/script>--></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script></scr'+'ipt>--></script>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></scr'+'ipt>-->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script></scr'+'ipt>--></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></scr'+'ipt>--></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script></script><script></script></script>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></script><script></script>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script></script><script></script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></script><script></script></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script></script><script></script>--><!--</script>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></script><script></script>--><!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script></script><script></script>--><!--</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></script><script></script>--><!--</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script></script><script></script>-- ></script>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></script><script></script>-- >",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script></script><script></script>-- ></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></script><script></script>-- ></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script></script><script></script>- -></script>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></script><script></script>- ->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script></script><script></script>- -></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></script><script></script>- -></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script></script><script></script>- - ></script>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></script><script></script>- - >",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script></script><script></script>- - ></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></script><script></script>- - ></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script></script><script></script>-></script>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></script><script></script>->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script></script><script></script>-></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></script><script></script>-></script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script>--!></script>X",
+ "errors": [
+ "(1,49): expected-named-closing-tag-but-got-eof",
+ "(1,49): unexpected-EOF-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script>--!></script>X",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script>--!></script>X</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script>--!></script>X</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<scr'+'ipt></script>--></script>",
+ "errors": [
+ "(1,59): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<scr'+'ipt>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<scr'+'ipt></script></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<script><!--<scr'+'ipt></script>--&gt;"
+ }
+ },
+ {
+ "data": "<!doctype html><script><!--<script></scr'+'ipt></script>X",
+ "errors": [
+ "(1,57): expected-named-closing-tag-but-got-eof",
+ "(1,57): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></scr'+'ipt></script>X",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script><!--<script></scr'+'ipt></script>X</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></scr'+'ipt></script>X</script>"
+ }
+ },
+ {
+ "data": "<!doctype html><style><!--<style></style>--></style>",
+ "errors": [
+ "(1,52): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--<style>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><style><!--<style></style></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<style><!--<style></style>--&gt;"
+ }
+ },
+ {
+ "data": "<!doctype html><style><!--</style>X",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><style><!--</style></head><body>X</body></html>",
+ "noQuirksBodyHtml": "<style><!--</style>X"
+ }
+ },
+ {
+ "data": "<!doctype html><style><!--...</style>...--></style>",
+ "errors": [
+ "(1,51): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--...",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "...-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><style><!--...</style></head><body>...--&gt;</body></html>",
+ "noQuirksBodyHtml": "<style><!--...</style>...--&gt;"
+ }
+ },
+ {
+ "data": "<!doctype html><style><!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style></style>X",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><style><!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style></style></head><body>X</body></html>",
+ "noQuirksBodyHtml": "<style><!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style></style>X"
+ }
+ },
+ {
+ "data": "<!doctype html><style><!--...<style><!--...--!></style>--></style>",
+ "errors": [
+ "(1,66): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--...<style><!--...--!>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><style><!--...<style><!--...--!></style></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<style><!--...<style><!--...--!></style>--&gt;"
+ }
+ },
+ {
+ "data": "<!doctype html><style><!--...</style><!-- --><style>@import ...</style>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--...",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "comment": " "
+ },
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "@import ...",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><style><!--...</style><!-- --><style>@import ...</style></head><body></body></html>",
+ "noQuirksBodyHtml": "<style><!--...</style><!-- --><style>@import ...</style>"
+ }
+ },
+ {
+ "data": "<!doctype html><style>...<style><!--...</style><!-- --></style>",
+ "errors": [
+ "(1,63): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "...<style><!--...",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "comment": " "
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><style>...<style><!--...</style><!-- --></head><body></body></html>",
+ "noQuirksBodyHtml": "<style>...<style><!--...</style><!-- -->"
+ }
+ },
+ {
+ "data": "<!doctype html><style>...<!--[if IE]><style>...</style>X",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "...<!--[if IE]><style>...",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><style>...<!--[if IE]><style>...</style></head><body>X</body></html>",
+ "noQuirksBodyHtml": "<style>...<!--[if IE]><style>...</style>X"
+ }
+ },
+ {
+ "data": "<!doctype html><title><!--<title></title>--></title>",
+ "errors": [
+ "(1,52): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "doctype": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "<!--<title>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><title>&lt;!--&lt;title&gt;</title></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<title>&lt;!--&lt;title&gt;</title>--&gt;"
+ }
+ },
+ {
+ "data": "<!doctype html><title>&lt;/title></title>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "doctype": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "</title>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><title>&lt;/title&gt;</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>&lt;/title&gt;</title>"
+ }
+ },
+ {
+ "data": "<!doctype html><title>foo/title><link></head><body>X",
+ "errors": [
+ "(1,52): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "doctype": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "foo/title><link></head><body>X",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><title>foo/title&gt;&lt;link&gt;&lt;/head&gt;&lt;body&gt;X</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>foo/title&gt;&lt;link&gt;&lt;/head&gt;&lt;body&gt;X</title>"
+ }
+ },
+ {
+ "data": "<!doctype html><noscript><!--<noscript></noscript>--></noscript>",
+ "errors": [
+ "(1,64): unexpected-end-tag"
+ ],
+ "script": "on",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "text": "<!--<noscript>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><noscript><!--<noscript></noscript></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<noscript><!--<noscript></noscript>--></noscript>"
+ }
+ },
+ {
+ "data": "<!doctype html><noscript><!--<noscript></noscript>--></noscript>",
+ "errors": [],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "comment": "<noscript></noscript>"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><noscript><!--<noscript></noscript>--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><!--<noscript></noscript>--></noscript>"
+ }
+ },
+ {
+ "data": "<!doctype html><noscript><!--</noscript>X<noscript>--></noscript>",
+ "errors": [],
+ "script": "on",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "text": "<!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ },
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "text": "-->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><noscript><!--</noscript></head><body>X<noscript>--></noscript></body></html>",
+ "noQuirksBodyHtml": "<noscript><!--</noscript>X<noscript>--></noscript>"
+ }
+ },
+ {
+ "data": "<!doctype html><noscript><!--</noscript>X<noscript>--></noscript>",
+ "errors": [],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "comment": "</noscript>X<noscript>"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><noscript><!--</noscript>X<noscript>--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><!--</noscript>X<noscript>--></noscript>"
+ }
+ },
+ {
+ "data": "<!doctype html><noscript><iframe></noscript>X",
+ "errors": [],
+ "script": "on",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "text": "<iframe>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><noscript><iframe></noscript></head><body>X</body></html>",
+ "noQuirksBodyHtml": "<noscript><iframe></noscript>X</iframe></noscript>"
+ }
+ },
+ {
+ "data": "<!doctype html><noscript><iframe></noscript>X",
+ "errors": [
+ " * (1,34) unexpected token in head noscript",
+ " * (1,46) unexpected EOF"
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true,
+ "iframe": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript"
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "iframe",
+ "children": [
+ {
+ "text": "</noscript>X",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><noscript></noscript></head><body><iframe></noscript>X</iframe></body></html>",
+ "noQuirksBodyHtml": "<noscript><iframe></noscript>X</iframe></noscript>"
+ }
+ },
+ {
+ "data": "<!doctype html><noframes><!--<noframes></noframes>--></noframes>",
+ "errors": [
+ "(1,64): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noframes": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "<!--<noframes>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><noframes><!--<noframes></noframes></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<noframes><!--<noframes></noframes>--&gt;"
+ }
+ },
+ {
+ "data": "<!doctype html><noframes><body><script><!--...</script></body></noframes></html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noframes": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "<body><script><!--...</script></body>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><noframes><body><script><!--...</script></body></noframes></head><body></body></html>",
+ "noQuirksBodyHtml": "<noframes><body><script><!--...</script></body></noframes>"
+ }
+ },
+ {
+ "data": "<!doctype html><textarea><!--<textarea></textarea>--></textarea>",
+ "errors": [
+ "(1,64): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "doctype": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "<!--<textarea>",
+ "escaped": true
+ }
+ ]
+ },
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><textarea>&lt;!--&lt;textarea&gt;</textarea>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<textarea>&lt;!--&lt;textarea&gt;</textarea>--&gt;"
+ }
+ },
+ {
+ "data": "<!doctype html><textarea>&lt;/textarea></textarea>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "doctype": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "</textarea>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><textarea>&lt;/textarea&gt;</textarea></body></html>",
+ "noQuirksBodyHtml": "<textarea>&lt;/textarea&gt;</textarea>"
+ }
+ },
+ {
+ "data": "<!doctype html><textarea>&lt;</textarea>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "doctype": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "<",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><textarea>&lt;</textarea></body></html>",
+ "noQuirksBodyHtml": "<textarea>&lt;</textarea>"
+ }
+ },
+ {
+ "data": "<!doctype html><textarea>a&lt;b</textarea>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "doctype": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "a<b",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><textarea>a&lt;b</textarea></body></html>",
+ "noQuirksBodyHtml": "<textarea>a&lt;b</textarea>"
+ }
+ },
+ {
+ "data": "<!doctype html><iframe><!--<iframe></iframe>--></iframe>",
+ "errors": [
+ "(1,56): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "iframe": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "iframe",
+ "children": [
+ {
+ "text": "<!--<iframe>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><iframe><!--<iframe></iframe>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<iframe><!--<iframe></iframe>--&gt;"
+ }
+ },
+ {
+ "data": "<!doctype html><iframe>...<!--X->...<!--/X->...</iframe>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "iframe": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "iframe",
+ "children": [
+ {
+ "text": "...<!--X->...<!--/X->...",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><iframe>...<!--X->...<!--/X->...</iframe></body></html>",
+ "noQuirksBodyHtml": "<iframe>...<!--X->...<!--/X->...</iframe>"
+ }
+ },
+ {
+ "data": "<!doctype html><xmp><!--<xmp></xmp>--></xmp>",
+ "errors": [
+ "(1,44): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "xmp": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "xmp",
+ "children": [
+ {
+ "text": "<!--<xmp>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><xmp><!--<xmp></xmp>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<xmp><!--<xmp></xmp>--&gt;"
+ }
+ },
+ {
+ "data": "<!doctype html><noembed><!--<noembed></noembed>--></noembed>",
+ "errors": [
+ "(1,60): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "noembed": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "noembed",
+ "children": [
+ {
+ "text": "<!--<noembed>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><noembed><!--<noembed></noembed>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<noembed><!--<noembed></noembed>--&gt;"
+ }
+ },
+ {
+ "data": "<script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,8): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></script>"
+ }
+ },
+ {
+ "data": "<script>a",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,9): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script>a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script>a</script>"
+ }
+ },
+ {
+ "data": "<script><",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,9): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><</script>"
+ }
+ },
+ {
+ "data": "<script></",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,10): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></</script>"
+ }
+ },
+ {
+ "data": "<script></S",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,11): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</S",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></S</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></S</script>"
+ }
+ },
+ {
+ "data": "<script></SC",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,12): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</SC",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></SC</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></SC</script>"
+ }
+ },
+ {
+ "data": "<script></SCR",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,13): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</SCR",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></SCR</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></SCR</script>"
+ }
+ },
+ {
+ "data": "<script></SCRI",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,14): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</SCRI",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></SCRI</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></SCRI</script>"
+ }
+ },
+ {
+ "data": "<script></SCRIP",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,15): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</SCRIP",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></SCRIP</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></SCRIP</script>"
+ }
+ },
+ {
+ "data": "<script></SCRIPT",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,16): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</SCRIPT",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></SCRIPT</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></SCRIPT</script>"
+ }
+ },
+ {
+ "data": "<script></SCRIPT ",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,17): expected-attribute-name-but-got-eof",
+ "(1,17): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></script>"
+ }
+ },
+ {
+ "data": "<script></s",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,11): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</s",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></s</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></s</script>"
+ }
+ },
+ {
+ "data": "<script></sc",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,12): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</sc",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></sc</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></sc</script>"
+ }
+ },
+ {
+ "data": "<script></scr",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,13): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</scr",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></scr</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></scr</script>"
+ }
+ },
+ {
+ "data": "<script></scri",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,14): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</scri",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></scri</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></scri</script>"
+ }
+ },
+ {
+ "data": "<script></scrip",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,15): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</scrip",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></scrip</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></scrip</script>"
+ }
+ },
+ {
+ "data": "<script></script",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,16): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</script",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></script</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></script</script>"
+ }
+ },
+ {
+ "data": "<script></script ",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,17): expected-attribute-name-but-got-eof",
+ "(1,17): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></script>"
+ }
+ },
+ {
+ "data": "<script><!",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,10): expected-script-data-but-got-eof",
+ "(1,10): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!</script>"
+ }
+ },
+ {
+ "data": "<script><!a",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,11): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!a</script>"
+ }
+ },
+ {
+ "data": "<script><!-",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,11): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!-",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!-</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!-</script>"
+ }
+ },
+ {
+ "data": "<script><!-a",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,12): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!-a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!-a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!-a</script>"
+ }
+ },
+ {
+ "data": "<script><!--",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,12): expected-named-closing-tag-but-got-eof",
+ "(1,12): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--</script>"
+ }
+ },
+ {
+ "data": "<script><!--a",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,13): expected-named-closing-tag-but-got-eof",
+ "(1,13): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--a</script>"
+ }
+ },
+ {
+ "data": "<script><!--<",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,13): expected-named-closing-tag-but-got-eof",
+ "(1,13): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<</script>"
+ }
+ },
+ {
+ "data": "<script><!--<a",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,14): expected-named-closing-tag-but-got-eof",
+ "(1,14): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<a</script>"
+ }
+ },
+ {
+ "data": "<script><!--</",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,14): expected-named-closing-tag-but-got-eof",
+ "(1,14): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--</",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--</</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--</</script>"
+ }
+ },
+ {
+ "data": "<script><!--</script",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,20): expected-named-closing-tag-but-got-eof",
+ "(1,20): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--</script",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--</script</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--</script</script>"
+ }
+ },
+ {
+ "data": "<script><!--</script ",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,21): expected-attribute-name-but-got-eof",
+ "(1,21): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--</script>"
+ }
+ },
+ {
+ "data": "<script><!--<s",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,14): expected-named-closing-tag-but-got-eof",
+ "(1,14): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<s",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<s</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<s</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,19): expected-named-closing-tag-but-got-eof",
+ "(1,19): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script ",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,20): eof-in-script-in-script",
+ "(1,20): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script>"
+ }
+ },
+ {
+ "data": "<script><!--<script <",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,21): eof-in-script-in-script",
+ "(1,21): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script <",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script <</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script <</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script <a",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,22): eof-in-script-in-script",
+ "(1,22): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script <a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script <a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script <a</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,22): eof-in-script-in-script",
+ "(1,22): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </s",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,23): eof-in-script-in-script",
+ "(1,23): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </s",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </s</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </s</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </script",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,28): eof-in-script-in-script",
+ "(1,28): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </script</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </scripta",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,29): eof-in-script-in-script",
+ "(1,29): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </scripta",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </scripta</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </scripta</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </script ",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,29): expected-named-closing-tag-but-got-eof",
+ "(1,29): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </script </script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script </script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,29): expected-named-closing-tag-but-got-eof",
+ "(1,29): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script></script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </script/",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,29): expected-named-closing-tag-but-got-eof",
+ "(1,29): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script/",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </script/</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script/</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </script <",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,30): expected-named-closing-tag-but-got-eof",
+ "(1,30): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script <",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </script <</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script <</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </script <a",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,31): expected-named-closing-tag-but-got-eof",
+ "(1,31): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script <a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </script <a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script <a</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </script </",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,31): expected-named-closing-tag-but-got-eof",
+ "(1,31): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script </",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </script </</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script </</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </script </script",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,37): expected-named-closing-tag-but-got-eof",
+ "(1,37): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script </script",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </script </script</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script </script</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </script </script ",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,38): expected-attribute-name-but-got-eof",
+ "(1,38): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </script </script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script </script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </script </script/",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,38): unexpected-EOF-after-solidus-in-tag",
+ "(1,38): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </script </script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script </script>"
+ }
+ },
+ {
+ "data": "<script><!--<script </script </script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script </script ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script </script </script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script </script </script>"
+ }
+ },
+ {
+ "data": "<script><!--<script -",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,21): eof-in-script-in-script",
+ "(1,21): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script -</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script -</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script -a",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,22): eof-in-script-in-script",
+ "(1,22): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script -a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script -a</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script --",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,22): eof-in-script-in-script",
+ "(1,22): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script --",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script --</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script --a",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,23): eof-in-script-in-script",
+ "(1,23): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script --a",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script --a</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --a</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script -->",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,23): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script --></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --></script>"
+ }
+ },
+ {
+ "data": "<script><!--<script --><",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,24): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script --><",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script --><</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --><</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script --></",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,25): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script --></",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script --></</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --></</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script --></script",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,31): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script --></script",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script --></script</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --></script</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script --></script ",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,32): expected-attribute-name-but-got-eof",
+ "(1,32): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script --></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --></script>"
+ }
+ },
+ {
+ "data": "<script><!--<script --></script/",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,32): unexpected-EOF-after-solidus-in-tag",
+ "(1,32): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script --></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --></script>"
+ }
+ },
+ {
+ "data": "<script><!--<script --></script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script -->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script --></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script --></script>"
+ }
+ },
+ {
+ "data": "<script><!--<script><\\/script>--></script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script><\\/script>-->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script><\\/script>--></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script><\\/script>--></script>"
+ }
+ },
+ {
+ "data": "<script><!--<script></scr'+'ipt>--></script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></scr'+'ipt>-->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script></scr'+'ipt>--></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></scr'+'ipt>--></script>"
+ }
+ },
+ {
+ "data": "<script><!--<script></script><script></script></script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></script><script></script>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script></script><script></script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></script><script></script></script>"
+ }
+ },
+ {
+ "data": "<script><!--<script></script><script></script>--><!--</script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></script><script></script>--><!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script></script><script></script>--><!--</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></script><script></script>--><!--</script>"
+ }
+ },
+ {
+ "data": "<script><!--<script></script><script></script>-- ></script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></script><script></script>-- >",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script></script><script></script>-- ></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></script><script></script>-- ></script>"
+ }
+ },
+ {
+ "data": "<script><!--<script></script><script></script>- -></script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></script><script></script>- ->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script></script><script></script>- -></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></script><script></script>- -></script>"
+ }
+ },
+ {
+ "data": "<script><!--<script></script><script></script>- - ></script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></script><script></script>- - >",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script></script><script></script>- - ></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></script><script></script>- - ></script>"
+ }
+ },
+ {
+ "data": "<script><!--<script></script><script></script>-></script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></script><script></script>->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script></script><script></script>-></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></script><script></script>-></script>"
+ }
+ },
+ {
+ "data": "<script><!--<script>--!></script>X",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,34): expected-named-closing-tag-but-got-eof",
+ "(1,34): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script>--!></script>X",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script>--!></script>X</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script>--!></script>X</script>"
+ }
+ },
+ {
+ "data": "<script><!--<scr'+'ipt></script>--></script>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,44): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<scr'+'ipt>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<scr'+'ipt></script></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<script><!--<scr'+'ipt></script>--&gt;"
+ }
+ },
+ {
+ "data": "<script><!--<script></scr'+'ipt></script>X",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,42): expected-named-closing-tag-but-got-eof",
+ "(1,42): unexpected-eof-in-text-mode"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "<!--<script></scr'+'ipt></script>X",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script><!--<script></scr'+'ipt></script>X</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script><!--<script></scr'+'ipt></script>X</script>"
+ }
+ },
+ {
+ "data": "<style><!--<style></style>--></style>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,37): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--<style>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style><!--<style></style></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<style><!--<style></style>--&gt;"
+ }
+ },
+ {
+ "data": "<style><!--</style>X",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style><!--</style></head><body>X</body></html>",
+ "noQuirksBodyHtml": "<style><!--</style>X"
+ }
+ },
+ {
+ "data": "<style><!--...</style>...--></style>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,36): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--...",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "...-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style><!--...</style></head><body>...--&gt;</body></html>",
+ "noQuirksBodyHtml": "<style><!--...</style>...--&gt;"
+ }
+ },
+ {
+ "data": "<style><!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style></style>X",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style><!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style></style></head><body>X</body></html>",
+ "noQuirksBodyHtml": "<style><!--<br><html xmlns:v=\"urn:schemas-microsoft-com:vml\"><!--[if !mso]><style></style>X"
+ }
+ },
+ {
+ "data": "<style><!--...<style><!--...--!></style>--></style>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,51): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--...<style><!--...--!>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style><!--...<style><!--...--!></style></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<style><!--...<style><!--...--!></style>--&gt;"
+ }
+ },
+ {
+ "data": "<style><!--...</style><!-- --><style>@import ...</style>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "<!--...",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "comment": " "
+ },
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "@import ...",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style><!--...</style><!-- --><style>@import ...</style></head><body></body></html>",
+ "noQuirksBodyHtml": "<style><!--...</style><!-- --><style>@import ...</style>"
+ }
+ },
+ {
+ "data": "<style>...<style><!--...</style><!-- --></style>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,48): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "...<style><!--...",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "comment": " "
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style>...<style><!--...</style><!-- --></head><body></body></html>",
+ "noQuirksBodyHtml": "<style>...<style><!--...</style><!-- -->"
+ }
+ },
+ {
+ "data": "<style>...<!--[if IE]><style>...</style>X",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "...<!--[if IE]><style>...",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style>...<!--[if IE]><style>...</style></head><body>X</body></html>",
+ "noQuirksBodyHtml": "<style>...<!--[if IE]><style>...</style>X"
+ }
+ },
+ {
+ "data": "<title><!--<title></title>--></title>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,37): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "<!--<title>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><title>&lt;!--&lt;title&gt;</title></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<title>&lt;!--&lt;title&gt;</title>--&gt;"
+ }
+ },
+ {
+ "data": "<title>&lt;/title></title>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "</title>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><title>&lt;/title&gt;</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>&lt;/title&gt;</title>"
+ }
+ },
+ {
+ "data": "<title>foo/title><link></head><body>X",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,37): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "foo/title><link></head><body>X",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><title>foo/title&gt;&lt;link&gt;&lt;/head&gt;&lt;body&gt;X</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>foo/title&gt;&lt;link&gt;&lt;/head&gt;&lt;body&gt;X</title>"
+ }
+ },
+ {
+ "data": "<noscript><!--<noscript></noscript>--></noscript>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,49): unexpected-end-tag"
+ ],
+ "script": "on",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "text": "<!--<noscript>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><!--<noscript></noscript></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<noscript><!--<noscript></noscript>--></noscript>"
+ }
+ },
+ {
+ "data": "<noscript><!--<noscript></noscript>--></noscript>",
+ "errors": [
+ " * (1,11) missing DOCTYPE"
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "comment": "<noscript></noscript>"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><!--<noscript></noscript>--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><!--<noscript></noscript>--></noscript>"
+ }
+ },
+ {
+ "data": "<noscript><!--</noscript>X<noscript>--></noscript>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag"
+ ],
+ "script": "on",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "text": "<!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ },
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "text": "-->",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><!--</noscript></head><body>X<noscript>--></noscript></body></html>",
+ "noQuirksBodyHtml": "<noscript><!--</noscript>X<noscript>--></noscript>"
+ }
+ },
+ {
+ "data": "<noscript><!--</noscript>X<noscript>--></noscript>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag"
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "comment": "</noscript>X<noscript>"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><!--</noscript>X<noscript>--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><!--</noscript>X<noscript>--></noscript>"
+ }
+ },
+ {
+ "data": "<noscript><iframe></noscript>X",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag"
+ ],
+ "script": "on",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "text": "<iframe>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><iframe></noscript></head><body>X</body></html>",
+ "noQuirksBodyHtml": "<noscript><iframe></noscript>X</iframe></noscript>"
+ }
+ },
+ {
+ "data": "<noscript><iframe></noscript>X",
+ "errors": [
+ " * (1,11) missing DOCTYPE",
+ " * (1,19) unexpected token in head noscript",
+ " * (1,31) unexpected EOF"
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true,
+ "iframe": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript"
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "iframe",
+ "children": [
+ {
+ "text": "</noscript>X",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript></noscript></head><body><iframe></noscript>X</iframe></body></html>",
+ "noQuirksBodyHtml": "<noscript><iframe></noscript>X</iframe></noscript>"
+ }
+ },
+ {
+ "data": "<noframes><!--<noframes></noframes>--></noframes>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,49): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noframes": true,
+ "body": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "<!--<noframes>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noframes><!--<noframes></noframes></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<noframes><!--<noframes></noframes>--&gt;"
+ }
+ },
+ {
+ "data": "<noframes><body><script><!--...</script></body></noframes></html>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noframes": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "<body><script><!--...</script></body>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noframes><body><script><!--...</script></body></noframes></head><body></body></html>",
+ "noQuirksBodyHtml": "<noframes><body><script><!--...</script></body></noframes>"
+ }
+ },
+ {
+ "data": "<textarea><!--<textarea></textarea>--></textarea>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,49): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "<!--<textarea>",
+ "escaped": true
+ }
+ ]
+ },
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><textarea>&lt;!--&lt;textarea&gt;</textarea>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<textarea>&lt;!--&lt;textarea&gt;</textarea>--&gt;"
+ }
+ },
+ {
+ "data": "<textarea>&lt;/textarea></textarea>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "</textarea>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><textarea>&lt;/textarea&gt;</textarea></body></html>",
+ "noQuirksBodyHtml": "<textarea>&lt;/textarea&gt;</textarea>"
+ }
+ },
+ {
+ "data": "<iframe><!--<iframe></iframe>--></iframe>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,41): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "iframe": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "iframe",
+ "children": [
+ {
+ "text": "<!--<iframe>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><iframe><!--<iframe></iframe>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<iframe><!--<iframe></iframe>--&gt;"
+ }
+ },
+ {
+ "data": "<iframe>...<!--X->...<!--/X->...</iframe>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "iframe": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "iframe",
+ "children": [
+ {
+ "text": "...<!--X->...<!--/X->...",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><iframe>...<!--X->...<!--/X->...</iframe></body></html>",
+ "noQuirksBodyHtml": "<iframe>...<!--X->...<!--/X->...</iframe>"
+ }
+ },
+ {
+ "data": "<xmp><!--<xmp></xmp>--></xmp>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,29): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "xmp": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "xmp",
+ "children": [
+ {
+ "text": "<!--<xmp>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><xmp><!--<xmp></xmp>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<xmp><!--<xmp></xmp>--&gt;"
+ }
+ },
+ {
+ "data": "<noembed><!--<noembed></noembed>--></noembed>",
+ "errors": [
+ "(1,9): expected-doctype-but-got-start-tag",
+ "(1,45): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "noembed": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "noembed",
+ "children": [
+ {
+ "text": "<!--<noembed>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><noembed><!--<noembed></noembed>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<noembed><!--<noembed></noembed>--&gt;"
+ }
+ },
+ {
+ "data": "<!doctype html><table>\n",
+ "errors": [
+ "(2,0): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "text": "\n"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table>\n</table></body></html>",
+ "noQuirksBodyHtml": "<table>\n</table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><td><span><font></span><span>",
+ "errors": [
+ "(1,26): unexpected-cell-in-table-body",
+ "(1,45): unexpected-end-tag",
+ "(1,51): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "span": true,
+ "font": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "font"
+ }
+ ]
+ },
+ {
+ "tag": "font",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><span><font></font></span><font><span></span></font></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><span><font></font></span><font><span></span></font></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><form><table></form><form></table></form>",
+ "errors": [
+ "(1,35): unexpected-end-tag-implies-table-voodoo",
+ "(1,35): unexpected-end-tag",
+ "(1,41): unexpected-form-in-table",
+ "(1,56): unexpected-end-tag",
+ "(1,56): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "form": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "form",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "form"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><form><table><form></form></table></form></body></html>",
+ "noQuirksBodyHtml": "<form><table><form></form></table></form>"
+ }
+ }
+ ],
+ "tests17.dat": [
+ {
+ "data": "<!doctype html><table><tbody><select><tr>",
+ "errors": [
+ "(1,37): unexpected-start-tag-implies-table-voodoo",
+ "(1,41): unexpected-table-element-start-tag-in-select-in-table",
+ "(1,41): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select><table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<select></select><table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><tr><select><td>",
+ "errors": [
+ "(1,34): unexpected-start-tag-implies-table-voodoo",
+ "(1,38): unexpected-table-element-start-tag-in-select-in-table",
+ "(1,38): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select><table><tbody><tr><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<select></select><table><tbody><tr><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><tr><td><select><td>",
+ "errors": [
+ "(1,42): unexpected-table-element-start-tag-in-select-in-table",
+ "(1,42): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><select></select></td><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><select></select></td><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><tr><th><select><td>",
+ "errors": [
+ "(1,42): unexpected-table-element-start-tag-in-select-in-table",
+ "(1,42): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "th": true,
+ "select": true,
+ "td": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "th",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><th><select></select></th><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><th><select></select></th><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><caption><select><tr>",
+ "errors": [
+ "(1,43): unexpected-table-element-start-tag-in-select-in-table",
+ "(1,43): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "select": true,
+ "tbody": true,
+ "tr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><caption><select></select></caption><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><caption><select></select></caption><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><select><tr>",
+ "errors": [
+ "(1,27): unexpected-start-tag-in-select",
+ "(1,27): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<!doctype html><select><td>",
+ "errors": [
+ "(1,27): unexpected-start-tag-in-select",
+ "(1,27): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<!doctype html><select><th>",
+ "errors": [
+ "(1,27): unexpected-start-tag-in-select",
+ "(1,27): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<!doctype html><select><tbody>",
+ "errors": [
+ "(1,30): unexpected-start-tag-in-select",
+ "(1,30): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<!doctype html><select><thead>",
+ "errors": [
+ "(1,30): unexpected-start-tag-in-select",
+ "(1,30): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<!doctype html><select><tfoot>",
+ "errors": [
+ "(1,30): unexpected-start-tag-in-select",
+ "(1,30): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<!doctype html><select><caption>",
+ "errors": [
+ "(1,32): unexpected-start-tag-in-select",
+ "(1,32): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><tr></table>a",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr></tr></tbody></table>a</body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr></tr></tbody></table>a"
+ }
+ }
+ ],
+ "tests18.dat": [
+ {
+ "data": "<!doctype html><plaintext></plaintext>",
+ "errors": [
+ "(1,38): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "plaintext": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "text": "</plaintext>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><plaintext></plaintext></plaintext></body></html>",
+ "noQuirksBodyHtml": "<plaintext></plaintext></plaintext>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><plaintext></plaintext>",
+ "errors": [
+ "(1,33): foster-parenting-start-tag",
+ "(1,34): foster-parenting-character",
+ "(1,35): foster-parenting-character",
+ "(1,36): foster-parenting-character",
+ "(1,37): foster-parenting-character",
+ "(1,38): foster-parenting-character",
+ "(1,39): foster-parenting-character",
+ "(1,40): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,42): foster-parenting-character",
+ "(1,43): foster-parenting-character",
+ "(1,44): foster-parenting-character",
+ "(1,45): foster-parenting-character",
+ "(1,45): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "plaintext": true,
+ "table": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "text": "</plaintext>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><plaintext></plaintext></plaintext><table></table></body></html>",
+ "noQuirksBodyHtml": "<plaintext></plaintext></plaintext><table></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><tbody><plaintext></plaintext>",
+ "errors": [
+ "(1,40): foster-parenting-start-tag",
+ "(1,41): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,52): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "plaintext": true,
+ "table": true,
+ "tbody": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "text": "</plaintext>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><plaintext></plaintext></plaintext><table><tbody></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<plaintext></plaintext></plaintext><table><tbody></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><tbody><tr><plaintext></plaintext>",
+ "errors": [
+ "(1,44): foster-parenting-start-tag",
+ "(1,45): foster-parenting-character",
+ "(1,46): foster-parenting-character",
+ "(1,47): foster-parenting-character",
+ "(1,48): foster-parenting-character",
+ "(1,49): foster-parenting-character",
+ "(1,50): foster-parenting-character",
+ "(1,51): foster-parenting-character",
+ "(1,52): foster-parenting-character",
+ "(1,53): foster-parenting-character",
+ "(1,54): foster-parenting-character",
+ "(1,55): foster-parenting-character",
+ "(1,56): foster-parenting-character",
+ "(1,56): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "plaintext": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "text": "</plaintext>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><plaintext></plaintext></plaintext><table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<plaintext></plaintext></plaintext><table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><td><plaintext></plaintext>",
+ "errors": [
+ "(1,26): unexpected-cell-in-table-body",
+ "(1,49): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "plaintext": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "text": "</plaintext>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><plaintext></plaintext></plaintext></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><plaintext></plaintext></plaintext></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><caption><plaintext></plaintext>",
+ "errors": [
+ "(1,54): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "plaintext": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "text": "</plaintext>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><caption><plaintext></plaintext></plaintext></caption></table></body></html>",
+ "noQuirksBodyHtml": "<table><caption><plaintext></plaintext></plaintext></caption></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><tr><style></script></style>abc",
+ "errors": [
+ "(1,51): foster-parenting-character",
+ "(1,52): foster-parenting-character",
+ "(1,53): foster-parenting-character",
+ "(1,53): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "style": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "abc"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "</script>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>abc<table><tbody><tr><style></script></style></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "abc<table><tbody><tr><style></script></style></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><tr><script></style></script>abc",
+ "errors": [
+ "(1,52): foster-parenting-character",
+ "(1,53): foster-parenting-character",
+ "(1,54): foster-parenting-character",
+ "(1,54): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "script": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "abc"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</style>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>abc<table><tbody><tr><script></style></script></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "abc<table><tbody><tr><script></style></script></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><caption><style></script></style>abc",
+ "errors": [
+ "(1,58): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "style": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "</script>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "abc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><caption><style></script></style>abc</caption></table></body></html>",
+ "noQuirksBodyHtml": "<table><caption><style></script></style>abc</caption></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><td><style></script></style>abc",
+ "errors": [
+ "(1,26): unexpected-cell-in-table-body",
+ "(1,53): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "style": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "</script>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "abc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><style></script></style>abc</td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><style></script></style>abc</td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><select><script></style></script>abc",
+ "errors": [
+ "(1,51): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "script": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</style>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "abc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select><script></style></script>abc</select></body></html>",
+ "noQuirksBodyHtml": "<select><script></style></script>abc</select>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><select><script></style></script>abc",
+ "errors": [
+ "(1,30): unexpected-start-tag-implies-table-voodoo",
+ "(1,58): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "script": true,
+ "table": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</style>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "abc"
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select><script></style></script>abc</select><table></table></body></html>",
+ "noQuirksBodyHtml": "<select><script></style></script>abc</select><table></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><tr><select><script></style></script>abc",
+ "errors": [
+ "(1,34): unexpected-start-tag-implies-table-voodoo",
+ "(1,62): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "script": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</style>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "abc"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select><script></style></script>abc</select><table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<select><script></style></script>abc</select><table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><frameset></frameset><noframes>abc",
+ "errors": [
+ "(1,49): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "noframes": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ },
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "abc",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset><noframes>abc</noframes></html>",
+ "noQuirksBodyHtml": "<noframes>abc</noframes>"
+ }
+ },
+ {
+ "data": "<!doctype html><frameset></frameset><noframes>abc</noframes><!--abc-->",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "noframes": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ },
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "abc",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "comment": "abc"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset><noframes>abc</noframes><!--abc--></html>",
+ "noQuirksBodyHtml": "<noframes>abc</noframes><!--abc-->"
+ }
+ },
+ {
+ "data": "<!doctype html><frameset></frameset></html><noframes>abc",
+ "errors": [
+ "(1,56): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "noframes": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ },
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "abc",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset><noframes>abc</noframes></html>",
+ "noQuirksBodyHtml": "<noframes>abc</noframes>"
+ }
+ },
+ {
+ "data": "<!doctype html><frameset></frameset></html><noframes>abc</noframes><!--abc-->",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "noframes": true
+ },
+ "doctype": true,
+ "no_escape": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ },
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "abc",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "abc"
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset><noframes>abc</noframes></html><!--abc-->",
+ "noQuirksBodyHtml": "<noframes>abc</noframes><!--abc-->"
+ }
+ },
+ {
+ "data": "<!doctype html><table><tr></tbody><tfoot>",
+ "errors": [
+ "(1,41): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "tfoot": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ },
+ {
+ "tag": "tfoot"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr></tr></tbody><tfoot></tfoot></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr></tr></tbody><tfoot></tfoot></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><td><svg></svg>abc<td>",
+ "errors": [
+ "(1,26): unexpected-cell-in-table-body",
+ "(1,44): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "text": "abc"
+ }
+ ]
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><svg></svg>abc</td><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><svg></svg>abc</td><td></td></tr></tbody></table>"
+ }
+ }
+ ],
+ "tests19.dat": [
+ {
+ "data": "<!doctype html><math><mn DefinitionUrl=\"foo\">",
+ "errors": [
+ "(1,45): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mn": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mn",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "definitionURL",
+ "value": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mn definitionURL=\"foo\"></mn></math></body></html>",
+ "noQuirksBodyHtml": "<math><mn definitionURL=\"foo\"></mn></math>"
+ }
+ },
+ {
+ "data": "<!doctype html><html></p><!--foo-->",
+ "errors": [
+ "(1,25): end-tag-after-implied-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "comment": "foo"
+ },
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><!--foo--><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<p></p><!--foo-->"
+ }
+ },
+ {
+ "data": "<!doctype html><head></head></p><!--foo-->",
+ "errors": [
+ "(1,32): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "comment": "foo"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><!--foo--><body></body></html>",
+ "noQuirksBodyHtml": "<p></p><!--foo-->"
+ }
+ },
+ {
+ "data": "<!doctype html><body><p><pre>",
+ "errors": [
+ "(1,29): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "pre": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "pre"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p></p><pre></pre></body></html>",
+ "noQuirksBodyHtml": "<p></p><pre></pre>"
+ }
+ },
+ {
+ "data": "<!doctype html><body><p><listing>",
+ "errors": [
+ "(1,33): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "listing": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "listing"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p></p><listing></listing></body></html>",
+ "noQuirksBodyHtml": "<p></p><listing></listing>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><plaintext>",
+ "errors": [
+ "(1,29): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "plaintext": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "plaintext"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p></p><plaintext></plaintext></body></html>",
+ "noQuirksBodyHtml": "<p></p><plaintext></plaintext>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><h1>",
+ "errors": [
+ "(1,22): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "h1": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "h1"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p></p><h1></h1></body></html>",
+ "noQuirksBodyHtml": "<p></p><h1></h1>"
+ }
+ },
+ {
+ "data": "<!doctype html><isindex type=\"hidden\">",
+ "errors": [
+ "(1,38): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "isindex": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "isindex",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "hidden"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><isindex type=\"hidden\"></isindex></body></html>",
+ "noQuirksBodyHtml": "<isindex type=\"hidden\"></isindex>"
+ }
+ },
+ {
+ "data": "<!doctype html><ruby><p><rp>",
+ "errors": [
+ "(1,28): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "p": true,
+ "rp": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "rp"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><ruby><p></p><rp></rp></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby><p></p><rp></rp></ruby>"
+ }
+ },
+ {
+ "data": "<!doctype html><ruby><div><span><rp>",
+ "errors": [
+ "(1,36): XXX-undefined-error",
+ "(1,36): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "div": true,
+ "span": true,
+ "rp": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "rp"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><ruby><div><span><rp></rp></span></div></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby><div><span><rp></rp></span></div></ruby>"
+ }
+ },
+ {
+ "data": "<!doctype html><ruby><div><p><rp>",
+ "errors": [
+ "(1,33): XXX-undefined-error",
+ "(1,33): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "div": true,
+ "p": true,
+ "rp": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "rp"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><ruby><div><p></p><rp></rp></div></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby><div><p></p><rp></rp></div></ruby>"
+ }
+ },
+ {
+ "data": "<!doctype html><ruby><p><rt>",
+ "errors": [
+ "(1,28): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "p": true,
+ "rt": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "rt"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><ruby><p></p><rt></rt></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby><p></p><rt></rt></ruby>"
+ }
+ },
+ {
+ "data": "<!doctype html><ruby><div><span><rt>",
+ "errors": [
+ "(1,36): XXX-undefined-error",
+ "(1,36): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "div": true,
+ "span": true,
+ "rt": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "rt"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><ruby><div><span><rt></rt></span></div></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby><div><span><rt></rt></span></div></ruby>"
+ }
+ },
+ {
+ "data": "<!doctype html><ruby><div><p><rt>",
+ "errors": [
+ "(1,33): XXX-undefined-error",
+ "(1,33): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "div": true,
+ "p": true,
+ "rt": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "rt"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><ruby><div><p></p><rt></rt></div></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby><div><p></p><rt></rt></div></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rb>b<rt></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rb": true,
+ "rt": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rb",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rt"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rb>b</rb><rt></rt></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rb>b</rb><rt></rt></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rp>b<rt></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rp": true,
+ "rt": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rp",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rt"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rp>b</rp><rt></rt></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rp>b</rp><rt></rt></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rt>b<rt></ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rt": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rt",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "tag": "rt"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rt>b</rt><rt></rt></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rt>b</rt><rt></rt></ruby>"
+ }
+ },
+ {
+ "data": "<html><ruby>a<rtc>b<rt>c<rb>d</ruby></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "rtc": true,
+ "rt": true,
+ "rb": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "rtc",
+ "children": [
+ {
+ "text": "b"
+ },
+ {
+ "tag": "rt",
+ "children": [
+ {
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "rb",
+ "children": [
+ {
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby>a<rtc>b<rt>c</rt></rtc><rb>d</rb></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby>a<rtc>b<rt>c</rt></rtc><rb>d</rb></ruby>"
+ }
+ },
+ {
+ "data": "<!doctype html><math/><foo>",
+ "errors": [
+ "(1,27): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "foo": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math></math><foo></foo></body></html>",
+ "noQuirksBodyHtml": "<math></math><foo></foo>"
+ }
+ },
+ {
+ "data": "<!doctype html><svg/><foo>",
+ "errors": [
+ "(1,26): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "foo": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg></svg><foo></foo></body></html>",
+ "noQuirksBodyHtml": "<svg></svg><foo></foo>"
+ }
+ },
+ {
+ "data": "<!doctype html><div></body><!--foo-->",
+ "errors": [
+ "(1,27): expected-one-end-tag-but-got-another"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ },
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><div></div></body><!--foo--></html>",
+ "noQuirksBodyHtml": "<div><!--foo--></div>"
+ }
+ },
+ {
+ "data": "<!doctype html><h1><div><h3><span></h1>foo",
+ "errors": [
+ "(1,39): end-tag-too-early",
+ "(1,42): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "h1": true,
+ "div": true,
+ "h3": true,
+ "span": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "h1",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "h3",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ },
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><h1><div><h3><span></span></h3>foo</div></h1></body></html>",
+ "noQuirksBodyHtml": "<h1><div><h3><span></span></h3>foo</div></h1>"
+ }
+ },
+ {
+ "data": "<!doctype html><p></h3>foo",
+ "errors": [
+ "(1,23): end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p>foo</p></body></html>",
+ "noQuirksBodyHtml": "<p>foo</p>"
+ }
+ },
+ {
+ "data": "<!doctype html><h3><li>abc</h2>foo",
+ "errors": [
+ "(1,31): end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "h3": true,
+ "li": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "h3",
+ "children": [
+ {
+ "tag": "li",
+ "children": [
+ {
+ "text": "abc"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><h3><li>abc</li></h3>foo</body></html>",
+ "noQuirksBodyHtml": "<h3><li>abc</li></h3>foo"
+ }
+ },
+ {
+ "data": "<!doctype html><table>abc<!--foo-->",
+ "errors": [
+ "(1,23): foster-parenting-character",
+ "(1,24): foster-parenting-character",
+ "(1,25): foster-parenting-character",
+ "(1,35): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "abc"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>abc<table><!--foo--></table></body></html>",
+ "noQuirksBodyHtml": "abc<table><!--foo--></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table> <!--foo-->",
+ "errors": [
+ "(1,34): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "text": " "
+ },
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table> <!--foo--></table></body></html>",
+ "noQuirksBodyHtml": "<table> <!--foo--></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table> b <!--foo-->",
+ "errors": [
+ "(1,23): foster-parenting-character",
+ "(1,24): foster-parenting-character",
+ "(1,25): foster-parenting-character",
+ "(1,35): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": " b "
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body> b <table><!--foo--></table></body></html>",
+ "noQuirksBodyHtml": " b <table><!--foo--></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><select><option><option>",
+ "errors": [
+ "(1,39): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option"
+ },
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select><option></option><option></option></select></body></html>",
+ "noQuirksBodyHtml": "<select><option></option><option></option></select>"
+ }
+ },
+ {
+ "data": "<!doctype html><select><option></optgroup>",
+ "errors": [
+ "(1,42): unexpected-end-tag-in-select",
+ "(1,42): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select><option></option></select></body></html>",
+ "noQuirksBodyHtml": "<select><option></option></select>"
+ }
+ },
+ {
+ "data": "<!doctype html><select><option></optgroup>",
+ "errors": [
+ "(1,42): unexpected-end-tag-in-select",
+ "(1,42): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select><option></option></select></body></html>",
+ "noQuirksBodyHtml": "<select><option></option></select>"
+ }
+ },
+ {
+ "data": "<!doctype html><dd><optgroup><dd>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "dd": true,
+ "optgroup": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "dd",
+ "children": [
+ {
+ "tag": "optgroup"
+ }
+ ]
+ },
+ {
+ "tag": "dd"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><dd><optgroup></optgroup></dd><dd></dd></body></html>",
+ "noQuirksBodyHtml": "<dd><optgroup></optgroup></dd><dd></dd>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><math><mi><p><h1>",
+ "errors": [
+ "(1,35): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "math math": true,
+ "math mi": true,
+ "h1": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "h1"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><math><mi><p></p><h1></h1></mi></math></p></body></html>",
+ "noQuirksBodyHtml": "<p><math><mi><p></p><h1></h1></mi></math></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><math><mo><p><h1>",
+ "errors": [
+ "(1,35): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "math math": true,
+ "math mo": true,
+ "h1": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mo",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "h1"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><math><mo><p></p><h1></h1></mo></math></p></body></html>",
+ "noQuirksBodyHtml": "<p><math><mo><p></p><h1></h1></mo></math></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><math><mn><p><h1>",
+ "errors": [
+ "(1,35): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "math math": true,
+ "math mn": true,
+ "h1": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mn",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "h1"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><math><mn><p></p><h1></h1></mn></math></p></body></html>",
+ "noQuirksBodyHtml": "<p><math><mn><p></p><h1></h1></mn></math></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><math><ms><p><h1>",
+ "errors": [
+ "(1,35): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "math math": true,
+ "math ms": true,
+ "h1": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "ms",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "h1"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><math><ms><p></p><h1></h1></ms></math></p></body></html>",
+ "noQuirksBodyHtml": "<p><math><ms><p></p><h1></h1></ms></math></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><math><mtext><p><h1>",
+ "errors": [
+ "(1,38): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "math math": true,
+ "math mtext": true,
+ "h1": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "h1"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><math><mtext><p></p><h1></h1></mtext></math></p></body></html>",
+ "noQuirksBodyHtml": "<p><math><mtext><p></p><h1></h1></mtext></math></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><frameset></noframes>",
+ "errors": [
+ "(1,36): unexpected-end-tag-in-frameset",
+ "(1,36): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!doctype html><html c=d><body></html><html a=b>",
+ "errors": [
+ "(1,48): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "a",
+ "value": "b"
+ },
+ {
+ "name": "c",
+ "value": "d"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html c=\"d\" a=\"b\"><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!doctype html><html c=d><frameset></frameset></html><html a=b>",
+ "errors": [
+ "(1,63): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "a",
+ "value": "b"
+ },
+ {
+ "name": "c",
+ "value": "d"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html c=\"d\" a=\"b\"><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!doctype html><html><frameset></frameset></html><!--foo-->",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ },
+ {
+ "comment": "foo"
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html><!--foo-->",
+ "noQuirksBodyHtml": "<!--foo-->"
+ }
+ },
+ {
+ "data": "<!doctype html><html><frameset></frameset></html> ",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ },
+ {
+ "text": " "
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset> </html>",
+ "noQuirksBodyHtml": " "
+ }
+ },
+ {
+ "data": "<!doctype html><html><frameset></frameset></html>abc",
+ "errors": [
+ "(1,50): expected-eof-but-got-char",
+ "(1,51): expected-eof-but-got-char",
+ "(1,52): expected-eof-but-got-char"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "abc"
+ }
+ },
+ {
+ "data": "<!doctype html><html><frameset></frameset></html><p>",
+ "errors": [
+ "(1,52): expected-eof-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<p></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><html><frameset></frameset></html></p>",
+ "errors": [
+ "(1,53): expected-eof-but-got-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<p></p>"
+ }
+ },
+ {
+ "data": "<html><frameset></frameset></html><!doctype html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,49): unexpected-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!doctype html><body><frameset>",
+ "errors": [
+ "(1,31): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!doctype html><p><frameset><frame>",
+ "errors": [
+ "(1,28): unexpected-start-tag",
+ "(1,35): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "frame": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "tag": "frame"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset><frame></frameset></html>",
+ "noQuirksBodyHtml": "<p></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p>a<frameset>",
+ "errors": [
+ "(1,29): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p>a</p></body></html>",
+ "noQuirksBodyHtml": "<p>a</p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p> <frameset><frame>",
+ "errors": [
+ "(1,29): unexpected-start-tag",
+ "(1,36): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "frame": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "tag": "frame"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset><frame></frameset></html>",
+ "noQuirksBodyHtml": "<p> </p>"
+ }
+ },
+ {
+ "data": "<!doctype html><pre><frameset>",
+ "errors": [
+ "(1,30): unexpected-start-tag",
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "pre": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "pre"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><pre></pre></body></html>",
+ "noQuirksBodyHtml": "<pre></pre>"
+ }
+ },
+ {
+ "data": "<!doctype html><listing><frameset>",
+ "errors": [
+ "(1,34): unexpected-start-tag",
+ "(1,34): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "listing": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "listing"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><listing></listing></body></html>",
+ "noQuirksBodyHtml": "<listing></listing>"
+ }
+ },
+ {
+ "data": "<!doctype html><li><frameset>",
+ "errors": [
+ "(1,29): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "li": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "li"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><li></li></body></html>",
+ "noQuirksBodyHtml": "<li></li>"
+ }
+ },
+ {
+ "data": "<!doctype html><dd><frameset>",
+ "errors": [
+ "(1,29): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "dd": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "dd"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><dd></dd></body></html>",
+ "noQuirksBodyHtml": "<dd></dd>"
+ }
+ },
+ {
+ "data": "<!doctype html><dt><frameset>",
+ "errors": [
+ "(1,29): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "dt": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "dt"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><dt></dt></body></html>",
+ "noQuirksBodyHtml": "<dt></dt>"
+ }
+ },
+ {
+ "data": "<!doctype html><button><frameset>",
+ "errors": [
+ "(1,33): unexpected-start-tag",
+ "(1,33): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "button": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "button"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><button></button></body></html>",
+ "noQuirksBodyHtml": "<button></button>"
+ }
+ },
+ {
+ "data": "<!doctype html><applet><frameset>",
+ "errors": [
+ "(1,33): unexpected-start-tag",
+ "(1,33): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "applet": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "applet"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><applet></applet></body></html>",
+ "noQuirksBodyHtml": "<applet></applet>"
+ }
+ },
+ {
+ "data": "<!doctype html><marquee><frameset>",
+ "errors": [
+ "(1,34): unexpected-start-tag",
+ "(1,34): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "marquee": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "marquee"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><marquee></marquee></body></html>",
+ "noQuirksBodyHtml": "<marquee></marquee>"
+ }
+ },
+ {
+ "data": "<!doctype html><object><frameset>",
+ "errors": [
+ "(1,33): unexpected-start-tag",
+ "(1,33): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "object": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "object"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><object></object></body></html>",
+ "noQuirksBodyHtml": "<object></object>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><frameset>",
+ "errors": [
+ "(1,32): unexpected-start-tag-implies-table-voodoo",
+ "(1,32): unexpected-start-tag",
+ "(1,32): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table></table></body></html>",
+ "noQuirksBodyHtml": "<table></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><area><frameset>",
+ "errors": [
+ "(1,31): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "area": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "area"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><area></body></html>",
+ "noQuirksBodyHtml": "<area>"
+ }
+ },
+ {
+ "data": "<!doctype html><basefont><frameset>",
+ "errors": [
+ "(1,35): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "basefont": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "basefont"
+ }
+ ]
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><basefont></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<basefont>"
+ }
+ },
+ {
+ "data": "<!doctype html><bgsound><frameset>",
+ "errors": [
+ "(1,34): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "bgsound": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "bgsound"
+ }
+ ]
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><bgsound></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<bgsound>"
+ }
+ },
+ {
+ "data": "<!doctype html><br><frameset>",
+ "errors": [
+ "(1,29): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "br": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "br"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><br></body></html>",
+ "noQuirksBodyHtml": "<br>"
+ }
+ },
+ {
+ "data": "<!doctype html><embed><frameset>",
+ "errors": [
+ "(1,32): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "embed": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "embed"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><embed></body></html>",
+ "noQuirksBodyHtml": "<embed>"
+ }
+ },
+ {
+ "data": "<!doctype html><img><frameset>",
+ "errors": [
+ "(1,30): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "img": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "img"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><img></body></html>",
+ "noQuirksBodyHtml": "<img>"
+ }
+ },
+ {
+ "data": "<!doctype html><input><frameset>",
+ "errors": [
+ "(1,32): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "input": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "input"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><input></body></html>",
+ "noQuirksBodyHtml": "<input>"
+ }
+ },
+ {
+ "data": "<!doctype html><keygen><frameset>",
+ "errors": [
+ "(1,33): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "keygen": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "keygen"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><keygen></body></html>",
+ "noQuirksBodyHtml": "<keygen>"
+ }
+ },
+ {
+ "data": "<!doctype html><wbr><frameset>",
+ "errors": [
+ "(1,30): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "wbr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "wbr"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><wbr></body></html>",
+ "noQuirksBodyHtml": "<wbr>"
+ }
+ },
+ {
+ "data": "<!doctype html><hr><frameset>",
+ "errors": [
+ "(1,29): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "hr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "hr"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><hr></body></html>",
+ "noQuirksBodyHtml": "<hr>"
+ }
+ },
+ {
+ "data": "<!doctype html><textarea></textarea><frameset>",
+ "errors": [
+ "(1,46): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><textarea></textarea></body></html>",
+ "noQuirksBodyHtml": "<textarea></textarea>"
+ }
+ },
+ {
+ "data": "<!doctype html><xmp></xmp><frameset>",
+ "errors": [
+ "(1,36): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "xmp": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "xmp"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><xmp></xmp></body></html>",
+ "noQuirksBodyHtml": "<xmp></xmp>"
+ }
+ },
+ {
+ "data": "<!doctype html><iframe></iframe><frameset>",
+ "errors": [
+ "(1,42): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "iframe": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "iframe"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><iframe></iframe></body></html>",
+ "noQuirksBodyHtml": "<iframe></iframe>"
+ }
+ },
+ {
+ "data": "<!doctype html><select></select><frameset>",
+ "errors": [
+ "(1,42): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<!doctype html><svg></svg><frameset><frame>",
+ "errors": [
+ "(1,36): unexpected-start-tag",
+ "(1,43): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "frame": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "tag": "frame"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset><frame></frameset></html>",
+ "noQuirksBodyHtml": "<svg></svg>"
+ }
+ },
+ {
+ "data": "<!doctype html><math></math><frameset><frame>",
+ "errors": [
+ "(1,38): unexpected-start-tag",
+ "(1,45): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "frame": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "tag": "frame"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset><frame></frameset></html>",
+ "noQuirksBodyHtml": "<math></math>"
+ }
+ },
+ {
+ "data": "<!doctype html><svg><foreignObject><div> <frameset><frame>",
+ "errors": [
+ "(1,51): unexpected-start-tag",
+ "(1,58): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "frame": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "tag": "frame"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset><frame></frameset></html>",
+ "noQuirksBodyHtml": "<svg><foreignObject><div> </div></foreignObject></svg>"
+ }
+ },
+ {
+ "data": "<!doctype html><svg>a</svg><frameset><frame>",
+ "errors": [
+ "(1,37): unexpected-start-tag",
+ "(1,44): unexpected-start-tag-ignored"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg>a</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>a</svg>"
+ }
+ },
+ {
+ "data": "<!doctype html><svg> </svg><frameset><frame>",
+ "errors": [
+ "(1,37): unexpected-start-tag",
+ "(1,44): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "frame": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "tag": "frame"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset><frame></frameset></html>",
+ "noQuirksBodyHtml": "<svg> </svg>"
+ }
+ },
+ {
+ "data": "<html>aaa<frameset></frameset>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,19): unexpected-start-tag",
+ "(1,30): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "aaa"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>aaa</body></html>",
+ "noQuirksBodyHtml": "aaa"
+ }
+ },
+ {
+ "data": "<html> a <frameset></frameset>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,19): unexpected-start-tag",
+ "(1,30): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "a "
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>a </body></html>",
+ "noQuirksBodyHtml": " a "
+ }
+ },
+ {
+ "data": "<!doctype html><div><frameset>",
+ "errors": [
+ "(1,30): unexpected-start-tag",
+ "(1,30): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<!doctype html><div><body><frameset>",
+ "errors": [
+ "(1,26): unexpected-start-tag",
+ "(1,36): unexpected-start-tag",
+ "(1,36): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><div></div></body></html>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><math></p>a",
+ "errors": [
+ "(1,28): unexpected-end-tag",
+ "(1,28): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "math math": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ },
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><math></math></p>a</body></html>",
+ "noQuirksBodyHtml": "<p><math></math></p>a"
+ }
+ },
+ {
+ "data": "<!doctype html><p><math><mn><span></p>a",
+ "errors": [
+ "(1,38): unexpected-end-tag",
+ "(1,39): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "math math": true,
+ "math mn": true,
+ "span": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mn",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><math><mn><span><p></p>a</span></mn></math></p></body></html>",
+ "noQuirksBodyHtml": "<p><math><mn><span><p></p>a</span></mn></math></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><math></html>",
+ "errors": [
+ "(1,28): unexpected-end-tag",
+ "(1,28): expected-one-end-tag-but-got-another",
+ "(1,28): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math></math></body></html>",
+ "noQuirksBodyHtml": "<math></math>"
+ }
+ },
+ {
+ "data": "<!doctype html><meta charset=\"ascii\">",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "meta": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "meta",
+ "attrs": [
+ {
+ "name": "charset",
+ "value": "ascii"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><meta charset=\"ascii\"></head><body></body></html>",
+ "noQuirksBodyHtml": "<meta charset=\"ascii\">"
+ }
+ },
+ {
+ "data": "<!doctype html><meta http-equiv=\"content-type\" content=\"text/html;charset=ascii\">",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "meta": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "meta",
+ "attrs": [
+ {
+ "name": "content",
+ "value": "text/html;charset=ascii"
+ },
+ {
+ "name": "http-equiv",
+ "value": "content-type"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><meta http-equiv=\"content-type\" content=\"text/html;charset=ascii\"></head><body></body></html>",
+ "noQuirksBodyHtml": "<meta http-equiv=\"content-type\" content=\"text/html;charset=ascii\">"
+ }
+ },
+ {
+ "data": "<!doctype html><head><!--aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--><meta charset=\"utf8\">",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "meta": true,
+ "body": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "comment": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ },
+ {
+ "tag": "meta",
+ "attrs": [
+ {
+ "name": "charset",
+ "value": "utf8"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><!--aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--><meta charset=\"utf8\"></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--><meta charset=\"utf8\">"
+ }
+ },
+ {
+ "data": "<!doctype html><html a=b><head></head><html c=d>",
+ "errors": [
+ "(1,48): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "a",
+ "value": "b"
+ },
+ {
+ "name": "c",
+ "value": "d"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html a=\"b\" c=\"d\"><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!doctype html><image/>",
+ "errors": [
+ "(1,23): image-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "img": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "img"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><img></body></html>",
+ "noQuirksBodyHtml": "<img>"
+ }
+ },
+ {
+ "data": "<!doctype html>a<i>b<table>c<b>d</i>e</b>f",
+ "errors": [
+ "(1,28): foster-parenting-character",
+ "(1,31): foster-parenting-start-tag",
+ "(1,32): foster-parenting-character",
+ "(1,36): foster-parenting-end-tag",
+ "(1,36): adoption-agency-1.3",
+ "(1,37): foster-parenting-character",
+ "(1,41): foster-parenting-end-tag",
+ "(1,42): foster-parenting-character",
+ "(1,42): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "i": true,
+ "b": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "bc"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "de"
+ }
+ ]
+ },
+ {
+ "text": "f"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>a<i>bc<b>de</b>f<table></table></i></body></html>",
+ "noQuirksBodyHtml": "a<i>bc<b>de</b>f<table></table></i>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><i>a<b>b<div>c<a>d</i>e</b>f",
+ "errors": [
+ "(1,25): foster-parenting-start-tag",
+ "(1,26): foster-parenting-character",
+ "(1,29): foster-parenting-start-tag",
+ "(1,30): foster-parenting-character",
+ "(1,35): foster-parenting-start-tag",
+ "(1,36): foster-parenting-character",
+ "(1,39): foster-parenting-start-tag",
+ "(1,40): foster-parenting-character",
+ "(1,44): foster-parenting-end-tag",
+ "(1,44): adoption-agency-1.3",
+ "(1,44): adoption-agency-1.3",
+ "(1,45): foster-parenting-character",
+ "(1,49): foster-parenting-end-tag",
+ "(1,49): adoption-agency-1.3",
+ "(1,49): adoption-agency-1.3",
+ "(1,50): foster-parenting-character",
+ "(1,50): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "i": true,
+ "b": true,
+ "div": true,
+ "a": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "c"
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "e"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "f"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><i>a<b>b</b></i><b></b><div><b><i>c<a>d</a></i><a>e</a></b><a>f</a></div><table></table></body></html>",
+ "noQuirksBodyHtml": "<i>a<b>b</b></i><b></b><div><b><i>c<a>d</a></i><a>e</a></b><a>f</a></div><table></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><i>a<b>b<div>c<a>d</i>e</b>f",
+ "errors": [
+ "(1,37): adoption-agency-1.3",
+ "(1,37): adoption-agency-1.3",
+ "(1,42): adoption-agency-1.3",
+ "(1,42): adoption-agency-1.3",
+ "(1,43): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "i": true,
+ "b": true,
+ "div": true,
+ "a": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "c"
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "e"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "f"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><i>a<b>b</b></i><b></b><div><b><i>c<a>d</a></i><a>e</a></b><a>f</a></div></body></html>",
+ "noQuirksBodyHtml": "<i>a<b>b</b></i><b></b><div><b><i>c<a>d</a></i><a>e</a></b><a>f</a></div>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><i>a<b>b<div>c</i>",
+ "errors": [
+ "(1,25): foster-parenting-start-tag",
+ "(1,26): foster-parenting-character",
+ "(1,29): foster-parenting-start-tag",
+ "(1,30): foster-parenting-character",
+ "(1,35): foster-parenting-start-tag",
+ "(1,36): foster-parenting-character",
+ "(1,40): foster-parenting-end-tag",
+ "(1,40): adoption-agency-1.3",
+ "(1,40): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "i": true,
+ "b": true,
+ "div": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><i>a<b>b</b></i><b><div><i>c</i></div></b><table></table></body></html>",
+ "noQuirksBodyHtml": "<i>a<b>b</b></i><b><div><i>c</i></div></b><table></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><i>a<b>b<div>c<a>d</i>e</b>f",
+ "errors": [
+ "(1,25): foster-parenting-start-tag",
+ "(1,26): foster-parenting-character",
+ "(1,29): foster-parenting-start-tag",
+ "(1,30): foster-parenting-character",
+ "(1,35): foster-parenting-start-tag",
+ "(1,36): foster-parenting-character",
+ "(1,39): foster-parenting-start-tag",
+ "(1,40): foster-parenting-character",
+ "(1,44): foster-parenting-end-tag",
+ "(1,44): adoption-agency-1.3",
+ "(1,44): adoption-agency-1.3",
+ "(1,45): foster-parenting-character",
+ "(1,49): foster-parenting-end-tag",
+ "(1,44): adoption-agency-1.3",
+ "(1,44): adoption-agency-1.3",
+ "(1,50): foster-parenting-character",
+ "(1,50): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "i": true,
+ "b": true,
+ "div": true,
+ "a": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "c"
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "e"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "f"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><i>a<b>b</b></i><b></b><div><b><i>c<a>d</a></i><a>e</a></b><a>f</a></div><table></table></body></html>",
+ "noQuirksBodyHtml": "<i>a<b>b</b></i><b></b><div><b><i>c<a>d</a></i><a>e</a></b><a>f</a></div><table></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><i>a<div>b<tr>c<b>d</i>e",
+ "errors": [
+ "(1,25): foster-parenting-start-tag",
+ "(1,26): foster-parenting-character",
+ "(1,31): foster-parenting-start-tag",
+ "(1,32): foster-parenting-character",
+ "(1,37): foster-parenting-character",
+ "(1,40): foster-parenting-start-tag",
+ "(1,41): foster-parenting-character",
+ "(1,45): foster-parenting-end-tag",
+ "(1,45): adoption-agency-1.3",
+ "(1,46): foster-parenting-character",
+ "(1,46): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "i": true,
+ "div": true,
+ "b": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "c"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "e"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><i>a<div>b</div></i><i>c<b>d</b></i><b>e</b><table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<i>a<div>b</div></i><i>c<b>d</b></i><b>e</b><table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><td><table><i>a<div>b<b>c</i>d",
+ "errors": [
+ "(1,26): unexpected-cell-in-table-body",
+ "(1,36): foster-parenting-start-tag",
+ "(1,37): foster-parenting-character",
+ "(1,42): foster-parenting-start-tag",
+ "(1,43): foster-parenting-character",
+ "(1,46): foster-parenting-start-tag",
+ "(1,47): foster-parenting-character",
+ "(1,51): foster-parenting-end-tag",
+ "(1,51): adoption-agency-1.3",
+ "(1,51): adoption-agency-1.3",
+ "(1,52): foster-parenting-character",
+ "(1,52): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "i": true,
+ "div": true,
+ "b": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "b"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><i>a</i><div><i>b<b>c</b></i><b>d</b></div><table></table></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><i>a</i><div><i>b<b>c</b></i><b>d</b></div><table></table></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><body><bgsound>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "bgsound": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "bgsound"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><bgsound></body></html>",
+ "noQuirksBodyHtml": "<bgsound>"
+ }
+ },
+ {
+ "data": "<!doctype html><body><basefont>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "basefont": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "basefont"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><basefont></body></html>",
+ "noQuirksBodyHtml": "<basefont>"
+ }
+ },
+ {
+ "data": "<!doctype html><a><b></a><basefont>",
+ "errors": [
+ "(1,25): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "b": true,
+ "basefont": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ },
+ {
+ "tag": "basefont"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><a><b></b></a><basefont></body></html>",
+ "noQuirksBodyHtml": "<a><b></b></a><basefont>"
+ }
+ },
+ {
+ "data": "<!doctype html><a><b></a><bgsound>",
+ "errors": [
+ "(1,25): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "b": true,
+ "bgsound": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ },
+ {
+ "tag": "bgsound"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><a><b></b></a><bgsound></body></html>",
+ "noQuirksBodyHtml": "<a><b></b></a><bgsound>"
+ }
+ },
+ {
+ "data": "<!doctype html><figcaption><article></figcaption>a",
+ "errors": [
+ "(1,49): end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "figcaption": true,
+ "article": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "figcaption",
+ "children": [
+ {
+ "tag": "article"
+ }
+ ]
+ },
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><figcaption><article></article></figcaption>a</body></html>",
+ "noQuirksBodyHtml": "<figcaption><article></article></figcaption>a"
+ }
+ },
+ {
+ "data": "<!doctype html><summary><article></summary>a",
+ "errors": [
+ "(1,43): end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "summary": true,
+ "article": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "summary",
+ "children": [
+ {
+ "tag": "article"
+ }
+ ]
+ },
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><summary><article></article></summary>a</body></html>",
+ "noQuirksBodyHtml": "<summary><article></article></summary>a"
+ }
+ },
+ {
+ "data": "<!doctype html><p><a><plaintext>b",
+ "errors": [
+ "(1,32): unexpected-end-tag",
+ "(1,33): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "a": true,
+ "plaintext": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a"
+ }
+ ]
+ },
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><a></a></p><plaintext><a>b</a></plaintext></body></html>",
+ "noQuirksBodyHtml": "<p><a></a></p><plaintext><a>b</a></plaintext>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><div>a<a></div>b<p>c</p>d",
+ "errors": [
+ "(1,30): end-tag-too-early",
+ "(1,40): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "a": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "a"
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "b"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "c"
+ }
+ ]
+ },
+ {
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><div>a<a></a></div><a>b<p>c</p>d</a></body></html>",
+ "noQuirksBodyHtml": "<div>a<a></a></div><a>b<p>c</p>d</a>"
+ }
+ }
+ ],
+ "tests2.dat": [
+ {
+ "data": "<!DOCTYPE html>Test",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Test"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>Test</body></html>",
+ "noQuirksBodyHtml": "Test"
+ }
+ },
+ {
+ "data": "<textarea>test</div>test",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,24): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "test</div>test",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><textarea>test&lt;/div&gt;test</textarea></body></html>",
+ "noQuirksBodyHtml": "<textarea>test&lt;/div&gt;test</textarea>"
+ }
+ },
+ {
+ "data": "<table><td>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-cell-in-table-body",
+ "(1,11): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><td>test</tbody></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-cell-in-table-body"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "test"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td>test</td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td>test</td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<frame>test",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,7): unexpected-start-tag-ignored"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "test"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>test</body></html>",
+ "noQuirksBodyHtml": "test"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><frameset>test",
+ "errors": [
+ "(1,29): unexpected-char-in-frameset",
+ "(1,29): unexpected-char-in-frameset",
+ "(1,29): unexpected-char-in-frameset",
+ "(1,29): unexpected-char-in-frameset",
+ "(1,29): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "test"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><frameset> te st",
+ "errors": [
+ "(1,29): unexpected-char-in-frameset",
+ "(1,29): unexpected-char-in-frameset",
+ "(1,29): unexpected-char-in-frameset",
+ "(1,29): unexpected-char-in-frameset",
+ "(1,29): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "text": " "
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset> </frameset></html>",
+ "noQuirksBodyHtml": " te st"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><frameset></frameset> te st",
+ "errors": [
+ "(1,29): unexpected-char-after-frameset",
+ "(1,29): unexpected-char-after-frameset",
+ "(1,29): unexpected-char-after-frameset",
+ "(1,29): unexpected-char-after-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ },
+ {
+ "text": " "
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset> </html>",
+ "noQuirksBodyHtml": " te st"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><frameset><!DOCTYPE html>",
+ "errors": [
+ "(1,40): unexpected-doctype",
+ "(1,40): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><font><p><b>test</font>",
+ "errors": [
+ "(1,38): adoption-agency-1.3",
+ "(1,38): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "font": true,
+ "p": true,
+ "b": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "font"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "test"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><font></font><p><font><b>test</b></font></p></body></html>",
+ "noQuirksBodyHtml": "<font></font><p><font><b>test</b></font></p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><dt><div><dd>",
+ "errors": [
+ "(1,28): end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "dt": true,
+ "div": true,
+ "dd": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "dt",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ },
+ {
+ "tag": "dd"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><dt><div></div></dt><dd></dd></body></html>",
+ "noQuirksBodyHtml": "<dt><div></div></dt><dd></dd>"
+ }
+ },
+ {
+ "data": "<script></x",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,11): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "</x",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></x</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></x</script>"
+ }
+ },
+ {
+ "data": "<table><plaintext><td>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,18): unexpected-start-tag-implies-table-voodoo",
+ "(1,22): foster-parenting-character-in-table",
+ "(1,22): foster-parenting-character-in-table",
+ "(1,22): foster-parenting-character-in-table",
+ "(1,22): foster-parenting-character-in-table",
+ "(1,22): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "plaintext": true,
+ "table": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "text": "<td>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><plaintext><td></plaintext><table></table></body></html>",
+ "noQuirksBodyHtml": "<plaintext><td></plaintext><table></table>"
+ }
+ },
+ {
+ "data": "<plaintext></plaintext>",
+ "errors": [
+ "(1,11): expected-doctype-but-got-start-tag",
+ "(1,23): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "plaintext": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "text": "</plaintext>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><plaintext></plaintext></plaintext></body></html>",
+ "noQuirksBodyHtml": "<plaintext></plaintext></plaintext>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><table><tr>TEST",
+ "errors": [
+ "(1,30): foster-parenting-character-in-table",
+ "(1,30): foster-parenting-character-in-table",
+ "(1,30): foster-parenting-character-in-table",
+ "(1,30): foster-parenting-character-in-table",
+ "(1,30): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "TEST"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>TEST<table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "TEST<table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body t1=1><body t2=2><body t3=3 t4=4>",
+ "errors": [
+ "(1,37): unexpected-start-tag",
+ "(1,53): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "attrs": [
+ {
+ "name": "t1",
+ "value": "1"
+ },
+ {
+ "name": "t2",
+ "value": "2"
+ },
+ {
+ "name": "t3",
+ "value": "3"
+ },
+ {
+ "name": "t4",
+ "value": "4"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body t1=\"1\" t2=\"2\" t3=\"3\" t4=\"4\"></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "</b test",
+ "errors": [
+ "(1,8): eof-in-attribute-name",
+ "(1,8): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE html></b test<b &=&amp>X",
+ "errors": [
+ "(1,24): invalid-character-in-attribute-name",
+ "(1,32): named-entity-without-semicolon",
+ "(1,33): attributes-in-end-tag",
+ "(1,33): unexpected-end-tag-before-html"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>X</body></html>",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "<!doctypehtml><scrIPt type=text/x-foobar;baz>X</SCRipt",
+ "errors": [
+ "(1,9): need-space-after-doctype",
+ "(1,54): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "text/x-foobar;baz"
+ }
+ ],
+ "children": [
+ {
+ "text": "X</SCRipt",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script type=\"text/x-foobar;baz\">X</SCRipt</script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script type=\"text/x-foobar;baz\">X</SCRipt</script>"
+ }
+ },
+ {
+ "data": "&",
+ "errors": [
+ "(1,1): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "&",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>&amp;</body></html>",
+ "noQuirksBodyHtml": "&amp;"
+ }
+ },
+ {
+ "data": "&#",
+ "errors": [
+ "(1,2): expected-numeric-entity",
+ "(1,2): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "&#",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>&amp;#</body></html>",
+ "noQuirksBodyHtml": "&amp;#"
+ }
+ },
+ {
+ "data": "&#X",
+ "errors": [
+ "(1,3): expected-numeric-entity",
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "&#X",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>&amp;#X</body></html>",
+ "noQuirksBodyHtml": "&amp;#X"
+ }
+ },
+ {
+ "data": "&#x",
+ "errors": [
+ "(1,3): expected-numeric-entity",
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "&#x",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>&amp;#x</body></html>",
+ "noQuirksBodyHtml": "&amp;#x"
+ }
+ },
+ {
+ "data": "&#45",
+ "errors": [
+ "(1,4): numeric-entity-without-semicolon",
+ "(1,4): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>-</body></html>",
+ "noQuirksBodyHtml": "-"
+ }
+ },
+ {
+ "data": "&x-test",
+ "errors": [
+ "(1,2): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "&x-test",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>&amp;x-test</body></html>",
+ "noQuirksBodyHtml": "&amp;x-test"
+ }
+ },
+ {
+ "data": "<!doctypehtml><p><li>",
+ "errors": [
+ "(1,9): need-space-after-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "li": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "li"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p></p><li></li></body></html>",
+ "noQuirksBodyHtml": "<p></p><li></li>"
+ }
+ },
+ {
+ "data": "<!doctypehtml><p><dt>",
+ "errors": [
+ "(1,9): need-space-after-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "dt": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "dt"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p></p><dt></dt></body></html>",
+ "noQuirksBodyHtml": "<p></p><dt></dt>"
+ }
+ },
+ {
+ "data": "<!doctypehtml><p><dd>",
+ "errors": [
+ "(1,9): need-space-after-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "dd": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "dd"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p></p><dd></dd></body></html>",
+ "noQuirksBodyHtml": "<p></p><dd></dd>"
+ }
+ },
+ {
+ "data": "<!doctypehtml><p><form>",
+ "errors": [
+ "(1,9): need-space-after-doctype",
+ "(1,23): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "form": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "form"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p></p><form></form></body></html>",
+ "noQuirksBodyHtml": "<p></p><form></form>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><p></P>X",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p></p>X</body></html>",
+ "noQuirksBodyHtml": "<p></p>X"
+ }
+ },
+ {
+ "data": "&AMP",
+ "errors": [
+ "(1,4): named-entity-without-semicolon",
+ "(1,4): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "&",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>&amp;</body></html>",
+ "noQuirksBodyHtml": "&amp;"
+ }
+ },
+ {
+ "data": "&AMp;",
+ "errors": [
+ "(1,3): expected-named-entity",
+ "(1,3): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "&AMp;",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>&amp;AMp;</body></html>",
+ "noQuirksBodyHtml": "&amp;AMp;"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><head></head><body><thisISasillyTESTelementNameToMakeSureCrazyTagNamesArePARSEDcorrectLY>",
+ "errors": [
+ "(1,110): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "thisisasillytestelementnametomakesurecrazytagnamesareparsedcorrectly": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "thisisasillytestelementnametomakesurecrazytagnamesareparsedcorrectly"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><thisisasillytestelementnametomakesurecrazytagnamesareparsedcorrectly></thisisasillytestelementnametomakesurecrazytagnamesareparsedcorrectly></body></html>",
+ "noQuirksBodyHtml": "<thisisasillytestelementnametomakesurecrazytagnamesareparsedcorrectly></thisisasillytestelementnametomakesurecrazytagnamesareparsedcorrectly>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>X</body>X",
+ "errors": [
+ "(1,24): unexpected-char-after-body"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "XX"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>XX</body></html>",
+ "noQuirksBodyHtml": "XX"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><!-- X",
+ "errors": [
+ "(1,21): eof-in-comment"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "comment": " X"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><!-- X--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!-- X-->"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><table><caption>test TEST</caption><td>test",
+ "errors": [
+ "(1,54): unexpected-cell-in-table-body",
+ "(1,58): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "text": "test TEST"
+ }
+ ]
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "test"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><caption>test TEST</caption><tbody><tr><td>test</td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><caption>test TEST</caption><tbody><tr><td>test</td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><select><option><optgroup>",
+ "errors": [
+ "(1,41): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true,
+ "optgroup": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option"
+ },
+ {
+ "tag": "optgroup"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select><option></option><optgroup></optgroup></select></body></html>",
+ "noQuirksBodyHtml": "<select><option></option><optgroup></optgroup></select>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><select><optgroup><option></optgroup><option><select><option>",
+ "errors": [
+ "(1,68): unexpected-select-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "optgroup": true,
+ "option": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "optgroup",
+ "children": [
+ {
+ "tag": "option"
+ }
+ ]
+ },
+ {
+ "tag": "option"
+ }
+ ]
+ },
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select><optgroup><option></option></optgroup><option></option></select><option></option></body></html>",
+ "noQuirksBodyHtml": "<select><optgroup><option></option></optgroup><option></option></select><option></option>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><select><optgroup><option><optgroup>",
+ "errors": [
+ "(1,51): eof-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "optgroup": true,
+ "option": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "optgroup",
+ "children": [
+ {
+ "tag": "option"
+ }
+ ]
+ },
+ {
+ "tag": "optgroup"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select><optgroup><option></option></optgroup><optgroup></optgroup></select></body></html>",
+ "noQuirksBodyHtml": "<select><optgroup><option></option></optgroup><optgroup></optgroup></select>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><datalist><option>foo</datalist>bar",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "datalist": true,
+ "option": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "datalist",
+ "children": [
+ {
+ "tag": "option",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><datalist><option>foo</option></datalist>bar</body></html>",
+ "noQuirksBodyHtml": "<datalist><option>foo</option></datalist>bar"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><font><input><input></font>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "font": true,
+ "input": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "font",
+ "children": [
+ {
+ "tag": "input"
+ },
+ {
+ "tag": "input"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><font><input><input></font></body></html>",
+ "noQuirksBodyHtml": "<font><input><input></font>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><!-- XXX - XXX -->",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "comment": " XXX - XXX "
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><!-- XXX - XXX --><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!-- XXX - XXX -->"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><!-- XXX - XXX",
+ "errors": [
+ "(1,29): eof-in-comment"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "comment": " XXX - XXX"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><!-- XXX - XXX--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!-- XXX - XXX-->"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><!-- XXX - XXX - XXX -->",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "comment": " XXX - XXX - XXX "
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><!-- XXX - XXX - XXX --><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!-- XXX - XXX - XXX -->"
+ }
+ },
+ {
+ "data": "test\ntest",
+ "errors": [
+ "(2,4): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "test\ntest"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>test\ntest</body></html>",
+ "noQuirksBodyHtml": "test\ntest"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><title>test</body></title>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "title": true
+ },
+ "doctype": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "test</body>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><title>test&lt;/body&gt;</title></body></html>",
+ "noQuirksBodyHtml": "<title>test&lt;/body&gt;</title>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><title>X</title><meta name=z><link rel=foo><style>\nx { content:\"</style\" } </style>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "title": true,
+ "meta": true,
+ "link": true,
+ "style": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ },
+ {
+ "tag": "meta",
+ "attrs": [
+ {
+ "name": "name",
+ "value": "z"
+ }
+ ]
+ },
+ {
+ "tag": "link",
+ "attrs": [
+ {
+ "name": "rel",
+ "value": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": "\nx { content:\"</style\" } ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><title>X</title><meta name=\"z\"><link rel=\"foo\"><style>\nx { content:\"</style\" } </style></body></html>",
+ "noQuirksBodyHtml": "<title>X</title><meta name=\"z\"><link rel=\"foo\"><style>\nx { content:\"</style\" } </style>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><select><optgroup></optgroup></select>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "optgroup": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "optgroup"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select><optgroup></optgroup></select></body></html>",
+ "noQuirksBodyHtml": "<select><optgroup></optgroup></select>"
+ }
+ },
+ {
+ "data": " \n ",
+ "errors": [
+ "(2,1): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": " \n "
+ }
+ },
+ {
+ "data": "<!DOCTYPE html> <html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": " "
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><script>\n</script> <title>x</title> </head>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "title": true,
+ "body": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "\n",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": " "
+ },
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ },
+ {
+ "text": " "
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><script>\n</script> <title>x</title> </head><body></body></html>",
+ "noQuirksBodyHtml": "<script>\n</script> <title>x</title> "
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><body><html id=x>",
+ "errors": [
+ "(1,38): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "x"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html id=\"x\"><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>X</body><html id=\"x\">",
+ "errors": [
+ "(1,36): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "x"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html id=\"x\"><head></head><body>X</body></html>",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><head><html id=x>",
+ "errors": [
+ "(1,32): non-html-root"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "x"
+ }
+ ],
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html id=\"x\"><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>X</html>X",
+ "errors": [
+ "(1,24): expected-eof-but-got-char"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "XX"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>XX</body></html>",
+ "noQuirksBodyHtml": "XX"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>X</html> ",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X "
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>X </body></html>",
+ "noQuirksBodyHtml": "X "
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>X</html><p>X",
+ "errors": [
+ "(1,26): expected-eof-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>X<p>X</p></body></html>",
+ "noQuirksBodyHtml": "X<p>X</p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>X<p/x/y/z>",
+ "errors": [
+ "(1,19): unexpected-character-after-solidus-in-tag",
+ "(1,21): unexpected-character-after-solidus-in-tag",
+ "(1,23): unexpected-character-after-solidus-in-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ },
+ {
+ "tag": "p",
+ "attrs": [
+ {
+ "name": "x",
+ "value": ""
+ },
+ {
+ "name": "y",
+ "value": ""
+ },
+ {
+ "name": "z",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>X<p x=\"\" y=\"\" z=\"\"></p></body></html>",
+ "noQuirksBodyHtml": "X<p x=\"\" y=\"\" z=\"\"></p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><!--x--",
+ "errors": [
+ "(1,22): eof-in-comment-double-dash"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "comment": "x"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><!--x--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!--x-->"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><table><tr><td></p></table>",
+ "errors": [
+ "(1,34): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><p></p></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><p></p></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE <!DOCTYPE HTML>><!--<!--x-->-->",
+ "errors": [
+ "(1,20): expected-space-or-right-bracket-in-doctype",
+ "(1,25): unknown-doctype",
+ "(1,35): unexpected-char-in-comment"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true,
+ "escaped": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "<!doctype"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": ">",
+ "escaped": true
+ },
+ {
+ "comment": "<!--x"
+ },
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE <!doctype><html><head></head><body>&gt;<!--<!--x-->--&gt;</body></html>",
+ "noQuirksBodyHtml": "&gt;<!--<!--x-->--&gt;"
+ }
+ },
+ {
+ "data": "<!doctype html><div><form></form><div></div></div>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "form": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "form"
+ },
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><div><form></form><div></div></div></body></html>",
+ "noQuirksBodyHtml": "<div><form></form><div></div></div>"
+ }
+ }
+ ],
+ "tests20.dat": [
+ {
+ "data": "<!doctype html><p><button><button>",
+ "errors": [
+ "(1,34): unexpected-start-tag-implies-end-tag",
+ "(1,34): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button"
+ },
+ {
+ "tag": "button"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button></button><button></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button></button><button></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><address>",
+ "errors": [
+ "(1,35): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "address": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "address"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><address></address></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><address></address></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><blockquote>",
+ "errors": [
+ "(1,38): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "blockquote": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "blockquote"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><blockquote></blockquote></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><blockquote></blockquote></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><menu>",
+ "errors": [
+ "(1,32): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "menu": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "menu"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><menu></menu></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><menu></menu></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><p>",
+ "errors": [
+ "(1,29): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><p></p></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><p></p></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><ul>",
+ "errors": [
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "ul": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "ul"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><ul></ul></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><ul></ul></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><h1>",
+ "errors": [
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "h1": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "h1"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><h1></h1></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><h1></h1></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><h6>",
+ "errors": [
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "h6": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "h6"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><h6></h6></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><h6></h6></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><listing>",
+ "errors": [
+ "(1,35): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "listing": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "listing"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><listing></listing></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><listing></listing></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><pre>",
+ "errors": [
+ "(1,31): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "pre": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "pre"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><pre></pre></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><pre></pre></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><form>",
+ "errors": [
+ "(1,32): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "form": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "form"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><form></form></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><form></form></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><li>",
+ "errors": [
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "li": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "li"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><li></li></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><li></li></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><dd>",
+ "errors": [
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "dd": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "dd"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><dd></dd></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><dd></dd></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><dt>",
+ "errors": [
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "dt": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "dt"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><dt></dt></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><dt></dt></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><plaintext>",
+ "errors": [
+ "(1,37): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "plaintext": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "plaintext"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><plaintext></plaintext></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><plaintext></plaintext></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><table>",
+ "errors": [
+ "(1,33): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><table></table></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><table></table></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><hr>",
+ "errors": [
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "hr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "hr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><hr></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><hr></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button><xmp>",
+ "errors": [
+ "(1,31): expected-named-closing-tag-but-got-eof",
+ "(1,31): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true,
+ "xmp": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "xmp"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><xmp></xmp></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><xmp></xmp></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><button></p>",
+ "errors": [
+ "(1,30): unexpected-end-tag",
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "button": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><button><p></p></button></p></body></html>",
+ "noQuirksBodyHtml": "<p><button><p></p></button></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><address><button></address>a",
+ "errors": [
+ "(1,42): end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "address": true,
+ "button": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "address",
+ "children": [
+ {
+ "tag": "button"
+ }
+ ]
+ },
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><address><button></button></address>a</body></html>",
+ "noQuirksBodyHtml": "<address><button></button></address>a"
+ }
+ },
+ {
+ "data": "<!doctype html><address><button></address>a",
+ "errors": [
+ "(1,42): end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "address": true,
+ "button": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "address",
+ "children": [
+ {
+ "tag": "button"
+ }
+ ]
+ },
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><address><button></button></address>a</body></html>",
+ "noQuirksBodyHtml": "<address><button></button></address>a"
+ }
+ },
+ {
+ "data": "<p><table></p>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,14): unexpected-end-tag-implies-table-voodoo",
+ "(1,14): unexpected-end-tag",
+ "(1,14): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><p></p><table></table></p></body></html>",
+ "noQuirksBodyHtml": "<p></p><p></p><table></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><svg>",
+ "errors": [
+ "(1,20): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg></svg></body></html>",
+ "noQuirksBodyHtml": "<svg></svg>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><figcaption>",
+ "errors": [
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "figcaption": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "figcaption"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p></p><figcaption></figcaption></body></html>",
+ "noQuirksBodyHtml": "<p></p><figcaption></figcaption>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><summary>",
+ "errors": [
+ "(1,27): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "summary": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "summary"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p></p><summary></summary></body></html>",
+ "noQuirksBodyHtml": "<p></p><summary></summary>"
+ }
+ },
+ {
+ "data": "<!doctype html><form><table><form>",
+ "errors": [
+ "(1,34): unexpected-form-in-table",
+ "(1,34): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "form": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "form",
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><form><table></table></form></body></html>",
+ "noQuirksBodyHtml": "<form><table></table></form>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><form><form>",
+ "errors": [
+ "(1,28): unexpected-form-in-table",
+ "(1,34): unexpected-form-in-table",
+ "(1,34): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "form": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "form"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><form></form></table></body></html>",
+ "noQuirksBodyHtml": "<table><form></form></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><form></table><form>",
+ "errors": [
+ "(1,28): unexpected-form-in-table",
+ "(1,42): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "form": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "form"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><form></form></table></body></html>",
+ "noQuirksBodyHtml": "<table><form></form></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><svg><foreignObject><p>",
+ "errors": [
+ "(1,38): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg foreignObject": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><foreignObject><p></p></foreignObject></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><foreignObject><p></p></foreignObject></svg>"
+ }
+ },
+ {
+ "data": "<!doctype html><svg><title>abc",
+ "errors": [
+ "(1,30): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg title": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "title",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "abc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><title>abc</title></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><title>abc</title></svg>"
+ }
+ },
+ {
+ "data": "<option><span><option>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,22): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "option": true,
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "option",
+ "children": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><option><span><option></option></span></option></body></html>",
+ "noQuirksBodyHtml": "<option><span><option></option></span></option>"
+ }
+ },
+ {
+ "data": "<option><option>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "option": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "option"
+ },
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><option></option><option></option></body></html>",
+ "noQuirksBodyHtml": "<option></option><option></option>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml><div>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,27): unexpected-html-element-in-foreign-content",
+ "(1,27): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ },
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml></annotation-xml></math><div></div></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml><div></div></annotation-xml></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml encoding=\"application/svg+xml\"><div>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,58): unexpected-html-element-in-foreign-content",
+ "(1,58): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "encoding",
+ "value": "application/svg+xml"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml encoding=\"application/svg+xml\"></annotation-xml></math><div></div></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml encoding=\"application/svg+xml\"><div></div></annotation-xml></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml encoding=\"application/xhtml+xml\"><div>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,60): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "encoding",
+ "value": "application/xhtml+xml"
+ }
+ ],
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml encoding=\"application/xhtml+xml\"><div></div></annotation-xml></math></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml encoding=\"application/xhtml+xml\"><div></div></annotation-xml></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml encoding=\"aPPlication/xhtmL+xMl\"><div>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,60): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "encoding",
+ "value": "aPPlication/xhtmL+xMl"
+ }
+ ],
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml encoding=\"aPPlication/xhtmL+xMl\"><div></div></annotation-xml></math></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml encoding=\"aPPlication/xhtmL+xMl\"><div></div></annotation-xml></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml encoding=\"text/html\"><div>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,48): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "encoding",
+ "value": "text/html"
+ }
+ ],
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml encoding=\"text/html\"><div></div></annotation-xml></math></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml encoding=\"text/html\"><div></div></annotation-xml></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml encoding=\"Text/htmL\"><div>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,48): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "encoding",
+ "value": "Text/htmL"
+ }
+ ],
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml encoding=\"Text/htmL\"><div></div></annotation-xml></math></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml encoding=\"Text/htmL\"><div></div></annotation-xml></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml encoding=\" text/html \"><div>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,50): unexpected-html-element-in-foreign-content",
+ "(1,50): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "encoding",
+ "value": " text/html "
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml encoding=\" text/html \"></annotation-xml></math><div></div></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml encoding=\" text/html \"><div></div></annotation-xml></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml> </annotation-xml>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,40): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": " "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml> </annotation-xml></math></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml> </annotation-xml></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml>c</annotation-xml>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,40): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml>c</annotation-xml></math></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml>c</annotation-xml></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml><!--foo-->",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,32): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "comment": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml><!--foo--></annotation-xml></math></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml><!--foo--></annotation-xml></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml></svg>x",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,28): unexpected-end-tag",
+ "(1,29): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml>x</annotation-xml></math></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml>x</annotation-xml></math>"
+ }
+ },
+ {
+ "data": "<math><annotation-xml><svg>x",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,28): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><annotation-xml><svg>x</svg></annotation-xml></math></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml><svg>x</svg></annotation-xml></math>"
+ }
+ }
+ ],
+ "tests21.dat": [
+ {
+ "data": "<svg><![CDATA[foo]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,20): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>foo</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>foo</svg>"
+ }
+ },
+ {
+ "data": "<math><![CDATA[foo]]>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,21): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math>foo</math></body></html>",
+ "noQuirksBodyHtml": "<math>foo</math>"
+ }
+ },
+ {
+ "data": "<div><![CDATA[foo]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,7): expected-dashes-or-doctype",
+ "(1,20): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "comment": "[CDATA[foo]]"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><!--[CDATA[foo]]--></div></body></html>",
+ "noQuirksBodyHtml": "<div><!--[CDATA[foo]]--></div>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[foo",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>foo</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>foo</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[foo",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>foo</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>foo</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,14): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg></svg></body></html>",
+ "noQuirksBodyHtml": "<svg></svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg></svg></body></html>",
+ "noQuirksBodyHtml": "<svg></svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[]] >]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,21): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "]] >",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>]] &gt;</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>]] &gt;</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[]] >]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,21): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "]] >",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>]] &gt;</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>]] &gt;</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[]]",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "]]"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>]]</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>]]</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[]",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,15): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "]"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>]</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>]</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[]>a",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "]>a",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>]&gt;a</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>]&gt;a</svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><svg><![CDATA[foo]]]>",
+ "errors": [
+ "(1,36): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo]"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg>foo]</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>foo]</svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><svg><![CDATA[foo]]]]>",
+ "errors": [
+ "(1,37): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo]]"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg>foo]]</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>foo]]</svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><svg><![CDATA[foo]]]]]>",
+ "errors": [
+ "(1,38): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "foo]]]"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg>foo]]]</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>foo]]]</svg>"
+ }
+ },
+ {
+ "data": "<svg><foreignObject><div><![CDATA[foo]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,27): expected-dashes-or-doctype",
+ "(1,40): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg foreignObject": true,
+ "div": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "comment": "[CDATA[foo]]"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg><foreignObject><div><!--[CDATA[foo]]--></div></foreignObject></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><foreignObject><div><!--[CDATA[foo]]--></div></foreignObject></svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[<svg>]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,22): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "<svg>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>&lt;svg&gt;</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>&lt;svg&gt;</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[</svg>a]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,24): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "</svg>a",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>&lt;/svg&gt;a</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>&lt;/svg&gt;a</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[<svg>a",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,20): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "<svg>a",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>&lt;svg&gt;a</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>&lt;svg&gt;a</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[</svg>a",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,21): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "</svg>a",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>&lt;/svg&gt;a</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>&lt;/svg&gt;a</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[<svg>]]><path>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,28): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg path": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "<svg>",
+ "escaped": true
+ },
+ {
+ "tag": "path",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>&lt;svg&gt;<path></path></svg></body></html>",
+ "noQuirksBodyHtml": "<svg>&lt;svg&gt;<path></path></svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[<svg>]]></path>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,29): unexpected-end-tag",
+ "(1,29): unexpected-end-tag",
+ "(1,29): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "<svg>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>&lt;svg&gt;</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>&lt;svg&gt;</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[<svg>]]><!--path-->",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,33): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "escaped": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "<svg>",
+ "escaped": true
+ },
+ {
+ "comment": "path"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>&lt;svg&gt;<!--path--></svg></body></html>",
+ "noQuirksBodyHtml": "<svg>&lt;svg&gt;<!--path--></svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[<svg>]]>path",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,26): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "<svg>path",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>&lt;svg&gt;path</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>&lt;svg&gt;path</svg>"
+ }
+ },
+ {
+ "data": "<svg><![CDATA[<!--svg-->]]>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,27): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "text": "<!--svg-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg>&lt;!--svg--&gt;</svg></body></html>",
+ "noQuirksBodyHtml": "<svg>&lt;!--svg--&gt;</svg>"
+ }
+ }
+ ],
+ "tests22.dat": [
+ {
+ "data": "<a><b><big><em><strong><div>X</a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,33): adoption-agency-1.3",
+ "(1,33): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "b": true,
+ "big": true,
+ "em": true,
+ "strong": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "big",
+ "children": [
+ {
+ "tag": "em",
+ "children": [
+ {
+ "tag": "strong"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "big",
+ "children": [
+ {
+ "tag": "em",
+ "children": [
+ {
+ "tag": "strong",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a><b><big><em><strong></strong></em></big></b></a><big><em><strong><div><a>X</a></div></strong></em></big></body></html>",
+ "noQuirksBodyHtml": "<a><b><big><em><strong></strong></em></big></b></a><big><em><strong><div><a>X</a></div></strong></em></big>"
+ }
+ },
+ {
+ "data": "<a><b><div id=1><div id=2><div id=3><div id=4><div id=5><div id=6><div id=7><div id=8>A</a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,91): adoption-agency-1.3",
+ "(1,91): adoption-agency-1.3",
+ "(1,91): adoption-agency-1.3",
+ "(1,91): adoption-agency-1.3",
+ "(1,91): adoption-agency-1.3",
+ "(1,91): adoption-agency-1.3",
+ "(1,91): adoption-agency-1.3",
+ "(1,91): adoption-agency-1.3",
+ "(1,91): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "b": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "1"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "2"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "3"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "5"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "6"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "7"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "8"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a><b></b></a><b><div id=\"1\"><a></a><div id=\"2\"><a></a><div id=\"3\"><a></a><div id=\"4\"><a></a><div id=\"5\"><a></a><div id=\"6\"><a></a><div id=\"7\"><a></a><div id=\"8\"><a>A</a></div></div></div></div></div></div></div></div></b></body></html>",
+ "noQuirksBodyHtml": "<a><b></b></a><b><div id=\"1\"><a></a><div id=\"2\"><a></a><div id=\"3\"><a></a><div id=\"4\"><a></a><div id=\"5\"><a></a><div id=\"6\"><a></a><div id=\"7\"><a></a><div id=\"8\"><a>A</a></div></div></div></div></div></div></div></div></b>"
+ }
+ },
+ {
+ "data": "<a><b><div id=1><div id=2><div id=3><div id=4><div id=5><div id=6><div id=7><div id=8><div id=9>A</a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,101): adoption-agency-1.3",
+ "(1,101): adoption-agency-1.3",
+ "(1,101): adoption-agency-1.3",
+ "(1,101): adoption-agency-1.3",
+ "(1,101): adoption-agency-1.3",
+ "(1,101): adoption-agency-1.3",
+ "(1,101): adoption-agency-1.3",
+ "(1,101): adoption-agency-1.3",
+ "(1,101): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "b": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "1"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "2"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "3"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "5"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "6"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "7"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "8"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "9"
+ }
+ ],
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a><b></b></a><b><div id=\"1\"><a></a><div id=\"2\"><a></a><div id=\"3\"><a></a><div id=\"4\"><a></a><div id=\"5\"><a></a><div id=\"6\"><a></a><div id=\"7\"><a></a><div id=\"8\"><a><div id=\"9\">A</div></a></div></div></div></div></div></div></div></div></b></body></html>",
+ "noQuirksBodyHtml": "<a><b></b></a><b><div id=\"1\"><a></a><div id=\"2\"><a></a><div id=\"3\"><a></a><div id=\"4\"><a></a><div id=\"5\"><a></a><div id=\"6\"><a></a><div id=\"7\"><a></a><div id=\"8\"><a><div id=\"9\">A</div></a></div></div></div></div></div></div></div></div></b>"
+ }
+ },
+ {
+ "data": "<a><b><div id=1><div id=2><div id=3><div id=4><div id=5><div id=6><div id=7><div id=8><div id=9><div id=10>A</a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,112): adoption-agency-1.3",
+ "(1,112): adoption-agency-1.3",
+ "(1,112): adoption-agency-1.3",
+ "(1,112): adoption-agency-1.3",
+ "(1,112): adoption-agency-1.3",
+ "(1,112): adoption-agency-1.3",
+ "(1,112): adoption-agency-1.3",
+ "(1,112): adoption-agency-1.3",
+ "(1,112): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "b": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "1"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "2"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "3"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "5"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "6"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "7"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "8"
+ }
+ ],
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "9"
+ }
+ ],
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "10"
+ }
+ ],
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a><b></b></a><b><div id=\"1\"><a></a><div id=\"2\"><a></a><div id=\"3\"><a></a><div id=\"4\"><a></a><div id=\"5\"><a></a><div id=\"6\"><a></a><div id=\"7\"><a></a><div id=\"8\"><a><div id=\"9\"><div id=\"10\">A</div></div></a></div></div></div></div></div></div></div></div></b></body></html>",
+ "noQuirksBodyHtml": "<a><b></b></a><b><div id=\"1\"><a></a><div id=\"2\"><a></a><div id=\"3\"><a></a><div id=\"4\"><a></a><div id=\"5\"><a></a><div id=\"6\"><a></a><div id=\"7\"><a></a><div id=\"8\"><a><div id=\"9\"><div id=\"10\">A</div></div></a></div></div></div></div></div></div></div></div></b>"
+ }
+ },
+ {
+ "data": "<cite><b><cite><i><cite><i><cite><i><div>X</b>TEST",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,46): adoption-agency-1.3",
+ "(1,50): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "cite": true,
+ "b": true,
+ "i": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "cite",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "cite",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "cite",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "cite",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ },
+ {
+ "text": "TEST"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><cite><b><cite><i><cite><i><cite><i></i></cite></i></cite></i></cite></b><i><i><div><b>X</b>TEST</div></i></i></cite></body></html>",
+ "noQuirksBodyHtml": "<cite><b><cite><i><cite><i><cite><i></i></cite></i></cite></i></cite></b><i><i><div><b>X</b>TEST</div></i></i></cite>"
+ }
+ }
+ ],
+ "tests23.dat": [
+ {
+ "data": "<p><font size=4><font color=red><font size=4><font size=4><font size=4><font size=4><font size=4><font color=red><p>X",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,116): unexpected-end-tag",
+ "(1,117): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "font": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "color",
+ "value": "red"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "color",
+ "value": "red"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "color",
+ "value": "red"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "color",
+ "value": "red"
+ }
+ ],
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><font size=\"4\"><font color=\"red\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font color=\"red\"></font></font></font></font></font></font></font></font></p><p><font color=\"red\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font color=\"red\">X</font></font></font></font></font></p></body></html>",
+ "noQuirksBodyHtml": "<p><font size=\"4\"><font color=\"red\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font color=\"red\"></font></font></font></font></font></font></font></font></p><p><font color=\"red\"><font size=\"4\"><font size=\"4\"><font size=\"4\"><font color=\"red\">X</font></font></font></font></font></p>"
+ }
+ },
+ {
+ "data": "<p><font size=4><font size=4><font size=4><font size=4><p>X",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,58): unexpected-end-tag",
+ "(1,59): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "font": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"4\"></font></font></font></font></p><p><font size=\"4\"><font size=\"4\"><font size=\"4\">X</font></font></font></p></body></html>",
+ "noQuirksBodyHtml": "<p><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"4\"></font></font></font></font></p><p><font size=\"4\"><font size=\"4\"><font size=\"4\">X</font></font></font></p>"
+ }
+ },
+ {
+ "data": "<p><font size=4><font size=4><font size=4><font size=\"5\"><font size=4><p>X",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,73): unexpected-end-tag",
+ "(1,74): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "font": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "5"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "5"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"5\"><font size=\"4\"></font></font></font></font></font></p><p><font size=\"4\"><font size=\"4\"><font size=\"5\"><font size=\"4\">X</font></font></font></font></p></body></html>",
+ "noQuirksBodyHtml": "<p><font size=\"4\"><font size=\"4\"><font size=\"4\"><font size=\"5\"><font size=\"4\"></font></font></font></font></font></p><p><font size=\"4\"><font size=\"4\"><font size=\"5\"><font size=\"4\">X</font></font></font></font></p>"
+ }
+ },
+ {
+ "data": "<p><font size=4 id=a><font size=4 id=b><font size=4><font size=4><p>X",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,68): unexpected-end-tag",
+ "(1,69): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "font": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "a"
+ },
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "b"
+ },
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "a"
+ },
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "b"
+ },
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "4"
+ }
+ ],
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><font size=\"4\" id=\"a\"><font size=\"4\" id=\"b\"><font size=\"4\"><font size=\"4\"></font></font></font></font></p><p><font size=\"4\" id=\"a\"><font size=\"4\" id=\"b\"><font size=\"4\"><font size=\"4\">X</font></font></font></font></p></body></html>",
+ "noQuirksBodyHtml": "<p><font size=\"4\" id=\"a\"><font size=\"4\" id=\"b\"><font size=\"4\"><font size=\"4\"></font></font></font></font></p><p><font size=\"4\" id=\"a\"><font size=\"4\" id=\"b\"><font size=\"4\"><font size=\"4\">X</font></font></font></font></p>"
+ }
+ },
+ {
+ "data": "<p><b id=a><b id=a><b id=a><b><object><b id=a><b id=a>X</object><p>Y",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,64): end-tag-too-early",
+ "(1,67): unexpected-end-tag",
+ "(1,68): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "b": true,
+ "object": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "a"
+ }
+ ],
+ "children": [
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "a"
+ }
+ ],
+ "children": [
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "a"
+ }
+ ],
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "object",
+ "children": [
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "a"
+ }
+ ],
+ "children": [
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "a"
+ }
+ ],
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "a"
+ }
+ ],
+ "children": [
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "a"
+ }
+ ],
+ "children": [
+ {
+ "tag": "b",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "a"
+ }
+ ],
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "Y"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><b id=\"a\"><b id=\"a\"><b id=\"a\"><b><object><b id=\"a\"><b id=\"a\">X</b></b></object></b></b></b></b></p><p><b id=\"a\"><b id=\"a\"><b id=\"a\"><b>Y</b></b></b></b></p></body></html>",
+ "noQuirksBodyHtml": "<p><b id=\"a\"><b id=\"a\"><b id=\"a\"><b><object><b id=\"a\"><b id=\"a\">X</b></b></object></b></b></b></b></p><p><b id=\"a\"><b id=\"a\"><b id=\"a\"><b>Y</b></b></b></b></p>"
+ }
+ }
+ ],
+ "tests24.dat": [
+ {
+ "data": "<!DOCTYPE html>&NotEqualTilde;",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "≂̸"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>≂̸</body></html>",
+ "noQuirksBodyHtml": "≂̸"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>&NotEqualTilde;A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "≂̸A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>≂̸A</body></html>",
+ "noQuirksBodyHtml": "≂̸A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>&ThickSpace;",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "  "
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>  </body></html>",
+ "noQuirksBodyHtml": "  "
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>&ThickSpace;A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "  A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>  A</body></html>",
+ "noQuirksBodyHtml": "  A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>&NotSubset;",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "⊂⃒"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>⊂⃒</body></html>",
+ "noQuirksBodyHtml": "⊂⃒"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>&NotSubset;A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "⊂⃒A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>⊂⃒A</body></html>",
+ "noQuirksBodyHtml": "⊂⃒A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>&Gopf;",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "𝔾"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>𝔾</body></html>",
+ "noQuirksBodyHtml": "𝔾"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html>&Gopf;A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "𝔾A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>𝔾A</body></html>",
+ "noQuirksBodyHtml": "𝔾A"
+ }
+ }
+ ],
+ "tests25.dat": [
+ {
+ "data": "<!DOCTYPE html><body><foo>A",
+ "errors": [
+ "(1,27): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "foo": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><foo>A</foo></body></html>",
+ "noQuirksBodyHtml": "<foo>A</foo>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><area>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "area": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "area"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><area>A</body></html>",
+ "noQuirksBodyHtml": "<area>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><base>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "base": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "base"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><base>A</body></html>",
+ "noQuirksBodyHtml": "<base>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><basefont>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "basefont": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "basefont"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><basefont>A</body></html>",
+ "noQuirksBodyHtml": "<basefont>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><bgsound>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "bgsound": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "bgsound"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><bgsound>A</body></html>",
+ "noQuirksBodyHtml": "<bgsound>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><br>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "br": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "br"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><br>A</body></html>",
+ "noQuirksBodyHtml": "<br>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><col>A",
+ "errors": [
+ "(1,26): unexpected-start-tag-ignored"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>A</body></html>",
+ "noQuirksBodyHtml": "A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><command>A",
+ "errors": [
+ "eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "command": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "command",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><command>A</command></body></html>",
+ "noQuirksBodyHtml": "<command>A</command>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><embed>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "embed": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "embed"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><embed>A</body></html>",
+ "noQuirksBodyHtml": "<embed>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><frame>A",
+ "errors": [
+ "(1,28): unexpected-start-tag-ignored"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>A</body></html>",
+ "noQuirksBodyHtml": "A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><hr>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "hr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "hr"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><hr>A</body></html>",
+ "noQuirksBodyHtml": "<hr>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><img>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "img": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "img"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><img>A</body></html>",
+ "noQuirksBodyHtml": "<img>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><input>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "input": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "input"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><input>A</body></html>",
+ "noQuirksBodyHtml": "<input>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><keygen>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "keygen": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "keygen"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><keygen>A</body></html>",
+ "noQuirksBodyHtml": "<keygen>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><link>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "link": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "link"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><link>A</body></html>",
+ "noQuirksBodyHtml": "<link>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><meta>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "meta": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "meta"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><meta>A</body></html>",
+ "noQuirksBodyHtml": "<meta>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><param>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "param": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "param"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><param>A</body></html>",
+ "noQuirksBodyHtml": "<param>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><source>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "source": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "source"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><source>A</body></html>",
+ "noQuirksBodyHtml": "<source>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><track>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "track": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "track"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><track>A</body></html>",
+ "noQuirksBodyHtml": "<track>A"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><wbr>A",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "wbr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "wbr"
+ },
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><wbr>A</body></html>",
+ "noQuirksBodyHtml": "<wbr>A"
+ }
+ }
+ ],
+ "tests26.dat": [
+ {
+ "data": "<!DOCTYPE html><body><a href='#1'><nobr>1<nobr></a><br><a href='#2'><nobr>2<nobr></a><br><a href='#3'><nobr>3<nobr></a>",
+ "errors": [
+ "(1,47): unexpected-start-tag-implies-end-tag",
+ "(1,51): adoption-agency-1.3",
+ "(1,74): unexpected-start-tag-implies-end-tag",
+ "(1,74): adoption-agency-1.3",
+ "(1,81): unexpected-start-tag-implies-end-tag",
+ "(1,85): adoption-agency-1.3",
+ "(1,108): unexpected-start-tag-implies-end-tag",
+ "(1,108): adoption-agency-1.3",
+ "(1,115): unexpected-start-tag-implies-end-tag",
+ "(1,119): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "nobr": true,
+ "br": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "#1"
+ }
+ ],
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "tag": "br"
+ },
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "#2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "#2"
+ }
+ ],
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "tag": "br"
+ },
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "#3"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "attrs": [
+ {
+ "name": "href",
+ "value": "#3"
+ }
+ ],
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "3"
+ }
+ ]
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><a href=\"#1\"><nobr>1</nobr><nobr></nobr></a><nobr><br><a href=\"#2\"></a></nobr><a href=\"#2\"><nobr>2</nobr><nobr></nobr></a><nobr><br><a href=\"#3\"></a></nobr><a href=\"#3\"><nobr>3</nobr><nobr></nobr></a></body></html>",
+ "noQuirksBodyHtml": "<a href=\"#1\"><nobr>1</nobr><nobr></nobr></a><nobr><br><a href=\"#2\"></a></nobr><a href=\"#2\"><nobr>2</nobr><nobr></nobr></a><nobr><br><a href=\"#3\"></a></nobr><a href=\"#3\"><nobr>3</nobr><nobr></nobr></a>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><b><nobr>1<nobr></b><i><nobr>2<nobr></i>3",
+ "errors": [
+ "(1,37): unexpected-start-tag-implies-end-tag",
+ "(1,41): adoption-agency-1.3",
+ "(1,50): unexpected-start-tag-implies-end-tag",
+ "(1,50): adoption-agency-1.3",
+ "(1,57): unexpected-start-tag-implies-end-tag",
+ "(1,61): adoption-agency-1.3",
+ "(1,62): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "nobr": true,
+ "i": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "3"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1</nobr><nobr></nobr></b><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></body></html>",
+ "noQuirksBodyHtml": "<b><nobr>1</nobr><nobr></nobr></b><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><b><nobr>1<table><nobr></b><i><nobr>2<nobr></i>3",
+ "errors": [
+ "(1,44): foster-parenting-start-tag",
+ "(1,48): foster-parenting-end-tag",
+ "(1,48): adoption-agency-1.3",
+ "(1,51): foster-parenting-start-tag",
+ "(1,57): foster-parenting-start-tag",
+ "(1,57): nobr-already-in-scope",
+ "(1,57): adoption-agency-1.2",
+ "(1,58): foster-parenting-character",
+ "(1,64): foster-parenting-start-tag",
+ "(1,64): nobr-already-in-scope",
+ "(1,68): foster-parenting-end-tag",
+ "(1,68): adoption-agency-1.2",
+ "(1,69): foster-parenting-character",
+ "(1,69): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "nobr": true,
+ "i": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "1"
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "3"
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1<nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr><table></table></nobr></b></body></html>",
+ "noQuirksBodyHtml": "<b><nobr>1<nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr><table></table></nobr></b>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><b><nobr>1<table><tr><td><nobr></b><i><nobr>2<nobr></i>3",
+ "errors": [
+ "(1,56): unexpected-end-tag",
+ "(1,65): unexpected-start-tag-implies-end-tag",
+ "(1,65): adoption-agency-1.3",
+ "(1,72): unexpected-start-tag-implies-end-tag",
+ "(1,76): adoption-agency-1.3",
+ "(1,77): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "nobr": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "i": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "1"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "3"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1<table><tbody><tr><td><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></td></tr></tbody></table></nobr></b></body></html>",
+ "noQuirksBodyHtml": "<b><nobr>1<table><tbody><tr><td><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></td></tr></tbody></table></nobr></b>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><b><nobr>1<div><nobr></b><i><nobr>2<nobr></i>3",
+ "errors": [
+ "(1,42): unexpected-start-tag-implies-end-tag",
+ "(1,42): adoption-agency-1.3",
+ "(1,46): adoption-agency-1.3",
+ "(1,46): adoption-agency-1.3",
+ "(1,55): unexpected-start-tag-implies-end-tag",
+ "(1,55): adoption-agency-1.3",
+ "(1,62): unexpected-start-tag-implies-end-tag",
+ "(1,66): adoption-agency-1.3",
+ "(1,67): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "nobr": true,
+ "div": true,
+ "i": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "nobr"
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "3"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1</nobr></b><div><b><nobr></nobr><nobr></nobr></b><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></div></body></html>",
+ "noQuirksBodyHtml": "<b><nobr>1</nobr></b><div><b><nobr></nobr><nobr></nobr></b><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></div>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><b><nobr>1<nobr></b><div><i><nobr>2<nobr></i>3",
+ "errors": [
+ "(1,37): unexpected-start-tag-implies-end-tag",
+ "(1,41): adoption-agency-1.3",
+ "(1,55): unexpected-start-tag-implies-end-tag",
+ "(1,55): adoption-agency-1.3",
+ "(1,62): unexpected-start-tag-implies-end-tag",
+ "(1,66): adoption-agency-1.3",
+ "(1,67): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "nobr": true,
+ "div": true,
+ "i": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "3"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1</nobr><nobr></nobr></b><div><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></div></body></html>",
+ "noQuirksBodyHtml": "<b><nobr>1</nobr><nobr></nobr></b><div><nobr><i></i></nobr><i><nobr>2</nobr><nobr></nobr></i><nobr>3</nobr></div>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><b><nobr>1<nobr><ins></b><i><nobr>",
+ "errors": [
+ "(1,37): unexpected-start-tag-implies-end-tag",
+ "(1,46): adoption-agency-1.3",
+ "(1,55): unexpected-start-tag-implies-end-tag",
+ "(1,55): adoption-agency-1.3",
+ "(1,55): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "nobr": true,
+ "ins": true,
+ "i": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "tag": "ins"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "nobr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1</nobr><nobr><ins></ins></nobr></b><nobr><i></i></nobr><i><nobr></nobr></i></body></html>",
+ "noQuirksBodyHtml": "<b><nobr>1</nobr><nobr><ins></ins></nobr></b><nobr><i></i></nobr><i><nobr></nobr></i>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><b><nobr>1<ins><nobr></b><i>2",
+ "errors": [
+ "(1,42): unexpected-start-tag-implies-end-tag",
+ "(1,42): adoption-agency-1.3",
+ "(1,46): adoption-agency-1.3",
+ "(1,50): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "nobr": true,
+ "ins": true,
+ "i": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "1"
+ },
+ {
+ "tag": "ins"
+ }
+ ]
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><b><nobr>1<ins></ins></nobr><nobr></nobr></b><nobr><i>2</i></nobr></body></html>",
+ "noQuirksBodyHtml": "<b><nobr>1<ins></ins></nobr><nobr></nobr></b><nobr><i>2</i></nobr>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><b>1<nobr></b><i><nobr>2</i>",
+ "errors": [
+ "(1,35): adoption-agency-1.3",
+ "(1,44): unexpected-start-tag-implies-end-tag",
+ "(1,44): adoption-agency-1.3",
+ "(1,49): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "nobr": true,
+ "i": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "1"
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "2"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><b>1<nobr></nobr></b><nobr><i></i></nobr><i><nobr>2</nobr></i></body></html>",
+ "noQuirksBodyHtml": "<b>1<nobr></nobr></b><nobr><i></i></nobr><i><nobr>2</nobr></i>"
+ }
+ },
+ {
+ "data": "<p><code x</code></p>\n",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,11): invalid-character-in-attribute-name",
+ "(1,12): unexpected-character-after-solidus-in-tag",
+ "(1,21): unexpected-end-tag",
+ "(2,0): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "code": true
+ },
+ "attrWithFunnyChar": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "code",
+ "attrs": [
+ {
+ "name": "code",
+ "value": ""
+ },
+ {
+ "name": "x<",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "code",
+ "attrs": [
+ {
+ "name": "code",
+ "value": ""
+ },
+ {
+ "name": "x<",
+ "value": ""
+ }
+ ],
+ "children": [
+ {
+ "text": "\n"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><code x<=\"\" code=\"\"></code></p><code x<=\"\" code=\"\">\n</code></body></html>",
+ "noQuirksBodyHtml": "<p><code x<=\"\" code=\"\"></code></p><code x<=\"\" code=\"\">\n</code>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><svg><foreignObject><p><i></p>a",
+ "errors": [
+ "(1,45): unexpected-end-tag",
+ "(1,46): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg foreignObject": true,
+ "p": true,
+ "i": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><svg><foreignObject><p><i></i></p><i>a</i></foreignObject></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><foreignObject><p><i></i></p><i>a</i></foreignObject></svg>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><table><tr><td><svg><foreignObject><p><i></p>a",
+ "errors": [
+ "(1,60): unexpected-end-tag",
+ "(1,61): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "svg svg": true,
+ "svg foreignObject": true,
+ "p": true,
+ "i": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><svg><foreignObject><p><i></i></p><i>a</i></foreignObject></svg></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><svg><foreignObject><p><i></i></p><i>a</i></foreignObject></svg></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><math><mtext><p><i></p>a",
+ "errors": [
+ "(1,38): unexpected-end-tag",
+ "(1,39): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mtext": true,
+ "p": true,
+ "i": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mtext><p><i></i></p><i>a</i></mtext></math></body></html>",
+ "noQuirksBodyHtml": "<math><mtext><p><i></i></p><i>a</i></mtext></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><table><tr><td><math><mtext><p><i></p>a",
+ "errors": [
+ "(1,53): unexpected-end-tag",
+ "(1,54): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "math math": true,
+ "math mtext": true,
+ "p": true,
+ "i": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mtext",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "i"
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><math><mtext><p><i></i></p><i>a</i></mtext></math></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><math><mtext><p><i></i></p><i>a</i></mtext></math></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><div><!/div>a",
+ "errors": [
+ "(1,28): expected-dashes-or-doctype",
+ "(1,34): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ },
+ "doctype": true,
+ "comment": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "comment": "/div"
+ },
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><div><!--/div-->a</div></body></html>",
+ "noQuirksBodyHtml": "<div><!--/div-->a</div>"
+ }
+ },
+ {
+ "data": "<button><p><button>",
+ "errors": [
+ "Line 1 Col 8 Unexpected start tag (button). Expected DOCTYPE.",
+ "Line 1 Col 19 Unexpected start tag (button) implies end tag (button).",
+ "Line 1 Col 19 Expected closing tag. Unexpected end of file."
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "button": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "button",
+ "children": [
+ {
+ "tag": "p"
+ }
+ ]
+ },
+ {
+ "tag": "button"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><button><p></p></button><button></button></body></html>",
+ "noQuirksBodyHtml": "<button><p></p></button><button></button>"
+ }
+ }
+ ],
+ "tests3.dat": [
+ {
+ "data": "<head></head><style></style>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,20): unexpected-start-tag-out-of-my-head"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style></style></head><body></body></html>",
+ "noQuirksBodyHtml": "<style></style>"
+ }
+ },
+ {
+ "data": "<head></head><script></script>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,21): unexpected-start-tag-out-of-my-head"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script></script></head><body></body></html>",
+ "noQuirksBodyHtml": "<script></script>"
+ }
+ },
+ {
+ "data": "<head></head><!-- --><style></style><!-- --><script></script>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,28): unexpected-start-tag-out-of-my-head",
+ "(1,52): unexpected-start-tag-out-of-my-head"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "script": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style"
+ },
+ {
+ "tag": "script"
+ }
+ ]
+ },
+ {
+ "comment": " "
+ },
+ {
+ "comment": " "
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style></style><script></script></head><!-- --><!-- --><body></body></html>",
+ "noQuirksBodyHtml": "<!-- --><style></style><!-- --><script></script>"
+ }
+ },
+ {
+ "data": "<head></head><!-- -->x<style></style><!-- --><script></script>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "style": true,
+ "script": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "comment": " "
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "x"
+ },
+ {
+ "tag": "style"
+ },
+ {
+ "comment": " "
+ },
+ {
+ "tag": "script"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><!-- --><body>x<style></style><!-- --><script></script></body></html>",
+ "noQuirksBodyHtml": "<!-- -->x<style></style><!-- --><script></script>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><head></head><body><pre>\n</pre></body></html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "pre": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "pre"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><pre></pre></body></html>",
+ "noQuirksBodyHtml": "<pre></pre>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><head></head><body><pre>\nfoo</pre></body></html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "pre": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "pre",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><pre>foo</pre></body></html>",
+ "noQuirksBodyHtml": "<pre>foo</pre>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><head></head><body><pre>\n\nfoo</pre></body></html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "pre": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "pre",
+ "children": [
+ {
+ "text": "\nfoo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><pre>\nfoo</pre></body></html>",
+ "noQuirksBodyHtml": "<pre>\nfoo</pre>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><head></head><body><pre>\nfoo\n</pre></body></html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "pre": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "pre",
+ "children": [
+ {
+ "text": "foo\n"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><pre>foo\n</pre></body></html>",
+ "noQuirksBodyHtml": "<pre>foo\n</pre>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><head></head><body><pre>x</pre><span>\n</span></body></html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "pre": true,
+ "span": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "pre",
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ },
+ {
+ "tag": "span",
+ "children": [
+ {
+ "text": "\n"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><pre>x</pre><span>\n</span></body></html>",
+ "noQuirksBodyHtml": "<pre>x</pre><span>\n</span>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><head></head><body><pre>x\ny</pre></body></html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "pre": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "pre",
+ "children": [
+ {
+ "text": "x\ny"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><pre>x\ny</pre></body></html>",
+ "noQuirksBodyHtml": "<pre>x\ny</pre>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><head></head><body><pre>x<div>\ny</pre></body></html>",
+ "errors": [
+ "(2,7): end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "pre": true,
+ "div": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "pre",
+ "children": [
+ {
+ "text": "x"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "\ny"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><pre>x<div>\ny</div></pre></body></html>",
+ "noQuirksBodyHtml": "<pre>x<div>\ny</div></pre>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><pre>&#x0a;&#x0a;A</pre>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "pre": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "pre",
+ "children": [
+ {
+ "text": "\nA"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><pre>\nA</pre></body></html>",
+ "noQuirksBodyHtml": "<pre>\nA</pre>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><HTML><META><HEAD></HEAD></HTML>",
+ "errors": [
+ "(1,33): two-heads-are-not-better-than-one"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "meta": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "meta"
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><meta></head><body></body></html>",
+ "noQuirksBodyHtml": "<meta>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><HTML><HEAD><head></HEAD></HTML>",
+ "errors": [
+ "(1,33): two-heads-are-not-better-than-one"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<textarea>foo<span>bar</span><i>baz",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,35): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "foo<span>bar</span><i>baz",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><textarea>foo&lt;span&gt;bar&lt;/span&gt;&lt;i&gt;baz</textarea></body></html>",
+ "noQuirksBodyHtml": "<textarea>foo&lt;span&gt;bar&lt;/span&gt;&lt;i&gt;baz</textarea>"
+ }
+ },
+ {
+ "data": "<title>foo<span>bar</em><i>baz",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,30): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "foo<span>bar</em><i>baz",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><title>foo&lt;span&gt;bar&lt;/em&gt;&lt;i&gt;baz</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>foo&lt;span&gt;bar&lt;/em&gt;&lt;i&gt;baz</title>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><textarea>\n</textarea>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><textarea></textarea></body></html>",
+ "noQuirksBodyHtml": "<textarea></textarea>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><textarea>\nfoo</textarea>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><textarea>foo</textarea></body></html>",
+ "noQuirksBodyHtml": "<textarea>foo</textarea>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><textarea>\n\nfoo</textarea>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": "\nfoo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><textarea>\nfoo</textarea></body></html>",
+ "noQuirksBodyHtml": "<textarea>\nfoo</textarea>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><html><head></head><body><ul><li><div><p><li></ul></body></html>",
+ "errors": [
+ "(1,60): end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ul": true,
+ "li": true,
+ "div": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ul",
+ "children": [
+ {
+ "tag": "li",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "p"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "li"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><ul><li><div><p></p></div></li><li></li></ul></body></html>",
+ "noQuirksBodyHtml": "<ul><li><div><p></p></div></li><li></li></ul>"
+ }
+ },
+ {
+ "data": "<!doctype html><nobr><nobr><nobr>",
+ "errors": [
+ "(1,27): unexpected-start-tag-implies-end-tag",
+ "(1,33): unexpected-start-tag-implies-end-tag",
+ "(1,33): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "nobr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "nobr"
+ },
+ {
+ "tag": "nobr"
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><nobr></nobr><nobr></nobr><nobr></nobr></body></html>",
+ "noQuirksBodyHtml": "<nobr></nobr><nobr></nobr><nobr></nobr>"
+ }
+ },
+ {
+ "data": "<!doctype html><nobr><nobr></nobr><nobr>",
+ "errors": [
+ "(1,27): unexpected-start-tag-implies-end-tag",
+ "(1,40): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "nobr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "nobr"
+ },
+ {
+ "tag": "nobr"
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><nobr></nobr><nobr></nobr><nobr></nobr></body></html>",
+ "noQuirksBodyHtml": "<nobr></nobr><nobr></nobr><nobr></nobr>"
+ }
+ },
+ {
+ "data": "<!doctype html><html><body><p><table></table></body></html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p></p><table></table></body></html>",
+ "noQuirksBodyHtml": "<p></p><table></table>"
+ }
+ },
+ {
+ "data": "<p><table></table>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><table></table></p></body></html>",
+ "noQuirksBodyHtml": "<p></p><table></table>"
+ }
+ }
+ ],
+ "tests4.dat": [
+ {
+ "data": "direct div content",
+ "errors": [],
+ "fragment": {
+ "name": "div"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "direct div content"
+ }
+ ],
+ "html": "direct div content",
+ "noQuirksBodyHtml": "direct div content"
+ }
+ },
+ {
+ "data": "direct textarea content",
+ "errors": [],
+ "fragment": {
+ "name": "textarea"
+ },
+ "document": {
+ "props": {
+ "tags": {}
+ },
+ "tree": [
+ {
+ "text": "direct textarea content"
+ }
+ ],
+ "html": "direct textarea content",
+ "noQuirksBodyHtml": "direct textarea content"
+ }
+ },
+ {
+ "data": "textarea content with <em>pseudo</em> <foo>markup",
+ "errors": [],
+ "fragment": {
+ "name": "textarea"
+ },
+ "document": {
+ "props": {
+ "tags": {},
+ "escaped": true
+ },
+ "tree": [
+ {
+ "text": "textarea content with <em>pseudo</em> <foo>markup",
+ "escaped": true
+ }
+ ],
+ "html": "textarea content with &lt;em&gt;pseudo&lt;/em&gt; &lt;foo&gt;markup",
+ "noQuirksBodyHtml": "textarea content with <em>pseudo</em> <foo>markup</foo>"
+ }
+ },
+ {
+ "data": "this is &#x0043;DATA inside a <style> element",
+ "errors": [],
+ "fragment": {
+ "name": "style"
+ },
+ "document": {
+ "props": {
+ "tags": {},
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "text": "this is &#x0043;DATA inside a <style> element",
+ "no_escape": true
+ }
+ ],
+ "html": "this is &#x0043;DATA inside a <style> element",
+ "noQuirksBodyHtml": "this is CDATA inside a <style> element</style>"
+ }
+ },
+ {
+ "data": "</plaintext>",
+ "errors": [],
+ "fragment": {
+ "name": "plaintext"
+ },
+ "document": {
+ "props": {
+ "tags": {},
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "text": "</plaintext>",
+ "no_escape": true
+ }
+ ],
+ "html": "</plaintext>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "setting html's innerHTML",
+ "errors": [],
+ "fragment": {
+ "name": "html"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "setting html's innerHTML"
+ }
+ ]
+ }
+ ],
+ "html": "<head></head><body>setting html's innerHTML</body>",
+ "noQuirksBodyHtml": "setting html's innerHTML"
+ }
+ },
+ {
+ "data": "<title>setting head's innerHTML</title>",
+ "errors": [],
+ "fragment": {
+ "name": "head"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "title": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "setting head's innerHTML"
+ }
+ ]
+ }
+ ],
+ "html": "<title>setting head's innerHTML</title>",
+ "noQuirksBodyHtml": "<title>setting head's innerHTML</title>"
+ }
+ }
+ ],
+ "tests5.dat": [
+ {
+ "data": "<style> <!-- </style>x",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": " <!-- ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style> <!-- </style></head><body>x</body></html>",
+ "noQuirksBodyHtml": "<style> <!-- </style>x"
+ }
+ },
+ {
+ "data": "<style> <!-- </style> --> </style>x",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,34): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": " <!-- ",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": " "
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "--> x",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style> <!-- </style> </head><body>--&gt; x</body></html>",
+ "noQuirksBodyHtml": "<style> <!-- </style> --&gt; x"
+ }
+ },
+ {
+ "data": "<style> <!--> </style>x",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": " <!--> ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style> <!--> </style></head><body>x</body></html>",
+ "noQuirksBodyHtml": "<style> <!--> </style>x"
+ }
+ },
+ {
+ "data": "<style> <!---> </style>x",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": " <!---> ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style> <!---> </style></head><body>x</body></html>",
+ "noQuirksBodyHtml": "<style> <!---> </style>x"
+ }
+ },
+ {
+ "data": "<iframe> <!---> </iframe>x",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "iframe": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "iframe",
+ "children": [
+ {
+ "text": " <!---> ",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><iframe> <!---> </iframe>x</body></html>",
+ "noQuirksBodyHtml": "<iframe> <!---> </iframe>x"
+ }
+ },
+ {
+ "data": "<iframe> <!--- </iframe>->x</iframe> --> </iframe>x",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,36): unexpected-end-tag",
+ "(1,50): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "iframe": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "iframe",
+ "children": [
+ {
+ "text": " <!--- ",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "->x --> x",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><iframe> <!--- </iframe>-&gt;x --&gt; x</body></html>",
+ "noQuirksBodyHtml": "<iframe> <!--- </iframe>-&gt;x --&gt; x"
+ }
+ },
+ {
+ "data": "<script> <!-- </script> --> </script>x",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,37): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "script": true,
+ "body": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": " <!-- ",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": " "
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "--> x",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><script> <!-- </script> </head><body>--&gt; x</body></html>",
+ "noQuirksBodyHtml": "<script> <!-- </script> --&gt; x"
+ }
+ },
+ {
+ "data": "<title> <!-- </title> --> </title>x",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,34): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": " <!-- ",
+ "escaped": true
+ }
+ ]
+ },
+ {
+ "text": " "
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "--> x",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><title> &lt;!-- </title> </head><body>--&gt; x</body></html>",
+ "noQuirksBodyHtml": "<title> &lt;!-- </title> --&gt; x"
+ }
+ },
+ {
+ "data": "<textarea> <!--- </textarea>->x</textarea> --> </textarea>x",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,42): unexpected-end-tag",
+ "(1,58): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "textarea": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "textarea",
+ "children": [
+ {
+ "text": " <!--- ",
+ "escaped": true
+ }
+ ]
+ },
+ {
+ "text": "->x --> x",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><textarea> &lt;!--- </textarea>-&gt;x --&gt; x</body></html>",
+ "noQuirksBodyHtml": "<textarea> &lt;!--- </textarea>-&gt;x --&gt; x"
+ }
+ },
+ {
+ "data": "<style> <!</-- </style>x",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": " <!</-- ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "x"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style> <!</-- </style></head><body>x</body></html>",
+ "noQuirksBodyHtml": "<style> <!</-- </style>x"
+ }
+ },
+ {
+ "data": "<p><xmp></xmp>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "xmp": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p"
+ },
+ {
+ "tag": "xmp"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p></p><xmp></xmp></body></html>",
+ "noQuirksBodyHtml": "<p></p><xmp></xmp>"
+ }
+ },
+ {
+ "data": "<xmp> <!-- > --> </xmp>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "xmp": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "xmp",
+ "children": [
+ {
+ "text": " <!-- > --> ",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><xmp> <!-- > --> </xmp></body></html>",
+ "noQuirksBodyHtml": "<xmp> <!-- > --> </xmp>"
+ }
+ },
+ {
+ "data": "<title>&amp;</title>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "&",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><title>&amp;</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>&amp;</title>"
+ }
+ },
+ {
+ "data": "<title><!--&amp;--></title>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "<!--&-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><title>&lt;!--&amp;--&gt;</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>&lt;!--&amp;--&gt;</title>"
+ }
+ },
+ {
+ "data": "<title><!--</title>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "<!--",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><title>&lt;!--</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>&lt;!--</title>"
+ }
+ },
+ {
+ "data": "<noscript><!--</noscript>--></noscript>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,39): unexpected-end-tag"
+ ],
+ "script": "on",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "no_escape": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "text": "<!--",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><!--</noscript></head><body>--&gt;</body></html>",
+ "noQuirksBodyHtml": "<noscript><!--</noscript>--></noscript>"
+ }
+ },
+ {
+ "data": "<noscript><!--</noscript>--></noscript>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag"
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "noscript": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "comment": "</noscript>"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><noscript><!--</noscript>--></noscript></head><body></body></html>",
+ "noQuirksBodyHtml": "<noscript><!--</noscript>--></noscript>"
+ }
+ }
+ ],
+ "tests6.dat": [
+ {
+ "data": "<!doctype html></head> <head>",
+ "errors": [
+ "(1,29): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "text": " "
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head> <body></body></html>",
+ "noQuirksBodyHtml": " "
+ }
+ },
+ {
+ "data": "<!doctype html><form><div></form><div>",
+ "errors": [
+ "(1,33): end-tag-too-early-ignored",
+ "(1,38): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "form": true,
+ "div": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "form",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><form><div><div></div></div></form></body></html>",
+ "noQuirksBodyHtml": "<form><div><div></div></div></form>"
+ }
+ },
+ {
+ "data": "<!doctype html><title>&amp;</title>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "doctype": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "&",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><title>&amp;</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>&amp;</title>"
+ }
+ },
+ {
+ "data": "<!doctype html><title><!--&amp;--></title>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "doctype": true,
+ "escaped": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "<!--&-->",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><title>&lt;!--&amp;--&gt;</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>&lt;!--&amp;--&gt;</title>"
+ }
+ },
+ {
+ "data": "<!doctype>",
+ "errors": [
+ "(1,9): need-space-after-doctype",
+ "(1,10): expected-doctype-name-but-got-right-bracket",
+ "(1,10): unknown-doctype"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": ""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE ><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!---x",
+ "errors": [
+ "(1,6): eof-in-comment",
+ "(1,6): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": "-x"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!---x--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!---x-->"
+ }
+ },
+ {
+ "data": "<body>\n<div>",
+ "errors": [
+ "(1,6): unexpected-start-tag",
+ "(2,5): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "div"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "\n<div></div>",
+ "noQuirksBodyHtml": "\n<div></div>"
+ }
+ },
+ {
+ "data": "<frameset></frameset>\nfoo",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(2,1): unexpected-char-after-frameset",
+ "(2,2): unexpected-char-after-frameset",
+ "(2,3): unexpected-char-after-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ },
+ {
+ "text": "\n"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset>\n</html>",
+ "noQuirksBodyHtml": "\nfoo"
+ }
+ },
+ {
+ "data": "<frameset></frameset>\n<noframes>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(2,10): expected-named-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "noframes": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ },
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "noframes"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset>\n<noframes></noframes></html>",
+ "noQuirksBodyHtml": "\n<noframes></noframes>"
+ }
+ },
+ {
+ "data": "<frameset></frameset>\n<div>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(2,5): unexpected-start-tag-after-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ },
+ {
+ "text": "\n"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset>\n</html>",
+ "noQuirksBodyHtml": "\n<div></div>"
+ }
+ },
+ {
+ "data": "<frameset></frameset>\n</html>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ },
+ {
+ "text": "\n"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset>\n</html>",
+ "noQuirksBodyHtml": "\n"
+ }
+ },
+ {
+ "data": "<frameset></frameset>\n</div>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(2,6): unexpected-end-tag-after-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ },
+ {
+ "text": "\n"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset>\n</html>",
+ "noQuirksBodyHtml": "\n"
+ }
+ },
+ {
+ "data": "<form><form>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,12): unexpected-start-tag",
+ "(1,12): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "form": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "form"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><form></form></body></html>",
+ "noQuirksBodyHtml": "<form></form>"
+ }
+ },
+ {
+ "data": "<button><button>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,16): unexpected-start-tag-implies-end-tag",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "button": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "button"
+ },
+ {
+ "tag": "button"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><button></button><button></button></body></html>",
+ "noQuirksBodyHtml": "<button></button><button></button>"
+ }
+ },
+ {
+ "data": "<table><tr><td></th>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,20): unexpected-end-tag",
+ "(1,20): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><caption><td>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,20): unexpected-cell-in-table-body",
+ "(1,20): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption"
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><caption></caption><tbody><tr><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><caption></caption><tbody><tr><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><caption><div>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,21): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><caption><div></div></caption></table></body></html>",
+ "noQuirksBodyHtml": "<table><caption><div></div></caption></table>"
+ }
+ },
+ {
+ "data": "</caption><div>",
+ "errors": [
+ "(1,10): XXX-undefined-error",
+ "(1,15): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<table><caption><div></caption>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,31): expected-one-end-tag-but-got-another",
+ "(1,31): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><caption><div></div></caption></table></body></html>",
+ "noQuirksBodyHtml": "<table><caption><div></div></caption></table>"
+ }
+ },
+ {
+ "data": "<table><caption></table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><caption></caption></table></body></html>",
+ "noQuirksBodyHtml": "<table><caption></caption></table>"
+ }
+ },
+ {
+ "data": "</table><div>",
+ "errors": [
+ "(1,8): unexpected-end-tag",
+ "(1,13): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<table><caption></body></col></colgroup></html></tbody></td></tfoot></th></thead></tr>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,23): unexpected-end-tag",
+ "(1,29): unexpected-end-tag",
+ "(1,40): unexpected-end-tag",
+ "(1,47): unexpected-end-tag",
+ "(1,55): unexpected-end-tag",
+ "(1,60): unexpected-end-tag",
+ "(1,68): unexpected-end-tag",
+ "(1,73): unexpected-end-tag",
+ "(1,81): unexpected-end-tag",
+ "(1,86): unexpected-end-tag",
+ "(1,86): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><caption></caption></table></body></html>",
+ "noQuirksBodyHtml": "<table><caption></caption></table>"
+ }
+ },
+ {
+ "data": "<table><caption><div></div>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,27): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><caption><div></div></caption></table></body></html>",
+ "noQuirksBodyHtml": "<table><caption><div></div></caption></table>"
+ }
+ },
+ {
+ "data": "<table><tr><td></body></caption></col></colgroup></html>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,22): unexpected-end-tag",
+ "(1,32): unexpected-end-tag",
+ "(1,38): unexpected-end-tag",
+ "(1,49): unexpected-end-tag",
+ "(1,56): unexpected-end-tag",
+ "(1,56): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "</table></tbody></tfoot></thead></tr><div>",
+ "errors": [
+ "(1,8): unexpected-end-tag",
+ "(1,16): unexpected-end-tag",
+ "(1,24): unexpected-end-tag",
+ "(1,32): unexpected-end-tag",
+ "(1,37): unexpected-end-tag",
+ "(1,42): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<table><colgroup>foo",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,18): foster-parenting-character-in-table",
+ "(1,19): foster-parenting-character-in-table",
+ "(1,20): foster-parenting-character-in-table",
+ "(1,20): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "foo"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>foo<table><colgroup></colgroup></table></body></html>",
+ "noQuirksBodyHtml": "foo<table><colgroup></colgroup></table>"
+ }
+ },
+ {
+ "data": "foo<col>",
+ "errors": [
+ "(1,1): unexpected-character-in-colgroup",
+ "(1,2): unexpected-character-in-colgroup",
+ "(1,3): unexpected-character-in-colgroup"
+ ],
+ "fragment": {
+ "name": "colgroup"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "col": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "col"
+ }
+ ],
+ "html": "<col>",
+ "noQuirksBodyHtml": "foo"
+ }
+ },
+ {
+ "data": "<table><colgroup></col>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,23): no-end-tag",
+ "(1,23): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "colgroup": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><colgroup></colgroup></table></body></html>",
+ "noQuirksBodyHtml": "<table><colgroup></colgroup></table>"
+ }
+ },
+ {
+ "data": "<frameset><div>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,15): unexpected-start-tag-in-frameset",
+ "(1,15): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "</frameset><frame>",
+ "errors": [
+ "(1,11): unexpected-frameset-in-frameset-innerhtml"
+ ],
+ "fragment": {
+ "name": "frameset"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "frame": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "frame"
+ }
+ ],
+ "html": "<frame>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<frameset></div>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag",
+ "(1,16): unexpected-end-tag-in-frameset",
+ "(1,16): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "</body><div>",
+ "errors": [
+ "(1,7): unexpected-close-tag",
+ "(1,12): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "body"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "div"
+ }
+ ],
+ "html": "<div></div>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<table><tr><div>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,16): unexpected-start-tag-implies-table-voodoo",
+ "(1,16): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div></div><table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<div></div><table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "</tr><td>",
+ "errors": [
+ "(1,5): unexpected-end-tag"
+ ],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td></td>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "</tbody></tfoot></thead><td>",
+ "errors": [
+ "(1,8): unexpected-end-tag",
+ "(1,16): unexpected-end-tag",
+ "(1,24): unexpected-end-tag"
+ ],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td></td>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<table><tr><div><td>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,16): foster-parenting-start-tag",
+ "(1,20): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div></div><table><tbody><tr><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<div></div><table><tbody><tr><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<caption><col><colgroup><tbody><tfoot><thead><tr>",
+ "errors": [
+ "(1,9): unexpected-start-tag",
+ "(1,14): unexpected-start-tag",
+ "(1,24): unexpected-start-tag",
+ "(1,31): unexpected-start-tag",
+ "(1,38): unexpected-start-tag",
+ "(1,45): unexpected-start-tag"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "tr"
+ }
+ ],
+ "html": "<tr></tr>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<table><tbody></thead>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,22): unexpected-end-tag-in-table-body",
+ "(1,22): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody></tbody></table>"
+ }
+ },
+ {
+ "data": "</table><tr>",
+ "errors": [
+ "(1,8): unexpected-end-tag"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "tr"
+ }
+ ],
+ "html": "<tr></tr>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<table><tbody></body></caption></col></colgroup></html></td></th></tr>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,21): unexpected-end-tag-in-table-body",
+ "(1,31): unexpected-end-tag-in-table-body",
+ "(1,37): unexpected-end-tag-in-table-body",
+ "(1,48): unexpected-end-tag-in-table-body",
+ "(1,55): unexpected-end-tag-in-table-body",
+ "(1,60): unexpected-end-tag-in-table-body",
+ "(1,65): unexpected-end-tag-in-table-body",
+ "(1,70): unexpected-end-tag-in-table-body",
+ "(1,70): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><tbody></div>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,20): unexpected-end-tag-implies-table-voodoo",
+ "(1,20): end-tag-too-early",
+ "(1,20): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,14): unexpected-start-tag-implies-end-tag",
+ "(1,14): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table></table><table></table></body></html>",
+ "noQuirksBodyHtml": "<table></table><table></table>"
+ }
+ },
+ {
+ "data": "<table></body></caption></col></colgroup></html></tbody></td></tfoot></th></thead></tr>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,14): unexpected-end-tag",
+ "(1,24): unexpected-end-tag",
+ "(1,30): unexpected-end-tag",
+ "(1,41): unexpected-end-tag",
+ "(1,48): unexpected-end-tag",
+ "(1,56): unexpected-end-tag",
+ "(1,61): unexpected-end-tag",
+ "(1,69): unexpected-end-tag",
+ "(1,74): unexpected-end-tag",
+ "(1,82): unexpected-end-tag",
+ "(1,87): unexpected-end-tag",
+ "(1,87): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table></table></body></html>",
+ "noQuirksBodyHtml": "<table></table>"
+ }
+ },
+ {
+ "data": "</table><tr>",
+ "errors": [
+ "(1,8): unexpected-end-tag"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "tbody": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ],
+ "html": "<tbody><tr></tr></tbody>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<body></body></html>",
+ "errors": [
+ "(1,20): unexpected-end-tag-after-body-innerhtml"
+ ],
+ "fragment": {
+ "name": "html"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ],
+ "html": "<head></head><body></body>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<html><frameset></frameset></html> ",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ },
+ {
+ "text": " "
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset> </html>",
+ "noQuirksBodyHtml": " "
+ }
+ },
+ {
+ "data": "<!DOCTYPE html PUBLIC \"-//W3C//DTD HTML 4.01//EN\"><html></html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html \"-//W3C//DTD HTML 4.01//EN\" \"\""
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<param><frameset></frameset>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,17): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<param>"
+ }
+ },
+ {
+ "data": "<source><frameset></frameset>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,18): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<source>"
+ }
+ },
+ {
+ "data": "<track><frameset></frameset>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,17): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<track>"
+ }
+ },
+ {
+ "data": "</html><frameset></frameset>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-end-tag",
+ "(1,17): expected-eof-but-got-start-tag",
+ "(1,17): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "</body><frameset></frameset>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-end-tag",
+ "(1,17): unexpected-start-tag-after-body",
+ "(1,17): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": ""
+ }
+ }
+ ],
+ "tests7.dat": [
+ {
+ "data": "<!doctype html><body><title>X</title>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "title": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><title>X</title></body></html>",
+ "noQuirksBodyHtml": "<title>X</title>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><title>X</title></table>",
+ "errors": [
+ "(1,29): unexpected-start-tag-implies-table-voodoo"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "title": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><title>X</title><table></table></body></html>",
+ "noQuirksBodyHtml": "<title>X</title><table></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><head></head><title>X</title>",
+ "errors": [
+ "(1,35): unexpected-start-tag-out-of-my-head"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><title>X</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>X</title>"
+ }
+ },
+ {
+ "data": "<!doctype html></head><title>X</title>",
+ "errors": [
+ "(1,29): unexpected-start-tag-out-of-my-head"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "title": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "title",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head><title>X</title></head><body></body></html>",
+ "noQuirksBodyHtml": "<title>X</title>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><meta></table>",
+ "errors": [
+ "(1,28): unexpected-start-tag-implies-table-voodoo"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "meta": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "meta"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><meta><table></table></body></html>",
+ "noQuirksBodyHtml": "<meta><table></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table>X<tr><td><table> <meta></table></table>",
+ "errors": [
+ "unexpected text in table",
+ "(1,45): unexpected-start-tag-implies-table-voodoo"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "meta": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "meta"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "text": " "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>X<table><tbody><tr><td><meta><table> </table></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "X<table><tbody><tr><td><meta><table> </table></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><html> <head>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": " "
+ }
+ },
+ {
+ "data": "<!doctype html> <head>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": " "
+ }
+ },
+ {
+ "data": "<!doctype html><table><style> <tr>x </style> </table>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "style": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "style",
+ "children": [
+ {
+ "text": " <tr>x ",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": " "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><style> <tr>x </style> </table></body></html>",
+ "noQuirksBodyHtml": "<table><style> <tr>x </style> </table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><TBODY><script> <tr>x </script> </table>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "script": true
+ },
+ "doctype": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": " <tr>x ",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": " "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><script> <tr>x </script> </tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><script> <tr>x </script> </tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><applet><p>X</p></applet>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "applet": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "applet",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><applet><p>X</p></applet></p></body></html>",
+ "noQuirksBodyHtml": "<p><applet><p>X</p></applet></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><p><object type=\"application/x-non-existant-plugin\"><p>X</p></object>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "object": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "object",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "application/x-non-existant-plugin"
+ }
+ ],
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><p><object type=\"application/x-non-existant-plugin\"><p>X</p></object></p></body></html>",
+ "noQuirksBodyHtml": "<p><object type=\"application/x-non-existant-plugin\"><p>X</p></object></p>"
+ }
+ },
+ {
+ "data": "<!doctype html><listing>\nX</listing>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "listing": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "listing",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><listing>X</listing></body></html>",
+ "noQuirksBodyHtml": "<listing>X</listing>"
+ }
+ },
+ {
+ "data": "<!doctype html><select><input>X",
+ "errors": [
+ "(1,30): unexpected-input-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "input": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ },
+ {
+ "tag": "input"
+ },
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select><input>X</body></html>",
+ "noQuirksBodyHtml": "<select></select><input>X"
+ }
+ },
+ {
+ "data": "<!doctype html><select><select>X",
+ "errors": [
+ "(1,31): unexpected-select-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ },
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select>X</body></html>",
+ "noQuirksBodyHtml": "<select></select>X"
+ }
+ },
+ {
+ "data": "<!doctype html><table><input type=hidDEN></table>",
+ "errors": [
+ "(1,41): unexpected-hidden-input-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "input": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "input",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "hidDEN"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><input type=\"hidDEN\"></table></body></html>",
+ "noQuirksBodyHtml": "<table><input type=\"hidDEN\"></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table>X<input type=hidDEN></table>",
+ "errors": [
+ "(1,23): foster-parenting-character",
+ "(1,42): unexpected-hidden-input-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "input": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "input",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "hidDEN"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body>X<table><input type=\"hidDEN\"></table></body></html>",
+ "noQuirksBodyHtml": "X<table><input type=\"hidDEN\"></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table> <input type=hidDEN></table>",
+ "errors": [
+ "(1,43): unexpected-hidden-input-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "input": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "text": " "
+ },
+ {
+ "tag": "input",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "hidDEN"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table> <input type=\"hidDEN\"></table></body></html>",
+ "noQuirksBodyHtml": "<table> <input type=\"hidDEN\"></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table> <input type='hidDEN'></table>",
+ "errors": [
+ "(1,45): unexpected-hidden-input-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "input": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "text": " "
+ },
+ {
+ "tag": "input",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "hidDEN"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table> <input type=\"hidDEN\"></table></body></html>",
+ "noQuirksBodyHtml": "<table> <input type=\"hidDEN\"></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><input type=\" hidden\"><input type=hidDEN></table>",
+ "errors": [
+ "(1,44): unexpected-start-tag-implies-table-voodoo",
+ "(1,63): unexpected-hidden-input-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "input": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "input",
+ "attrs": [
+ {
+ "name": "type",
+ "value": " hidden"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "input",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "hidDEN"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><input type=\" hidden\"><table><input type=\"hidDEN\"></table></body></html>",
+ "noQuirksBodyHtml": "<input type=\" hidden\"><table><input type=\"hidDEN\"></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><table><select>X<tr>",
+ "errors": [
+ "(1,30): unexpected-start-tag-implies-table-voodoo",
+ "(1,35): unexpected-table-element-start-tag-in-select-in-table",
+ "(1,35): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select>X</select><table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<select>X</select><table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!doctype html><select>X</select>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select>X</select></body></html>",
+ "noQuirksBodyHtml": "<select>X</select>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE hTmL><html></html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<!DOCTYPE HTML><html></html>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<body>X</body></body>",
+ "errors": [
+ "(1,21): unexpected-end-tag-after-body"
+ ],
+ "fragment": {
+ "name": "html"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "X"
+ }
+ ]
+ }
+ ],
+ "html": "<head></head><body>X</body>",
+ "noQuirksBodyHtml": "X"
+ }
+ },
+ {
+ "data": "<div><p>a</x> b",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,13): unexpected-end-tag",
+ "(1,15): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "a b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><p>a b</p></div></body></html>",
+ "noQuirksBodyHtml": "<div><p>a b</p></div>"
+ }
+ },
+ {
+ "data": "<table><tr><td><code></code> </table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "code": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "code"
+ },
+ {
+ "text": " "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td><code></code> </td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><code></code> </td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><b><tr><td>aaa</td></tr>bbb</table>ccc",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,10): foster-parenting-start-tag",
+ "(1,32): foster-parenting-character",
+ "(1,33): foster-parenting-character",
+ "(1,34): foster-parenting-character",
+ "(1,45): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "bbb"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "aaa"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "ccc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b></b><b>bbb</b><table><tbody><tr><td>aaa</td></tr></tbody></table><b>ccc</b></body></html>",
+ "noQuirksBodyHtml": "<b></b><b>bbb</b><table><tbody><tr><td>aaa</td></tr></tbody></table><b>ccc</b>"
+ }
+ },
+ {
+ "data": "A<table><tr> B</tr> B</table>",
+ "errors": [
+ "(1,1): expected-doctype-but-got-chars",
+ "(1,13): foster-parenting-character",
+ "(1,14): foster-parenting-character",
+ "(1,20): foster-parenting-character",
+ "(1,21): foster-parenting-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "A B B"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>A B B<table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "A B B<table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "A<table><tr> B</tr> </em>C</table>",
+ "errors": [
+ "(1,1): expected-doctype-but-got-chars",
+ "(1,13): foster-parenting-character",
+ "(1,14): foster-parenting-character",
+ "(1,20): foster-parenting-character",
+ "(1,25): unexpected-end-tag",
+ "(1,25): unexpected-end-tag-in-special-element",
+ "(1,26): foster-parenting-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "A BC"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ },
+ {
+ "text": " "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>A BC<table><tbody><tr></tr> </tbody></table></body></html>",
+ "noQuirksBodyHtml": "A BC<table><tbody><tr></tr> </tbody></table>"
+ }
+ },
+ {
+ "data": "<select><keygen>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,16): unexpected-input-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "keygen": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ },
+ {
+ "tag": "keygen"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select></select><keygen></body></html>",
+ "noQuirksBodyHtml": "<select></select><keygen>"
+ }
+ }
+ ],
+ "tests8.dat": [
+ {
+ "data": "<div>\n<div></div>\n</span>x",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(3,7): unexpected-end-tag",
+ "(3,8): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "div"
+ },
+ {
+ "text": "\nx"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>\n<div></div>\nx</div></body></html>",
+ "noQuirksBodyHtml": "<div>\n<div></div>\nx</div>"
+ }
+ },
+ {
+ "data": "<div>x<div></div>\n</span>x",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(2,7): unexpected-end-tag",
+ "(2,8): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "x"
+ },
+ {
+ "tag": "div"
+ },
+ {
+ "text": "\nx"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>x<div></div>\nx</div></body></html>",
+ "noQuirksBodyHtml": "<div>x<div></div>\nx</div>"
+ }
+ },
+ {
+ "data": "<div>x<div></div>x</span>x",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,25): unexpected-end-tag",
+ "(1,26): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "x"
+ },
+ {
+ "tag": "div"
+ },
+ {
+ "text": "xx"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>x<div></div>xx</div></body></html>",
+ "noQuirksBodyHtml": "<div>x<div></div>xx</div>"
+ }
+ },
+ {
+ "data": "<div>x<div></div>y</span>z",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,25): unexpected-end-tag",
+ "(1,26): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "x"
+ },
+ {
+ "tag": "div"
+ },
+ {
+ "text": "yz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>x<div></div>yz</div></body></html>",
+ "noQuirksBodyHtml": "<div>x<div></div>yz</div>"
+ }
+ },
+ {
+ "data": "<table><div>x<div></div>x</span>x",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,12): foster-parenting-start-tag",
+ "(1,13): foster-parenting-character",
+ "(1,18): foster-parenting-start-tag",
+ "(1,24): foster-parenting-end-tag",
+ "(1,25): foster-parenting-start-tag",
+ "(1,32): foster-parenting-end-tag",
+ "(1,32): unexpected-end-tag",
+ "(1,33): foster-parenting-character",
+ "(1,33): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "x"
+ },
+ {
+ "tag": "div"
+ },
+ {
+ "text": "xx"
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>x<div></div>xx</div><table></table></body></html>",
+ "noQuirksBodyHtml": "<div>x<div></div>xx</div><table></table>"
+ }
+ },
+ {
+ "data": "x<table>x",
+ "errors": [
+ "(1,1): expected-doctype-but-got-chars",
+ "(1,9): foster-parenting-character",
+ "(1,9): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "xx"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>xx<table></table></body></html>",
+ "noQuirksBodyHtml": "xx<table></table>"
+ }
+ },
+ {
+ "data": "x<table><table>x",
+ "errors": [
+ "(1,1): expected-doctype-but-got-chars",
+ "(1,15): unexpected-start-tag-implies-end-tag",
+ "(1,16): foster-parenting-character",
+ "(1,16): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "x"
+ },
+ {
+ "tag": "table"
+ },
+ {
+ "text": "x"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>x<table></table>x<table></table></body></html>",
+ "noQuirksBodyHtml": "x<table></table>x<table></table>"
+ }
+ },
+ {
+ "data": "<b>a<div></div><div></b>y",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,24): adoption-agency-1.3",
+ "(1,25): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "a"
+ },
+ {
+ "tag": "div"
+ }
+ ]
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "b"
+ },
+ {
+ "text": "y"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b>a<div></div></b><div><b></b>y</div></body></html>",
+ "noQuirksBodyHtml": "<b>a<div></div></b><div><b></b>y</div>"
+ }
+ },
+ {
+ "data": "<a><div><p></a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,15): adoption-agency-1.3",
+ "(1,15): adoption-agency-1.3",
+ "(1,15): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "div": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a></a><div><a></a><p><a></a></p></div></body></html>",
+ "noQuirksBodyHtml": "<a></a><div><a></a><p><a></a></p></div>"
+ }
+ }
+ ],
+ "tests9.dat": [
+ {
+ "data": "<!DOCTYPE html><math></math>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math></math></body></html>",
+ "noQuirksBodyHtml": "<math></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><math></math>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math></math></body></html>",
+ "noQuirksBodyHtml": "<math></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><math><mi>",
+ "errors": [
+ "(1,25) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mi></mi></math></body></html>",
+ "noQuirksBodyHtml": "<math><mi></mi></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><math><annotation-xml><svg><u>",
+ "errors": [
+ "(1,45) unexpected-html-element-in-foreign-content",
+ "(1,45) expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math annotation-xml": true,
+ "svg svg": true,
+ "u": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "annotation-xml",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "u"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><annotation-xml><svg></svg></annotation-xml></math><u></u></body></html>",
+ "noQuirksBodyHtml": "<math><annotation-xml><svg><u></u></svg></annotation-xml></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><select><math></math></select>",
+ "errors": [
+ "(1,35) unexpected-start-tag-in-select",
+ "(1,42) unexpected-end-tag-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select></select></body></html>",
+ "noQuirksBodyHtml": "<select></select>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><select><option><math></math></option></select>",
+ "errors": [
+ "(1,43) unexpected-start-tag-in-select",
+ "(1,50) unexpected-end-tag-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select><option></option></select></body></html>",
+ "noQuirksBodyHtml": "<select><option></option></select>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><math></math></table>",
+ "errors": [
+ "(1,34) unexpected-start-tag-implies-table-voodoo"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math></math><table></table></body></html>",
+ "noQuirksBodyHtml": "<math></math><table></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><math><mi>foo</mi></math></table>",
+ "errors": [
+ "(1,34) foster-parenting-start-token",
+ "(1,39) foster-parenting-character",
+ "(1,40) foster-parenting-character",
+ "(1,41) foster-parenting-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi></math><table></table></body></html>",
+ "noQuirksBodyHtml": "<math><mi>foo</mi></math><table></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><math><mi>foo</mi><mi>bar</mi></math></table>",
+ "errors": [
+ "(1,34) foster-parenting-start-tag",
+ "(1,39) foster-parenting-character",
+ "(1,40) foster-parenting-character",
+ "(1,41) foster-parenting-character",
+ "(1,51) foster-parenting-character",
+ "(1,52) foster-parenting-character",
+ "(1,53) foster-parenting-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true,
+ "table": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi><mi>bar</mi></math><table></table></body></html>",
+ "noQuirksBodyHtml": "<math><mi>foo</mi><mi>bar</mi></math><table></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><tbody><math><mi>foo</mi><mi>bar</mi></math></tbody></table>",
+ "errors": [
+ "(1,41) foster-parenting-start-tag",
+ "(1,46) foster-parenting-character",
+ "(1,47) foster-parenting-character",
+ "(1,48) foster-parenting-character",
+ "(1,58) foster-parenting-character",
+ "(1,59) foster-parenting-character",
+ "(1,60) foster-parenting-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true,
+ "table": true,
+ "tbody": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi><mi>bar</mi></math><table><tbody></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<math><mi>foo</mi><mi>bar</mi></math><table><tbody></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><tbody><tr><math><mi>foo</mi><mi>bar</mi></math></tr></tbody></table>",
+ "errors": [
+ "(1,45) foster-parenting-start-tag",
+ "(1,50) foster-parenting-character",
+ "(1,51) foster-parenting-character",
+ "(1,52) foster-parenting-character",
+ "(1,62) foster-parenting-character",
+ "(1,63) foster-parenting-character",
+ "(1,64) foster-parenting-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi><mi>bar</mi></math><table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<math><mi>foo</mi><mi>bar</mi></math><table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><tbody><tr><td><math><mi>foo</mi><mi>bar</mi></math></td></tr></tbody></table>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "math math": true,
+ "math mi": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><math><mi>foo</mi><mi>bar</mi></math></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><math><mi>foo</mi><mi>bar</mi></math></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><tbody><tr><td><math><mi>foo</mi><mi>bar</mi></math><p>baz</td></tr></tbody></table>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "math math": true,
+ "math mi": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><caption><math><mi>foo</mi><mi>bar</mi></math><p>baz</caption></table>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "math math": true,
+ "math mi": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><caption><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></caption></table></body></html>",
+ "noQuirksBodyHtml": "<table><caption><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></caption></table>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><caption><math><mi>foo</mi><mi>bar</mi><p>baz</table><p>quux",
+ "errors": [
+ "(1,70) unexpected-html-element-in-foreign-content"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "math math": true,
+ "math mi": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "quux"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><caption><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></caption></table><p>quux</p></body></html>",
+ "noQuirksBodyHtml": "<table><caption><math><mi>foo</mi><mi>bar</mi><p>baz</p></math></caption></table><p>quux</p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><caption><math><mi>foo</mi><mi>bar</mi>baz</table><p>quux",
+ "errors": [
+ "(1,78) unexpected-end-tag",
+ "(1,78) expected-one-end-tag-but-got-another"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "caption": true,
+ "math math": true,
+ "math mi": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ },
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "quux"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><caption><math><mi>foo</mi><mi>bar</mi>baz</math></caption></table><p>quux</p></body></html>",
+ "noQuirksBodyHtml": "<table><caption><math><mi>foo</mi><mi>bar</mi>baz</math></caption></table><p>quux</p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><colgroup><math><mi>foo</mi><mi>bar</mi><p>baz</table><p>quux",
+ "errors": [
+ "(1,44) foster-parenting-start-tag",
+ "(1,49) foster-parenting-character",
+ "(1,50) foster-parenting-character",
+ "(1,51) foster-parenting-character",
+ "(1,61) foster-parenting-character",
+ "(1,62) foster-parenting-character",
+ "(1,63) foster-parenting-character",
+ "(1,71) unexpected-html-element-in-foreign-content",
+ "(1,71) foster-parenting-start-tag",
+ "(1,63) foster-parenting-character",
+ "(1,63) foster-parenting-character",
+ "(1,63) foster-parenting-character"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true,
+ "p": true,
+ "table": true,
+ "colgroup": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup"
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "quux"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi><mi>bar</mi></math><p>baz</p><table><colgroup></colgroup></table><p>quux</p></body></html>",
+ "noQuirksBodyHtml": "<math><mi>foo</mi><mi>bar</mi><p>baz</p></math><table><colgroup></colgroup></table><p>quux</p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><tr><td><select><math><mi>foo</mi><mi>bar</mi><p>baz</table><p>quux",
+ "errors": [
+ "(1,50) unexpected-start-tag-in-select",
+ "(1,54) unexpected-start-tag-in-select",
+ "(1,62) unexpected-end-tag-in-select",
+ "(1,66) unexpected-start-tag-in-select",
+ "(1,74) unexpected-end-tag-in-select",
+ "(1,77) unexpected-start-tag-in-select",
+ "(1,88) unexpected-table-element-end-tag-in-select-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "select": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "text": "foobarbaz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "quux"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><table><tbody><tr><td><select>foobarbaz</select></td></tr></tbody></table><p>quux</p></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><select>foobarbaz</select></td></tr></tbody></table><p>quux</p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body><table><select><math><mi>foo</mi><mi>bar</mi><p>baz</table><p>quux",
+ "errors": [
+ "(1,36) unexpected-start-tag-implies-table-voodoo",
+ "(1,42) unexpected-start-tag-in-select",
+ "(1,46) unexpected-start-tag-in-select",
+ "(1,54) unexpected-end-tag-in-select",
+ "(1,58) unexpected-start-tag-in-select",
+ "(1,66) unexpected-end-tag-in-select",
+ "(1,69) unexpected-start-tag-in-select",
+ "(1,80) unexpected-table-element-end-tag-in-select-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "table": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "text": "foobarbaz"
+ }
+ ]
+ },
+ {
+ "tag": "table"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "quux"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><select>foobarbaz</select><table></table><p>quux</p></body></html>",
+ "noQuirksBodyHtml": "<select>foobarbaz</select><table></table><p>quux</p>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body></body></html><math><mi>foo</mi><mi>bar</mi><p>baz",
+ "errors": [
+ "(1,41) expected-eof-but-got-start-tag",
+ "(1,68) unexpected-html-element-in-foreign-content"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></body></html>",
+ "noQuirksBodyHtml": "<math><mi>foo</mi><mi>bar</mi><p>baz</p></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body></body><math><mi>foo</mi><mi>bar</mi><p>baz",
+ "errors": [
+ "(1,34) unexpected-start-tag-after-body",
+ "(1,61) unexpected-html-element-in-foreign-content"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true,
+ "p": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><math><mi>foo</mi><mi>bar</mi></math><p>baz</p></body></html>",
+ "noQuirksBodyHtml": "<math><mi>foo</mi><mi>bar</mi><p>baz</p></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><frameset><math><mi></mi><mi></mi><p><span>",
+ "errors": [
+ "(1,31) unexpected-start-tag-in-frameset",
+ "(1,35) unexpected-start-tag-in-frameset",
+ "(1,40) unexpected-end-tag-in-frameset",
+ "(1,44) unexpected-start-tag-in-frameset",
+ "(1,49) unexpected-end-tag-in-frameset",
+ "(1,52) unexpected-start-tag-in-frameset",
+ "(1,58) unexpected-start-tag-in-frameset",
+ "(1,58) eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<math><mi></mi><mi></mi><p><span></span></p></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><frameset></frameset><math><mi></mi><mi></mi><p><span>",
+ "errors": [
+ "(1,42) unexpected-start-tag-after-frameset",
+ "(1,46) unexpected-start-tag-after-frameset",
+ "(1,51) unexpected-end-tag-after-frameset",
+ "(1,55) unexpected-start-tag-after-frameset",
+ "(1,60) unexpected-end-tag-after-frameset",
+ "(1,63) unexpected-start-tag-after-frameset",
+ "(1,69) unexpected-start-tag-after-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<math><mi></mi><mi></mi><p><span></span></p></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body xlink:href=foo><math xlink:href=foo></math>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "attrs": [
+ {
+ "name": "xlink:href",
+ "value": "foo"
+ }
+ ],
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "href",
+ "ns": "http://www.w3.org/1999/xlink",
+ "value": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\"><math xlink:href=\"foo\"></math></body></html>",
+ "noQuirksBodyHtml": "<math xlink:href=\"foo\"></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body xlink:href=foo xml:lang=en><math><mi xml:lang=en xlink:href=foo></mi></math>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "attrs": [
+ {
+ "name": "xlink:href",
+ "value": "foo"
+ },
+ {
+ "name": "xml:lang",
+ "value": "en"
+ }
+ ],
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "href",
+ "ns": "http://www.w3.org/1999/xlink",
+ "value": "foo"
+ },
+ {
+ "name": "lang",
+ "ns": "http://www.w3.org/XML/1998/namespace",
+ "value": "en"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\" xml:lang=\"en\"><math><mi xml:lang=\"en\" xlink:href=\"foo\"></mi></math></body></html>",
+ "noQuirksBodyHtml": "<math><mi xml:lang=\"en\" xlink:href=\"foo\"></mi></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body xlink:href=foo xml:lang=en><math><mi xml:lang=en xlink:href=foo /></math>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "attrs": [
+ {
+ "name": "xlink:href",
+ "value": "foo"
+ },
+ {
+ "name": "xml:lang",
+ "value": "en"
+ }
+ ],
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "href",
+ "ns": "http://www.w3.org/1999/xlink",
+ "value": "foo"
+ },
+ {
+ "name": "lang",
+ "ns": "http://www.w3.org/XML/1998/namespace",
+ "value": "en"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\" xml:lang=\"en\"><math><mi xml:lang=\"en\" xlink:href=\"foo\"></mi></math></body></html>",
+ "noQuirksBodyHtml": "<math><mi xml:lang=\"en\" xlink:href=\"foo\"></mi></math>"
+ }
+ },
+ {
+ "data": "<!DOCTYPE html><body xlink:href=foo xml:lang=en><math><mi xml:lang=en xlink:href=foo />bar</math>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mi": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "attrs": [
+ {
+ "name": "xlink:href",
+ "value": "foo"
+ },
+ {
+ "name": "xml:lang",
+ "value": "en"
+ }
+ ],
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "attrs": [
+ {
+ "name": "href",
+ "ns": "http://www.w3.org/1999/xlink",
+ "value": "foo"
+ },
+ {
+ "name": "lang",
+ "ns": "http://www.w3.org/XML/1998/namespace",
+ "value": "en"
+ }
+ ]
+ },
+ {
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body xlink:href=\"foo\" xml:lang=\"en\"><math><mi xml:lang=\"en\" xlink:href=\"foo\"></mi>bar</math></body></html>",
+ "noQuirksBodyHtml": "<math><mi xml:lang=\"en\" xlink:href=\"foo\"></mi>bar</math>"
+ }
+ }
+ ],
+ "tests_innerHTML_1.dat": [
+ {
+ "data": "<body><span>",
+ "errors": [
+ "(1,6): unexpected-start-tag",
+ "(1,12): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "body"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span"
+ }
+ ],
+ "html": "<span></span>",
+ "noQuirksBodyHtml": "<span></span>"
+ }
+ },
+ {
+ "data": "<span><body>",
+ "errors": [
+ "(1,12): unexpected-start-tag",
+ "(1,12): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "body"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span"
+ }
+ ],
+ "html": "<span></span>",
+ "noQuirksBodyHtml": "<span></span>"
+ }
+ },
+ {
+ "data": "<span><body>",
+ "errors": [
+ "(1,12): unexpected-start-tag",
+ "(1,12): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "div"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span"
+ }
+ ],
+ "html": "<span></span>",
+ "noQuirksBodyHtml": "<span></span>"
+ }
+ },
+ {
+ "data": "<body><span>",
+ "errors": [
+ "(1,12): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "html"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "head": true,
+ "body": true,
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<head></head><body><span></span></body>",
+ "noQuirksBodyHtml": "<span></span>"
+ }
+ },
+ {
+ "data": "<frameset><span>",
+ "errors": [
+ "(1,10): unexpected-start-tag",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "body"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span"
+ }
+ ],
+ "html": "<span></span>",
+ "noQuirksBodyHtml": "<span></span>"
+ }
+ },
+ {
+ "data": "<span><frameset>",
+ "errors": [
+ "(1,16): unexpected-start-tag",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "body"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span"
+ }
+ ],
+ "html": "<span></span>",
+ "noQuirksBodyHtml": "<span></span>"
+ }
+ },
+ {
+ "data": "<span><frameset>",
+ "errors": [
+ "(1,16): unexpected-start-tag",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "div"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span"
+ }
+ ],
+ "html": "<span></span>",
+ "noQuirksBodyHtml": "<span></span>"
+ }
+ },
+ {
+ "data": "<frameset><span>",
+ "errors": [
+ "(1,16): unexpected-start-tag-in-frameset",
+ "(1,16): eof-in-frameset"
+ ],
+ "fragment": {
+ "name": "html"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "head": true,
+ "frameset": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ],
+ "html": "<head></head><frameset></frameset>",
+ "noQuirksBodyHtml": "<span></span>"
+ }
+ },
+ {
+ "data": "<table><tr>",
+ "errors": [
+ "(1,7): unexpected-start-tag"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "tbody": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ],
+ "html": "<tbody><tr></tr></tbody>",
+ "noQuirksBodyHtml": "<table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "</table><tr>",
+ "errors": [
+ "(1,8): unexpected-end-tag"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "tbody": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ],
+ "html": "<tbody><tr></tr></tbody>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<a>",
+ "errors": [
+ "(1,3): unexpected-start-tag-implies-table-voodoo",
+ "(1,3): eof-in-table"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<a>",
+ "errors": [
+ "(1,3): unexpected-start-tag-implies-table-voodoo",
+ "(1,3): eof-in-table"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<a><caption>a",
+ "errors": [
+ "(1,3): unexpected-start-tag-implies-table-voodoo",
+ "(1,13): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true,
+ "caption": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "caption",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ],
+ "html": "<a></a><caption>a</caption>",
+ "noQuirksBodyHtml": "<a>a</a>"
+ }
+ },
+ {
+ "data": "<a><colgroup><col>",
+ "errors": [
+ "(1,3): foster-parenting-start-token",
+ "(1,18): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true,
+ "colgroup": true,
+ "col": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ }
+ ],
+ "html": "<a></a><colgroup><col></colgroup>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<a><tbody><tr>",
+ "errors": [
+ "(1,3): foster-parenting-start-tag"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true,
+ "tbody": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ],
+ "html": "<a></a><tbody><tr></tr></tbody>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<a><tfoot><tr>",
+ "errors": [
+ "(1,3): foster-parenting-start-tag"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true,
+ "tfoot": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "tfoot",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ],
+ "html": "<a></a><tfoot><tr></tr></tfoot>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<a><thead><tr>",
+ "errors": [
+ "(1,3): foster-parenting-start-tag"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true,
+ "thead": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "thead",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ],
+ "html": "<a></a><thead><tr></tr></thead>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<a><tr>",
+ "errors": [
+ "(1,3): foster-parenting-start-tag"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true,
+ "tbody": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ],
+ "html": "<a></a><tbody><tr></tr></tbody>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<a><th>",
+ "errors": [
+ "(1,3): unexpected-start-tag-implies-table-voodoo",
+ "(1,7): unexpected-cell-in-table-body"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true,
+ "tbody": true,
+ "tr": true,
+ "th": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "th"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<a></a><tbody><tr><th></th></tr></tbody>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<a><td>",
+ "errors": [
+ "(1,3): unexpected-start-tag-implies-table-voodoo",
+ "(1,7): unexpected-cell-in-table-body"
+ ],
+ "fragment": {
+ "name": "table"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<a></a><tbody><tr><td></td></tr></tbody>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<table></table><tbody>",
+ "errors": [
+ "(1,22): unexpected-start-tag"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "table"
+ }
+ ],
+ "html": "<table></table>",
+ "noQuirksBodyHtml": "<table></table>"
+ }
+ },
+ {
+ "data": "</table><span>",
+ "errors": [
+ "(1,8): unexpected-end-tag",
+ "(1,14): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span"
+ }
+ ],
+ "html": "<span></span>",
+ "noQuirksBodyHtml": "<span></span>"
+ }
+ },
+ {
+ "data": "<span></table>",
+ "errors": [
+ "(1,14): unexpected-end-tag",
+ "(1,14): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span"
+ }
+ ],
+ "html": "<span></span>",
+ "noQuirksBodyHtml": "<span></span>"
+ }
+ },
+ {
+ "data": "</caption><span>",
+ "errors": [
+ "(1,10): XXX-undefined-error",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span"
+ }
+ ],
+ "html": "<span></span>",
+ "noQuirksBodyHtml": "<span></span>"
+ }
+ },
+ {
+ "data": "<span></caption><span>",
+ "errors": [
+ "(1,16): XXX-undefined-error",
+ "(1,22): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<span><span></span></span>",
+ "noQuirksBodyHtml": "<span><span></span></span>"
+ }
+ },
+ {
+ "data": "<span><caption><span>",
+ "errors": [
+ "(1,15): unexpected-start-tag",
+ "(1,21): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<span><span></span></span>",
+ "noQuirksBodyHtml": "<span><span></span></span>"
+ }
+ },
+ {
+ "data": "<span><col><span>",
+ "errors": [
+ "(1,11): unexpected-start-tag",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<span><span></span></span>",
+ "noQuirksBodyHtml": "<span><span></span></span>"
+ }
+ },
+ {
+ "data": "<span><colgroup><span>",
+ "errors": [
+ "(1,16): unexpected-start-tag",
+ "(1,22): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<span><span></span></span>",
+ "noQuirksBodyHtml": "<span><span></span></span>"
+ }
+ },
+ {
+ "data": "<span><html><span>",
+ "errors": [
+ "(1,12): non-html-root",
+ "(1,18): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<span><span></span></span>",
+ "noQuirksBodyHtml": "<span><span></span></span>"
+ }
+ },
+ {
+ "data": "<span><tbody><span>",
+ "errors": [
+ "(1,13): unexpected-start-tag",
+ "(1,19): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<span><span></span></span>",
+ "noQuirksBodyHtml": "<span><span></span></span>"
+ }
+ },
+ {
+ "data": "<span><td><span>",
+ "errors": [
+ "(1,10): unexpected-start-tag",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<span><span></span></span>",
+ "noQuirksBodyHtml": "<span><span></span></span>"
+ }
+ },
+ {
+ "data": "<span><tfoot><span>",
+ "errors": [
+ "(1,13): unexpected-start-tag",
+ "(1,19): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<span><span></span></span>",
+ "noQuirksBodyHtml": "<span><span></span></span>"
+ }
+ },
+ {
+ "data": "<span><thead><span>",
+ "errors": [
+ "(1,13): unexpected-start-tag",
+ "(1,19): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<span><span></span></span>",
+ "noQuirksBodyHtml": "<span><span></span></span>"
+ }
+ },
+ {
+ "data": "<span><th><span>",
+ "errors": [
+ "(1,10): unexpected-start-tag",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<span><span></span></span>",
+ "noQuirksBodyHtml": "<span><span></span></span>"
+ }
+ },
+ {
+ "data": "<span><tr><span>",
+ "errors": [
+ "(1,10): unexpected-start-tag",
+ "(1,16): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<span><span></span></span>",
+ "noQuirksBodyHtml": "<span><span></span></span>"
+ }
+ },
+ {
+ "data": "<span></table><span>",
+ "errors": [
+ "(1,14): unexpected-end-tag",
+ "(1,20): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "caption"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "span",
+ "children": [
+ {
+ "tag": "span"
+ }
+ ]
+ }
+ ],
+ "html": "<span><span></span></span>",
+ "noQuirksBodyHtml": "<span><span></span></span>"
+ }
+ },
+ {
+ "data": "</colgroup><col>",
+ "errors": [
+ "(1,11): XXX-undefined-error"
+ ],
+ "fragment": {
+ "name": "colgroup"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "col": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "col"
+ }
+ ],
+ "html": "<col>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<a><col>",
+ "errors": [
+ "(1,3): XXX-undefined-error"
+ ],
+ "fragment": {
+ "name": "colgroup"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "col": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "col"
+ }
+ ],
+ "html": "<col>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<caption><a>",
+ "errors": [
+ "(1,9): XXX-undefined-error",
+ "(1,12): unexpected-start-tag-implies-table-voodoo",
+ "(1,12): eof-in-table"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<col><a>",
+ "errors": [
+ "(1,5): XXX-undefined-error",
+ "(1,8): unexpected-start-tag-implies-table-voodoo",
+ "(1,8): eof-in-table"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<colgroup><a>",
+ "errors": [
+ "(1,10): XXX-undefined-error",
+ "(1,13): unexpected-start-tag-implies-table-voodoo",
+ "(1,13): eof-in-table"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<tbody><a>",
+ "errors": [
+ "(1,7): XXX-undefined-error",
+ "(1,10): unexpected-start-tag-implies-table-voodoo",
+ "(1,10): eof-in-table"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<tfoot><a>",
+ "errors": [
+ "(1,7): XXX-undefined-error",
+ "(1,10): unexpected-start-tag-implies-table-voodoo",
+ "(1,10): eof-in-table"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<thead><a>",
+ "errors": [
+ "(1,7): XXX-undefined-error",
+ "(1,10): unexpected-start-tag-implies-table-voodoo",
+ "(1,10): eof-in-table"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "</table><a>",
+ "errors": [
+ "(1,8): XXX-undefined-error",
+ "(1,11): unexpected-start-tag-implies-table-voodoo",
+ "(1,11): eof-in-table"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<a><tr>",
+ "errors": [
+ "(1,3): unexpected-start-tag-implies-table-voodoo"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "tr"
+ }
+ ],
+ "html": "<a></a><tr></tr>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<a><td>",
+ "errors": [
+ "(1,3): unexpected-start-tag-implies-table-voodoo",
+ "(1,7): unexpected-cell-in-table-body"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ],
+ "html": "<a></a><tr><td></td></tr>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<a><td>",
+ "errors": [
+ "(1,3): unexpected-start-tag-implies-table-voodoo",
+ "(1,7): unexpected-cell-in-table-body"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ],
+ "html": "<a></a><tr><td></td></tr>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<a><td>",
+ "errors": [
+ "(1,3): unexpected-start-tag-implies-table-voodoo",
+ "(1,7): unexpected-cell-in-table-body"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ],
+ "html": "<a></a><tr><td></td></tr>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<td><table><tbody><a><tr>",
+ "errors": [
+ "(1,4): unexpected-cell-in-table-body",
+ "(1,21): unexpected-start-tag-implies-table-voodoo",
+ "(1,25): eof-in-table"
+ ],
+ "fragment": {
+ "name": "tbody"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "tr": true,
+ "td": true,
+ "a": true,
+ "table": true,
+ "tbody": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<tr><td><a></a><table><tbody><tr></tr></tbody></table></td></tr>",
+ "noQuirksBodyHtml": "<a></a><table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "</tr><td>",
+ "errors": [
+ "(1,5): XXX-undefined-error"
+ ],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td></td>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<td><table><a><tr></tr><tr>",
+ "errors": [
+ "(1,14): unexpected-start-tag-implies-table-voodoo",
+ "(1,27): eof-in-table"
+ ],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true,
+ "a": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ },
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<td><a></a><table><tbody><tr></tr><tr></tr></tbody></table></td>",
+ "noQuirksBodyHtml": "<a></a><table><tbody><tr></tr><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<caption><td>",
+ "errors": [
+ "(1,9): XXX-undefined-error"
+ ],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td></td>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<col><td>",
+ "errors": [
+ "(1,5): XXX-undefined-error"
+ ],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td></td>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<colgroup><td>",
+ "errors": [
+ "(1,10): XXX-undefined-error"
+ ],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td></td>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<tbody><td>",
+ "errors": [
+ "(1,7): XXX-undefined-error"
+ ],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td></td>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<tfoot><td>",
+ "errors": [
+ "(1,7): XXX-undefined-error"
+ ],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td></td>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<thead><td>",
+ "errors": [
+ "(1,7): XXX-undefined-error"
+ ],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td></td>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<tr><td>",
+ "errors": [
+ "(1,4): XXX-undefined-error"
+ ],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td></td>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "</table><td>",
+ "errors": [
+ "(1,8): XXX-undefined-error"
+ ],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td></td>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<td><table></table><td>",
+ "errors": [],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ },
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td><table></table></td><td></td>",
+ "noQuirksBodyHtml": "<table></table>"
+ }
+ },
+ {
+ "data": "<td><table></table><td>",
+ "errors": [],
+ "fragment": {
+ "name": "tr"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "td": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "table"
+ }
+ ]
+ },
+ {
+ "tag": "td"
+ }
+ ],
+ "html": "<td><table></table></td><td></td>",
+ "noQuirksBodyHtml": "<table></table>"
+ }
+ },
+ {
+ "data": "<caption><a>",
+ "errors": [
+ "(1,9): XXX-undefined-error",
+ "(1,12): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<col><a>",
+ "errors": [
+ "(1,5): XXX-undefined-error",
+ "(1,8): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<colgroup><a>",
+ "errors": [
+ "(1,10): XXX-undefined-error",
+ "(1,13): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<tbody><a>",
+ "errors": [
+ "(1,7): XXX-undefined-error",
+ "(1,10): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<tfoot><a>",
+ "errors": [
+ "(1,7): XXX-undefined-error",
+ "(1,10): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<th><a>",
+ "errors": [
+ "(1,4): XXX-undefined-error",
+ "(1,7): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<thead><a>",
+ "errors": [
+ "(1,7): XXX-undefined-error",
+ "(1,10): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<tr><a>",
+ "errors": [
+ "(1,4): XXX-undefined-error",
+ "(1,7): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "</table><a>",
+ "errors": [
+ "(1,8): XXX-undefined-error",
+ "(1,11): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "</tbody><a>",
+ "errors": [
+ "(1,8): XXX-undefined-error",
+ "(1,11): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "</td><a>",
+ "errors": [
+ "(1,5): unexpected-end-tag",
+ "(1,8): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "</tfoot><a>",
+ "errors": [
+ "(1,8): XXX-undefined-error",
+ "(1,11): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "</thead><a>",
+ "errors": [
+ "(1,8): XXX-undefined-error",
+ "(1,11): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "</th><a>",
+ "errors": [
+ "(1,5): unexpected-end-tag",
+ "(1,8): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "</tr><a>",
+ "errors": [
+ "(1,5): XXX-undefined-error",
+ "(1,8): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "a"
+ }
+ ],
+ "html": "<a></a>",
+ "noQuirksBodyHtml": "<a></a>"
+ }
+ },
+ {
+ "data": "<table><td><td>",
+ "errors": [
+ "(1,11): unexpected-cell-in-table-body",
+ "(1,15): expected-closing-tag-but-got-eof"
+ ],
+ "fragment": {
+ "name": "td"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<table><tbody><tr><td></td><td></td></tr></tbody></table>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td></td><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "</select><option>",
+ "errors": [
+ "(1,9): XXX-undefined-error"
+ ],
+ "fragment": {
+ "name": "select"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "option": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "option"
+ }
+ ],
+ "html": "<option></option>",
+ "noQuirksBodyHtml": "<option></option>"
+ }
+ },
+ {
+ "data": "<input><option>",
+ "errors": [
+ "(1,7): unexpected-input-in-select"
+ ],
+ "fragment": {
+ "name": "select"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "option": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "option"
+ }
+ ],
+ "html": "<option></option>",
+ "noQuirksBodyHtml": "<input><option></option>"
+ }
+ },
+ {
+ "data": "<keygen><option>",
+ "errors": [
+ "(1,8): unexpected-input-in-select"
+ ],
+ "fragment": {
+ "name": "select"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "option": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "option"
+ }
+ ],
+ "html": "<option></option>",
+ "noQuirksBodyHtml": "<keygen><option></option>"
+ }
+ },
+ {
+ "data": "<textarea><option>",
+ "errors": [
+ "(1,10): unexpected-input-in-select"
+ ],
+ "fragment": {
+ "name": "select"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "option": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "option"
+ }
+ ],
+ "html": "<option></option>",
+ "noQuirksBodyHtml": "<textarea>&lt;option&gt;</textarea>"
+ }
+ },
+ {
+ "data": "</html><!--abc-->",
+ "errors": [
+ "(1,7): unexpected-end-tag-after-body-innerhtml"
+ ],
+ "fragment": {
+ "name": "html"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ },
+ {
+ "comment": "abc"
+ }
+ ],
+ "html": "<head></head><body></body><!--abc-->",
+ "noQuirksBodyHtml": "<!--abc-->"
+ }
+ },
+ {
+ "data": "</frameset><frame>",
+ "errors": [
+ "(1,11): unexpected-frameset-in-frameset-innerhtml"
+ ],
+ "fragment": {
+ "name": "frameset"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "frame": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "frame"
+ }
+ ],
+ "html": "<frame>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "",
+ "errors": [],
+ "fragment": {
+ "name": "html"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ],
+ "html": "<head></head><body></body>",
+ "noQuirksBodyHtml": ""
+ }
+ }
+ ],
+ "tricky01.dat": [
+ {
+ "data": "<b><p>Bold </b> Not bold</p>\nAlso not bold.",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,15): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "Bold "
+ }
+ ]
+ },
+ {
+ "text": " Not bold"
+ }
+ ]
+ },
+ {
+ "text": "\nAlso not bold."
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b></b><p><b>Bold </b> Not bold</p>\nAlso not bold.</body></html>",
+ "noQuirksBodyHtml": "<b></b><p><b>Bold </b> Not bold</p>\nAlso not bold."
+ }
+ },
+ {
+ "data": "<html>\n<font color=red><i>Italic and Red<p>Italic and Red </font> Just italic.</p> Italic only.</i> Plain\n<p>I should not be red. <font color=red>Red. <i>Italic and red.</p>\n<p>Italic and red. </i> Red.</font> I should not be red.</p>\n<b>Bold <i>Bold and italic</b> Only Italic </i> Plain",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(2,58): adoption-agency-1.3",
+ "(3,67): unexpected-end-tag",
+ "(4,23): adoption-agency-1.3",
+ "(4,35): adoption-agency-1.3",
+ "(5,30): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "font": true,
+ "i": true,
+ "p": true,
+ "b": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "color",
+ "value": "red"
+ }
+ ],
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "Italic and Red"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "color",
+ "value": "red"
+ }
+ ],
+ "children": [
+ {
+ "text": "Italic and Red "
+ }
+ ]
+ },
+ {
+ "text": " Just italic."
+ }
+ ]
+ },
+ {
+ "text": " Italic only."
+ }
+ ]
+ },
+ {
+ "text": " Plain\n"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "I should not be red. "
+ },
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "color",
+ "value": "red"
+ }
+ ],
+ "children": [
+ {
+ "text": "Red. "
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "Italic and red."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "color",
+ "value": "red"
+ }
+ ],
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "\n"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "color",
+ "value": "red"
+ }
+ ],
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "Italic and red. "
+ }
+ ]
+ },
+ {
+ "text": " Red."
+ }
+ ]
+ },
+ {
+ "text": " I should not be red."
+ }
+ ]
+ },
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "Bold "
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "Bold and italic"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": " Only Italic "
+ }
+ ]
+ },
+ {
+ "text": " Plain"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><font color=\"red\"><i>Italic and Red</i></font><i><p><font color=\"red\">Italic and Red </font> Just italic.</p> Italic only.</i> Plain\n<p>I should not be red. <font color=\"red\">Red. <i>Italic and red.</i></font></p><font color=\"red\"><i>\n</i></font><p><font color=\"red\"><i>Italic and red. </i> Red.</font> I should not be red.</p>\n<b>Bold <i>Bold and italic</i></b><i> Only Italic </i> Plain</body></html>",
+ "noQuirksBodyHtml": "\n<font color=\"red\"><i>Italic and Red</i></font><i><p><font color=\"red\">Italic and Red </font> Just italic.</p> Italic only.</i> Plain\n<p>I should not be red. <font color=\"red\">Red. <i>Italic and red.</i></font></p><font color=\"red\"><i>\n</i></font><p><font color=\"red\"><i>Italic and red. </i> Red.</font> I should not be red.</p>\n<b>Bold <i>Bold and italic</i></b><i> Only Italic </i> Plain"
+ }
+ },
+ {
+ "data": "<html><body>\n<p><font size=\"7\">First paragraph.</p>\n<p>Second paragraph.</p></font>\n<b><p><i>Bold and Italic</b> Italic</p>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(2,38): unexpected-end-tag",
+ "(4,28): adoption-agency-1.3",
+ "(4,28): adoption-agency-1.3",
+ "(4,39): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "font": true,
+ "b": true,
+ "i": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "7"
+ }
+ ],
+ "children": [
+ {
+ "text": "First paragraph."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "font",
+ "attrs": [
+ {
+ "name": "size",
+ "value": "7"
+ }
+ ],
+ "children": [
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "Second paragraph."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "b"
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": "Bold and Italic"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "i",
+ "children": [
+ {
+ "text": " Italic"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>\n<p><font size=\"7\">First paragraph.</font></p><font size=\"7\">\n<p>Second paragraph.</p></font>\n<b></b><p><b><i>Bold and Italic</i></b><i> Italic</i></p></body></html>",
+ "noQuirksBodyHtml": "\n<p><font size=\"7\">First paragraph.</font></p><font size=\"7\">\n<p>Second paragraph.</p></font>\n<b></b><p><b><i>Bold and Italic</i></b><i> Italic</i></p>"
+ }
+ },
+ {
+ "data": "<html>\n<dl>\n<dt><b>Boo\n<dd>Goo?\n</dl>\n</html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(4,4): end-tag-too-early",
+ "(5,5): end-tag-too-early",
+ "(6,7): expected-one-end-tag-but-got-another"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "dl": true,
+ "dt": true,
+ "b": true,
+ "dd": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "dl",
+ "children": [
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "dt",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "Boo\n"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "dd",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "Goo?\n"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "text": "\n"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><dl>\n<dt><b>Boo\n</b></dt><dd><b>Goo?\n</b></dd></dl><b>\n</b></body></html>",
+ "noQuirksBodyHtml": "\n<dl>\n<dt><b>Boo\n</b></dt><dd><b>Goo?\n</b></dd></dl><b>\n</b>"
+ }
+ },
+ {
+ "data": "<html><body>\n<label><a><div>Hello<div>World</div></a></label> \n</body></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(2,40): adoption-agency-1.3",
+ "(2,48): unexpected-end-tag",
+ "(3,7): expected-one-end-tag-but-got-another"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "label": true,
+ "a": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "label",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "Hello"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "World"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": " \n"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>\n<label><a></a><div><a>Hello<div>World</div></a> \n</div></label></body></html>",
+ "noQuirksBodyHtml": "\n<label><a></a><div><a>Hello<div>World</div></a> \n</div></label>"
+ }
+ },
+ {
+ "data": "<table><center> <font>a</center> <img> <tr><td> </td> </tr> </table>",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,15): foster-parenting-start-tag",
+ "(1,16): foster-parenting-character",
+ "(1,22): foster-parenting-start-tag",
+ "(1,23): foster-parenting-character",
+ "(1,32): foster-parenting-end-tag",
+ "(1,32): end-tag-too-early",
+ "(1,33): foster-parenting-character",
+ "(1,38): foster-parenting-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "center": true,
+ "font": true,
+ "img": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "center",
+ "children": [
+ {
+ "text": " "
+ },
+ {
+ "tag": "font",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "font",
+ "children": [
+ {
+ "tag": "img"
+ },
+ {
+ "text": " "
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "text": " "
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": " "
+ }
+ ]
+ },
+ {
+ "text": " "
+ }
+ ]
+ },
+ {
+ "text": " "
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><center> <font>a</font></center><font><img> </font><table> <tbody><tr><td> </td> </tr> </tbody></table></body></html>",
+ "noQuirksBodyHtml": "<center> <font>a</font></center><font><img> </font><table> <tbody><tr><td> </td> </tr> </tbody></table>"
+ }
+ },
+ {
+ "data": "<table><tr><p><a><p>You should see this text.",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,14): unexpected-start-tag-implies-table-voodoo",
+ "(1,17): unexpected-start-tag-implies-table-voodoo",
+ "(1,20): unexpected-start-tag-implies-table-voodoo",
+ "(1,20): closing-non-current-p-element",
+ "(1,21): foster-parenting-character",
+ "(1,22): foster-parenting-character",
+ "(1,23): foster-parenting-character",
+ "(1,24): foster-parenting-character",
+ "(1,25): foster-parenting-character",
+ "(1,26): foster-parenting-character",
+ "(1,27): foster-parenting-character",
+ "(1,28): foster-parenting-character",
+ "(1,29): foster-parenting-character",
+ "(1,30): foster-parenting-character",
+ "(1,31): foster-parenting-character",
+ "(1,32): foster-parenting-character",
+ "(1,33): foster-parenting-character",
+ "(1,34): foster-parenting-character",
+ "(1,35): foster-parenting-character",
+ "(1,36): foster-parenting-character",
+ "(1,37): foster-parenting-character",
+ "(1,38): foster-parenting-character",
+ "(1,39): foster-parenting-character",
+ "(1,40): foster-parenting-character",
+ "(1,41): foster-parenting-character",
+ "(1,42): foster-parenting-character",
+ "(1,43): foster-parenting-character",
+ "(1,44): foster-parenting-character",
+ "(1,45): foster-parenting-character",
+ "(1,45): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "a": true,
+ "table": true,
+ "tbody": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a"
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "You should see this text."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p><a></a></p><p><a>You should see this text.</a></p><table><tbody><tr></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<p><a></a></p><p><a>You should see this text.</a></p><table><tbody><tr></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<TABLE>\n<TR>\n<CENTER><CENTER><TD></TD></TR><TR>\n<FONT>\n<TABLE><tr></tr></TABLE>\n</P>\n<a></font><font></a>\nThis page contains an insanely badly-nested tag sequence.",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(3,8): unexpected-start-tag-implies-table-voodoo",
+ "(3,16): unexpected-start-tag-implies-table-voodoo",
+ "(4,6): unexpected-start-tag-implies-table-voodoo",
+ "(4,6): unexpected character token in table (the newline)",
+ "(5,7): unexpected-start-tag-implies-end-tag",
+ "(6,4): unexpected p end tag",
+ "(7,10): adoption-agency-1.3",
+ "(7,20): adoption-agency-1.3",
+ "(8,57): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "center": true,
+ "font": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "p": true,
+ "a": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "center",
+ "children": [
+ {
+ "tag": "center"
+ }
+ ]
+ },
+ {
+ "tag": "font",
+ "children": [
+ {
+ "text": "\n"
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "td"
+ }
+ ]
+ },
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "text": "\n"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "font",
+ "children": [
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "p"
+ },
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "a"
+ }
+ ]
+ },
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "font"
+ }
+ ]
+ },
+ {
+ "tag": "font",
+ "children": [
+ {
+ "text": "\nThis page contains an insanely badly-nested tag sequence."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><center><center></center></center><font>\n</font><table>\n<tbody><tr>\n<td></td></tr><tr>\n</tr></tbody></table><table><tbody><tr></tr></tbody></table><font>\n<p></p>\n<a></a></font><a><font></font></a><font>\nThis page contains an insanely badly-nested tag sequence.</font></body></html>",
+ "noQuirksBodyHtml": "<center><center></center></center><font>\n</font><table>\n<tbody><tr>\n<td></td></tr><tr>\n</tr></tbody></table><table><tbody><tr></tr></tbody></table><font>\n<p></p>\n<a></a></font><a><font></font></a><font>\nThis page contains an insanely badly-nested tag sequence.</font>"
+ }
+ },
+ {
+ "data": "<html>\n<body>\n<b><nobr><div>This text is in a div inside a nobr</nobr>More text that should not be in the nobr, i.e., the\nnobr should have closed the div inside it implicitly. </b><pre>A pre tag outside everything else.</pre>\n</body>\n</html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(3,56): adoption-agency-1.3",
+ "(4,58): adoption-agency-1.3",
+ "(5,7): expected-one-end-tag-but-got-another"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "nobr": true,
+ "div": true,
+ "pre": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "nobr"
+ }
+ ]
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "This text is in a div inside a nobr"
+ }
+ ]
+ },
+ {
+ "text": "More text that should not be in the nobr, i.e., the\nnobr should have closed the div inside it implicitly. "
+ }
+ ]
+ },
+ {
+ "tag": "pre",
+ "children": [
+ {
+ "text": "A pre tag outside everything else."
+ }
+ ]
+ },
+ {
+ "text": "\n\n"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>\n<b><nobr></nobr></b><div><b><nobr>This text is in a div inside a nobr</nobr>More text that should not be in the nobr, i.e., the\nnobr should have closed the div inside it implicitly. </b><pre>A pre tag outside everything else.</pre>\n\n</div></body></html>",
+ "noQuirksBodyHtml": "\n\n<b><nobr></nobr></b><div><b><nobr>This text is in a div inside a nobr</nobr>More text that should not be in the nobr, i.e., the\nnobr should have closed the div inside it implicitly. </b><pre>A pre tag outside everything else.</pre>\n\n</div>"
+ }
+ }
+ ],
+ "webkit01.dat": [
+ {
+ "data": "Test",
+ "errors": [
+ "(1,4): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "Test"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>Test</body></html>",
+ "noQuirksBodyHtml": "Test"
+ }
+ },
+ {
+ "data": "<div></div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div></div></body></html>",
+ "noQuirksBodyHtml": "<div></div>"
+ }
+ },
+ {
+ "data": "<div>Test</div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "Test"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>Test</div></body></html>",
+ "noQuirksBodyHtml": "<div>Test</div>"
+ }
+ },
+ {
+ "data": "<di",
+ "errors": [
+ "(1,3): eof-in-tag-name",
+ "(1,3): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<div>Hello</div>\n<script>\nconsole.log(\"PASS\");\n</script>\n<div>Bye</div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ },
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "\nconsole.log(\"PASS\");\n",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "Bye"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>Hello</div>\n<script>\nconsole.log(\"PASS\");\n</script>\n<div>Bye</div></body></html>",
+ "noQuirksBodyHtml": "<div>Hello</div>\n<script>\nconsole.log(\"PASS\");\n</script>\n<div>Bye</div>"
+ }
+ },
+ {
+ "data": "<div foo=\"bar\">Hello</div>",
+ "errors": [
+ "(1,15): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "foo",
+ "value": "bar"
+ }
+ ],
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div foo=\"bar\">Hello</div></body></html>",
+ "noQuirksBodyHtml": "<div foo=\"bar\">Hello</div>"
+ }
+ },
+ {
+ "data": "<div>Hello</div>\n<script>\nconsole.log(\"FOO<span>BAR</span>BAZ\");\n</script>\n<div>Bye</div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "script": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "Hello"
+ }
+ ]
+ },
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "script",
+ "children": [
+ {
+ "text": "\nconsole.log(\"FOO<span>BAR</span>BAZ\");\n",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "Bye"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div>Hello</div>\n<script>\nconsole.log(\"FOO<span>BAR</span>BAZ\");\n</script>\n<div>Bye</div></body></html>",
+ "noQuirksBodyHtml": "<div>Hello</div>\n<script>\nconsole.log(\"FOO<span>BAR</span>BAZ\");\n</script>\n<div>Bye</div>"
+ }
+ },
+ {
+ "data": "<foo bar=\"baz\"></foo><potato quack=\"duck\"></potato>",
+ "errors": [
+ "(1,15): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "foo": true,
+ "potato": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "foo",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "baz"
+ }
+ ]
+ },
+ {
+ "tag": "potato",
+ "attrs": [
+ {
+ "name": "quack",
+ "value": "duck"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><foo bar=\"baz\"></foo><potato quack=\"duck\"></potato></body></html>",
+ "noQuirksBodyHtml": "<foo bar=\"baz\"></foo><potato quack=\"duck\"></potato>"
+ }
+ },
+ {
+ "data": "<foo bar=\"baz\"><potato quack=\"duck\"></potato></foo>",
+ "errors": [
+ "(1,15): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "foo": true,
+ "potato": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "foo",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "baz"
+ }
+ ],
+ "children": [
+ {
+ "tag": "potato",
+ "attrs": [
+ {
+ "name": "quack",
+ "value": "duck"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><foo bar=\"baz\"><potato quack=\"duck\"></potato></foo></body></html>",
+ "noQuirksBodyHtml": "<foo bar=\"baz\"><potato quack=\"duck\"></potato></foo>"
+ }
+ },
+ {
+ "data": "<foo></foo bar=\"baz\"><potato></potato quack=\"duck\">",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,21): attributes-in-end-tag",
+ "(1,51): attributes-in-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "foo": true,
+ "potato": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "foo"
+ },
+ {
+ "tag": "potato"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><foo></foo><potato></potato></body></html>",
+ "noQuirksBodyHtml": "<foo></foo><potato></potato>"
+ }
+ },
+ {
+ "data": "</ tttt>",
+ "errors": [
+ "(1,2): expected-closing-tag-but-got-char",
+ "(1,8): expected-doctype-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "comment": " tttt"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<!-- tttt--><html><head></head><body></body></html>",
+ "noQuirksBodyHtml": "<!-- tttt-->"
+ }
+ },
+ {
+ "data": "<div FOO ><img><img></div>",
+ "errors": [
+ "(1,10): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "img": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "foo",
+ "value": ""
+ }
+ ],
+ "children": [
+ {
+ "tag": "img"
+ },
+ {
+ "tag": "img"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div foo=\"\"><img><img></div></body></html>",
+ "noQuirksBodyHtml": "<div foo=\"\"><img><img></div>"
+ }
+ },
+ {
+ "data": "<p>Test</p<p>Test2</p>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,13): unexpected-end-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "children": [
+ {
+ "text": "TestTest2"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p>TestTest2</p></body></html>",
+ "noQuirksBodyHtml": "<p>TestTest2</p>"
+ }
+ },
+ {
+ "data": "<rdar://problem/6869687>",
+ "errors": [
+ "(1,7): unexpected-character-after-solidus-in-tag",
+ "(1,8): unexpected-character-after-solidus-in-tag",
+ "(1,16): unexpected-character-after-solidus-in-tag",
+ "(1,24): expected-doctype-but-got-start-tag",
+ "(1,24): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "rdar:": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "rdar:",
+ "attrs": [
+ {
+ "name": "6869687",
+ "value": ""
+ },
+ {
+ "name": "problem",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><rdar: problem=\"\" 6869687=\"\"></rdar:></body></html>",
+ "noQuirksBodyHtml": "<rdar: problem=\"\" 6869687=\"\"></rdar:>"
+ }
+ },
+ {
+ "data": "<A>test< /A>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,8): expected-tag-name",
+ "(1,12): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "text": "test< /A>",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a>test&lt; /A&gt;</a></body></html>",
+ "noQuirksBodyHtml": "<a>test&lt; /A&gt;</a>"
+ }
+ },
+ {
+ "data": "&lt;",
+ "errors": [
+ "(1,4): expected-doctype-but-got-chars"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "escaped": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "<",
+ "escaped": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>&lt;</body></html>",
+ "noQuirksBodyHtml": "&lt;"
+ }
+ },
+ {
+ "data": "<body foo='bar'><body foo='baz' yo='mama'>",
+ "errors": [
+ "(1,16): expected-doctype-but-got-start-tag",
+ "(1,42): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "attrs": [
+ {
+ "name": "foo",
+ "value": "bar"
+ },
+ {
+ "name": "yo",
+ "value": "mama"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body foo=\"bar\" yo=\"mama\"></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<body></br foo=\"bar\"></body>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,21): attributes-in-end-tag",
+ "(1,21): unexpected-end-tag-treated-as"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "br": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "br"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><br></body></html>",
+ "noQuirksBodyHtml": "<br>"
+ }
+ },
+ {
+ "data": "<bdy><br foo=\"bar\"></body>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,26): expected-one-end-tag-but-got-another"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "bdy": true,
+ "br": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "bdy",
+ "children": [
+ {
+ "tag": "br",
+ "attrs": [
+ {
+ "name": "foo",
+ "value": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><bdy><br foo=\"bar\"></bdy></body></html>",
+ "noQuirksBodyHtml": "<bdy><br foo=\"bar\"></bdy>"
+ }
+ },
+ {
+ "data": "<body></body></br foo=\"bar\">",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,28): attributes-in-end-tag",
+ "(1,28): unexpected-end-tag-after-body",
+ "(1,28): unexpected-end-tag-treated-as"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "br": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "br"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><br></body></html>",
+ "noQuirksBodyHtml": "<br>"
+ }
+ },
+ {
+ "data": "<bdy></body><br foo=\"bar\">",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,12): expected-one-end-tag-but-got-another",
+ "(1,26): unexpected-start-tag-after-body",
+ "(1,26): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "bdy": true,
+ "br": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "bdy",
+ "children": [
+ {
+ "tag": "br",
+ "attrs": [
+ {
+ "name": "foo",
+ "value": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><bdy><br foo=\"bar\"></bdy></body></html>",
+ "noQuirksBodyHtml": "<bdy><br foo=\"bar\"></bdy>"
+ }
+ },
+ {
+ "data": "<html><body></body></html><!-- Hi there -->",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ },
+ {
+ "comment": " Hi there "
+ }
+ ],
+ "html": "<html><head></head><body></body></html><!-- Hi there -->",
+ "noQuirksBodyHtml": "<!-- Hi there -->"
+ }
+ },
+ {
+ "data": "<html><body></body></html>x<!-- Hi there -->",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,27): expected-eof-but-got-char"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "x"
+ },
+ {
+ "comment": " Hi there "
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>x<!-- Hi there --></body></html>",
+ "noQuirksBodyHtml": "x<!-- Hi there -->"
+ }
+ },
+ {
+ "data": "<html><body></body></html>x<!-- Hi there --></html><!-- Again -->",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,27): expected-eof-but-got-char"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "x"
+ },
+ {
+ "comment": " Hi there "
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": " Again "
+ }
+ ],
+ "html": "<html><head></head><body>x<!-- Hi there --></body></html><!-- Again -->",
+ "noQuirksBodyHtml": "x<!-- Hi there --><!-- Again -->"
+ }
+ },
+ {
+ "data": "<html><body></body></html>x<!-- Hi there --></body></html><!-- Again -->",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,27): expected-eof-but-got-char"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ },
+ "comment": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "x"
+ },
+ {
+ "comment": " Hi there "
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": " Again "
+ }
+ ],
+ "html": "<html><head></head><body>x<!-- Hi there --></body></html><!-- Again -->",
+ "noQuirksBodyHtml": "x<!-- Hi there --><!-- Again -->"
+ }
+ },
+ {
+ "data": "<html><body><ruby><div><rp>xx</rp></div></ruby></body></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,27): XXX-undefined-error"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "div": true,
+ "rp": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "rp",
+ "children": [
+ {
+ "text": "xx"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby><div><rp>xx</rp></div></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby><div><rp>xx</rp></div></ruby>"
+ }
+ },
+ {
+ "data": "<html><body><ruby><div><rt>xx</rt></div></ruby></body></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,27): XXX-undefined-error"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ruby": true,
+ "div": true,
+ "rt": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ruby",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "rt",
+ "children": [
+ {
+ "text": "xx"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ruby><div><rt>xx</rt></div></ruby></body></html>",
+ "noQuirksBodyHtml": "<ruby><div><rt>xx</rt></div></ruby>"
+ }
+ },
+ {
+ "data": "<html><frameset><!--1--><noframes>A</noframes><!--2--></frameset><!--3--><noframes>B</noframes><!--4--></html><!--5--><noframes>C</noframes><!--6-->",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true,
+ "noframes": true
+ },
+ "comment": true,
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset",
+ "children": [
+ {
+ "comment": "1"
+ },
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "A",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "comment": "2"
+ }
+ ]
+ },
+ {
+ "comment": "3"
+ },
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "B",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "comment": "4"
+ },
+ {
+ "tag": "noframes",
+ "children": [
+ {
+ "text": "C",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "5"
+ },
+ {
+ "comment": "6"
+ }
+ ],
+ "html": "<html><head></head><frameset><!--1--><noframes>A</noframes><!--2--></frameset><!--3--><noframes>B</noframes><!--4--><noframes>C</noframes></html><!--5--><!--6-->",
+ "noQuirksBodyHtml": "<!--1--><noframes>A</noframes><!--2--><!--3--><noframes>B</noframes><!--4--><!--5--><noframes>C</noframes><!--6-->"
+ }
+ },
+ {
+ "data": "<select><option>A<select><option>B<select><option>C<select><option>D<select><option>E<select><option>F<select><option>G<select>",
+ "errors": [
+ "(1,8): expected-doctype-but-got-start-tag",
+ "(1,25): unexpected-select-in-select",
+ "(1,59): unexpected-select-in-select",
+ "(1,93): unexpected-select-in-select",
+ "(1,127): unexpected-select-in-select"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "select": true,
+ "option": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "option",
+ "children": [
+ {
+ "text": "B"
+ },
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option",
+ "children": [
+ {
+ "text": "C"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "option",
+ "children": [
+ {
+ "text": "D"
+ },
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option",
+ "children": [
+ {
+ "text": "E"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "option",
+ "children": [
+ {
+ "text": "F"
+ },
+ {
+ "tag": "select",
+ "children": [
+ {
+ "tag": "option",
+ "children": [
+ {
+ "text": "G"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><select><option>A</option></select><option>B<select><option>C</option></select></option><option>D<select><option>E</option></select></option><option>F<select><option>G</option></select></option></body></html>",
+ "noQuirksBodyHtml": "<select><option>A</option></select><option>B<select><option>C</option></select></option><option>D<select><option>E</option></select></option><option>F<select><option>G</option></select></option>"
+ }
+ },
+ {
+ "data": "<dd><dd><dt><dt><dd><li><li>",
+ "errors": [
+ "(1,4): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "dd": true,
+ "dt": true,
+ "li": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "dd"
+ },
+ {
+ "tag": "dd"
+ },
+ {
+ "tag": "dt"
+ },
+ {
+ "tag": "dt"
+ },
+ {
+ "tag": "dd",
+ "children": [
+ {
+ "tag": "li"
+ },
+ {
+ "tag": "li"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><dd></dd><dd></dd><dt></dt><dt></dt><dd><li></li><li></li></dd></body></html>",
+ "noQuirksBodyHtml": "<dd></dd><dd></dd><dt></dt><dt></dt><dd><li></li><li></li></dd>"
+ }
+ },
+ {
+ "data": "<div><b></div><div><nobr>a<nobr>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,14): end-tag-too-early",
+ "(1,32): unexpected-start-tag-implies-end-tag",
+ "(1,32): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "b": true,
+ "nobr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "nobr",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "tag": "nobr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><b></b></div><div><b><nobr>a</nobr><nobr></nobr></b></div></body></html>",
+ "noQuirksBodyHtml": "<div><b></b></div><div><b><nobr>a</nobr><nobr></nobr></b></div>"
+ }
+ },
+ {
+ "data": "<head></head>\n<body></body>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "text": "\n"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head>\n<body></body></html>",
+ "noQuirksBodyHtml": "\n"
+ }
+ },
+ {
+ "data": "<head></head> <style></style>ddd",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,21): unexpected-start-tag-out-of-my-head"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "style": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head",
+ "children": [
+ {
+ "tag": "style"
+ }
+ ]
+ },
+ {
+ "text": " "
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "ddd"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head><style></style></head> <body>ddd</body></html>",
+ "noQuirksBodyHtml": " <style></style>ddd"
+ }
+ },
+ {
+ "data": "<kbd><table></kbd><col><select><tr>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,18): unexpected-end-tag-implies-table-voodoo",
+ "(1,18): unexpected-end-tag",
+ "(1,31): unexpected-start-tag-implies-table-voodoo",
+ "(1,35): unexpected-table-element-start-tag-in-select-in-table",
+ "(1,35): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "kbd": true,
+ "select": true,
+ "table": true,
+ "colgroup": true,
+ "col": true,
+ "tbody": true,
+ "tr": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "kbd",
+ "children": [
+ {
+ "tag": "select"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><kbd><select></select><table><colgroup><col></colgroup><tbody><tr></tr></tbody></table></kbd></body></html>",
+ "noQuirksBodyHtml": "<kbd><select></select><table><colgroup><col></colgroup><tbody><tr></tr></tbody></table></kbd>"
+ }
+ },
+ {
+ "data": "<kbd><table></kbd><col><select><tr></table><div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,18): unexpected-end-tag-implies-table-voodoo",
+ "(1,18): unexpected-end-tag",
+ "(1,31): unexpected-start-tag-implies-table-voodoo",
+ "(1,35): unexpected-table-element-start-tag-in-select-in-table",
+ "(1,48): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "kbd": true,
+ "select": true,
+ "table": true,
+ "colgroup": true,
+ "col": true,
+ "tbody": true,
+ "tr": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "kbd",
+ "children": [
+ {
+ "tag": "select"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "colgroup",
+ "children": [
+ {
+ "tag": "col"
+ }
+ ]
+ },
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><kbd><select></select><table><colgroup><col></colgroup><tbody><tr></tr></tbody></table><div></div></kbd></body></html>",
+ "noQuirksBodyHtml": "<kbd><select></select><table><colgroup><col></colgroup><tbody><tr></tr></tbody></table><div></div></kbd>"
+ }
+ },
+ {
+ "data": "<a><li><style></style><title></title></a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,41): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "li": true,
+ "style": true,
+ "title": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "li",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "style"
+ },
+ {
+ "tag": "title"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a></a><li><a><style></style><title></title></a></li></body></html>",
+ "noQuirksBodyHtml": "<a></a><li><a><style></style><title></title></a></li>"
+ }
+ },
+ {
+ "data": "<font></p><p><meta><title></title></font>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,10): unexpected-end-tag",
+ "(1,41): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "font": true,
+ "p": true,
+ "meta": true,
+ "title": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "font",
+ "children": [
+ {
+ "tag": "p"
+ }
+ ]
+ },
+ {
+ "tag": "p",
+ "children": [
+ {
+ "tag": "font",
+ "children": [
+ {
+ "tag": "meta"
+ },
+ {
+ "tag": "title"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><font><p></p></font><p><font><meta><title></title></font></p></body></html>",
+ "noQuirksBodyHtml": "<font><p></p></font><p><font><meta><title></title></font></p>"
+ }
+ },
+ {
+ "data": "<a><center><title></title><a>",
+ "errors": [
+ "(1,3): expected-doctype-but-got-start-tag",
+ "(1,29): unexpected-start-tag-implies-end-tag",
+ "(1,29): adoption-agency-1.3",
+ "(1,29): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "a": true,
+ "center": true,
+ "title": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "a"
+ },
+ {
+ "tag": "center",
+ "children": [
+ {
+ "tag": "a",
+ "children": [
+ {
+ "tag": "title"
+ }
+ ]
+ },
+ {
+ "tag": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><a></a><center><a><title></title></a><a></a></center></body></html>",
+ "noQuirksBodyHtml": "<a></a><center><a><title></title></a><a></a></center>"
+ }
+ },
+ {
+ "data": "<svg><title><div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,17): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg title": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "title",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg><title><div></div></title></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><title><div></div></title></svg>"
+ }
+ },
+ {
+ "data": "<svg><title><rect><div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,23): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg title": true,
+ "rect": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "title",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "rect",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg><title><rect><div></div></rect></title></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><title><rect><div></div></rect></title></svg>"
+ }
+ },
+ {
+ "data": "<svg><title><svg><div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,22): unexpected-html-element-in-foreign-content",
+ "(1,22): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg title": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "title",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg><title><svg></svg><div></div></title></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><title><svg><div></div></svg></title></svg>"
+ }
+ },
+ {
+ "data": "<img <=\"\" FAIL>",
+ "errors": [
+ "(1,6): invalid-character-in-attribute-name",
+ "(1,15): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "img": true
+ },
+ "attrWithFunnyChar": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "img",
+ "attrs": [
+ {
+ "name": "<",
+ "value": ""
+ },
+ {
+ "name": "fail",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><img <=\"\" fail=\"\"></body></html>",
+ "noQuirksBodyHtml": "<img <=\"\" fail=\"\">"
+ }
+ },
+ {
+ "data": "<ul><li><div id='foo'/>A</li><li>B<div>C</div></li></ul>",
+ "errors": [
+ "(1,4): expected-doctype-but-got-start-tag",
+ "(1,23): non-void-element-with-trailing-solidus",
+ "(1,29): end-tag-too-early"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "ul": true,
+ "li": true,
+ "div": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "ul",
+ "children": [
+ {
+ "tag": "li",
+ "children": [
+ {
+ "tag": "div",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "foo"
+ }
+ ],
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "li",
+ "children": [
+ {
+ "text": "B"
+ },
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "C"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><ul><li><div id=\"foo\">A</div></li><li>B<div>C</div></li></ul></body></html>",
+ "noQuirksBodyHtml": "<ul><li><div id=\"foo\">A</div></li><li>B<div>C</div></li></ul>"
+ }
+ },
+ {
+ "data": "<svg><em><desc></em>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,9): unexpected-html-element-in-foreign-content",
+ "(1,20): adoption-agency-1.3"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "em": true,
+ "desc": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "em",
+ "children": [
+ {
+ "tag": "desc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg></svg><em><desc></desc></em></body></html>",
+ "noQuirksBodyHtml": "<svg><em><desc></desc></em></svg>"
+ }
+ },
+ {
+ "data": "<table><tr><td><svg><desc><td></desc><circle>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true,
+ "svg svg": true,
+ "svg desc": true,
+ "circle": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "desc",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "td",
+ "children": [
+ {
+ "tag": "circle"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td><svg><desc></desc></svg></td><td><circle></circle></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td><svg><desc></desc></svg></td><td><circle></circle></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<svg><tfoot></mi><td>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag",
+ "(1,17): unexpected-end-tag",
+ "(1,17): unexpected-end-tag",
+ "(1,21): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg tfoot": true,
+ "svg td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "tfoot",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "td",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg><tfoot><td></td></tfoot></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><tfoot><td></td></tfoot></svg>"
+ }
+ },
+ {
+ "data": "<math><mrow><mrow><mn>1</mn></mrow><mi>a</mi></mrow></math>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "math math": true,
+ "math mrow": true,
+ "math mn": true,
+ "math mi": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "math",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mrow",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mrow",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "tag": "mn",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "1"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "mi",
+ "ns": "http://www.w3.org/1998/Math/MathML",
+ "children": [
+ {
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><math><mrow><mrow><mn>1</mn></mrow><mi>a</mi></mrow></math></body></html>",
+ "noQuirksBodyHtml": "<math><mrow><mrow><mn>1</mn></mrow><mi>a</mi></mrow></math>"
+ }
+ },
+ {
+ "data": "<!doctype html><input type=\"hidden\"><frameset>",
+ "errors": [
+ "(1,46): unexpected-start-tag",
+ "(1,46): eof-in-frameset"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "frameset": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "frameset"
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><frameset></frameset></html>",
+ "noQuirksBodyHtml": "<input type=\"hidden\">"
+ }
+ },
+ {
+ "data": "<!doctype html><input type=\"button\"><frameset>",
+ "errors": [
+ "(1,46): unexpected-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "input": true
+ },
+ "doctype": true
+ },
+ "tree": [
+ {
+ "doctype": "html"
+ },
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "input",
+ "attrs": [
+ {
+ "name": "type",
+ "value": "button"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<!DOCTYPE html><html><head></head><body><input type=\"button\"></body></html>",
+ "noQuirksBodyHtml": "<input type=\"button\">"
+ }
+ }
+ ],
+ "webkit02.dat": [
+ {
+ "data": "<foo bar=qux/>",
+ "errors": [
+ "(1,14): expected-doctype-but-got-start-tag",
+ "(1,14): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "foo": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "foo",
+ "attrs": [
+ {
+ "name": "bar",
+ "value": "qux/"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><foo bar=\"qux/\"></foo></body></html>",
+ "noQuirksBodyHtml": "<foo bar=\"qux/\"></foo>"
+ }
+ },
+ {
+ "data": "<p id=\"status\"><noscript><strong>A</strong></noscript><span>B</span></p>",
+ "errors": [
+ "(1,15): expected-doctype-but-got-start-tag"
+ ],
+ "script": "on",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "noscript": true,
+ "span": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "status"
+ }
+ ],
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "text": "<strong>A</strong>",
+ "no_escape": true
+ }
+ ]
+ },
+ {
+ "tag": "span",
+ "children": [
+ {
+ "text": "B"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p id=\"status\"><noscript><strong>A</strong></noscript><span>B</span></p></body></html>",
+ "noQuirksBodyHtml": "<p id=\"status\"><noscript><strong>A</strong></noscript><span>B</span></p>"
+ }
+ },
+ {
+ "data": "<p id=\"status\"><noscript><strong>A</strong></noscript><span>B</span></p>",
+ "errors": [
+ "(1,15): expected-doctype-but-got-start-tag"
+ ],
+ "script": "off",
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "p": true,
+ "noscript": true,
+ "strong": true,
+ "span": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "p",
+ "attrs": [
+ {
+ "name": "id",
+ "value": "status"
+ }
+ ],
+ "children": [
+ {
+ "tag": "noscript",
+ "children": [
+ {
+ "tag": "strong",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "span",
+ "children": [
+ {
+ "text": "B"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><p id=\"status\"><noscript><strong>A</strong></noscript><span>B</span></p></body></html>",
+ "noQuirksBodyHtml": "<p id=\"status\"><noscript><strong>A</strong></noscript><span>B</span></p>"
+ }
+ },
+ {
+ "data": "<div><sarcasm><div></div></sarcasm></div>",
+ "errors": [
+ "(1,5): expected-doctype-but-got-start-tag"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "div": true,
+ "sarcasm": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "tag": "sarcasm",
+ "children": [
+ {
+ "tag": "div"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><div><sarcasm><div></div></sarcasm></div></body></html>",
+ "noQuirksBodyHtml": "<div><sarcasm><div></div></sarcasm></div>"
+ }
+ },
+ {
+ "data": "<html><body><img src=\"\" border=\"0\" alt=\"><div>A</div></body></html>",
+ "errors": [
+ "(1,6): expected-doctype-but-got-start-tag",
+ "(1,67): eof-in-attribute-value-double-quote"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body"
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body></body></html>",
+ "noQuirksBodyHtml": ""
+ }
+ },
+ {
+ "data": "<table><td></tbody>A",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-cell-in-table-body",
+ "(1,20): foster-parenting-character",
+ "(1,20): eof-in-table"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "text": "A"
+ },
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body>A<table><tbody><tr><td></td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "A<table><tbody><tr><td></td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><td></thead>A",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-cell-in-table-body",
+ "(1,19): XXX-undefined-error",
+ "(1,20): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td>A</td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td>A</td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><td></tfoot>A",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,11): unexpected-cell-in-table-body",
+ "(1,19): XXX-undefined-error",
+ "(1,20): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "tbody": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "tbody",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><tbody><tr><td>A</td></tr></tbody></table></body></html>",
+ "noQuirksBodyHtml": "<table><tbody><tr><td>A</td></tr></tbody></table>"
+ }
+ },
+ {
+ "data": "<table><thead><td></tbody>A",
+ "errors": [
+ "(1,7): expected-doctype-but-got-start-tag",
+ "(1,18): unexpected-cell-in-table-body",
+ "(1,26): XXX-undefined-error",
+ "(1,27): expected-closing-tag-but-got-eof"
+ ],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "table": true,
+ "thead": true,
+ "tr": true,
+ "td": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "table",
+ "children": [
+ {
+ "tag": "thead",
+ "children": [
+ {
+ "tag": "tr",
+ "children": [
+ {
+ "tag": "td",
+ "children": [
+ {
+ "text": "A"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><table><thead><tr><td>A</td></tr></thead></table></body></html>",
+ "noQuirksBodyHtml": "<table><thead><tr><td>A</td></tr></thead></table>"
+ }
+ },
+ {
+ "data": "<legend>test</legend>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "legend": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "legend",
+ "children": [
+ {
+ "text": "test"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><legend>test</legend></body></html>",
+ "noQuirksBodyHtml": "<legend>test</legend>"
+ }
+ },
+ {
+ "data": "<table><input>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "input": true,
+ "table": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "input"
+ },
+ {
+ "tag": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><input><table></table></body></html>",
+ "noQuirksBodyHtml": "<input><table></table>"
+ }
+ },
+ {
+ "data": "<b><em><foo><foo><aside></b>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "em": true,
+ "foo": true,
+ "aside": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "em",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "em",
+ "children": [
+ {
+ "tag": "aside",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b><em><foo><foo></foo></foo></em></b><em><aside><b></b></aside></em></body></html>",
+ "noQuirksBodyHtml": "<b><em><foo><foo></foo></foo></em></b><em><aside><b></b></aside></em>"
+ }
+ },
+ {
+ "data": "<b><em><foo><foo><aside></b></em>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "em": true,
+ "foo": true,
+ "aside": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "em",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "em"
+ },
+ {
+ "tag": "aside",
+ "children": [
+ {
+ "tag": "em",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b><em><foo><foo></foo></foo></em></b><em></em><aside><em><b></b></em></aside></body></html>",
+ "noQuirksBodyHtml": "<b><em><foo><foo></foo></foo></em></b><em></em><aside><em><b></b></em></aside>"
+ }
+ },
+ {
+ "data": "<b><em><foo><foo><foo><aside></b>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "em": true,
+ "foo": true,
+ "aside": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "em",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "aside",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b><em><foo><foo><foo></foo></foo></foo></em></b><aside><b></b></aside></body></html>",
+ "noQuirksBodyHtml": "<b><em><foo><foo><foo></foo></foo></foo></em></b><aside><b></b></aside>"
+ }
+ },
+ {
+ "data": "<b><em><foo><foo><foo><aside></b></em>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "b": true,
+ "em": true,
+ "foo": true,
+ "aside": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "em",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "aside",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><b><em><foo><foo><foo></foo></foo></foo></em></b><aside><b></b></aside></body></html>",
+ "noQuirksBodyHtml": "<b><em><foo><foo><foo></foo></foo></foo></em></b><aside><b></b></aside>"
+ }
+ },
+ {
+ "data": "<b><em><foo><foo><foo><foo><foo><foo><foo><foo><foo><foo><aside></b></em>",
+ "errors": [],
+ "fragment": {
+ "name": "div"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "b": true,
+ "em": true,
+ "foo": true,
+ "aside": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "em",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "aside",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ],
+ "html": "<b><em><foo><foo><foo><foo><foo><foo><foo><foo><foo><foo></foo></foo></foo></foo></foo></foo></foo></foo></foo></foo></em></b><aside><b></b></aside>",
+ "noQuirksBodyHtml": "<b><em><foo><foo><foo><foo><foo><foo><foo><foo><foo><foo></foo></foo></foo></foo></foo></foo></foo></foo></foo></foo></em></b><aside><b></b></aside>"
+ }
+ },
+ {
+ "data": "<b><em><foo><foob><foob><foob><foob><fooc><fooc><fooc><fooc><food><aside></b></em>",
+ "errors": [],
+ "fragment": {
+ "name": "div"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "b": true,
+ "em": true,
+ "foo": true,
+ "foob": true,
+ "fooc": true,
+ "food": true,
+ "aside": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "b",
+ "children": [
+ {
+ "tag": "em",
+ "children": [
+ {
+ "tag": "foo",
+ "children": [
+ {
+ "tag": "foob",
+ "children": [
+ {
+ "tag": "foob",
+ "children": [
+ {
+ "tag": "foob",
+ "children": [
+ {
+ "tag": "foob",
+ "children": [
+ {
+ "tag": "fooc",
+ "children": [
+ {
+ "tag": "fooc",
+ "children": [
+ {
+ "tag": "fooc",
+ "children": [
+ {
+ "tag": "fooc",
+ "children": [
+ {
+ "tag": "food"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "tag": "aside",
+ "children": [
+ {
+ "tag": "b"
+ }
+ ]
+ }
+ ],
+ "html": "<b><em><foo><foob><foob><foob><foob><fooc><fooc><fooc><fooc><food></food></fooc></fooc></fooc></fooc></foob></foob></foob></foob></foo></em></b><aside><b></b></aside>",
+ "noQuirksBodyHtml": "<b><em><foo><foob><foob><foob><foob><fooc><fooc><fooc><fooc><food></food></fooc></fooc></fooc></fooc></foob></foob></foob></foob></foo></em></b><aside><b></b></aside>"
+ }
+ },
+ {
+ "data": "<option><XH<optgroup></optgroup>",
+ "errors": [],
+ "fragment": {
+ "name": "select"
+ },
+ "document": {
+ "props": {
+ "tags": {
+ "option": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "option"
+ }
+ ],
+ "html": "<option></option>",
+ "noQuirksBodyHtml": "<option><xh<optgroup></xh<optgroup></option>"
+ }
+ },
+ {
+ "data": "<svg><foreignObject><div>foo</div><plaintext></foreignObject></svg><div>bar</div>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg foreignObject": true,
+ "div": true,
+ "plaintext": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "div",
+ "children": [
+ {
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "text": "</foreignObject></svg><div>bar</div>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg><foreignObject><div>foo</div><plaintext></foreignObject></svg><div>bar</div></plaintext></foreignObject></svg></body></html>",
+ "noQuirksBodyHtml": "<svg><foreignObject><div>foo</div><plaintext></foreignObject></svg><div>bar</div></plaintext></foreignObject></svg>"
+ }
+ },
+ {
+ "data": "<svg><foreignObject></foreignObject><title></svg>foo",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "svg svg": true,
+ "svg foreignObject": true,
+ "svg title": true
+ }
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "svg",
+ "ns": "http://www.w3.org/2000/svg",
+ "children": [
+ {
+ "tag": "foreignObject",
+ "ns": "http://www.w3.org/2000/svg"
+ },
+ {
+ "tag": "title",
+ "ns": "http://www.w3.org/2000/svg"
+ }
+ ]
+ },
+ {
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><svg><foreignObject></foreignObject><title></title></svg>foo</body></html>",
+ "noQuirksBodyHtml": "<svg><foreignObject></foreignObject><title></title></svg>foo"
+ }
+ },
+ {
+ "data": "</foreignObject><plaintext><div>foo</div>",
+ "errors": [],
+ "document": {
+ "props": {
+ "tags": {
+ "html": true,
+ "head": true,
+ "body": true,
+ "plaintext": true
+ },
+ "no_escape": true
+ },
+ "tree": [
+ {
+ "tag": "html",
+ "children": [
+ {
+ "tag": "head"
+ },
+ {
+ "tag": "body",
+ "children": [
+ {
+ "tag": "plaintext",
+ "children": [
+ {
+ "text": "<div>foo</div>",
+ "no_escape": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "html": "<html><head></head><body><plaintext><div>foo</div></plaintext></body></html>",
+ "noQuirksBodyHtml": "<plaintext><div>foo</div></plaintext>"
+ }
+ }
+ ]
+} \ No newline at end of file
diff --git a/www/wiki/tests/phpunit/includes/title/ForeignTitleTest.php b/www/wiki/tests/phpunit/includes/title/ForeignTitleTest.php
new file mode 100644
index 00000000..f2fccc75
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/title/ForeignTitleTest.php
@@ -0,0 +1,103 @@
+<?php
+/**
+ * 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
+ * @author This, that and the other
+ */
+
+/**
+ * @covers ForeignTitle
+ *
+ * @group Title
+ */
+class ForeignTitleTest extends MediaWikiTestCase {
+
+ public function basicProvider() {
+ return [
+ [
+ new ForeignTitle( 20, 'Contributor', 'JohnDoe' ),
+ 20, 'Contributor', 'JohnDoe'
+ ],
+ [
+ new ForeignTitle( '1', 'Discussion', 'Capital' ),
+ 1, 'Discussion', 'Capital'
+ ],
+ [
+ new ForeignTitle( 0, '', 'MainNamespace' ),
+ 0, '', 'MainNamespace'
+ ],
+ [
+ new ForeignTitle( 4, 'Some ns', 'Article title with spaces' ),
+ 4, 'Some_ns', 'Article_title_with_spaces'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider basicProvider
+ */
+ public function testBasic( ForeignTitle $title, $expectedId, $expectedName,
+ $expectedText
+ ) {
+ $this->assertEquals( true, $title->isNamespaceIdKnown() );
+ $this->assertEquals( $expectedId, $title->getNamespaceId() );
+ $this->assertEquals( $expectedName, $title->getNamespaceName() );
+ $this->assertEquals( $expectedText, $title->getText() );
+ }
+
+ public function testUnknownNamespaceCheck() {
+ $title = new ForeignTitle( null, 'this', 'that' );
+
+ $this->assertEquals( false, $title->isNamespaceIdKnown() );
+ $this->assertEquals( 'this', $title->getNamespaceName() );
+ $this->assertEquals( 'that', $title->getText() );
+ }
+
+ public function testUnknownNamespaceError() {
+ $this->setExpectedException( MWException::class );
+ $title = new ForeignTitle( null, 'this', 'that' );
+ $title->getNamespaceId();
+ }
+
+ public function fullTextProvider() {
+ return [
+ [
+ new ForeignTitle( 20, 'Contributor', 'JohnDoe' ),
+ 'Contributor:JohnDoe'
+ ],
+ [
+ new ForeignTitle( '1', 'Discussion', 'Capital' ),
+ 'Discussion:Capital'
+ ],
+ [
+ new ForeignTitle( 0, '', 'MainNamespace' ),
+ 'MainNamespace'
+ ],
+ [
+ new ForeignTitle( 4, 'Some ns', 'Article title with spaces' ),
+ 'Some_ns:Article_title_with_spaces'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider fullTextProvider
+ */
+ public function testFullText( ForeignTitle $title, $fullText ) {
+ $this->assertEquals( $fullText, $title->getFullText() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php b/www/wiki/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php
new file mode 100644
index 00000000..e1b98ec3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php
@@ -0,0 +1,415 @@
+<?php
+/**
+ * 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
+ * @author Daniel Kinzler
+ */
+
+/**
+ * @covers MediaWikiTitleCodec
+ *
+ * @group Title
+ * @group Database
+ * ^--- needed because of global state in
+ */
+class MediaWikiTitleCodecTest extends MediaWikiTestCase {
+
+ public function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgAllowUserJs' => false,
+ 'wgDefaultLanguageVariant' => false,
+ 'wgMetaNamespace' => 'Project',
+ 'wgLocalInterwikis' => [ 'localtestiw' ],
+ 'wgCapitalLinks' => true,
+
+ // NOTE: this is why global state is evil.
+ // TODO: refactor access to the interwiki codes so it can be injected.
+ 'wgHooks' => [
+ 'InterwikiLoadPrefix' => [
+ function ( $prefix, &$data ) {
+ if ( $prefix === 'localtestiw' ) {
+ $data = [ 'iw_url' => 'localtestiw' ];
+ } elseif ( $prefix === 'remotetestiw' ) {
+ $data = [ 'iw_url' => 'remotetestiw' ];
+ }
+ return false;
+ }
+ ]
+ ]
+ ] );
+ $this->setUserLang( 'en' );
+ $this->setContentLang( 'en' );
+ }
+
+ /**
+ * Returns a mock GenderCache that will consider a user "female" if the
+ * first part of the user name ends with "a".
+ *
+ * @return GenderCache
+ */
+ private function getGenderCache() {
+ $genderCache = $this->getMockBuilder( GenderCache::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $genderCache->expects( $this->any() )
+ ->method( 'getGenderOf' )
+ ->will( $this->returnCallback( function ( $userName ) {
+ return preg_match( '/^[^- _]+a( |_|$)/u', $userName ) ? 'female' : 'male';
+ } ) );
+
+ return $genderCache;
+ }
+
+ protected function makeCodec( $lang ) {
+ $gender = $this->getGenderCache();
+ $lang = Language::factory( $lang );
+ // language object can came from cache, which does not respect test settings
+ $lang->resetNamespaces();
+ return new MediaWikiTitleCodec( $lang, $gender );
+ }
+
+ public static function provideFormat() {
+ return [
+ [ NS_MAIN, 'Foo_Bar', '', '', 'en', 'Foo Bar' ],
+ [ NS_USER, 'Hansi_Maier', 'stuff_and_so_on', '', 'en', 'User:Hansi Maier#stuff and so on' ],
+ [ false, 'Hansi_Maier', '', '', 'en', 'Hansi Maier' ],
+ [
+ NS_USER_TALK,
+ 'hansi__maier',
+ '',
+ '',
+ 'en',
+ 'User talk:hansi maier',
+ 'User talk:Hansi maier'
+ ],
+
+ // getGenderCache() provides a mock that considers first
+ // names ending in "a" to be female.
+ [ NS_USER, 'Lisa_Müller', '', '', 'de', 'Benutzerin:Lisa Müller' ],
+ [ NS_MAIN, 'FooBar', '', 'remotetestiw', 'en', 'remotetestiw:FooBar' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideFormat
+ */
+ public function testFormat( $namespace, $text, $fragment, $interwiki, $lang, $expected,
+ $normalized = null
+ ) {
+ if ( $normalized === null ) {
+ $normalized = $expected;
+ }
+
+ $codec = $this->makeCodec( $lang );
+ $actual = $codec->formatTitle( $namespace, $text, $fragment, $interwiki );
+
+ $this->assertEquals( $expected, $actual, 'formatted' );
+
+ // test round trip
+ $parsed = $codec->parseTitle( $actual, NS_MAIN );
+ $actual2 = $codec->formatTitle(
+ $parsed->getNamespace(),
+ $parsed->getText(),
+ $parsed->getFragment(),
+ $parsed->getInterwiki()
+ );
+
+ $this->assertEquals( $normalized, $actual2, 'normalized after round trip' );
+ }
+
+ public static function provideGetText() {
+ return [
+ [ NS_MAIN, 'Foo_Bar', '', 'en', 'Foo Bar' ],
+ [ NS_USER, 'Hansi_Maier', 'stuff_and_so_on', 'en', 'Hansi Maier' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetText
+ */
+ public function testGetText( $namespace, $dbkey, $fragment, $lang, $expected ) {
+ $codec = $this->makeCodec( $lang );
+ $title = new TitleValue( $namespace, $dbkey, $fragment );
+
+ $actual = $codec->getText( $title );
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideGetPrefixedText() {
+ return [
+ [ NS_MAIN, 'Foo_Bar', '', 'en', 'Foo Bar' ],
+ [ NS_USER, 'Hansi_Maier', 'stuff_and_so_on', 'en', 'User:Hansi Maier' ],
+
+ // No capitalization or normalization is applied while formatting!
+ [ NS_USER_TALK, 'hansi__maier', '', 'en', 'User talk:hansi maier' ],
+
+ // getGenderCache() provides a mock that considers first
+ // names ending in "a" to be female.
+ [ NS_USER, 'Lisa_Müller', '', 'de', 'Benutzerin:Lisa Müller' ],
+ [ 1000000, 'Invalid_namespace', '', 'en', ':Invalid namespace' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetPrefixedText
+ */
+ public function testGetPrefixedText( $namespace, $dbkey, $fragment, $lang, $expected ) {
+ $codec = $this->makeCodec( $lang );
+ $title = new TitleValue( $namespace, $dbkey, $fragment );
+
+ $actual = $codec->getPrefixedText( $title );
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideGetPrefixedDBkey() {
+ return [
+ [ NS_MAIN, 'Foo_Bar', '', '', 'en', 'Foo_Bar' ],
+ [ NS_USER, 'Hansi_Maier', 'stuff_and_so_on', '', 'en', 'User:Hansi_Maier' ],
+
+ // No capitalization or normalization is applied while formatting!
+ [ NS_USER_TALK, 'hansi__maier', '', '', 'en', 'User_talk:hansi__maier' ],
+
+ // getGenderCache() provides a mock that considers first
+ // names ending in "a" to be female.
+ [ NS_USER, 'Lisa_Müller', '', '', 'de', 'Benutzerin:Lisa_Müller' ],
+
+ [ NS_MAIN, 'Remote_page', '', 'remotetestiw', 'en', 'remotetestiw:Remote_page' ],
+
+ // non-existent namespace
+ [ 10000000, 'Foobar', '', '', 'en', ':Foobar' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetPrefixedDBkey
+ */
+ public function testGetPrefixedDBkey( $namespace, $dbkey, $fragment,
+ $interwiki, $lang, $expected
+ ) {
+ $codec = $this->makeCodec( $lang );
+ $title = new TitleValue( $namespace, $dbkey, $fragment, $interwiki );
+
+ $actual = $codec->getPrefixedDBkey( $title );
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideGetFullText() {
+ return [
+ [ NS_MAIN, 'Foo_Bar', '', 'en', 'Foo Bar' ],
+ [ NS_USER, 'Hansi_Maier', 'stuff_and_so_on', 'en', 'User:Hansi Maier#stuff and so on' ],
+
+ // No capitalization or normalization is applied while formatting!
+ [ NS_USER_TALK, 'hansi__maier', '', 'en', 'User talk:hansi maier' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetFullText
+ */
+ public function testGetFullText( $namespace, $dbkey, $fragment, $lang, $expected ) {
+ $codec = $this->makeCodec( $lang );
+ $title = new TitleValue( $namespace, $dbkey, $fragment );
+
+ $actual = $codec->getFullText( $title );
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ public static function provideParseTitle() {
+ // TODO: test capitalization and trimming
+ // TODO: test unicode normalization
+
+ return [
+ [ ' : Hansi_Maier _ ', NS_MAIN, 'en',
+ new TitleValue( NS_MAIN, 'Hansi_Maier', '' ) ],
+ [ 'User:::1', NS_MAIN, 'de',
+ new TitleValue( NS_USER, '0:0:0:0:0:0:0:1', '' ) ],
+ [ ' lisa Müller', NS_USER, 'de',
+ new TitleValue( NS_USER, 'Lisa_Müller', '' ) ],
+ [ 'benutzerin:lisa Müller#stuff', NS_MAIN, 'de',
+ new TitleValue( NS_USER, 'Lisa_Müller', 'stuff' ) ],
+
+ [ ':Category:Quux', NS_MAIN, 'en',
+ new TitleValue( NS_CATEGORY, 'Quux', '' ) ],
+ [ 'Category:Quux', NS_MAIN, 'en',
+ new TitleValue( NS_CATEGORY, 'Quux', '' ) ],
+ [ 'Category:Quux', NS_CATEGORY, 'en',
+ new TitleValue( NS_CATEGORY, 'Quux', '' ) ],
+ [ 'Quux', NS_CATEGORY, 'en',
+ new TitleValue( NS_CATEGORY, 'Quux', '' ) ],
+ [ ':Quux', NS_CATEGORY, 'en',
+ new TitleValue( NS_MAIN, 'Quux', '' ) ],
+
+ // getGenderCache() provides a mock that considers first
+ // names ending in "a" to be female.
+
+ [ 'a b c', NS_MAIN, 'en',
+ new TitleValue( NS_MAIN, 'A_b_c' ) ],
+ [ ' a b c ', NS_MAIN, 'en',
+ new TitleValue( NS_MAIN, 'A_b_c' ) ],
+ [ ' _ Foo __ Bar_ _', NS_MAIN, 'en',
+ new TitleValue( NS_MAIN, 'Foo_Bar' ) ],
+
+ // NOTE: cases copied from TitleTest::testSecureAndSplit. Keep in sync.
+ [ 'Sandbox', NS_MAIN, 'en', ],
+ [ 'A "B"', NS_MAIN, 'en', ],
+ [ 'A \'B\'', NS_MAIN, 'en', ],
+ [ '.com', NS_MAIN, 'en', ],
+ [ '~', NS_MAIN, 'en', ],
+ [ '"', NS_MAIN, 'en', ],
+ [ '\'', NS_MAIN, 'en', ],
+
+ [ 'Talk:Sandbox', NS_MAIN, 'en',
+ new TitleValue( NS_TALK, 'Sandbox' ) ],
+ [ 'Talk:Foo:Sandbox', NS_MAIN, 'en',
+ new TitleValue( NS_TALK, 'Foo:Sandbox' ) ],
+ [ 'File:Example.svg', NS_MAIN, 'en',
+ new TitleValue( NS_FILE, 'Example.svg' ) ],
+ [ 'File_talk:Example.svg', NS_MAIN, 'en',
+ new TitleValue( NS_FILE_TALK, 'Example.svg' ) ],
+ [ 'Foo/.../Sandbox', NS_MAIN, 'en',
+ 'Foo/.../Sandbox' ],
+ [ 'Sandbox/...', NS_MAIN, 'en',
+ 'Sandbox/...' ],
+ [ 'A~~', NS_MAIN, 'en',
+ 'A~~' ],
+ // Length is 256 total, but only title part matters
+ [ 'Category:' . str_repeat( 'x', 248 ), NS_MAIN, 'en',
+ new TitleValue( NS_CATEGORY,
+ 'X' . str_repeat( 'x', 247 ) ) ],
+ [ str_repeat( 'x', 252 ), NS_MAIN, 'en',
+ 'X' . str_repeat( 'x', 251 ) ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideParseTitle
+ */
+ public function testParseTitle( $text, $ns, $lang, $title = null ) {
+ if ( $title === null ) {
+ $title = str_replace( ' ', '_', trim( $text ) );
+ }
+
+ if ( is_string( $title ) ) {
+ $title = new TitleValue( NS_MAIN, $title, '' );
+ }
+
+ $codec = $this->makeCodec( $lang );
+ $actual = $codec->parseTitle( $text, $ns );
+
+ $this->assertEquals( $title, $actual );
+ }
+
+ public static function provideParseTitle_invalid() {
+ // TODO: test unicode errors
+
+ return [
+ [ '#' ],
+ [ '::' ],
+ [ '::xx' ],
+ [ '::##' ],
+ [ ' :: x' ],
+
+ [ 'Talk:File:Foo.jpg' ],
+ [ 'Talk:localtestiw:Foo' ],
+ [ '::1' ], // only valid in user namespace
+ [ 'User::x' ], // leading ":" in a user name is only valid of IPv6 addresses
+
+ // NOTE: cases copied from TitleTest::testSecureAndSplit. Keep in sync.
+ [ '' ],
+ [ ':' ],
+ [ '__ __' ],
+ [ ' __ ' ],
+ // Bad characters forbidden regardless of wgLegalTitleChars
+ [ 'A [ B' ],
+ [ 'A ] B' ],
+ [ 'A { B' ],
+ [ 'A } B' ],
+ [ 'A < B' ],
+ [ 'A > B' ],
+ [ 'A | B' ],
+ // URL encoding
+ [ 'A%20B' ],
+ [ 'A%23B' ],
+ [ 'A%2523B' ],
+ // XML/HTML character entity references
+ // Note: Commented out because they are not marked invalid by the PHP test as
+ // Title::newFromText runs Sanitizer::decodeCharReferencesAndNormalize first.
+ // [ 'A &eacute; B' ],
+ // [ 'A &#233; B' ],
+ // [ 'A &#x00E9; B' ],
+ // Subject of NS_TALK does not roundtrip to NS_MAIN
+ [ 'Talk:File:Example.svg' ],
+ // Directory navigation
+ [ '.' ],
+ [ '..' ],
+ [ './Sandbox' ],
+ [ '../Sandbox' ],
+ [ 'Foo/./Sandbox' ],
+ [ 'Foo/../Sandbox' ],
+ [ 'Sandbox/.' ],
+ [ 'Sandbox/..' ],
+ // Tilde
+ [ 'A ~~~ Name' ],
+ [ 'A ~~~~ Signature' ],
+ [ 'A ~~~~~ Timestamp' ],
+ [ str_repeat( 'x', 256 ) ],
+ // Namespace prefix without actual title
+ [ 'Talk:' ],
+ [ 'Category: ' ],
+ [ 'Category: #bar' ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideParseTitle_invalid
+ */
+ public function testParseTitle_invalid( $text ) {
+ $this->setExpectedException( MalformedTitleException::class );
+
+ $codec = $this->makeCodec( 'en' );
+ $codec->parseTitle( $text, NS_MAIN );
+ }
+
+ public static function provideGetNamespaceName() {
+ return [
+ [ NS_MAIN, 'Foo', 'en', '' ],
+ [ NS_USER, 'Foo', 'en', 'User' ],
+ [ NS_USER, 'Hansi Maier', 'de', 'Benutzer' ],
+
+ // getGenderCache() provides a mock that considers first
+ // names ending in "a" to be female.
+ [ NS_USER, 'Lisa Müller', 'de', 'Benutzerin' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetNamespaceName
+ */
+ public function testGetNamespaceName( $namespace, $text, $lang, $expected ) {
+ $codec = $this->makeCodec( $lang );
+ $name = $codec->getNamespaceName( $namespace, $text );
+
+ $this->assertEquals( $expected, $name );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/title/NaiveForeignTitleFactoryTest.php b/www/wiki/tests/phpunit/includes/title/NaiveForeignTitleFactoryTest.php
new file mode 100644
index 00000000..b8cc39f0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/title/NaiveForeignTitleFactoryTest.php
@@ -0,0 +1,91 @@
+<?php
+/**
+ * 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
+ * @author This, that and the other
+ */
+
+/**
+ * @covers NaiveForeignTitleFactory
+ *
+ * @group Title
+ */
+class NaiveForeignTitleFactoryTest extends MediaWikiTestCase {
+
+ public function basicProvider() {
+ return [
+ [
+ 'MainNamespaceArticle', 0,
+ new ForeignTitle( 0, '', 'MainNamespaceArticle' ),
+ ],
+ [
+ 'MainNamespaceArticle', null,
+ new ForeignTitle( null, '', 'MainNamespaceArticle' ),
+ ],
+ [
+ 'Talk:Nice_talk', 1,
+ new ForeignTitle( 1, 'Talk', 'Nice_talk' ),
+ ],
+ [
+ 'Bogus:Nice_talk', 0,
+ new ForeignTitle( 0, '', 'Bogus:Nice_talk' ),
+ ],
+ [
+ 'Bogus:Nice_talk', 9000, // non-existent local namespace ID
+ new ForeignTitle( 9000, 'Bogus', 'Nice_talk' ),
+ ],
+ [
+ 'Bogus:Nice_talk', 4, // existing local namespace ID
+ new ForeignTitle( 4, 'Bogus', 'Nice_talk' ),
+ ],
+ [
+ 'Talk:Extra:Nice_talk', 1,
+ new ForeignTitle( 1, 'Talk', 'Extra:Nice_talk' ),
+ ],
+ [
+ 'Talk:Extra:Nice_talk', null,
+ new ForeignTitle( null, 'Talk', 'Extra:Nice_talk' ),
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider basicProvider
+ */
+ public function testBasic( $title, $ns, ForeignTitle $foreignTitle ) {
+ $factory = new NaiveForeignTitleFactory();
+ $testTitle = $factory->createForeignTitle( $title, $ns );
+
+ $this->assertEquals( $testTitle->isNamespaceIdKnown(),
+ $foreignTitle->isNamespaceIdKnown() );
+
+ if (
+ $testTitle->isNamespaceIdKnown() &&
+ $foreignTitle->isNamespaceIdKnown()
+ ) {
+ $this->assertEquals( $testTitle->getNamespaceId(),
+ $foreignTitle->getNamespaceId() );
+ }
+
+ $this->assertEquals( $testTitle->getNamespaceName(),
+ $foreignTitle->getNamespaceName() );
+ $this->assertEquals( $testTitle->getText(), $foreignTitle->getText() );
+
+ $this->assertEquals( str_replace( ' ', '_', $title ),
+ $foreignTitle->getFullText() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/title/NaiveImportTitleFactoryTest.php b/www/wiki/tests/phpunit/includes/title/NaiveImportTitleFactoryTest.php
new file mode 100644
index 00000000..d711bac5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/title/NaiveImportTitleFactoryTest.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * 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
+ * @author This, that and the other
+ */
+
+/**
+ * @covers NaiveImportTitleFactory
+ *
+ * @group Title
+ */
+class NaiveImportTitleFactoryTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgLanguageCode' => 'en',
+ 'wgContLang' => Language::factory( 'en' ),
+ 'wgExtraNamespaces' => [ 100 => 'Portal' ],
+ ] );
+ }
+
+ public function basicProvider() {
+ return [
+ [
+ new ForeignTitle( 0, '', 'MainNamespaceArticle' ),
+ 'MainNamespaceArticle'
+ ],
+ [
+ new ForeignTitle( null, '', 'MainNamespaceArticle' ),
+ 'MainNamespaceArticle'
+ ],
+ [
+ new ForeignTitle( 1, 'Discussion', 'Nice_talk' ),
+ 'Talk:Nice_talk'
+ ],
+ [
+ new ForeignTitle( 0, '', 'Bogus:Nice_talk' ),
+ 'Bogus:Nice_talk'
+ ],
+ [
+ new ForeignTitle( 100, 'Bogus', 'Nice_talk' ),
+ 'Bogus:Nice_talk' // not Portal:Nice_talk
+ ],
+ [
+ new ForeignTitle( 1, 'Bogus', 'Nice_talk' ),
+ 'Talk:Nice_talk' // not Bogus:Nice_talk
+ ],
+ [
+ new ForeignTitle( 100, 'Portal', 'Nice_talk' ),
+ 'Portal:Nice_talk'
+ ],
+ [
+ new ForeignTitle( 724, 'Portal', 'Nice_talk' ),
+ 'Portal:Nice_talk'
+ ],
+ [
+ new ForeignTitle( 2, 'Portal', 'Nice_talk' ),
+ 'User:Nice_talk'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider basicProvider
+ */
+ public function testBasic( ForeignTitle $foreignTitle, $titleText ) {
+ $factory = new NaiveImportTitleFactory();
+ $testTitle = $factory->createTitleFromForeignTitle( $foreignTitle );
+ $title = Title::newFromText( $titleText );
+
+ $this->assertTrue( $title->equals( $testTitle ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/title/NamespaceAwareForeignTitleFactoryTest.php b/www/wiki/tests/phpunit/includes/title/NamespaceAwareForeignTitleFactoryTest.php
new file mode 100644
index 00000000..9aa3578d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/title/NamespaceAwareForeignTitleFactoryTest.php
@@ -0,0 +1,101 @@
+<?php
+/**
+ * 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
+ * @author This, that and the other
+ */
+
+/**
+ * @covers NamespaceAwareForeignTitleFactory
+ *
+ * @group Title
+ */
+class NamespaceAwareForeignTitleFactoryTest extends MediaWikiTestCase {
+
+ public function basicProvider() {
+ return [
+ [
+ 'MainNamespaceArticle', 0,
+ new ForeignTitle( 0, '', 'MainNamespaceArticle' ),
+ ],
+ [
+ 'MainNamespaceArticle', null,
+ new ForeignTitle( 0, '', 'MainNamespaceArticle' ),
+ ],
+ [
+ 'Magic:_The_Gathering', 0,
+ new ForeignTitle( 0, '', 'Magic:_The_Gathering' ),
+ ],
+ [
+ 'Talk:Nice_talk', 1,
+ new ForeignTitle( 1, 'Talk', 'Nice_talk' ),
+ ],
+ [
+ 'Talk:Magic:_The_Gathering', 1,
+ new ForeignTitle( 1, 'Talk', 'Magic:_The_Gathering' ),
+ ],
+ [
+ 'Bogus:Nice_talk', 0,
+ new ForeignTitle( 0, '', 'Bogus:Nice_talk' ),
+ ],
+ [
+ 'Bogus:Nice_talk', null,
+ new ForeignTitle( 9000, 'Bogus', 'Nice_talk' ),
+ ],
+ [
+ 'Bogus:Nice_talk', 4,
+ new ForeignTitle( 4, 'Bogus', 'Nice_talk' ),
+ ],
+ [
+ 'Bogus:Nice_talk', 1,
+ new ForeignTitle( 1, 'Talk', 'Nice_talk' ),
+ ],
+ // Misconfigured wiki with unregistered namespace (T114115)
+ [
+ 'Nice_talk', 1234,
+ new ForeignTitle( 1234, 'Ns1234', 'Nice_talk' ),
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider basicProvider
+ */
+ public function testBasic( $title, $ns, ForeignTitle $foreignTitle ) {
+ $foreignNamespaces = [
+ 0 => '', 1 => 'Talk', 100 => 'Portal', 9000 => 'Bogus'
+ ];
+
+ $factory = new NamespaceAwareForeignTitleFactory( $foreignNamespaces );
+ $testTitle = $factory->createForeignTitle( $title, $ns );
+
+ $this->assertEquals( $testTitle->isNamespaceIdKnown(),
+ $foreignTitle->isNamespaceIdKnown() );
+
+ if (
+ $testTitle->isNamespaceIdKnown() &&
+ $foreignTitle->isNamespaceIdKnown()
+ ) {
+ $this->assertEquals( $testTitle->getNamespaceId(),
+ $foreignTitle->getNamespaceId() );
+ }
+
+ $this->assertEquals( $testTitle->getNamespaceName(),
+ $foreignTitle->getNamespaceName() );
+ $this->assertEquals( $testTitle->getText(), $foreignTitle->getText() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/title/NamespaceImportTitleFactoryTest.php b/www/wiki/tests/phpunit/includes/title/NamespaceImportTitleFactoryTest.php
new file mode 100644
index 00000000..9b6ac93c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/title/NamespaceImportTitleFactoryTest.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * 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
+ * @author This, that and the other
+ */
+
+/**
+ * @covers NamespaceImportTitleFactory
+ *
+ * @group Title
+ */
+class NamespaceImportTitleFactoryTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgLanguageCode' => 'en',
+ 'wgContLang' => Language::factory( 'en' ),
+ ] );
+ }
+
+ public function basicProvider() {
+ return [
+ [
+ new ForeignTitle( 0, '', 'MainNamespaceArticle' ),
+ 0,
+ 'MainNamespaceArticle'
+ ],
+ [
+ new ForeignTitle( 0, '', 'MainNamespaceArticle' ),
+ 2,
+ 'User:MainNamespaceArticle'
+ ],
+ [
+ new ForeignTitle( 1, 'Discussion', 'Nice_talk' ),
+ 0,
+ 'Nice_talk'
+ ],
+ [
+ new ForeignTitle( 0, '', 'Bogus:Nice_talk' ),
+ 0,
+ 'Bogus:Nice_talk'
+ ],
+ [
+ new ForeignTitle( 0, '', 'Bogus:Nice_talk' ),
+ 2,
+ 'User:Bogus:Nice_talk'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider basicProvider
+ */
+ public function testBasic( ForeignTitle $foreignTitle, $ns, $titleText ) {
+ $factory = new NamespaceImportTitleFactory( $ns );
+ $testTitle = $factory->createTitleFromForeignTitle( $foreignTitle );
+ $title = Title::newFromText( $titleText );
+
+ $this->assertTrue( $title->equals( $testTitle ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/title/SubpageImportTitleFactoryTest.php b/www/wiki/tests/phpunit/includes/title/SubpageImportTitleFactoryTest.php
new file mode 100644
index 00000000..008cf5d9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/title/SubpageImportTitleFactoryTest.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * 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
+ * @author This, that and the other
+ */
+
+/**
+ * @covers SubpageImportTitleFactory
+ *
+ * @group Title
+ */
+class SubpageImportTitleFactoryTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgLanguageCode' => 'en',
+ 'wgContLang' => Language::factory( 'en' ),
+ 'wgNamespacesWithSubpages' => [ 0 => false, 2 => true ],
+ ] );
+ }
+
+ public function basicProvider() {
+ return [
+ [
+ new ForeignTitle( 0, '', 'MainNamespaceArticle' ),
+ Title::newFromText( 'User:Graham' ),
+ Title::newFromText( 'User:Graham/MainNamespaceArticle' )
+ ],
+ [
+ new ForeignTitle( 1, 'Discussion', 'Nice_talk' ),
+ Title::newFromText( 'User:Graham' ),
+ Title::newFromText( 'User:Graham/Discussion:Nice_talk' )
+ ],
+ [
+ new ForeignTitle( 0, '', 'Bogus:Nice_talk' ),
+ Title::newFromText( 'User:Graham' ),
+ Title::newFromText( 'User:Graham/Bogus:Nice_talk' )
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider basicProvider
+ */
+ public function testBasic( ForeignTitle $foreignTitle, Title $rootPage,
+ Title $title
+ ) {
+ $factory = new SubpageImportTitleFactory( $rootPage );
+ $testTitle = $factory->createTitleFromForeignTitle( $foreignTitle );
+
+ $this->assertTrue( $testTitle->equals( $title ) );
+ }
+
+ public function failureProvider() {
+ return [
+ [
+ Title::newFromText( 'Graham' ),
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider failureProvider
+ */
+ public function testFailures( Title $rootPage ) {
+ $this->setExpectedException( MWException::class );
+ new SubpageImportTitleFactory( $rootPage );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/title/TitleValueTest.php b/www/wiki/tests/phpunit/includes/title/TitleValueTest.php
new file mode 100644
index 00000000..d221b431
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/title/TitleValueTest.php
@@ -0,0 +1,148 @@
+<?php
+/**
+ * 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
+ * @author Daniel Kinzler
+ */
+
+/**
+ * @covers TitleValue
+ *
+ * @group Title
+ */
+class TitleValueTest extends MediaWikiTestCase {
+
+ public function goodConstructorProvider() {
+ return [
+ [ NS_USER, 'TestThis', 'stuff', '', true, false ],
+ [ NS_USER, 'TestThis', '', 'baz', false, true ],
+ ];
+ }
+
+ /**
+ * @dataProvider goodConstructorProvider
+ */
+ public function testConstruction( $ns, $text, $fragment, $interwiki, $hasFragment,
+ $hasInterwiki
+ ) {
+ $title = new TitleValue( $ns, $text, $fragment, $interwiki );
+
+ $this->assertEquals( $ns, $title->getNamespace() );
+ $this->assertTrue( $title->inNamespace( $ns ) );
+ $this->assertEquals( $text, $title->getText() );
+ $this->assertEquals( $fragment, $title->getFragment() );
+ $this->assertEquals( $hasFragment, $title->hasFragment() );
+ $this->assertEquals( $interwiki, $title->getInterwiki() );
+ $this->assertEquals( $hasInterwiki, $title->isExternal() );
+ }
+
+ public function badConstructorProvider() {
+ return [
+ [ 'foo', 'title', 'fragment', '' ],
+ [ null, 'title', 'fragment', '' ],
+ [ 2.3, 'title', 'fragment', '' ],
+
+ [ NS_MAIN, 5, 'fragment', '' ],
+ [ NS_MAIN, null, 'fragment', '' ],
+ [ NS_MAIN, '', 'fragment', '' ],
+ [ NS_MAIN, 'foo bar', '', '' ],
+ [ NS_MAIN, 'bar_', '', '' ],
+ [ NS_MAIN, '_foo', '', '' ],
+ [ NS_MAIN, ' eek ', '', '' ],
+
+ [ NS_MAIN, 'title', 5, '' ],
+ [ NS_MAIN, 'title', null, '' ],
+ [ NS_MAIN, 'title', [], '' ],
+
+ [ NS_MAIN, 'title', '', 5 ],
+ [ NS_MAIN, 'title', null, 5 ],
+ [ NS_MAIN, 'title', [], 5 ],
+ ];
+ }
+
+ /**
+ * @dataProvider badConstructorProvider
+ */
+ public function testConstructionErrors( $ns, $text, $fragment, $interwiki ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ new TitleValue( $ns, $text, $fragment, $interwiki );
+ }
+
+ public function fragmentTitleProvider() {
+ return [
+ [ new TitleValue( NS_MAIN, 'Test' ), 'foo' ],
+ [ new TitleValue( NS_TALK, 'Test', 'foo' ), '' ],
+ [ new TitleValue( NS_CATEGORY, 'Test', 'foo' ), 'bar' ],
+ ];
+ }
+
+ /**
+ * @dataProvider fragmentTitleProvider
+ */
+ public function testCreateFragmentTitle( TitleValue $title, $fragment ) {
+ $fragmentTitle = $title->createFragmentTarget( $fragment );
+
+ $this->assertEquals( $title->getNamespace(), $fragmentTitle->getNamespace() );
+ $this->assertEquals( $title->getText(), $fragmentTitle->getText() );
+ $this->assertEquals( $fragment, $fragmentTitle->getFragment() );
+ }
+
+ public function getTextProvider() {
+ return [
+ [ 'Foo', 'Foo' ],
+ [ 'Foo_Bar', 'Foo Bar' ],
+ ];
+ }
+
+ /**
+ * @dataProvider getTextProvider
+ */
+ public function testGetText( $dbkey, $text ) {
+ $title = new TitleValue( NS_MAIN, $dbkey );
+
+ $this->assertEquals( $text, $title->getText() );
+ }
+
+ public function provideTestToString() {
+ yield [
+ new TitleValue( 0, 'Foo' ),
+ '0:Foo'
+ ];
+ yield [
+ new TitleValue( 1, 'Bar_Baz' ),
+ '1:Bar_Baz'
+ ];
+ yield [
+ new TitleValue( 9, 'JoJo', 'Frag' ),
+ '9:JoJo#Frag'
+ ];
+ yield [
+ new TitleValue( 200, 'tea', 'Fragment', 'wikicode' ),
+ 'wikicode:200:tea#Fragment'
+ ];
+ }
+
+ /**
+ * @dataProvider provideTestToString
+ */
+ public function testToString( TitleValue $value, $expected ) {
+ $this->assertSame(
+ $expected,
+ $value->__toString()
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/upload/UploadBaseTest.php b/www/wiki/tests/phpunit/includes/upload/UploadBaseTest.php
new file mode 100644
index 00000000..a80262e9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/upload/UploadBaseTest.php
@@ -0,0 +1,614 @@
+<?php
+
+/**
+ * @group Upload
+ */
+class UploadBaseTest extends MediaWikiTestCase {
+
+ /** @var UploadTestHandler */
+ protected $upload;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->upload = new UploadTestHandler;
+
+ $this->setMwGlobals( 'wgHooks', [
+ 'InterwikiLoadPrefix' => [
+ function ( $prefix, &$data ) {
+ return false;
+ }
+ ],
+ ] );
+ }
+
+ /**
+ * First checks the return code
+ * of UploadBase::getTitle() and then the actual returned title
+ *
+ * @dataProvider provideTestTitleValidation
+ * @covers UploadBase::getTitle
+ */
+ public function testTitleValidation( $srcFilename, $dstFilename, $code, $msg ) {
+ /* Check the result code */
+ $this->assertEquals( $code,
+ $this->upload->testTitleValidation( $srcFilename ),
+ "$msg code" );
+
+ /* If we expect a valid title, check the title itself. */
+ if ( $code == UploadBase::OK ) {
+ $this->assertEquals( $dstFilename,
+ $this->upload->getTitle()->getText(),
+ "$msg text" );
+ }
+ }
+
+ /**
+ * Test various forms of valid and invalid titles that can be supplied.
+ */
+ public static function provideTestTitleValidation() {
+ return [
+ /* Test a valid title */
+ [ 'ValidTitle.jpg', 'ValidTitle.jpg', UploadBase::OK,
+ 'upload valid title' ],
+ /* A title with a slash */
+ [ 'A/B.jpg', 'A-B.jpg', UploadBase::OK,
+ 'upload title with slash' ],
+ /* A title with illegal char */
+ [ 'A:B.jpg', 'A-B.jpg', UploadBase::OK,
+ 'upload title with colon' ],
+ /* Stripping leading File: prefix */
+ [ 'File:C.jpg', 'C.jpg', UploadBase::OK,
+ 'upload title with File prefix' ],
+ /* Test illegal suggested title (r94601) */
+ [ '%281%29.JPG', null, UploadBase::ILLEGAL_FILENAME,
+ 'illegal title for upload' ],
+ /* A title without extension */
+ [ 'A', null, UploadBase::FILETYPE_MISSING,
+ 'upload title without extension' ],
+ /* A title with no basename */
+ [ '.jpg', null, UploadBase::MIN_LENGTH_PARTNAME,
+ 'upload title without basename' ],
+ /* A title that is longer than 255 bytes */
+ [ str_repeat( 'a', 255 ) . '.jpg', null, UploadBase::FILENAME_TOO_LONG,
+ 'upload title longer than 255 bytes' ],
+ /* A title that is longer than 240 bytes */
+ [ str_repeat( 'a', 240 ) . '.jpg', null, UploadBase::FILENAME_TOO_LONG,
+ 'upload title longer than 240 bytes' ],
+ ];
+ }
+
+ /**
+ * Test the upload verification functions
+ * @covers UploadBase::verifyUpload
+ */
+ public function testVerifyUpload() {
+ /* Setup with zero file size */
+ $this->upload->initializePathInfo( '', '', 0 );
+ $result = $this->upload->verifyUpload();
+ $this->assertEquals( UploadBase::EMPTY_FILE,
+ $result['status'],
+ 'upload empty file' );
+ }
+
+ // Helper used to create an empty file of size $size.
+ private function createFileOfSize( $size ) {
+ $filename = $this->getNewTempFile();
+
+ $fh = fopen( $filename, 'w' );
+ ftruncate( $fh, $size );
+ fclose( $fh );
+
+ return $filename;
+ }
+
+ /**
+ * @covers UploadBase::verifyUpload
+ *
+ * test uploading a 100 bytes file with $wgMaxUploadSize = 100
+ *
+ * This method should be abstracted so we can test different settings.
+ */
+ public function testMaxUploadSize() {
+ $this->setMwGlobals( [
+ 'wgMaxUploadSize' => 100,
+ 'wgFileExtensions' => [
+ 'txt',
+ ],
+ ] );
+
+ $filename = $this->createFileOfSize( 100 );
+ $this->upload->initializePathInfo( basename( $filename ) . '.txt', $filename, 100 );
+ $result = $this->upload->verifyUpload();
+
+ $this->assertEquals(
+ [ 'status' => UploadBase::OK ],
+ $result
+ );
+ }
+
+ /**
+ * @covers UploadBase::checkSvgScriptCallback
+ * @dataProvider provideCheckSvgScriptCallback
+ */
+ public function testCheckSvgScriptCallback( $svg, $wellFormed, $filterMatch, $message ) {
+ list( $formed, $match ) = $this->upload->checkSvgString( $svg );
+ $this->assertSame( $wellFormed, $formed, $message . " (well-formed)" );
+ $this->assertSame( $filterMatch, $match, $message . " (filter match)" );
+ }
+
+ public static function provideCheckSvgScriptCallback() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ // html5sec SVG vectors
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
+ true,
+ true,
+ 'Script tag in svg (http://html5sec.org/#47)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"><g onload="javascript:alert(1)"></g></svg>',
+ true,
+ true,
+ 'SVG with onload property (http://html5sec.org/#11)'
+ ],
+ [
+ '<svg onload="javascript:alert(1)" xmlns="http://www.w3.org/2000/svg"></svg>',
+ true,
+ true,
+ 'SVG with onload property (http://html5sec.org/#65)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"> <a xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="javascript:alert(1)"><rect width="1000" height="1000" fill="white"/></a> </svg>',
+ true,
+ true,
+ 'SVG with javascript xlink (http://html5sec.org/#87)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="data:application/xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4KPGRlZnM+CjxjaXJjbGUgaWQ9InRlc3QiIHI9IjUwIiBjeD0iMTAwIiBjeT0iMTAwIiBzdHlsZT0iZmlsbDogI0YwMCI+CjxzZXQgYXR0cmlidXRlTmFtZT0iZmlsbCIgYXR0cmlidXRlVHlwZT0iQ1NTIiBvbmJlZ2luPSdhbGVydChkb2N1bWVudC5jb29raWUpJwpvbmVuZD0nYWxlcnQoIm9uZW5kIiknIHRvPSIjMDBGIiBiZWdpbj0iMXMiIGR1cj0iNXMiIC8+CjwvY2lyY2xlPgo8L2RlZnM+Cjx1c2UgeGxpbms6aHJlZj0iI3Rlc3QiLz4KPC9zdmc+#test"/> </svg>',
+ true,
+ true,
+ 'SVG with Opera image xlink (http://html5sec.org/#88 - c)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <animation xlink:href="javascript:alert(1)"/> </svg>',
+ true,
+ true,
+ 'SVG with Opera animation xlink (http://html5sec.org/#88 - a)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <animation xlink:href="data:text/xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' onload=\'alert(1)\'%3E%3C/svg%3E"/> </svg>',
+ true,
+ true,
+ 'SVG with Opera animation xlink (http://html5sec.org/#88 - b)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <image xlink:href="data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' onload=\'alert(1)\'%3E%3C/svg%3E"/> </svg>',
+ true,
+ true,
+ 'SVG with Opera image xlink (http://html5sec.org/#88 - c)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <foreignObject xlink:href="javascript:alert(1)"/> </svg>',
+ true,
+ true,
+ 'SVG with Opera foreignObject xlink (http://html5sec.org/#88 - d)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <foreignObject xlink:href="data:text/xml,%3Cscript xmlns=\'http://www.w3.org/1999/xhtml\'%3Ealert(1)%3C/script%3E"/> </svg>',
+ true,
+ true,
+ 'SVG with Opera foreignObject xlink (http://html5sec.org/#88 - e)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"> <set attributeName="onmouseover" to="alert(1)"/> </svg>',
+ true,
+ true,
+ 'SVG with event handler set (http://html5sec.org/#89 - a)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"> <animate attributeName="onunload" to="alert(1)"/> </svg>',
+ true,
+ true,
+ 'SVG with event handler animate (http://html5sec.org/#89 - a)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"> <handler xmlns:ev="http://www.w3.org/2001/xml-events" ev:event="load">alert(1)</handler> </svg>',
+ true,
+ true,
+ 'SVG with element handler (http://html5sec.org/#94)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <feImage> <set attributeName="xlink:href" to="data:image/svg+xml;charset=utf-8;base64, PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxzY3JpcHQ%2BYWxlcnQoMSk8L3NjcmlwdD48L3N2Zz4NCg%3D%3D"/> </feImage> </svg>',
+ true,
+ true,
+ 'SVG with href to data: url (http://html5sec.org/#95)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" id="foo"> <x xmlns="http://www.w3.org/2001/xml-events" event="load" observer="foo" handler="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Chandler%20xml%3Aid%3D%22bar%22%20type%3D%22application%2Fecmascript%22%3E alert(1) %3C%2Fhandler%3E%0A%3C%2Fsvg%3E%0A#bar"/> </svg>',
+ true,
+ true,
+ 'SVG with Tiny handler (http://html5sec.org/#104)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"> <a id="x"><rect fill="white" width="1000" height="1000"/></a> <rect fill="white" style="clip-path:url(test3.svg#a);fill:url(#b);filter:url(#c);marker:url(#d);mask:url(#e);stroke:url(#f);"/> </svg>',
+ true,
+ true,
+ 'SVG with new CSS styles properties (http://html5sec.org/#109)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"> <a id="x"><rect fill="white" width="1000" height="1000"/></a> <rect clip-path="url(test3.svg#a)" /> </svg>',
+ true,
+ true,
+ 'SVG with new CSS styles properties as attributes'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"> <a id="x"> <rect fill="white" width="1000" height="1000"/> </a> <rect fill="url(http://html5sec.org/test3.svg#a)" /> </svg>',
+ true,
+ true,
+ 'SVG with new CSS styles properties as attributes (2)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"> <path d="M0,0" style="marker-start:url(test4.svg#a)"/> </svg>',
+ true,
+ true,
+ 'SVG with path marker-start (http://html5sec.org/#110)'
+ ],
+ [
+ '<?xml version="1.0"?> <?xml-stylesheet type="text/xml" href="#stylesheet"?> <!DOCTYPE doc [ <!ATTLIST xsl:stylesheet id ID #REQUIRED>]> <svg xmlns="http://www.w3.org/2000/svg"> <xsl:stylesheet id="stylesheet" version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:template match="/"> <iframe xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(1)"></iframe> </xsl:template> </xsl:stylesheet> <circle fill="red" r="40"></circle> </svg>',
+ false,
+ true,
+ 'SVG with embedded stylesheet (http://html5sec.org/#125)'
+ ],
+ [
+ '<?xml version="1.0"?> <?xml-stylesheet type="text/xml" href="#stylesheet"?> <svg xmlns="http://www.w3.org/2000/svg"> <xsl:stylesheet id="stylesheet" version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:template match="/"> <iframe xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(1)"></iframe> </xsl:template> </xsl:stylesheet> <circle fill="red" r="40"></circle> </svg>',
+ true,
+ true,
+ 'SVG with embedded stylesheet no doctype'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" id="x"> <listener event="load" handler="#y" xmlns="http://www.w3.org/2001/xml-events" observer="x"/> <handler id="y">alert(1)</handler> </svg>',
+ true,
+ true,
+ 'SVG with handler attribute (http://html5sec.org/#127)'
+ ],
+ [
+ // Haven't found a browser that accepts this particular example, but we
+ // don't want to allow embeded svgs, ever
+ '<svg> <image style=\'filter:url("data:image/svg+xml;charset=utf-8;base64, PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxzY3JpcHQ/YWxlcnQoMSk8L3NjcmlwdD48L3N2Zz4NCg==")\' /> </svg>',
+ true,
+ true,
+ 'SVG with image filter via style (http://html5sec.org/#129)'
+ ],
+ [
+ // This doesn't seem possible without embedding the svg, but just in case
+ '<svg> <a xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="?"> <circle r="400"></circle> <animate attributeName="xlink:href" begin="0" from="javascript:alert(1)" to="" /> </a></svg>',
+ true,
+ true,
+ 'SVG with animate from (http://html5sec.org/#137)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <a><text y="1em">Click me</text> <animate attributeName="xlink:href" values="javascript:alert(\'Bang!\')" begin="0s" dur="0.1s" fill="freeze" /> </a></svg>',
+ true,
+ true,
+ 'SVG with animate xlink:href (http://html5sec.org/#137)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:y="http://www.w3.org/1999/xlink"> <a y:href="#"> <text y="1em">Click me</text> <animate attributeName="y:href" values="javascript:alert(\'Bang!\')" begin="0s" dur="0.1s" fill="freeze" /> </a> </svg>',
+ true,
+ true,
+ 'SVG with animate y:href (http://html5sec.org/#137)'
+ ],
+
+ // Other hostile SVG's
+ [
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg xmlns:xlink="http://www.w3.org/1999/xlink"> <image xlink:href="https://upload.wikimedia.org/wikipedia/commons/3/34/Bahnstrecke_Zeitz-Camburg_1930.png" /> </svg>',
+ true,
+ true,
+ 'SVG with non-local image href (T67839)'
+ ],
+ [
+ '<?xml version="1.0" ?> <?xml-stylesheet type="text/xsl" href="/w/index.php?title=User:Jeeves/test.xsl&amp;action=raw&amp;format=xml" ?> <svg> <height>50</height> <width>100</width> </svg>',
+ true,
+ true,
+ 'SVG with remote stylesheet (T59550)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" viewbox="-1 -1 15 15"> <rect y="0" height="13" width="12" stroke="#179" rx="1" fill="#2ac"/> <text x="1.5" y="11" font-family="courier" stroke="white" font-size="16"><![CDATA[B]]></text> <iframe xmlns="http://www.w3.org/1999/xhtml" srcdoc="&#x3C;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3E;&#x61;&#x6C;&#x65;&#x72;&#x74;&#x28;&#x27;&#x58;&#x53;&#x53;&#x45;&#x44;&#x20;&#x3D;&#x3E;&#x20;&#x44;&#x6F;&#x6D;&#x61;&#x69;&#x6E;&#x28;&#x27;&#x2B;&#x74;&#x6F;&#x70;&#x2E;&#x64;&#x6F;&#x63;&#x75;&#x6D;&#x65;&#x6E;&#x74;&#x2E;&#x64;&#x6F;&#x6D;&#x61;&#x69;&#x6E;&#x2B;&#x27;&#x29;&#x27;&#x29;&#x3B;&#x3C;&#x2F;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3E;"></iframe> </svg>',
+ true,
+ true,
+ 'SVG with rembeded iframe (T62771)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 3 177 153" xmlns:xlink="http://www.w3.org/1999/xlink"> <style>@import url("https://fonts.googleapis.com/css?family=Bitter:700&amp;text=WebPlatform.org");</style> <g transform="translate(-.5,-.5)"> <text fill="#474747" x="95" y="150" text-anchor="middle" font-family="Bitter" font-size="20" font-weight="bold">WebPlatform.org</text> </g> </svg>',
+ true,
+ true,
+ 'SVG with @import in style element (T71008)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 3 177 153" xmlns:xlink="http://www.w3.org/1999/xlink"> <style>@import url("https://fonts.googleapis.com/css?family=Bitter:700&amp;text=WebPlatform.org");<foo/></style> <g transform="translate(-.5,-.5)"> <text fill="#474747" x="95" y="150" text-anchor="middle" font-family="Bitter" font-size="20" font-weight="bold">WebPlatform.org</text> </g> </svg>',
+ true,
+ true,
+ 'SVG with @import in style element and child element (T71008#c11)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 3 177 153" xmlns:xlink="http://www.w3.org/1999/xlink"> <style>@imporT "https://fonts.googleapis.com/css?family=Bitter:700&amp;text=WebPlatform.org";</style> <g transform="translate(-.5,-.5)"> <text fill="#474747" x="95" y="150" text-anchor="middle" font-family="Bitter" font-size="20" font-weight="bold">WebPlatform.org</text> </g> </svg>',
+ true,
+ true,
+ 'SVG with case-insensitive @import in style element (bug T85349)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:url(https://www.google.com/images/srpr/logo11w.png)"/> </svg>',
+ true,
+ true,
+ 'SVG with remote background image (T71008)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:\55rl(https://www.google.com/images/srpr/logo11w.png)"/> </svg>',
+ true,
+ true,
+ 'SVG with remote background image, encoded (T71008)'
+ ],
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg"> <style> #a { background-image:\55rl(\'https://www.google.com/images/srpr/logo11w.png\'); } </style> <rect width="100" height="100" id="a"/> </svg>',
+ true,
+ true,
+ 'SVG with remote background image, in style element (T71008)'
+ ],
+ [
+ // This currently doesn't seem to work in any browsers, but in case
+ // https://www.w3.org/TR/css3-images/ is implemented for SVG files
+ '<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:image(\'sprites.svg#xywh=40,0,20,20\')"/> </svg>',
+ true,
+ true,
+ 'SVG with remote background image using image() (T71008)'
+ ],
+ [
+ // As reported by Cure53
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <a xlink:href="data:text/html;charset=utf-8;base64, PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pPC9zY3JpcHQ%2BDQo%3D"> <circle r="400" fill="red"></circle> </a> </svg>',
+ true,
+ true,
+ 'SVG with data:text/html link target (firefox only)'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY lol "lol"> <!ENTITY lol2 "&#x3C;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3E;&#x61;&#x6C;&#x65;&#x72;&#x74;&#x28;&#x27;&#x58;&#x53;&#x53;&#x45;&#x44;&#x20;&#x3D;&#x3E;&#x20;&#x27;&#x2B;&#x64;&#x6F;&#x63;&#x75;&#x6D;&#x65;&#x6E;&#x74;&#x2E;&#x64;&#x6F;&#x6D;&#x61;&#x69;&#x6E;&#x29;&#x3B;&#x3C;&#x2F;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3E;"> ]> <svg xmlns="http://www.w3.org/2000/svg" width="68" height="68" viewBox="-34 -34 68 68" version="1.1"> <circle cx="0" cy="0" r="24" fill="#c8c8c8"/> <text x="0" y="0" fill="black">&lol2;</text> </svg>',
+ false,
+ true,
+ 'SVG with encoded script tag in internal entity (reported by Beyond Security)'
+ ],
+ [
+ '<?xml version="1.0"?> <!DOCTYPE svg [ <!ENTITY foo SYSTEM "file:///etc/passwd"> ]> <svg xmlns="http://www.w3.org/2000/svg" version="1.1"> <desc>&foo;</desc> <rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:1;stroke:rgb(0,0,2)" /> </svg>',
+ false,
+ false,
+ 'SVG with external entity'
+ ],
+ [
+ // The base64 = <script>alert(1)</script>. If for some reason
+ // entities actually do get loaded, this should trigger
+ // filterMatch to be true. So this test verifies that we
+ // are not loading external entities.
+ '<?xml version="1.0"?> <!DOCTYPE svg [ <!ENTITY foo SYSTEM "data:text/plain;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pgo="> ]> <svg xmlns="http://www.w3.org/2000/svg" version="1.1"> <desc>&foo;</desc> <rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:1;stroke:rgb(0,0,2)" /> </svg>',
+ false,
+ false, /* False verifies entities aren't getting loaded */
+ 'SVG with data: uri external entity'
+ ],
+ [
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"> <g> <a xlink:href=\"javascript:alert('1&#10;https://google.com')\"> <rect width=\"300\" height=\"100\" style=\"fill:rgb(0,0,255);stroke-width:1;stroke:rgb(0,0,2)\" /> </a> </g> </svg>",
+ true,
+ true,
+ 'SVG with javascript <a> link with newline (T122653)'
+ ],
+ // Test good, but strange files that we want to allow
+ [
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <g> <a xlink:href="http://en.wikipedia.org/wiki/Main_Page"> <path transform="translate(0,496)" id="path6706" d="m 112.09375,107.6875 -5.0625,3.625 -4.3125,5.03125 -0.46875,0.5 -4.09375,3.34375 -9.125,5.28125 -8.625,-3.375 z" style="fill:#cccccc;fill-opacity:1;stroke:#6e6e6e;stroke-width:0.69999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;display:inline" /> </a> </g> </svg>',
+ true,
+ false,
+ 'SVG with <a> link to a remote site'
+ ],
+ [
+ '<svg> <defs> <filter id="filter6226" x="-0.93243687" width="2.8648737" y="-0.24250539" height="1.4850108"> <feGaussianBlur stdDeviation="3.2344681" id="feGaussianBlur6228" /> </filter> <clipPath id="clipPath2436"> <path d="M 0,0 L 0,0 L 0,0 L 0,0 z" id="path2438" /> </clipPath> </defs> <g clip-path="url(#clipPath2436)" id="g2460"> <text id="text2466"> <tspan>12345</tspan> </text> </g> <path style="fill:#346733;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:1, 1;stroke-dashoffset:0;filter:url(\'#filter6226\');fill-opacity:1;opacity:0.79807692" d="M 236.82371,332.63732 C 236.92217,332.63732 z" id="path5618" /> </svg>',
+ true,
+ false,
+ 'SVG with local urls, including filter: in style'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE x [<!ATTLIST image x:href CDATA "data:image/png,foo" onerror CDATA "alert(\'XSSED = \'+document.domain)" onload CDATA "alert(\'XSSED = \'+document.domain)"> ]> <svg xmlns:h="http://www.w3.org/1999/xhtml" xmlns:x="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"> <image /> </svg>',
+ false,
+ false,
+ 'SVG with evil default attribute values'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg SYSTEM "data:application/xml-dtd;base64,PCFET0NUWVBFIHN2ZyBbPCFBVFRMSVNUIGltYWdlIHg6aHJlZiBDREFUQSAiZGF0YTppbWFnZS9wbmcsZm9vIiBvbmVycm9yIENEQVRBICJhbGVydCgnWFNTRUQgPSAnK2RvY3VtZW50LmRvbWFpbikiIG9ubG9hZCBDREFUQSAiYWxlcnQoJ1hTU0VEID0gJytkb2N1bWVudC5kb21haW4pIj4gXT4K"><svg xmlns:x="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"> <image /> </svg>',
+ true,
+ true,
+ 'SVG with an evil external dtd'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//FOO/bar" "http://example.com"><svg></svg>',
+ true,
+ true,
+ 'SVG with random public doctype'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg SYSTEM \'http://example.com/evil.dtd\' ><svg></svg>',
+ true,
+ true,
+ 'SVG with random SYSTEM doctype'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY % foo "bar" >] ><svg></svg>',
+ false,
+ false,
+ 'SVG with parameter entity'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY foo "bar%a;" ] ><svg></svg>',
+ false,
+ false,
+ 'SVG with entity referencing parameter entity'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY foo "bar0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"> ] ><svg></svg>',
+ false,
+ false,
+ 'SVG with long entity'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY foo \'"Hi", said bob\'> ] ><svg><g>&foo;</g></svg>',
+ true,
+ false,
+ 'SVG with apostrophe quote entity'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY name "Bob"><!ENTITY foo \'"Hi", said &name;.\'> ] ><svg><g>&foo;</g></svg>',
+ false,
+ false,
+ 'SVG with recursive entity',
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd" [ <!ATTLIST svg xmlns:xlink CDATA #FIXED "http://www.w3.org/1999/xlink"> ]> <svg width="417pt" height="366pt"
+ viewBox="0.00 0.00 417.00 366.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg>',
+ true, /* well-formed */
+ false, /* filter-hit */
+ 'GraphViz-esque svg with #FIXED xlink ns (Should be allowed)'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd" [ <!ATTLIST svg xmlns:xlink CDATA #FIXED "http://www.w3.org/1999/xlink2"> ]> <svg width="417pt" height="366pt"
+ viewBox="0.00 0.00 417.00 366.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg>',
+ false,
+ false,
+ 'GraphViz ATLIST exception should match exactly'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!-- Comment-here --> <!ENTITY foo "#ff6666">]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
+ true,
+ false,
+ 'DTD with comments (Should be allowed)'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!-- invalid--comment --> <!ENTITY foo "#ff6666">]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
+ false,
+ false,
+ 'DTD with invalid comment'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!-- invalid ---> <!ENTITY foo "#ff6666">]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
+ false,
+ false,
+ 'DTD with invalid comment 2'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY bar "&foo;"> <!ENTITY foo "#ff6666">]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
+ true,
+ false,
+ 'DTD with aliased entities (Should be allowed)'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY bar \'&foo;\'> <!ENTITY foo \'#ff6666\'>]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
+ true,
+ false,
+ 'DTD with aliased entities apos (Should be allowed)'
+ ]
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @covers UploadBase::detectScriptInSvg
+ * @dataProvider provideDetectScriptInSvg
+ */
+ public function testDetectScriptInSvg( $svg, $expected, $message ) {
+ // This only checks some weird cases, most tests are in testCheckSvgScriptCallback() above
+ $result = $this->upload->detectScriptInSvg( $svg, false );
+ $this->assertSame( $expected, $result, $message );
+ }
+
+ public static function provideDetectScriptInSvg() {
+ global $IP;
+ return [
+ [
+ "$IP/tests/phpunit/data/upload/buggynamespace-original.svg",
+ false,
+ 'SVG with a weird but valid namespace definition created by Adobe Illustrator'
+ ],
+ [
+ "$IP/tests/phpunit/data/upload/buggynamespace-okay.svg",
+ false,
+ 'SVG with a namespace definition created by Adobe Illustrator and mangled by Inkscape'
+ ],
+ [
+ "$IP/tests/phpunit/data/upload/buggynamespace-okay2.svg",
+ false,
+ 'SVG with a namespace definition created by Adobe Illustrator and mangled by Inkscape (twice)'
+ ],
+ [
+ "$IP/tests/phpunit/data/upload/buggynamespace-bad.svg",
+ [ 'uploadscriptednamespace', 'i' ],
+ 'SVG with a namespace definition using an undefined entity'
+ ],
+ [
+ "$IP/tests/phpunit/data/upload/buggynamespace-evilhtml.svg",
+ [ 'uploadscriptednamespace', 'http://www.w3.org/1999/xhtml' ],
+ 'SVG with an html namespace encoded as an entity'
+ ],
+ ];
+ }
+
+ /**
+ * @covers UploadBase::checkXMLEncodingMissmatch
+ * @dataProvider provideCheckXMLEncodingMissmatch
+ */
+ public function testCheckXMLEncodingMissmatch( $fileContents, $evil ) {
+ $filename = $this->getNewTempFile();
+ file_put_contents( $filename, $fileContents );
+ $this->assertSame( $evil, UploadBase::checkXMLEncodingMissmatch( $filename ) );
+ }
+
+ public function provideCheckXMLEncodingMissmatch() {
+ return [
+ [ '<?xml version="1.0" encoding="utf-7"?><svg></svg>', true ],
+ [ '<?xml version="1.0" encoding="utf-8"?><svg></svg>', false ],
+ [ '<?xml version="1.0" encoding="WINDOWS-1252"?><svg></svg>', false ],
+ ];
+ }
+}
+
+class UploadTestHandler extends UploadBase {
+ public function initializeFromRequest( &$request ) {
+ }
+
+ public function testTitleValidation( $name ) {
+ $this->mTitle = false;
+ $this->mDesiredDestName = $name;
+ $this->mTitleError = UploadBase::OK;
+ $this->getTitle();
+
+ return $this->mTitleError;
+ }
+
+ /**
+ * Almost the same as UploadBase::detectScriptInSvg, except it's
+ * public, works on an xml string instead of filename, and returns
+ * the result instead of interpreting them.
+ */
+ public function checkSvgString( $svg ) {
+ $check = new XmlTypeCheck(
+ $svg,
+ [ $this, 'checkSvgScriptCallback' ],
+ false,
+ [
+ 'processing_instruction_handler' => 'UploadBase::checkSvgPICallback',
+ 'external_dtd_handler' => 'UploadBase::checkSvgExternalDTD'
+ ]
+ );
+ return [ $check->wellFormed, $check->filterMatch ];
+ }
+
+ /**
+ * Same as parent function, but override visibility to 'public'.
+ */
+ public function detectScriptInSvg( $filename, $partial ) {
+ return parent::detectScriptInSvg( $filename, $partial );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/upload/UploadFromUrlTest.php b/www/wiki/tests/phpunit/includes/upload/UploadFromUrlTest.php
new file mode 100644
index 00000000..a69a137b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/upload/UploadFromUrlTest.php
@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * @group Broken
+ * @group Upload
+ * @group Database
+ *
+ * @covers UploadFromUrl
+ */
+class UploadFromUrlTest extends ApiTestCase {
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgEnableUploads' => true,
+ 'wgAllowCopyUploads' => true,
+ ] );
+
+ if ( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ) {
+ $this->deleteFile( 'UploadFromUrlTest.png' );
+ }
+ }
+
+ protected function doApiRequest( array $params, array $unused = null,
+ $appendModule = false, User $user = null, $tokenType = null
+ ) {
+ global $wgRequest;
+
+ $req = new FauxRequest( $params, true, $wgRequest->getSession() );
+ $module = new ApiMain( $req, true );
+ $module->execute();
+
+ return [
+ $module->getResult()->getResultData( null, [ 'Strip' => 'all' ] ),
+ $req
+ ];
+ }
+
+ /**
+ * Ensure that the job queue is empty before continuing
+ */
+ public function testClearQueue() {
+ $job = JobQueueGroup::singleton()->pop();
+ while ( $job ) {
+ $job = JobQueueGroup::singleton()->pop();
+ }
+ $this->assertFalse( $job );
+ }
+
+ /**
+ * @depends testClearQueue
+ */
+ public function testSetupUrlDownload( $data ) {
+ $token = $this->user->getEditToken();
+ $exception = false;
+
+ try {
+ $this->doApiRequest( [
+ 'action' => 'upload',
+ ] );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ $this->assertEquals( "The token parameter must be set", $e->getMessage() );
+ }
+ $this->assertTrue( $exception, "Got exception" );
+
+ $exception = false;
+ try {
+ $this->doApiRequest( [
+ 'action' => 'upload',
+ 'token' => $token,
+ ], $data );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ $this->assertEquals( "One of the parameters sessionkey, file, url is required",
+ $e->getMessage() );
+ }
+ $this->assertTrue( $exception, "Got exception" );
+
+ $exception = false;
+ try {
+ $this->doApiRequest( [
+ 'action' => 'upload',
+ 'url' => 'http://www.example.com/test.png',
+ 'token' => $token,
+ ], $data );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ $this->assertEquals( "The filename parameter must be set", $e->getMessage() );
+ }
+ $this->assertTrue( $exception, "Got exception" );
+
+ $this->user->removeGroup( 'sysop' );
+ $exception = false;
+ try {
+ $this->doApiRequest( [
+ 'action' => 'upload',
+ 'url' => 'http://www.example.com/test.png',
+ 'filename' => 'UploadFromUrlTest.png',
+ 'token' => $token,
+ ], $data );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ $this->assertEquals( "Permission denied", $e->getMessage() );
+ }
+ $this->assertTrue( $exception, "Got exception" );
+ }
+
+ /**
+ * @depends testClearQueue
+ */
+ public function testSyncDownload( $data ) {
+ $token = $this->user->getEditToken();
+
+ $job = JobQueueGroup::singleton()->pop();
+ $this->assertFalse( $job, 'Starting with an empty jobqueue' );
+
+ $this->user->addGroup( 'users' );
+ $data = $this->doApiRequest( [
+ 'action' => 'upload',
+ 'filename' => 'UploadFromUrlTest.png',
+ 'url' => 'http://upload.wikimedia.org/wikipedia/mediawiki/b/bc/Wiki.png',
+ 'ignorewarnings' => true,
+ 'token' => $token,
+ ], $data );
+
+ $job = JobQueueGroup::singleton()->pop();
+ $this->assertFalse( $job );
+
+ $this->assertEquals( 'Success', $data[0]['upload']['result'] );
+ $this->deleteFile( 'UploadFromUrlTest.png' );
+
+ return $data;
+ }
+
+ protected function deleteFile( $name ) {
+ $t = Title::newFromText( $name, NS_FILE );
+ $this->assertTrue( $t->exists(), "File '$name' exists" );
+
+ if ( $t->exists() ) {
+ $file = wfFindFile( $name, [ 'ignoreRedirect' => true ] );
+ $empty = "";
+ FileDeleteForm::doDelete( $t, $file, $empty, "none", true );
+ $page = WikiPage::factory( $t );
+ $page->doDeleteArticle( "testing" );
+ }
+ $t = Title::newFromText( $name, NS_FILE );
+
+ $this->assertFalse( $t->exists(), "File '$name' was deleted" );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/upload/UploadStashTest.php b/www/wiki/tests/phpunit/includes/upload/UploadStashTest.php
new file mode 100644
index 00000000..39acbb07
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/upload/UploadStashTest.php
@@ -0,0 +1,113 @@
+<?php
+
+/**
+ * @group Database
+ *
+ * @covers UploadStash
+ */
+class UploadStashTest extends MediaWikiTestCase {
+ /**
+ * @var TestUser[] Array of UploadStashTestUser
+ */
+ public static $users;
+
+ /**
+ * @var string
+ */
+ private $tmpFile;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->tmpFile = $this->getNewTempFile();
+ file_put_contents( $this->tmpFile, "\x00" );
+
+ self::$users = [
+ 'sysop' => new TestUser(
+ 'Uploadstashtestsysop',
+ 'Upload Stash Test Sysop',
+ 'upload_stash_test_sysop@example.com',
+ [ 'sysop' ]
+ ),
+ 'uploader' => new TestUser(
+ 'Uploadstashtestuser',
+ 'Upload Stash Test User',
+ 'upload_stash_test_user@example.com',
+ []
+ )
+ ];
+ }
+
+ /**
+ * @todo give this test a real name explaining what is being tested here
+ */
+ public function testBug29408() {
+ $this->setMwGlobals( 'wgUser', self::$users['uploader']->getUser() );
+
+ $repo = RepoGroup::singleton()->getLocalRepo();
+ $stash = new UploadStash( $repo );
+
+ // Throws exception caught by PHPUnit on failure
+ $file = $stash->stashFile( $this->tmpFile );
+ // We'll never reach this point if we hit T31408
+ $this->assertTrue( true, 'Unrecognized file without extension' );
+
+ $stash->removeFile( $file->getFileKey() );
+ }
+
+ public static function provideInvalidRequests() {
+ return [
+ 'Check failure on bad wpFileKey' =>
+ [ new FauxRequest( [ 'wpFileKey' => 'foo' ] ) ],
+ 'Check failure on bad wpSessionKey' =>
+ [ new FauxRequest( [ 'wpSessionKey' => 'foo' ] ) ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideInvalidRequests
+ */
+ public function testValidRequestWithInvalidRequests( $request ) {
+ $this->assertFalse( UploadFromStash::isValidRequest( $request ) );
+ }
+
+ public static function provideValidRequests() {
+ return [
+ 'Check good wpFileKey' =>
+ [ new FauxRequest( [ 'wpFileKey' => 'testkey-test.test' ] ) ],
+ 'Check good wpSessionKey' =>
+ [ new FauxRequest( [ 'wpFileKey' => 'testkey-test.test' ] ) ],
+ 'Check key precedence' =>
+ [ new FauxRequest( [
+ 'wpFileKey' => 'testkey-test.test',
+ 'wpSessionKey' => 'foo'
+ ] ) ],
+ ];
+ }
+ /**
+ * @dataProvider provideValidRequests
+ */
+ public function testValidRequestWithValidRequests( $request ) {
+ $this->assertTrue( UploadFromStash::isValidRequest( $request ) );
+ }
+
+ public function testExceptionWhenStoreTempFails() {
+ $mockRepoStoreStatusResult = Status::newFatal( 'TEST_ERROR' );
+ $mockRepo = $this->getMockBuilder( FileRepo::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mockRepo->expects( $this->once() )
+ ->method( 'storeTemp' )
+ ->willReturn( $mockRepoStoreStatusResult );
+
+ $stash = new UploadStash( $mockRepo );
+ try {
+ $stash->stashFile( $this->tmpFile );
+ $this->fail( 'Expected UploadStashFileException not thrown' );
+ } catch ( UploadStashFileException $e ) {
+ $this->assertInstanceOf( ILocalizedException::class, $e );
+ } catch ( Exception $e ) {
+ $this->fail( 'Unexpected exception class ' . get_class( $e ) );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/user/BotPasswordTest.php b/www/wiki/tests/phpunit/includes/user/BotPasswordTest.php
new file mode 100644
index 00000000..3bbc2dfa
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/user/BotPasswordTest.php
@@ -0,0 +1,420 @@
+<?php
+
+use MediaWiki\Session\SessionManager;
+use Wikimedia\ScopedCallback;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers BotPassword
+ * @group Database
+ */
+class BotPasswordTest extends MediaWikiTestCase {
+
+ /** @var TestUser */
+ private $testUser;
+
+ /** @var string */
+ private $testUserName;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgEnableBotPasswords' => true,
+ 'wgBotPasswordsDatabase' => false,
+ 'wgCentralIdLookupProvider' => 'BotPasswordTest OkMock',
+ 'wgGrantPermissions' => [
+ 'test' => [ 'read' => true ],
+ ],
+ 'wgUserrightsInterwikiDelimiter' => '@',
+ ] );
+
+ $this->testUser = $this->getMutableTestUser();
+ $this->testUserName = $this->testUser->getUser()->getName();
+
+ $mock1 = $this->getMockForAbstractClass( CentralIdLookup::class );
+ $mock1->expects( $this->any() )->method( 'isAttached' )
+ ->will( $this->returnValue( true ) );
+ $mock1->expects( $this->any() )->method( 'lookupUserNames' )
+ ->will( $this->returnValue( [ $this->testUserName => 42, 'UTDummy' => 43, 'UTInvalid' => 0 ] ) );
+ $mock1->expects( $this->never() )->method( 'lookupCentralIds' );
+
+ $mock2 = $this->getMockForAbstractClass( CentralIdLookup::class );
+ $mock2->expects( $this->any() )->method( 'isAttached' )
+ ->will( $this->returnValue( false ) );
+ $mock2->expects( $this->any() )->method( 'lookupUserNames' )
+ ->will( $this->returnArgument( 0 ) );
+ $mock2->expects( $this->never() )->method( 'lookupCentralIds' );
+
+ $this->mergeMwGlobalArrayValue( 'wgCentralIdLookupProviders', [
+ 'BotPasswordTest OkMock' => [ 'factory' => function () use ( $mock1 ) {
+ return $mock1;
+ } ],
+ 'BotPasswordTest FailMock' => [ 'factory' => function () use ( $mock2 ) {
+ return $mock2;
+ } ],
+ ] );
+
+ CentralIdLookup::resetCache();
+ }
+
+ public function addDBData() {
+ $passwordFactory = new \PasswordFactory();
+ $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+ $passwordHash = $passwordFactory->newFromPlaintext( 'foobaz' );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete(
+ 'bot_passwords',
+ [ 'bp_user' => [ 42, 43 ], 'bp_app_id' => 'BotPassword' ],
+ __METHOD__
+ );
+ $dbw->insert(
+ 'bot_passwords',
+ [
+ [
+ 'bp_user' => 42,
+ 'bp_app_id' => 'BotPassword',
+ 'bp_password' => $passwordHash->toString(),
+ 'bp_token' => 'token!',
+ 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
+ 'bp_grants' => '["test"]',
+ ],
+ [
+ 'bp_user' => 43,
+ 'bp_app_id' => 'BotPassword',
+ 'bp_password' => $passwordHash->toString(),
+ 'bp_token' => 'token!',
+ 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
+ 'bp_grants' => '["test"]',
+ ],
+ ],
+ __METHOD__
+ );
+ }
+
+ public function testBasics() {
+ $user = $this->testUser->getUser();
+ $bp = BotPassword::newFromUser( $user, 'BotPassword' );
+ $this->assertInstanceOf( BotPassword::class, $bp );
+ $this->assertTrue( $bp->isSaved() );
+ $this->assertSame( 42, $bp->getUserCentralId() );
+ $this->assertSame( 'BotPassword', $bp->getAppId() );
+ $this->assertSame( 'token!', trim( $bp->getToken(), " \0" ) );
+ $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
+ $this->assertSame( [ 'test' ], $bp->getGrants() );
+
+ $this->assertNull( BotPassword::newFromUser( $user, 'DoesNotExist' ) );
+
+ $this->setMwGlobals( [
+ 'wgCentralIdLookupProvider' => 'BotPasswordTest FailMock'
+ ] );
+ $this->assertNull( BotPassword::newFromUser( $user, 'BotPassword' ) );
+
+ $this->assertSame( '@', BotPassword::getSeparator() );
+ $this->setMwGlobals( [
+ 'wgUserrightsInterwikiDelimiter' => '#',
+ ] );
+ $this->assertSame( '#', BotPassword::getSeparator() );
+ }
+
+ public function testUnsaved() {
+ $user = $this->testUser->getUser();
+ $bp = BotPassword::newUnsaved( [
+ 'user' => $user,
+ 'appId' => 'DoesNotExist'
+ ] );
+ $this->assertInstanceOf( BotPassword::class, $bp );
+ $this->assertFalse( $bp->isSaved() );
+ $this->assertSame( 42, $bp->getUserCentralId() );
+ $this->assertSame( 'DoesNotExist', $bp->getAppId() );
+ $this->assertEquals( MWRestrictions::newDefault(), $bp->getRestrictions() );
+ $this->assertSame( [], $bp->getGrants() );
+
+ $bp = BotPassword::newUnsaved( [
+ 'username' => 'UTDummy',
+ 'appId' => 'DoesNotExist2',
+ 'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
+ 'grants' => [ 'test' ],
+ ] );
+ $this->assertInstanceOf( BotPassword::class, $bp );
+ $this->assertFalse( $bp->isSaved() );
+ $this->assertSame( 43, $bp->getUserCentralId() );
+ $this->assertSame( 'DoesNotExist2', $bp->getAppId() );
+ $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
+ $this->assertSame( [ 'test' ], $bp->getGrants() );
+
+ $user = $this->testUser->getUser();
+ $bp = BotPassword::newUnsaved( [
+ 'centralId' => 45,
+ 'appId' => 'DoesNotExist'
+ ] );
+ $this->assertInstanceOf( BotPassword::class, $bp );
+ $this->assertFalse( $bp->isSaved() );
+ $this->assertSame( 45, $bp->getUserCentralId() );
+ $this->assertSame( 'DoesNotExist', $bp->getAppId() );
+
+ $user = $this->testUser->getUser();
+ $bp = BotPassword::newUnsaved( [
+ 'user' => $user,
+ 'appId' => 'BotPassword'
+ ] );
+ $this->assertInstanceOf( BotPassword::class, $bp );
+ $this->assertFalse( $bp->isSaved() );
+
+ $this->assertNull( BotPassword::newUnsaved( [
+ 'user' => $user,
+ 'appId' => '',
+ ] ) );
+ $this->assertNull( BotPassword::newUnsaved( [
+ 'user' => $user,
+ 'appId' => str_repeat( 'X', BotPassword::APPID_MAXLENGTH + 1 ),
+ ] ) );
+ $this->assertNull( BotPassword::newUnsaved( [
+ 'user' => $this->testUserName,
+ 'appId' => 'Ok',
+ ] ) );
+ $this->assertNull( BotPassword::newUnsaved( [
+ 'username' => 'UTInvalid',
+ 'appId' => 'Ok',
+ ] ) );
+ $this->assertNull( BotPassword::newUnsaved( [
+ 'appId' => 'Ok',
+ ] ) );
+ }
+
+ public function testGetPassword() {
+ $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+
+ $password = $bp->getPassword();
+ $this->assertInstanceOf( Password::class, $password );
+ $this->assertTrue( $password->equals( 'foobaz' ) );
+
+ $bp->centralId = 44;
+ $password = $bp->getPassword();
+ $this->assertInstanceOf( InvalidPassword::class, $password );
+
+ $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update(
+ 'bot_passwords',
+ [ 'bp_password' => 'garbage' ],
+ [ 'bp_user' => 42, 'bp_app_id' => 'BotPassword' ],
+ __METHOD__
+ );
+ $password = $bp->getPassword();
+ $this->assertInstanceOf( InvalidPassword::class, $password );
+ }
+
+ public function testInvalidateAllPasswordsForUser() {
+ $bp1 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+ $bp2 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 43, 'BotPassword' ) );
+
+ $this->assertNotInstanceOf( InvalidPassword::class, $bp1->getPassword(), 'sanity check' );
+ $this->assertNotInstanceOf( InvalidPassword::class, $bp2->getPassword(), 'sanity check' );
+ BotPassword::invalidateAllPasswordsForUser( $this->testUserName );
+ $this->assertInstanceOf( InvalidPassword::class, $bp1->getPassword() );
+ $this->assertNotInstanceOf( InvalidPassword::class, $bp2->getPassword() );
+
+ $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+ $this->assertInstanceOf( InvalidPassword::class, $bp->getPassword() );
+ }
+
+ public function testRemoveAllPasswordsForUser() {
+ $this->assertNotNull( BotPassword::newFromCentralId( 42, 'BotPassword' ), 'sanity check' );
+ $this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ), 'sanity check' );
+
+ BotPassword::removeAllPasswordsForUser( $this->testUserName );
+
+ $this->assertNull( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+ $this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ) );
+ }
+
+ /**
+ * @dataProvider provideCanonicalizeLoginData
+ */
+ public function testCanonicalizeLoginData( $username, $password, $expectedResult ) {
+ $result = BotPassword::canonicalizeLoginData( $username, $password );
+ if ( is_array( $expectedResult ) ) {
+ $this->assertArrayEquals( $expectedResult, $result, true, true );
+ } else {
+ $this->assertSame( $expectedResult, $result );
+ }
+ }
+
+ public function provideCanonicalizeLoginData() {
+ return [
+ [ 'user', 'pass', false ],
+ [ 'user', 'abc@def', false ],
+ [ 'legacy@user', 'pass', false ],
+ [ 'user@bot', '12345678901234567890123456789012',
+ [ 'user@bot', '12345678901234567890123456789012', true ] ],
+ [ 'user', 'bot@12345678901234567890123456789012',
+ [ 'user@bot', '12345678901234567890123456789012', true ] ],
+ [ 'user', 'bot@12345678901234567890123456789012345',
+ [ 'user@bot', '12345678901234567890123456789012345', true ] ],
+ [ 'user', 'bot@x@12345678901234567890123456789012',
+ [ 'user@bot@x', '12345678901234567890123456789012', true ] ],
+ ];
+ }
+
+ public function testLogin() {
+ // Test failure when bot passwords aren't enabled
+ $this->setMwGlobals( 'wgEnableBotPasswords', false );
+ $status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', new FauxRequest );
+ $this->assertEquals( Status::newFatal( 'botpasswords-disabled' ), $status );
+ $this->setMwGlobals( 'wgEnableBotPasswords', true );
+
+ // Test failure when BotPasswordSessionProvider isn't configured
+ $manager = new SessionManager( [
+ 'logger' => new Psr\Log\NullLogger,
+ 'store' => new EmptyBagOStuff,
+ ] );
+ $reset = MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager );
+ $this->assertNull(
+ $manager->getProvider( MediaWiki\Session\BotPasswordSessionProvider::class ),
+ 'sanity check'
+ );
+ $status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', new FauxRequest );
+ $this->assertEquals( Status::newFatal( 'botpasswords-no-provider' ), $status );
+ ScopedCallback::consume( $reset );
+
+ // Now configure BotPasswordSessionProvider for further tests...
+ $mainConfig = RequestContext::getMain()->getConfig();
+ $config = new HashConfig( [
+ 'SessionProviders' => $mainConfig->get( 'SessionProviders' ) + [
+ MediaWiki\Session\BotPasswordSessionProvider::class => [
+ 'class' => MediaWiki\Session\BotPasswordSessionProvider::class,
+ 'args' => [ [ 'priority' => 40 ] ],
+ ]
+ ],
+ ] );
+ $manager = new SessionManager( [
+ 'config' => new MultiConfig( [ $config, RequestContext::getMain()->getConfig() ] ),
+ 'logger' => new Psr\Log\NullLogger,
+ 'store' => new EmptyBagOStuff,
+ ] );
+ $reset = MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager );
+
+ // No "@"-thing in the username
+ $status = BotPassword::login( $this->testUserName, 'foobaz', new FauxRequest );
+ $this->assertEquals( Status::newFatal( 'botpasswords-invalid-name', '@' ), $status );
+
+ // No base user
+ $status = BotPassword::login( 'UTDummy@BotPassword', 'foobaz', new FauxRequest );
+ $this->assertEquals( Status::newFatal( 'nosuchuser', 'UTDummy' ), $status );
+
+ // No bot password
+ $status = BotPassword::login( "{$this->testUserName}@DoesNotExist", 'foobaz', new FauxRequest );
+ $this->assertEquals(
+ Status::newFatal( 'botpasswords-not-exist', $this->testUserName, 'DoesNotExist' ),
+ $status
+ );
+
+ // Failed restriction
+ $request = $this->getMockBuilder( FauxRequest::class )
+ ->setMethods( [ 'getIP' ] )
+ ->getMock();
+ $request->expects( $this->any() )->method( 'getIP' )
+ ->will( $this->returnValue( '10.0.0.1' ) );
+ $status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', $request );
+ $this->assertEquals( Status::newFatal( 'botpasswords-restriction-failed' ), $status );
+
+ // Wrong password
+ $status = BotPassword::login(
+ "{$this->testUserName}@BotPassword", $this->testUser->getPassword(), new FauxRequest );
+ $this->assertEquals( Status::newFatal( 'wrongpassword' ), $status );
+
+ // Success!
+ $request = new FauxRequest;
+ $this->assertNotInstanceOf(
+ MediaWiki\Session\BotPasswordSessionProvider::class,
+ $request->getSession()->getProvider(),
+ 'sanity check'
+ );
+ $status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', $request );
+ $this->assertInstanceOf( Status::class, $status );
+ $this->assertTrue( $status->isGood() );
+ $session = $status->getValue();
+ $this->assertInstanceOf( MediaWiki\Session\Session::class, $session );
+ $this->assertInstanceOf(
+ MediaWiki\Session\BotPasswordSessionProvider::class, $session->getProvider()
+ );
+ $this->assertSame( $session->getId(), $request->getSession()->getId() );
+
+ ScopedCallback::consume( $reset );
+ }
+
+ /**
+ * @dataProvider provideSave
+ * @param string|null $password
+ */
+ public function testSave( $password ) {
+ $passwordFactory = new \PasswordFactory();
+ $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+
+ $bp = BotPassword::newUnsaved( [
+ 'centralId' => 42,
+ 'appId' => 'TestSave',
+ 'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
+ 'grants' => [ 'test' ],
+ ] );
+ $this->assertFalse( $bp->isSaved(), 'sanity check' );
+ $this->assertNull(
+ BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ), 'sanity check'
+ );
+
+ $passwordHash = $password ? $passwordFactory->newFromPlaintext( $password ) : null;
+ $this->assertFalse( $bp->save( 'update', $passwordHash ) );
+ $this->assertTrue( $bp->save( 'insert', $passwordHash ) );
+ $bp2 = BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST );
+ $this->assertInstanceOf( BotPassword::class, $bp2 );
+ $this->assertEquals( $bp->getUserCentralId(), $bp2->getUserCentralId() );
+ $this->assertEquals( $bp->getAppId(), $bp2->getAppId() );
+ $this->assertEquals( $bp->getToken(), $bp2->getToken() );
+ $this->assertEquals( $bp->getRestrictions(), $bp2->getRestrictions() );
+ $this->assertEquals( $bp->getGrants(), $bp2->getGrants() );
+ $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
+ if ( $password === null ) {
+ $this->assertInstanceOf( InvalidPassword::class, $pw );
+ } else {
+ $this->assertTrue( $pw->equals( $password ) );
+ }
+
+ $token = $bp->getToken();
+ $this->assertEquals( 42, $bp->getUserCentralId() );
+ $this->assertEquals( 'TestSave', $bp->getAppId() );
+ $this->assertFalse( $bp->save( 'insert' ) );
+ $this->assertTrue( $bp->save( 'update' ) );
+ $this->assertNotEquals( $token, $bp->getToken() );
+ $bp2 = BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST );
+ $this->assertInstanceOf( BotPassword::class, $bp2 );
+ $this->assertEquals( $bp->getToken(), $bp2->getToken() );
+ $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
+ if ( $password === null ) {
+ $this->assertInstanceOf( InvalidPassword::class, $pw );
+ } else {
+ $this->assertTrue( $pw->equals( $password ) );
+ }
+
+ $passwordHash = $passwordFactory->newFromPlaintext( 'XXX' );
+ $token = $bp->getToken();
+ $this->assertTrue( $bp->save( 'update', $passwordHash ) );
+ $this->assertNotEquals( $token, $bp->getToken() );
+ $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
+ $this->assertTrue( $pw->equals( 'XXX' ) );
+
+ $this->assertTrue( $bp->delete() );
+ $this->assertFalse( $bp->isSaved() );
+ $this->assertNull( BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ) );
+
+ $this->assertFalse( $bp->save( 'foobar' ) );
+ }
+
+ public static function provideSave() {
+ return [
+ [ null ],
+ [ 'foobar' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/user/CentralIdLookupTest.php b/www/wiki/tests/phpunit/includes/user/CentralIdLookupTest.php
new file mode 100644
index 00000000..dc9fe2ad
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/user/CentralIdLookupTest.php
@@ -0,0 +1,183 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers CentralIdLookup
+ * @group Database
+ */
+class CentralIdLookupTest extends MediaWikiTestCase {
+
+ public function testFactory() {
+ $mock = $this->getMockForAbstractClass( CentralIdLookup::class );
+
+ $this->setMwGlobals( [
+ 'wgCentralIdLookupProviders' => [
+ 'local' => [ 'class' => LocalIdLookup::class ],
+ 'local2' => [ 'class' => LocalIdLookup::class ],
+ 'mock' => [ 'factory' => function () use ( $mock ) {
+ return $mock;
+ } ],
+ 'bad' => [ 'class' => stdClass::class ],
+ ],
+ 'wgCentralIdLookupProvider' => 'mock',
+ ] );
+
+ $this->assertSame( $mock, CentralIdLookup::factory() );
+ $this->assertSame( $mock, CentralIdLookup::factory( 'mock' ) );
+ $this->assertSame( 'mock', $mock->getProviderId() );
+
+ $local = CentralIdLookup::factory( 'local' );
+ $this->assertNotSame( $mock, $local );
+ $this->assertInstanceOf( LocalIdLookup::class, $local );
+ $this->assertSame( $local, CentralIdLookup::factory( 'local' ) );
+ $this->assertSame( 'local', $local->getProviderId() );
+
+ $local2 = CentralIdLookup::factory( 'local2' );
+ $this->assertNotSame( $local, $local2 );
+ $this->assertInstanceOf( LocalIdLookup::class, $local2 );
+ $this->assertSame( 'local2', $local2->getProviderId() );
+
+ $this->assertNull( CentralIdLookup::factory( 'unconfigured' ) );
+ $this->assertNull( CentralIdLookup::factory( 'bad' ) );
+ }
+
+ public function testCheckAudience() {
+ $mock = TestingAccessWrapper::newFromObject(
+ $this->getMockForAbstractClass( CentralIdLookup::class )
+ );
+
+ $user = static::getTestSysop()->getUser();
+ $this->assertSame( $user, $mock->checkAudience( $user ) );
+
+ $user = $mock->checkAudience( CentralIdLookup::AUDIENCE_PUBLIC );
+ $this->assertInstanceOf( User::class, $user );
+ $this->assertSame( 0, $user->getId() );
+
+ $this->assertNull( $mock->checkAudience( CentralIdLookup::AUDIENCE_RAW ) );
+
+ try {
+ $mock->checkAudience( 100 );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame( 'Invalid audience', $ex->getMessage() );
+ }
+ }
+
+ public function testNameFromCentralId() {
+ $mock = $this->getMockForAbstractClass( CentralIdLookup::class );
+ $mock->expects( $this->once() )->method( 'lookupCentralIds' )
+ ->with(
+ $this->equalTo( [ 15 => null ] ),
+ $this->equalTo( CentralIdLookup::AUDIENCE_RAW ),
+ $this->equalTo( CentralIdLookup::READ_LATEST )
+ )
+ ->will( $this->returnValue( [ 15 => 'FooBar' ] ) );
+
+ $this->assertSame(
+ 'FooBar',
+ $mock->nameFromCentralId( 15, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST )
+ );
+ }
+
+ /**
+ * @dataProvider provideLocalUserFromCentralId
+ * @param string $name
+ * @param bool $succeeds
+ */
+ public function testLocalUserFromCentralId( $name, $succeeds ) {
+ $mock = $this->getMockForAbstractClass( CentralIdLookup::class );
+ $mock->expects( $this->any() )->method( 'isAttached' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->once() )->method( 'lookupCentralIds' )
+ ->with(
+ $this->equalTo( [ 42 => null ] ),
+ $this->equalTo( CentralIdLookup::AUDIENCE_RAW ),
+ $this->equalTo( CentralIdLookup::READ_LATEST )
+ )
+ ->will( $this->returnValue( [ 42 => $name ] ) );
+
+ $user = $mock->localUserFromCentralId(
+ 42, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
+ );
+ if ( $succeeds ) {
+ $this->assertInstanceOf( User::class, $user );
+ $this->assertSame( $name, $user->getName() );
+ } else {
+ $this->assertNull( $user );
+ }
+
+ $mock = $this->getMockForAbstractClass( CentralIdLookup::class );
+ $mock->expects( $this->any() )->method( 'isAttached' )
+ ->will( $this->returnValue( false ) );
+ $mock->expects( $this->once() )->method( 'lookupCentralIds' )
+ ->with(
+ $this->equalTo( [ 42 => null ] ),
+ $this->equalTo( CentralIdLookup::AUDIENCE_RAW ),
+ $this->equalTo( CentralIdLookup::READ_LATEST )
+ )
+ ->will( $this->returnValue( [ 42 => $name ] ) );
+ $this->assertNull(
+ $mock->localUserFromCentralId( 42, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST )
+ );
+ }
+
+ public static function provideLocalUserFromCentralId() {
+ return [
+ [ 'UTSysop', true ],
+ [ 'UTDoesNotExist', false ],
+ [ null, false ],
+ [ '', false ],
+ [ '<X>', false ],
+ ];
+ }
+
+ public function testCentralIdFromName() {
+ $mock = $this->getMockForAbstractClass( CentralIdLookup::class );
+ $mock->expects( $this->once() )->method( 'lookupUserNames' )
+ ->with(
+ $this->equalTo( [ 'FooBar' => 0 ] ),
+ $this->equalTo( CentralIdLookup::AUDIENCE_RAW ),
+ $this->equalTo( CentralIdLookup::READ_LATEST )
+ )
+ ->will( $this->returnValue( [ 'FooBar' => 23 ] ) );
+
+ $this->assertSame(
+ 23,
+ $mock->centralIdFromName( 'FooBar', CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST )
+ );
+ }
+
+ public function testCentralIdFromLocalUser() {
+ $mock = $this->getMockForAbstractClass( CentralIdLookup::class );
+ $mock->expects( $this->any() )->method( 'isAttached' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->once() )->method( 'lookupUserNames' )
+ ->with(
+ $this->equalTo( [ 'FooBar' => 0 ] ),
+ $this->equalTo( CentralIdLookup::AUDIENCE_RAW ),
+ $this->equalTo( CentralIdLookup::READ_LATEST )
+ )
+ ->will( $this->returnValue( [ 'FooBar' => 23 ] ) );
+
+ $this->assertSame(
+ 23,
+ $mock->centralIdFromLocalUser(
+ User::newFromName( 'FooBar' ), CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
+ )
+ );
+
+ $mock = $this->getMockForAbstractClass( CentralIdLookup::class );
+ $mock->expects( $this->any() )->method( 'isAttached' )
+ ->will( $this->returnValue( false ) );
+ $mock->expects( $this->never() )->method( 'lookupUserNames' );
+
+ $this->assertSame(
+ 0,
+ $mock->centralIdFromLocalUser(
+ User::newFromName( 'FooBar' ), CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
+ )
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/user/ExternalUserNamesTest.php b/www/wiki/tests/phpunit/includes/user/ExternalUserNamesTest.php
new file mode 100644
index 00000000..429bda46
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/user/ExternalUserNamesTest.php
@@ -0,0 +1,131 @@
+<?php
+
+use MediaWiki\Interwiki\InterwikiLookup;
+
+/**
+ * @covers ExternalUserNames
+ */
+class ExternalUserNamesTest extends MediaWikiTestCase {
+
+ public function provideGetUserLinkTitle() {
+ return [
+ [ 'valid:>User1', Title::makeTitle( NS_MAIN, ':User:User1', '', 'valid' ) ],
+ [
+ 'valid:valid:>User1',
+ Title::makeTitle( NS_MAIN, 'valid::User:User1', '', 'valid' )
+ ],
+ [
+ '127.0.0.1',
+ Title::makeTitle( NS_SPECIAL, 'Contributions/127.0.0.1', '', '' )
+ ],
+ [ 'invalid:>User1', null ]
+ ];
+ }
+
+ /**
+ * @covers ExternalUserNames::getUserLinkTitle
+ * @dataProvider provideGetUserLinkTitle
+ */
+ public function testGetUserLinkTitle( $username, $expected ) {
+ $interwikiLookupMock = $this->getMockBuilder( InterwikiLookup::class )
+ ->getMock();
+
+ $interwikiValueMap = [
+ [ 'valid', true ],
+ [ 'invalid', false ]
+ ];
+ $interwikiLookupMock->expects( $this->any() )
+ ->method( 'isValidInterwiki' )
+ ->will( $this->returnValueMap( $interwikiValueMap ) );
+
+ $this->setService( 'InterwikiLookup', $interwikiLookupMock );
+
+ $this->assertEquals(
+ $expected,
+ ExternalUserNames::getUserLinkTitle( $username )
+ );
+ }
+
+ public function provideApplyPrefix() {
+ return [
+ [ 'User1', 'prefix', 'prefix>User1' ],
+ [ 'User1', 'prefix:>', 'prefix>User1' ],
+ [ 'User1', 'prefix:', 'prefix>User1' ],
+ ];
+ }
+
+ /**
+ * @covers ExternalUserNames::applyPrefix
+ * @dataProvider provideApplyPrefix
+ */
+ public function testApplyPrefix( $username, $prefix, $expected ) {
+ $externalUserNames = new ExternalUserNames( $prefix, true );
+
+ $this->assertSame(
+ $expected,
+ $externalUserNames->applyPrefix( $username )
+ );
+ }
+
+ public function provideAddPrefix() {
+ return [
+ [ 'User1', 'prefix', 'prefix>User1' ],
+ [ 'User2', 'prefix2', 'prefix2>User2' ],
+ [ 'User3', 'prefix3', 'prefix3>User3' ],
+ ];
+ }
+
+ /**
+ * @covers ExternalUserNames::addPrefix
+ * @dataProvider provideAddPrefix
+ */
+ public function testAddPrefix( $username, $prefix, $expected ) {
+ $externalUserNames = new ExternalUserNames( $prefix, true );
+
+ $this->assertSame(
+ $expected,
+ $externalUserNames->addPrefix( $username )
+ );
+ }
+
+ public function provideIsExternal() {
+ return [
+ [ 'User1', false ],
+ [ '>User1', true ],
+ [ 'prefix>User1', true ],
+ [ 'prefix:>User1', true ],
+ ];
+ }
+
+ /**
+ * @covers ExternalUserNames::isExternal
+ * @dataProvider provideIsExternal
+ */
+ public function testIsExternal( $username, $expected ) {
+ $this->assertSame(
+ $expected,
+ ExternalUserNames::isExternal( $username )
+ );
+ }
+
+ public function provideGetLocal() {
+ return [
+ [ 'User1', 'User1' ],
+ [ '>User2', 'User2' ],
+ [ 'prefix>User3', 'User3' ],
+ [ 'prefix:>User4', 'User4' ],
+ ];
+ }
+
+ /**
+ * @covers ExternalUserNames::getLocal
+ * @dataProvider provideGetLocal
+ */
+ public function testGetLocal( $username, $expected ) {
+ $this->assertSame(
+ $expected,
+ ExternalUserNames::getLocal( $username )
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/user/LocalIdLookupTest.php b/www/wiki/tests/phpunit/includes/user/LocalIdLookupTest.php
new file mode 100644
index 00000000..c91d8e0c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/user/LocalIdLookupTest.php
@@ -0,0 +1,156 @@
+<?php
+
+/**
+ * @covers LocalIdLookup
+ * @group Database
+ */
+class LocalIdLookupTest extends MediaWikiTestCase {
+ private $localUsers = [];
+
+ protected function setUp() {
+ global $wgGroupPermissions;
+
+ parent::setUp();
+
+ $this->stashMwGlobals( [ 'wgGroupPermissions' ] );
+ $wgGroupPermissions['local-id-lookup-test']['hideuser'] = true;
+ }
+
+ public function addDBData() {
+ for ( $i = 1; $i <= 4; $i++ ) {
+ $this->localUsers[] = $this->getMutableTestUser()->getUser();
+ }
+
+ $sysop = static::getTestSysop()->getUser();
+
+ $block = new Block( [
+ 'address' => $this->localUsers[2]->getName(),
+ 'by' => $sysop->getId(),
+ 'reason' => __METHOD__,
+ 'expiry' => '1 day',
+ 'hideName' => false,
+ ] );
+ $block->insert();
+
+ $block = new Block( [
+ 'address' => $this->localUsers[3]->getName(),
+ 'by' => $sysop->getId(),
+ 'reason' => __METHOD__,
+ 'expiry' => '1 day',
+ 'hideName' => true,
+ ] );
+ $block->insert();
+ }
+
+ public function getLookupUser() {
+ return static::getTestUser( [ 'local-id-lookup-test' ] )->getUser();
+ }
+
+ public function testLookupCentralIds() {
+ $lookup = new LocalIdLookup();
+
+ $user1 = $this->getLookupUser();
+ $user2 = User::newFromName( 'UTLocalIdLookup2' );
+
+ $this->assertTrue( $user1->isAllowed( 'hideuser' ), 'sanity check' );
+ $this->assertFalse( $user2->isAllowed( 'hideuser' ), 'sanity check' );
+
+ $this->assertSame( [], $lookup->lookupCentralIds( [] ) );
+
+ $expect = [];
+ foreach ( $this->localUsers as $localUser ) {
+ $expect[$localUser->getId()] = $localUser->getName();
+ }
+ $expect[12345] = 'X';
+ ksort( $expect );
+
+ $expect2 = $expect;
+ $expect2[$this->localUsers[3]->getId()] = '';
+
+ $arg = array_fill_keys( array_keys( $expect ), 'X' );
+
+ $this->assertSame( $expect2, $lookup->lookupCentralIds( $arg ) );
+ $this->assertSame( $expect, $lookup->lookupCentralIds( $arg, CentralIdLookup::AUDIENCE_RAW ) );
+ $this->assertSame( $expect, $lookup->lookupCentralIds( $arg, $user1 ) );
+ $this->assertSame( $expect2, $lookup->lookupCentralIds( $arg, $user2 ) );
+ }
+
+ public function testLookupUserNames() {
+ $lookup = new LocalIdLookup();
+ $user1 = $this->getLookupUser();
+ $user2 = User::newFromName( 'UTLocalIdLookup2' );
+
+ $this->assertTrue( $user1->isAllowed( 'hideuser' ), 'sanity check' );
+ $this->assertFalse( $user2->isAllowed( 'hideuser' ), 'sanity check' );
+
+ $this->assertSame( [], $lookup->lookupUserNames( [] ) );
+
+ $expect = [];
+ foreach ( $this->localUsers as $localUser ) {
+ $expect[$localUser->getName()] = $localUser->getId();
+ }
+ $expect['UTDoesNotExist'] = 'X';
+ ksort( $expect );
+
+ $expect2 = $expect;
+ $expect2[$this->localUsers[3]->getName()] = 'X';
+
+ $arg = array_fill_keys( array_keys( $expect ), 'X' );
+
+ $this->assertSame( $expect2, $lookup->lookupUserNames( $arg ) );
+ $this->assertSame( $expect, $lookup->lookupUserNames( $arg, CentralIdLookup::AUDIENCE_RAW ) );
+ $this->assertSame( $expect, $lookup->lookupUserNames( $arg, $user1 ) );
+ $this->assertSame( $expect2, $lookup->lookupUserNames( $arg, $user2 ) );
+ }
+
+ public function testIsAttached() {
+ $lookup = new LocalIdLookup();
+ $user1 = $this->getLookupUser();
+ $user2 = User::newFromName( 'DoesNotExist' );
+
+ $this->assertTrue( $lookup->isAttached( $user1 ) );
+ $this->assertFalse( $lookup->isAttached( $user2 ) );
+
+ $wiki = wfWikiID();
+ $this->assertTrue( $lookup->isAttached( $user1, $wiki ) );
+ $this->assertFalse( $lookup->isAttached( $user2, $wiki ) );
+
+ $wiki = 'not-' . wfWikiID();
+ $this->assertFalse( $lookup->isAttached( $user1, $wiki ) );
+ $this->assertFalse( $lookup->isAttached( $user2, $wiki ) );
+ }
+
+ /**
+ * @dataProvider provideIsAttachedShared
+ * @param bool $sharedDB $wgSharedDB is set
+ * @param bool $sharedTable $wgSharedTables contains 'user'
+ * @param bool $localDBSet $wgLocalDatabases contains the shared DB
+ */
+ public function testIsAttachedShared( $sharedDB, $sharedTable, $localDBSet ) {
+ global $wgDBName;
+ $this->setMwGlobals( [
+ 'wgSharedDB' => $sharedDB ? $wgDBName : null,
+ 'wgSharedTables' => $sharedTable ? [ 'user' ] : [],
+ 'wgLocalDatabases' => $localDBSet ? [ 'shared' ] : [],
+ ] );
+
+ $lookup = new LocalIdLookup();
+ $this->assertSame(
+ $sharedDB && $sharedTable && $localDBSet,
+ $lookup->isAttached( $this->getLookupUser(), 'shared' )
+ );
+ }
+
+ public static function provideIsAttachedShared() {
+ $ret = [];
+ for ( $i = 0; $i < 7; $i++ ) {
+ $ret[] = [
+ (bool)( $i & 1 ),
+ (bool)( $i & 2 ),
+ (bool)( $i & 4 ),
+ ];
+ }
+ return $ret;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/user/PasswordResetTest.php b/www/wiki/tests/phpunit/includes/user/PasswordResetTest.php
new file mode 100644
index 00000000..1f578ab0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/user/PasswordResetTest.php
@@ -0,0 +1,193 @@
+<?php
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * @covers PasswordReset
+ * @group Database
+ */
+class PasswordResetTest extends MediaWikiTestCase {
+ /**
+ * @dataProvider provideIsAllowed
+ */
+ public function testIsAllowed( $passwordResetRoutes, $enableEmail,
+ $allowsAuthenticationDataChange, $canEditPrivate, $block, $globalBlock, $isAllowed
+ ) {
+ $config = new HashConfig( [
+ 'PasswordResetRoutes' => $passwordResetRoutes,
+ 'EnableEmail' => $enableEmail,
+ ] );
+
+ $authManager = $this->getMockBuilder( AuthManager::class )->disableOriginalConstructor()
+ ->getMock();
+ $authManager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' )
+ ->willReturn( $allowsAuthenticationDataChange ? Status::newGood() : Status::newFatal( 'foo' ) );
+
+ $user = $this->getMockBuilder( User::class )->getMock();
+ $user->expects( $this->any() )->method( 'getName' )->willReturn( 'Foo' );
+ $user->expects( $this->any() )->method( 'getBlock' )->willReturn( $block );
+ $user->expects( $this->any() )->method( 'getGlobalBlock' )->willReturn( $globalBlock );
+ $user->expects( $this->any() )->method( 'isAllowed' )
+ ->will( $this->returnCallback( function ( $perm ) use ( $canEditPrivate ) {
+ if ( $perm === 'editmyprivateinfo' ) {
+ return $canEditPrivate;
+ } else {
+ $this->fail( 'Unexpected permission check' );
+ }
+ } ) );
+
+ $passwordReset = new PasswordReset( $config, $authManager );
+
+ $this->assertSame( $isAllowed, $passwordReset->isAllowed( $user )->isGood() );
+ }
+
+ public function provideIsAllowed() {
+ return [
+ 'no routes' => [
+ 'passwordResetRoutes' => [],
+ 'enableEmail' => true,
+ 'allowsAuthenticationDataChange' => true,
+ 'canEditPrivate' => true,
+ 'block' => null,
+ 'globalBlock' => null,
+ 'isAllowed' => false,
+ ],
+ 'email disabled' => [
+ 'passwordResetRoutes' => [ 'username' => true ],
+ 'enableEmail' => false,
+ 'allowsAuthenticationDataChange' => true,
+ 'canEditPrivate' => true,
+ 'block' => null,
+ 'globalBlock' => null,
+ 'isAllowed' => false,
+ ],
+ 'auth data change disabled' => [
+ 'passwordResetRoutes' => [ 'username' => true ],
+ 'enableEmail' => true,
+ 'allowsAuthenticationDataChange' => false,
+ 'canEditPrivate' => true,
+ 'block' => null,
+ 'globalBlock' => null,
+ 'isAllowed' => false,
+ ],
+ 'cannot edit private data' => [
+ 'passwordResetRoutes' => [ 'username' => true ],
+ 'enableEmail' => true,
+ 'allowsAuthenticationDataChange' => true,
+ 'canEditPrivate' => false,
+ 'block' => null,
+ 'globalBlock' => null,
+ 'isAllowed' => false,
+ ],
+ 'blocked with account creation disabled' => [
+ 'passwordResetRoutes' => [ 'username' => true ],
+ 'enableEmail' => true,
+ 'allowsAuthenticationDataChange' => true,
+ 'canEditPrivate' => true,
+ 'block' => new Block( [ 'createAccount' => true ] ),
+ 'globalBlock' => null,
+ 'isAllowed' => false,
+ ],
+ 'blocked w/o account creation disabled' => [
+ 'passwordResetRoutes' => [ 'username' => true ],
+ 'enableEmail' => true,
+ 'allowsAuthenticationDataChange' => true,
+ 'canEditPrivate' => true,
+ 'block' => new Block( [] ),
+ 'globalBlock' => null,
+ 'isAllowed' => true,
+ ],
+ 'using blocked proxy' => [
+ 'passwordResetRoutes' => [ 'username' => true ],
+ 'enableEmail' => true,
+ 'allowsAuthenticationDataChange' => true,
+ 'canEditPrivate' => true,
+ 'block' => new Block( [ 'systemBlock' => 'proxy' ] ),
+ 'globalBlock' => null,
+ 'isAllowed' => false,
+ ],
+ 'globally blocked with account creation disabled' => [
+ 'passwordResetRoutes' => [ 'username' => true ],
+ 'enableEmail' => true,
+ 'allowsAuthenticationDataChange' => true,
+ 'canEditPrivate' => true,
+ 'block' => null,
+ 'globalBlock' => new Block( [ 'systemBlock' => 'global-block', 'createAccount' => true ] ),
+ 'isAllowed' => false,
+ ],
+ 'globally blocked with account creation not disabled' => [
+ 'passwordResetRoutes' => [ 'username' => true ],
+ 'enableEmail' => true,
+ 'allowsAuthenticationDataChange' => true,
+ 'canEditPrivate' => true,
+ 'block' => null,
+ 'globalBlock' => new Block( [ 'systemBlock' => 'global-block', 'createAccount' => false ] ),
+ 'isAllowed' => true,
+ ],
+ 'blocked via wgSoftBlockRanges' => [
+ 'passwordResetRoutes' => [ 'username' => true ],
+ 'enableEmail' => true,
+ 'allowsAuthenticationDataChange' => true,
+ 'canEditPrivate' => true,
+ 'block' => new Block( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
+ 'globalBlock' => null,
+ 'isAllowed' => true,
+ ],
+ 'all OK' => [
+ 'passwordResetRoutes' => [ 'username' => true ],
+ 'enableEmail' => true,
+ 'allowsAuthenticationDataChange' => true,
+ 'canEditPrivate' => true,
+ 'block' => null,
+ 'globalBlock' => null,
+ 'isAllowed' => true,
+ ],
+ ];
+ }
+
+ public function testExecute_email() {
+ $config = new HashConfig( [
+ 'PasswordResetRoutes' => [ 'username' => true, 'email' => true ],
+ 'EnableEmail' => true,
+ ] );
+
+ // Unregister the hooks for proper unit testing
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'User::mailPasswordInternal' => [],
+ 'SpecialPasswordResetOnSubmit' => [],
+ ] );
+
+ $authManager = $this->getMockBuilder( AuthManager::class )->disableOriginalConstructor()
+ ->getMock();
+ $authManager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' )
+ ->willReturn( Status::newGood() );
+ $authManager->expects( $this->exactly( 2 ) )->method( 'changeAuthenticationData' );
+
+ $request = new FauxRequest();
+ $request->setIP( '1.2.3.4' );
+ $performingUser = $this->getMockBuilder( User::class )->getMock();
+ $performingUser->expects( $this->any() )->method( 'getRequest' )->willReturn( $request );
+ $performingUser->expects( $this->any() )->method( 'isAllowed' )->willReturn( true );
+
+ $targetUser1 = $this->getMockBuilder( User::class )->getMock();
+ $targetUser2 = $this->getMockBuilder( User::class )->getMock();
+ $targetUser1->expects( $this->any() )->method( 'getName' )->willReturn( 'User1' );
+ $targetUser2->expects( $this->any() )->method( 'getName' )->willReturn( 'User2' );
+ $targetUser1->expects( $this->any() )->method( 'getId' )->willReturn( 1 );
+ $targetUser2->expects( $this->any() )->method( 'getId' )->willReturn( 2 );
+ $targetUser1->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' );
+ $targetUser2->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' );
+
+ $passwordReset = $this->getMockBuilder( PasswordReset::class )
+ ->setMethods( [ 'getUsersByEmail' ] )->setConstructorArgs( [ $config, $authManager ] )
+ ->getMock();
+ $passwordReset->expects( $this->any() )->method( 'getUsersByEmail' )->with( 'foo@bar.baz' )
+ ->willReturn( [ $targetUser1, $targetUser2 ] );
+
+ $status = $passwordReset->isAllowed( $performingUser );
+ $this->assertTrue( $status->isGood() );
+
+ $status = $passwordReset->execute( $performingUser, null, 'foo@bar.baz' );
+ $this->assertTrue( $status->isGood() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/user/UserArrayFromResultTest.php b/www/wiki/tests/phpunit/includes/user/UserArrayFromResultTest.php
new file mode 100644
index 00000000..beaacec8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/user/UserArrayFromResultTest.php
@@ -0,0 +1,114 @@
+<?php
+
+/**
+ * @author Addshore
+ * @covers UserArrayFromResult
+ */
+class UserArrayFromResultTest extends MediaWikiTestCase {
+
+ private function getMockResultWrapper( $row = null, $numRows = 1 ) {
+ $resultWrapper = $this->getMockBuilder( Wikimedia\Rdbms\ResultWrapper::class )
+ ->disableOriginalConstructor();
+
+ $resultWrapper = $resultWrapper->getMock();
+ $resultWrapper->expects( $this->atLeastOnce() )
+ ->method( 'current' )
+ ->will( $this->returnValue( $row ) );
+ $resultWrapper->expects( $this->any() )
+ ->method( 'numRows' )
+ ->will( $this->returnValue( $numRows ) );
+
+ return $resultWrapper;
+ }
+
+ private function getRowWithUsername( $username = 'fooUser' ) {
+ $row = new stdClass();
+ $row->user_name = $username;
+ return $row;
+ }
+
+ private function getUserArrayFromResult( $resultWrapper ) {
+ return new UserArrayFromResult( $resultWrapper );
+ }
+
+ /**
+ * @covers UserArrayFromResult::__construct
+ */
+ public function testConstructionWithFalseRow() {
+ $row = false;
+ $resultWrapper = $this->getMockResultWrapper( $row );
+
+ $object = $this->getUserArrayFromResult( $resultWrapper );
+
+ $this->assertEquals( $resultWrapper, $object->res );
+ $this->assertSame( 0, $object->key );
+ $this->assertEquals( $row, $object->current );
+ }
+
+ /**
+ * @covers UserArrayFromResult::__construct
+ */
+ public function testConstructionWithRow() {
+ $username = 'addshore';
+ $row = $this->getRowWithUsername( $username );
+ $resultWrapper = $this->getMockResultWrapper( $row );
+
+ $object = $this->getUserArrayFromResult( $resultWrapper );
+
+ $this->assertEquals( $resultWrapper, $object->res );
+ $this->assertSame( 0, $object->key );
+ $this->assertInstanceOf( User::class, $object->current );
+ $this->assertEquals( $username, $object->current->mName );
+ }
+
+ public static function provideNumberOfRows() {
+ return [
+ [ 0 ],
+ [ 1 ],
+ [ 122 ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideNumberOfRows
+ * @covers UserArrayFromResult::count
+ */
+ public function testCountWithVaryingValues( $numRows ) {
+ $object = $this->getUserArrayFromResult( $this->getMockResultWrapper(
+ $this->getRowWithUsername(),
+ $numRows
+ ) );
+ $this->assertEquals( $numRows, $object->count() );
+ }
+
+ /**
+ * @covers UserArrayFromResult::current
+ */
+ public function testCurrentAfterConstruction() {
+ $username = 'addshore';
+ $userRow = $this->getRowWithUsername( $username );
+ $object = $this->getUserArrayFromResult( $this->getMockResultWrapper( $userRow ) );
+ $this->assertInstanceOf( User::class, $object->current() );
+ $this->assertEquals( $username, $object->current()->mName );
+ }
+
+ public function provideTestValid() {
+ return [
+ [ $this->getRowWithUsername(), true ],
+ [ false, false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideTestValid
+ * @covers UserArrayFromResult::valid
+ */
+ public function testValid( $input, $expected ) {
+ $object = $this->getUserArrayFromResult( $this->getMockResultWrapper( $input ) );
+ $this->assertEquals( $expected, $object->valid() );
+ }
+
+ // @todo unit test for key()
+ // @todo unit test for next()
+ // @todo unit test for rewind()
+}
diff --git a/www/wiki/tests/phpunit/includes/user/UserGroupMembershipTest.php b/www/wiki/tests/phpunit/includes/user/UserGroupMembershipTest.php
new file mode 100644
index 00000000..4862747b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/user/UserGroupMembershipTest.php
@@ -0,0 +1,153 @@
+<?php
+
+/**
+ * @group Database
+ */
+class UserGroupMembershipTest extends MediaWikiTestCase {
+
+ protected $tablesUsed = [ 'user', 'user_groups' ];
+
+ /**
+ * @var User Belongs to no groups
+ */
+ protected $userNoGroups;
+ /**
+ * @var User Belongs to the 'unittesters' group indefinitely, and the
+ * 'testwriters' group with expiry
+ */
+ protected $userTester;
+ /**
+ * @var string The timestamp, in TS_MW format, of the expiry of $userTester's
+ * membership in the 'testwriters' group
+ */
+ protected $expiryTime;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgGroupPermissions' => [
+ 'unittesters' => [
+ 'runtest' => true,
+ ],
+ 'testwriters' => [
+ 'writetest' => true,
+ ]
+ ]
+ ] );
+
+ $this->userNoGroups = new User;
+ $this->userNoGroups->setName( 'NoGroups' );
+ $this->userNoGroups->addToDatabase();
+
+ $this->userTester = new User;
+ $this->userTester->setName( 'Tester' );
+ $this->userTester->addToDatabase();
+ $this->userTester->addGroup( 'unittesters' );
+ $this->expiryTime = wfTimestamp( TS_MW, time() + 100500 );
+ $this->userTester->addGroup( 'testwriters', $this->expiryTime );
+ }
+
+ /**
+ * @covers UserGroupMembership::insert
+ * @covers UserGroupMembership::delete
+ */
+ public function testAddAndRemoveGroups() {
+ $user = $this->getMutableTestUser()->getUser();
+
+ // basic tests
+ $ugm = new UserGroupMembership( $user->getId(), 'unittesters' );
+ $this->assertTrue( $ugm->insert() );
+ $user->clearInstanceCache();
+ $this->assertContains( 'unittesters', $user->getGroups() );
+ $this->assertArrayHasKey( 'unittesters', $user->getGroupMemberships() );
+ $this->assertTrue( $user->isAllowed( 'runtest' ) );
+
+ // try updating without allowUpdate. Should fail
+ $ugm = new UserGroupMembership( $user->getId(), 'unittesters', $this->expiryTime );
+ $this->assertFalse( $ugm->insert() );
+
+ // now try updating with allowUpdate
+ $this->assertTrue( $ugm->insert( 2 ) );
+ $user->clearInstanceCache();
+ $this->assertContains( 'unittesters', $user->getGroups() );
+ $this->assertArrayHasKey( 'unittesters', $user->getGroupMemberships() );
+ $this->assertTrue( $user->isAllowed( 'runtest' ) );
+
+ // try removing the group
+ $ugm->delete();
+ $user->clearInstanceCache();
+ $this->assertThat( $user->getGroups(),
+ $this->logicalNot( $this->contains( 'unittesters' ) ) );
+ $this->assertThat( $user->getGroupMemberships(),
+ $this->logicalNot( $this->arrayHasKey( 'unittesters' ) ) );
+ $this->assertFalse( $user->isAllowed( 'runtest' ) );
+
+ // check that the user group is now in user_former_groups
+ $this->assertContains( 'unittesters', $user->getFormerGroups() );
+ }
+
+ private function addUserTesterToExpiredGroup() {
+ // put $userTester in a group with expiry in the past
+ $ugm = new UserGroupMembership( $this->userTester->getId(), 'sysop', '20010102030405' );
+ $ugm->insert();
+ }
+
+ /**
+ * @covers UserGroupMembership::getMembershipsForUser
+ */
+ public function testGetMembershipsForUser() {
+ $this->addUserTesterToExpiredGroup();
+
+ // check that the user in no groups has no group memberships
+ $ugms = UserGroupMembership::getMembershipsForUser( $this->userNoGroups->getId() );
+ $this->assertEmpty( $ugms );
+
+ // check that the user in 2 groups has 2 group memberships
+ $testerUserId = $this->userTester->getId();
+ $ugms = UserGroupMembership::getMembershipsForUser( $testerUserId );
+ $this->assertCount( 2, $ugms );
+
+ // check that the required group memberships are present on $userTester,
+ // with the correct user IDs and expiries
+ $expectedGroups = [ 'unittesters', 'testwriters' ];
+
+ foreach ( $expectedGroups as $group ) {
+ $this->assertArrayHasKey( $group, $ugms );
+ $this->assertEquals( $ugms[$group]->getUserId(), $testerUserId );
+ $this->assertEquals( $ugms[$group]->getGroup(), $group );
+
+ if ( $group === 'unittesters' ) {
+ $this->assertNull( $ugms[$group]->getExpiry() );
+ } elseif ( $group === 'testwriters' ) {
+ $this->assertEquals( $ugms[$group]->getExpiry(), $this->expiryTime );
+ }
+ }
+ }
+
+ /**
+ * @covers UserGroupMembership::getMembership
+ */
+ public function testGetMembership() {
+ $this->addUserTesterToExpiredGroup();
+
+ // groups that the user doesn't belong to shouldn't be returned
+ $ugm = UserGroupMembership::getMembership( $this->userNoGroups->getId(), 'sysop' );
+ $this->assertFalse( $ugm );
+
+ // implicit groups shouldn't be returned
+ $ugm = UserGroupMembership::getMembership( $this->userNoGroups->getId(), 'user' );
+ $this->assertFalse( $ugm );
+
+ // expired groups shouldn't be returned
+ $ugm = UserGroupMembership::getMembership( $this->userTester->getId(), 'sysop' );
+ $this->assertFalse( $ugm );
+
+ // groups that the user does belong to should be returned with correct properties
+ $ugm = UserGroupMembership::getMembership( $this->userTester->getId(), 'unittesters' );
+ $this->assertInstanceOf( UserGroupMembership::class, $ugm );
+ $this->assertEquals( $ugm->getUserId(), $this->userTester->getId() );
+ $this->assertEquals( $ugm->getGroup(), 'unittesters' );
+ $this->assertNull( $ugm->getExpiry() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/user/UserTest.php b/www/wiki/tests/phpunit/includes/user/UserTest.php
new file mode 100644
index 00000000..ebfecbca
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/user/UserTest.php
@@ -0,0 +1,1208 @@
+<?php
+
+define( 'NS_UNITTEST', 5600 );
+define( 'NS_UNITTEST_TALK', 5601 );
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Database
+ */
+class UserTest extends MediaWikiTestCase {
+ /**
+ * @var User
+ */
+ protected $user;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgGroupPermissions' => [],
+ 'wgRevokePermissions' => [],
+ 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
+ ] );
+ $this->overrideMwServices();
+
+ $this->setUpPermissionGlobals();
+
+ $this->user = $this->getTestUser( [ 'unittesters' ] )->getUser();
+ }
+
+ private function setUpPermissionGlobals() {
+ global $wgGroupPermissions, $wgRevokePermissions;
+
+ # Data for regular $wgGroupPermissions test
+ $wgGroupPermissions['unittesters'] = [
+ 'test' => true,
+ 'runtest' => true,
+ 'writetest' => false,
+ 'nukeworld' => false,
+ ];
+ $wgGroupPermissions['testwriters'] = [
+ 'test' => true,
+ 'writetest' => true,
+ 'modifytest' => true,
+ ];
+
+ # Data for regular $wgRevokePermissions test
+ $wgRevokePermissions['formertesters'] = [
+ 'runtest' => true,
+ ];
+
+ # For the options test
+ $wgGroupPermissions['*'] = [
+ 'editmyoptions' => true,
+ ];
+ }
+
+ /**
+ * @covers User::getGroupPermissions
+ */
+ public function testGroupPermissions() {
+ $rights = User::getGroupPermissions( [ 'unittesters' ] );
+ $this->assertContains( 'runtest', $rights );
+ $this->assertNotContains( 'writetest', $rights );
+ $this->assertNotContains( 'modifytest', $rights );
+ $this->assertNotContains( 'nukeworld', $rights );
+
+ $rights = User::getGroupPermissions( [ 'unittesters', 'testwriters' ] );
+ $this->assertContains( 'runtest', $rights );
+ $this->assertContains( 'writetest', $rights );
+ $this->assertContains( 'modifytest', $rights );
+ $this->assertNotContains( 'nukeworld', $rights );
+ }
+
+ /**
+ * @covers User::getGroupPermissions
+ */
+ public function testRevokePermissions() {
+ $rights = User::getGroupPermissions( [ 'unittesters', 'formertesters' ] );
+ $this->assertNotContains( 'runtest', $rights );
+ $this->assertNotContains( 'writetest', $rights );
+ $this->assertNotContains( 'modifytest', $rights );
+ $this->assertNotContains( 'nukeworld', $rights );
+ }
+
+ /**
+ * @covers User::getRights
+ */
+ public function testUserPermissions() {
+ $rights = $this->user->getRights();
+ $this->assertContains( 'runtest', $rights );
+ $this->assertNotContains( 'writetest', $rights );
+ $this->assertNotContains( 'modifytest', $rights );
+ $this->assertNotContains( 'nukeworld', $rights );
+ }
+
+ /**
+ * @covers User::getRights
+ */
+ public function testUserGetRightsHooks() {
+ $user = $this->getTestUser( [ 'unittesters', 'testwriters' ] )->getUser();
+ $userWrapper = TestingAccessWrapper::newFromObject( $user );
+
+ $rights = $user->getRights();
+ $this->assertContains( 'test', $rights, 'sanity check' );
+ $this->assertContains( 'runtest', $rights, 'sanity check' );
+ $this->assertContains( 'writetest', $rights, 'sanity check' );
+ $this->assertNotContains( 'nukeworld', $rights, 'sanity check' );
+
+ // Add a hook manipluating the rights
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'UserGetRights' => [ function ( $user, &$rights ) {
+ $rights[] = 'nukeworld';
+ $rights = array_diff( $rights, [ 'writetest' ] );
+ } ] ] );
+
+ $userWrapper->mRights = null;
+ $rights = $user->getRights();
+ $this->assertContains( 'test', $rights );
+ $this->assertContains( 'runtest', $rights );
+ $this->assertNotContains( 'writetest', $rights );
+ $this->assertContains( 'nukeworld', $rights );
+
+ // Add a Session that limits rights
+ $mock = $this->getMockBuilder( stdClass::class )
+ ->setMethods( [ 'getAllowedUserRights', 'deregisterSession', 'getSessionId' ] )
+ ->getMock();
+ $mock->method( 'getAllowedUserRights' )->willReturn( [ 'test', 'writetest' ] );
+ $mock->method( 'getSessionId' )->willReturn(
+ new MediaWiki\Session\SessionId( str_repeat( 'X', 32 ) )
+ );
+ $session = MediaWiki\Session\TestUtils::getDummySession( $mock );
+ $mockRequest = $this->getMockBuilder( FauxRequest::class )
+ ->setMethods( [ 'getSession' ] )
+ ->getMock();
+ $mockRequest->method( 'getSession' )->willReturn( $session );
+ $userWrapper->mRequest = $mockRequest;
+
+ $userWrapper->mRights = null;
+ $rights = $user->getRights();
+ $this->assertContains( 'test', $rights );
+ $this->assertNotContains( 'runtest', $rights );
+ $this->assertNotContains( 'writetest', $rights );
+ $this->assertNotContains( 'nukeworld', $rights );
+ }
+
+ /**
+ * @dataProvider provideGetGroupsWithPermission
+ * @covers User::getGroupsWithPermission
+ */
+ public function testGetGroupsWithPermission( $expected, $right ) {
+ $result = User::getGroupsWithPermission( $right );
+ sort( $result );
+ sort( $expected );
+
+ $this->assertEquals( $expected, $result, "Groups with permission $right" );
+ }
+
+ public static function provideGetGroupsWithPermission() {
+ return [
+ [
+ [ 'unittesters', 'testwriters' ],
+ 'test'
+ ],
+ [
+ [ 'unittesters' ],
+ 'runtest'
+ ],
+ [
+ [ 'testwriters' ],
+ 'writetest'
+ ],
+ [
+ [ 'testwriters' ],
+ 'modifytest'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIPs
+ * @covers User::isIP
+ */
+ public function testIsIP( $value, $result, $message ) {
+ $this->assertEquals( $this->user->isIP( $value ), $result, $message );
+ }
+
+ public static function provideIPs() {
+ return [
+ [ '', false, 'Empty string' ],
+ [ ' ', false, 'Blank space' ],
+ [ '10.0.0.0', true, 'IPv4 private 10/8' ],
+ [ '10.255.255.255', true, 'IPv4 private 10/8' ],
+ [ '192.168.1.1', true, 'IPv4 private 192.168/16' ],
+ [ '203.0.113.0', true, 'IPv4 example' ],
+ [ '2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff', true, 'IPv6 example' ],
+ // Not valid IPs but classified as such by MediaWiki for negated asserting
+ // of whether this might be the identifier of a logged-out user or whether
+ // to allow usernames like it.
+ [ '300.300.300.300', true, 'Looks too much like an IPv4 address' ],
+ [ '203.0.113.xxx', true, 'Assigned by UseMod to cloaked logged-out users' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideUserNames
+ * @covers User::isValidUserName
+ */
+ public function testIsValidUserName( $username, $result, $message ) {
+ $this->assertEquals( $this->user->isValidUserName( $username ), $result, $message );
+ }
+
+ public static function provideUserNames() {
+ return [
+ [ '', false, 'Empty string' ],
+ [ ' ', false, 'Blank space' ],
+ [ 'abcd', false, 'Starts with small letter' ],
+ [ 'Ab/cd', false, 'Contains slash' ],
+ [ 'Ab cd', true, 'Whitespace' ],
+ [ '192.168.1.1', false, 'IP' ],
+ [ '116.17.184.5/32', false, 'IP range' ],
+ [ '::e:f:2001/96', false, 'IPv6 range' ],
+ [ 'User:Abcd', false, 'Reserved Namespace' ],
+ [ '12abcd232', true, 'Starts with Numbers' ],
+ [ '?abcd', true, 'Start with ? mark' ],
+ [ '#abcd', false, 'Start with #' ],
+ [ 'Abcdകഖഗഘ', true, ' Mixed scripts' ],
+ [ 'ജോസ്‌തോമസ്', false, 'ZWNJ- Format control character' ],
+ [ 'Ab cd', false, ' Ideographic space' ],
+ [ '300.300.300.300', false, 'Looks too much like an IPv4 address' ],
+ [ '302.113.311.900', false, 'Looks too much like an IPv4 address' ],
+ [ '203.0.113.xxx', false, 'Reserved for usage by UseMod for cloaked logged-out users' ],
+ ];
+ }
+
+ /**
+ * Test, if for all rights a right- message exist,
+ * which is used on Special:ListGroupRights as help text
+ * Extensions and core
+ *
+ * @coversNothing
+ */
+ public function testAllRightsWithMessage() {
+ // Getting all user rights, for core: User::$mCoreRights, for extensions: $wgAvailableRights
+ $allRights = User::getAllRights();
+ $allMessageKeys = Language::getMessageKeysFor( 'en' );
+
+ $rightsWithMessage = [];
+ foreach ( $allMessageKeys as $message ) {
+ // === 0: must be at beginning of string (position 0)
+ if ( strpos( $message, 'right-' ) === 0 ) {
+ $rightsWithMessage[] = substr( $message, strlen( 'right-' ) );
+ }
+ }
+
+ sort( $allRights );
+ sort( $rightsWithMessage );
+
+ $this->assertEquals(
+ $allRights,
+ $rightsWithMessage,
+ 'Each user rights (core/extensions) has a corresponding right- message.'
+ );
+ }
+
+ /**
+ * Test User::editCount
+ * @group medium
+ * @covers User::getEditCount
+ */
+ public function testGetEditCount() {
+ $user = $this->getMutableTestUser()->getUser();
+
+ // let the user have a few (3) edits
+ $page = WikiPage::factory( Title::newFromText( 'Help:UserTest_EditCount' ) );
+ for ( $i = 0; $i < 3; $i++ ) {
+ $page->doEditContent(
+ ContentHandler::makeContent( (string)$i, $page->getTitle() ),
+ 'test',
+ 0,
+ false,
+ $user
+ );
+ }
+
+ $this->assertEquals(
+ 3,
+ $user->getEditCount(),
+ 'After three edits, the user edit count should be 3'
+ );
+
+ // increase the edit count
+ $user->incEditCount();
+
+ $this->assertEquals(
+ 4,
+ $user->getEditCount(),
+ 'After increasing the edit count manually, the user edit count should be 4'
+ );
+ }
+
+ /**
+ * Test User::editCount
+ * @group medium
+ * @covers User::getEditCount
+ */
+ public function testGetEditCountForAnons() {
+ $user = User::newFromName( 'Anonymous' );
+
+ $this->assertNull(
+ $user->getEditCount(),
+ 'Edit count starts null for anonymous users.'
+ );
+
+ $user->incEditCount();
+
+ $this->assertNull(
+ $user->getEditCount(),
+ 'Edit count remains null for anonymous users despite calls to increase it.'
+ );
+ }
+
+ /**
+ * Test User::editCount
+ * @group medium
+ * @covers User::incEditCount
+ */
+ public function testIncEditCount() {
+ $user = $this->getMutableTestUser()->getUser();
+ $user->incEditCount();
+
+ $reloadedUser = User::newFromId( $user->getId() );
+ $reloadedUser->incEditCount();
+
+ $this->assertEquals(
+ 2,
+ $reloadedUser->getEditCount(),
+ 'Increasing the edit count after a fresh load leaves the object up to date.'
+ );
+ }
+
+ /**
+ * Test changing user options.
+ * @covers User::setOption
+ * @covers User::getOption
+ */
+ public function testOptions() {
+ $user = $this->getMutableTestUser()->getUser();
+
+ $user->setOption( 'userjs-someoption', 'test' );
+ $user->setOption( 'rclimit', 200 );
+ $user->setOption( 'wpwatchlistdays', '0' );
+ $user->saveSettings();
+
+ $user = User::newFromName( $user->getName() );
+ $user->load( User::READ_LATEST );
+ $this->assertEquals( 'test', $user->getOption( 'userjs-someoption' ) );
+ $this->assertEquals( 200, $user->getOption( 'rclimit' ) );
+
+ $user = User::newFromName( $user->getName() );
+ MediaWikiServices::getInstance()->getMainWANObjectCache()->clearProcessCache();
+ $this->assertEquals( 'test', $user->getOption( 'userjs-someoption' ) );
+ $this->assertEquals( 200, $user->getOption( 'rclimit' ) );
+
+ // Check that an option saved as a string '0' is returned as an integer.
+ $user = User::newFromName( $user->getName() );
+ $user->load( User::READ_LATEST );
+ $this->assertSame( 0, $user->getOption( 'wpwatchlistdays' ) );
+ }
+
+ /**
+ * T39963
+ * Make sure defaults are loaded when setOption is called.
+ * @covers User::loadOptions
+ */
+ public function testAnonOptions() {
+ global $wgDefaultUserOptions;
+ $this->user->setOption( 'userjs-someoption', 'test' );
+ $this->assertEquals( $wgDefaultUserOptions['rclimit'], $this->user->getOption( 'rclimit' ) );
+ $this->assertEquals( 'test', $this->user->getOption( 'userjs-someoption' ) );
+ }
+
+ /**
+ * Test password validity checks. There are 3 checks in core,
+ * - ensure the password meets the minimal length
+ * - ensure the password is not the same as the username
+ * - ensure the username/password combo isn't forbidden
+ * @covers User::checkPasswordValidity()
+ * @covers User::getPasswordValidity()
+ * @covers User::isValidPassword()
+ */
+ public function testCheckPasswordValidity() {
+ $this->setMwGlobals( [
+ 'wgPasswordPolicy' => [
+ 'policies' => [
+ 'sysop' => [
+ 'MinimalPasswordLength' => 8,
+ 'MinimumPasswordLengthToLogin' => 1,
+ 'PasswordCannotMatchUsername' => 1,
+ ],
+ 'default' => [
+ 'MinimalPasswordLength' => 6,
+ 'PasswordCannotMatchUsername' => true,
+ 'PasswordCannotMatchBlacklist' => true,
+ 'MaximalPasswordLength' => 40,
+ ],
+ ],
+ 'checks' => [
+ 'MinimalPasswordLength' => 'PasswordPolicyChecks::checkMinimalPasswordLength',
+ 'MinimumPasswordLengthToLogin' => 'PasswordPolicyChecks::checkMinimumPasswordLengthToLogin',
+ 'PasswordCannotMatchUsername' => 'PasswordPolicyChecks::checkPasswordCannotMatchUsername',
+ 'PasswordCannotMatchBlacklist' => 'PasswordPolicyChecks::checkPasswordCannotMatchBlacklist',
+ 'MaximalPasswordLength' => 'PasswordPolicyChecks::checkMaximalPasswordLength',
+ ],
+ ],
+ ] );
+
+ $user = static::getTestUser()->getUser();
+
+ // Sanity
+ $this->assertTrue( $user->isValidPassword( 'Password1234' ) );
+
+ // Minimum length
+ $this->assertFalse( $user->isValidPassword( 'a' ) );
+ $this->assertFalse( $user->checkPasswordValidity( 'a' )->isGood() );
+ $this->assertTrue( $user->checkPasswordValidity( 'a' )->isOK() );
+ $this->assertEquals( 'passwordtooshort', $user->getPasswordValidity( 'a' ) );
+
+ // Maximum length
+ $longPass = str_repeat( 'a', 41 );
+ $this->assertFalse( $user->isValidPassword( $longPass ) );
+ $this->assertFalse( $user->checkPasswordValidity( $longPass )->isGood() );
+ $this->assertFalse( $user->checkPasswordValidity( $longPass )->isOK() );
+ $this->assertEquals( 'passwordtoolong', $user->getPasswordValidity( $longPass ) );
+
+ // Matches username
+ $this->assertFalse( $user->checkPasswordValidity( $user->getName() )->isGood() );
+ $this->assertTrue( $user->checkPasswordValidity( $user->getName() )->isOK() );
+ $this->assertEquals( 'password-name-match', $user->getPasswordValidity( $user->getName() ) );
+
+ // On the forbidden list
+ $user = User::newFromName( 'Useruser' );
+ $this->assertFalse( $user->checkPasswordValidity( 'Passpass' )->isGood() );
+ $this->assertEquals( 'password-login-forbidden', $user->getPasswordValidity( 'Passpass' ) );
+ }
+
+ /**
+ * @covers User::getCanonicalName()
+ * @dataProvider provideGetCanonicalName
+ */
+ public function testGetCanonicalName( $name, $expectedArray ) {
+ // fake interwiki map for the 'Interwiki prefix' testcase
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'InterwikiLoadPrefix' => [
+ function ( $prefix, &$iwdata ) {
+ if ( $prefix === 'interwiki' ) {
+ $iwdata = [
+ 'iw_url' => 'http://example.com/',
+ 'iw_local' => 0,
+ 'iw_trans' => 0,
+ ];
+ return false;
+ }
+ },
+ ],
+ ] );
+
+ foreach ( $expectedArray as $validate => $expected ) {
+ $this->assertEquals(
+ $expected,
+ User::getCanonicalName( $name, $validate === 'false' ? false : $validate ), $validate );
+ }
+ }
+
+ public static function provideGetCanonicalName() {
+ return [
+ 'Leading space' => [ ' Leading space', [ 'creatable' => 'Leading space' ] ],
+ 'Trailing space ' => [ 'Trailing space ', [ 'creatable' => 'Trailing space' ] ],
+ 'Namespace prefix' => [ 'Talk:Username', [ 'creatable' => false, 'usable' => false,
+ 'valid' => false, 'false' => 'Talk:Username' ] ],
+ 'Interwiki prefix' => [ 'interwiki:Username', [ 'creatable' => false, 'usable' => false,
+ 'valid' => false, 'false' => 'Interwiki:Username' ] ],
+ 'With hash' => [ 'name with # hash', [ 'creatable' => false, 'usable' => false ] ],
+ 'Multi spaces' => [ 'Multi spaces', [ 'creatable' => 'Multi spaces',
+ 'usable' => 'Multi spaces' ] ],
+ 'Lowercase' => [ 'lowercase', [ 'creatable' => 'Lowercase' ] ],
+ 'Invalid character' => [ 'in[]valid', [ 'creatable' => false, 'usable' => false,
+ 'valid' => false, 'false' => 'In[]valid' ] ],
+ 'With slash' => [ 'with / slash', [ 'creatable' => false, 'usable' => false, 'valid' => false,
+ 'false' => 'With / slash' ] ],
+ ];
+ }
+
+ /**
+ * @covers User::equals
+ */
+ public function testEquals() {
+ $first = $this->getMutableTestUser()->getUser();
+ $second = User::newFromName( $first->getName() );
+
+ $this->assertTrue( $first->equals( $first ) );
+ $this->assertTrue( $first->equals( $second ) );
+ $this->assertTrue( $second->equals( $first ) );
+
+ $third = $this->getMutableTestUser()->getUser();
+ $fourth = $this->getMutableTestUser()->getUser();
+
+ $this->assertFalse( $third->equals( $fourth ) );
+ $this->assertFalse( $fourth->equals( $third ) );
+
+ // Test users loaded from db with id
+ $user = $this->getMutableTestUser()->getUser();
+ $fifth = User::newFromId( $user->getId() );
+ $sixth = User::newFromName( $user->getName() );
+ $this->assertTrue( $fifth->equals( $sixth ) );
+ }
+
+ /**
+ * @covers User::getId
+ */
+ public function testGetId() {
+ $user = static::getTestUser()->getUser();
+ $this->assertTrue( $user->getId() > 0 );
+ }
+
+ /**
+ * @covers User::isLoggedIn
+ * @covers User::isAnon
+ */
+ public function testLoggedIn() {
+ $user = $this->getMutableTestUser()->getUser();
+ $this->assertTrue( $user->isLoggedIn() );
+ $this->assertFalse( $user->isAnon() );
+
+ // Non-existent users are perceived as anonymous
+ $user = User::newFromName( 'UTNonexistent' );
+ $this->assertFalse( $user->isLoggedIn() );
+ $this->assertTrue( $user->isAnon() );
+
+ $user = new User;
+ $this->assertFalse( $user->isLoggedIn() );
+ $this->assertTrue( $user->isAnon() );
+ }
+
+ /**
+ * @covers User::checkAndSetTouched
+ */
+ public function testCheckAndSetTouched() {
+ $user = $this->getMutableTestUser()->getUser();
+ $user = TestingAccessWrapper::newFromObject( $user );
+ $this->assertTrue( $user->isLoggedIn() );
+
+ $touched = $user->getDBTouched();
+ $this->assertTrue(
+ $user->checkAndSetTouched(), "checkAndSetTouched() succeded" );
+ $this->assertGreaterThan(
+ $touched, $user->getDBTouched(), "user_touched increased with casOnTouched()" );
+
+ $touched = $user->getDBTouched();
+ $this->assertTrue(
+ $user->checkAndSetTouched(), "checkAndSetTouched() succeded #2" );
+ $this->assertGreaterThan(
+ $touched, $user->getDBTouched(), "user_touched increased with casOnTouched() #2" );
+ }
+
+ /**
+ * @covers User::findUsersByGroup
+ */
+ public function testFindUsersByGroup() {
+ $users = User::findUsersByGroup( [] );
+ $this->assertEquals( 0, iterator_count( $users ) );
+
+ $users = User::findUsersByGroup( 'foo' );
+ $this->assertEquals( 0, iterator_count( $users ) );
+
+ $user = $this->getMutableTestUser( [ 'foo' ] )->getUser();
+ $users = User::findUsersByGroup( 'foo' );
+ $this->assertEquals( 1, iterator_count( $users ) );
+ $users->rewind();
+ $this->assertTrue( $user->equals( $users->current() ) );
+
+ // arguments have OR relationship
+ $user2 = $this->getMutableTestUser( [ 'bar' ] )->getUser();
+ $users = User::findUsersByGroup( [ 'foo', 'bar' ] );
+ $this->assertEquals( 2, iterator_count( $users ) );
+ $users->rewind();
+ $this->assertTrue( $user->equals( $users->current() ) );
+ $users->next();
+ $this->assertTrue( $user2->equals( $users->current() ) );
+
+ // users are not duplicated
+ $user = $this->getMutableTestUser( [ 'baz', 'boom' ] )->getUser();
+ $users = User::findUsersByGroup( [ 'baz', 'boom' ] );
+ $this->assertEquals( 1, iterator_count( $users ) );
+ $users->rewind();
+ $this->assertTrue( $user->equals( $users->current() ) );
+ }
+
+ /**
+ * When a user is autoblocked a cookie is set with which to track them
+ * in case they log out and change IP addresses.
+ * @link https://phabricator.wikimedia.org/T5233
+ */
+ public function testAutoblockCookies() {
+ // Set up the bits of global configuration that we use.
+ $this->setMwGlobals( [
+ 'wgCookieSetOnAutoblock' => true,
+ 'wgCookiePrefix' => 'wmsitetitle',
+ 'wgSecretKey' => MWCryptRand::generateHex( 64, true ),
+ ] );
+
+ // Unregister the hooks for proper unit testing
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'PerformRetroactiveAutoblock' => []
+ ] );
+
+ // 1. Log in a test user, and block them.
+ $userBlocker = $this->getTestSysop()->getUser();
+ $user1tmp = $this->getTestUser()->getUser();
+ $request1 = new FauxRequest();
+ $request1->getSession()->setUser( $user1tmp );
+ $expiryFiveHours = wfTimestamp() + ( 5 * 60 * 60 );
+ $block = new Block( [
+ 'enableAutoblock' => true,
+ 'expiry' => wfTimestamp( TS_MW, $expiryFiveHours ),
+ ] );
+ $block->setBlocker( $this->getTestSysop()->getUser() );
+ $block->setTarget( $user1tmp );
+ $block->setBlocker( $userBlocker );
+ $res = $block->insert();
+ $this->assertTrue( (bool)$res['id'], 'Failed to insert block' );
+ $user1 = User::newFromSession( $request1 );
+ $user1->mBlock = $block;
+ $user1->load();
+
+ // Confirm that the block has been applied as required.
+ $this->assertTrue( $user1->isLoggedIn() );
+ $this->assertTrue( $user1->isBlocked() );
+ $this->assertEquals( Block::TYPE_USER, $block->getType() );
+ $this->assertTrue( $block->isAutoblocking() );
+ $this->assertGreaterThanOrEqual( 1, $block->getId() );
+
+ // Test for the desired cookie name, value, and expiry.
+ $cookies = $request1->response()->getCookies();
+ $this->assertArrayHasKey( 'wmsitetitleBlockID', $cookies );
+ $this->assertEquals( $expiryFiveHours, $cookies['wmsitetitleBlockID']['expire'] );
+ $cookieValue = Block::getIdFromCookieValue( $cookies['wmsitetitleBlockID']['value'] );
+ $this->assertEquals( $block->getId(), $cookieValue );
+
+ // 2. Create a new request, set the cookies, and see if the (anon) user is blocked.
+ $request2 = new FauxRequest();
+ $request2->setCookie( 'BlockID', $block->getCookieValue() );
+ $user2 = User::newFromSession( $request2 );
+ $user2->load();
+ $this->assertNotEquals( $user1->getId(), $user2->getId() );
+ $this->assertNotEquals( $user1->getToken(), $user2->getToken() );
+ $this->assertTrue( $user2->isAnon() );
+ $this->assertFalse( $user2->isLoggedIn() );
+ $this->assertTrue( $user2->isBlocked() );
+ // Non-strict type-check.
+ $this->assertEquals( true, $user2->getBlock()->isAutoblocking(), 'Autoblock does not work' );
+ // Can't directly compare the objects becuase of member type differences.
+ // One day this will work: $this->assertEquals( $block, $user2->getBlock() );
+ $this->assertEquals( $block->getId(), $user2->getBlock()->getId() );
+ $this->assertEquals( $block->getExpiry(), $user2->getBlock()->getExpiry() );
+
+ // 3. Finally, set up a request as a new user, and the block should still be applied.
+ $user3tmp = $this->getTestUser()->getUser();
+ $request3 = new FauxRequest();
+ $request3->getSession()->setUser( $user3tmp );
+ $request3->setCookie( 'BlockID', $block->getId() );
+ $user3 = User::newFromSession( $request3 );
+ $user3->load();
+ $this->assertTrue( $user3->isLoggedIn() );
+ $this->assertTrue( $user3->isBlocked() );
+ $this->assertEquals( true, $user3->getBlock()->isAutoblocking() ); // Non-strict type-check.
+
+ // Clean up.
+ $block->delete();
+ }
+
+ /**
+ * Make sure that no cookie is set to track autoblocked users
+ * when $wgCookieSetOnAutoblock is false.
+ */
+ public function testAutoblockCookiesDisabled() {
+ // Set up the bits of global configuration that we use.
+ $this->setMwGlobals( [
+ 'wgCookieSetOnAutoblock' => false,
+ 'wgCookiePrefix' => 'wm_no_cookies',
+ 'wgSecretKey' => MWCryptRand::generateHex( 64, true ),
+ ] );
+
+ // Unregister the hooks for proper unit testing
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'PerformRetroactiveAutoblock' => []
+ ] );
+
+ // 1. Log in a test user, and block them.
+ $userBlocker = $this->getTestSysop()->getUser();
+ $testUser = $this->getTestUser()->getUser();
+ $request1 = new FauxRequest();
+ $request1->getSession()->setUser( $testUser );
+ $block = new Block( [ 'enableAutoblock' => true ] );
+ $block->setBlocker( $this->getTestSysop()->getUser() );
+ $block->setTarget( $testUser );
+ $block->setBlocker( $userBlocker );
+ $res = $block->insert();
+ $this->assertTrue( (bool)$res['id'], 'Failed to insert block' );
+ $user = User::newFromSession( $request1 );
+ $user->mBlock = $block;
+ $user->load();
+
+ // 2. Test that the cookie IS NOT present.
+ $this->assertTrue( $user->isLoggedIn() );
+ $this->assertTrue( $user->isBlocked() );
+ $this->assertEquals( Block::TYPE_USER, $block->getType() );
+ $this->assertTrue( $block->isAutoblocking() );
+ $this->assertGreaterThanOrEqual( 1, $user->getBlockId() );
+ $this->assertGreaterThanOrEqual( $block->getId(), $user->getBlockId() );
+ $cookies = $request1->response()->getCookies();
+ $this->assertArrayNotHasKey( 'wm_no_cookiesBlockID', $cookies );
+
+ // Clean up.
+ $block->delete();
+ }
+
+ /**
+ * When a user is autoblocked and a cookie is set to track them, the expiry time of the cookie
+ * should match the block's expiry, to a maximum of 24 hours. If the expiry time is changed,
+ * the cookie's should change with it.
+ */
+ public function testAutoblockCookieInfiniteExpiry() {
+ $this->setMwGlobals( [
+ 'wgCookieSetOnAutoblock' => true,
+ 'wgCookiePrefix' => 'wm_infinite_block',
+ 'wgSecretKey' => MWCryptRand::generateHex( 64, true ),
+ ] );
+
+ // Unregister the hooks for proper unit testing
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'PerformRetroactiveAutoblock' => []
+ ] );
+
+ // 1. Log in a test user, and block them indefinitely.
+ $userBlocker = $this->getTestSysop()->getUser();
+ $user1Tmp = $this->getTestUser()->getUser();
+ $request1 = new FauxRequest();
+ $request1->getSession()->setUser( $user1Tmp );
+ $block = new Block( [ 'enableAutoblock' => true, 'expiry' => 'infinity' ] );
+ $block->setBlocker( $this->getTestSysop()->getUser() );
+ $block->setTarget( $user1Tmp );
+ $block->setBlocker( $userBlocker );
+ $res = $block->insert();
+ $this->assertTrue( (bool)$res['id'], 'Failed to insert block' );
+ $user1 = User::newFromSession( $request1 );
+ $user1->mBlock = $block;
+ $user1->load();
+
+ // 2. Test the cookie's expiry timestamp.
+ $this->assertTrue( $user1->isLoggedIn() );
+ $this->assertTrue( $user1->isBlocked() );
+ $this->assertEquals( Block::TYPE_USER, $block->getType() );
+ $this->assertTrue( $block->isAutoblocking() );
+ $this->assertGreaterThanOrEqual( 1, $user1->getBlockId() );
+ $cookies = $request1->response()->getCookies();
+ // Test the cookie's expiry to the nearest minute.
+ $this->assertArrayHasKey( 'wm_infinite_blockBlockID', $cookies );
+ $expOneDay = wfTimestamp() + ( 24 * 60 * 60 );
+ // Check for expiry dates in a 10-second window, to account for slow testing.
+ $this->assertEquals(
+ $expOneDay,
+ $cookies['wm_infinite_blockBlockID']['expire'],
+ 'Expiry date',
+ 5.0
+ );
+
+ // 3. Change the block's expiry (to 2 hours), and the cookie's should be changed also.
+ $newExpiry = wfTimestamp() + 2 * 60 * 60;
+ $block->mExpiry = wfTimestamp( TS_MW, $newExpiry );
+ $block->update();
+ $user2tmp = $this->getTestUser()->getUser();
+ $request2 = new FauxRequest();
+ $request2->getSession()->setUser( $user2tmp );
+ $user2 = User::newFromSession( $request2 );
+ $user2->mBlock = $block;
+ $user2->load();
+ $cookies = $request2->response()->getCookies();
+ $this->assertEquals( wfTimestamp( TS_MW, $newExpiry ), $block->getExpiry() );
+ $this->assertEquals( $newExpiry, $cookies['wm_infinite_blockBlockID']['expire'] );
+
+ // Clean up.
+ $block->delete();
+ }
+
+ public function testSoftBlockRanges() {
+ $setSessionUser = function ( User $user, WebRequest $request ) {
+ $this->setMwGlobals( 'wgUser', $user );
+ RequestContext::getMain()->setUser( $user );
+ RequestContext::getMain()->setRequest( $request );
+ TestingAccessWrapper::newFromObject( $user )->mRequest = $request;
+ $request->getSession()->setUser( $user );
+ };
+ $this->setMwGlobals( 'wgSoftBlockRanges', [ '10.0.0.0/8' ] );
+
+ // IP isn't in $wgSoftBlockRanges
+ $wgUser = new User();
+ $request = new FauxRequest();
+ $request->setIP( '192.168.0.1' );
+ $setSessionUser( $wgUser, $request );
+ $this->assertNull( $wgUser->getBlock() );
+
+ // IP is in $wgSoftBlockRanges
+ $wgUser = new User();
+ $request = new FauxRequest();
+ $request->setIP( '10.20.30.40' );
+ $setSessionUser( $wgUser, $request );
+ $block = $wgUser->getBlock();
+ $this->assertInstanceOf( Block::class, $block );
+ $this->assertSame( 'wgSoftBlockRanges', $block->getSystemBlockType() );
+
+ // Make sure the block is really soft
+ $wgUser = $this->getTestUser()->getUser();
+ $request = new FauxRequest();
+ $request->setIP( '10.20.30.40' );
+ $setSessionUser( $wgUser, $request );
+ $this->assertFalse( $wgUser->isAnon(), 'sanity check' );
+ $this->assertNull( $wgUser->getBlock() );
+ }
+
+ /**
+ * Test that a modified BlockID cookie doesn't actually load the relevant block (T152951).
+ */
+ public function testAutoblockCookieInauthentic() {
+ // Set up the bits of global configuration that we use.
+ $this->setMwGlobals( [
+ 'wgCookieSetOnAutoblock' => true,
+ 'wgCookiePrefix' => 'wmsitetitle',
+ 'wgSecretKey' => MWCryptRand::generateHex( 64, true ),
+ ] );
+
+ // Unregister the hooks for proper unit testing
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'PerformRetroactiveAutoblock' => []
+ ] );
+
+ // 1. Log in a blocked test user.
+ $userBlocker = $this->getTestSysop()->getUser();
+ $user1tmp = $this->getTestUser()->getUser();
+ $request1 = new FauxRequest();
+ $request1->getSession()->setUser( $user1tmp );
+ $block = new Block( [ 'enableAutoblock' => true ] );
+ $block->setBlocker( $this->getTestSysop()->getUser() );
+ $block->setTarget( $user1tmp );
+ $block->setBlocker( $userBlocker );
+ $res = $block->insert();
+ $this->assertTrue( (bool)$res['id'], 'Failed to insert block' );
+ $user1 = User::newFromSession( $request1 );
+ $user1->mBlock = $block;
+ $user1->load();
+
+ // 2. Create a new request, set the cookie to an invalid value, and make sure the (anon)
+ // user not blocked.
+ $request2 = new FauxRequest();
+ $request2->setCookie( 'BlockID', $block->getId() . '!zzzzzzz' );
+ $user2 = User::newFromSession( $request2 );
+ $user2->load();
+ $this->assertTrue( $user2->isAnon() );
+ $this->assertFalse( $user2->isLoggedIn() );
+ $this->assertFalse( $user2->isBlocked() );
+
+ // Clean up.
+ $block->delete();
+ }
+
+ /**
+ * The BlockID cookie is normally verified with a HMAC, but not if wgSecretKey is not set.
+ * This checks that a non-authenticated cookie still works.
+ */
+ public function testAutoblockCookieNoSecretKey() {
+ // Set up the bits of global configuration that we use.
+ $this->setMwGlobals( [
+ 'wgCookieSetOnAutoblock' => true,
+ 'wgCookiePrefix' => 'wmsitetitle',
+ 'wgSecretKey' => null,
+ ] );
+
+ // Unregister the hooks for proper unit testing
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'PerformRetroactiveAutoblock' => []
+ ] );
+
+ // 1. Log in a blocked test user.
+ $userBlocker = $this->getTestSysop()->getUser();
+ $user1tmp = $this->getTestUser()->getUser();
+ $request1 = new FauxRequest();
+ $request1->getSession()->setUser( $user1tmp );
+ $block = new Block( [ 'enableAutoblock' => true ] );
+ $block->setBlocker( $this->getTestSysop()->getUser() );
+ $block->setTarget( $user1tmp );
+ $block->setBlocker( $userBlocker );
+ $res = $block->insert();
+ $this->assertTrue( (bool)$res['id'], 'Failed to insert block' );
+ $user1 = User::newFromSession( $request1 );
+ $user1->mBlock = $block;
+ $user1->load();
+ $this->assertTrue( $user1->isBlocked() );
+
+ // 2. Create a new request, set the cookie to just the block ID, and the user should
+ // still get blocked when they log in again.
+ $request2 = new FauxRequest();
+ $request2->setCookie( 'BlockID', $block->getId() );
+ $user2 = User::newFromSession( $request2 );
+ $user2->load();
+ $this->assertNotEquals( $user1->getId(), $user2->getId() );
+ $this->assertNotEquals( $user1->getToken(), $user2->getToken() );
+ $this->assertTrue( $user2->isAnon() );
+ $this->assertFalse( $user2->isLoggedIn() );
+ $this->assertTrue( $user2->isBlocked() );
+ $this->assertEquals( true, $user2->getBlock()->isAutoblocking() ); // Non-strict type-check.
+
+ // Clean up.
+ $block->delete();
+ }
+
+ /**
+ * @covers User::isPingLimitable
+ */
+ public function testIsPingLimitable() {
+ $request = new FauxRequest();
+ $request->setIP( '1.2.3.4' );
+ $user = User::newFromSession( $request );
+
+ $this->setMwGlobals( 'wgRateLimitsExcludedIPs', [] );
+ $this->assertTrue( $user->isPingLimitable() );
+
+ $this->setMwGlobals( 'wgRateLimitsExcludedIPs', [ '1.2.3.4' ] );
+ $this->assertFalse( $user->isPingLimitable() );
+
+ $this->setMwGlobals( 'wgRateLimitsExcludedIPs', [ '1.2.3.0/8' ] );
+ $this->assertFalse( $user->isPingLimitable() );
+
+ $this->setMwGlobals( 'wgRateLimitsExcludedIPs', [] );
+ $noRateLimitUser = $this->getMockBuilder( User::class )->disableOriginalConstructor()
+ ->setMethods( [ 'getIP', 'getRights' ] )->getMock();
+ $noRateLimitUser->expects( $this->any() )->method( 'getIP' )->willReturn( '1.2.3.4' );
+ $noRateLimitUser->expects( $this->any() )->method( 'getRights' )->willReturn( [ 'noratelimit' ] );
+ $this->assertFalse( $noRateLimitUser->isPingLimitable() );
+ }
+
+ public function provideExperienceLevel() {
+ return [
+ [ 2, 2, 'newcomer' ],
+ [ 12, 3, 'newcomer' ],
+ [ 8, 5, 'newcomer' ],
+ [ 15, 10, 'learner' ],
+ [ 450, 20, 'learner' ],
+ [ 460, 33, 'learner' ],
+ [ 525, 28, 'learner' ],
+ [ 538, 33, 'experienced' ],
+ ];
+ }
+
+ /**
+ * @covers User::getExperienceLevel
+ * @dataProvider provideExperienceLevel
+ */
+ public function testExperienceLevel( $editCount, $memberSince, $expLevel ) {
+ $this->setMwGlobals( [
+ 'wgLearnerEdits' => 10,
+ 'wgLearnerMemberSince' => 4,
+ 'wgExperiencedUserEdits' => 500,
+ 'wgExperiencedUserMemberSince' => 30,
+ ] );
+
+ $db = wfGetDB( DB_MASTER );
+ $userQuery = User::getQueryInfo();
+ $row = $db->selectRow(
+ $userQuery['tables'],
+ $userQuery['fields'],
+ [ 'user_id' => $this->getTestUser()->getUser()->getId() ],
+ __METHOD__,
+ [],
+ $userQuery['joins']
+ );
+ $row->user_editcount = $editCount;
+ $row->user_registration = $db->timestamp( time() - $memberSince * 86400 );
+ $user = User::newFromRow( $row );
+
+ $this->assertEquals( $expLevel, $user->getExperienceLevel() );
+ }
+
+ /**
+ * @covers User::getExperienceLevel
+ */
+ public function testExperienceLevelAnon() {
+ $user = User::newFromName( '10.11.12.13', false );
+
+ $this->assertFalse( $user->getExperienceLevel() );
+ }
+
+ public static function provideIsLocallBlockedProxy() {
+ return [
+ [ '1.2.3.4', '1.2.3.4' ],
+ [ '1.2.3.4', '1.2.3.0/16' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsLocallBlockedProxy
+ * @covers User::isLocallyBlockedProxy
+ */
+ public function testIsLocallyBlockedProxy( $ip, $blockListEntry ) {
+ $this->setMwGlobals(
+ 'wgProxyList', []
+ );
+ $this->assertFalse( User::isLocallyBlockedProxy( $ip ) );
+
+ $this->setMwGlobals(
+ 'wgProxyList',
+ [
+ $blockListEntry
+ ]
+ );
+ $this->assertTrue( User::isLocallyBlockedProxy( $ip ) );
+
+ $this->setMwGlobals(
+ 'wgProxyList',
+ [
+ 'test' => $blockListEntry
+ ]
+ );
+ $this->assertTrue( User::isLocallyBlockedProxy( $ip ) );
+
+ $this->hideDeprecated(
+ 'IP addresses in the keys of $wgProxyList (found the following IP ' .
+ 'addresses in keys: ' . $blockListEntry . ', please move them to values)'
+ );
+ $this->setMwGlobals(
+ 'wgProxyList',
+ [
+ $blockListEntry => 'test'
+ ]
+ );
+ $this->assertTrue( User::isLocallyBlockedProxy( $ip ) );
+ }
+
+ public function testActorId() {
+ $this->hideDeprecated( 'User::selectFields' );
+
+ // Newly-created user has an actor ID
+ $user = User::createNew( 'UserTestActorId1' );
+ $id = $user->getId();
+ $this->assertTrue( $user->getActorId() > 0, 'User::createNew sets an actor ID' );
+
+ $user = User::newFromName( 'UserTestActorId2' );
+ $user->addToDatabase();
+ $this->assertTrue( $user->getActorId() > 0, 'User::addToDatabase sets an actor ID' );
+
+ $user = User::newFromName( 'UserTestActorId1' );
+ $this->assertTrue( $user->getActorId() > 0, 'Actor ID can be retrieved for user loaded by name' );
+
+ $user = User::newFromId( $id );
+ $this->assertTrue( $user->getActorId() > 0, 'Actor ID can be retrieved for user loaded by ID' );
+
+ $user2 = User::newFromActorId( $user->getActorId() );
+ $this->assertEquals( $user->getId(), $user2->getId(),
+ 'User::newFromActorId works for an existing user' );
+
+ $row = $this->db->selectRow( 'user', User::selectFields(), [ 'user_id' => $id ], __METHOD__ );
+ $user = User::newFromRow( $row );
+ $this->assertTrue( $user->getActorId() > 0,
+ 'Actor ID can be retrieved for user loaded with User::selectFields()' );
+
+ $this->db->delete( 'actor', [ 'actor_user' => $id ], __METHOD__ );
+ User::purge( wfWikiId(), $id );
+ // Because WANObjectCache->delete() stupidly doesn't delete from the process cache.
+ ObjectCache::getMainWANInstance()->clearProcessCache();
+
+ $user = User::newFromId( $id );
+ $this->assertFalse( $user->getActorId() > 0, 'No Actor ID by default if none in database' );
+ $this->assertTrue( $user->getActorId( $this->db ) > 0, 'Actor ID can be created if none in db' );
+
+ $user->setName( 'UserTestActorId4-renamed' );
+ $user->saveSettings();
+ $this->assertEquals(
+ $user->getName(),
+ $this->db->selectField(
+ 'actor', 'actor_name', [ 'actor_id' => $user->getActorId() ], __METHOD__
+ ),
+ 'User::saveSettings updates actor table for name change'
+ );
+
+ // For sanity
+ $ip = '192.168.12.34';
+ $this->db->delete( 'actor', [ 'actor_name' => $ip ], __METHOD__ );
+
+ $user = User::newFromName( $ip, false );
+ $this->assertFalse( $user->getActorId() > 0, 'Anonymous user has no actor ID by default' );
+ $this->assertTrue( $user->getActorId( $this->db ) > 0,
+ 'Actor ID can be created for an anonymous user' );
+
+ $user = User::newFromName( $ip, false );
+ $this->assertTrue( $user->getActorId() > 0, 'Actor ID can be loaded for an anonymous user' );
+ $user2 = User::newFromActorId( $user->getActorId() );
+ $this->assertEquals( $user->getName(), $user2->getName(),
+ 'User::newFromActorId works for an anonymous user' );
+ }
+
+ public function testNewFromAnyId() {
+ // Registered user
+ $user = $this->getTestUser()->getUser();
+ for ( $i = 1; $i <= 7; $i++ ) {
+ $test = User::newFromAnyId(
+ ( $i & 1 ) ? $user->getId() : null,
+ ( $i & 2 ) ? $user->getName() : null,
+ ( $i & 4 ) ? $user->getActorId() : null
+ );
+ $this->assertSame( $user->getId(), $test->getId() );
+ $this->assertSame( $user->getName(), $test->getName() );
+ $this->assertSame( $user->getActorId(), $test->getActorId() );
+ }
+
+ // Anon user. Can't load by only user ID when that's 0.
+ $user = User::newFromName( '192.168.12.34', false );
+ $user->getActorId( $this->db ); // Make sure an actor ID exists
+
+ $test = User::newFromAnyId( null, '192.168.12.34', null );
+ $this->assertSame( $user->getId(), $test->getId() );
+ $this->assertSame( $user->getName(), $test->getName() );
+ $this->assertSame( $user->getActorId(), $test->getActorId() );
+ $test = User::newFromAnyId( null, null, $user->getActorId() );
+ $this->assertSame( $user->getId(), $test->getId() );
+ $this->assertSame( $user->getName(), $test->getName() );
+ $this->assertSame( $user->getActorId(), $test->getActorId() );
+
+ // Bogus data should still "work" as long as nothing triggers a ->load(),
+ // and accessing the specified data shouldn't do that.
+ $test = User::newFromAnyId( 123456, 'Bogus', 654321 );
+ $this->assertSame( 123456, $test->getId() );
+ $this->assertSame( 'Bogus', $test->getName() );
+ $this->assertSame( 654321, $test->getActorId() );
+
+ // Exceptional cases
+ try {
+ User::newFromAnyId( null, null, null );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ }
+ try {
+ User::newFromAnyId( 0, null, 0 );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ }
+ }
+
+ /**
+ * @covers User::getBlockedStatus
+ * @covers User::getBlock
+ * @covers User::blockedBy
+ * @covers User::blockedFor
+ * @covers User::isHidden
+ * @covers User::isBlockedFrom
+ */
+ public function testBlockInstanceCache() {
+ // First, check the user isn't blocked
+ $user = $this->getMutableTestUser()->getUser();
+ $ut = Title::makeTitle( NS_USER_TALK, $user->getName() );
+ $this->assertNull( $user->getBlock( false ), 'sanity check' );
+ $this->assertSame( '', $user->blockedBy(), 'sanity check' );
+ $this->assertSame( '', $user->blockedFor(), 'sanity check' );
+ $this->assertFalse( (bool)$user->isHidden(), 'sanity check' );
+ $this->assertFalse( $user->isBlockedFrom( $ut ), 'sanity check' );
+
+ // Block the user
+ $blocker = $this->getTestSysop()->getUser();
+ $block = new Block( [
+ 'hideName' => true,
+ 'allowUsertalk' => false,
+ 'reason' => 'Because',
+ ] );
+ $block->setTarget( $user );
+ $block->setBlocker( $blocker );
+ $res = $block->insert();
+ $this->assertTrue( (bool)$res['id'], 'sanity check: Failed to insert block' );
+
+ // Clear cache and confirm it loaded the block properly
+ $user->clearInstanceCache();
+ $this->assertInstanceOf( Block::class, $user->getBlock( false ) );
+ $this->assertSame( $blocker->getName(), $user->blockedBy() );
+ $this->assertSame( 'Because', $user->blockedFor() );
+ $this->assertTrue( (bool)$user->isHidden() );
+ $this->assertTrue( $user->isBlockedFrom( $ut ) );
+
+ // Unblock
+ $block->delete();
+
+ // Clear cache and confirm it loaded the not-blocked properly
+ $user->clearInstanceCache();
+ $this->assertNull( $user->getBlock( false ) );
+ $this->assertSame( '', $user->blockedBy() );
+ $this->assertSame( '', $user->blockedFor() );
+ $this->assertFalse( (bool)$user->isHidden() );
+ $this->assertFalse( $user->isBlockedFrom( $ut ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/utils/AvroValidatorTest.php b/www/wiki/tests/phpunit/includes/utils/AvroValidatorTest.php
new file mode 100644
index 00000000..cf45f9fd
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/utils/AvroValidatorTest.php
@@ -0,0 +1,118 @@
+<?php
+/**
+ * Tests for IP validity functions.
+ *
+ * Ported from /t/inc/IP.t by avar.
+ *
+ * @todo Test methods in this call should be split into a method and a
+ * dataprovider.
+ */
+
+/**
+ * @group IP
+ * @covers AvroValidator
+ */
+class AvroValidatorTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function setUp() {
+ if ( !class_exists( 'AvroSchema' ) ) {
+ $this->markTestSkipped( 'Avro is required to run the AvroValidatorTest' );
+ }
+ parent::setUp();
+ }
+
+ public function getErrorsProvider() {
+ $stringSchema = AvroSchema::parse( json_encode( [ 'type' => 'string' ] ) );
+ $stringArraySchema = AvroSchema::parse( json_encode( [
+ 'type' => 'array',
+ 'items' => 'string',
+ ] ) );
+ $recordSchema = AvroSchema::parse( json_encode( [
+ 'type' => 'record',
+ 'name' => 'ut',
+ 'fields' => [
+ [ 'name' => 'id', 'type' => 'int', 'required' => true ],
+ ],
+ ] ) );
+ $enumSchema = AvroSchema::parse( json_encode( [
+ 'type' => 'record',
+ 'name' => 'ut',
+ 'fields' => [
+ [ 'name' => 'count', 'type' => [ 'int', 'null' ] ],
+ ],
+ ] ) );
+
+ return [
+ [
+ 'No errors with a simple string serialization',
+ $stringSchema, 'foobar', [],
+ ],
+
+ [
+ 'Cannot serialize integer into string',
+ $stringSchema, 5, 'Expected string, but recieved integer',
+ ],
+
+ [
+ 'Cannot serialize array into string',
+ $stringSchema, [], 'Expected string, but recieved array',
+ ],
+
+ [
+ 'allows and ignores extra fields',
+ $recordSchema, [ 'id' => 4, 'foo' => 'bar' ], [],
+ ],
+
+ [
+ 'detects missing fields',
+ $recordSchema, [], [ 'id' => 'Missing expected field' ],
+ ],
+
+ [
+ 'handles first element in enum',
+ $enumSchema, [ 'count' => 4 ], [],
+ ],
+
+ [
+ 'handles second element in enum',
+ $enumSchema, [ 'count' => null ], [],
+ ],
+
+ [
+ 'rejects element not in union',
+ $enumSchema, [ 'count' => 'invalid' ], [ 'count' => [
+ 'Expected any one of these to be true',
+ [
+ 'Expected integer, but recieved string',
+ 'Expected null, but recieved string',
+ ]
+ ] ]
+ ],
+ [
+ 'Empty array is accepted',
+ $stringArraySchema, [], []
+ ],
+ [
+ 'correct array element accepted',
+ $stringArraySchema, [ 'fizzbuzz' ], []
+ ],
+ [
+ 'incorrect array element rejected',
+ $stringArraySchema, [ '12', 34 ], [ 'Expected string, but recieved integer' ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider getErrorsProvider
+ */
+ public function testGetErrors( $message, $schema, $datum, $expected ) {
+ $this->assertEquals(
+ $expected,
+ AvroValidator::getErrors( $schema, $datum ),
+ $message
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/utils/BatchRowUpdateTest.php b/www/wiki/tests/phpunit/includes/utils/BatchRowUpdateTest.php
new file mode 100644
index 00000000..52b14339
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/utils/BatchRowUpdateTest.php
@@ -0,0 +1,252 @@
+<?php
+
+/**
+ * Tests for BatchRowUpdate and its components
+ *
+ * @group db
+ *
+ * @covers BatchRowUpdate
+ * @covers BatchRowIterator
+ * @covers BatchRowWriter
+ */
+class BatchRowUpdateTest extends MediaWikiTestCase {
+
+ public function testWriterBasicFunctionality() {
+ $db = $this->mockDb( [ 'update' ] );
+ $writer = new BatchRowWriter( $db, 'echo_event' );
+
+ $updates = [
+ self::mockUpdate( [ 'something' => 'changed' ] ),
+ self::mockUpdate( [ 'otherthing' => 'changed' ] ),
+ self::mockUpdate( [ 'and' => 'something', 'else' => 'changed' ] ),
+ ];
+
+ $db->expects( $this->exactly( count( $updates ) ) )
+ ->method( 'update' );
+
+ $writer->write( $updates );
+ }
+
+ protected static function mockUpdate( array $changes ) {
+ static $i = 0;
+ return [
+ 'primaryKey' => [ 'event_id' => $i++ ],
+ 'changes' => $changes,
+ ];
+ }
+
+ public function testReaderBasicIterate() {
+ $batchSize = 2;
+ $response = $this->genSelectResult( $batchSize, /*numRows*/ 5, function () {
+ static $i = 0;
+ return [ 'id_field' => ++$i ];
+ } );
+ $db = $this->mockDbConsecutiveSelect( $response );
+ $reader = new BatchRowIterator( $db, 'some_table', 'id_field', $batchSize );
+
+ $pos = 0;
+ foreach ( $reader as $rows ) {
+ $this->assertEquals( $response[$pos], $rows, "Testing row in position $pos" );
+ $pos++;
+ }
+ // -1 is because the final array() marks the end and isnt included
+ $this->assertEquals( count( $response ) - 1, $pos );
+ }
+
+ public static function provider_readerGetPrimaryKey() {
+ $row = [
+ 'id_field' => 42,
+ 'some_col' => 'dvorak',
+ 'other_col' => 'samurai',
+ ];
+ return [
+
+ [
+ 'Must return single column pk when requested',
+ [ 'id_field' => 42 ],
+ $row
+ ],
+
+ [
+ 'Must return multiple column pks when requested',
+ [ 'id_field' => 42, 'other_col' => 'samurai' ],
+ $row
+ ],
+
+ ];
+ }
+
+ /**
+ * @dataProvider provider_readerGetPrimaryKey
+ */
+ public function testReaderGetPrimaryKey( $message, array $expected, array $row ) {
+ $reader = new BatchRowIterator( $this->mockDb(), 'some_table', array_keys( $expected ), 8675309 );
+ $this->assertEquals( $expected, $reader->extractPrimaryKeys( (object)$row ), $message );
+ }
+
+ public static function provider_readerSetFetchColumns() {
+ return [
+
+ [
+ 'Must merge primary keys into select conditions',
+ // Expected column select
+ [ 'foo', 'bar' ],
+ // primary keys
+ [ 'foo' ],
+ // setFetchColumn
+ [ 'bar' ]
+ ],
+
+ [
+ 'Must not merge primary keys into the all columns selector',
+ // Expected column select
+ [ '*' ],
+ // primary keys
+ [ 'foo' ],
+ // setFetchColumn
+ [ '*' ],
+ ],
+
+ [
+ 'Must not duplicate primary keys into column selector',
+ // Expected column select.
+ // TODO: figure out how to only assert the array_values portion and not the keys
+ [ 0 => 'foo', 1 => 'bar', 3 => 'baz' ],
+ // primary keys
+ [ 'foo', 'bar', ],
+ // setFetchColumn
+ [ 'bar', 'baz' ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provider_readerSetFetchColumns
+ */
+ public function testReaderSetFetchColumns(
+ $message, array $columns, array $primaryKeys, array $fetchColumns
+ ) {
+ $db = $this->mockDb( [ 'select' ] );
+ $db->expects( $this->once() )
+ ->method( 'select' )
+ // only testing second parameter of Database::select
+ ->with( 'some_table', $columns )
+ ->will( $this->returnValue( new ArrayIterator( [] ) ) );
+
+ $reader = new BatchRowIterator( $db, 'some_table', $primaryKeys, 22 );
+ $reader->setFetchColumns( $fetchColumns );
+ // triggers first database select
+ $reader->rewind();
+ }
+
+ public static function provider_readerSelectConditions() {
+ return [
+
+ [
+ "With single primary key must generate id > 'value'",
+ // Expected second iteration
+ [ "( id_field > '3' )" ],
+ // Primary key(s)
+ 'id_field',
+ ],
+
+ [
+ 'With multiple primary keys the first conditions ' .
+ 'must use >= and the final condition must use >',
+ // Expected second iteration
+ [ "( id_field = '3' AND foo > '103' ) OR ( id_field > '3' )" ],
+ // Primary key(s)
+ [ 'id_field', 'foo' ],
+ ],
+
+ ];
+ }
+
+ /**
+ * Slightly hackish to use reflection, but asserting different parameters
+ * to consecutive calls of Database::select in phpunit is error prone
+ *
+ * @dataProvider provider_readerSelectConditions
+ */
+ public function testReaderSelectConditionsMultiplePrimaryKeys(
+ $message, $expectedSecondIteration, $primaryKeys, $batchSize = 3
+ ) {
+ $results = $this->genSelectResult( $batchSize, $batchSize * 3, function () {
+ static $i = 0, $j = 100, $k = 1000;
+ return [ 'id_field' => ++$i, 'foo' => ++$j, 'bar' => ++$k ];
+ } );
+ $db = $this->mockDbConsecutiveSelect( $results );
+
+ $conditions = [ 'bar' => 42, 'baz' => 'hai' ];
+ $reader = new BatchRowIterator( $db, 'some_table', $primaryKeys, $batchSize );
+ $reader->addConditions( $conditions );
+
+ $buildConditions = new ReflectionMethod( $reader, 'buildConditions' );
+ $buildConditions->setAccessible( true );
+
+ // On first iteration only the passed conditions must be used
+ $this->assertEquals( $conditions, $buildConditions->invoke( $reader ),
+ 'First iteration must return only the conditions passed in addConditions' );
+ $reader->rewind();
+
+ // Second iteration must use the maximum primary key of last set
+ $this->assertEquals(
+ $conditions + $expectedSecondIteration,
+ $buildConditions->invoke( $reader ),
+ $message
+ );
+ }
+
+ protected function mockDbConsecutiveSelect( array $retvals ) {
+ $db = $this->mockDb( [ 'select', 'addQuotes' ] );
+ $db->expects( $this->any() )
+ ->method( 'select' )
+ ->will( $this->consecutivelyReturnFromSelect( $retvals ) );
+ $db->expects( $this->any() )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'"; // not real quoting: doesn't matter in test
+ } ) );
+
+ return $db;
+ }
+
+ protected function consecutivelyReturnFromSelect( array $results ) {
+ $retvals = [];
+ foreach ( $results as $rows ) {
+ // The Database::select method returns iterators, so we do too.
+ $retvals[] = $this->returnValue( new ArrayIterator( $rows ) );
+ }
+
+ return call_user_func_array( [ $this, 'onConsecutiveCalls' ], $retvals );
+ }
+
+ protected function genSelectResult( $batchSize, $numRows, $rowGenerator ) {
+ $res = [];
+ for ( $i = 0; $i < $numRows; $i += $batchSize ) {
+ $rows = [];
+ for ( $j = 0; $j < $batchSize && $i + $j < $numRows; $j++ ) {
+ $rows [] = (object)call_user_func( $rowGenerator );
+ }
+ $res[] = $rows;
+ }
+ $res[] = []; // termination condition requires empty result for last row
+ return $res;
+ }
+
+ protected function mockDb( $methods = [] ) {
+ // @TODO: mock from Database
+ // FIXME: the constructor normally sets mAtomicLevels and mSrvCache
+ $databaseMysql = $this->getMockBuilder( Wikimedia\Rdbms\DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( array_merge( [ 'isOpen', 'getApproximateLagStatus' ], $methods ) )
+ ->getMock();
+ $databaseMysql->expects( $this->any() )
+ ->method( 'isOpen' )
+ ->will( $this->returnValue( true ) );
+ $databaseMysql->expects( $this->any() )
+ ->method( 'getApproximateLagStatus' )
+ ->will( $this->returnValue( [ 'lag' => 0, 'since' => 0 ] ) );
+ return $databaseMysql;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/utils/ClassCollectorTest.php b/www/wiki/tests/phpunit/includes/utils/ClassCollectorTest.php
new file mode 100644
index 00000000..9e5163f9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/utils/ClassCollectorTest.php
@@ -0,0 +1,56 @@
+<?php
+
+/**
+ * @covers ClassCollector
+ */
+class ClassCollectorTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public static function provideCases() {
+ return [
+ [
+ "class Foo {}",
+ [ 'Foo' ],
+ ],
+ [
+ "namespace Example;\nclass Foo {}\nclass Bar {}",
+ [ 'Example\Foo', 'Example\Bar' ],
+ ],
+ [
+ "class_alias( 'Foo', 'Bar' );",
+ [ 'Bar' ],
+ ],
+ [
+ "namespace Example;\nclass Foo {}\nclass_alias( 'Example\Foo', 'Foo' );",
+ [ 'Example\Foo', 'Foo' ],
+ ],
+ [
+ "namespace Example;\nclass Foo {}\nclass_alias( 'Example\Foo', 'Bar' );",
+ [ 'Example\Foo', 'Bar' ],
+ ],
+ [
+ "class_alias( Foo::class, 'Bar' );",
+ [ 'Bar' ],
+ ],
+ [
+ // Namespaced class is not currently supported. Must use namespace declaration
+ // earlier in the file.
+ "class_alias( Example\Foo::class, 'Bar' );",
+ [],
+ ],
+ [
+ "namespace Example;\nclass Foo {}\nclass_alias( Foo::class, 'Bar' );",
+ [ 'Example\Foo', 'Bar' ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCases
+ */
+ public function testGetClasses( $code, array $classes, $message = null ) {
+ $cc = new ClassCollector();
+ $this->assertEquals( $classes, $cc->getClasses( "<?php\n$code" ), $message );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/utils/FileContentsHasherTest.php b/www/wiki/tests/phpunit/includes/utils/FileContentsHasherTest.php
new file mode 100644
index 00000000..316d9f42
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/utils/FileContentsHasherTest.php
@@ -0,0 +1,57 @@
+<?php
+
+/**
+ * @covers FileContentsHasherTest
+ */
+class FileContentsHasherTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function provideSingleFile() {
+ return array_map( function ( $file ) {
+ return [ $file, file_get_contents( $file ) ];
+ }, glob( __DIR__ . '/../../data/filecontentshasher/*.*' ) );
+ }
+
+ public function provideMultipleFiles() {
+ return [
+ [ $this->provideSingleFile() ]
+ ];
+ }
+
+ /**
+ * @covers FileContentsHasher::getFileContentsHash
+ * @covers FileContentsHasher::getFileContentsHashInternal
+ * @dataProvider provideSingleFile
+ */
+ public function testSingleFileHash( $fileName, $contents ) {
+ foreach ( [ 'md4', 'md5' ] as $algo ) {
+ $expectedHash = hash( $algo, $contents );
+ $actualHash = FileContentsHasher::getFileContentsHash( $fileName, $algo );
+ $this->assertEquals( $expectedHash, $actualHash );
+ $actualHashRepeat = FileContentsHasher::getFileContentsHash( $fileName, $algo );
+ $this->assertEquals( $expectedHash, $actualHashRepeat );
+ }
+ }
+
+ /**
+ * @covers FileContentsHasher::getFileContentsHash
+ * @covers FileContentsHasher::getFileContentsHashInternal
+ * @dataProvider provideMultipleFiles
+ */
+ public function testMultipleFileHash( $files ) {
+ $fileNames = [];
+ $hashes = [];
+ foreach ( $files as $fileInfo ) {
+ list( $fileName, $contents ) = $fileInfo;
+ $fileNames[] = $fileName;
+ $hashes[] = md5( $contents );
+ }
+
+ $expectedHash = md5( implode( '', $hashes ) );
+ $actualHash = FileContentsHasher::getFileContentsHash( $fileNames, 'md5' );
+ $this->assertEquals( $expectedHash, $actualHash );
+ $actualHashRepeat = FileContentsHasher::getFileContentsHash( $fileNames, 'md5' );
+ $this->assertEquals( $expectedHash, $actualHashRepeat );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/utils/MWCryptHKDFTest.php b/www/wiki/tests/phpunit/includes/utils/MWCryptHKDFTest.php
new file mode 100644
index 00000000..05a33c5a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/utils/MWCryptHKDFTest.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * @group HKDF
+ * @covers CryptHKDF
+ * @covers MWCryptHKDF
+ */
+class MWCryptHKDFTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( 'wgSecretKey', '5bf1945342e67799cb50704a7fa19ac6' );
+ }
+
+ /**
+ * Test basic usage works
+ */
+ public function testGenerate() {
+ $a = MWCryptHKDF::generateHex( 64 );
+ $b = MWCryptHKDF::generateHex( 64 );
+
+ $this->assertTrue( strlen( $a ) == 64, "MWCryptHKDF produced fewer bytes than expected" );
+ $this->assertTrue( strlen( $b ) == 64, "MWCryptHKDF produced fewer bytes than expected" );
+ $this->assertFalse( $a == $b, "Two runs of MWCryptHKDF produced the same result." );
+ }
+
+ /**
+ * @dataProvider providerRfc5869
+ */
+ public function testRfc5869( $hash, $ikm, $salt, $info, $L, $prk, $okm ) {
+ $ikm = hex2bin( $ikm );
+ $salt = hex2bin( $salt );
+ $info = hex2bin( $info );
+ $okm = hex2bin( $okm );
+ $result = MWCryptHKDF::HKDF( $hash, $ikm, $salt, $info, $L );
+ $this->assertEquals( $okm, $result );
+ }
+
+ /**
+ * Test vectors from Appendix A on https://tools.ietf.org/html/rfc5869
+ */
+ public static function providerRfc5869() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ // A.1
+ [
+ 'sha256',
+ '0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b', // ikm
+ '000102030405060708090a0b0c', // salt
+ 'f0f1f2f3f4f5f6f7f8f9', // context
+ 42, // bytes
+ '077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5', // prk
+ '3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865' // okm
+ ],
+ // A.2
+ [
+ 'sha256',
+ '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f',
+ '606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf',
+ 'b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff',
+ 82,
+ '06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244',
+ 'b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87'
+ ],
+ // A.3
+ [
+ 'sha256',
+ '0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b', // ikm
+ '', // salt
+ '', // context
+ 42, // bytes
+ '19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04', // prk
+ '8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8' // okm
+ ],
+ // A.4
+ [
+ 'sha1',
+ '0b0b0b0b0b0b0b0b0b0b0b', // ikm
+ '000102030405060708090a0b0c', // salt
+ 'f0f1f2f3f4f5f6f7f8f9', // context
+ 42, // bytes
+ '9b6c18c432a7bf8f0e71c8eb88f4b30baa2ba243', // prk
+ '085a01ea1b10f36933068b56efa5ad81a4f14b822f5b091568a9cdd4f155fda2c22e422478d305f3f896' // okm
+ ],
+ // A.5
+ [
+ 'sha1',
+ '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f', // ikm
+ '606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf', // salt
+ 'b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff', // context
+ 82, // bytes
+ '8adae09a2a307059478d309b26c4115a224cfaf6', // prk
+ '0bd770a74d1160f7c9f12cd5912a06ebff6adcae899d92191fe4305673ba2ffe8fa3f1a4e5ad79f3f334b3b202b2173c486ea37ce3d397ed034c7f9dfeb15c5e927336d0441f4c4300e2cff0d0900b52d3b4' // okm
+ ],
+ ];
+ // phpcs:enable
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/utils/MWCryptHashTest.php b/www/wiki/tests/phpunit/includes/utils/MWCryptHashTest.php
new file mode 100644
index 00000000..94705bff
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/utils/MWCryptHashTest.php
@@ -0,0 +1,64 @@
+<?php
+
+/**
+ * @group Hash
+ *
+ * @covers MWCryptHash
+ */
+class MWCryptHashTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function testHashLength() {
+ if ( MWCryptHash::hashAlgo() !== 'whirlpool' ) {
+ $this->markTestSkipped( 'Hash algorithm isn\'t whirlpool' );
+ }
+
+ $this->assertEquals( 64, MWCryptHash::hashLength(), 'Raw hash length' );
+ $this->assertEquals( 128, MWCryptHash::hashLength( false ), 'Hex hash length' );
+ }
+
+ public function testHash() {
+ if ( MWCryptHash::hashAlgo() !== 'whirlpool' ) {
+ $this->markTestSkipped( 'Hash algorithm isn\'t whirlpool' );
+ }
+
+ $data = 'foobar';
+ // phpcs:ignore Generic.Files.LineLength
+ $hash = '9923afaec3a86f865bb231a588f453f84e8151a2deb4109aebc6de4284be5bebcff4fab82a7e51d920237340a043736e9d13bab196006dcca0fe65314d68eab9';
+
+ $this->assertEquals(
+ hex2bin( $hash ),
+ MWCryptHash::hash( $data ),
+ 'Raw hash'
+ );
+ $this->assertEquals(
+ $hash,
+ MWCryptHash::hash( $data, false ),
+ 'Hex hash'
+ );
+ }
+
+ public function testHmac() {
+ if ( MWCryptHash::hashAlgo() !== 'whirlpool' ) {
+ $this->markTestSkipped( 'Hash algorithm isn\'t whirlpool' );
+ }
+
+ $data = 'foobar';
+ $key = 'secret';
+ // phpcs:ignore Generic.Files.LineLength
+ $hash = 'ddc94177b2020e55ce2049199fd9cc6327f416ff6dc621cc34cb43d9bec61d73372b4790c0e24957f565ecaf2d42821e6303619093e99cbe14a3b9250bda5f81';
+
+ $this->assertEquals(
+ hex2bin( $hash ),
+ MWCryptHash::hmac( $data, $key ),
+ 'Raw hmac'
+ );
+ $this->assertEquals(
+ $hash,
+ MWCryptHash::hmac( $data, $key, false ),
+ 'Hex hmac'
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/utils/MWGrantsTest.php b/www/wiki/tests/phpunit/includes/utils/MWGrantsTest.php
new file mode 100644
index 00000000..eae9c15d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/utils/MWGrantsTest.php
@@ -0,0 +1,117 @@
+<?php
+class MWGrantsTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgGrantPermissions' => [
+ 'hidden1' => [ 'read' => true, 'autoconfirmed' => false ],
+ 'hidden2' => [ 'autoconfirmed' => true ],
+ 'normal' => [ 'edit' => true ],
+ 'normal2' => [ 'edit' => true, 'create' => true ],
+ 'admin' => [ 'protect' => true, 'delete' => true ],
+ ],
+ 'wgGrantPermissionGroups' => [
+ 'hidden1' => 'hidden',
+ 'hidden2' => 'hidden',
+ 'normal' => 'normal-group',
+ 'admin' => 'admin',
+ ],
+ ] );
+ }
+
+ /**
+ * @covers MWGrants::getValidGrants
+ */
+ public function testGetValidGrants() {
+ $this->assertSame(
+ [ 'hidden1', 'hidden2', 'normal', 'normal2', 'admin' ],
+ MWGrants::getValidGrants()
+ );
+ }
+
+ /**
+ * @covers MWGrants::getRightsByGrant
+ */
+ public function testGetRightsByGrant() {
+ $this->assertSame(
+ [
+ 'hidden1' => [ 'read' ],
+ 'hidden2' => [ 'autoconfirmed' ],
+ 'normal' => [ 'edit' ],
+ 'normal2' => [ 'edit', 'create' ],
+ 'admin' => [ 'protect', 'delete' ],
+ ],
+ MWGrants::getRightsByGrant()
+ );
+ }
+
+ /**
+ * @dataProvider provideGetGrantRights
+ * @covers MWGrants::getGrantRights
+ * @param array|string $grants
+ * @param array $rights
+ */
+ public function testGetGrantRights( $grants, $rights ) {
+ $this->assertSame( $rights, MWGrants::getGrantRights( $grants ) );
+ }
+
+ public static function provideGetGrantRights() {
+ return [
+ [ 'hidden1', [ 'read' ] ],
+ [ [ 'hidden1', 'hidden2', 'hidden3' ], [ 'read', 'autoconfirmed' ] ],
+ [ [ 'normal1', 'normal2' ], [ 'edit', 'create' ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGrantsAreValid
+ * @covers MWGrants::grantsAreValid
+ * @param array $grants
+ * @param bool $valid
+ */
+ public function testGrantsAreValid( $grants, $valid ) {
+ $this->assertSame( $valid, MWGrants::grantsAreValid( $grants ) );
+ }
+
+ public static function provideGrantsAreValid() {
+ return [
+ [ [ 'hidden1', 'hidden2' ], true ],
+ [ [ 'hidden1', 'hidden3' ], false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetGrantGroups
+ * @covers MWGrants::getGrantGroups
+ * @param array|null $grants
+ * @param array $expect
+ */
+ public function testGetGrantGroups( $grants, $expect ) {
+ $this->assertSame( $expect, MWGrants::getGrantGroups( $grants ) );
+ }
+
+ public static function provideGetGrantGroups() {
+ return [
+ [ null, [
+ 'hidden' => [ 'hidden1', 'hidden2' ],
+ 'normal-group' => [ 'normal' ],
+ 'other' => [ 'normal2' ],
+ 'admin' => [ 'admin' ],
+ ] ],
+ [ [ 'hidden1', 'normal' ], [
+ 'hidden' => [ 'hidden1' ],
+ 'normal-group' => [ 'normal' ],
+ ] ],
+ ];
+ }
+
+ /**
+ * @covers MWGrants::getHiddenGrants
+ */
+ public function testGetHiddenGrants() {
+ $this->assertSame( [ 'hidden1', 'hidden2' ], MWGrants::getHiddenGrants() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/utils/MWRestrictionsTest.php b/www/wiki/tests/phpunit/includes/utils/MWRestrictionsTest.php
new file mode 100644
index 00000000..abdfbb14
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/utils/MWRestrictionsTest.php
@@ -0,0 +1,217 @@
+<?php
+class MWRestrictionsTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected static $restrictionsForChecks;
+
+ public static function setUpBeforeClass() {
+ self::$restrictionsForChecks = MWRestrictions::newFromArray( [
+ 'IPAddresses' => [
+ '10.0.0.0/8',
+ '172.16.0.0/12',
+ '2001:db8::/33',
+ ]
+ ] );
+ }
+
+ /**
+ * @covers MWRestrictions::newDefault
+ * @covers MWRestrictions::__construct
+ */
+ public function testNewDefault() {
+ $ret = MWRestrictions::newDefault();
+ $this->assertInstanceOf( MWRestrictions::class, $ret );
+ $this->assertSame(
+ '{"IPAddresses":["0.0.0.0/0","::/0"]}',
+ $ret->toJson()
+ );
+ }
+
+ /**
+ * @covers MWRestrictions::newFromArray
+ * @covers MWRestrictions::__construct
+ * @covers MWRestrictions::loadFromArray
+ * @covers MWRestrictions::toArray
+ * @dataProvider provideArray
+ * @param array $data
+ * @param bool|InvalidArgumentException $expect True if the call succeeds,
+ * otherwise the exception that should be thrown.
+ */
+ public function testArray( $data, $expect ) {
+ if ( $expect === true ) {
+ $ret = MWRestrictions::newFromArray( $data );
+ $this->assertInstanceOf( MWRestrictions::class, $ret );
+ $this->assertSame( $data, $ret->toArray() );
+ } else {
+ try {
+ MWRestrictions::newFromArray( $data );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertEquals( $expect, $ex );
+ }
+ }
+ }
+
+ public static function provideArray() {
+ return [
+ [ [ 'IPAddresses' => [] ], true ],
+ [ [ 'IPAddresses' => [ '127.0.0.1/32' ] ], true ],
+ [
+ [ 'IPAddresses' => [ '256.0.0.1/32' ] ],
+ new InvalidArgumentException( 'Invalid IP address: 256.0.0.1/32' )
+ ],
+ [
+ [ 'IPAddresses' => '127.0.0.1/32' ],
+ new InvalidArgumentException( 'IPAddresses is not an array' )
+ ],
+ [
+ [],
+ new InvalidArgumentException( 'Array is missing required keys: IPAddresses' )
+ ],
+ [
+ [ 'foo' => 'bar', 'bar' => 42 ],
+ new InvalidArgumentException( 'Array contains invalid keys: foo, bar' )
+ ],
+ ];
+ }
+
+ /**
+ * @covers MWRestrictions::newFromJson
+ * @covers MWRestrictions::__construct
+ * @covers MWRestrictions::loadFromArray
+ * @covers MWRestrictions::toJson
+ * @covers MWRestrictions::__toString
+ * @dataProvider provideJson
+ * @param string $json
+ * @param array|InvalidArgumentException $expect
+ */
+ public function testJson( $json, $expect ) {
+ if ( is_array( $expect ) ) {
+ $ret = MWRestrictions::newFromJson( $json );
+ $this->assertInstanceOf( MWRestrictions::class, $ret );
+ $this->assertSame( $expect, $ret->toArray() );
+
+ $this->assertSame( $json, $ret->toJson( false ) );
+ $this->assertSame( $json, (string)$ret );
+
+ $this->assertSame(
+ FormatJson::encode( $expect, true, FormatJson::ALL_OK ),
+ $ret->toJson( true )
+ );
+ } else {
+ try {
+ MWRestrictions::newFromJson( $json );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertTrue( true );
+ }
+ }
+ }
+
+ public static function provideJson() {
+ return [
+ [
+ '{"IPAddresses":[]}',
+ [ 'IPAddresses' => [] ]
+ ],
+ [
+ '{"IPAddresses":["127.0.0.1/32"]}',
+ [ 'IPAddresses' => [ '127.0.0.1/32' ] ]
+ ],
+ [
+ '{"IPAddresses":["256.0.0.1/32"]}',
+ new InvalidArgumentException( 'Invalid IP address: 256.0.0.1/32' )
+ ],
+ [
+ '{"IPAddresses":"127.0.0.1/32"}',
+ new InvalidArgumentException( 'IPAddresses is not an array' )
+ ],
+ [
+ '{}',
+ new InvalidArgumentException( 'Array is missing required keys: IPAddresses' )
+ ],
+ [
+ '{"foo":"bar","bar":42}',
+ new InvalidArgumentException( 'Array contains invalid keys: foo, bar' )
+ ],
+ [
+ '{"IPAddresses":[]',
+ new InvalidArgumentException( 'Invalid restrictions JSON' )
+ ],
+ [
+ '"IPAddresses"',
+ new InvalidArgumentException( 'Invalid restrictions JSON' )
+ ],
+ ];
+ }
+
+ /**
+ * @covers MWRestrictions::checkIP
+ * @dataProvider provideCheckIP
+ * @param string $ip
+ * @param bool $pass
+ */
+ public function testCheckIP( $ip, $pass ) {
+ $this->assertSame( $pass, self::$restrictionsForChecks->checkIP( $ip ) );
+ }
+
+ public static function provideCheckIP() {
+ return [
+ [ '10.0.0.1', true ],
+ [ '172.16.0.0', true ],
+ [ '192.0.2.1', false ],
+ [ '2001:db8:1::', true ],
+ [ '2001:0db8:0000:0000:0000:0000:0000:0000', true ],
+ [ '2001:0DB8:8000::', false ],
+ ];
+ }
+
+ /**
+ * @covers MWRestrictions::check
+ * @dataProvider provideCheck
+ * @param WebRequest $request
+ * @param Status $expect
+ */
+ public function testCheck( $request, $expect ) {
+ $this->assertEquals( $expect, self::$restrictionsForChecks->check( $request ) );
+ }
+
+ public function provideCheck() {
+ $ret = [];
+
+ $mockBuilder = $this->getMockBuilder( FauxRequest::class )
+ ->setMethods( [ 'getIP' ] );
+
+ foreach ( self::provideCheckIP() as $checkIP ) {
+ $ok = [];
+ $request = $mockBuilder->getMock();
+
+ $request->expects( $this->any() )->method( 'getIP' )
+ ->will( $this->returnValue( $checkIP[0] ) );
+ $ok['ip'] = $checkIP[1];
+
+ /* If we ever add more restrictions, add nested for loops here:
+ * foreach ( self::provideCheckFoo() as $checkFoo ) {
+ * $request->expects( $this->any() )->method( 'getFoo' )
+ * ->will( $this->returnValue( $checkFoo[0] );
+ * $ok['foo'] = $checkFoo[1];
+ *
+ * foreach ( self::provideCheckBar() as $checkBar ) {
+ * $request->expects( $this->any() )->method( 'getBar' )
+ * ->will( $this->returnValue( $checkBar[0] );
+ * $ok['bar'] = $checkBar[1];
+ *
+ * // etc.
+ * }
+ * }
+ */
+
+ $status = Status::newGood();
+ $status->setResult( $ok === array_filter( $ok ), $ok );
+ $ret[] = [ $request, $status ];
+ }
+
+ return $ret;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/utils/UIDGeneratorTest.php b/www/wiki/tests/phpunit/includes/utils/UIDGeneratorTest.php
new file mode 100644
index 00000000..d335a93a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/utils/UIDGeneratorTest.php
@@ -0,0 +1,173 @@
+<?php
+
+class UIDGeneratorTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected function tearDown() {
+ // Bug: 44850
+ UIDGenerator::unitTestTearDown();
+ parent::tearDown();
+ }
+
+ /**
+ * Test that generated UIDs have the expected properties
+ *
+ * @dataProvider provider_testTimestampedUID
+ * @covers UIDGenerator::newTimestampedUID128
+ * @covers UIDGenerator::newTimestampedUID88
+ */
+ public function testTimestampedUID( $method, $digitlen, $bits, $tbits, $hostbits ) {
+ $id = call_user_func( [ UIDGenerator::class, $method ] );
+ $this->assertEquals( true, ctype_digit( $id ), "UID made of digit characters" );
+ $this->assertLessThanOrEqual( $digitlen, strlen( $id ),
+ "UID has the right number of digits" );
+ $this->assertLessThanOrEqual( $bits, strlen( Wikimedia\base_convert( $id, 10, 2 ) ),
+ "UID has the right number of bits" );
+
+ $ids = [];
+ for ( $i = 0; $i < 300; $i++ ) {
+ $ids[] = call_user_func( [ UIDGenerator::class, $method ] );
+ }
+
+ $lastId = array_shift( $ids );
+
+ $this->assertSame( array_unique( $ids ), $ids, "All generated IDs are unique." );
+
+ foreach ( $ids as $id ) {
+ // Convert string to binary and pad to full length so we can
+ // extract segments
+ $id_bin = Wikimedia\base_convert( $id, 10, 2, $bits );
+ $lastId_bin = Wikimedia\base_convert( $lastId, 10, 2, $bits );
+
+ $timestamp_bin = substr( $id_bin, 0, $tbits );
+ $last_timestamp_bin = substr( $lastId_bin, 0, $tbits );
+
+ $this->assertGreaterThanOrEqual(
+ $last_timestamp_bin,
+ $timestamp_bin,
+ "timestamp ($timestamp_bin) of current ID ($id_bin) >= timestamp ($last_timestamp_bin) " .
+ "of prior one ($lastId_bin)" );
+
+ $hostbits_bin = substr( $id_bin, -$hostbits );
+ $last_hostbits_bin = substr( $lastId_bin, -$hostbits );
+
+ if ( $hostbits ) {
+ $this->assertEquals(
+ $hostbits_bin,
+ $last_hostbits_bin,
+ "Host ID ($hostbits_bin) of current ID ($id_bin) is same as host ID ($last_hostbits_bin) " .
+ "of prior one ($lastId_bin)." );
+ }
+
+ $lastId = $id;
+ }
+ }
+
+ /**
+ * array( method, length, bits, hostbits )
+ * NOTE: When adding a new method name here please update the covers tags for the tests!
+ */
+ public static function provider_testTimestampedUID() {
+ return [
+ [ 'newTimestampedUID128', 39, 128, 46, 48 ],
+ [ 'newTimestampedUID128', 39, 128, 46, 48 ],
+ [ 'newTimestampedUID88', 27, 88, 46, 32 ],
+ ];
+ }
+
+ /**
+ * @covers UIDGenerator::newUUIDv1
+ */
+ public function testUUIDv1() {
+ $ids = [];
+ for ( $i = 0; $i < 100; $i++ ) {
+ $id = UIDGenerator::newUUIDv1();
+ $this->assertEquals( true,
+ preg_match( '!^[0-9a-f]{8}-[0-9a-f]{4}-1[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$!', $id ),
+ "UID $id has the right format" );
+ $ids[] = $id;
+
+ $id = UIDGenerator::newRawUUIDv1();
+ $this->assertEquals( true,
+ preg_match( '!^[0-9a-f]{12}1[0-9a-f]{3}[89ab][0-9a-f]{15}$!', $id ),
+ "UID $id has the right format" );
+
+ $id = UIDGenerator::newRawUUIDv1();
+ $this->assertEquals( true,
+ preg_match( '!^[0-9a-f]{12}1[0-9a-f]{3}[89ab][0-9a-f]{15}$!', $id ),
+ "UID $id has the right format" );
+ }
+
+ $this->assertEquals( array_unique( $ids ), $ids, "All generated IDs are unique." );
+ }
+
+ /**
+ * @covers UIDGenerator::newUUIDv4
+ */
+ public function testUUIDv4() {
+ $ids = [];
+ for ( $i = 0; $i < 100; $i++ ) {
+ $id = UIDGenerator::newUUIDv4();
+ $ids[] = $id;
+ $this->assertEquals( true,
+ preg_match( '!^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$!', $id ),
+ "UID $id has the right format" );
+ }
+
+ $this->assertEquals( array_unique( $ids ), $ids, 'All generated IDs are unique.' );
+ }
+
+ /**
+ * @covers UIDGenerator::newRawUUIDv4
+ */
+ public function testRawUUIDv4() {
+ for ( $i = 0; $i < 100; $i++ ) {
+ $id = UIDGenerator::newRawUUIDv4();
+ $this->assertEquals( true,
+ preg_match( '!^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$!', $id ),
+ "UID $id has the right format" );
+ }
+ }
+
+ /**
+ * @covers UIDGenerator::newRawUUIDv4
+ */
+ public function testRawUUIDv4QuickRand() {
+ for ( $i = 0; $i < 100; $i++ ) {
+ $id = UIDGenerator::newRawUUIDv4( UIDGenerator::QUICK_RAND );
+ $this->assertEquals( true,
+ preg_match( '!^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$!', $id ),
+ "UID $id has the right format" );
+ }
+ }
+
+ /**
+ * @covers UIDGenerator::newSequentialPerNodeID
+ */
+ public function testNewSequentialID() {
+ $id1 = UIDGenerator::newSequentialPerNodeID( 'test', 32 );
+ $id2 = UIDGenerator::newSequentialPerNodeID( 'test', 32 );
+
+ $this->assertInternalType( 'float', $id1, "ID returned as float" );
+ $this->assertInternalType( 'float', $id2, "ID returned as float" );
+ $this->assertGreaterThan( 0, $id1, "ID greater than 1" );
+ $this->assertGreaterThan( $id1, $id2, "IDs increasing in value" );
+ }
+
+ /**
+ * @covers UIDGenerator::newSequentialPerNodeIDs
+ */
+ public function testNewSequentialIDs() {
+ $ids = UIDGenerator::newSequentialPerNodeIDs( 'test', 32, 5 );
+ $lastId = null;
+ foreach ( $ids as $id ) {
+ $this->assertInternalType( 'float', $id, "ID returned as float" );
+ $this->assertGreaterThan( 0, $id, "ID greater than 1" );
+ if ( $lastId ) {
+ $this->assertGreaterThan( $lastId, $id, "IDs increasing in value" );
+ }
+ $lastId = $id;
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/utils/ZipDirectoryReaderTest.php b/www/wiki/tests/phpunit/includes/utils/ZipDirectoryReaderTest.php
new file mode 100644
index 00000000..9f18e5af
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/utils/ZipDirectoryReaderTest.php
@@ -0,0 +1,87 @@
+<?php
+
+/**
+ * @covers ZipDirectoryReader
+ * NOTE: this test is more like an integration test than a unit test
+ */
+class ZipDirectoryReaderTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected $zipDir;
+ protected $entries;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->zipDir = __DIR__ . '/../../data/zip';
+ }
+
+ function zipCallback( $entry ) {
+ $this->entries[] = $entry;
+ }
+
+ function readZipAssertError( $file, $error, $assertMessage ) {
+ $this->entries = [];
+ $status = ZipDirectoryReader::read( "{$this->zipDir}/$file", [ $this, 'zipCallback' ] );
+ $this->assertTrue( $status->hasMessage( $error ), $assertMessage );
+ }
+
+ function readZipAssertSuccess( $file, $assertMessage ) {
+ $this->entries = [];
+ $status = ZipDirectoryReader::read( "{$this->zipDir}/$file", [ $this, 'zipCallback' ] );
+ $this->assertTrue( $status->isOK(), $assertMessage );
+ }
+
+ public function testEmpty() {
+ $this->readZipAssertSuccess( 'empty.zip', 'Empty zip' );
+ }
+
+ public function testMultiDisk0() {
+ $this->readZipAssertError( 'split.zip', 'zip-unsupported',
+ 'Split zip error' );
+ }
+
+ public function testNoSignature() {
+ $this->readZipAssertError( 'nosig.zip', 'zip-wrong-format',
+ 'No signature should give "wrong format" error' );
+ }
+
+ public function testSimple() {
+ $this->readZipAssertSuccess( 'class.zip', 'Simple ZIP' );
+ $this->assertEquals( $this->entries, [ [
+ 'name' => 'Class.class',
+ 'mtime' => '20010115000000',
+ 'size' => 1,
+ ] ] );
+ }
+
+ public function testBadCentralEntrySignature() {
+ $this->readZipAssertError( 'wrong-central-entry-sig.zip', 'zip-bad',
+ 'Bad central entry error' );
+ }
+
+ public function testTrailingBytes() {
+ $this->readZipAssertError( 'trail.zip', 'zip-bad',
+ 'Trailing bytes error' );
+ }
+
+ public function testWrongCDStart() {
+ $this->readZipAssertError( 'wrong-cd-start-disk.zip', 'zip-unsupported',
+ 'Wrong CD start disk error' );
+ }
+
+ public function testCentralDirectoryGap() {
+ $this->readZipAssertError( 'cd-gap.zip', 'zip-bad',
+ 'CD gap error' );
+ }
+
+ public function testCentralDirectoryTruncated() {
+ $this->readZipAssertError( 'cd-truncated.zip', 'zip-bad',
+ 'CD truncated error (should hit unpack() overrun)' );
+ }
+
+ public function testLooksLikeZip64() {
+ $this->readZipAssertError( 'looks-like-zip64.zip', 'zip-unsupported',
+ 'A file which looks like ZIP64 but isn\'t, should give error' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php b/www/wiki/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php
new file mode 100644
index 00000000..a8761e39
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php
@@ -0,0 +1,246 @@
+<?php
+
+/**
+ * @author Addshore
+ *
+ * @covers NoWriteWatchedItemStore
+ */
+class NoWriteWatchedItemStoreUnitTest extends MediaWikiTestCase {
+
+ public function testAddWatch() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->never() )->method( 'addWatch' );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->addWatch( $this->getTestSysop()->getUser(), new TitleValue( 0, 'Foo' ) );
+ }
+
+ public function testAddWatchBatchForUser() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->never() )->method( 'addWatchBatchForUser' );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->addWatchBatchForUser( $this->getTestSysop()->getUser(), [] );
+ }
+
+ public function testRemoveWatch() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->never() )->method( 'removeWatch' );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->removeWatch( $this->getTestSysop()->getUser(), new TitleValue( 0, 'Foo' ) );
+ }
+
+ public function testSetNotificationTimestampsForUser() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->never() )->method( 'setNotificationTimestampsForUser' );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->setNotificationTimestampsForUser(
+ $this->getTestSysop()->getUser(),
+ 'timestamp',
+ []
+ );
+ }
+
+ public function testUpdateNotificationTimestamp() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->never() )->method( 'updateNotificationTimestamp' );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->updateNotificationTimestamp(
+ $this->getTestSysop()->getUser(),
+ new TitleValue( 0, 'Foo' ),
+ 'timestamp'
+ );
+ }
+
+ public function testResetNotificationTimestamp() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->never() )->method( 'resetNotificationTimestamp' );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->resetNotificationTimestamp(
+ $this->getTestSysop()->getUser(),
+ Title::newFromText( 'Foo' )
+ );
+ }
+
+ public function testCountWatchedItems() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )->method( 'countWatchedItems' )->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->countWatchedItems(
+ $this->getTestSysop()->getUser()
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testCountWatchers() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )->method( 'countWatchers' )->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->countWatchers(
+ new TitleValue( 0, 'Foo' )
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testCountVisitingWatchers() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )
+ ->method( 'countVisitingWatchers' )
+ ->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->countVisitingWatchers(
+ new TitleValue( 0, 'Foo' ),
+ 9
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testCountWatchersMultiple() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )
+ ->method( 'countVisitingWatchersMultiple' )
+ ->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->countWatchersMultiple(
+ [ new TitleValue( 0, 'Foo' ) ],
+ []
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testCountVisitingWatchersMultiple() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )
+ ->method( 'countVisitingWatchersMultiple' )
+ ->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->countVisitingWatchersMultiple(
+ [ [ new TitleValue( 0, 'Foo' ), 99 ] ],
+ 11
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testGetWatchedItem() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )->method( 'getWatchedItem' )->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->getWatchedItem(
+ $this->getTestSysop()->getUser(),
+ new TitleValue( 0, 'Foo' )
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testLoadWatchedItem() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )->method( 'loadWatchedItem' )->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->loadWatchedItem(
+ $this->getTestSysop()->getUser(),
+ new TitleValue( 0, 'Foo' )
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testGetWatchedItemsForUser() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )
+ ->method( 'getWatchedItemsForUser' )
+ ->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->getWatchedItemsForUser(
+ $this->getTestSysop()->getUser(),
+ []
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testIsWatched() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )->method( 'isWatched' )->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->isWatched(
+ $this->getTestSysop()->getUser(),
+ new TitleValue( 0, 'Foo' )
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testGetNotificationTimestampsBatch() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )
+ ->method( 'getNotificationTimestampsBatch' )
+ ->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->getNotificationTimestampsBatch(
+ $this->getTestSysop()->getUser(),
+ [ new TitleValue( 0, 'Foo' ) ]
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testCountUnreadNotifications() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )
+ ->method( 'countUnreadNotifications' )
+ ->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->countUnreadNotifications(
+ $this->getTestSysop()->getUser(),
+ 88
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testDuplicateAllAssociatedEntries() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->duplicateAllAssociatedEntries(
+ new TitleValue( 0, 'Foo' ),
+ new TitleValue( 0, 'Bar' )
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php
new file mode 100644
index 00000000..50e6c202
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php
@@ -0,0 +1,1706 @@
+<?php
+
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers WatchedItemQueryService
+ */
+class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|CommentStore
+ */
+ private function getMockCommentStore() {
+ $mockStore = $this->getMockBuilder( CommentStore::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mockStore->expects( $this->any() )
+ ->method( 'getFields' )
+ ->willReturn( [ 'commentstore' => 'fields' ] );
+ $mockStore->expects( $this->any() )
+ ->method( 'getJoin' )
+ ->willReturn( [
+ 'tables' => [ 'commentstore' => 'table' ],
+ 'fields' => [ 'commentstore' => 'field' ],
+ 'joins' => [ 'commentstore' => 'join' ],
+ ] );
+ return $mockStore;
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|ActorMigration
+ */
+ private function getMockActorMigration() {
+ $mockStore = $this->getMockBuilder( ActorMigration::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mockStore->expects( $this->any() )
+ ->method( 'getJoin' )
+ ->willReturn( [
+ 'tables' => [ 'actormigration' => 'table' ],
+ 'fields' => [
+ 'rc_user' => 'actormigration_user',
+ 'rc_user_text' => 'actormigration_user_text',
+ 'rc_actor' => 'actormigration_actor',
+ ],
+ 'joins' => [ 'actormigration' => 'join' ],
+ ] );
+ $mockStore->expects( $this->any() )
+ ->method( 'getWhere' )
+ ->willReturn( [
+ 'tables' => [ 'actormigration' => 'table' ],
+ 'conds' => 'actormigration_conds',
+ 'joins' => [ 'actormigration' => 'join' ],
+ ] );
+ $mockStore->expects( $this->any() )
+ ->method( 'isAnon' )
+ ->willReturn( 'actormigration is anon' );
+ $mockStore->expects( $this->any() )
+ ->method( 'isNotAnon' )
+ ->willReturn( 'actormigration is not anon' );
+ return $mockStore;
+ }
+
+ /**
+ * @param PHPUnit_Framework_MockObject_MockObject|Database $mockDb
+ * @return WatchedItemQueryService
+ */
+ private function newService( $mockDb ) {
+ return new WatchedItemQueryService(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCommentStore(),
+ $this->getMockActorMigration()
+ );
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|Database
+ */
+ private function getMockDb() {
+ $mock = $this->getMockBuilder( Database::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $mock->expects( $this->any() )
+ ->method( 'makeList' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'int' )
+ )
+ ->will( $this->returnCallback( function ( $a, $conj ) {
+ $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
+ $conds = [];
+ foreach ( $a as $k => $v ) {
+ if ( is_int( $k ) ) {
+ $conds[] = "($v)";
+ } elseif ( is_array( $v ) ) {
+ $conds[] = "($k IN ('" . implode( "','", $v ) . "'))";
+ } else {
+ $conds[] = "($k = '$v')";
+ }
+ }
+ return implode( $sqlConj, $conds );
+ } ) );
+
+ $mock->expects( $this->any() )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+
+ $mock->expects( $this->any() )
+ ->method( 'timestamp' )
+ ->will( $this->returnArgument( 0 ) );
+
+ $mock->expects( $this->any() )
+ ->method( 'bitAnd' )
+ ->willReturnCallback( function ( $a, $b ) {
+ return "($a & $b)";
+ } );
+
+ return $mock;
+ }
+
+ /**
+ * @param PHPUnit_Framework_MockObject_MockObject|Database $mockDb
+ * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer
+ */
+ private function getMockLoadBalancer( $mockDb ) {
+ $mock = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'getConnectionRef' )
+ ->with( DB_REPLICA )
+ ->will( $this->returnValue( $mockDb ) );
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockNonAnonUserWithId( $id ) {
+ $mock = $this->getMockBuilder( User::class )->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'isAnon' )
+ ->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )
+ ->method( 'getId' )
+ ->will( $this->returnValue( $id ) );
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockUnrestrictedNonAnonUserWithId( $id ) {
+ $mock = $this->getMockNonAnonUserWithId( $id );
+ $mock->expects( $this->any() )
+ ->method( 'isAllowed' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )
+ ->method( 'isAllowedAny' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )
+ ->method( 'useRCPatrol' )
+ ->will( $this->returnValue( true ) );
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @param string $notAllowedAction
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockNonAnonUserWithIdAndRestrictedPermissions( $id, $notAllowedAction ) {
+ $mock = $this->getMockNonAnonUserWithId( $id );
+
+ $mock->expects( $this->any() )
+ ->method( 'isAllowed' )
+ ->will( $this->returnCallback( function ( $action ) use ( $notAllowedAction ) {
+ return $action !== $notAllowedAction;
+ } ) );
+ $mock->expects( $this->any() )
+ ->method( 'isAllowedAny' )
+ ->will( $this->returnCallback( function () use ( $notAllowedAction ) {
+ $actions = func_get_args();
+ return !in_array( $notAllowedAction, $actions );
+ } ) );
+
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockNonAnonUserWithIdAndNoPatrolRights( $id ) {
+ $mock = $this->getMockNonAnonUserWithId( $id );
+
+ $mock->expects( $this->any() )
+ ->method( 'isAllowed' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )
+ ->method( 'isAllowedAny' )
+ ->will( $this->returnValue( true ) );
+
+ $mock->expects( $this->any() )
+ ->method( 'useRCPatrol' )
+ ->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )
+ ->method( 'useNPPatrol' )
+ ->will( $this->returnValue( false ) );
+
+ return $mock;
+ }
+
+ private function getMockAnonUser() {
+ $mock = $this->getMockBuilder( User::class )->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'isAnon' )
+ ->will( $this->returnValue( true ) );
+ return $mock;
+ }
+
+ private function getFakeRow( array $rowValues ) {
+ $fakeRow = new stdClass();
+ foreach ( $rowValues as $valueName => $value ) {
+ $fakeRow->$valueName = $value;
+ }
+ return $fakeRow;
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ ],
+ [
+ 'wl_user' => 1,
+ '(rc_this_oldid=page_latest) OR (rc_type=3)',
+ ],
+ $this->isType( 'string' ),
+ [
+ 'LIMIT' => 3,
+ ],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ ]
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow( [
+ 'rc_id' => 1,
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Foo1',
+ 'rc_timestamp' => '20151212010101',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow( [
+ 'rc_id' => 2,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo2',
+ 'rc_timestamp' => '20151212010102',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ $this->getFakeRow( [
+ 'rc_id' => 3,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo3',
+ 'rc_timestamp' => '20151212010103',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ ] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $startFrom = null;
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user, [ 'limit' => 2 ], $startFrom
+ );
+
+ $this->assertInternalType( 'array', $items );
+ $this->assertCount( 2, $items );
+
+ foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) {
+ $this->assertInstanceOf( WatchedItem::class, $watchedItem );
+ $this->assertInternalType( 'array', $recentChangeInfo );
+ }
+
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+ $items[0][0]
+ );
+ $this->assertEquals(
+ [
+ 'rc_id' => 1,
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Foo1',
+ 'rc_timestamp' => '20151212010101',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ ],
+ $items[0][1]
+ );
+
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+ $items[1][0]
+ );
+ $this->assertEquals(
+ [
+ 'rc_id' => 2,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo2',
+ 'rc_timestamp' => '20151212010102',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ ],
+ $items[1][1]
+ );
+
+ $this->assertEquals( [ '20151212010103', 3 ], $startFrom );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_extension() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page', 'extension_dummy_table' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ 'extension_dummy_field',
+ ],
+ [
+ 'wl_user' => 1,
+ '(rc_this_oldid=page_latest) OR (rc_type=3)',
+ 'extension_dummy_cond',
+ ],
+ $this->isType( 'string' ),
+ [
+ 'extension_dummy_option',
+ ],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ 'extension_dummy_join_cond' => [],
+ ]
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow( [
+ 'rc_id' => 1,
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Foo1',
+ 'rc_timestamp' => '20151212010101',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow( [
+ 'rc_id' => 2,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo2',
+ 'rc_timestamp' => '20151212010102',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ ] ) );
+
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $mockExtension = $this->getMockBuilder( WatchedItemQueryServiceExtension::class )
+ ->getMock();
+ $mockExtension->expects( $this->once() )
+ ->method( 'modifyWatchedItemsWithRCInfoQuery' )
+ ->with(
+ $this->identicalTo( $user ),
+ $this->isType( 'array' ),
+ $this->isInstanceOf( IDatabase::class ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnCallback( function (
+ $user, $options, $db, &$tables, &$fields, &$conds, &$dbOptions, &$joinConds
+ ) {
+ $tables[] = 'extension_dummy_table';
+ $fields[] = 'extension_dummy_field';
+ $conds[] = 'extension_dummy_cond';
+ $dbOptions[] = 'extension_dummy_option';
+ $joinConds['extension_dummy_join_cond'] = [];
+ } ) );
+ $mockExtension->expects( $this->once() )
+ ->method( 'modifyWatchedItemsWithRCInfo' )
+ ->with(
+ $this->identicalTo( $user ),
+ $this->isType( 'array' ),
+ $this->isInstanceOf( IDatabase::class ),
+ $this->isType( 'array' ),
+ $this->anything(),
+ $this->anything() // Can't test for null here, PHPUnit applies this after the callback
+ )
+ ->will( $this->returnCallback( function ( $user, $options, $db, &$items, $res, &$startFrom ) {
+ foreach ( $items as $i => &$item ) {
+ $item[1]['extension_dummy_field'] = $i;
+ }
+ unset( $item );
+
+ $this->assertNull( $startFrom );
+ $startFrom = [ '20160203123456', 42 ];
+ } ) );
+
+ $queryService = $this->newService( $mockDb );
+ TestingAccessWrapper::newFromObject( $queryService )->extensions = [ $mockExtension ];
+
+ $startFrom = null;
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user, [], $startFrom
+ );
+
+ $this->assertInternalType( 'array', $items );
+ $this->assertCount( 2, $items );
+
+ foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) {
+ $this->assertInstanceOf( WatchedItem::class, $watchedItem );
+ $this->assertInternalType( 'array', $recentChangeInfo );
+ }
+
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+ $items[0][0]
+ );
+ $this->assertEquals(
+ [
+ 'rc_id' => 1,
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Foo1',
+ 'rc_timestamp' => '20151212010101',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'extension_dummy_field' => 0,
+ ],
+ $items[0][1]
+ );
+
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+ $items[1][0]
+ );
+ $this->assertEquals(
+ [
+ 'rc_id' => 2,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo2',
+ 'rc_timestamp' => '20151212010102',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'extension_dummy_field' => 1,
+ ],
+ $items[1][1]
+ );
+
+ $this->assertEquals( [ '20160203123456', 42 ], $startFrom );
+ }
+
+ public function getWatchedItemsWithRecentChangeInfoOptionsProvider() {
+ return [
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_FLAGS ] ],
+ null,
+ [],
+ [ 'rc_type', 'rc_minor', 'rc_bot' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER ] ],
+ null,
+ [ 'actormigration' => 'table' ],
+ [ 'rc_user_text' => 'actormigration_user_text' ],
+ [],
+ [],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ],
+ null,
+ [ 'actormigration' => 'table' ],
+ [ 'rc_user' => 'actormigration_user' ],
+ [],
+ [],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
+ null,
+ [ 'commentstore' => 'table' ],
+ [ 'commentstore' => 'field' ],
+ [],
+ [],
+ [ 'commentstore' => 'join' ],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_PATROL_INFO ] ],
+ null,
+ [],
+ [ 'rc_patrolled', 'rc_log_type' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_SIZES ] ],
+ null,
+ [],
+ [ 'rc_old_len', 'rc_new_len' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_LOG_INFO ] ],
+ null,
+ [],
+ [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'namespaceIds' => [ 0, 1 ] ],
+ null,
+ [],
+ [],
+ [ 'wl_namespace' => [ 0, 1 ] ],
+ [],
+ [],
+ ],
+ [
+ [ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ] ],
+ null,
+ [],
+ [],
+ [ 'wl_namespace' => [ 0, 1 ] ],
+ [],
+ [],
+ ],
+ [
+ [ 'rcTypes' => [ RC_EDIT, RC_NEW ] ],
+ null,
+ [],
+ [],
+ [ 'rc_type' => [ RC_EDIT, RC_NEW ] ],
+ [],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ null,
+ [],
+ [],
+ [],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER ],
+ null,
+ [],
+ [],
+ [],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'start' => '20151212010101' ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp <= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'end' => '20151212010101' ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp >= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [
+ 'dir' => WatchedItemQueryService::DIR_OLDER,
+ 'start' => '20151212020101',
+ 'end' => '20151212010101'
+ ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp <= '20151212020101'", "rc_timestamp >= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'start' => '20151212010101' ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp >= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'end' => '20151212010101' ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp <= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [
+ 'dir' => WatchedItemQueryService::DIR_NEWER,
+ 'start' => '20151212010101',
+ 'end' => '20151212020101'
+ ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp >= '20151212010101'", "rc_timestamp <= '20151212020101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [ 'limit' => 10 ],
+ null,
+ [],
+ [],
+ [],
+ [ 'LIMIT' => 11 ],
+ [],
+ ],
+ [
+ [ 'limit' => "10; DROP TABLE watchlist;\n--" ],
+ null,
+ [],
+ [],
+ [],
+ [ 'LIMIT' => 11 ],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_MINOR ] ],
+ null,
+ [],
+ [],
+ [ 'rc_minor != 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_MINOR ] ],
+ null,
+ [],
+ [],
+ [ 'rc_minor = 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_BOT ] ],
+ null,
+ [],
+ [],
+ [ 'rc_bot != 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_BOT ] ],
+ null,
+ [],
+ [],
+ [ 'rc_bot = 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_ANON ] ],
+ null,
+ [ 'actormigration' => 'table' ],
+ [],
+ [ 'actormigration is anon' ],
+ [],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ],
+ null,
+ [ 'actormigration' => 'table' ],
+ [],
+ [ 'actormigration is not anon' ],
+ [],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_PATROLLED ] ],
+ null,
+ [],
+ [],
+ [ 'rc_patrolled != 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_PATROLLED ] ],
+ null,
+ [],
+ [],
+ [ 'rc_patrolled' => 0 ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_UNREAD ] ],
+ null,
+ [],
+ [],
+ [ 'rc_timestamp >= wl_notificationtimestamp' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_UNREAD ] ],
+ null,
+ [],
+ [],
+ [ 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp' ],
+ [],
+ [],
+ ],
+ [
+ [ 'onlyByUser' => 'SomeOtherUser' ],
+ null,
+ [ 'actormigration' => 'table' ],
+ [],
+ [ 'actormigration_conds' ],
+ [],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'notByUser' => 'SomeOtherUser' ],
+ null,
+ [ 'actormigration' => 'table' ],
+ [],
+ [ 'NOT(actormigration_conds)' ],
+ [],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101', 123 ],
+ [],
+ [],
+ [
+ "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))"
+ ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER ],
+ [ '20151212010101', 123 ],
+ [],
+ [],
+ [
+ "(rc_timestamp > '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id >= 123))"
+ ],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101', "123; DROP TABLE watchlist;\n--" ],
+ [],
+ [],
+ [
+ "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))"
+ ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider getWatchedItemsWithRecentChangeInfoOptionsProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_optionsAndEmptyResult(
+ array $options,
+ $startFrom,
+ array $expectedExtraTables,
+ array $expectedExtraFields,
+ array $expectedExtraConds,
+ array $expectedDbOptions,
+ array $expectedExtraJoinConds
+ ) {
+ $expectedTables = array_merge( [ 'recentchanges', 'watchlist', 'page' ], $expectedExtraTables );
+ $expectedFields = array_merge(
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ ],
+ $expectedExtraFields
+ );
+ $expectedConds = array_merge(
+ [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)', ],
+ $expectedExtraConds
+ );
+ $expectedJoinConds = array_merge(
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ ],
+ $expectedExtraJoinConds
+ );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ $expectedTables,
+ $expectedFields,
+ $expectedConds,
+ $this->isType( 'string' ),
+ $expectedDbOptions,
+ $expectedJoinConds
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom );
+
+ $this->assertEmpty( $items );
+ $this->assertNull( $startFrom );
+ }
+
+ public function filterPatrolledOptionProvider() {
+ return [
+ [ WatchedItemQueryService::FILTER_PATROLLED ],
+ [ WatchedItemQueryService::FILTER_NOT_PATROLLED ],
+ ];
+ }
+
+ /**
+ * @dataProvider filterPatrolledOptionProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_filterPatrolledAndUserWithNoPatrolRights(
+ $filtersOption
+ ) {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ $this->isType( 'array' ),
+ [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ],
+ $this->isType( 'string' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $user = $this->getMockNonAnonUserWithIdAndNoPatrolRights( 1 );
+
+ $queryService = $this->newService( $mockDb );
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'filters' => [ $filtersOption ] ]
+ );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function mysqlIndexOptimizationProvider() {
+ return [
+ [
+ 'mysql',
+ [],
+ [ "rc_timestamp > ''" ],
+ ],
+ [
+ 'mysql',
+ [ 'start' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ "rc_timestamp <= '20151212010101'" ],
+ ],
+ [
+ 'mysql',
+ [ 'end' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ "rc_timestamp >= '20151212010101'" ],
+ ],
+ [
+ 'postgres',
+ [],
+ [],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider mysqlIndexOptimizationProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_mysqlIndexOptimization(
+ $dbType,
+ array $options,
+ array $expectedExtraConds
+ ) {
+ $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ];
+ $conds = array_merge( $commonConds, $expectedExtraConds );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ $this->isType( 'array' ),
+ $conds,
+ $this->isType( 'string' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnValue( [] ) );
+ $mockDb->expects( $this->any() )
+ ->method( 'getType' )
+ ->will( $this->returnValue( $dbType ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function userPermissionRelatedExtraChecksProvider() {
+ return [
+ [
+ [],
+ 'deletedhistory',
+ [],
+ [
+ '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' .
+ LogPage::DELETED_ACTION . ')'
+ ],
+ [],
+ ],
+ [
+ [],
+ 'suppressrevision',
+ [],
+ [
+ '(rc_type != ' . RC_LOG . ') OR (' .
+ '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+ ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
+ ],
+ [],
+ ],
+ [
+ [],
+ 'viewsuppressed',
+ [],
+ [
+ '(rc_type != ' . RC_LOG . ') OR (' .
+ '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+ ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
+ ],
+ [],
+ ],
+ [
+ [ 'onlyByUser' => 'SomeOtherUser' ],
+ 'deletedhistory',
+ [ 'actormigration' => 'table' ],
+ [
+ 'actormigration_conds',
+ '(rc_deleted & ' . Revision::DELETED_USER . ') != ' . Revision::DELETED_USER,
+ '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' .
+ LogPage::DELETED_ACTION . ')'
+ ],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'onlyByUser' => 'SomeOtherUser' ],
+ 'suppressrevision',
+ [ 'actormigration' => 'table' ],
+ [
+ 'actormigration_conds',
+ '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' .
+ ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ),
+ '(rc_type != ' . RC_LOG . ') OR (' .
+ '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+ ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
+ ],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'onlyByUser' => 'SomeOtherUser' ],
+ 'viewsuppressed',
+ [ 'actormigration' => 'table' ],
+ [
+ 'actormigration_conds',
+ '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' .
+ ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ),
+ '(rc_type != ' . RC_LOG . ') OR (' .
+ '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+ ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
+ ],
+ [ 'actormigration' => 'join' ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider userPermissionRelatedExtraChecksProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_userPermissionRelatedExtraChecks(
+ array $options,
+ $notAllowedAction,
+ array $expectedExtraTables,
+ array $expectedExtraConds,
+ array $expectedExtraJoins
+ ) {
+ $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ];
+ $conds = array_merge( $commonConds, $expectedExtraConds );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ array_merge( [ 'recentchanges', 'watchlist', 'page' ], $expectedExtraTables ),
+ $this->isType( 'array' ),
+ $conds,
+ $this->isType( 'string' ),
+ $this->isType( 'array' ),
+ array_merge( [
+ 'watchlist' => [ 'INNER JOIN', [ 'wl_namespace=rc_namespace', 'wl_title=rc_title' ] ],
+ 'page' => [ 'LEFT JOIN', 'rc_cur_id=page_id' ],
+ ], $expectedExtraJoins )
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $user = $this->getMockNonAnonUserWithIdAndRestrictedPermissions( 1, $notAllowedAction );
+
+ $queryService = $this->newService( $mockDb );
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_allRevisionsOptionAndEmptyResult() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ ],
+ [ 'wl_user' => 1, ],
+ $this->isType( 'string' ),
+ [],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, [ 'allRevisions' => true ] );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider() {
+ return [
+ [
+ [ 'rcTypes' => [ 1337 ] ],
+ null,
+ 'Bad value for parameter $options[\'rcTypes\']',
+ ],
+ [
+ [ 'rcTypes' => [ 'edit' ] ],
+ null,
+ 'Bad value for parameter $options[\'rcTypes\']',
+ ],
+ [
+ [ 'rcTypes' => [ RC_EDIT, 1337 ] ],
+ null,
+ 'Bad value for parameter $options[\'rcTypes\']',
+ ],
+ [
+ [ 'dir' => 'foo' ],
+ null,
+ 'Bad value for parameter $options[\'dir\']',
+ ],
+ [
+ [ 'start' => '20151212010101' ],
+ null,
+ 'Bad value for parameter $options[\'dir\']: must be provided',
+ ],
+ [
+ [ 'end' => '20151212010101' ],
+ null,
+ 'Bad value for parameter $options[\'dir\']: must be provided',
+ ],
+ [
+ [],
+ [ '20151212010101', 123 ],
+ 'Bad value for parameter $options[\'dir\']: must be provided',
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ '20151212010101',
+ 'Bad value for parameter $startFrom: must be a two-element array',
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101' ],
+ 'Bad value for parameter $startFrom: must be a two-element array',
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101', 123, 'foo' ],
+ 'Bad value for parameter $startFrom: must be a two-element array',
+ ],
+ [
+ [ 'watchlistOwner' => $this->getMockUnrestrictedNonAnonUserWithId( 2 ) ],
+ null,
+ 'Bad value for parameter $options[\'watchlistOwnerToken\']',
+ ],
+ [
+ [ 'watchlistOwner' => 'Other User', 'watchlistOwnerToken' => 'some-token' ],
+ null,
+ 'Bad value for parameter $options[\'watchlistOwner\']',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_invalidOptions(
+ array $options,
+ $startFrom,
+ $expectedInExceptionMessage
+ ) {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( $this->anything() );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage );
+ $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorOptionAndEmptyResult() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_cur_id',
+ ],
+ [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ],
+ $this->isType( 'string' ),
+ [],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'usedInGenerator' => true ]
+ );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorAllRevisionsOptions() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_this_oldid',
+ ],
+ [ 'wl_user' => 1 ],
+ $this->isType( 'string' ),
+ [],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'usedInGenerator' => true, 'allRevisions' => true, ]
+ );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerOptionAndEmptyResult() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ [
+ 'wl_user' => 2,
+ '(rc_this_oldid=page_latest) OR (rc_type=3)',
+ ],
+ $this->isType( 'string' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+ $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 );
+ $otherUser->expects( $this->once() )
+ ->method( 'getOption' )
+ ->with( 'watchlisttoken' )
+ ->willReturn( '0123456789abcdef' );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => '0123456789abcdef' ]
+ );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function invalidWatchlistTokenProvider() {
+ return [
+ [ 'wrongToken' ],
+ [ '' ],
+ ];
+ }
+
+ /**
+ * @dataProvider invalidWatchlistTokenProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerAndInvalidToken( $token ) {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( $this->anything() );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+ $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 );
+ $otherUser->expects( $this->once() )
+ ->method( 'getOption' )
+ ->with( 'watchlisttoken' )
+ ->willReturn( '0123456789abcdef' );
+
+ $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' );
+ $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => $token ]
+ );
+ }
+
+ public function testGetWatchedItemsForUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [ 'wl_user' => 1 ]
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow( [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Foo1',
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow( [
+ 'wl_namespace' => 1,
+ 'wl_title' => 'Foo2',
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ ] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsForUser( $user );
+
+ $this->assertInternalType( 'array', $items );
+ $this->assertCount( 2, $items );
+ $this->assertContainsOnlyInstancesOf( WatchedItem::class, $items );
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+ $items[0]
+ );
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+ $items[1]
+ );
+ }
+
+ public function provideGetWatchedItemsForUserOptions() {
+ return [
+ [
+ [ 'namespaceIds' => [ 0, 1 ], ],
+ [ 'wl_namespace' => [ 0, 1 ], ],
+ []
+ ],
+ [
+ [ 'sort' => WatchedItemQueryService::SORT_ASC, ],
+ [],
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ ],
+ [
+ [
+ 'namespaceIds' => [ 0 ],
+ 'sort' => WatchedItemQueryService::SORT_ASC,
+ ],
+ [ 'wl_namespace' => [ 0 ], ],
+ [ 'ORDER BY' => 'wl_title ASC' ]
+ ],
+ [
+ [ 'limit' => 10 ],
+ [],
+ [ 'LIMIT' => 10 ]
+ ],
+ [
+ [
+ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ],
+ 'limit' => "10; DROP TABLE watchlist;\n--",
+ ],
+ [ 'wl_namespace' => [ 0, 1 ], ],
+ [ 'LIMIT' => 10 ]
+ ],
+ [
+ [ 'filter' => WatchedItemQueryService::FILTER_CHANGED ],
+ [ 'wl_notificationtimestamp IS NOT NULL' ],
+ []
+ ],
+ [
+ [ 'filter' => WatchedItemQueryService::FILTER_NOT_CHANGED ],
+ [ 'wl_notificationtimestamp IS NULL' ],
+ []
+ ],
+ [
+ [ 'sort' => WatchedItemQueryService::SORT_DESC, ],
+ [],
+ [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
+ ],
+ [
+ [
+ 'namespaceIds' => [ 0 ],
+ 'sort' => WatchedItemQueryService::SORT_DESC,
+ ],
+ [ 'wl_namespace' => [ 0 ], ],
+ [ 'ORDER BY' => 'wl_title DESC' ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetWatchedItemsForUserOptions
+ */
+ public function testGetWatchedItemsForUser_optionsAndEmptyResult(
+ array $options,
+ array $expectedConds,
+ array $expectedDbOptions
+ ) {
+ $mockDb = $this->getMockDb();
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ $expectedConds,
+ $this->isType( 'string' ),
+ $expectedDbOptions
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+
+ $items = $queryService->getWatchedItemsForUser( $user, $options );
+ $this->assertEmpty( $items );
+ }
+
+ public function provideGetWatchedItemsForUser_fromUntilStartFromOptions() {
+ return [
+ [
+ [
+ 'from' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_ASC
+ ],
+ [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ],
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ ],
+ [
+ [
+ 'from' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_DESC,
+ ],
+ [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ],
+ [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
+ ],
+ [
+ [
+ 'until' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_ASC
+ ],
+ [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ],
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ ],
+ [
+ [
+ 'until' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_DESC
+ ],
+ [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ],
+ [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
+ ],
+ [
+ [
+ 'from' => new TitleValue( 0, 'AnotherDbKey' ),
+ 'until' => new TitleValue( 0, 'SomeOtherDbKey' ),
+ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_ASC
+ ],
+ [
+ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))",
+ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))",
+ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))",
+ ],
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ ],
+ [
+ [
+ 'from' => new TitleValue( 0, 'SomeOtherDbKey' ),
+ 'until' => new TitleValue( 0, 'AnotherDbKey' ),
+ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_DESC
+ ],
+ [
+ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))",
+ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))",
+ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))",
+ ],
+ [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetWatchedItemsForUser_fromUntilStartFromOptions
+ */
+ public function testGetWatchedItemsForUser_fromUntilStartFromOptions(
+ array $options,
+ array $expectedConds,
+ array $expectedDbOptions
+ ) {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->any() )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+ $mockDb->expects( $this->any() )
+ ->method( 'makeList' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'int' )
+ )
+ ->will( $this->returnCallback( function ( $a, $conj ) {
+ $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
+ return implode( $sqlConj, array_map( function ( $s ) {
+ return '(' . $s . ')';
+ }, $a
+ ) );
+ } ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ $expectedConds,
+ $this->isType( 'string' ),
+ $expectedDbOptions
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+
+ $items = $queryService->getWatchedItemsForUser( $user, $options );
+ $this->assertEmpty( $items );
+ }
+
+ public function getWatchedItemsForUserInvalidOptionsProvider() {
+ return [
+ [
+ [ 'sort' => 'foo' ],
+ 'Bad value for parameter $options[\'sort\']'
+ ],
+ [
+ [ 'filter' => 'foo' ],
+ 'Bad value for parameter $options[\'filter\']'
+ ],
+ [
+ [ 'from' => new TitleValue( 0, 'SomeDbKey' ), ],
+ 'Bad value for parameter $options[\'sort\']: must be provided'
+ ],
+ [
+ [ 'until' => new TitleValue( 0, 'SomeDbKey' ), ],
+ 'Bad value for parameter $options[\'sort\']: must be provided'
+ ],
+ [
+ [ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), ],
+ 'Bad value for parameter $options[\'sort\']: must be provided'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider getWatchedItemsForUserInvalidOptionsProvider
+ */
+ public function testGetWatchedItemsForUser_invalidOptionThrowsException(
+ array $options,
+ $expectedInExceptionMessage
+ ) {
+ $queryService = $this->newService( $this->getMockDb() );
+
+ $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage );
+ $queryService->getWatchedItemsForUser( $this->getMockNonAnonUserWithId( 1 ), $options );
+ }
+
+ public function testGetWatchedItemsForUser_userNotAllowedToViewWatchlist() {
+ $mockDb = $this->getMockDb();
+
+ $mockDb->expects( $this->never() )
+ ->method( $this->anything() );
+
+ $queryService = $this->newService( $mockDb );
+
+ $items = $queryService->getWatchedItemsForUser( $this->getMockAnonUser() );
+ $this->assertEmpty( $items );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php
new file mode 100644
index 00000000..3102929e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php
@@ -0,0 +1,231 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @author Addshore
+ *
+ * @group Database
+ *
+ * @covers WatchedItemStore
+ */
+class WatchedItemStoreIntegrationTest extends MediaWikiTestCase {
+
+ public function setUp() {
+ parent::setUp();
+ self::$users['WatchedItemStoreIntegrationTestUser']
+ = new TestUser( 'WatchedItemStoreIntegrationTestUser' );
+ }
+
+ private function getUser() {
+ return self::$users['WatchedItemStoreIntegrationTestUser']->getUser();
+ }
+
+ public function testWatchAndUnWatchItem() {
+ $user = $this->getUser();
+ $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ // Cleanup after previous tests
+ $store->removeWatch( $user, $title );
+ $initialWatchers = $store->countWatchers( $title );
+ $initialUserWatchedItems = $store->countWatchedItems( $user );
+
+ $this->assertFalse(
+ $store->isWatched( $user, $title ),
+ 'Page should not initially be watched'
+ );
+
+ $store->addWatch( $user, $title );
+ $this->assertTrue(
+ $store->isWatched( $user, $title ),
+ 'Page should be watched'
+ );
+ $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );
+ $watchedItemsForUser = $store->getWatchedItemsForUser( $user );
+ $this->assertCount( $initialUserWatchedItems + 1, $watchedItemsForUser );
+ $watchedItemsForUserHasExpectedItem = false;
+ foreach ( $watchedItemsForUser as $watchedItem ) {
+ if (
+ $watchedItem->getUser()->equals( $user ) &&
+ $watchedItem->getLinkTarget() == $title->getTitleValue()
+ ) {
+ $watchedItemsForUserHasExpectedItem = true;
+ }
+ }
+ $this->assertTrue(
+ $watchedItemsForUserHasExpectedItem,
+ 'getWatchedItemsForUser should contain the page'
+ );
+ $this->assertEquals( $initialWatchers + 1, $store->countWatchers( $title ) );
+ $this->assertEquals(
+ $initialWatchers + 1,
+ $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
+ );
+ $this->assertEquals(
+ [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialWatchers + 1 ] ],
+ $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 1 ] )
+ );
+ $this->assertEquals(
+ [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
+ $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 2 ] )
+ );
+ $this->assertEquals(
+ [ $title->getNamespace() => [ $title->getDBkey() => null ] ],
+ $store->getNotificationTimestampsBatch( $user, [ $title ] )
+ );
+
+ $store->removeWatch( $user, $title );
+ $this->assertFalse(
+ $store->isWatched( $user, $title ),
+ 'Page should be unwatched'
+ );
+ $this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) );
+ $watchedItemsForUser = $store->getWatchedItemsForUser( $user );
+ $this->assertCount( $initialUserWatchedItems, $watchedItemsForUser );
+ $watchedItemsForUserHasExpectedItem = false;
+ foreach ( $watchedItemsForUser as $watchedItem ) {
+ if (
+ $watchedItem->getUser()->equals( $user ) &&
+ $watchedItem->getLinkTarget() == $title->getTitleValue()
+ ) {
+ $watchedItemsForUserHasExpectedItem = true;
+ }
+ }
+ $this->assertFalse(
+ $watchedItemsForUserHasExpectedItem,
+ 'getWatchedItemsForUser should not contain the page'
+ );
+ $this->assertEquals( $initialWatchers, $store->countWatchers( $title ) );
+ $this->assertEquals(
+ $initialWatchers,
+ $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
+ );
+ $this->assertEquals(
+ [ $title->getNamespace() => [ $title->getDBkey() => false ] ],
+ $store->getNotificationTimestampsBatch( $user, [ $title ] )
+ );
+ }
+
+ public function testWatchBatchAndClearItems() {
+ $user = $this->getUser();
+ $title1 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage1' );
+ $title2 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage2' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $user, [ $title1, $title2 ] );
+
+ $this->assertTrue( $store->isWatched( $user, $title1 ) );
+ $this->assertTrue( $store->isWatched( $user, $title2 ) );
+
+ $store->clearUserWatchedItems( $user );
+
+ $this->assertFalse( $store->isWatched( $user, $title1 ) );
+ $this->assertFalse( $store->isWatched( $user, $title2 ) );
+ }
+
+ public function testUpdateResetAndSetNotificationTimestamp() {
+ $user = $this->getUser();
+ $otherUser = ( new TestUser( 'WatchedItemStoreIntegrationTestUser_otherUser' ) )->getUser();
+ $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $store->addWatch( $user, $title );
+ $this->assertNull( $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() );
+ $initialVisitingWatchers = $store->countVisitingWatchers( $title, '20150202020202' );
+ $initialUnreadNotifications = $store->countUnreadNotifications( $user );
+
+ $store->updateNotificationTimestamp( $otherUser, $title, '20150202010101' );
+ $this->assertEquals(
+ '20150202010101',
+ $store->loadWatchedItem( $user, $title )->getNotificationTimestamp()
+ );
+ $this->assertEquals(
+ [ $title->getNamespace() => [ $title->getDBkey() => '20150202010101' ] ],
+ $store->getNotificationTimestampsBatch( $user, [ $title ] )
+ );
+ $this->assertEquals(
+ $initialVisitingWatchers - 1,
+ $store->countVisitingWatchers( $title, '20150202020202' )
+ );
+ $this->assertEquals(
+ $initialVisitingWatchers - 1,
+ $store->countVisitingWatchersMultiple(
+ [ [ $title, '20150202020202' ] ]
+ )[$title->getNamespace()][$title->getDBkey()]
+ );
+ $this->assertEquals(
+ $initialUnreadNotifications + 1,
+ $store->countUnreadNotifications( $user )
+ );
+ $this->assertSame(
+ true,
+ $store->countUnreadNotifications( $user, $initialUnreadNotifications + 1 )
+ );
+
+ $this->assertTrue( $store->resetNotificationTimestamp( $user, $title ) );
+ $this->assertNull( $store->getWatchedItem( $user, $title )->getNotificationTimestamp() );
+ $this->assertEquals(
+ [ $title->getNamespace() => [ $title->getDBkey() => null ] ],
+ $store->getNotificationTimestampsBatch( $user, [ $title ] )
+ );
+ $this->assertEquals(
+ $initialVisitingWatchers,
+ $store->countVisitingWatchers( $title, '20150202020202' )
+ );
+ $this->assertEquals(
+ $initialVisitingWatchers,
+ $store->countVisitingWatchersMultiple(
+ [ [ $title, '20150202020202' ] ]
+ )[$title->getNamespace()][$title->getDBkey()]
+ );
+ $this->assertEquals(
+ [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialVisitingWatchers ] ],
+ $store->countVisitingWatchersMultiple(
+ [ [ $title, '20150202020202' ] ], $initialVisitingWatchers
+ )
+ );
+ $this->assertEquals(
+ [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
+ $store->countVisitingWatchersMultiple(
+ [ [ $title, '20150202020202' ] ], $initialVisitingWatchers + 1
+ )
+ );
+
+ // setNotificationTimestampsForUser specifying a title
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, '20200202020202', [ $title ] )
+ );
+ $this->assertEquals(
+ '20200202020202',
+ $store->getWatchedItem( $user, $title )->getNotificationTimestamp()
+ );
+
+ // setNotificationTimestampsForUser not specifying a title
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, '20210202020202' )
+ );
+ $this->assertEquals(
+ '20210202020202',
+ $store->getWatchedItem( $user, $title )->getNotificationTimestamp()
+ );
+ }
+
+ public function testDuplicateAllAssociatedEntries() {
+ $user = $this->getUser();
+ $titleOld = Title::newFromText( 'WatchedItemStoreIntegrationTestPageOld' );
+ $titleNew = Title::newFromText( 'WatchedItemStoreIntegrationTestPageNew' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $store->addWatch( $user, $titleOld->getSubjectPage() );
+ $store->addWatch( $user, $titleOld->getTalkPage() );
+ // Cleanup after previous tests
+ $store->removeWatch( $user, $titleNew->getSubjectPage() );
+ $store->removeWatch( $user, $titleNew->getTalkPage() );
+
+ $store->duplicateAllAssociatedEntries( $titleOld, $titleNew );
+
+ $this->assertTrue( $store->isWatched( $user, $titleOld->getSubjectPage() ) );
+ $this->assertTrue( $store->isWatched( $user, $titleOld->getTalkPage() ) );
+ $this->assertTrue( $store->isWatched( $user, $titleNew->getSubjectPage() ) );
+ $this->assertTrue( $store->isWatched( $user, $titleNew->getTalkPage() ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php
new file mode 100644
index 00000000..26f69088
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php
@@ -0,0 +1,2753 @@
+<?php
+use MediaWiki\Linker\LinkTarget;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\ScopedCallback;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @author Addshore
+ *
+ * @covers WatchedItemStore
+ */
+class WatchedItemStoreUnitTest extends MediaWikiTestCase {
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|IDatabase
+ */
+ private function getMockDb() {
+ return $this->createMock( IDatabase::class );
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer
+ */
+ private function getMockLoadBalancer(
+ $mockDb,
+ $expectedConnectionType = null
+ ) {
+ $mock = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ if ( $expectedConnectionType !== null ) {
+ $mock->expects( $this->any() )
+ ->method( 'getConnectionRef' )
+ ->with( $expectedConnectionType )
+ ->will( $this->returnValue( $mockDb ) );
+ } else {
+ $mock->expects( $this->any() )
+ ->method( 'getConnectionRef' )
+ ->will( $this->returnValue( $mockDb ) );
+ }
+ return $mock;
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|HashBagOStuff
+ */
+ private function getMockCache() {
+ $mock = $this->getMockBuilder( HashBagOStuff::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'get', 'set', 'delete', 'makeKey' ] )
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'makeKey' )
+ ->will( $this->returnCallback( function () {
+ return implode( ':', func_get_args() );
+ } ) );
+ return $mock;
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|ReadOnlyMode
+ */
+ private function getMockReadOnlyMode( $readOnly = false ) {
+ $mock = $this->getMockBuilder( ReadOnlyMode::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'isReadOnly' )
+ ->will( $this->returnValue( $readOnly ) );
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockNonAnonUserWithId( $id ) {
+ $mock = $this->createMock( User::class );
+ $mock->expects( $this->any() )
+ ->method( 'isAnon' )
+ ->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )
+ ->method( 'getId' )
+ ->will( $this->returnValue( $id ) );
+ return $mock;
+ }
+
+ /**
+ * @return User
+ */
+ private function getAnonUser() {
+ return User::newFromName( 'Anon_User' );
+ }
+
+ private function getFakeRow( array $rowValues ) {
+ $fakeRow = new stdClass();
+ foreach ( $rowValues as $valueName => $value ) {
+ $fakeRow->$valueName = $value;
+ }
+ return $fakeRow;
+ }
+
+ private function newWatchedItemStore( LoadBalancer $loadBalancer, HashBagOStuff $cache,
+ ReadOnlyMode $readOnlyMode
+ ) {
+ return new WatchedItemStore(
+ $loadBalancer,
+ $cache,
+ $readOnlyMode,
+ 1000
+ );
+ }
+
+ public function testClearWatchedItems() {
+ $user = $this->getMockNonAnonUserWithId( 7 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_user' => $user->getId(),
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 12 ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'delete' )
+ ->with(
+ 'watchlist',
+ [ 'wl_user' => 7 ],
+ $this->isType( 'string' )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( 'RM-KEY' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+ TestingAccessWrapper::newFromObject( $store )
+ ->cacheIndex = [ 0 => [ 'F' => [ 7 => 'RM-KEY', 9 => 'KEEP-KEY' ] ] ];
+
+ $this->assertTrue( $store->clearUserWatchedItems( $user ) );
+ }
+
+ public function testClearWatchedItems_tooManyItemsWatched() {
+ $user = $this->getMockNonAnonUserWithId( 7 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_user' => $user->getId(),
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 99999 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse( $store->clearUserWatchedItems( $user ) );
+ }
+
+ public function testCountWatchedItems() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_user' => $user->getId(),
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( '12' ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals( 12, $store->countWatchedItems( $user ) );
+ }
+
+ public function testCountWatchers() {
+ $titleValue = new TitleValue( 0, 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_namespace' => $titleValue->getNamespace(),
+ 'wl_title' => $titleValue->getDBkey(),
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( '7' ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals( 7, $store->countWatchers( $titleValue ) );
+ }
+
+ public function testCountWatchersMultiple() {
+ $titleValues = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 0, 'OtherDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+
+ $dbResult = [
+ $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] ),
+ $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ] ),
+ $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ]
+ ),
+ ];
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
+ [ 'makeWhereFrom2d return value' ],
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ ]
+ )
+ ->will(
+ $this->returnValue( $dbResult )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
+ 1 => [ 'AnotherDbKey' => 500 ],
+ ];
+ $this->assertEquals( $expected, $store->countWatchersMultiple( $titleValues ) );
+ }
+
+ public function provideIntWithDbUnsafeVersion() {
+ return [
+ [ 50 ],
+ [ "50; DROP TABLE watchlist;\n--" ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIntWithDbUnsafeVersion
+ */
+ public function testCountWatchersMultiple_withMinimumWatchers( $minWatchers ) {
+ $titleValues = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 0, 'OtherDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+
+ $dbResult = [
+ $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] ),
+ $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ] ),
+ $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ]
+ ),
+ ];
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
+ [ 'makeWhereFrom2d return value' ],
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ 'HAVING' => 'COUNT(*) >= 50',
+ ]
+ )
+ ->will(
+ $this->returnValue( $dbResult )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
+ 1 => [ 'AnotherDbKey' => 500 ],
+ ];
+ $this->assertEquals(
+ $expected,
+ $store->countWatchersMultiple( $titleValues, [ 'minimumWatchers' => $minWatchers ] )
+ );
+ }
+
+ public function testCountVisitingWatchers() {
+ $titleValue = new TitleValue( 0, 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_namespace' => $titleValue->getNamespace(),
+ 'wl_title' => $titleValue->getDBkey(),
+ 'wl_notificationtimestamp >= \'TS111TS\' OR wl_notificationtimestamp IS NULL',
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( '7' ) );
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals( 7, $store->countVisitingWatchers( $titleValue, '111' ) );
+ }
+
+ public function testCountVisitingWatchersMultiple() {
+ $titleValuesWithThresholds = [
+ [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
+ [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
+ [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
+ ];
+
+ $dbResult = [
+ $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] ),
+ $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ] ),
+ $this->getFakeRow(
+ [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ]
+ ),
+ ];
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 2 * 3 ) )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+ $mockDb->expects( $this->exactly( 3 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+ $mockDb->expects( $this->any() )
+ ->method( 'makeList' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'int' )
+ )
+ ->will( $this->returnCallback( function ( $a, $conj ) {
+ $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
+ return implode( $sqlConj, array_map( function ( $s ) {
+ return '(' . $s . ')';
+ }, $a
+ ) );
+ } ) );
+ $mockDb->expects( $this->never() )
+ ->method( 'makeWhereFrom2d' );
+
+ $expectedCond =
+ '((wl_namespace = 0) AND (' .
+ "(((wl_title = 'SomeDbKey') AND (" .
+ "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
+ ')) OR (' .
+ "(wl_title = 'OtherDbKey') AND (" .
+ "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
+ '))))' .
+ ') OR ((wl_namespace = 1) AND (' .
+ "(((wl_title = 'AnotherDbKey') AND (".
+ "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" .
+ ')))))';
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+ $expectedCond,
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ ]
+ )
+ ->will(
+ $this->returnValue( $dbResult )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
+ 1 => [ 'AnotherDbKey' => 500 ],
+ ];
+ $this->assertEquals(
+ $expected,
+ $store->countVisitingWatchersMultiple( $titleValuesWithThresholds )
+ );
+ }
+
+ public function testCountVisitingWatchersMultiple_withMissingTargets() {
+ $titleValuesWithThresholds = [
+ [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
+ [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
+ [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
+ [ new TitleValue( 0, 'SomeNotExisitingDbKey' ), null ],
+ [ new TitleValue( 0, 'OtherNotExisitingDbKey' ), null ],
+ ];
+
+ $dbResult = [
+ $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] ),
+ $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ] ),
+ $this->getFakeRow(
+ [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ]
+ ),
+ $this->getFakeRow(
+ [ 'wl_title' => 'SomeNotExisitingDbKey', 'wl_namespace' => '0', 'watchers' => '100' ]
+ ),
+ $this->getFakeRow(
+ [ 'wl_title' => 'OtherNotExisitingDbKey', 'wl_namespace' => '0', 'watchers' => '200' ]
+ ),
+ ];
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 2 * 3 ) )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+ $mockDb->expects( $this->exactly( 3 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+ $mockDb->expects( $this->any() )
+ ->method( 'makeList' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'int' )
+ )
+ ->will( $this->returnCallback( function ( $a, $conj ) {
+ $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
+ return implode( $sqlConj, array_map( function ( $s ) {
+ return '(' . $s . ')';
+ }, $a
+ ) );
+ } ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'SomeNotExisitingDbKey' => 1, 'OtherNotExisitingDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+
+ $expectedCond =
+ '((wl_namespace = 0) AND (' .
+ "(((wl_title = 'SomeDbKey') AND (" .
+ "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
+ ')) OR (' .
+ "(wl_title = 'OtherDbKey') AND (" .
+ "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
+ '))))' .
+ ') OR ((wl_namespace = 1) AND (' .
+ "(((wl_title = 'AnotherDbKey') AND (".
+ "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" .
+ '))))' .
+ ') OR ' .
+ '(makeWhereFrom2d return value)';
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+ $expectedCond,
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ ]
+ )
+ ->will(
+ $this->returnValue( $dbResult )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [
+ 'SomeDbKey' => 100, 'OtherDbKey' => 300,
+ 'SomeNotExisitingDbKey' => 100, 'OtherNotExisitingDbKey' => 200
+ ],
+ 1 => [ 'AnotherDbKey' => 500 ],
+ ];
+ $this->assertEquals(
+ $expected,
+ $store->countVisitingWatchersMultiple( $titleValuesWithThresholds )
+ );
+ }
+
+ /**
+ * @dataProvider provideIntWithDbUnsafeVersion
+ */
+ public function testCountVisitingWatchersMultiple_withMinimumWatchers( $minWatchers ) {
+ $titleValuesWithThresholds = [
+ [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
+ [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
+ [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
+ ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->any() )
+ ->method( 'makeList' )
+ ->will( $this->returnValue( 'makeList return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+ 'makeList return value',
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ 'HAVING' => 'COUNT(*) >= 50',
+ ]
+ )
+ ->will(
+ $this->returnValue( [] )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [ 'SomeDbKey' => 0, 'OtherDbKey' => 0 ],
+ 1 => [ 'AnotherDbKey' => 0 ],
+ ];
+ $this->assertEquals(
+ $expected,
+ $store->countVisitingWatchersMultiple( $titleValuesWithThresholds, $minWatchers )
+ );
+ }
+
+ public function testCountUnreadNotifications() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectRowCount' )
+ ->with(
+ 'watchlist',
+ '1',
+ [
+ "wl_notificationtimestamp IS NOT NULL",
+ 'wl_user' => 1,
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( '9' ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals( 9, $store->countUnreadNotifications( $user ) );
+ }
+
+ /**
+ * @dataProvider provideIntWithDbUnsafeVersion
+ */
+ public function testCountUnreadNotifications_withUnreadLimit_overLimit( $limit ) {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectRowCount' )
+ ->with(
+ 'watchlist',
+ '1',
+ [
+ "wl_notificationtimestamp IS NOT NULL",
+ 'wl_user' => 1,
+ ],
+ $this->isType( 'string' ),
+ [ 'LIMIT' => 50 ]
+ )
+ ->will( $this->returnValue( '50' ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertSame(
+ true,
+ $store->countUnreadNotifications( $user, $limit )
+ );
+ }
+
+ /**
+ * @dataProvider provideIntWithDbUnsafeVersion
+ */
+ public function testCountUnreadNotifications_withUnreadLimit_underLimit( $limit ) {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectRowCount' )
+ ->with(
+ 'watchlist',
+ '1',
+ [
+ "wl_notificationtimestamp IS NOT NULL",
+ 'wl_user' => 1,
+ ],
+ $this->isType( 'string' ),
+ [ 'LIMIT' => 50 ]
+ )
+ ->will( $this->returnValue( '9' ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ 9,
+ $store->countUnreadNotifications( $user, $limit )
+ );
+ }
+
+ public function testDuplicateEntry_nothingToDuplicate() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Old_Title',
+ ],
+ 'WatchedItemStore::duplicateEntry',
+ [ 'FOR UPDATE' ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->duplicateEntry(
+ Title::newFromText( 'Old_Title' ),
+ Title::newFromText( 'New_Title' )
+ );
+ }
+
+ public function testDuplicateEntry_somethingToDuplicate() {
+ $fakeRows = [
+ $this->getFakeRow( [ 'wl_user' => '1', 'wl_notificationtimestamp' => '20151212010101' ] ),
+ $this->getFakeRow( [ 'wl_user' => '2', 'wl_notificationtimestamp' => null ] ),
+ ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->at( 0 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Old_Title',
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) );
+ $mockDb->expects( $this->at( 1 ) )
+ ->method( 'replace' )
+ ->with(
+ 'watchlist',
+ [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'New_Title',
+ 'wl_notificationtimestamp' => '20151212010101',
+ ],
+ [
+ 'wl_user' => 2,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'New_Title',
+ 'wl_notificationtimestamp' => null,
+ ],
+ ],
+ $this->isType( 'string' )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->duplicateEntry(
+ Title::newFromText( 'Old_Title' ),
+ Title::newFromText( 'New_Title' )
+ );
+ }
+
+ public function testDuplicateAllAssociatedEntries_nothingToDuplicate() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->at( 0 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Old_Title',
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
+ $mockDb->expects( $this->at( 1 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => 1,
+ 'wl_title' => 'Old_Title',
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->duplicateAllAssociatedEntries(
+ Title::newFromText( 'Old_Title' ),
+ Title::newFromText( 'New_Title' )
+ );
+ }
+
+ public function provideLinkTargetPairs() {
+ return [
+ [ Title::newFromText( 'Old_Title' ), Title::newFromText( 'New_Title' ) ],
+ [ new TitleValue( 0, 'Old_Title' ), new TitleValue( 0, 'New_Title' ) ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideLinkTargetPairs
+ */
+ public function testDuplicateAllAssociatedEntries_somethingToDuplicate(
+ LinkTarget $oldTarget,
+ LinkTarget $newTarget
+ ) {
+ $fakeRows = [
+ $this->getFakeRow( [ 'wl_user' => '1', 'wl_notificationtimestamp' => '20151212010101' ] ),
+ ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->at( 0 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => $oldTarget->getNamespace(),
+ 'wl_title' => $oldTarget->getDBkey(),
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) );
+ $mockDb->expects( $this->at( 1 ) )
+ ->method( 'replace' )
+ ->with(
+ 'watchlist',
+ [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => $newTarget->getNamespace(),
+ 'wl_title' => $newTarget->getDBkey(),
+ 'wl_notificationtimestamp' => '20151212010101',
+ ],
+ ],
+ $this->isType( 'string' )
+ );
+ $mockDb->expects( $this->at( 2 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => $oldTarget->getNamespace() + 1,
+ 'wl_title' => $oldTarget->getDBkey(),
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) );
+ $mockDb->expects( $this->at( 3 ) )
+ ->method( 'replace' )
+ ->with(
+ 'watchlist',
+ [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => $newTarget->getNamespace() + 1,
+ 'wl_title' => $newTarget->getDBkey(),
+ 'wl_notificationtimestamp' => '20151212010101',
+ ],
+ ],
+ $this->isType( 'string' )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->duplicateAllAssociatedEntries(
+ $oldTarget,
+ $newTarget
+ );
+ }
+
+ public function testAddWatch_nonAnonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'insert' )
+ ->with(
+ 'watchlist',
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Some_Page',
+ 'wl_notificationtimestamp' => null,
+ ]
+ ]
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:Some_Page:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->addWatch(
+ $this->getMockNonAnonUserWithId( 1 ),
+ Title::newFromText( 'Some_Page' )
+ );
+ }
+
+ public function testAddWatch_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'insert' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->addWatch(
+ $this->getAnonUser(),
+ Title::newFromText( 'Some_Page' )
+ );
+ }
+
+ public function testAddWatchBatchForUser_readOnlyDBReturnsFalse() {
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $this->getMockDb() ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode( true )
+ );
+
+ $this->assertFalse(
+ $store->addWatchBatchForUser(
+ $this->getMockNonAnonUserWithId( 1 ),
+ [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ]
+ )
+ );
+ }
+
+ public function testAddWatchBatchForUser_nonAnonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'insert' )
+ ->with(
+ 'watchlist',
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Some_Page',
+ 'wl_notificationtimestamp' => null,
+ ],
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 1,
+ 'wl_title' => 'Some_Page',
+ 'wl_notificationtimestamp' => null,
+ ]
+ ]
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->exactly( 2 ) )
+ ->method( 'delete' );
+ $mockCache->expects( $this->at( 1 ) )
+ ->method( 'delete' )
+ ->with( '0:Some_Page:1' );
+ $mockCache->expects( $this->at( 3 ) )
+ ->method( 'delete' )
+ ->with( '1:Some_Page:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $mockUser = $this->getMockNonAnonUserWithId( 1 );
+
+ $this->assertTrue(
+ $store->addWatchBatchForUser(
+ $mockUser,
+ [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ]
+ )
+ );
+ }
+
+ public function testAddWatchBatchForUser_anonymousUsersAreSkipped() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'insert' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->addWatchBatchForUser(
+ $this->getAnonUser(),
+ [ new TitleValue( 0, 'Other_Page' ) ]
+ )
+ );
+ }
+
+ public function testAddWatchBatchReturnsTrue_whenGivenEmptyList() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'insert' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->addWatchBatchForUser( $user, [] )
+ );
+ }
+
+ public function testLoadWatchedItem_existingItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with(
+ '0:SomeDbKey:1'
+ );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $watchedItem = $store->loadWatchedItem(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ );
+ $this->assertInstanceOf( WatchedItem::class, $watchedItem );
+ $this->assertEquals( 1, $watchedItem->getUser()->getId() );
+ $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() );
+ $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() );
+ }
+
+ public function testLoadWatchedItem_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->loadWatchedItem(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testLoadWatchedItem_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->loadWatchedItem(
+ $this->getAnonUser(),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testRemoveWatch_existingItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'delete' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ );
+ $mockDb->expects( $this->once() )
+ ->method( 'affectedRows' )
+ ->will( $this->returnValue( 1 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->removeWatch(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testRemoveWatch_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'delete' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ );
+ $mockDb->expects( $this->once() )
+ ->method( 'affectedRows' )
+ ->will( $this->returnValue( 0 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->removeWatch(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testRemoveWatch_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'delete' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->removeWatch(
+ $this->getAnonUser(),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testGetWatchedItem_existingItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with(
+ '0:SomeDbKey:1'
+ )
+ ->will( $this->returnValue( null ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with(
+ '0:SomeDbKey:1'
+ );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $watchedItem = $store->getWatchedItem(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ );
+ $this->assertInstanceOf( WatchedItem::class, $watchedItem );
+ $this->assertEquals( 1, $watchedItem->getUser()->getId() );
+ $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() );
+ $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() );
+ }
+
+ public function testGetWatchedItem_cachedItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockUser = $this->getMockNonAnonUserWithId( 1 );
+ $linkTarget = new TitleValue( 0, 'SomeDbKey' );
+ $cachedItem = new WatchedItem( $mockUser, $linkTarget, '20151212010101' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with(
+ '0:SomeDbKey:1'
+ )
+ ->will( $this->returnValue( $cachedItem ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ $cachedItem,
+ $store->getWatchedItem(
+ $mockUser,
+ $linkTarget
+ )
+ );
+ }
+
+ public function testGetWatchedItem_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( false ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->getWatchedItem(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testGetWatchedItem_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->getWatchedItem(
+ $this->getAnonUser(),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testGetWatchedItemsForUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [ 'wl_user' => 1 ]
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow( [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Foo1',
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow( [
+ 'wl_namespace' => 1,
+ 'wl_title' => 'Foo2',
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ ] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $watchedItems = $store->getWatchedItemsForUser( $user );
+
+ $this->assertInternalType( 'array', $watchedItems );
+ $this->assertCount( 2, $watchedItems );
+ foreach ( $watchedItems as $watchedItem ) {
+ $this->assertInstanceOf( WatchedItem::class, $watchedItem );
+ }
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+ $watchedItems[0]
+ );
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+ $watchedItems[1]
+ );
+ }
+
+ public function provideDbTypes() {
+ return [
+ [ false, DB_REPLICA ],
+ [ true, DB_MASTER ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideDbTypes
+ */
+ public function testGetWatchedItemsForUser_optionsAndEmptyResult( $forWrite, $dbType ) {
+ $mockDb = $this->getMockDb();
+ $mockCache = $this->getMockCache();
+ $mockLoadBalancer = $this->getMockLoadBalancer( $mockDb, $dbType );
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [ 'wl_user' => 1 ],
+ $this->isType( 'string' ),
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $store = $this->newWatchedItemStore(
+ $mockLoadBalancer,
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $watchedItems = $store->getWatchedItemsForUser(
+ $user,
+ [ 'forWrite' => $forWrite, 'sort' => WatchedItemStore::SORT_ASC ]
+ );
+ $this->assertEquals( [], $watchedItems );
+ }
+
+ public function testGetWatchedItemsForUser_badSortOptionThrowsException() {
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $this->getMockDb() ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->setExpectedException( InvalidArgumentException::class );
+ $store->getWatchedItemsForUser(
+ $this->getMockNonAnonUserWithId( 1 ),
+ [ 'sort' => 'foo' ]
+ );
+ }
+
+ public function testIsWatchedItem_existingItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( false ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with(
+ '0:SomeDbKey:1'
+ );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->isWatched(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testIsWatchedItem_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( false ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->isWatched(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testIsWatchedItem_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->isWatched(
+ $this->getAnonUser(),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch() {
+ $targets = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+ $dbResult = [
+ $this->getFakeRow( [
+ 'wl_namespace' => '0',
+ 'wl_title' => 'SomeDbKey',
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow(
+ [
+ 'wl_namespace' => '1',
+ 'wl_title' => 'AnotherDbKey',
+ 'wl_notificationtimestamp' => null,
+ ]
+ ),
+ ];
+
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'SomeDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [
+ 'makeWhereFrom2d return value',
+ 'wl_user' => 1
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( $dbResult ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->exactly( 2 ) )
+ ->method( 'get' )
+ ->withConsecutive(
+ [ '0:SomeDbKey:1' ],
+ [ '1:AnotherDbKey:1' ]
+ )
+ ->will( $this->returnValue( null ) );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'SomeDbKey' => '20151212010101', ],
+ 1 => [ 'AnotherDbKey' => null, ],
+ ],
+ $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch_notWatchedTarget() {
+ $targets = [
+ new TitleValue( 0, 'OtherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'OtherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [
+ 'makeWhereFrom2d return value',
+ 'wl_user' => 1
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( $this->getFakeRow( [] ) ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:OtherDbKey:1' )
+ ->will( $this->returnValue( null ) );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'OtherDbKey' => false, ],
+ ],
+ $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch_cachedItem() {
+ $targets = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $cachedItem = new WatchedItem( $user, $targets[0], '20151212010101' );
+
+ $mockDb = $this->getMockDb();
+
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ 1 => [ 'AnotherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [
+ 'makeWhereFrom2d return value',
+ 'wl_user' => 1
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow(
+ [ 'wl_namespace' => '1', 'wl_title' => 'AnotherDbKey', 'wl_notificationtimestamp' => null, ]
+ )
+ ] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->at( 1 ) )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( $cachedItem ) );
+ $mockCache->expects( $this->at( 3 ) )
+ ->method( 'get' )
+ ->with( '1:AnotherDbKey:1' )
+ ->will( $this->returnValue( null ) );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'SomeDbKey' => '20151212010101', ],
+ 1 => [ 'AnotherDbKey' => null, ],
+ ],
+ $store->getNotificationTimestampsBatch( $user, $targets )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch_allItemsCached() {
+ $targets = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $cachedItems = [
+ new WatchedItem( $user, $targets[0], '20151212010101' ),
+ new WatchedItem( $user, $targets[1], null ),
+ ];
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )->method( $this->anything() );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->at( 1 ) )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( $cachedItems[0] ) );
+ $mockCache->expects( $this->at( 3 ) )
+ ->method( 'get' )
+ ->with( '1:AnotherDbKey:1' )
+ ->will( $this->returnValue( $cachedItems[1] ) );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'SomeDbKey' => '20151212010101', ],
+ 1 => [ 'AnotherDbKey' => null, ],
+ ],
+ $store->getNotificationTimestampsBatch( $user, $targets )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch_anonymousUser() {
+ $targets = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )->method( $this->anything() );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( $this->anything() );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'SomeDbKey' => false, ],
+ 1 => [ 'AnotherDbKey' => false, ],
+ ],
+ $store->getNotificationTimestampsBatch( $this->getAnonUser(), $targets )
+ );
+ }
+
+ public function testResetNotificationTimestamp_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->resetNotificationTimestamp(
+ $this->getAnonUser(),
+ Title::newFromText( 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testResetNotificationTimestamp_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->resetNotificationTimestamp(
+ $this->getMockNonAnonUserWithId( 1 ),
+ Title::newFromText( 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testResetNotificationTimestamp_item() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $title = Title::newFromText( 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with(
+ '0:SomeDbKey:1',
+ $this->isInstanceOf( WatchedItem::class )
+ );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ // Note: This does not actually assert the job is correct
+ $callableCallCounter = 0;
+ $mockCallback = function ( $callable ) use ( &$callableCallCounter ) {
+ $callableCallCounter++;
+ $this->assertInternalType( 'callable', $callable );
+ };
+ $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title
+ )
+ );
+ $this->assertEquals( 1, $callableCallCounter );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ public function testResetNotificationTimestamp_noItemForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $title = Title::newFromText( 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ // Note: This does not actually assert the job is correct
+ $callableCallCounter = 0;
+ $mockCallback = function ( $callable ) use ( &$callableCallCounter ) {
+ $callableCallCounter++;
+ $this->assertInternalType( 'callable', $callable );
+ };
+ $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force'
+ )
+ );
+ $this->assertEquals( 1, $callableCallCounter );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ /**
+ * @param string $text
+ * @param int $ns
+ *
+ * @return PHPUnit_Framework_MockObject_MockObject|Title
+ */
+ private function getMockTitle( $text, $ns = 0 ) {
+ $title = $this->createMock( Title::class );
+ $title->expects( $this->any() )
+ ->method( 'getText' )
+ ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) );
+ $title->expects( $this->any() )
+ ->method( 'getDbKey' )
+ ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) );
+ $title->expects( $this->any() )
+ ->method( 'getNamespace' )
+ ->will( $this->returnValue( $ns ) );
+ return $title;
+ }
+
+ private function verifyCallbackJob(
+ $callback,
+ LinkTarget $expectedTitle,
+ $expectedUserId,
+ callable $notificationTimestampCondition
+ ) {
+ $this->assertInternalType( 'callable', $callback );
+
+ $callbackReflector = new ReflectionFunction( $callback );
+ $vars = $callbackReflector->getStaticVariables();
+ $this->assertArrayHasKey( 'job', $vars );
+ $this->assertInstanceOf( ActivityUpdateJob::class, $vars['job'] );
+
+ /** @var ActivityUpdateJob $job */
+ $job = $vars['job'];
+ $this->assertEquals( $expectedTitle->getDBkey(), $job->getTitle()->getDBkey() );
+ $this->assertEquals( $expectedTitle->getNamespace(), $job->getTitle()->getNamespace() );
+
+ $jobParams = $job->getParams();
+ $this->assertArrayHasKey( 'type', $jobParams );
+ $this->assertEquals( 'updateWatchlistNotification', $jobParams['type'] );
+ $this->assertArrayHasKey( 'userid', $jobParams );
+ $this->assertEquals( $expectedUserId, $jobParams['userid'] );
+ $this->assertArrayHasKey( 'notifTime', $jobParams );
+ $this->assertTrue( $notificationTimestampCondition( $jobParams['notifTime'] ) );
+ }
+
+ public function testResetNotificationTimestamp_oldidSpecifiedLatestRevisionForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeTitle' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( false ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeTitle:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $callableCallCounter = 0;
+ $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$callableCallCounter, $title, $user ) {
+ $callableCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time === null;
+ }
+ );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $callableCallCounter );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ public function testResetNotificationTimestamp_oldidSpecifiedNotLatestRevisionForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeDbKey' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( 33 ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $addUpdateCallCounter = 0;
+ $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) {
+ $addUpdateCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time !== null && $time > '20151212010101';
+ }
+ );
+ }
+ );
+
+ $getTimestampCallCounter = 0;
+ $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback(
+ function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) {
+ $getTimestampCallCounter++;
+ $this->assertEquals( $title, $titleParam );
+ $this->assertEquals( $oldid, $oldidParam );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $addUpdateCallCounter );
+ $this->assertEquals( 1, $getTimestampCallCounter );
+
+ ScopedCallback::consume( $scopedOverrideDeferred );
+ ScopedCallback::consume( $scopedOverrideRevision );
+ }
+
+ public function testResetNotificationTimestamp_notWatchedPageForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeDbKey' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( 33 ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( false ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $callableCallCounter = 0;
+ $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$callableCallCounter, $title, $user ) {
+ $callableCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time === null;
+ }
+ );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $callableCallCounter );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ public function testResetNotificationTimestamp_futureNotificationTimestampForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeDbKey' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( 33 ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $addUpdateCallCounter = 0;
+ $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) {
+ $addUpdateCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time === '30151212010101';
+ }
+ );
+ }
+ );
+
+ $getTimestampCallCounter = 0;
+ $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback(
+ function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) {
+ $getTimestampCallCounter++;
+ $this->assertEquals( $title, $titleParam );
+ $this->assertEquals( $oldid, $oldidParam );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $addUpdateCallCounter );
+ $this->assertEquals( 1, $getTimestampCallCounter );
+
+ ScopedCallback::consume( $scopedOverrideDeferred );
+ ScopedCallback::consume( $scopedOverrideRevision );
+ }
+
+ public function testResetNotificationTimestamp_futureNotificationTimestampNotForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeDbKey' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( 33 ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $addUpdateCallCounter = 0;
+ $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) {
+ $addUpdateCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time === false;
+ }
+ );
+ }
+ );
+
+ $getTimestampCallCounter = 0;
+ $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback(
+ function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) {
+ $getTimestampCallCounter++;
+ $this->assertEquals( $title, $titleParam );
+ $this->assertEquals( $oldid, $oldidParam );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ '',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $addUpdateCallCounter );
+ $this->assertEquals( 1, $getTimestampCallCounter );
+
+ ScopedCallback::consume( $scopedOverrideDeferred );
+ ScopedCallback::consume( $scopedOverrideRevision );
+ }
+
+ public function testSetNotificationTimestampsForUser_anonUser() {
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $this->getMockDb() ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+ $this->assertFalse( $store->setNotificationTimestampsForUser( $this->getAnonUser(), '' ) );
+ }
+
+ public function testSetNotificationTimestampsForUser_allRows() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $timestamp = '20100101010101';
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ],
+ [ 'wl_user' => 1 ]
+ )
+ ->will( $this->returnValue( true ) );
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, $timestamp )
+ );
+ }
+
+ public function testSetNotificationTimestampsForUser_nullTimestamp() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $timestamp = null;
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => null ],
+ [ 'wl_user' => 1 ]
+ )
+ ->will( $this->returnValue( true ) );
+ $mockDb->expects( $this->exactly( 0 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, $timestamp )
+ );
+ }
+
+ public function testSetNotificationTimestampsForUser_specificTargets() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $timestamp = '20100101010101';
+ $targets = [ new TitleValue( 0, 'Foo' ), new TitleValue( 0, 'Bar' ) ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ],
+ [ 'wl_user' => 1, 0 => 'makeWhereFrom2d return value' ]
+ )
+ ->will( $this->returnValue( true ) );
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'Foo' => 1, 'Bar' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, $timestamp, $targets )
+ );
+ }
+
+ public function testUpdateNotificationTimestamp_watchersExist() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectFieldValues' )
+ ->with(
+ 'watchlist',
+ 'wl_user',
+ [
+ 'wl_user != 1',
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ 'wl_notificationtimestamp IS NULL'
+ ]
+ )
+ ->will( $this->returnValue( [ '2', '3' ] ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => null ],
+ [
+ 'wl_user' => [ 2, 3 ],
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [ 2, 3 ],
+ $store->updateNotificationTimestamp(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' ),
+ '20151212010101'
+ )
+ );
+ }
+
+ public function testUpdateNotificationTimestamp_noWatchers() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectFieldValues' )
+ ->with(
+ 'watchlist',
+ 'wl_user',
+ [
+ 'wl_user != 1',
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ 'wl_notificationtimestamp IS NULL'
+ ]
+ )
+ ->will(
+ $this->returnValue( [] )
+ );
+ $mockDb->expects( $this->never() )
+ ->method( 'update' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $watchers = $store->updateNotificationTimestamp(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' ),
+ '20151212010101'
+ );
+ $this->assertInternalType( 'array', $watchers );
+ $this->assertEmpty( $watchers );
+ }
+
+ public function testUpdateNotificationTimestamp_clearsCachedItems() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $titleValue = new TitleValue( 0, 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'selectFieldValues' )
+ ->will(
+ $this->returnValue( [ '2', '3' ] )
+ );
+ $mockDb->expects( $this->once() )
+ ->method( 'update' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ // This will add the item to the cache
+ $store->getWatchedItem( $user, $titleValue );
+
+ $store->updateNotificationTimestamp(
+ $this->getMockNonAnonUserWithId( 1 ),
+ $titleValue,
+ '20151212010101'
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/languages/LanguageClassesTestCase.php b/www/wiki/tests/phpunit/languages/LanguageClassesTestCase.php
new file mode 100644
index 00000000..2216ba40
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/LanguageClassesTestCase.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * Helping class to run tests using a clean language instance.
+ *
+ * This is intended for the MediaWiki language class tests under
+ * tests/phpunit/languages.
+ *
+ * Before each tests, a new language object is build which you
+ * can retrieve in your test using the $this->getLang() method:
+ *
+ * @par Using the crafted language object:
+ * @code
+ * function testHasLanguageObject() {
+ * $langObject = $this->getLang();
+ * $this->assertInstanceOf( 'LanguageFoo',
+ * $langObject
+ * );
+ * }
+ * @endcode
+ */
+abstract class LanguageClassesTestCase extends MediaWikiTestCase {
+ /**
+ * Internal language object
+ *
+ * A new object is created before each tests thanks to PHPUnit
+ * setUp() method, it is deleted after each test too. To get
+ * this object you simply use the getLang method.
+ *
+ * You must have setup a language code first. See $LanguageClassCode
+ * @code
+ * function testWeAreTheChampions() {
+ * $this->getLang(); # language object
+ * }
+ * @endcode
+ */
+ private $languageObject;
+
+ /**
+ * @return Language
+ */
+ protected function getLang() {
+ return $this->languageObject;
+ }
+
+ /**
+ * Create a new language object before each test.
+ */
+ protected function setUp() {
+ parent::setUp();
+ $found = preg_match( '/Language(.+)Test/', static::class, $m );
+ if ( $found ) {
+ # Normalize language code since classes uses underscores
+ $m[1] = strtolower( str_replace( '_', '-', $m[1] ) );
+ } else {
+ # Fallback to english language
+ $m[1] = 'en';
+ wfDebug(
+ __METHOD__ . ' could not extract a language name '
+ . 'out of ' . static::class . " failling back to 'en'\n"
+ );
+ }
+ // @todo validate $m[1] which should be a valid language code
+ $this->languageObject = Language::factory( $m[1] );
+ }
+
+ /**
+ * Delete the internal language object so each test start
+ * out with a fresh language instance.
+ */
+ protected function tearDown() {
+ unset( $this->languageObject );
+ parent::tearDown();
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/LanguageCodeTest.php b/www/wiki/tests/phpunit/languages/LanguageCodeTest.php
new file mode 100644
index 00000000..544a0635
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/LanguageCodeTest.php
@@ -0,0 +1,161 @@
+<?php
+
+/**
+ * @covers LanguageCode
+ * @group Language
+ *
+ * @author Thiemo Kreuz
+ */
+class LanguageCodeTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function testConstructor() {
+ $instance = new LanguageCode();
+
+ $this->assertInstanceOf( LanguageCode::class, $instance );
+ }
+
+ public function testGetDeprecatedCodeMapping() {
+ $map = LanguageCode::getDeprecatedCodeMapping();
+
+ $this->assertInternalType( 'array', $map );
+ $this->assertContainsOnly( 'string', array_keys( $map ) );
+ $this->assertArrayNotHasKey( '', $map );
+ $this->assertContainsOnly( 'string', $map );
+ $this->assertNotContains( '', $map );
+
+ // Codes special to MediaWiki should never appear in a map of "deprecated" codes
+ $this->assertArrayNotHasKey( 'qqq', $map, 'documentation' );
+ $this->assertNotContains( 'qqq', $map, 'documentation' );
+ $this->assertArrayNotHasKey( 'qqx', $map, 'debug code' );
+ $this->assertNotContains( 'qqx', $map, 'debug code' );
+
+ // Valid language codes that are currently not "deprecated"
+ $this->assertArrayNotHasKey( 'bh', $map, 'family of Bihari languages' );
+ $this->assertArrayNotHasKey( 'no', $map, 'family of Norwegian languages' );
+ $this->assertArrayNotHasKey( 'simple', $map );
+ }
+
+ public function testReplaceDeprecatedCodes() {
+ $this->assertEquals( 'gsw', LanguageCode::replaceDeprecatedCodes( 'als' ) );
+ $this->assertEquals( 'gsw', LanguageCode::replaceDeprecatedCodes( 'gsw' ) );
+ $this->assertEquals( null, LanguageCode::replaceDeprecatedCodes( null ) );
+ }
+
+ /**
+ * test @see LanguageCode::bcp47().
+ * Please note the BCP 47 explicitly state that language codes are case
+ * insensitive, there are some exceptions to the rule :)
+ * This test is used to verify our formatting against all lower and
+ * all upper cases language code.
+ *
+ * @see https://tools.ietf.org/html/bcp47
+ * @dataProvider provideLanguageCodes()
+ */
+ public function testBcp47( $code, $expected ) {
+ $code = strtolower( $code );
+ $this->assertEquals( $expected, LanguageCode::bcp47( $code ),
+ "Applying BCP47 standard to lower case '$code'"
+ );
+
+ $code = strtoupper( $code );
+ $this->assertEquals( $expected, LanguageCode::bcp47( $code ),
+ "Applying BCP47 standard to upper case '$code'"
+ );
+ }
+
+ /**
+ * Array format is ($code, $expected)
+ */
+ public static function provideLanguageCodes() {
+ return [
+ // Extracted from BCP 47 (list not exhaustive)
+ # 2.1.1
+ [ 'en-ca-x-ca', 'en-CA-x-ca' ],
+ [ 'sgn-be-fr', 'sgn-BE-FR' ],
+ [ 'az-latn-x-latn', 'az-Latn-x-latn' ],
+ # 2.2
+ [ 'sr-Latn-RS', 'sr-Latn-RS' ],
+ [ 'az-arab-ir', 'az-Arab-IR' ],
+
+ # 2.2.5
+ [ 'sl-nedis', 'sl-nedis' ],
+ [ 'de-ch-1996', 'de-CH-1996' ],
+
+ # 2.2.6
+ [
+ 'en-latn-gb-boont-r-extended-sequence-x-private',
+ 'en-Latn-GB-boont-r-extended-sequence-x-private'
+ ],
+
+ // Examples from BCP 47 Appendix A
+ # Simple language subtag:
+ [ 'DE', 'de' ],
+ [ 'fR', 'fr' ],
+ [ 'ja', 'ja' ],
+
+ # Language subtag plus script subtag:
+ [ 'zh-hans', 'zh-Hans' ],
+ [ 'sr-cyrl', 'sr-Cyrl' ],
+ [ 'sr-latn', 'sr-Latn' ],
+
+ # Extended language subtags and their primary language subtag
+ # counterparts:
+ [ 'zh-cmn-hans-cn', 'zh-cmn-Hans-CN' ],
+ [ 'cmn-hans-cn', 'cmn-Hans-CN' ],
+ [ 'zh-yue-hk', 'zh-yue-HK' ],
+ [ 'yue-hk', 'yue-HK' ],
+
+ # Language-Script-Region:
+ [ 'zh-hans-cn', 'zh-Hans-CN' ],
+ [ 'sr-latn-RS', 'sr-Latn-RS' ],
+
+ # Language-Variant:
+ [ 'sl-rozaj', 'sl-rozaj' ],
+ [ 'sl-rozaj-biske', 'sl-rozaj-biske' ],
+ [ 'sl-nedis', 'sl-nedis' ],
+
+ # Language-Region-Variant:
+ [ 'de-ch-1901', 'de-CH-1901' ],
+ [ 'sl-it-nedis', 'sl-IT-nedis' ],
+
+ # Language-Script-Region-Variant:
+ [ 'hy-latn-it-arevela', 'hy-Latn-IT-arevela' ],
+
+ # Language-Region:
+ [ 'de-de', 'de-DE' ],
+ [ 'en-us', 'en-US' ],
+ [ 'es-419', 'es-419' ],
+
+ # Private use subtags:
+ [ 'de-ch-x-phonebk', 'de-CH-x-phonebk' ],
+ [ 'az-arab-x-aze-derbend', 'az-Arab-x-aze-derbend' ],
+ /**
+ * Previous test does not reflect the BCP 47 which states:
+ * az-Arab-x-AZE-derbend
+ * AZE being private, it should be lower case, hence the test above
+ * should probably be:
+ * [ 'az-arab-x-aze-derbend', 'az-Arab-x-AZE-derbend' ],
+ */
+
+ # Private use registry values:
+ [ 'x-whatever', 'x-whatever' ],
+ [ 'qaa-qaaa-qm-x-southern', 'qaa-Qaaa-QM-x-southern' ],
+ [ 'de-qaaa', 'de-Qaaa' ],
+ [ 'sr-latn-qm', 'sr-Latn-QM' ],
+ [ 'sr-qaaa-rs', 'sr-Qaaa-RS' ],
+
+ # Tags that use extensions
+ [ 'en-us-u-islamcal', 'en-US-u-islamcal' ],
+ [ 'zh-cn-a-myext-x-private', 'zh-CN-a-myext-x-private' ],
+ [ 'en-a-myext-b-another', 'en-a-myext-b-another' ],
+
+ # Invalid:
+ // de-419-DE
+ // a-DE
+ // ar-a-aaa-b-bbb-a-ccc
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/languages/LanguageConverterTest.php b/www/wiki/tests/phpunit/languages/LanguageConverterTest.php
new file mode 100644
index 00000000..82ab7def
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/LanguageConverterTest.php
@@ -0,0 +1,207 @@
+<?php
+
+class LanguageConverterTest extends MediaWikiLangTestCase {
+ /** @var LanguageToTest */
+ protected $lang = null;
+ /** @var TestConverter */
+ protected $lc = null;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgContLang' => Language::factory( 'tg' ),
+ 'wgLanguageCode' => 'tg',
+ 'wgDefaultLanguageVariant' => false,
+ 'wgRequest' => new FauxRequest( [] ),
+ 'wgUser' => new User,
+ ] );
+
+ $this->lang = new LanguageToTest();
+ $this->lc = new TestConverter(
+ $this->lang, 'tg',
+ [ 'tg', 'tg-latn' ]
+ );
+ }
+
+ protected function tearDown() {
+ unset( $this->lc );
+ unset( $this->lang );
+
+ parent::tearDown();
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ */
+ public function testGetPreferredVariantDefaults() {
+ $this->assertEquals( 'tg', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getHeaderVariant
+ */
+ public function testGetPreferredVariantHeaders() {
+ global $wgRequest;
+ $wgRequest->setHeader( 'Accept-Language', 'tg-latn' );
+
+ $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getHeaderVariant
+ */
+ public function testGetPreferredVariantHeaderWeight() {
+ global $wgRequest;
+ $wgRequest->setHeader( 'Accept-Language', 'tg;q=1' );
+
+ $this->assertEquals( 'tg', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getHeaderVariant
+ */
+ public function testGetPreferredVariantHeaderWeight2() {
+ global $wgRequest;
+ $wgRequest->setHeader( 'Accept-Language', 'tg-latn;q=1' );
+
+ $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getHeaderVariant
+ */
+ public function testGetPreferredVariantHeaderMulti() {
+ global $wgRequest;
+ $wgRequest->setHeader( 'Accept-Language', 'en, tg-latn;q=1' );
+
+ $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ */
+ public function testGetPreferredVariantUserOption() {
+ global $wgUser;
+
+ $wgUser = new User;
+ $wgUser->load(); // from 'defaults'
+ $wgUser->mId = 1;
+ $wgUser->mDataLoaded = true;
+ $wgUser->mOptionsLoaded = true;
+ $wgUser->setOption( 'variant', 'tg-latn' );
+
+ $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getUserVariant
+ */
+ public function testGetPreferredVariantUserOptionForForeignLanguage() {
+ global $wgContLang, $wgUser;
+
+ $wgContLang = Language::factory( 'en' );
+ $wgUser = new User;
+ $wgUser->load(); // from 'defaults'
+ $wgUser->mId = 1;
+ $wgUser->mDataLoaded = true;
+ $wgUser->mOptionsLoaded = true;
+ $wgUser->setOption( 'variant-tg', 'tg-latn' );
+
+ $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getUserVariant
+ * @covers LanguageConverter::getURLVariant
+ */
+ public function testGetPreferredVariantHeaderUserVsUrl() {
+ global $wgContLang, $wgRequest, $wgUser;
+
+ $wgContLang = Language::factory( 'tg-latn' );
+ $wgRequest->setVal( 'variant', 'tg' );
+ $wgUser = User::newFromId( "admin" );
+ $wgUser->setId( 1 );
+ $wgUser->mFrom = 'defaults';
+ $wgUser->mOptionsLoaded = true;
+ // The user's data is ignored because the variant is set in the URL.
+ $wgUser->setOption( 'variant', 'tg-latn' );
+ $this->assertEquals( 'tg', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ */
+ public function testGetPreferredVariantDefaultLanguageVariant() {
+ global $wgDefaultLanguageVariant;
+
+ $wgDefaultLanguageVariant = 'tg-latn';
+ $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getURLVariant
+ */
+ public function testGetPreferredVariantDefaultLanguageVsUrlVariant() {
+ global $wgDefaultLanguageVariant, $wgRequest, $wgContLang;
+
+ $wgContLang = Language::factory( 'tg-latn' );
+ $wgDefaultLanguageVariant = 'tg';
+ $wgRequest->setVal( 'variant', null );
+ $this->assertEquals( 'tg', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * Test exhausting pcre.backtrack_limit
+ *
+ * @covers LanguageConverter::autoConvert
+ */
+ public function testAutoConvertT124404() {
+ $testString = '';
+ for ( $i = 0; $i < 1000; $i++ ) {
+ $testString .= 'xxx xxx xxx';
+ }
+ $testString .= "\n<big id='в'></big>";
+ $old = ini_set( 'pcre.backtrack_limit', 200 );
+ $result = $this->lc->autoConvert( $testString, 'tg-latn' );
+ ini_set( 'pcre.backtrack_limit', $old );
+ // The в in the id attribute should not get converted to a v
+ $this->assertFalse(
+ strpos( $result, 'v' ),
+ "в converted to v despite being in attribue"
+ );
+ }
+}
+
+/**
+ * Test converter (from Tajiki to latin orthography)
+ */
+class TestConverter extends LanguageConverter {
+ private $table = [
+ 'б' => 'b',
+ 'в' => 'v',
+ 'г' => 'g',
+ ];
+
+ function loadDefaultTables() {
+ $this->mTables = [
+ 'tg-latn' => new ReplacementArray( $this->table ),
+ 'tg' => new ReplacementArray()
+ ];
+ }
+}
+
+class LanguageToTest extends Language {
+ function __construct() {
+ parent::__construct();
+ $variants = [ 'tg', 'tg-latn' ];
+ $this->mConverter = new TestConverter( $this, 'tg', $variants );
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/LanguageTest.php b/www/wiki/tests/phpunit/languages/LanguageTest.php
new file mode 100644
index 00000000..66bd76df
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/LanguageTest.php
@@ -0,0 +1,1869 @@
+<?php
+
+class LanguageTest extends LanguageClassesTestCase {
+ /**
+ * @covers Language::convertDoubleWidth
+ * @covers Language::normalizeForSearch
+ */
+ public function testLanguageConvertDoubleWidthToSingleWidth() {
+ $this->assertEquals(
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
+ $this->getLang()->normalizeForSearch(
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+ ),
+ 'convertDoubleWidth() with the full alphabet and digits'
+ );
+ }
+
+ /**
+ * @dataProvider provideFormattableTimes
+ * @covers Language::formatTimePeriod
+ */
+ public function testFormatTimePeriod( $seconds, $format, $expected, $desc ) {
+ $this->assertEquals( $expected, $this->getLang()->formatTimePeriod( $seconds, $format ), $desc );
+ }
+
+ public static function provideFormattableTimes() {
+ return [
+ [
+ 9.45,
+ [],
+ '9.5 s',
+ 'formatTimePeriod() rounding (<10s)'
+ ],
+ [
+ 9.45,
+ [ 'noabbrevs' => true ],
+ '9.5 seconds',
+ 'formatTimePeriod() rounding (<10s)'
+ ],
+ [
+ 9.95,
+ [],
+ '10 s',
+ 'formatTimePeriod() rounding (<10s)'
+ ],
+ [
+ 9.95,
+ [ 'noabbrevs' => true ],
+ '10 seconds',
+ 'formatTimePeriod() rounding (<10s)'
+ ],
+ [
+ 59.55,
+ [],
+ '1 min 0 s',
+ 'formatTimePeriod() rounding (<60s)'
+ ],
+ [
+ 59.55,
+ [ 'noabbrevs' => true ],
+ '1 minute 0 seconds',
+ 'formatTimePeriod() rounding (<60s)'
+ ],
+ [
+ 119.55,
+ [],
+ '2 min 0 s',
+ 'formatTimePeriod() rounding (<1h)'
+ ],
+ [
+ 119.55,
+ [ 'noabbrevs' => true ],
+ '2 minutes 0 seconds',
+ 'formatTimePeriod() rounding (<1h)'
+ ],
+ [
+ 3599.55,
+ [],
+ '1 h 0 min 0 s',
+ 'formatTimePeriod() rounding (<1h)'
+ ],
+ [
+ 3599.55,
+ [ 'noabbrevs' => true ],
+ '1 hour 0 minutes 0 seconds',
+ 'formatTimePeriod() rounding (<1h)'
+ ],
+ [
+ 7199.55,
+ [],
+ '2 h 0 min 0 s',
+ 'formatTimePeriod() rounding (>=1h)'
+ ],
+ [
+ 7199.55,
+ [ 'noabbrevs' => true ],
+ '2 hours 0 minutes 0 seconds',
+ 'formatTimePeriod() rounding (>=1h)'
+ ],
+ [
+ 7199.55,
+ 'avoidseconds',
+ '2 h 0 min',
+ 'formatTimePeriod() rounding (>=1h), avoidseconds'
+ ],
+ [
+ 7199.55,
+ [ 'avoid' => 'avoidseconds', 'noabbrevs' => true ],
+ '2 hours 0 minutes',
+ 'formatTimePeriod() rounding (>=1h), avoidseconds'
+ ],
+ [
+ 7199.55,
+ 'avoidminutes',
+ '2 h 0 min',
+ 'formatTimePeriod() rounding (>=1h), avoidminutes'
+ ],
+ [
+ 7199.55,
+ [ 'avoid' => 'avoidminutes', 'noabbrevs' => true ],
+ '2 hours 0 minutes',
+ 'formatTimePeriod() rounding (>=1h), avoidminutes'
+ ],
+ [
+ 172799.55,
+ 'avoidseconds',
+ '48 h 0 min',
+ 'formatTimePeriod() rounding (=48h), avoidseconds'
+ ],
+ [
+ 172799.55,
+ [ 'avoid' => 'avoidseconds', 'noabbrevs' => true ],
+ '48 hours 0 minutes',
+ 'formatTimePeriod() rounding (=48h), avoidseconds'
+ ],
+ [
+ 259199.55,
+ 'avoidminutes',
+ '3 d 0 h',
+ 'formatTimePeriod() rounding (>48h), avoidminutes'
+ ],
+ [
+ 259199.55,
+ [ 'avoid' => 'avoidminutes', 'noabbrevs' => true ],
+ '3 days 0 hours',
+ 'formatTimePeriod() rounding (>48h), avoidminutes'
+ ],
+ [
+ 176399.55,
+ 'avoidseconds',
+ '2 d 1 h 0 min',
+ 'formatTimePeriod() rounding (>48h), avoidseconds'
+ ],
+ [
+ 176399.55,
+ [ 'avoid' => 'avoidseconds', 'noabbrevs' => true ],
+ '2 days 1 hour 0 minutes',
+ 'formatTimePeriod() rounding (>48h), avoidseconds'
+ ],
+ [
+ 176399.55,
+ 'avoidminutes',
+ '2 d 1 h',
+ 'formatTimePeriod() rounding (>48h), avoidminutes'
+ ],
+ [
+ 176399.55,
+ [ 'avoid' => 'avoidminutes', 'noabbrevs' => true ],
+ '2 days 1 hour',
+ 'formatTimePeriod() rounding (>48h), avoidminutes'
+ ],
+ [
+ 259199.55,
+ 'avoidseconds',
+ '3 d 0 h 0 min',
+ 'formatTimePeriod() rounding (>48h), avoidseconds'
+ ],
+ [
+ 259199.55,
+ [ 'avoid' => 'avoidseconds', 'noabbrevs' => true ],
+ '3 days 0 hours 0 minutes',
+ 'formatTimePeriod() rounding (>48h), avoidseconds'
+ ],
+ [
+ 172801.55,
+ 'avoidseconds',
+ '2 d 0 h 0 min',
+ 'formatTimePeriod() rounding, (>48h), avoidseconds'
+ ],
+ [
+ 172801.55,
+ [ 'avoid' => 'avoidseconds', 'noabbrevs' => true ],
+ '2 days 0 hours 0 minutes',
+ 'formatTimePeriod() rounding, (>48h), avoidseconds'
+ ],
+ [
+ 176460.55,
+ [],
+ '2 d 1 h 1 min 1 s',
+ 'formatTimePeriod() rounding, recursion, (>48h)'
+ ],
+ [
+ 176460.55,
+ [ 'noabbrevs' => true ],
+ '2 days 1 hour 1 minute 1 second',
+ 'formatTimePeriod() rounding, recursion, (>48h)'
+ ],
+ ];
+ }
+
+ /**
+ * @covers Language::truncateForDatabase
+ * @covers Language::truncateInternal
+ */
+ public function testTruncateForDatabase() {
+ $this->assertEquals(
+ "XXX",
+ $this->getLang()->truncateForDatabase( "1234567890", 0, 'XXX' ),
+ 'truncate prefix, len 0, small ellipsis'
+ );
+
+ $this->assertEquals(
+ "12345XXX",
+ $this->getLang()->truncateForDatabase( "1234567890", 8, 'XXX' ),
+ 'truncate prefix, small ellipsis'
+ );
+
+ $this->assertEquals(
+ "123456789",
+ $this->getLang()->truncateForDatabase( "123456789", 5, 'XXXXXXXXXXXXXXX' ),
+ 'truncate prefix, large ellipsis'
+ );
+
+ $this->assertEquals(
+ "XXX67890",
+ $this->getLang()->truncateForDatabase( "1234567890", -8, 'XXX' ),
+ 'truncate suffix, small ellipsis'
+ );
+
+ $this->assertEquals(
+ "123456789",
+ $this->getLang()->truncateForDatabase( "123456789", -5, 'XXXXXXXXXXXXXXX' ),
+ 'truncate suffix, large ellipsis'
+ );
+ $this->assertEquals(
+ "123XXX",
+ $this->getLang()->truncateForDatabase( "123 ", 9, 'XXX' ),
+ 'truncate prefix, with spaces'
+ );
+ $this->assertEquals(
+ "12345XXX",
+ $this->getLang()->truncateForDatabase( "12345 8", 11, 'XXX' ),
+ 'truncate prefix, with spaces and non-space ending'
+ );
+ $this->assertEquals(
+ "XXX234",
+ $this->getLang()->truncateForDatabase( "1 234", -8, 'XXX' ),
+ 'truncate suffix, with spaces'
+ );
+ $this->assertEquals(
+ "12345XXX",
+ $this->getLang()->truncateForDatabase( "1234567890", 5, 'XXX', false ),
+ 'truncate without adjustment'
+ );
+ $this->assertEquals(
+ "泰乐菌...",
+ $this->getLang()->truncateForDatabase( "泰乐菌素123456789", 11, '...', false ),
+ 'truncate does not chop Unicode characters in half'
+ );
+ $this->assertEquals(
+ "\n泰乐菌...",
+ $this->getLang()->truncateForDatabase( "\n泰乐菌素123456789", 12, '...', false ),
+ 'truncate does not chop Unicode characters in half if there is a preceding newline'
+ );
+ }
+
+ /**
+ * @dataProvider provideTruncateData
+ * @covers Language::truncateForVisual
+ * @covers Language::truncateInternal
+ */
+ public function testTruncateForVisual(
+ $expected, $string, $length, $ellipsis = '...', $adjustLength = true
+ ) {
+ $this->assertEquals(
+ $expected,
+ $this->getLang()->truncateForVisual( $string, $length, $ellipsis, $adjustLength )
+ );
+ }
+
+ /**
+ * @return array Format is ($expected, $string, $length, $ellipsis, $adjustLength)
+ */
+ public static function provideTruncateData() {
+ return [
+ [ "XXX", "тестирам да ли ради", 0, "XXX" ],
+ [ "testnXXX", "testni scenarij", 8, "XXX" ],
+ [ "حالة اختبار", "حالة اختبار", 5, "XXXXXXXXXXXXXXX" ],
+ [ "XXXедент", "прецедент", -8, "XXX" ],
+ [ "XXപിൾ", "ആപ്പിൾ", -5, "XX" ],
+ [ "神秘XXX", "神秘 ", 9, "XXX" ],
+ [ "ΔημιουργXXX", "Δημιουργία Σύμπαντος", 11, "XXX" ],
+ [ "XXXの家です", "地球は私たちの唯 の家です", -8, "XXX" ],
+ [ "زندگیXXX", "زندگی زیباست", 6, "XXX", false ],
+ [ "ცხოვრება...", "ცხოვრება არის საოცარი", 8, "...", false ],
+ [ "\nທ່ານ...", "\nທ່ານບໍ່ຮູ້ຫນັງສື", 5, "...", false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideHTMLTruncateData
+ * @covers Language::truncateHTML
+ */
+ public function testTruncateHtml( $len, $ellipsis, $input, $expected ) {
+ // Actual HTML...
+ $this->assertEquals(
+ $expected,
+ $this->getLang()->truncateHtml( $input, $len, $ellipsis )
+ );
+ }
+
+ /**
+ * @return array Format is ($len, $ellipsis, $input, $expected)
+ */
+ public static function provideHTMLTruncateData() {
+ return [
+ [ 0, 'XXX', "1234567890", "XXX" ],
+ [ 8, 'XXX', "1234567890", "12345XXX" ],
+ [ 5, 'XXXXXXXXXXXXXXX', '1234567890', "1234567890" ],
+ [ 2, '***',
+ '<p><span style="font-weight:bold;"></span></p>',
+ '<p><span style="font-weight:bold;"></span></p>',
+ ],
+ [ 2, '***',
+ '<p><span style="font-weight:bold;">123456789</span></p>',
+ '<p><span style="font-weight:bold;">***</span></p>',
+ ],
+ [ 2, '***',
+ '<p><span style="font-weight:bold;">&nbsp;23456789</span></p>',
+ '<p><span style="font-weight:bold;">***</span></p>',
+ ],
+ [ 3, '***',
+ '<p><span style="font-weight:bold;">123456789</span></p>',
+ '<p><span style="font-weight:bold;">***</span></p>',
+ ],
+ [ 4, '***',
+ '<p><span style="font-weight:bold;">123456789</span></p>',
+ '<p><span style="font-weight:bold;">1***</span></p>',
+ ],
+ [ 5, '***',
+ '<tt><span style="font-weight:bold;">123456789</span></tt>',
+ '<tt><span style="font-weight:bold;">12***</span></tt>',
+ ],
+ [ 6, '***',
+ '<p><a href="www.mediawiki.org">123456789</a></p>',
+ '<p><a href="www.mediawiki.org">123***</a></p>',
+ ],
+ [ 6, '***',
+ '<p><a href="www.mediawiki.org">12&nbsp;456789</a></p>',
+ '<p><a href="www.mediawiki.org">12&nbsp;***</a></p>',
+ ],
+ [ 7, '***',
+ '<small><span style="font-weight:bold;">123<p id="#moo">456</p>789</span></small>',
+ '<small><span style="font-weight:bold;">123<p id="#moo">4***</p></span></small>',
+ ],
+ [ 8, '***',
+ '<div><span style="font-weight:bold;">123<span>4</span>56789</span></div>',
+ '<div><span style="font-weight:bold;">123<span>4</span>5***</span></div>',
+ ],
+ [ 9, '***',
+ '<p><table style="font-weight:bold;"><tr><td>123456789</td></tr></table></p>',
+ '<p><table style="font-weight:bold;"><tr><td>123456789</td></tr></table></p>',
+ ],
+ [ 10, '***',
+ '<p><font style="font-weight:bold;">123456789</font></p>',
+ '<p><font style="font-weight:bold;">123456789</font></p>',
+ ],
+ ];
+ }
+
+ /**
+ * Test Language::isWellFormedLanguageTag()
+ * @dataProvider provideWellFormedLanguageTags
+ * @covers Language::isWellFormedLanguageTag
+ */
+ public function testWellFormedLanguageTag( $code, $message = '' ) {
+ $this->assertTrue(
+ Language::isWellFormedLanguageTag( $code ),
+ "validating code $code $message"
+ );
+ }
+
+ /**
+ * The test cases are based on the tests in the GaBuZoMeu parser
+ * written by Stéphane Bortzmeyer <bortzmeyer@nic.fr>
+ * and distributed as free software, under the GNU General Public Licence.
+ * http://www.bortzmeyer.org/gabuzomeu-parsing-language-tags.html
+ */
+ public static function provideWellFormedLanguageTags() {
+ return [
+ [ 'fr', 'two-letter code' ],
+ [ 'fr-latn', 'two-letter code with lower case script code' ],
+ [ 'fr-Latn-FR', 'two-letter code with title case script code and uppercase country code' ],
+ [ 'fr-Latn-419', 'two-letter code with title case script code and region number' ],
+ [ 'fr-FR', 'two-letter code with uppercase' ],
+ [ 'ax-TZ', 'Not in the registry, but well-formed' ],
+ [ 'fr-shadok', 'two-letter code with variant' ],
+ [ 'fr-y-myext-myext2', 'non-x singleton' ],
+ [ 'fra-Latn', 'ISO 639 can be 3-letters' ],
+ [ 'fra', 'three-letter language code' ],
+ [ 'fra-FX', 'three-letter language code with country code' ],
+ [ 'i-klingon', 'grandfathered with singleton' ],
+ [ 'I-kLINgon', 'tags are case-insensitive...' ],
+ [ 'no-bok', 'grandfathered without singleton' ],
+ [ 'i-enochian', 'Grandfathered' ],
+ [ 'x-fr-CH', 'private use' ],
+ [ 'es-419', 'two-letter code with region number' ],
+ [ 'en-Latn-GB-boont-r-extended-sequence-x-private', 'weird, but well-formed' ],
+ [ 'ab-x-abc-x-abc', 'anything goes after x' ],
+ [ 'ab-x-abc-a-a', 'anything goes after x, including several non-x singletons' ],
+ [ 'i-default', 'grandfathered' ],
+ [ 'abcd-Latn', 'Language of 4 chars reserved for future use' ],
+ [ 'AaBbCcDd-x-y-any-x', 'Language of 5-8 chars, registered' ],
+ [ 'de-CH-1901', 'with country and year' ],
+ [ 'en-US-x-twain', 'with country and singleton' ],
+ [ 'zh-cmn', 'three-letter variant' ],
+ [ 'zh-cmn-Hant', 'three-letter variant and script' ],
+ [ 'zh-cmn-Hant-HK', 'three-letter variant, script and country' ],
+ [ 'xr-p-lze', 'Extension' ],
+ ];
+ }
+
+ /**
+ * Negative test for Language::isWellFormedLanguageTag()
+ * @dataProvider provideMalformedLanguageTags
+ * @covers Language::isWellFormedLanguageTag
+ */
+ public function testMalformedLanguageTag( $code, $message = '' ) {
+ $this->assertFalse(
+ Language::isWellFormedLanguageTag( $code ),
+ "validating that code $code is a malformed language tag - $message"
+ );
+ }
+
+ /**
+ * The test cases are based on the tests in the GaBuZoMeu parser
+ * written by Stéphane Bortzmeyer <bortzmeyer@nic.fr>
+ * and distributed as free software, under the GNU General Public Licence.
+ * http://www.bortzmeyer.org/gabuzomeu-parsing-language-tags.html
+ */
+ public static function provideMalformedLanguageTags() {
+ return [
+ [ 'f', 'language too short' ],
+ [ 'f-Latn', 'language too short with script' ],
+ [ 'xr-lxs-qut', 'variants too short' ], # extlangS
+ [ 'fr-Latn-F', 'region too short' ],
+ [ 'a-value', 'language too short with region' ],
+ [ 'tlh-a-b-foo', 'valid three-letter with wrong variant' ],
+ [
+ 'i-notexist',
+ 'grandfathered but not registered: invalid, even if we only test well-formedness'
+ ],
+ [ 'abcdefghi-012345678', 'numbers too long' ],
+ [ 'ab-abc-abc-abc-abc', 'invalid extensions' ],
+ [ 'ab-abcd-abc', 'invalid extensions' ],
+ [ 'ab-ab-abc', 'invalid extensions' ],
+ [ 'ab-123-abc', 'invalid extensions' ],
+ [ 'a-Hant-ZH', 'short language with valid extensions' ],
+ [ 'a1-Hant-ZH', 'invalid character in language' ],
+ [ 'ab-abcde-abc', 'invalid extensions' ],
+ [ 'ab-1abc-abc', 'invalid characters in extensions' ],
+ [ 'ab-ab-abcd', 'invalid order of extensions' ],
+ [ 'ab-123-abcd', 'invalid order of extensions' ],
+ [ 'ab-abcde-abcd', 'invalid extensions' ],
+ [ 'ab-1abc-abcd', 'invalid characters in extensions' ],
+ [ 'ab-a-b', 'extensions too short' ],
+ [ 'ab-a-x', 'extensions too short, even with singleton' ],
+ [ 'ab--ab', 'two separators' ],
+ [ 'ab-abc-', 'separator in the end' ],
+ [ '-ab-abc', 'separator in the beginning' ],
+ [ 'abcd-efg', 'language too long' ],
+ [ 'aabbccddE', 'tag too long' ],
+ [ 'pa_guru', 'A tag with underscore is invalid in strict mode' ],
+ [ 'de-f', 'subtag too short' ],
+ ];
+ }
+
+ /**
+ * Negative test for Language::isWellFormedLanguageTag()
+ * @covers Language::isWellFormedLanguageTag
+ */
+ public function testLenientLanguageTag() {
+ $this->assertTrue(
+ Language::isWellFormedLanguageTag( 'pa_guru', true ),
+ 'pa_guru is a well-formed language tag in lenient mode'
+ );
+ }
+
+ /**
+ * Test Language::isValidBuiltInCode()
+ * @dataProvider provideLanguageCodes
+ * @covers Language::isValidBuiltInCode
+ */
+ public function testBuiltInCodeValidation( $code, $expected, $message = '' ) {
+ $this->assertEquals( $expected,
+ (bool)Language::isValidBuiltInCode( $code ),
+ "validating code $code $message"
+ );
+ }
+
+ public static function provideLanguageCodes() {
+ return [
+ [ 'fr', true, 'Two letters, minor case' ],
+ [ 'EN', false, 'Two letters, upper case' ],
+ [ 'tyv', true, 'Three letters' ],
+ [ 'be-tarask', true, 'With dash' ],
+ [ 'be-x-old', true, 'With extension (two dashes)' ],
+ [ 'be_tarask', false, 'Reject underscores' ],
+ ];
+ }
+
+ /**
+ * Test Language::isKnownLanguageTag()
+ * @dataProvider provideKnownLanguageTags
+ * @covers Language::isKnownLanguageTag
+ */
+ public function testKnownLanguageTag( $code, $message = '' ) {
+ $this->assertTrue(
+ (bool)Language::isKnownLanguageTag( $code ),
+ "validating code $code - $message"
+ );
+ }
+
+ public static function provideKnownLanguageTags() {
+ return [
+ [ 'fr', 'simple code' ],
+ [ 'bat-smg', 'an MW legacy tag' ],
+ [ 'sgs', 'an internal standard MW name, for which a legacy tag is used externally' ],
+ ];
+ }
+
+ /**
+ * @covers Language::isKnownLanguageTag
+ */
+ public function testKnownCldrLanguageTag() {
+ if ( !class_exists( 'LanguageNames' ) ) {
+ $this->markTestSkipped( 'The LanguageNames class is not available. '
+ . 'The CLDR extension is probably not installed.' );
+ }
+
+ $this->assertTrue(
+ (bool)Language::isKnownLanguageTag( 'pal' ),
+ 'validating code "pal" an ancient language, which probably will '
+ . 'not appear in Names.php, but appears in CLDR in English'
+ );
+ }
+
+ /**
+ * Negative tests for Language::isKnownLanguageTag()
+ * @dataProvider provideUnKnownLanguageTags
+ * @covers Language::isKnownLanguageTag
+ */
+ public function testUnknownLanguageTag( $code, $message = '' ) {
+ $this->assertFalse(
+ (bool)Language::isKnownLanguageTag( $code ),
+ "checking that code $code is invalid - $message"
+ );
+ }
+
+ public static function provideUnknownLanguageTags() {
+ return [
+ [ 'mw', 'non-existent two-letter code' ],
+ [ 'foo"<bar', 'very invalid language code' ],
+ ];
+ }
+
+ /**
+ * Test too short timestamp
+ * @expectedException MWException
+ * @covers Language::sprintfDate
+ */
+ public function testSprintfDateTooShortTimestamp() {
+ $this->getLang()->sprintfDate( 'xiY', '1234567890123' );
+ }
+
+ /**
+ * Test too long timestamp
+ * @expectedException MWException
+ * @covers Language::sprintfDate
+ */
+ public function testSprintfDateTooLongTimestamp() {
+ $this->getLang()->sprintfDate( 'xiY', '123456789012345' );
+ }
+
+ /**
+ * Test too short timestamp
+ * @expectedException MWException
+ * @covers Language::sprintfDate
+ */
+ public function testSprintfDateNotAllDigitTimestamp() {
+ $this->getLang()->sprintfDate( 'xiY', '-1234567890123' );
+ }
+
+ /**
+ * @dataProvider provideSprintfDateSamples
+ * @covers Language::sprintfDate
+ */
+ public function testSprintfDate( $format, $ts, $expected, $msg ) {
+ $ttl = null;
+ $this->assertEquals(
+ $expected,
+ $this->getLang()->sprintfDate( $format, $ts, null, $ttl ),
+ "sprintfDate('$format', '$ts'): $msg"
+ );
+ if ( $ttl ) {
+ $dt = new DateTime( $ts );
+ $lastValidTS = $dt->add( new DateInterval( 'PT' . ( $ttl - 1 ) . 'S' ) )->format( 'YmdHis' );
+ $this->assertEquals(
+ $expected,
+ $this->getLang()->sprintfDate( $format, $lastValidTS, null ),
+ "sprintfDate('$format', '$ts'): TTL $ttl too high (output was different at $lastValidTS)"
+ );
+ } else {
+ // advance the time enough to make all of the possible outputs different (except possibly L)
+ $dt = new DateTime( $ts );
+ $newTS = $dt->add( new DateInterval( 'P1Y1M8DT13H1M1S' ) )->format( 'YmdHis' );
+ $this->assertEquals(
+ $expected,
+ $this->getLang()->sprintfDate( $format, $newTS, null ),
+ "sprintfDate('$format', '$ts'): Missing TTL (output was different at $newTS)"
+ );
+ }
+ }
+
+ /**
+ * sprintfDate should always use UTC when no zone is given.
+ * @dataProvider provideSprintfDateSamples
+ * @covers Language::sprintfDate
+ */
+ public function testSprintfDateNoZone( $format, $ts, $expected, $ignore, $msg ) {
+ $oldTZ = date_default_timezone_get();
+ $res = date_default_timezone_set( 'Asia/Seoul' );
+ if ( !$res ) {
+ $this->markTestSkipped( "Error setting Timezone" );
+ }
+
+ $this->assertEquals(
+ $expected,
+ $this->getLang()->sprintfDate( $format, $ts ),
+ "sprintfDate('$format', '$ts'): $msg"
+ );
+
+ date_default_timezone_set( $oldTZ );
+ }
+
+ /**
+ * sprintfDate should use passed timezone
+ * @dataProvider provideSprintfDateSamples
+ * @covers Language::sprintfDate
+ */
+ public function testSprintfDateTZ( $format, $ts, $ignore, $expected, $msg ) {
+ $tz = new DateTimeZone( 'Asia/Seoul' );
+ if ( !$tz ) {
+ $this->markTestSkipped( "Error getting Timezone" );
+ }
+
+ $this->assertEquals(
+ $expected,
+ $this->getLang()->sprintfDate( $format, $ts, $tz ),
+ "sprintfDate('$format', '$ts', 'Asia/Seoul'): $msg"
+ );
+ }
+
+ /**
+ * sprintfDate should only calculate a TTL if the caller is going to use it.
+ * @covers Language::sprintfDate
+ */
+ public function testSprintfDateNoTtlIfNotNeeded() {
+ $noTtl = 'unused'; // Value used to represent that the caller didn't pass a variable in.
+ $ttl = null;
+ $this->getLang()->sprintfDate( 'YmdHis', wfTimestampNow(), null, $noTtl );
+ $this->getLang()->sprintfDate( 'YmdHis', wfTimestampNow(), null, $ttl );
+
+ $this->assertSame(
+ 'unused',
+ $noTtl,
+ 'If the caller does not set the $ttl variable, do not compute it.'
+ );
+ $this->assertInternalType( 'int', $ttl, 'TTL should have been computed.' );
+ }
+
+ public static function provideSprintfDateSamples() {
+ return [
+ [
+ 'xiY',
+ '20111212000000',
+ '1390', // note because we're testing English locale we get Latin-standard digits
+ '1390',
+ 'Iranian calendar full year'
+ ],
+ [
+ 'xiy',
+ '20111212000000',
+ '90',
+ '90',
+ 'Iranian calendar short year'
+ ],
+ [
+ 'o',
+ '20120101235000',
+ '2011',
+ '2011',
+ 'ISO 8601 (week) year'
+ ],
+ [
+ 'W',
+ '20120101235000',
+ '52',
+ '52',
+ 'Week number'
+ ],
+ [
+ 'W',
+ '20120102235000',
+ '1',
+ '1',
+ 'Week number'
+ ],
+ [
+ 'o-\\WW-N',
+ '20091231235000',
+ '2009-W53-4',
+ '2009-W53-4',
+ 'leap week'
+ ],
+ // What follows is mostly copied from
+ // https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions#.23time
+ [
+ 'Y',
+ '20120102090705',
+ '2012',
+ '2012',
+ 'Full year'
+ ],
+ [
+ 'y',
+ '20120102090705',
+ '12',
+ '12',
+ '2 digit year'
+ ],
+ [
+ 'L',
+ '20120102090705',
+ '1',
+ '1',
+ 'Leap year'
+ ],
+ [
+ 'n',
+ '20120102090705',
+ '1',
+ '1',
+ 'Month index, not zero pad'
+ ],
+ [
+ 'N',
+ '20120102090705',
+ '01',
+ '01',
+ 'Month index. Zero pad'
+ ],
+ [
+ 'M',
+ '20120102090705',
+ 'Jan',
+ 'Jan',
+ 'Month abbrev'
+ ],
+ [
+ 'F',
+ '20120102090705',
+ 'January',
+ 'January',
+ 'Full month'
+ ],
+ [
+ 'xg',
+ '20120102090705',
+ 'January',
+ 'January',
+ 'Genitive month name (same in EN)'
+ ],
+ [
+ 'j',
+ '20120102090705',
+ '2',
+ '2',
+ 'Day of month (not zero pad)'
+ ],
+ [
+ 'd',
+ '20120102090705',
+ '02',
+ '02',
+ 'Day of month (zero-pad)'
+ ],
+ [
+ 'z',
+ '20120102090705',
+ '1',
+ '1',
+ 'Day of year (zero-indexed)'
+ ],
+ [
+ 'D',
+ '20120102090705',
+ 'Mon',
+ 'Mon',
+ 'Day of week (abbrev)'
+ ],
+ [
+ 'l',
+ '20120102090705',
+ 'Monday',
+ 'Monday',
+ 'Full day of week'
+ ],
+ [
+ 'N',
+ '20120101090705',
+ '7',
+ '7',
+ 'Day of week (Mon=1, Sun=7)'
+ ],
+ [
+ 'w',
+ '20120101090705',
+ '0',
+ '0',
+ 'Day of week (Sun=0, Sat=6)'
+ ],
+ [
+ 'N',
+ '20120102090705',
+ '1',
+ '1',
+ 'Day of week'
+ ],
+ [
+ 'a',
+ '20120102090705',
+ 'am',
+ 'am',
+ 'am vs pm'
+ ],
+ [
+ 'A',
+ '20120102120000',
+ 'PM',
+ 'PM',
+ 'AM vs PM'
+ ],
+ [
+ 'a',
+ '20120102000000',
+ 'am',
+ 'am',
+ 'AM vs PM'
+ ],
+ [
+ 'g',
+ '20120102090705',
+ '9',
+ '9',
+ '12 hour, not Zero'
+ ],
+ [
+ 'h',
+ '20120102090705',
+ '09',
+ '09',
+ '12 hour, zero padded'
+ ],
+ [
+ 'G',
+ '20120102090705',
+ '9',
+ '9',
+ '24 hour, not zero'
+ ],
+ [
+ 'H',
+ '20120102090705',
+ '09',
+ '09',
+ '24 hour, zero'
+ ],
+ [
+ 'H',
+ '20120102110705',
+ '11',
+ '11',
+ '24 hour, zero'
+ ],
+ [
+ 'i',
+ '20120102090705',
+ '07',
+ '07',
+ 'Minutes'
+ ],
+ [
+ 's',
+ '20120102090705',
+ '05',
+ '05',
+ 'seconds'
+ ],
+ [
+ 'U',
+ '20120102090705',
+ '1325495225',
+ '1325462825',
+ 'unix time'
+ ],
+ [
+ 't',
+ '20120102090705',
+ '31',
+ '31',
+ 'Days in current month'
+ ],
+ [
+ 'c',
+ '20120102090705',
+ '2012-01-02T09:07:05+00:00',
+ '2012-01-02T09:07:05+09:00',
+ 'ISO 8601 timestamp'
+ ],
+ [
+ 'r',
+ '20120102090705',
+ 'Mon, 02 Jan 2012 09:07:05 +0000',
+ 'Mon, 02 Jan 2012 09:07:05 +0900',
+ 'RFC 5322'
+ ],
+ [
+ 'e',
+ '20120102090705',
+ 'UTC',
+ 'Asia/Seoul',
+ 'Timezone identifier'
+ ],
+ [
+ 'I',
+ '19880602090705',
+ '0',
+ '1',
+ 'DST indicator'
+ ],
+ [
+ 'O',
+ '20120102090705',
+ '+0000',
+ '+0900',
+ 'Timezone offset'
+ ],
+ [
+ 'P',
+ '20120102090705',
+ '+00:00',
+ '+09:00',
+ 'Timezone offset with colon'
+ ],
+ [
+ 'T',
+ '20120102090705',
+ 'UTC',
+ 'KST',
+ 'Timezone abbreviation'
+ ],
+ [
+ 'Z',
+ '20120102090705',
+ '0',
+ '32400',
+ 'Timezone offset in seconds'
+ ],
+ [
+ 'xmj xmF xmn xmY',
+ '20120102090705',
+ '7 Safar 2 1433',
+ '7 Safar 2 1433',
+ 'Islamic'
+ ],
+ [
+ 'xij xiF xin xiY',
+ '20120102090705',
+ '12 Dey 10 1390',
+ '12 Dey 10 1390',
+ 'Iranian'
+ ],
+ [
+ 'xjj xjF xjn xjY',
+ '20120102090705',
+ '7 Tevet 4 5772',
+ '7 Tevet 4 5772',
+ 'Hebrew'
+ ],
+ [
+ 'xjt',
+ '20120102090705',
+ '29',
+ '29',
+ 'Hebrew number of days in month'
+ ],
+ [
+ 'xjx',
+ '20120102090705',
+ 'Tevet',
+ 'Tevet',
+ 'Hebrew genitive month name (No difference in EN)'
+ ],
+ [
+ 'xkY',
+ '20120102090705',
+ '2555',
+ '2555',
+ 'Thai year'
+ ],
+ [
+ 'xoY',
+ '20120102090705',
+ '101',
+ '101',
+ 'Minguo'
+ ],
+ [
+ 'xtY',
+ '20120102090705',
+ '平成24',
+ '平成24',
+ 'nengo'
+ ],
+ [
+ 'xtY',
+ '20190430235959',
+ '平成31',
+ '平成31',
+ 'nengo - last day of heisei'
+ ],
+ [
+ 'xtY',
+ '20190501000000',
+ '令和元',
+ '令和元',
+ 'nengo - first day of reiwa'
+ ],
+ [
+ 'xtY',
+ '20200501000000',
+ '令和2',
+ '令和2',
+ 'nengo - second year of reiwa'
+ ],
+ [
+ 'xrxkYY',
+ '20120102090705',
+ 'MMDLV2012',
+ 'MMDLV2012',
+ 'Roman numerals'
+ ],
+ [
+ 'xhxjYY',
+ '20120102090705',
+ 'ה\'תשע"ב2012',
+ 'ה\'תשע"ב2012',
+ 'Hebrew numberals'
+ ],
+ [
+ 'xnY',
+ '20120102090705',
+ '2012',
+ '2012',
+ 'Raw numerals (doesn\'t mean much in EN)'
+ ],
+ [
+ '[[Y "(yea"\\r)]] \\"xx\\"',
+ '20120102090705',
+ '[[2012 (year)]] "x"',
+ '[[2012 (year)]] "x"',
+ 'Various escaping'
+ ],
+
+ ];
+ }
+
+ /**
+ * @dataProvider provideFormatSizes
+ * @covers Language::formatSize
+ */
+ public function testFormatSize( $size, $expected, $msg ) {
+ $this->assertEquals(
+ $expected,
+ $this->getLang()->formatSize( $size ),
+ "formatSize('$size'): $msg"
+ );
+ }
+
+ public static function provideFormatSizes() {
+ return [
+ [
+ 0,
+ "0 bytes",
+ "Zero bytes"
+ ],
+ [
+ 1024,
+ "1 KB",
+ "1 kilobyte"
+ ],
+ [
+ 1024 * 1024,
+ "1 MB",
+ "1,024 megabytes"
+ ],
+ [
+ 1024 * 1024 * 1024,
+ "1 GB",
+ "1 gigabyte"
+ ],
+ [
+ pow( 1024, 4 ),
+ "1 TB",
+ "1 terabyte"
+ ],
+ [
+ pow( 1024, 5 ),
+ "1 PB",
+ "1 petabyte"
+ ],
+ [
+ pow( 1024, 6 ),
+ "1 EB",
+ "1,024 exabyte"
+ ],
+ [
+ pow( 1024, 7 ),
+ "1 ZB",
+ "1 zetabyte"
+ ],
+ [
+ pow( 1024, 8 ),
+ "1 YB",
+ "1 yottabyte"
+ ],
+ // How big!? THIS BIG!
+ ];
+ }
+
+ /**
+ * @dataProvider provideFormatBitrate
+ * @covers Language::formatBitrate
+ */
+ public function testFormatBitrate( $bps, $expected, $msg ) {
+ $this->assertEquals(
+ $expected,
+ $this->getLang()->formatBitrate( $bps ),
+ "formatBitrate('$bps'): $msg"
+ );
+ }
+
+ public static function provideFormatBitrate() {
+ return [
+ [
+ 0,
+ "0 bps",
+ "0 bits per second"
+ ],
+ [
+ 999,
+ "999 bps",
+ "999 bits per second"
+ ],
+ [
+ 1000,
+ "1 kbps",
+ "1 kilobit per second"
+ ],
+ [
+ 1000 * 1000,
+ "1 Mbps",
+ "1 megabit per second"
+ ],
+ [
+ pow( 10, 9 ),
+ "1 Gbps",
+ "1 gigabit per second"
+ ],
+ [
+ pow( 10, 12 ),
+ "1 Tbps",
+ "1 terabit per second"
+ ],
+ [
+ pow( 10, 15 ),
+ "1 Pbps",
+ "1 petabit per second"
+ ],
+ [
+ pow( 10, 18 ),
+ "1 Ebps",
+ "1 exabit per second"
+ ],
+ [
+ pow( 10, 21 ),
+ "1 Zbps",
+ "1 zetabit per second"
+ ],
+ [
+ pow( 10, 24 ),
+ "1 Ybps",
+ "1 yottabit per second"
+ ],
+ [
+ pow( 10, 27 ),
+ "1,000 Ybps",
+ "1,000 yottabits per second"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideFormatDuration
+ * @covers Language::formatDuration
+ */
+ public function testFormatDuration( $duration, $expected, $intervals = [] ) {
+ $this->assertEquals(
+ $expected,
+ $this->getLang()->formatDuration( $duration, $intervals ),
+ "formatDuration('$duration'): $expected"
+ );
+ }
+
+ public static function provideFormatDuration() {
+ return [
+ [
+ 0,
+ '0 seconds',
+ ],
+ [
+ 1,
+ '1 second',
+ ],
+ [
+ 2,
+ '2 seconds',
+ ],
+ [
+ 60,
+ '1 minute',
+ ],
+ [
+ 2 * 60,
+ '2 minutes',
+ ],
+ [
+ 3600,
+ '1 hour',
+ ],
+ [
+ 2 * 3600,
+ '2 hours',
+ ],
+ [
+ 24 * 3600,
+ '1 day',
+ ],
+ [
+ 2 * 86400,
+ '2 days',
+ ],
+ [
+ // ( 365 + ( 24 * 3 + 25 ) / 400 ) * 86400 = 31556952
+ ( 365 + ( 24 * 3 + 25 ) / 400.0 ) * 86400,
+ '1 year',
+ ],
+ [
+ 2 * 31556952,
+ '2 years',
+ ],
+ [
+ 10 * 31556952,
+ '1 decade',
+ ],
+ [
+ 20 * 31556952,
+ '2 decades',
+ ],
+ [
+ 100 * 31556952,
+ '1 century',
+ ],
+ [
+ 200 * 31556952,
+ '2 centuries',
+ ],
+ [
+ 1000 * 31556952,
+ '1 millennium',
+ ],
+ [
+ 2000 * 31556952,
+ '2 millennia',
+ ],
+ [
+ 9001,
+ '2 hours, 30 minutes and 1 second'
+ ],
+ [
+ 3601,
+ '1 hour and 1 second'
+ ],
+ [
+ 31556952 + 2 * 86400 + 9000,
+ '1 year, 2 days, 2 hours and 30 minutes'
+ ],
+ [
+ 42 * 1000 * 31556952 + 42,
+ '42 millennia and 42 seconds'
+ ],
+ [
+ 60,
+ '60 seconds',
+ [ 'seconds' ],
+ ],
+ [
+ 61,
+ '61 seconds',
+ [ 'seconds' ],
+ ],
+ [
+ 1,
+ '1 second',
+ [ 'seconds' ],
+ ],
+ [
+ 31556952 + 2 * 86400 + 9000,
+ '1 year, 2 days and 150 minutes',
+ [ 'years', 'days', 'minutes' ],
+ ],
+ [
+ 42,
+ '0 days',
+ [ 'years', 'days' ],
+ ],
+ [
+ 31556952 + 2 * 86400 + 9000,
+ '1 year, 2 days and 150 minutes',
+ [ 'minutes', 'days', 'years' ],
+ ],
+ [
+ 42,
+ '0 days',
+ [ 'days', 'years' ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideCheckTitleEncodingData
+ * @covers Language::checkTitleEncoding
+ */
+ public function testCheckTitleEncoding( $s ) {
+ $this->assertEquals(
+ $s,
+ $this->getLang()->checkTitleEncoding( $s ),
+ "checkTitleEncoding('$s')"
+ );
+ }
+
+ public static function provideCheckTitleEncodingData() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [ "" ],
+ [ "United States of America" ], // 7bit ASCII
+ [ rawurldecode( "S%C3%A9rie%20t%C3%A9l%C3%A9vis%C3%A9e" ) ],
+ [
+ rawurldecode(
+ "Acteur%7CAlbert%20Robbins%7CAnglais%7CAnn%20Donahue%7CAnthony%20E.%20Zuiker%7CCarol%20Mendelsohn"
+ )
+ ],
+ // The following two data sets come from T38839. They fail if checkTitleEncoding uses a regexp to test for
+ // valid UTF-8 encoding and the pcre.recursion_limit is low (like, say, 1024). They succeed if checkTitleEncoding
+ // uses mb_check_encoding for its test.
+ [
+ rawurldecode(
+ "Acteur%7CAlbert%20Robbins%7CAnglais%7CAnn%20Donahue%7CAnthony%20E.%20Zuiker%7CCarol%20Mendelsohn%7C"
+ . "Catherine%20Willows%7CDavid%20Hodges%7CDavid%20Phillips%7CGil%20Grissom%7CGreg%20Sanders%7CHodges%7C"
+ . "Internet%20Movie%20Database%7CJim%20Brass%7CLady%20Heather%7C"
+ . "Les%20Experts%20(s%C3%A9rie%20t%C3%A9l%C3%A9vis%C3%A9e)%7CLes%20Experts%20:%20Manhattan%7C"
+ . "Les%20Experts%20:%20Miami%7CListe%20des%20personnages%20des%20Experts%7C"
+ . "Liste%20des%20%C3%A9pisodes%20des%20Experts%7CMod%C3%A8le%20discussion:Palette%20Les%20Experts%7C"
+ . "Nick%20Stokes%7CPersonnage%20de%20fiction%7CPersonnage%20fictif%7CPersonnage%20de%20fiction%7C"
+ . "Personnages%20r%C3%A9currents%20dans%20Les%20Experts%7CRaymond%20Langston%7CRiley%20Adams%7C"
+ . "Saison%201%20des%20Experts%7CSaison%2010%20des%20Experts%7CSaison%2011%20des%20Experts%7C"
+ . "Saison%2012%20des%20Experts%7CSaison%202%20des%20Experts%7CSaison%203%20des%20Experts%7C"
+ . "Saison%204%20des%20Experts%7CSaison%205%20des%20Experts%7CSaison%206%20des%20Experts%7C"
+ . "Saison%207%20des%20Experts%7CSaison%208%20des%20Experts%7CSaison%209%20des%20Experts%7C"
+ . "Sara%20Sidle%7CSofia%20Curtis%7CS%C3%A9rie%20t%C3%A9l%C3%A9vis%C3%A9e%7CWallace%20Langham%7C"
+ . "Warrick%20Brown%7CWendy%20Simms%7C%C3%89tats-Unis"
+ ),
+ ],
+ [
+ rawurldecode(
+ "Mod%C3%A8le%3AArrondissements%20homonymes%7CMod%C3%A8le%3ABandeau%20standard%20pour%20page%20d'homonymie%7C"
+ . "Mod%C3%A8le%3ABatailles%20homonymes%7CMod%C3%A8le%3ACantons%20homonymes%7C"
+ . "Mod%C3%A8le%3ACommunes%20fran%C3%A7aises%20homonymes%7CMod%C3%A8le%3AFilms%20homonymes%7C"
+ . "Mod%C3%A8le%3AGouvernements%20homonymes%7CMod%C3%A8le%3AGuerres%20homonymes%7CMod%C3%A8le%3AHomonymie%7C"
+ . "Mod%C3%A8le%3AHomonymie%20bateau%7CMod%C3%A8le%3AHomonymie%20d'%C3%A9tablissements%20scolaires%20ou"
+ . "%20universitaires%7CMod%C3%A8le%3AHomonymie%20d'%C3%AEles%7CMod%C3%A8le%3AHomonymie%20de%20clubs%20sportifs%7C"
+ . "Mod%C3%A8le%3AHomonymie%20de%20comt%C3%A9s%7CMod%C3%A8le%3AHomonymie%20de%20monument%7C"
+ . "Mod%C3%A8le%3AHomonymie%20de%20nom%20romain%7CMod%C3%A8le%3AHomonymie%20de%20parti%20politique%7C"
+ . "Mod%C3%A8le%3AHomonymie%20de%20route%7CMod%C3%A8le%3AHomonymie%20dynastique%7C"
+ . "Mod%C3%A8le%3AHomonymie%20vid%C3%A9oludique%7CMod%C3%A8le%3AHomonymie%20%C3%A9difice%20religieux%7C"
+ . "Mod%C3%A8le%3AInternationalisation%7CMod%C3%A8le%3AIsom%C3%A9rie%7CMod%C3%A8le%3AParonymie%7C"
+ . "Mod%C3%A8le%3APatronyme%7CMod%C3%A8le%3APatronyme%20basque%7CMod%C3%A8le%3APatronyme%20italien%7C"
+ . "Mod%C3%A8le%3APatronymie%7CMod%C3%A8le%3APersonnes%20homonymes%7CMod%C3%A8le%3ASaints%20homonymes%7C"
+ . "Mod%C3%A8le%3ATitres%20homonymes%7CMod%C3%A8le%3AToponymie%7CMod%C3%A8le%3AUnit%C3%A9s%20homonymes%7C"
+ . "Mod%C3%A8le%3AVilles%20homonymes%7CMod%C3%A8le%3A%C3%89difices%20religieux%20homonymes"
+ )
+ ]
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @dataProvider provideRomanNumeralsData
+ * @covers Language::romanNumeral
+ */
+ public function testRomanNumerals( $num, $numerals ) {
+ $this->assertEquals(
+ $numerals,
+ Language::romanNumeral( $num ),
+ "romanNumeral('$num')"
+ );
+ }
+
+ public static function provideRomanNumeralsData() {
+ return [
+ [ 1, 'I' ],
+ [ 2, 'II' ],
+ [ 3, 'III' ],
+ [ 4, 'IV' ],
+ [ 5, 'V' ],
+ [ 6, 'VI' ],
+ [ 7, 'VII' ],
+ [ 8, 'VIII' ],
+ [ 9, 'IX' ],
+ [ 10, 'X' ],
+ [ 20, 'XX' ],
+ [ 30, 'XXX' ],
+ [ 40, 'XL' ],
+ [ 49, 'XLIX' ],
+ [ 50, 'L' ],
+ [ 60, 'LX' ],
+ [ 70, 'LXX' ],
+ [ 80, 'LXXX' ],
+ [ 90, 'XC' ],
+ [ 99, 'XCIX' ],
+ [ 100, 'C' ],
+ [ 200, 'CC' ],
+ [ 300, 'CCC' ],
+ [ 400, 'CD' ],
+ [ 500, 'D' ],
+ [ 600, 'DC' ],
+ [ 700, 'DCC' ],
+ [ 800, 'DCCC' ],
+ [ 900, 'CM' ],
+ [ 999, 'CMXCIX' ],
+ [ 1000, 'M' ],
+ [ 1989, 'MCMLXXXIX' ],
+ [ 2000, 'MM' ],
+ [ 3000, 'MMM' ],
+ [ 4000, 'MMMM' ],
+ [ 5000, 'MMMMM' ],
+ [ 6000, 'MMMMMM' ],
+ [ 7000, 'MMMMMMM' ],
+ [ 8000, 'MMMMMMMM' ],
+ [ 9000, 'MMMMMMMMM' ],
+ [ 9999, 'MMMMMMMMMCMXCIX' ],
+ [ 10000, 'MMMMMMMMMM' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideHebrewNumeralsData
+ * @covers Language::hebrewNumeral
+ */
+ public function testHebrewNumeral( $num, $numerals ) {
+ $this->assertEquals(
+ $numerals,
+ Language::hebrewNumeral( $num ),
+ "hebrewNumeral('$num')"
+ );
+ }
+
+ public static function provideHebrewNumeralsData() {
+ return [
+ [ -1, -1 ],
+ [ 0, 0 ],
+ [ 1, "א'" ],
+ [ 2, "ב'" ],
+ [ 3, "ג'" ],
+ [ 4, "ד'" ],
+ [ 5, "ה'" ],
+ [ 6, "ו'" ],
+ [ 7, "ז'" ],
+ [ 8, "ח'" ],
+ [ 9, "ט'" ],
+ [ 10, "י'" ],
+ [ 11, 'י"א' ],
+ [ 14, 'י"ד' ],
+ [ 15, 'ט"ו' ],
+ [ 16, 'ט"ז' ],
+ [ 17, 'י"ז' ],
+ [ 20, "כ'" ],
+ [ 21, 'כ"א' ],
+ [ 30, "ל'" ],
+ [ 40, "מ'" ],
+ [ 50, "נ'" ],
+ [ 60, "ס'" ],
+ [ 70, "ע'" ],
+ [ 80, "פ'" ],
+ [ 90, "צ'" ],
+ [ 99, 'צ"ט' ],
+ [ 100, "ק'" ],
+ [ 101, 'ק"א' ],
+ [ 110, 'ק"י' ],
+ [ 200, "ר'" ],
+ [ 300, "ש'" ],
+ [ 400, "ת'" ],
+ [ 500, 'ת"ק' ],
+ [ 800, 'ת"ת' ],
+ [ 1000, "א' אלף" ],
+ [ 1001, "א'א'" ],
+ [ 1012, "א'י\"ב" ],
+ [ 1020, "א'ך'" ],
+ [ 1030, "א'ל'" ],
+ [ 1081, "א'פ\"א" ],
+ [ 2000, "ב' אלפים" ],
+ [ 2016, "ב'ט\"ז" ],
+ [ 3000, "ג' אלפים" ],
+ [ 4000, "ד' אלפים" ],
+ [ 4904, "ד'תתק\"ד" ],
+ [ 5000, "ה' אלפים" ],
+ [ 5680, "ה'תר\"ף" ],
+ [ 5690, "ה'תר\"ץ" ],
+ [ 5708, "ה'תש\"ח" ],
+ [ 5720, "ה'תש\"ך" ],
+ [ 5740, "ה'תש\"ם" ],
+ [ 5750, "ה'תש\"ן" ],
+ [ 5775, "ה'תשע\"ה" ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePluralData
+ * @covers Language::convertPlural
+ */
+ public function testConvertPlural( $expected, $number, $forms ) {
+ $chosen = $this->getLang()->convertPlural( $number, $forms );
+ $this->assertEquals( $expected, $chosen );
+ }
+
+ public static function providePluralData() {
+ // Params are: [expected text, number given, [the plural forms]]
+ return [
+ [ 'plural', 0, [
+ 'singular', 'plural'
+ ] ],
+ [ 'explicit zero', 0, [
+ '0=explicit zero', 'singular', 'plural'
+ ] ],
+ [ 'explicit one', 1, [
+ 'singular', 'plural', '1=explicit one',
+ ] ],
+ [ 'singular', 1, [
+ 'singular', 'plural', '0=explicit zero',
+ ] ],
+ [ 'plural', 3, [
+ '0=explicit zero', '1=explicit one', 'singular', 'plural'
+ ] ],
+ [ 'explicit eleven', 11, [
+ 'singular', 'plural', '11=explicit eleven',
+ ] ],
+ [ 'plural', 12, [
+ 'singular', 'plural', '11=explicit twelve',
+ ] ],
+ [ 'plural', 12, [
+ 'singular', 'plural', '=explicit form',
+ ] ],
+ [ 'other', 2, [
+ 'kissa=kala', '1=2=3', 'other',
+ ] ],
+ [ '', 2, [
+ '0=explicit zero', '1=explicit one',
+ ] ],
+ ];
+ }
+
+ /**
+ * @covers Language::embedBidi()
+ */
+ public function testEmbedBidi() {
+ $lre = "\xE2\x80\xAA"; // U+202A LEFT-TO-RIGHT EMBEDDING
+ $rle = "\xE2\x80\xAB"; // U+202B RIGHT-TO-LEFT EMBEDDING
+ $pdf = "\xE2\x80\xAC"; // U+202C POP DIRECTIONAL FORMATTING
+ $lang = $this->getLang();
+ $this->assertEquals(
+ '123',
+ $lang->embedBidi( '123' ),
+ 'embedBidi with neutral argument'
+ );
+ $this->assertEquals(
+ $lre . 'Ben_(WMF)' . $pdf,
+ $lang->embedBidi( 'Ben_(WMF)' ),
+ 'embedBidi with LTR argument'
+ );
+ $this->assertEquals(
+ $rle . 'יהודי (מנוחין)' . $pdf,
+ $lang->embedBidi( 'יהודי (מנוחין)' ),
+ 'embedBidi with RTL argument'
+ );
+ }
+
+ /**
+ * @covers Language::translateBlockExpiry()
+ * @dataProvider provideTranslateBlockExpiry
+ */
+ public function testTranslateBlockExpiry( $expectedData, $str, $now, $desc ) {
+ $lang = $this->getLang();
+ if ( is_array( $expectedData ) ) {
+ list( $func, $arg ) = $expectedData;
+ $expected = $lang->$func( $arg );
+ } else {
+ $expected = $expectedData;
+ }
+ $this->assertEquals( $expected, $lang->translateBlockExpiry( $str, null, $now ), $desc );
+ }
+
+ public static function provideTranslateBlockExpiry() {
+ return [
+ [ '2 hours', '2 hours', 0, 'simple data from ipboptions' ],
+ [ 'indefinite', 'infinite', 0, 'infinite from ipboptions' ],
+ [ 'indefinite', 'infinity', 0, 'alternative infinite from ipboptions' ],
+ [ 'indefinite', 'indefinite', 0, 'another alternative infinite from ipboptions' ],
+ [ [ 'formatDuration', 1023 * 60 * 60 ], '1023 hours', 0, 'relative' ],
+ [ [ 'formatDuration', -1023 ], '-1023 seconds', 0, 'negative relative' ],
+ [
+ [ 'formatDuration', 1023 * 60 * 60 ],
+ '1023 hours',
+ wfTimestamp( TS_UNIX, '19910203040506' ),
+ 'relative with initial timestamp'
+ ],
+ [ [ 'formatDuration', 0 ], 'now', 0, 'now' ],
+ [
+ [ 'timeanddate', '20120102070000' ],
+ '2012-1-1 7:00 +1 day',
+ 0,
+ 'mixed, handled as absolute'
+ ],
+ [ [ 'timeanddate', '19910203040506' ], '1991-2-3 4:05:06', 0, 'absolute' ],
+ [ [ 'timeanddate', '19700101000000' ], '1970-1-1 0:00:00', 0, 'absolute at epoch' ],
+ [ [ 'timeanddate', '19691231235959' ], '1969-12-31 23:59:59', 0, 'time before epoch' ],
+ [
+ [ 'timeanddate', '19910910000000' ],
+ '10 september',
+ wfTimestamp( TS_UNIX, '19910203040506' ),
+ 'partial'
+ ],
+ [ 'dummy', 'dummy', 0, 'return garbage as is' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideFormatNum
+ * @covers Language::formatNum
+ */
+ public function testFormatNum(
+ $translateNumerals, $langCode, $number, $nocommafy, $expected
+ ) {
+ $this->setMwGlobals( [ 'wgTranslateNumerals' => $translateNumerals ] );
+ $lang = Language::factory( $langCode );
+ $formattedNum = $lang->formatNum( $number, $nocommafy );
+ $this->assertType( 'string', $formattedNum );
+ $this->assertEquals( $expected, $formattedNum );
+ }
+
+ public function provideFormatNum() {
+ return [
+ [ true, 'en', 100, false, '100' ],
+ [ true, 'en', 101, true, '101' ],
+ [ false, 'en', 103, false, '103' ],
+ [ false, 'en', 104, true, '104' ],
+ [ true, 'en', '105', false, '105' ],
+ [ true, 'en', '106', true, '106' ],
+ [ false, 'en', '107', false, '107' ],
+ [ false, 'en', '108', true, '108' ],
+ ];
+ }
+
+ /**
+ * @covers Language::parseFormattedNumber
+ * @dataProvider parseFormattedNumberProvider
+ */
+ public function testParseFormattedNumber( $langCode, $number ) {
+ $lang = Language::factory( $langCode );
+
+ $localisedNum = $lang->formatNum( $number );
+ $normalisedNum = $lang->parseFormattedNumber( $localisedNum );
+
+ $this->assertEquals( $number, $normalisedNum );
+ }
+
+ public function parseFormattedNumberProvider() {
+ return [
+ [ 'de', 377.01 ],
+ [ 'fa', 334 ],
+ [ 'fa', 382.772 ],
+ [ 'ar', 1844 ],
+ [ 'lzh', 3731 ],
+ [ 'zh-classical', 7432 ]
+ ];
+ }
+
+ /**
+ * @covers Language::commafy()
+ * @dataProvider provideCommafyData
+ */
+ public function testCommafy( $number, $numbersWithCommas ) {
+ $this->assertEquals(
+ $numbersWithCommas,
+ $this->getLang()->commafy( $number ),
+ "commafy('$number')"
+ );
+ }
+
+ public static function provideCommafyData() {
+ return [
+ [ -1, '-1' ],
+ [ 10, '10' ],
+ [ 100, '100' ],
+ [ 1000, '1,000' ],
+ [ 10000, '10,000' ],
+ [ 100000, '100,000' ],
+ [ 1000000, '1,000,000' ],
+ [ -1.0001, '-1.0001' ],
+ [ 1.0001, '1.0001' ],
+ [ 10.0001, '10.0001' ],
+ [ 100.0001, '100.0001' ],
+ [ 1000.0001, '1,000.0001' ],
+ [ 10000.0001, '10,000.0001' ],
+ [ 100000.0001, '100,000.0001' ],
+ [ 1000000.0001, '1,000,000.0001' ],
+ [ '200000000000000000000', '200,000,000,000,000,000,000' ],
+ [ '-200000000000000000000', '-200,000,000,000,000,000,000' ],
+ ];
+ }
+
+ /**
+ * @covers Language::listToText
+ */
+ public function testListToText() {
+ $lang = $this->getLang();
+ $and = $lang->getMessageFromDB( 'and' );
+ $s = $lang->getMessageFromDB( 'word-separator' );
+ $c = $lang->getMessageFromDB( 'comma-separator' );
+
+ $this->assertEquals( '', $lang->listToText( [] ) );
+ $this->assertEquals( 'a', $lang->listToText( [ 'a' ] ) );
+ $this->assertEquals( "a{$and}{$s}b", $lang->listToText( [ 'a', 'b' ] ) );
+ $this->assertEquals( "a{$c}b{$and}{$s}c", $lang->listToText( [ 'a', 'b', 'c' ] ) );
+ $this->assertEquals( "a{$c}b{$c}c{$and}{$s}d", $lang->listToText( [ 'a', 'b', 'c', 'd' ] ) );
+ }
+
+ /**
+ * @dataProvider provideIsSupportedLanguage
+ * @covers Language::isSupportedLanguage
+ */
+ public function testIsSupportedLanguage( $code, $expected, $comment ) {
+ $this->assertEquals( $expected, Language::isSupportedLanguage( $code ), $comment );
+ }
+
+ public static function provideIsSupportedLanguage() {
+ return [
+ [ 'en', true, 'is supported language' ],
+ [ 'fi', true, 'is supported language' ],
+ [ 'bunny', false, 'is not supported language' ],
+ [ 'FI', false, 'is not supported language, input should be in lower case' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetParentLanguage
+ * @covers Language::getParentLanguage
+ */
+ public function testGetParentLanguage( $code, $expected, $comment ) {
+ $lang = Language::factory( $code );
+ if ( is_null( $expected ) ) {
+ $this->assertNull( $lang->getParentLanguage(), $comment );
+ } else {
+ $this->assertEquals( $expected, $lang->getParentLanguage()->getCode(), $comment );
+ }
+ }
+
+ public static function provideGetParentLanguage() {
+ return [
+ [ 'zh-cn', 'zh', 'zh is the parent language of zh-cn' ],
+ [ 'zh', 'zh', 'zh is defined as the parent language of zh, '
+ . 'because zh converter can convert zh-cn to zh' ],
+ [ 'zh-invalid', null, 'do not be fooled by arbitrarily composed language codes' ],
+ [ 'de-formal', null, 'de does not have converter' ],
+ [ 'de', null, 'de does not have converter' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetNamespaceAliases
+ * @covers Language::getNamespaceAliases
+ */
+ public function testGetNamespaceAliases( $languageCode, $subset ) {
+ $language = Language::factory( $languageCode );
+ $aliases = $language->getNamespaceAliases();
+ foreach ( $subset as $alias => $nsId ) {
+ $this->assertEquals( $nsId, $aliases[$alias] );
+ }
+ }
+
+ public static function provideGetNamespaceAliases() {
+ // TODO: Add tests for NS_PROJECT_TALK and GenderNamespaces
+ return [
+ [
+ 'zh',
+ [
+ '文件' => NS_FILE,
+ '檔案' => NS_FILE,
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @covers Language::equals
+ */
+ public function testEquals() {
+ $en1 = new Language();
+ $en1->setCode( 'en' );
+
+ $en2 = Language::factory( 'en' );
+ $en2->setCode( 'en' );
+
+ $this->assertTrue( $en1->equals( $en2 ), 'en equals en' );
+
+ $fr = Language::factory( 'fr' );
+ $this->assertFalse( $en1->equals( $fr ), 'en not equals fr' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/SpecialPageAliasTest.php b/www/wiki/tests/phpunit/languages/SpecialPageAliasTest.php
new file mode 100644
index 00000000..0bb6a4d2
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/SpecialPageAliasTest.php
@@ -0,0 +1,62 @@
+<?php
+
+/**
+ * Verifies that special page aliases are valid, with no slashes.
+ *
+ * @group Language
+ * @group SpecialPageAliases
+ * @group SystemTest
+ * @group medium
+ *
+ * @author Katie Filbert < aude.wiki@gmail.com >
+ */
+class SpecialPageAliasTest extends MediaWikiTestCase {
+
+ /**
+ * @dataProvider validSpecialPageAliasesProvider
+ */
+ public function testValidSpecialPageAliases( $code, $specialPageAliases ) {
+ foreach ( $specialPageAliases as $specialPage => $aliases ) {
+ foreach ( $aliases as $alias ) {
+ $msg = "$specialPage alias '$alias' in $code is valid with no slashes";
+ $this->assertRegExp( '/^[^\/]*$/', $msg );
+ }
+ }
+ }
+
+ public function validSpecialPageAliasesProvider() {
+ $codes = array_keys( Language::fetchLanguageNames( null, 'mwfile' ) );
+
+ $data = [];
+
+ foreach ( $codes as $code ) {
+ $specialPageAliases = $this->getSpecialPageAliases( $code );
+
+ if ( $specialPageAliases !== [] ) {
+ $data[] = [ $code, $specialPageAliases ];
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param string $code
+ *
+ * @return array
+ */
+ protected function getSpecialPageAliases( $code ) {
+ $file = Language::getMessagesFileName( $code );
+
+ if ( is_readable( $file ) ) {
+ include $file;
+
+ if ( isset( $specialPageAliases ) && $specialPageAliases !== null ) {
+ return $specialPageAliases;
+ }
+ }
+
+ return [];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageAmTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageAmTest.php
new file mode 100644
index 00000000..a44cace4
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageAmTest.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/LanguageAm.php */
+class LanguageAmTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageArTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageArTest.php
new file mode 100644
index 00000000..f3f5a3f1
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageArTest.php
@@ -0,0 +1,89 @@
+<?php
+/**
+ * Based on LanguagMlTest
+ * @file
+ */
+
+/**
+ * @covers LanguageAr
+ */
+class LanguageArTest extends LanguageClassesTestCase {
+ /**
+ * @covers Language::formatNum
+ * @todo split into a test and a dataprovider
+ */
+ public function testFormatNum() {
+ $this->assertEquals( '١٬٢٣٤٬٥٦٧', $this->getLang()->formatNum( '1234567' ) );
+ $this->assertEquals( '-١٢٫٨٩', $this->getLang()->formatNum( -12.89 ) );
+ }
+
+ /**
+ * Mostly to test the raw ascii feature.
+ * @dataProvider providerSprintfDate
+ * @covers Language::sprintfDate
+ */
+ public function testSprintfDate( $format, $date, $expected ) {
+ $this->assertEquals( $expected, $this->getLang()->sprintfDate( $format, $date ) );
+ }
+
+ public static function providerSprintfDate() {
+ return [
+ [
+ 'xg "vs" g',
+ '20120102030410',
+ 'يناير vs ٣'
+ ],
+ [
+ 'xmY',
+ '20120102030410',
+ '١٤٣٣'
+ ],
+ [
+ 'xnxmY',
+ '20120102030410',
+ '1433'
+ ],
+ [
+ 'xN xmj xmn xN xmY',
+ '20120102030410',
+ ' 7 2 ١٤٣٣'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'zero', 'one', 'two', 'few', 'many', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'zero', 0 ],
+ [ 'one', 1 ],
+ [ 'two', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 9 ],
+ [ 'few', 110 ],
+ [ 'many', 11 ],
+ [ 'many', 15 ],
+ [ 'many', 99 ],
+ [ 'many', 9999 ],
+ [ 'other', 100 ],
+ [ 'other', 102 ],
+ [ 'other', 1000 ],
+ [ 'other', 1.7 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageArqTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageArqTest.php
new file mode 100644
index 00000000..e6692d17
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageArqTest.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * Based on LanguageMlTest
+ * @author Joel Sahleen
+ * @copyright Copyright © 2014, Joel Sahleen
+ * @file
+ */
+
+/** Tests for MediaWiki languages/LanguageArq.php */
+class LanguageArqTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider provideNumber
+ * @covers Language::formatNum
+ */
+ public function testFormatNum( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->formatNum( $value ) );
+ }
+
+ public static function provideNumber() {
+ return [
+ [ '1.234.567', '1234567' ],
+ [ '-12,89', -12.89 ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageBeTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageBeTest.php
new file mode 100644
index 00000000..de1adb7d
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageBeTest.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/LanguageBe.php */
+class LanguageBeTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'many', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 1 ],
+ [ 'many', 11 ],
+ [ 'one', 91 ],
+ [ 'one', 121 ],
+ [ 'few', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 4 ],
+ [ 'few', 334 ],
+ [ 'many', 5 ],
+ [ 'many', 15 ],
+ [ 'many', 120 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageBe_taraskTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageBe_taraskTest.php
new file mode 100644
index 00000000..4f049cd1
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageBe_taraskTest.php
@@ -0,0 +1,100 @@
+<?php
+
+// phpcs:disable Squiz.Classes.ValidClassName.NotCamelCaps
+/**
+ * @covers LanguageBe_tarask
+ */
+class LanguageBe_taraskTest extends LanguageClassesTestCase {
+ // phpcs:enable
+ /**
+ * Make sure the language code we are given is indeed
+ * be-tarask. This is to ensure LanguageClassesTestCase
+ * does not give us the wrong language.
+ */
+ public function testBeTaraskTestsUsesBeTaraskCode() {
+ $this->assertEquals( 'be-tarask',
+ $this->getLang()->getCode()
+ );
+ }
+
+ /**
+ * @see T25156 & r64981
+ * @covers Language::commafy
+ */
+ public function testSearchRightSingleQuotationMarkAsApostroph() {
+ $this->assertEquals(
+ "'",
+ $this->getLang()->normalizeForSearch( '’' ),
+ 'T25156: U+2019 conversion to U+0027'
+ );
+ }
+
+ /**
+ * @see T25156 & r64981
+ * @covers Language::commafy
+ */
+ public function testCommafy() {
+ $this->assertEquals( '1,234,567', $this->getLang()->commafy( '1234567' ) );
+ $this->assertEquals( '12,345', $this->getLang()->commafy( '12345' ) );
+ }
+
+ /**
+ * @see T25156 & r64981
+ * @covers Language::commafy
+ */
+ public function testDoesNotCommafyFourDigitsNumber() {
+ $this->assertEquals( '1234', $this->getLang()->commafy( '1234' ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'many', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 1 ],
+ [ 'many', 11 ],
+ [ 'one', 91 ],
+ [ 'one', 121 ],
+ [ 'few', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 4 ],
+ [ 'few', 334 ],
+ [ 'many', 5 ],
+ [ 'many', 15 ],
+ [ 'many', 120 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePluralTwoForms
+ * @covers Language::convertPlural
+ */
+ public function testPluralTwoForms( $result, $value ) {
+ $forms = [ '1=one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ public static function providePluralTwoForms() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 11 ],
+ [ 'other', 91 ],
+ [ 'other', 121 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageBhoTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageBhoTest.php
new file mode 100644
index 00000000..a9aba202
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageBhoTest.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/LanguageBho.php */
+class LanguageBhoTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageBsTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageBsTest.php
new file mode 100644
index 00000000..29b2ccf3
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageBsTest.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/**
+ * Tests for Croatian (hrvatski)
+ *
+ * @covers LanguageBs
+ */
+class LanguageBsTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'few', 2 ],
+ [ 'few', 4 ],
+ [ 'other', 5 ],
+ [ 'other', 11 ],
+ [ 'other', 20 ],
+ [ 'one', 21 ],
+ [ 'few', 24 ],
+ [ 'other', 25 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageCrhTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageCrhTest.php
new file mode 100644
index 00000000..7c99614e
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageCrhTest.php
@@ -0,0 +1,92 @@
+<?php
+
+/**
+ * @covers LanguageCrh
+ * @covers CrhConverter
+ */
+class LanguageCrhTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider provideAutoConvertToAllVariants
+ * @covers Language::autoConvertToAllVariants
+ */
+ public function testAutoConvertToAllVariants( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->autoConvertToAllVariants( $value ) );
+ }
+
+ public static function provideAutoConvertToAllVariants() {
+ return [
+ [ // general words, covering more of the alphabet
+ [
+ 'crh' => 'рузгярнынъ ruzgârnıñ Париж Parij',
+ 'crh-cyrl' => 'рузгярнынъ рузгярнынъ Париж Париж',
+ 'crh-latn' => 'ruzgârnıñ ruzgârnıñ Parij Parij',
+ ],
+ 'рузгярнынъ ruzgârnıñ Париж Parij'
+ ],
+ [ // general words, covering more of the alphabet
+ [
+ 'crh' => 'чёкюч çöküç элифбени elifbeni полициясы politsiyası',
+ 'crh-cyrl' => 'чёкюч чёкюч элифбени элифбени полициясы полициясы',
+ 'crh-latn' => 'çöküç çöküç elifbeni elifbeni politsiyası politsiyası',
+ ],
+ 'чёкюч çöküç элифбени elifbeni полициясы politsiyası'
+ ],
+ [ // general words, covering more of the alphabet
+ [
+ 'crh' => 'хусусында hususında акъшамларны aqşamlarnı опькеленюв öpkelenüv',
+ 'crh-cyrl' => 'хусусында хусусында акъшамларны акъшамларны опькеленюв опькеленюв',
+ 'crh-latn' => 'hususında hususında aqşamlarnı aqşamlarnı öpkelenüv öpkelenüv',
+ ],
+ 'хусусында hususında акъшамларны aqşamlarnı опькеленюв öpkelenüv'
+ ],
+ [ // general words, covering more of the alphabet
+ [
+ 'crh' => 'кулюмсиреди külümsiredi айтмайджагъым aytmaycağım козьяшсыз közyaşsız',
+ 'crh-cyrl' => 'кулюмсиреди кулюмсиреди айтмайджагъым айтмайджагъым козьяшсыз козьяшсыз',
+ 'crh-latn' => 'külümsiredi külümsiredi aytmaycağım aytmaycağım közyaşsız közyaşsız',
+ ],
+ 'кулюмсиреди külümsiredi айтмайджагъым aytmaycağım козьяшсыз közyaşsız'
+ ],
+ [ // exception words
+ [
+ 'crh' => 'инструменталь instrumental гургуль gürgül тюшюнмемек tüşünmemek',
+ 'crh-cyrl' => 'инструменталь инструменталь гургуль гургуль тюшюнмемек тюшюнмемек',
+ 'crh-latn' => 'instrumental instrumental gürgül gürgül tüşünmemek tüşünmemek',
+ ],
+ 'инструменталь instrumental гургуль gürgül тюшюнмемек tüşünmemek'
+ ],
+ [ // recent problem words, part 1
+ [
+ 'crh' => 'künü куню sürgünligi сюргюнлиги özü озю etti этти',
+ 'crh-cyrl' => 'куню куню сюргюнлиги сюргюнлиги озю озю этти этти',
+ 'crh-latn' => 'künü künü sürgünligi sürgünligi özü özü etti etti',
+ ],
+ 'künü куню sürgünligi сюргюнлиги özü озю etti этти'
+ ],
+ [ // recent problem words, part 2
+ [
+ 'crh' => 'esas эсас dört дёрт keldi кельди',
+ 'crh-cyrl' => 'эсас эсас дёрт дёрт кельди кельди',
+ 'crh-latn' => 'esas esas dört dört keldi keldi',
+ ],
+ 'esas эсас dört дёрт keldi кельди'
+ ],
+ [ // multi part words
+ [
+ 'crh' => 'эки юз eki yüz',
+ 'crh-cyrl' => 'эки юз эки юз',
+ 'crh-latn' => 'eki yüz eki yüz',
+ ],
+ 'эки юз eki yüz'
+ ],
+ [ // ALL CAPS, made up acronyms (not 100% sure these are correct)
+ [
+ 'crh' => 'ÑAB QIC ĞUK COT НЪАБ КЪЫДж ГЪУК ДЖОТ CA ДЖА',
+ 'crh-cyrl' => 'НЪАБ КЪЫДж ГЪУК ДЖОТ НЪАБ КЪЫДж ГЪУК ДЖОТ ДЖА ДЖА',
+ 'crh-latn' => 'ÑAB QIC ĞUK COT ÑAB QIC ĞUK COT CA CA',
+ ],
+ 'ÑAB QIC ĞUK COT НЪАБ КЪЫДж ГЪУК ДЖОТ CA ДЖА'
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageCsTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageCsTest.php
new file mode 100644
index 00000000..2d38ee8d
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageCsTest.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/Languagecs.php */
+class LanguageCsTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'few', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 4 ],
+ [ 'other', 5 ],
+ [ 'other', 11 ],
+ [ 'other', 20 ],
+ [ 'other', 25 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageCuTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageCuTest.php
new file mode 100644
index 00000000..565a8856
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageCuTest.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/**
+ * @covers LanguageCu
+ */
+class LanguageCuTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'two', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'two', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 4 ],
+ [ 'other', 5 ],
+ [ 'one', 11 ],
+ [ 'other', 20 ],
+ [ 'two', 22 ],
+ [ 'few', 223 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageCyTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageCyTest.php
new file mode 100644
index 00000000..bd078722
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageCyTest.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageCy.php */
+class LanguageCyTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'zero', 'one', 'two', 'few', 'many', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'zero', 0 ],
+ [ 'one', 1 ],
+ [ 'two', 2 ],
+ [ 'few', 3 ],
+ [ 'many', 6 ],
+ [ 'other', 4 ],
+ [ 'other', 5 ],
+ [ 'other', 11 ],
+ [ 'other', 20 ],
+ [ 'other', 22 ],
+ [ 'other', 223 ],
+ [ 'other', 200.00 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageDsbTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageDsbTest.php
new file mode 100644
index 00000000..877a70cd
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageDsbTest.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/**
+ * @covers LanguageDsb
+ */
+class LanguageDsbTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'two', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'one', 101 ],
+ [ 'one', 90001 ],
+ [ 'two', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 203 ],
+ [ 'few', 4 ],
+ [ 'other', 99 ],
+ [ 'other', 555 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageFrTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageFrTest.php
new file mode 100644
index 00000000..af6893ae
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageFrTest.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageFr.php */
+class LanguageFrTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageGaTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageGaTest.php
new file mode 100644
index 00000000..2ef5edb2
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageGaTest.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageGa.php */
+class LanguageGaTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'two', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'two', 2 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageGanTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageGanTest.php
new file mode 100644
index 00000000..c5d9e5e6
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageGanTest.php
@@ -0,0 +1,38 @@
+<?php
+
+/**
+ * @covers LanguageGan
+ * @covers GanConverter
+ */
+class LanguageGanTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider provideAutoConvertToAllVariants
+ * @covers Language::autoConvertToAllVariants
+ */
+ public function testAutoConvertToAllVariants( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->autoConvertToAllVariants( $value ) );
+ }
+
+ public static function provideAutoConvertToAllVariants() {
+ return [
+ // zh2Hans
+ [
+ [
+ 'gan' => '㑯',
+ 'gan-hans' => '㑔',
+ 'gan-hant' => '㑯',
+ ],
+ '㑯'
+ ],
+ // zh2Hant
+ [
+ [
+ 'gan' => '㐷',
+ 'gan-hans' => '㐷',
+ 'gan-hant' => '傌',
+ ],
+ '㐷'
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageGdTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageGdTest.php
new file mode 100644
index 00000000..4be4fded
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageGdTest.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012-2013, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageGd.php */
+class LanguageGdTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providerPlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'two', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ public static function providerPlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'two', 2 ],
+ [ 'one', 11 ],
+ [ 'two', 12 ],
+ [ 'few', 3 ],
+ [ 'few', 19 ],
+ [ 'other', 200 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providerPluralExplicit
+ * @covers Language::convertPlural
+ */
+ public function testExplicitPlural( $result, $value ) {
+ $forms = [ 'one', 'two', 'few', 'other', '11=Form11', '12=Form12' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ public static function providerPluralExplicit() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'two', 2 ],
+ [ 'Form11', 11 ],
+ [ 'Form12', 12 ],
+ [ 'few', 3 ],
+ [ 'few', 19 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageGvTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageGvTest.php
new file mode 100644
index 00000000..20e47867
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageGvTest.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * Test for Manx (Gaelg) language
+ *
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2013, Santhosh Thottingal
+ * @file
+ */
+
+class LanguageGvTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'two', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'few', 0 ],
+ [ 'one', 1 ],
+ [ 'two', 2 ],
+ [ 'other', 3 ],
+ [ 'few', 20 ],
+ [ 'one', 21 ],
+ [ 'two', 22 ],
+ [ 'other', 23 ],
+ [ 'other', 50 ],
+ [ 'few', 60 ],
+ [ 'few', 80 ],
+ [ 'few', 100 ]
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageHeTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageHeTest.php
new file mode 100644
index 00000000..c1b774af
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageHeTest.php
@@ -0,0 +1,132 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+/** Tests for MediaWiki Hebrew grammar transformation handling */
+class LanguageHeTest extends LanguageClassesTestCase {
+ /**
+ * The most common usage for the plural forms is two forms,
+ * for singular and plural. In this case, the second form
+ * is technically dual, but in practice it's used as plural.
+ * In some cases, usually with expressions of time, three forms
+ * are needed - singular, dual and plural.
+ * CLDR also specifies a fourth form for multiples of 10,
+ * which is very rare. It also has a mistake, because
+ * the number 10 itself is supposed to be just plural,
+ * so currently it's overridden in MediaWiki.
+ */
+
+ // @todo the below test*PluralForms test methods can be refactored
+ // to use a single test method and data provider..
+
+ /**
+ * @dataProvider provideTwoPluralForms
+ * @covers Language::convertPlural
+ */
+ public function testTwoPluralForms( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider provideThreePluralForms
+ * @covers Language::convertPlural
+ */
+ public function testThreePluralForms( $result, $value ) {
+ $forms = [ 'one', 'two', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider provideFourPluralForms
+ * @covers Language::convertPlural
+ */
+ public function testFourPluralForms( $result, $value ) {
+ $forms = [ 'one', 'two', 'many', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider provideFourPluralForms
+ * @covers Language::convertPlural
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function provideTwoPluralForms() {
+ return [
+ [ 'other', 0 ], // Zero - plural
+ [ 'one', 1 ], // Singular
+ [ 'other', 2 ], // No third form provided, use it as plural
+ [ 'other', 3 ], // Plural - other
+ [ 'other', 10 ], // No fourth form provided, use it as plural
+ [ 'other', 20 ], // No fourth form provided, use it as plural
+ ];
+ }
+
+ public static function provideThreePluralForms() {
+ return [
+ [ 'other', 0 ], // Zero - plural
+ [ 'one', 1 ], // Singular
+ [ 'two', 2 ], // Dual
+ [ 'other', 3 ], // Plural - other
+ [ 'other', 10 ], // No fourth form provided, use it as plural
+ [ 'other', 20 ], // No fourth form provided, use it as plural
+ ];
+ }
+
+ public static function provideFourPluralForms() {
+ return [
+ [ 'other', 0 ], // Zero - plural
+ [ 'one', 1 ], // Singular
+ [ 'two', 2 ], // Dual
+ [ 'other', 3 ], // Plural - other
+ [ 'other', 10 ], // 10 is supposed to be plural (other), not "many"
+ [ 'many', 20 ], // Fourth form provided - rare, but supported by CLDR
+ ];
+ }
+
+ /**
+ * @dataProvider provideGrammar
+ * @covers Language::convertGrammar
+ */
+ public function testGrammar( $result, $word, $case ) {
+ $this->assertEquals( $result, $this->getLang()->convertGrammar( $word, $case ) );
+ }
+
+ // The comments in the beginning of the line help avoid RTL problems
+ // with text editors.
+ public static function provideGrammar() {
+ return [
+ [
+ /* result */'וויקיפדיה',
+ /* word */'ויקיפדיה',
+ /* case */'תחילית',
+ ],
+ [
+ /* result */'וולפגנג',
+ /* word */'וולפגנג',
+ /* case */'prefixed',
+ ],
+ [
+ /* result */'קובץ',
+ /* word */'הקובץ',
+ /* case */'תחילית',
+ ],
+ [
+ /* result */'־Wikipedia',
+ /* word */'Wikipedia',
+ /* case */'תחילית',
+ ],
+ [
+ /* result */'־1995',
+ /* word */'1995',
+ /* case */'תחילית',
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageHiTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageHiTest.php
new file mode 100644
index 00000000..72c75757
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageHiTest.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/LanguageHi.php */
+class LanguageHiTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageHrTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageHrTest.php
new file mode 100644
index 00000000..746e713e
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageHrTest.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageHr.php */
+class LanguageHrTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'few', 2 ],
+ [ 'few', 4 ],
+ [ 'other', 5 ],
+ [ 'other', 11 ],
+ [ 'other', 20 ],
+ [ 'one', 21 ],
+ [ 'few', 24 ],
+ [ 'other', 25 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageHsbTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageHsbTest.php
new file mode 100644
index 00000000..0841f6f9
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageHsbTest.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/**
+ * @covers LanguageHsb
+ */
+class LanguageHsbTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'two', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'one', 101 ],
+ [ 'one', 90001 ],
+ [ 'two', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 203 ],
+ [ 'few', 4 ],
+ [ 'other', 99 ],
+ [ 'other', 555 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageHuTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageHuTest.php
new file mode 100644
index 00000000..a1925bdf
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageHuTest.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/**
+ * @covers LanguageHu
+ */
+class LanguageHuTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageHyTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageHyTest.php
new file mode 100644
index 00000000..b4936154
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageHyTest.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/**
+ * Tests for Armenian (Հայերեն)
+ *
+ * @covers LanguageHy
+ */
+class LanguageHyTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageIuTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageIuTest.php
new file mode 100644
index 00000000..01d97fc0
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageIuTest.php
@@ -0,0 +1,38 @@
+<?php
+
+/**
+ * @covers LanguageIu
+ * @covers IuConverter
+ */
+class LanguageIuTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider provideAutoConvertToAllVariants
+ * @covers Language::autoConvertToAllVariants
+ */
+ public function testAutoConvertToAllVariants( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->autoConvertToAllVariants( $value ) );
+ }
+
+ public static function provideAutoConvertToAllVariants() {
+ return [
+ // ike-cans
+ [
+ [
+ 'ike-cans' => 'ᐴ',
+ 'ike-latn' => 'PUU',
+ 'iu' => 'PUU',
+ ],
+ 'PUU'
+ ],
+ // ike-latn
+ [
+ [
+ 'ike-cans' => 'ᐴ',
+ 'ike-latn' => 'puu',
+ 'iu' => 'ᐴ',
+ ],
+ 'ᐴ'
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageKkTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageKkTest.php
new file mode 100644
index 00000000..f21950e0
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageKkTest.php
@@ -0,0 +1,33 @@
+<?php
+
+/**
+ * @covers LanguageKk
+ * @covers LanguageKk_cyrl
+ * @covers KkConverter
+ */
+class LanguageKkTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider provideAutoConvertToAllVariants
+ * @covers Language::autoConvertToAllVariants
+ */
+ public function testAutoConvertToAllVariants( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->autoConvertToAllVariants( $value ) );
+ }
+
+ public static function provideAutoConvertToAllVariants() {
+ return [
+ [
+ [
+ 'kk' => 'Адамдарға ақыл-парасат, ар-ождан берілген',
+ 'kk-cyrl' => 'Адамдарға ақыл-парасат, ар-ождан берілген',
+ 'kk-latn' => 'Adamdarğa aqıl-parasat, ar-ojdan berilgen',
+ 'kk-arab' => 'ادامدارعا اقىل-پاراسات، ار-وجدان بەرىلگەن',
+ 'kk-kz' => 'Адамдарға ақыл-парасат, ар-ождан берілген',
+ 'kk-tr' => 'Adamdarğa aqıl-parasat, ar-ojdan berilgen',
+ 'kk-cn' => 'ادامدارعا اقىل-پاراسات، ار-وجدان بەرىلگەن'
+ ],
+ 'Адамдарға ақыл-парасат, ар-ождан берілген'
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageKshTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageKshTest.php
new file mode 100644
index 00000000..6419e281
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageKshTest.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/**
+ * @covers LanguageKsh
+ */
+class LanguageKshTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other', 'zero' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'zero', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageKuTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageKuTest.php
new file mode 100644
index 00000000..db693088
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageKuTest.php
@@ -0,0 +1,44 @@
+<?php
+
+/**
+ * @covers LanguageKu
+ * @covers KuConverter
+ */
+class LanguageKuTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider provideAutoConvertToAllVariants
+ * @covers Language::autoConvertToAllVariants
+ */
+ public function testAutoConvertToAllVariants( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->autoConvertToAllVariants( $value ) );
+ }
+
+ public static function provideAutoConvertToAllVariants() {
+ return [
+ [
+ [
+ 'ku' => '١',
+ 'ku-arab' => '١',
+ 'ku-latn' => '1',
+ ],
+ '١'
+ ],
+ [
+ [
+ 'ku' => 'Wîkîpediya ensîklopediyeke azad bi rengê wîkî ye.',
+ 'ku-arab' => 'ویکیپەدیائە نسیکلۆپەدیەکەئا زاد ب رەنگێ ویکی یە.',
+ 'ku-latn' => 'Wîkîpediya ensîklopediyeke azad bi rengê wîkî ye.',
+ ],
+ 'Wîkîpediya ensîklopediyeke azad bi rengê wîkî ye.'
+ ],
+ [
+ [
+ 'ku' => 'ویکیپەدیا ەنسیکلۆپەدیەکەئا زاد ب رەنگێ ویکی یە.',
+ 'ku-arab' => 'ویکیپەدیا ەنسیکلۆپەدیەکەئا زاد ب رەنگێ ویکی یە.',
+ 'ku-latn' => 'wîkîpedîa ensîklopedîekea zad b rengê wîkî îe.',
+ ],
+ 'ویکیپەدیا ەنسیکلۆپەدیەکەئا زاد ب رەنگێ ویکی یە.'
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageLnTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageLnTest.php
new file mode 100644
index 00000000..34bd1791
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageLnTest.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageLn.php */
+class LanguageLnTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageLtTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageLtTest.php
new file mode 100644
index 00000000..dc7ef454
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageLtTest.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/LanguageLt.php */
+class LanguageLtTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'few', 2 ],
+ [ 'few', 9 ],
+ [ 'other', 10 ],
+ [ 'other', 11 ],
+ [ 'other', 20 ],
+ [ 'one', 21 ],
+ [ 'few', 32 ],
+ [ 'one', 41 ],
+ [ 'one', 40001 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePluralTwoForms
+ * @covers Language::convertPlural
+ */
+ public function testOneFewPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ // This fails for 21, but not sure why.
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ public static function providePluralTwoForms() {
+ return [
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 15 ],
+ [ 'other', 20 ],
+ [ 'one', 21 ],
+ [ 'other', 22 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageLvTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageLvTest.php
new file mode 100644
index 00000000..9827c68b
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageLvTest.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for Latvian */
+class LanguageLvTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'zero', 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'zero', 0 ],
+ [ 'one', 1 ],
+ [ 'zero', 11 ],
+ [ 'one', 21 ],
+ [ 'zero', 411 ],
+ [ 'other', 2 ],
+ [ 'other', 9 ],
+ [ 'zero', 12 ],
+ [ 'other', 12.345 ],
+ [ 'zero', 20 ],
+ [ 'other', 22 ],
+ [ 'one', 31 ],
+ [ 'zero', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageMgTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageMgTest.php
new file mode 100644
index 00000000..7a84c071
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageMgTest.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageMg.php */
+class LanguageMgTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 200 ],
+ [ 'other', 123.3434 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageMkTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageMkTest.php
new file mode 100644
index 00000000..6469a063
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageMkTest.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for македонски/Macedonian */
+class LanguageMkTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'one', 11 ],
+ [ 'one', 21 ],
+ [ 'one', 411 ],
+ [ 'other', 12.345 ],
+ [ 'other', 20 ],
+ [ 'one', 31 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageMlTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageMlTest.php
new file mode 100644
index 00000000..673b5c77
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageMlTest.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2011, Santhosh Thottingal
+ * @file
+ */
+
+/**
+ * @covers LanguageMl
+ */
+class LanguageMlTest extends LanguageClassesTestCase {
+
+ /**
+ * @dataProvider providerFormatNum
+ * T31495
+ * @covers Language::formatNum
+ */
+ public function testFormatNum( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->formatNum( $value ) );
+ }
+
+ public static function providerFormatNum() {
+ return [
+ [ '12,34,567', '1234567' ],
+ [ '12,345', '12345' ],
+ [ '1', '1' ],
+ [ '123', '123' ],
+ [ '1,234', '1234' ],
+ [ '12,345.56', '12345.56' ],
+ [ '12,34,56,79,81,23,45,678', '12345679812345678' ],
+ [ '.12345', '.12345' ],
+ [ '-12,00,000', '-1200000' ],
+ [ '-98', '-98' ],
+ [ '-98', -98 ],
+ [ '-1,23,45,678', -12345678 ],
+ [ '', '' ],
+ [ '', null ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageMoTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageMoTest.php
new file mode 100644
index 00000000..d11bad2f
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageMoTest.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageMo.php */
+class LanguageMoTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'few', 0 ],
+ [ 'one', 1 ],
+ [ 'few', 2 ],
+ [ 'few', 19 ],
+ [ 'other', 20 ],
+ [ 'other', 99 ],
+ [ 'other', 100 ],
+ [ 'few', 101 ],
+ [ 'few', 119 ],
+ [ 'other', 120 ],
+ [ 'other', 200 ],
+ [ 'few', 201 ],
+ [ 'few', 219 ],
+ [ 'other', 220 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageMtTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageMtTest.php
new file mode 100644
index 00000000..d8a0d7a1
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageMtTest.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageMt.php */
+class LanguageMtTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'many', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'few', 0 ],
+ [ 'one', 1 ],
+ [ 'few', 2 ],
+ [ 'few', 10 ],
+ [ 'many', 11 ],
+ [ 'many', 19 ],
+ [ 'other', 20 ],
+ [ 'other', 99 ],
+ [ 'other', 100 ],
+ [ 'other', 101 ],
+ [ 'few', 102 ],
+ [ 'few', 110 ],
+ [ 'many', 111 ],
+ [ 'many', 119 ],
+ [ 'other', 120 ],
+ [ 'other', 201 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePluralTwoForms
+ * @covers Language::convertPlural
+ */
+ public function testPluralTwoForms( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ public static function providePluralTwoForms() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 10 ],
+ [ 'other', 11 ],
+ [ 'other', 19 ],
+ [ 'other', 20 ],
+ [ 'other', 99 ],
+ [ 'other', 100 ],
+ [ 'other', 101 ],
+ [ 'other', 102 ],
+ [ 'other', 110 ],
+ [ 'other', 111 ],
+ [ 'other', 119 ],
+ [ 'other', 120 ],
+ [ 'other', 201 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageNlTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageNlTest.php
new file mode 100644
index 00000000..26bd691a
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageNlTest.php
@@ -0,0 +1,24 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2011, Santhosh Thottingal
+ * @file
+ */
+
+/** Tests for MediaWiki languages/LanguageNl.php */
+class LanguageNlTest extends LanguageClassesTestCase {
+
+ /**
+ * @covers Language::formatNum
+ * @todo split into a test and a dataprovider
+ */
+ public function testFormatNum() {
+ $this->assertEquals( '1.234.567', $this->getLang()->formatNum( '1234567' ) );
+ $this->assertEquals( '12.345', $this->getLang()->formatNum( '12345' ) );
+ $this->assertEquals( '1', $this->getLang()->formatNum( '1' ) );
+ $this->assertEquals( '123', $this->getLang()->formatNum( '123' ) );
+ $this->assertEquals( '1.234', $this->getLang()->formatNum( '1234' ) );
+ $this->assertEquals( '12.345,56', $this->getLang()->formatNum( '12345.56' ) );
+ $this->assertEquals( ',1234556', $this->getLang()->formatNum( '.1234556' ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageNsoTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageNsoTest.php
new file mode 100644
index 00000000..f1783431
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageNsoTest.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageNso.php */
+class LanguageNsoTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguagePlTest.php b/www/wiki/tests/phpunit/languages/classes/LanguagePlTest.php
new file mode 100644
index 00000000..14877290
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguagePlTest.php
@@ -0,0 +1,104 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguagePl.php */
+class LanguagePlTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'many' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'many', 0 ],
+ [ 'one', 1 ],
+ [ 'few', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 4 ],
+ [ 'many', 5 ],
+ [ 'many', 9 ],
+ [ 'many', 10 ],
+ [ 'many', 11 ],
+ [ 'many', 21 ],
+ [ 'few', 22 ],
+ [ 'few', 23 ],
+ [ 'few', 24 ],
+ [ 'many', 25 ],
+ [ 'many', 200 ],
+ [ 'many', 201 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePluralTwoForms
+ * @covers Language::convertPlural
+ */
+ public function testPluralTwoForms( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ public static function providePluralTwoForms() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 3 ],
+ [ 'other', 4 ],
+ [ 'other', 5 ],
+ [ 'other', 9 ],
+ [ 'other', 10 ],
+ [ 'other', 11 ],
+ [ 'other', 21 ],
+ [ 'other', 22 ],
+ [ 'other', 23 ],
+ [ 'other', 24 ],
+ [ 'other', 25 ],
+ [ 'other', 200 ],
+ [ 'other', 201 ],
+ ];
+ }
+
+ /**
+ * @covers Language::commafy()
+ * @dataProvider provideCommafyData
+ */
+ public function testCommafy( $number, $numbersWithCommas ) {
+ $this->assertEquals(
+ $numbersWithCommas,
+ $this->getLang()->commafy( $number ),
+ "commafy('$number')"
+ );
+ }
+
+ public static function provideCommafyData() {
+ // Note that commafy() always uses English separators (',' and '.') instead of
+ // Polish (' ' and ','). There is another function that converts them later.
+ return [
+ [ 1000, '1000' ],
+ [ 10000, '10,000' ],
+ [ 1000.0001, '1000.0001' ],
+ [ 10000.0001, '10,000.0001' ],
+ [ -1000, '-1000' ],
+ [ -10000, '-10,000' ],
+ [ -1000.0001, '-1000.0001' ],
+ [ -10000.0001, '-10,000.0001' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageRoTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageRoTest.php
new file mode 100644
index 00000000..bbe26f63
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageRoTest.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageRo.php */
+class LanguageRoTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'few', 0 ],
+ [ 'one', 1 ],
+ [ 'few', 2 ],
+ [ 'few', 19 ],
+ [ 'other', 20 ],
+ [ 'other', 99 ],
+ [ 'other', 100 ],
+ [ 'few', 101 ],
+ [ 'few', 119 ],
+ [ 'other', 120 ],
+ [ 'other', 200 ],
+ [ 'few', 201 ],
+ [ 'few', 219 ],
+ [ 'other', 220 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageRuTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageRuTest.php
new file mode 100644
index 00000000..a34c03fd
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageRuTest.php
@@ -0,0 +1,189 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * based on LanguageBe_tarask.php
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+class LanguageRuTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'many', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * Test explicit plural forms - n=FormN forms
+ * @covers Language::convertPlural
+ */
+ public function testExplicitPlural() {
+ $forms = [ 'one', 'few', 'many', 'other', '12=dozen' ];
+ $this->assertEquals( 'dozen', $this->getLang()->convertPlural( 12, $forms ) );
+ $forms = [ 'one', 'few', 'many', '100=hundred', 'other', '12=dozen' ];
+ $this->assertEquals( 'hundred', $this->getLang()->convertPlural( 100, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 1 ],
+ [ 'many', 11 ],
+ [ 'one', 91 ],
+ [ 'one', 121 ],
+ [ 'few', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 4 ],
+ [ 'few', 334 ],
+ [ 'many', 5 ],
+ [ 'many', 15 ],
+ [ 'many', 120 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePluralTwoForms
+ * @covers Language::convertPlural
+ */
+ public function testPluralTwoForms( $result, $value ) {
+ $forms = [ '1=one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ public static function providePluralTwoForms() {
+ return [
+ [ 'one', 1 ],
+ [ 'other', 11 ],
+ [ 'other', 91 ],
+ [ 'other', 121 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providerGrammar
+ * @covers Language::convertGrammar
+ */
+ public function testGrammar( $result, $word, $case ) {
+ $this->assertEquals( $result, $this->getLang()->convertGrammar( $word, $case ) );
+ }
+
+ public static function providerGrammar() {
+ return [
+ [
+ 'Википедии',
+ 'Википедия',
+ 'genitive',
+ ],
+ [
+ 'Викитеки',
+ 'Викитека',
+ 'genitive',
+ ],
+ [
+ 'Викитеке',
+ 'Викитека',
+ 'prepositional',
+ ],
+ [
+ 'Викисклада',
+ 'Викисклад',
+ 'genitive',
+ ],
+ [
+ 'Викиверситета',
+ 'Викиверситет',
+ 'genitive',
+ ],
+ [
+ 'Викискладе',
+ 'Викисклад',
+ 'prepositional',
+ ],
+ [
+ 'Викиданных',
+ 'Викиданные',
+ 'prepositional',
+ ],
+ [
+ 'Викиверситете',
+ 'Викиверситет',
+ 'prepositional',
+ ],
+ [
+ 'русского',
+ 'русский',
+ 'languagegen',
+ ],
+ [
+ 'немецкого',
+ 'немецкий',
+ 'languagegen',
+ ],
+ [
+ 'иврита',
+ 'иврит',
+ 'languagegen',
+ ],
+ [
+ 'эсперанто',
+ 'эсперанто',
+ 'languagegen',
+ ],
+ [
+ 'русском',
+ 'русский',
+ 'languageprep',
+ ],
+ [
+ 'немецком',
+ 'немецкий',
+ 'languageprep',
+ ],
+ [
+ 'идише',
+ 'идиш',
+ 'languageprep',
+ ],
+ [
+ 'эсперанто',
+ 'эсперанто',
+ 'languageprep',
+ ],
+ [
+ 'по-русски',
+ 'русский',
+ 'languageadverb',
+ ],
+ [
+ 'по-немецки',
+ 'немецкий',
+ 'languageadverb',
+ ],
+ [
+ 'на иврите',
+ 'иврит',
+ 'languageadverb',
+ ],
+ [
+ 'на эсперанто',
+ 'эсперанто',
+ 'languageadverb',
+ ],
+ [
+ 'на языке гуарани',
+ 'гуарани',
+ 'languageadverb',
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageSeTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageSeTest.php
new file mode 100644
index 00000000..b0da3983
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageSeTest.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageSe.php */
+class LanguageSeTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'two', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'two', 2 ],
+ [ 'other', 3 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePluralTwoForms
+ * @covers Language::convertPlural
+ */
+ public function testPluralTwoForms( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ public static function providePluralTwoForms() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 3 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageSgsTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageSgsTest.php
new file mode 100644
index 00000000..7721433d
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageSgsTest.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+/** Tests for Samogitian */
+class LanguageSgsTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePluralAllForms
+ * @covers Language::convertPlural
+ */
+ public function testPluralAllForms( $result, $value ) {
+ $forms = [ 'one', 'two', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePluralAllForms
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePluralAllForms() {
+ return [
+ [ 'few', 0 ],
+ [ 'one', 1 ],
+ [ 'two', 2 ],
+ [ 'other', 3 ],
+ [ 'few', 10 ],
+ [ 'few', 11 ],
+ [ 'few', 12 ],
+ [ 'few', 19 ],
+ [ 'other', 20 ],
+ [ 'few', 100 ],
+ [ 'one', 101 ],
+ [ 'few', 111 ],
+ [ 'few', 112 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePluralTwoForms
+ * @covers Language::convertPlural
+ */
+ public function testPluralTwoForms( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ public static function providePluralTwoForms() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 3 ],
+ [ 'other', 10 ],
+ [ 'other', 11 ],
+ [ 'other', 12 ],
+ [ 'other', 19 ],
+ [ 'other', 20 ],
+ [ 'other', 100 ],
+ [ 'one', 101 ],
+ [ 'other', 111 ],
+ [ 'other', 112 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageShTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageShTest.php
new file mode 100644
index 00000000..2a669178
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageShTest.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+/** Tests for srpskohrvatski / српскохрватски / Serbocroatian */
+class LanguageShTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'few', 2 ],
+ [ 'few', 4 ],
+ [ 'other', 5 ],
+ [ 'other', 10 ],
+ [ 'other', 11 ],
+ [ 'other', 12 ],
+ [ 'one', 101 ],
+ [ 'few', 102 ],
+ [ 'other', 111 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageShiTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageShiTest.php
new file mode 100644
index 00000000..1d0f8635
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageShiTest.php
@@ -0,0 +1,36 @@
+<?php
+
+/**
+ * @covers LanguageShi
+ * @covers ShiConverter
+ */
+class LanguageShiTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider provideAutoConvertToAllVariants
+ * @covers Language::autoConvertToAllVariants
+ */
+ public function testAutoConvertToAllVariants( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->autoConvertToAllVariants( $value ) );
+ }
+
+ public static function provideAutoConvertToAllVariants() {
+ return [
+ [
+ [
+ 'shi' => 'AƔ',
+ 'shi-tfng' => 'ⴰⵖ',
+ 'shi-latn' => 'AƔ',
+ ],
+ 'AƔ'
+ ],
+ [
+ [
+ 'shi' => 'ⴰⵖ',
+ 'shi-tfng' => 'ⴰⵖ',
+ 'shi-latn' => 'aɣ',
+ ],
+ 'ⴰⵖ'
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageSkTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageSkTest.php
new file mode 100644
index 00000000..dd9a9ab1
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageSkTest.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * based on LanguageSkTest.php
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageSk.php */
+class LanguageSkTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'few', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 4 ],
+ [ 'other', 5 ],
+ [ 'other', 11 ],
+ [ 'other', 20 ],
+ [ 'other', 25 ],
+ [ 'other', 200 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageSlTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageSlTest.php
new file mode 100644
index 00000000..50100ce7
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageSlTest.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * @author Santhosh Thottingal
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * based on LanguageSkTest.php
+ * @file
+ */
+
+/**
+ * @covers LanguageSl
+ */
+class LanguageSlTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providerPlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'two', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providerPlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providerPlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'two', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 4 ],
+ [ 'other', 5 ],
+ [ 'other', 99 ],
+ [ 'other', 100 ],
+ [ 'one', 101 ],
+ [ 'two', 102 ],
+ [ 'few', 103 ],
+ [ 'one', 201 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageSmaTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageSmaTest.php
new file mode 100644
index 00000000..6b859943
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageSmaTest.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageSma.php */
+class LanguageSmaTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'two', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'two', 2 ],
+ [ 'other', 3 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePluralTwoForms
+ * @covers Language::convertPlural
+ */
+ public function testPluralTwoForms( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ public static function providePluralTwoForms() {
+ return [
+ [ 'other', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ [ 'other', 3 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageSrTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageSrTest.php
new file mode 100644
index 00000000..e81d5370
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageSrTest.php
@@ -0,0 +1,252 @@
+<?php
+/**
+ * PHPUnit tests for the Serbian language.
+ * The language can be represented using two scripts:
+ * - Latin (SR_el)
+ * - Cyrillic (SR_ec)
+ * Both representations seems to be bijective, hence MediaWiki can convert
+ * from one script to the other.
+ *
+ * @author Antoine Musso <hashar at free dot fr>
+ * @copyright Copyright © 2011, Antoine Musso <hashar at free dot fr>
+ * @file
+ *
+ * @todo methods in test class should be tidied:
+ * - Should be split into separate test methods and data providers
+ * - Tests for LanguageConverter and Language should probably be separate..
+ */
+
+/**
+ * @covers LanguageSr
+ * @covers SrConverter
+ */
+class LanguageSrTest extends LanguageClassesTestCase {
+ /**
+ * @covers LanguageConverter::convertTo
+ */
+ public function testEasyConversions() {
+ $this->assertCyrillic(
+ 'шђчћжШЂЧЋЖ',
+ 'Cyrillic guessing characters'
+ );
+ $this->assertLatin(
+ 'šđč枊ĐČĆŽ',
+ 'Latin guessing characters'
+ );
+ }
+
+ /**
+ * @covers LanguageConverter::convertTo
+ */
+ public function testMixedConversions() {
+ $this->assertCyrillic(
+ 'шђчћжШЂЧЋЖ - šđčćž',
+ 'Mostly cyrillic characters'
+ );
+ $this->assertLatin(
+ 'šđč枊ĐČĆŽ - шђчћж',
+ 'Mostly latin characters'
+ );
+ }
+
+ /**
+ * @covers LanguageConverter::convertTo
+ */
+ public function testSameAmountOfLatinAndCyrillicGetConverted() {
+ $this->assertConverted(
+ '4 latin: šđčć | 4 cyrillic: шђчћ',
+ 'sr-ec'
+ );
+ $this->assertConverted(
+ '4 latin: šđčć | 4 cyrillic: шђчћ',
+ 'sr-el'
+ );
+ }
+
+ /**
+ * @author Nikola Smolenski
+ * @covers LanguageConverter::convertTo
+ */
+ public function testConversionToCyrillic() {
+ // A simple convertion of Latin to Cyrillic
+ $this->assertEquals( 'абвг',
+ $this->convertToCyrillic( 'abvg' )
+ );
+ // Same as above, but assert that -{}-s must be removed and not converted
+ $this->assertEquals( 'ljабnjвгdž',
+ $this->convertToCyrillic( '-{lj}-ab-{nj}-vg-{dž}-' )
+ );
+ // A simple convertion of Cyrillic to Cyrillic
+ $this->assertEquals( 'абвг',
+ $this->convertToCyrillic( 'абвг' )
+ );
+ // Same as above, but assert that -{}-s must be removed and not converted
+ $this->assertEquals( 'ljабnjвгdž',
+ $this->convertToCyrillic( '-{lj}-аб-{nj}-вг-{dž}-' )
+ );
+ // This text has some Latin, but is recognized as Cyrillic, so it should not be converted
+ $this->assertEquals( 'abvgшђжчћ',
+ $this->convertToCyrillic( 'abvgшђжчћ' )
+ );
+ // Same as above, but assert that -{}-s must be removed
+ $this->assertEquals( 'љabvgњшђжчћџ',
+ $this->convertToCyrillic( '-{љ}-abvg-{њ}-шђжчћ-{џ}-' )
+ );
+ // This text has some Cyrillic, but is recognized as Latin, so it should be converted
+ $this->assertEquals( 'абвгшђжчћ',
+ $this->convertToCyrillic( 'абвгšđžčć' )
+ );
+ // Same as above, but assert that -{}-s must be removed and not converted
+ $this->assertEquals( 'ljабвгnjшђжчћdž',
+ $this->convertToCyrillic( '-{lj}-абвг-{nj}-šđžčć-{dž}-' )
+ );
+ // Roman numerals are not converted
+ $this->assertEquals( 'а I б II в III г IV шђжчћ',
+ $this->convertToCyrillic( 'a I b II v III g IV šđžčć' )
+ );
+ }
+
+ /**
+ * @covers LanguageConverter::convertTo
+ */
+ public function testConversionToLatin() {
+ // A simple convertion of Latin to Latin
+ $this->assertEquals( 'abcd',
+ $this->convertToLatin( 'abcd' )
+ );
+ // A simple convertion of Cyrillic to Latin
+ $this->assertEquals( 'abcd',
+ $this->convertToLatin( 'абцд' )
+ );
+ // This text has some Latin, but is recognized as Cyrillic, so it should be converted
+ $this->assertEquals( 'abcdšđžčć',
+ $this->convertToLatin( 'abcdшђжчћ' )
+ );
+ // This text has some Cyrillic, but is recognized as Latin, so it should not be converted
+ $this->assertEquals( 'абцдšđžčć',
+ $this->convertToLatin( 'абцдšđžčć' )
+ );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 1 ],
+ [ 'other', 11 ],
+ [ 'one', 91 ],
+ [ 'one', 121 ],
+ [ 'few', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 4 ],
+ [ 'few', 334 ],
+ [ 'other', 5 ],
+ [ 'other', 15 ],
+ [ 'other', 120 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePluralTwoForms
+ * @covers Language::convertPlural
+ */
+ public function testPluralTwoForms( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ public static function providePluralTwoForms() {
+ return [
+ [ 'one', 1 ],
+ [ 'other', 11 ],
+ [ 'other', 4 ],
+ [ 'one', 91 ],
+ [ 'one', 121 ],
+ ];
+ }
+
+ # #### HELPERS #####################################################
+ /**
+ *Wrapper to verify text stay the same after applying conversion
+ * @param string $text Text to convert
+ * @param string $variant Language variant 'sr-ec' or 'sr-el'
+ * @param string $msg Optional message
+ */
+ protected function assertUnConverted( $text, $variant, $msg = '' ) {
+ $this->assertEquals(
+ $text,
+ $this->convertTo( $text, $variant ),
+ $msg
+ );
+ }
+
+ /**
+ * Wrapper to verify a text is different once converted to a variant.
+ * @param string $text Text to convert
+ * @param string $variant Language variant 'sr-ec' or 'sr-el'
+ * @param string $msg Optional message
+ */
+ protected function assertConverted( $text, $variant, $msg = '' ) {
+ $this->assertNotEquals(
+ $text,
+ $this->convertTo( $text, $variant ),
+ $msg
+ );
+ }
+
+ /**
+ * Verifiy the given Cyrillic text is not converted when using
+ * using the cyrillic variant and converted to Latin when using
+ * the Latin variant.
+ * @param string $text Text to convert
+ * @param string $msg Optional message
+ */
+ protected function assertCyrillic( $text, $msg = '' ) {
+ $this->assertUnConverted( $text, 'sr-ec', $msg );
+ $this->assertConverted( $text, 'sr-el', $msg );
+ }
+
+ /**
+ * Verifiy the given Latin text is not converted when using
+ * using the Latin variant and converted to Cyrillic when using
+ * the Cyrillic variant.
+ * @param string $text Text to convert
+ * @param string $msg Optional message
+ */
+ protected function assertLatin( $text, $msg = '' ) {
+ $this->assertUnConverted( $text, 'sr-el', $msg );
+ $this->assertConverted( $text, 'sr-ec', $msg );
+ }
+
+ /** Wrapper for converter::convertTo() method*/
+ protected function convertTo( $text, $variant ) {
+ return $this->getLang()
+ ->mConverter
+ ->convertTo(
+ $text, $variant
+ );
+ }
+
+ protected function convertToCyrillic( $text ) {
+ return $this->convertTo( $text, 'sr-ec' );
+ }
+
+ protected function convertToLatin( $text ) {
+ return $this->convertTo( $text, 'sr-el' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageTgTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageTgTest.php
new file mode 100644
index 00000000..89697675
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageTgTest.php
@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * @covers LanguageTg
+ * @covers TgConverter
+ */
+class LanguageTgTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider provideAutoConvertToAllVariants
+ * @covers Language::autoConvertToAllVariants
+ */
+ public function testAutoConvertToAllVariants( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->autoConvertToAllVariants( $value ) );
+ }
+
+ public static function provideAutoConvertToAllVariants() {
+ return [
+ [
+ [
+ 'tg' => 'г',
+ 'tg-latn' => 'g',
+ ],
+ 'г'
+ ],
+ [
+ [
+ 'tg' => 'g',
+ 'tg-latn' => 'g',
+ ],
+ 'g'
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageTiTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageTiTest.php
new file mode 100644
index 00000000..66f11848
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageTiTest.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageTi.php */
+class LanguageTiTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageTlTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageTlTest.php
new file mode 100644
index 00000000..09fef2da
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageTlTest.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+/** Tests for MediaWiki languages/classes/LanguageTl.php */
+class LanguageTlTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 0 ],
+ [ 'one', 1 ],
+ [ 'one', 2 ],
+ [ 'other', 4 ],
+ [ 'other', 6 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageTrTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageTrTest.php
new file mode 100644
index 00000000..3ddf2d03
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageTrTest.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * @author Antoine Musso
+ * @copyright Copyright © 2011, Antoine Musso
+ * @file
+ */
+
+/**
+ * @covers LanguageTr
+ */
+class LanguageTrTest extends LanguageClassesTestCase {
+
+ /**
+ * See T30040
+ * Credits to irc://irc.freenode.net/wikipedia-tr users:
+ * - berm
+ * - []LuCkY[]
+ * - Emperyan
+ * @see https://en.wikipedia.org/wiki/Dotted_and_dotless_I
+ * @dataProvider provideDottedAndDotlessI
+ * @covers Language::ucfirst
+ * @covers Language::lcfirst
+ */
+ public function testDottedAndDotlessI( $func, $input, $inputCase, $expected ) {
+ if ( $func == 'ucfirst' ) {
+ $res = $this->getLang()->ucfirst( $input );
+ } elseif ( $func == 'lcfirst' ) {
+ $res = $this->getLang()->lcfirst( $input );
+ } else {
+ throw new MWException( __METHOD__ . " given an invalid function name '$func'" );
+ }
+
+ $msg = "Converting $inputCase case '$input' with $func should give '$expected'";
+
+ $this->assertEquals( $expected, $res, $msg );
+ }
+
+ public static function provideDottedAndDotlessI() {
+ return [
+ # function, input, input case, expected
+ # Case changed:
+ [ 'ucfirst', 'ı', 'lower', 'I' ],
+ [ 'ucfirst', 'i', 'lower', 'İ' ],
+ [ 'lcfirst', 'I', 'upper', 'ı' ],
+ [ 'lcfirst', 'İ', 'upper', 'i' ],
+
+ # Already using the correct case
+ [ 'ucfirst', 'I', 'upper', 'I' ],
+ [ 'ucfirst', 'İ', 'upper', 'İ' ],
+ [ 'lcfirst', 'ı', 'lower', 'ı' ],
+ [ 'lcfirst', 'i', 'lower', 'i' ],
+
+ # A real example taken from T30040 using
+ # https://tr.wikipedia.org/wiki/%C4%B0Phone
+ [ 'lcfirst', 'iPhone', 'lower', 'iPhone' ],
+
+ # next case is valid in Turkish but are different words if we
+ # consider IPhone is English!
+ [ 'lcfirst', 'IPhone', 'upper', 'ıPhone' ],
+
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageUkTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageUkTest.php
new file mode 100644
index 00000000..0ccebbe2
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageUkTest.php
@@ -0,0 +1,109 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * based on LanguageBe_tarask.php
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+class LanguageUkTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'few', 'many', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * Test explicit plural forms - n=FormN forms
+ * @covers Language::convertPlural
+ */
+ public function testExplicitPlural() {
+ $forms = [ 'one', 'few', 'many', 'other', '12=dozen' ];
+ $this->assertEquals( 'dozen', $this->getLang()->convertPlural( 12, $forms ) );
+ $forms = [ 'one', 'few', 'many', '100=hundred', 'other', '12=dozen' ];
+ $this->assertEquals( 'hundred', $this->getLang()->convertPlural( 100, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 1 ],
+ [ 'many', 11 ],
+ [ 'one', 91 ],
+ [ 'one', 121 ],
+ [ 'few', 2 ],
+ [ 'few', 3 ],
+ [ 'few', 4 ],
+ [ 'few', 334 ],
+ [ 'many', 5 ],
+ [ 'many', 15 ],
+ [ 'many', 120 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providePluralTwoForms
+ * @covers Language::convertPlural
+ */
+ public function testPluralTwoForms( $result, $value ) {
+ $forms = [ '1=one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ public static function providePluralTwoForms() {
+ return [
+ [ 'one', 1 ],
+ [ 'other', 11 ],
+ [ 'other', 91 ],
+ [ 'other', 121 ],
+ ];
+ }
+
+ /**
+ * @dataProvider providerGrammar
+ * @covers Language::convertGrammar
+ */
+ public function testGrammar( $result, $word, $case ) {
+ $this->assertEquals( $result, $this->getLang()->convertGrammar( $word, $case ) );
+ }
+
+ public static function providerGrammar() {
+ return [
+ [
+ 'Вікіпедії',
+ 'Вікіпедія',
+ 'genitive',
+ ],
+ [
+ 'Віківидів',
+ 'Віківиди',
+ 'genitive',
+ ],
+ [
+ 'Вікіцитат',
+ 'Вікіцитати',
+ 'genitive',
+ ],
+ [
+ 'Вікіпідручника',
+ 'Вікіпідручник',
+ 'genitive',
+ ],
+ [
+ 'Вікіпедію',
+ 'Вікіпедія',
+ 'accusative',
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageUzTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageUzTest.php
new file mode 100644
index 00000000..367226d7
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageUzTest.php
@@ -0,0 +1,127 @@
+<?php
+/**
+ * PHPUnit tests for the Uzbek language.
+ * The language can be represented using two scripts:
+ * - Latin (uz-latn)
+ * - Cyrillic (uz-cyrl)
+ *
+ * @author Robin Pepermans
+ * @author Antoine Musso <hashar at free dot fr>
+ * @copyright Copyright © 2012, Robin Pepermans
+ * @copyright Copyright © 2011, Antoine Musso <hashar at free dot fr>
+ * @file
+ *
+ * @todo methods in test class should be tidied:
+ * - Should be split into separate test methods and data providers
+ * - Tests for LanguageConverter and Language should probably be separate..
+ */
+
+/**
+ * @covers LanguageUz
+ * @covers UzConverter
+ */
+class LanguageUzTest extends LanguageClassesTestCase {
+
+ /**
+ * @author Nikola Smolenski
+ * @covers LanguageConverter::convertTo
+ */
+ public function testConversionToCyrillic() {
+ // A convertion of Latin to Cyrillic
+ $this->assertEquals( 'абвгғ',
+ $this->convertToCyrillic( 'abvggʻ' )
+ );
+ // Same as above, but assert that -{}-s must be removed and not converted
+ $this->assertEquals( 'ljабnjвгўоdb',
+ $this->convertToCyrillic( '-{lj}-ab-{nj}-vgoʻo-{db}-' )
+ );
+ // A simple convertion of Cyrillic to Cyrillic
+ $this->assertEquals( 'абвг',
+ $this->convertToCyrillic( 'абвг' )
+ );
+ // Same as above, but assert that -{}-s must be removed and not converted
+ $this->assertEquals( 'ljабnjвгdaž',
+ $this->convertToCyrillic( '-{lj}-аб-{nj}-вг-{da}-ž' )
+ );
+ }
+
+ /**
+ * @covers LanguageConverter::convertTo
+ */
+ public function testConversionToLatin() {
+ // A simple convertion of Latin to Latin
+ $this->assertEquals( 'abdef',
+ $this->convertToLatin( 'abdef' )
+ );
+ // A convertion of Cyrillic to Latin
+ $this->assertEquals( 'gʻabtsdOʻQyo',
+ $this->convertToLatin( 'ғабцдЎҚё' )
+ );
+ }
+
+ # #### HELPERS #####################################################
+ /**
+ * Wrapper to verify text stay the same after applying conversion
+ * @param string $text Text to convert
+ * @param string $variant Language variant 'uz-cyrl' or 'uz-latn'
+ * @param string $msg Optional message
+ */
+ protected function assertUnConverted( $text, $variant, $msg = '' ) {
+ $this->assertEquals(
+ $text,
+ $this->convertTo( $text, $variant ),
+ $msg
+ );
+ }
+
+ /**
+ * Wrapper to verify a text is different once converted to a variant.
+ * @param string $text Text to convert
+ * @param string $variant Language variant 'uz-cyrl' or 'uz-latn'
+ * @param string $msg Optional message
+ */
+ protected function assertConverted( $text, $variant, $msg = '' ) {
+ $this->assertNotEquals(
+ $text,
+ $this->convertTo( $text, $variant ),
+ $msg
+ );
+ }
+
+ /**
+ * Verifiy the given Cyrillic text is not converted when using
+ * using the cyrillic variant and converted to Latin when using
+ * the Latin variant.
+ * @param string $text Text to convert
+ * @param string $msg Optional message
+ */
+ protected function assertCyrillic( $text, $msg = '' ) {
+ $this->assertUnConverted( $text, 'uz-cyrl', $msg );
+ $this->assertConverted( $text, 'uz-latn', $msg );
+ }
+
+ /**
+ * Verifiy the given Latin text is not converted when using
+ * using the Latin variant and converted to Cyrillic when using
+ * the Cyrillic variant.
+ * @param string $text Text to convert
+ * @param string $msg Optional message
+ */
+ protected function assertLatin( $text, $msg = '' ) {
+ $this->assertUnConverted( $text, 'uz-latn', $msg );
+ $this->assertConverted( $text, 'uz-cyrl', $msg );
+ }
+
+ /** Wrapper for converter::convertTo() method*/
+ protected function convertTo( $text, $variant ) {
+ return $this->getLang()->mConverter->convertTo( $text, $variant );
+ }
+
+ protected function convertToCyrillic( $text ) {
+ return $this->convertTo( $text, 'uz-cyrl' );
+ }
+
+ protected function convertToLatin( $text ) {
+ return $this->convertTo( $text, 'uz-latn' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageWaTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageWaTest.php
new file mode 100644
index 00000000..80c98603
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageWaTest.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * @author Amir E. Aharoni
+ * @copyright Copyright © 2012, Amir E. Aharoni
+ * @file
+ */
+
+/**
+ * @covers LanguageWa
+ */
+class LanguageWaTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider providePlural
+ * @covers Language::convertPlural
+ */
+ public function testPlural( $result, $value ) {
+ $forms = [ 'one', 'other' ];
+ $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
+ }
+
+ /**
+ * @dataProvider providePlural
+ * @covers Language::getPluralRuleType
+ */
+ public function testGetPluralRuleType( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
+ }
+
+ public static function providePlural() {
+ return [
+ [ 'one', 0 ],
+ [ 'one', 1 ],
+ [ 'other', 2 ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/languages/classes/LanguageZhTest.php b/www/wiki/tests/phpunit/languages/classes/LanguageZhTest.php
new file mode 100644
index 00000000..2e73ac51
--- /dev/null
+++ b/www/wiki/tests/phpunit/languages/classes/LanguageZhTest.php
@@ -0,0 +1,126 @@
+<?php
+
+/**
+ * @covers LanguageZh
+ * @covers LanguageZh_hans
+ * @covers ZhConverter
+ */
+class LanguageZhTest extends LanguageClassesTestCase {
+ /**
+ * @dataProvider provideAutoConvertToAllVariants
+ * @covers Language::autoConvertToAllVariants
+ */
+ public function testAutoConvertToAllVariants( $result, $value ) {
+ $this->assertEquals( $result, $this->getLang()->autoConvertToAllVariants( $value ) );
+ }
+
+ public static function provideAutoConvertToAllVariants() {
+ return [
+ // Plain hant -> hans
+ [
+ [
+ 'zh' => '㑯',
+ 'zh-hans' => '㑔',
+ 'zh-hant' => '㑯',
+ 'zh-cn' => '㑔',
+ 'zh-hk' => '㑯',
+ 'zh-mo' => '㑯',
+ 'zh-my' => '㑔',
+ 'zh-sg' => '㑔',
+ 'zh-tw' => '㑯',
+ ],
+ '㑯'
+ ],
+ // Plain hans -> hant
+ [
+ [
+ 'zh' => '㐷',
+ 'zh-hans' => '㐷',
+ 'zh-hant' => '傌',
+ 'zh-cn' => '㐷',
+ 'zh-hk' => '傌',
+ 'zh-mo' => '傌',
+ 'zh-my' => '㐷',
+ 'zh-sg' => '㐷',
+ 'zh-tw' => '傌',
+ ],
+ '㐷'
+ ],
+ // zh-cn specific
+ [
+ [
+ 'zh' => '仲介',
+ 'zh-hans' => '仲介',
+ 'zh-hant' => '仲介',
+ 'zh-cn' => '中介',
+ 'zh-hk' => '仲介',
+ 'zh-mo' => '仲介',
+ 'zh-my' => '中介',
+ 'zh-sg' => '中介',
+ 'zh-tw' => '仲介',
+ ],
+ '仲介'
+ ],
+ // zh-hk specific
+ [
+ [
+ 'zh' => '中文里',
+ 'zh-hans' => '中文里',
+ 'zh-hant' => '中文裡',
+ 'zh-cn' => '中文里',
+ 'zh-hk' => '中文裏',
+ 'zh-mo' => '中文裏',
+ 'zh-my' => '中文里',
+ 'zh-sg' => '中文里',
+ 'zh-tw' => '中文裡',
+ ],
+ '中文里'
+ ],
+ // zh-tw specific
+ [
+ [
+ 'zh' => '甲肝',
+ 'zh-hans' => '甲肝',
+ 'zh-hant' => '甲肝',
+ 'zh-cn' => '甲肝',
+ 'zh-hk' => '甲肝',
+ 'zh-mo' => '甲肝',
+ 'zh-my' => '甲肝',
+ 'zh-sg' => '甲肝',
+ 'zh-tw' => 'A肝',
+ ],
+ '甲肝'
+ ],
+ // zh-tw overrides zh-hant
+ [
+ [
+ 'zh' => '账',
+ 'zh-hans' => '账',
+ 'zh-hant' => '賬',
+ 'zh-cn' => '账',
+ 'zh-hk' => '賬',
+ 'zh-mo' => '賬',
+ 'zh-my' => '账',
+ 'zh-sg' => '账',
+ 'zh-tw' => '帳',
+ ],
+ '账'
+ ],
+ // zh-hk overrides zh-hant
+ [
+ [
+ 'zh' => '一地里',
+ 'zh-hans' => '一地里',
+ 'zh-hant' => '一地裡',
+ 'zh-cn' => '一地里',
+ 'zh-hk' => '一地裏',
+ 'zh-mo' => '一地裏',
+ 'zh-my' => '一地里',
+ 'zh-sg' => '一地里',
+ 'zh-tw' => '一地裡',
+ ],
+ '一地里'
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/maintenance/BenchmarkerTest.php b/www/wiki/tests/phpunit/maintenance/BenchmarkerTest.php
new file mode 100644
index 00000000..c15d789d
--- /dev/null
+++ b/www/wiki/tests/phpunit/maintenance/BenchmarkerTest.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace MediaWiki\Tests\Maintenance;
+
+use Benchmarker;
+use MediaWikiCoversValidator;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers Benchmarker
+ */
+class BenchmarkerTest extends \PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public function testBenchSimple() {
+ $bench = $this->getMockBuilder( Benchmarker::class )
+ ->setMethods( [ 'execute', 'output' ] )
+ ->getMock();
+ $benchProxy = TestingAccessWrapper::newFromObject( $bench );
+ $benchProxy->defaultCount = 3;
+
+ $count = 0;
+ $bench->bench( [
+ 'test' => function () use ( &$count ) {
+ $count++;
+ }
+ ] );
+
+ $this->assertSame( 3, $count );
+ }
+
+ public function testBenchSetup() {
+ $bench = $this->getMockBuilder( Benchmarker::class )
+ ->setMethods( [ 'execute', 'output' ] )
+ ->getMock();
+ $benchProxy = TestingAccessWrapper::newFromObject( $bench );
+ $benchProxy->defaultCount = 2;
+
+ $buffer = [];
+ $bench->bench( [
+ 'test' => [
+ 'setup' => function () use ( &$buffer ) {
+ $buffer[] = 'setup';
+ },
+ 'function' => function () use ( &$buffer ) {
+ $buffer[] = 'run';
+ }
+ ]
+ ] );
+
+ $this->assertSame( [ 'setup', 'run', 'run' ], $buffer );
+ }
+
+ public function testBenchVerbose() {
+ $bench = $this->getMockBuilder( Benchmarker::class )
+ ->setMethods( [ 'execute', 'output', 'hasOption', 'verboseRun' ] )
+ ->getMock();
+ $benchProxy = TestingAccessWrapper::newFromObject( $bench );
+ $benchProxy->defaultCount = 1;
+
+ $bench->expects( $this->exactly( 2 ) )->method( 'hasOption' )
+ ->will( $this->returnValueMap( [
+ [ 'verbose', true ],
+ [ 'count', false ],
+ ] ) );
+
+ $bench->expects( $this->once() )->method( 'verboseRun' )
+ ->with( 0 )
+ ->willReturn( null );
+
+ $bench->bench( [
+ 'test' => function () {
+ }
+ ] );
+ }
+
+ public function noop() {
+ }
+
+ public function testBenchName_method() {
+ $bench = $this->getMockBuilder( Benchmarker::class )
+ ->setMethods( [ 'execute', 'output', 'addResult' ] )
+ ->getMock();
+ $benchProxy = TestingAccessWrapper::newFromObject( $bench );
+ $benchProxy->defaultCount = 1;
+
+ $bench->expects( $this->once() )->method( 'addResult' )
+ ->with( $this->callback( function ( $res ) {
+ return isset( $res['name'] ) && $res['name'] === __CLASS__ . '::noop()';
+ } ) );
+
+ $bench->bench( [
+ [ 'function' => [ $this, 'noop' ] ]
+ ] );
+ }
+
+ public function testBenchName_string() {
+ $bench = $this->getMockBuilder( Benchmarker::class )
+ ->setMethods( [ 'execute', 'output', 'addResult' ] )
+ ->getMock();
+ $benchProxy = TestingAccessWrapper::newFromObject( $bench );
+ $benchProxy->defaultCount = 1;
+
+ $bench->expects( $this->once() )->method( 'addResult' )
+ ->with( $this->callback( function ( $res ) {
+ return 'strtolower(A)';
+ } ) );
+
+ $bench->bench( [ [
+ 'function' => 'strtolower',
+ 'args' => [ 'A' ],
+ ] ] );
+ }
+
+ /**
+ * @covers Benchmarker::verboseRun
+ */
+ public function testVerboseRun() {
+ $bench = $this->getMockBuilder( Benchmarker::class )
+ ->setMethods( [ 'execute', 'output', 'hasOption', 'startBench', 'addResult' ] )
+ ->getMock();
+ $benchProxy = TestingAccessWrapper::newFromObject( $bench );
+ $benchProxy->defaultCount = 1;
+
+ $bench->expects( $this->exactly( 2 ) )->method( 'hasOption' )
+ ->will( $this->returnValueMap( [
+ [ 'verbose', true ],
+ [ 'count', false ],
+ ] ) );
+
+ $bench->expects( $this->once() )->method( 'output' )
+ ->with( $this->callback( function ( $out ) {
+ return preg_match( '/memory.+ peak/', $out ) === 1;
+ } ) );
+
+ $bench->bench( [
+ 'test' => function () {
+ }
+ ] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/maintenance/DumpTestCase.php b/www/wiki/tests/phpunit/maintenance/DumpTestCase.php
new file mode 100644
index 00000000..9b90bfe6
--- /dev/null
+++ b/www/wiki/tests/phpunit/maintenance/DumpTestCase.php
@@ -0,0 +1,417 @@
+<?php
+
+namespace MediaWiki\Tests\Maintenance;
+
+use ContentHandler;
+use ExecutableFinder;
+use MediaWikiLangTestCase;
+use Page;
+use User;
+use XMLReader;
+use MWException;
+
+/**
+ * Base TestCase for dumps
+ */
+abstract class DumpTestCase extends MediaWikiLangTestCase {
+
+ /**
+ * exception to be rethrown once in sound PHPUnit surrounding
+ *
+ * As the current MediaWikiTestCase::run is not robust enough to recover
+ * from thrown exceptions directly, we cannot throw frow within
+ * self::addDBData, although it would be appropriate. Hence, we catch the
+ * exception and store it until we are in setUp and may finally rethrow
+ * the exception without crashing the test suite.
+ *
+ * @var Exception|null
+ */
+ protected $exceptionFromAddDBData = null;
+
+ /**
+ * Holds the XMLReader used for analyzing an XML dump
+ *
+ * @var XMLReader|null
+ */
+ protected $xml = null;
+
+ /** @var bool|null Whether the 'gzip' utility is available */
+ protected static $hasGzip = null;
+
+ /**
+ * Skip the test if 'gzip' is not in $PATH.
+ *
+ * @return bool
+ */
+ protected function checkHasGzip() {
+ if ( self::$hasGzip === null ) {
+ self::$hasGzip = ( ExecutableFinder::findInDefaultPaths( 'gzip' ) !== false );
+ }
+
+ if ( !self::$hasGzip ) {
+ $this->markTestSkipped( "Skip test, requires the gzip utility in PATH" );
+ }
+
+ return self::$hasGzip;
+ }
+
+ /**
+ * Adds a revision to a page, while returning the resuting revision's id
+ *
+ * @param Page $page Page to add the revision to
+ * @param string $text Revisions text
+ * @param string $summary Revisions summary
+ * @param string $model The model ID (defaults to wikitext)
+ *
+ * @throws MWException
+ * @return array
+ */
+ protected function addRevision( Page $page, $text, $summary, $model = CONTENT_MODEL_WIKITEXT ) {
+ $status = $page->doEditContent(
+ ContentHandler::makeContent( $text, $page->getTitle(), $model ),
+ $summary
+ );
+
+ if ( $status->isGood() ) {
+ $value = $status->getValue();
+ $revision = $value['revision'];
+ $revision_id = $revision->getId();
+ $text_id = $revision->getTextId();
+
+ if ( ( $revision_id > 0 ) && ( $text_id > 0 ) ) {
+ return [ $revision_id, $text_id ];
+ }
+ }
+
+ throw new MWException( "Could not determine revision id ("
+ . $status->getWikiText( false, false, 'en' ) . ")" );
+ }
+
+ /**
+ * gunzips the given file and stores the result in the original file name
+ *
+ * @param string $fname Filename to read the gzipped data from and stored
+ * the gunzipped data into
+ */
+ protected function gunzip( $fname ) {
+ $gzipped_contents = file_get_contents( $fname );
+ if ( $gzipped_contents === false ) {
+ $this->fail( "Could not get contents of $fname" );
+ }
+
+ $contents = gzdecode( $gzipped_contents );
+
+ $this->assertEquals(
+ strlen( $contents ),
+ file_put_contents( $fname, $contents ),
+ '# bytes written'
+ );
+ }
+
+ /**
+ * Default set up function.
+ *
+ * Clears $wgUser, and reports errors from addDBData to PHPUnit
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ // Check if any Exception is stored for rethrowing from addDBData
+ // @see self::exceptionFromAddDBData
+ if ( $this->exceptionFromAddDBData !== null ) {
+ throw $this->exceptionFromAddDBData;
+ }
+
+ $this->setMwGlobals( 'wgUser', new User() );
+ }
+
+ /**
+ * Checks for test output consisting only of lines containing ETA announcements
+ */
+ function expectETAOutput() {
+ // Newer PHPUnits require assertion about the output using PHPUnit's own
+ // expectOutput[...] functions. However, the PHPUnit shipped prediactes
+ // do not allow to check /each/ line of the output using /readable/ REs.
+ // So we ...
+
+ // 1. ... add a dummy output checking to make PHPUnit not complain
+ // about unchecked test output
+ $this->expectOutputRegex( '//' );
+
+ // 2. Do the real output checking on our own.
+ $lines = explode( "\n", $this->getActualOutput() );
+ $this->assertGreaterThan( 1, count( $lines ), "Minimal lines of produced output" );
+ $this->assertEquals( '', array_pop( $lines ), "Output ends in LF" );
+ $timestamp_re = "[0-9]{4}-[01][0-9]-[0-3][0-9] [0-2][0-9]:[0-5][0-9]:[0-6][0-9]";
+ foreach ( $lines as $line ) {
+ $this->assertRegExp(
+ "/$timestamp_re: .* \(ID [0-9]+\) [0-9]* pages .*, [0-9]* revs .*, ETA/",
+ $line
+ );
+ }
+ }
+
+ /**
+ * Step the current XML reader until node end of given name is found.
+ *
+ * @param string $name Name of the closing element to look for
+ * (e.g.: "mediawiki" when looking for </mediawiki>)
+ *
+ * @return bool True if the end node could be found. false otherwise.
+ */
+ protected function skipToNodeEnd( $name ) {
+ while ( $this->xml->read() ) {
+ if ( $this->xml->nodeType == XMLReader::END_ELEMENT &&
+ $this->xml->name == $name
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Step the current XML reader to the first element start after the node
+ * end of a given name.
+ *
+ * @param string $name Name of the closing element to look for
+ * (e.g.: "mediawiki" when looking for </mediawiki>)
+ *
+ * @return bool True if new element after the closing of $name could be
+ * found. false otherwise.
+ */
+ protected function skipPastNodeEnd( $name ) {
+ $this->assertTrue( $this->skipToNodeEnd( $name ),
+ "Skipping to end of $name" );
+ while ( $this->xml->read() ) {
+ if ( $this->xml->nodeType == XMLReader::ELEMENT ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Opens an XML file to analyze and optionally skips past siteinfo.
+ *
+ * @param string $fname Name of file to analyze
+ * @param bool $skip_siteinfo (optional) If true, step the xml reader
+ * to the first element after </siteinfo>
+ */
+ protected function assertDumpStart( $fname, $skip_siteinfo = true ) {
+ $this->xml = new XMLReader();
+ $this->assertTrue( $this->xml->open( $fname ),
+ "Opening temporary file $fname via XMLReader failed" );
+ if ( $skip_siteinfo ) {
+ $this->assertTrue( $this->skipPastNodeEnd( "siteinfo" ),
+ "Skipping past end of siteinfo" );
+ }
+ }
+
+ /**
+ * Asserts that the xml reader is at the final closing tag of an xml file and
+ * closes the reader.
+ *
+ * @param string $name (optional) the name of the final tag
+ * (e.g.: "mediawiki" for </mediawiki>)
+ */
+ protected function assertDumpEnd( $name = "mediawiki" ) {
+ $this->assertNodeEnd( $name, false );
+ if ( $this->xml->read() ) {
+ $this->skipWhitespace();
+ }
+ $this->assertEquals( $this->xml->nodeType, XMLReader::NONE,
+ "No proper entity left to parse" );
+ $this->xml->close();
+ }
+
+ /**
+ * Steps the xml reader over white space
+ */
+ protected function skipWhitespace() {
+ $cont = true;
+ while ( $cont && ( ( $this->xml->nodeType == XMLReader::WHITESPACE )
+ || ( $this->xml->nodeType == XMLReader::SIGNIFICANT_WHITESPACE ) ) ) {
+ $cont = $this->xml->read();
+ }
+ }
+
+ /**
+ * Asserts that the xml reader is at an element of given name, and optionally
+ * skips past it.
+ *
+ * @param string $name The name of the element to check for
+ * (e.g.: "mediawiki" for <mediawiki>)
+ * @param bool $skip (optional) if true, skip past the found element
+ */
+ protected function assertNodeStart( $name, $skip = true ) {
+ $this->assertEquals( $name, $this->xml->name, "Node name" );
+ $this->assertEquals( XMLReader::ELEMENT, $this->xml->nodeType, "Node type" );
+ if ( $skip ) {
+ $this->assertTrue( $this->xml->read(), "Skipping past start tag" );
+ }
+ }
+
+ /**
+ * Asserts that the xml reader is at an closing element of given name, and optionally
+ * skips past it.
+ *
+ * @param string $name The name of the closing element to check for
+ * (e.g.: "mediawiki" for </mediawiki>)
+ * @param bool $skip (optional) if true, skip past the found element
+ */
+ protected function assertNodeEnd( $name, $skip = true ) {
+ $this->assertEquals( $name, $this->xml->name, "Node name" );
+ $this->assertEquals( XMLReader::END_ELEMENT, $this->xml->nodeType, "Node type" );
+ if ( $skip ) {
+ $this->assertTrue( $this->xml->read(), "Skipping past end tag" );
+ }
+ }
+
+ /**
+ * Asserts that the xml reader is at an element of given tag that contains a given text,
+ * and skips over the element.
+ *
+ * @param string $name The name of the element to check for
+ * (e.g.: "mediawiki" for <mediawiki>...</mediawiki>)
+ * @param string|bool $text If string, check if it equals the elements text.
+ * If false, ignore the element's text
+ * @param bool $skip_ws (optional) if true, skip past white spaces that trail the
+ * closing element.
+ */
+ protected function assertTextNode( $name, $text, $skip_ws = true ) {
+ $this->assertNodeStart( $name );
+
+ if ( $text !== false ) {
+ $this->assertEquals( $text, $this->xml->value, "Text of node " . $name );
+ }
+ $this->assertTrue( $this->xml->read(), "Skipping past processed text of " . $name );
+ $this->assertNodeEnd( $name );
+
+ if ( $skip_ws ) {
+ $this->skipWhitespace();
+ }
+ }
+
+ /**
+ * Asserts that the xml reader is at the start of a page element and skips over the first
+ * tags, after checking them.
+ *
+ * Besides the opening page element, this function also checks for and skips over the
+ * title, ns, and id tags. Hence after this function, the xml reader is at the first
+ * revision of the current page.
+ *
+ * @param int $id Id of the page to assert
+ * @param int $ns Number of namespage to assert
+ * @param string $name Title of the current page
+ */
+ protected function assertPageStart( $id, $ns, $name ) {
+ $this->assertNodeStart( "page" );
+ $this->skipWhitespace();
+
+ $this->assertTextNode( "title", $name );
+ $this->assertTextNode( "ns", $ns );
+ $this->assertTextNode( "id", $id );
+ }
+
+ /**
+ * Asserts that the xml reader is at the page's closing element and skips to the next
+ * element.
+ */
+ protected function assertPageEnd() {
+ $this->assertNodeEnd( "page" );
+ $this->skipWhitespace();
+ }
+
+ /**
+ * Asserts that the xml reader is at a revision and checks its representation before
+ * skipping over it.
+ *
+ * @param int $id Id of the revision
+ * @param string $summary Summary of the revision
+ * @param int $text_id Id of the revision's text
+ * @param int $text_bytes Number of bytes in the revision's text
+ * @param string $text_sha1 The base36 SHA-1 of the revision's text
+ * @param string|bool $text (optional) The revision's string, or false to check for a
+ * revision stub
+ * @param int|bool $parentid (optional) id of the parent revision
+ * @param string $model The expected content model id (default: CONTENT_MODEL_WIKITEXT)
+ * @param string $format The expected format model id (default: CONTENT_FORMAT_WIKITEXT)
+ */
+ protected function assertRevision( $id, $summary, $text_id, $text_bytes,
+ $text_sha1, $text = false, $parentid = false,
+ $model = CONTENT_MODEL_WIKITEXT, $format = CONTENT_FORMAT_WIKITEXT
+ ) {
+ $this->assertNodeStart( "revision" );
+ $this->skipWhitespace();
+
+ $this->assertTextNode( "id", $id );
+ if ( $parentid !== false ) {
+ $this->assertTextNode( "parentid", $parentid );
+ }
+ $this->assertTextNode( "timestamp", false );
+
+ $this->assertNodeStart( "contributor" );
+ $this->skipWhitespace();
+ $this->assertTextNode( "ip", false );
+ $this->assertNodeEnd( "contributor" );
+ $this->skipWhitespace();
+
+ $this->assertTextNode( "comment", $summary );
+ $this->skipWhitespace();
+
+ $this->assertTextNode( "model", $model );
+ $this->skipWhitespace();
+
+ $this->assertTextNode( "format", $format );
+ $this->skipWhitespace();
+
+ if ( $this->xml->name == "text" ) {
+ // note: <text> tag may occur here or at the very end.
+ $text_found = true;
+ $this->assertText( $id, $text_id, $text_bytes, $text );
+ } else {
+ $text_found = false;
+ }
+
+ $this->assertTextNode( "sha1", $text_sha1 );
+
+ if ( !$text_found ) {
+ $this->assertText( $id, $text_id, $text_bytes, $text );
+ }
+
+ $this->assertNodeEnd( "revision" );
+ $this->skipWhitespace();
+ }
+
+ protected function assertText( $id, $text_id, $text_bytes, $text ) {
+ $this->assertNodeStart( "text", false );
+ if ( $text_bytes !== false ) {
+ $this->assertEquals( $this->xml->getAttribute( "bytes" ), $text_bytes,
+ "Attribute 'bytes' of revision " . $id );
+ }
+
+ if ( $text === false ) {
+ // Testing for a stub
+ $this->assertEquals( $this->xml->getAttribute( "id" ), $text_id,
+ "Text id of revision " . $id );
+ $this->assertFalse( $this->xml->hasValue, "Revision has text" );
+ $this->assertTrue( $this->xml->read(), "Skipping text start tag" );
+ if ( ( $this->xml->nodeType == XMLReader::END_ELEMENT )
+ && ( $this->xml->name == "text" )
+ ) {
+ $this->xml->read();
+ }
+ $this->skipWhitespace();
+ } else {
+ // Testing for a real dump
+ $this->assertTrue( $this->xml->read(), "Skipping text start tag" );
+ $this->assertEquals( $text, $this->xml->value, "Text of revision " . $id );
+ $this->assertTrue( $this->xml->read(), "Skipping past text" );
+ $this->assertNodeEnd( "text" );
+ $this->skipWhitespace();
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/maintenance/MaintenanceBaseTestCase.php b/www/wiki/tests/phpunit/maintenance/MaintenanceBaseTestCase.php
new file mode 100644
index 00000000..bdcf7e5f
--- /dev/null
+++ b/www/wiki/tests/phpunit/maintenance/MaintenanceBaseTestCase.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace MediaWiki\Tests\Maintenance;
+
+use Maintenance;
+use MediaWikiTestCase;
+use Wikimedia\TestingAccessWrapper;
+
+abstract class MaintenanceBaseTestCase extends MediaWikiTestCase {
+
+ /**
+ * The main Maintenance instance that is used for testing, wrapped and mockable.
+ *
+ * @var Maintenance
+ */
+ protected $maintenance;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->maintenance = $this->createMaintenance();
+ }
+
+ /**
+ * Do a little stream cleanup to prevent output in case the child class
+ * hasn't tested the capture buffer.
+ */
+ protected function tearDown() {
+ if ( $this->maintenance ) {
+ $this->maintenance->cleanupChanneled();
+ }
+
+ // This is smelly, but maintenance scripts usually produce output, so
+ // we anticipate and ignore with a regex that will catch everything.
+ //
+ // If you call $this->expectOutputRegex in your subclass, this guard
+ // won't be triggered, and your specific pattern will be respected.
+ if ( !$this->hasExpectationOnOutput() ) {
+ $this->expectOutputRegex( '/.*/' );
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * @return string Class name
+ *
+ * Subclasses must implement this in order to use the $this->maintenance
+ * variable. Normally, it will be set like:
+ * return PopulateDatabaseMaintenance::class;
+ *
+ * If you need to change the way your maintenance class is constructed,
+ * override createMaintenance.
+ */
+ abstract protected function getMaintenanceClass();
+
+ /**
+ * Called by setUp to initialize $this->maintenance.
+ *
+ * @return object The Maintenance instance to test.
+ */
+ protected function createMaintenance() {
+ $className = $this->getMaintenanceClass();
+ $obj = new $className();
+
+ // We use TestingAccessWrapper in order to access protected internals
+ // such as `output()`.
+ return TestingAccessWrapper::newFromObject( $obj );
+ }
+
+ /**
+ * Asserts the output before and after simulating shutdown
+ *
+ * This function simulates shutdown of self::maintenance.
+ *
+ * @param string $preShutdownOutput Expected output before simulating shutdown
+ * @param bool $expectNLAppending Whether or not shutdown simulation is expected
+ * to add a newline to the output. If false, $preShutdownOutput is the
+ * expected output after shutdown simulation. Otherwise,
+ * $preShutdownOutput with an appended newline is the expected output
+ * after shutdown simulation.
+ */
+ protected function assertOutputPrePostShutdown( $preShutdownOutput, $expectNLAppending ) {
+ $this->assertEquals( $preShutdownOutput, $this->getActualOutput(),
+ "Output before shutdown simulation" );
+
+ $this->maintenance->cleanupChanneled();
+
+ $postShutdownOutput = $preShutdownOutput . ( $expectNLAppending ? "\n" : "" );
+ $this->expectOutputString( $postShutdownOutput );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/maintenance/MaintenanceTest.php b/www/wiki/tests/phpunit/maintenance/MaintenanceTest.php
new file mode 100644
index 00000000..141561f0
--- /dev/null
+++ b/www/wiki/tests/phpunit/maintenance/MaintenanceTest.php
@@ -0,0 +1,536 @@
+<?php
+
+namespace MediaWiki\Tests\Maintenance;
+
+use Maintenance;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers Maintenance
+ */
+class MaintenanceTest extends MaintenanceBaseTestCase {
+
+ /**
+ * @see MaintenanceBaseTestCase::getMaintenanceClass
+ */
+ protected function getMaintenanceClass() {
+ return Maintenance::class;
+ }
+
+ /**
+ * @see MaintenanceBaseTestCase::createMaintenance
+ *
+ * Note to extension authors looking for a model to follow: This function
+ * is normally not needed in a maintenance test, it's only overridden here
+ * because Maintenance is abstract.
+ */
+ protected function createMaintenance() {
+ $className = $this->getMaintenanceClass();
+ $obj = $this->getMockForAbstractClass( $className );
+
+ return TestingAccessWrapper::newFromObject( $obj );
+ }
+
+ // Although the following tests do not seem to be too consistent (compare for
+ // example the newlines within the test.*StringString tests, or the
+ // test.*Intermittent.* tests), the objective of these tests is not to describe
+ // consistent behavior, but rather currently existing behavior.
+
+ /**
+ * @dataProvider provideOutputData
+ */
+ function testOutput( $outputs, $expected, $extraNL ) {
+ foreach ( $outputs as $data ) {
+ if ( is_array( $data ) ) {
+ list( $msg, $channel ) = $data;
+ } else {
+ $msg = $data;
+ $channel = null;
+ }
+ $this->maintenance->output( $msg, $channel );
+ }
+ $this->assertOutputPrePostShutdown( $expected, $extraNL );
+ }
+
+ public function provideOutputData() {
+ return [
+ [ [ "" ], "", false ],
+ [ [ "foo" ], "foo", false ],
+ [ [ "foo", "bar" ], "foobar", false ],
+ [ [ "foo\n" ], "foo\n", false ],
+ [ [ "foo\n\n" ], "foo\n\n", false ],
+ [ [ "foo\nbar" ], "foo\nbar", false ],
+ [ [ "foo\nbar\n" ], "foo\nbar\n", false ],
+ [ [ "foo\n", "bar\n" ], "foo\nbar\n", false ],
+ [ [ "", "foo", "", "\n", "ba", "", "r\n" ], "foo\nbar\n", false ],
+ [ [ "", "foo", "", "\nb", "a", "", "r\n" ], "foo\nbar\n", false ],
+ [ [ [ "foo", "bazChannel" ] ], "foo", true ],
+ [ [ [ "foo\n", "bazChannel" ] ], "foo", true ],
+
+ // If this test fails, note that output takes strings with double line
+ // endings (although output's implementation in this situation calls
+ // outputChanneled with a string ending in a nl ... which is not allowed
+ // according to the documentation of outputChanneled)
+ [ [ [ "foo\n\n", "bazChannel" ] ], "foo\n", true ],
+ [ [ [ "foo\nbar", "bazChannel" ] ], "foo\nbar", true ],
+ [ [ [ "foo\nbar\n", "bazChannel" ] ], "foo\nbar", true ],
+ [
+ [
+ [ "foo\n", "bazChannel" ],
+ [ "bar\n", "bazChannel" ],
+ ],
+ "foobar",
+ true
+ ],
+ [
+ [
+ [ "", "bazChannel" ],
+ [ "foo", "bazChannel" ],
+ [ "", "bazChannel" ],
+ [ "\n", "bazChannel" ],
+ [ "ba", "bazChannel" ],
+ [ "", "bazChannel" ],
+ [ "r\n", "bazChannel" ],
+ ],
+ "foobar",
+ true
+ ],
+ [
+ [
+ [ "", "bazChannel" ],
+ [ "foo", "bazChannel" ],
+ [ "", "bazChannel" ],
+ [ "\nb", "bazChannel" ],
+ [ "a", "bazChannel" ],
+ [ "", "bazChannel" ],
+ [ "r\n", "bazChannel" ],
+ ],
+ "foo\nbar",
+ true
+ ],
+ [
+ [
+ [ "foo", "bazChannel" ],
+ [ "bar", "bazChannel" ],
+ [ "qux", "quuxChannel" ],
+ [ "corge", "bazChannel" ],
+ ],
+ "foobar\nqux\ncorge",
+ true
+ ],
+ [
+ [
+ [ "foo", "bazChannel" ],
+ [ "bar\n", "bazChannel" ],
+ [ "qux\n", "quuxChannel" ],
+ [ "corge", "bazChannel" ],
+ ],
+ "foobar\nqux\ncorge",
+ true
+ ],
+ [
+ [
+ [ "foo", null ],
+ [ "bar", "bazChannel" ],
+ [ "qux", null ],
+ [ "quux", "bazChannel" ],
+ ],
+ "foobar\nquxquux",
+ true
+ ],
+ [
+ [
+ [ "foo", "bazChannel" ],
+ [ "bar", null ],
+ [ "qux", "bazChannel" ],
+ [ "quux", null ],
+ ],
+ "foo\nbarqux\nquux",
+ false
+ ],
+ [
+ [
+ [ "foo", 1 ],
+ [ "bar", 1.0 ],
+ ],
+ "foo\nbar",
+ true
+ ],
+ [ [ "foo", "", "bar" ], "foobar", false ],
+ [ [ "foo", false, "bar" ], "foobar", false ],
+ [
+ [
+ [ "qux", "quuxChannel" ],
+ "foo",
+ false,
+ "bar"
+ ],
+ "qux\nfoobar",
+ false
+ ],
+ [
+ [
+ [ "foo", "bazChannel" ],
+ [ "", "bazChannel" ],
+ [ "bar", "bazChannel" ],
+ ],
+ "foobar",
+ true
+ ],
+ [
+ [
+ [ "foo", "bazChannel" ],
+ [ false, "bazChannel" ],
+ [ "bar", "bazChannel" ],
+ ],
+ "foobar",
+ true
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideOutputChanneledData
+ */
+ function testOutputChanneled( $outputs, $expected, $extraNL ) {
+ foreach ( $outputs as $data ) {
+ if ( is_array( $data ) ) {
+ list( $msg, $channel ) = $data;
+ } else {
+ $msg = $data;
+ $channel = null;
+ }
+ $this->maintenance->outputChanneled( $msg, $channel );
+ }
+ $this->assertOutputPrePostShutdown( $expected, $extraNL );
+ }
+
+ public function provideOutputChanneledData() {
+ return [
+ [ [ "" ], "\n", false ],
+ [ [ "foo" ], "foo\n", false ],
+ [ [ "foo", "bar" ], "foo\nbar\n", false ],
+ [ [ "foo\nbar" ], "foo\nbar\n", false ],
+ [ [ "", "foo", "", "\nb", "a", "", "r" ], "\nfoo\n\n\nb\na\n\nr\n", false ],
+ [ [ [ "foo", "bazChannel" ] ], "foo", true ],
+ [
+ [
+ [ "foo\nbar", "bazChannel" ]
+ ],
+ "foo\nbar",
+ true
+ ],
+ [
+ [
+ [ "foo", "bazChannel" ],
+ [ "bar", "bazChannel" ],
+ ],
+ "foobar",
+ true
+ ],
+ [
+ [
+ [ "", "bazChannel" ],
+ [ "foo", "bazChannel" ],
+ [ "", "bazChannel" ],
+ [ "\nb", "bazChannel" ],
+ [ "a", "bazChannel" ],
+ [ "", "bazChannel" ],
+ [ "r", "bazChannel" ],
+ ],
+ "foo\nbar",
+ true
+ ],
+ [
+ [
+ [ "foo", "bazChannel" ],
+ [ "bar", "bazChannel" ],
+ [ "qux", "quuxChannel" ],
+ [ "corge", "bazChannel" ],
+ ],
+ "foobar\nqux\ncorge",
+ true
+ ],
+ [
+ [
+ [ "foo", "bazChannel" ],
+ [ "bar", "bazChannel" ],
+ [ "qux", "quuxChannel" ],
+ [ "corge", "bazChannel" ],
+ ],
+ "foobar\nqux\ncorge",
+ true
+ ],
+ [
+ [
+ [ "foo", "bazChannel" ],
+ [ "bar", null ],
+ [ "qux", null ],
+ [ "corge", "bazChannel" ],
+ ],
+ "foo\nbar\nqux\ncorge",
+ true
+ ],
+ [
+ [
+ [ "foo", null ],
+ [ "bar", "bazChannel" ],
+ [ "qux", null ],
+ [ "quux", "bazChannel" ],
+ ],
+ "foo\nbar\nqux\nquux",
+ true
+ ],
+ [
+ [
+ [ "foo", "bazChannel" ],
+ [ "bar", null ],
+ [ "qux", "bazChannel" ],
+ [ "quux", null ],
+ ],
+ "foo\nbar\nqux\nquux\n",
+ false
+ ],
+ [
+ [
+ [ "foo", 1 ],
+ [ "bar", 1.0 ],
+ ],
+ "foo\nbar",
+ true
+ ],
+ [ [ "foo", "", "bar" ], "foo\n\nbar\n", false ],
+ [ [ "foo", false, "bar" ], "foo\nbar\n", false ],
+ ];
+ }
+
+ function testCleanupChanneledClean() {
+ $this->maintenance->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "", false );
+ }
+
+ function testCleanupChanneledAfterOutput() {
+ $this->maintenance->output( "foo" );
+ $this->maintenance->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foo", false );
+ }
+
+ function testCleanupChanneledAfterOutputWNullChannel() {
+ $this->maintenance->output( "foo", null );
+ $this->maintenance->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foo", false );
+ }
+
+ function testCleanupChanneledAfterOutputWChannel() {
+ $this->maintenance->output( "foo", "bazChannel" );
+ $this->maintenance->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foo\n", false );
+ }
+
+ function testCleanupChanneledAfterNLOutput() {
+ $this->maintenance->output( "foo\n" );
+ $this->maintenance->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foo\n", false );
+ }
+
+ function testCleanupChanneledAfterNLOutputWNullChannel() {
+ $this->maintenance->output( "foo\n", null );
+ $this->maintenance->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foo\n", false );
+ }
+
+ function testCleanupChanneledAfterNLOutputWChannel() {
+ $this->maintenance->output( "foo\n", "bazChannel" );
+ $this->maintenance->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foo\n", false );
+ }
+
+ function testCleanupChanneledAfterOutputChanneledWOChannel() {
+ $this->maintenance->outputChanneled( "foo" );
+ $this->maintenance->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foo\n", false );
+ }
+
+ function testCleanupChanneledAfterOutputChanneledWNullChannel() {
+ $this->maintenance->outputChanneled( "foo", null );
+ $this->maintenance->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foo\n", false );
+ }
+
+ function testCleanupChanneledAfterOutputChanneledWChannel() {
+ $this->maintenance->outputChanneled( "foo", "bazChannel" );
+ $this->maintenance->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foo\n", false );
+ }
+
+ function testMultipleMaintenanceObjectsInteractionOutput() {
+ $m2 = $this->createMaintenance();
+
+ $this->maintenance->output( "foo" );
+ $m2->output( "bar" );
+
+ $this->assertEquals( "foobar", $this->getActualOutput(),
+ "Output before shutdown simulation (m2)" );
+ $m2->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foobar", false );
+ }
+
+ function testMultipleMaintenanceObjectsInteractionOutputWNullChannel() {
+ $m2 = $this->createMaintenance();
+
+ $this->maintenance->output( "foo", null );
+ $m2->output( "bar", null );
+
+ $this->assertEquals( "foobar", $this->getActualOutput(),
+ "Output before shutdown simulation (m2)" );
+ $m2->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foobar", false );
+ }
+
+ function testMultipleMaintenanceObjectsInteractionOutputWChannel() {
+ $m2 = $this->createMaintenance();
+
+ $this->maintenance->output( "foo", "bazChannel" );
+ $m2->output( "bar", "bazChannel" );
+
+ $this->assertEquals( "foobar", $this->getActualOutput(),
+ "Output before shutdown simulation (m2)" );
+ $m2->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foobar\n", true );
+ }
+
+ function testMultipleMaintenanceObjectsInteractionOutputWNullChannelNL() {
+ $m2 = $this->createMaintenance();
+
+ $this->maintenance->output( "foo\n", null );
+ $m2->output( "bar\n", null );
+
+ $this->assertEquals( "foo\nbar\n", $this->getActualOutput(),
+ "Output before shutdown simulation (m2)" );
+ $m2->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foo\nbar\n", false );
+ }
+
+ function testMultipleMaintenanceObjectsInteractionOutputWChannelNL() {
+ $m2 = $this->createMaintenance();
+
+ $this->maintenance->output( "foo\n", "bazChannel" );
+ $m2->output( "bar\n", "bazChannel" );
+
+ $this->assertEquals( "foobar", $this->getActualOutput(),
+ "Output before shutdown simulation (m2)" );
+ $m2->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foobar\n", true );
+ }
+
+ function testMultipleMaintenanceObjectsInteractionOutputChanneled() {
+ $m2 = $this->createMaintenance();
+
+ $this->maintenance->outputChanneled( "foo" );
+ $m2->outputChanneled( "bar" );
+
+ $this->assertEquals( "foo\nbar\n", $this->getActualOutput(),
+ "Output before shutdown simulation (m2)" );
+ $m2->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foo\nbar\n", false );
+ }
+
+ function testMultipleMaintenanceObjectsInteractionOutputChanneledWNullChannel() {
+ $m2 = $this->createMaintenance();
+
+ $this->maintenance->outputChanneled( "foo", null );
+ $m2->outputChanneled( "bar", null );
+
+ $this->assertEquals( "foo\nbar\n", $this->getActualOutput(),
+ "Output before shutdown simulation (m2)" );
+ $m2->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foo\nbar\n", false );
+ }
+
+ function testMultipleMaintenanceObjectsInteractionOutputChanneledWChannel() {
+ $m2 = $this->createMaintenance();
+
+ $this->maintenance->outputChanneled( "foo", "bazChannel" );
+ $m2->outputChanneled( "bar", "bazChannel" );
+
+ $this->assertEquals( "foobar", $this->getActualOutput(),
+ "Output before shutdown simulation (m2)" );
+ $m2->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foobar\n", true );
+ }
+
+ function testMultipleMaintenanceObjectsInteractionCleanupChanneledWChannel() {
+ $m2 = $this->createMaintenance();
+
+ $this->maintenance->outputChanneled( "foo", "bazChannel" );
+ $m2->outputChanneled( "bar", "bazChannel" );
+
+ $this->assertEquals( "foobar", $this->getActualOutput(),
+ "Output before first cleanup" );
+ $this->maintenance->cleanupChanneled();
+ $this->assertEquals( "foobar\n", $this->getActualOutput(),
+ "Output after first cleanup" );
+ $m2->cleanupChanneled();
+ $this->assertEquals( "foobar\n\n", $this->getActualOutput(),
+ "Output after second cleanup" );
+
+ $m2->cleanupChanneled();
+ $this->assertOutputPrePostShutdown( "foobar\n\n", false );
+ }
+
+ /**
+ * @covers Maintenance::getConfig
+ */
+ public function testGetConfig() {
+ $this->assertInstanceOf( 'Config', $this->maintenance->getConfig() );
+ $this->assertSame(
+ MediaWikiServices::getInstance()->getMainConfig(),
+ $this->maintenance->getConfig()
+ );
+ }
+
+ /**
+ * @covers Maintenance::setConfig
+ */
+ public function testSetConfig() {
+ $conf = $this->createMock( 'Config' );
+ $this->maintenance->setConfig( $conf );
+ $this->assertSame( $conf, $this->maintenance->getConfig() );
+ }
+
+ function testParseArgs() {
+ $m2 = $this->createMaintenance();
+
+ // Create an option with an argument allowed to be specified multiple times
+ $m2->addOption( 'multi', 'This option does stuff', false, true, false, true );
+ $m2->loadWithArgv( [ '--multi', 'this1', '--multi', 'this2' ] );
+
+ $this->assertEquals( [ 'this1', 'this2' ], $m2->getOption( 'multi' ) );
+ $this->assertEquals( [ [ 'multi', 'this1' ], [ 'multi', 'this2' ] ],
+ $m2->orderedOptions );
+
+ $m2->cleanupChanneled();
+
+ $m2 = $this->createMaintenance();
+
+ $m2->addOption( 'multi', 'This option does stuff', false, false, false, true );
+ $m2->loadWithArgv( [ '--multi', '--multi' ] );
+
+ $this->assertEquals( [ 1, 1 ], $m2->getOption( 'multi' ) );
+ $this->assertEquals( [ [ 'multi', 1 ], [ 'multi', 1 ] ], $m2->orderedOptions );
+
+ $m2->cleanupChanneled();
+
+ $m2 = $this->createMaintenance();
+
+ // Create an option with an argument allowed to be specified multiple times
+ $m2->addOption( 'multi', 'This option doesn\'t actually support multiple occurrences' );
+ $m2->loadWithArgv( [ '--multi=yo' ] );
+
+ $this->assertEquals( 'yo', $m2->getOption( 'multi' ) );
+ $this->assertEquals( [ [ 'multi', 'yo' ] ], $m2->orderedOptions );
+
+ $m2->cleanupChanneled();
+ }
+}
diff --git a/www/wiki/tests/phpunit/maintenance/backupPrefetchTest.php b/www/wiki/tests/phpunit/maintenance/backupPrefetchTest.php
new file mode 100644
index 00000000..8824c7af
--- /dev/null
+++ b/www/wiki/tests/phpunit/maintenance/backupPrefetchTest.php
@@ -0,0 +1,279 @@
+<?php
+
+namespace MediaWiki\Tests\Maintenance;
+
+use BaseDump;
+use MediaWikiTestCase;
+
+/**
+ * Tests for BaseDump
+ *
+ * @group Dump
+ * @covers BaseDump
+ */
+class BaseDumpTest extends MediaWikiTestCase {
+
+ /**
+ * @var BaseDump The BaseDump instance used within a test.
+ *
+ * If set, this BaseDump gets automatically closed in tearDown.
+ */
+ private $dump = null;
+
+ protected function tearDown() {
+ if ( $this->dump !== null ) {
+ $this->dump->close();
+ }
+
+ // T39458, parent teardown need to be done after closing the
+ // dump or it might cause some permissions errors.
+ parent::tearDown();
+ }
+
+ /**
+ * asserts that a prefetch yields an expected string
+ *
+ * @param string|null $expected The exepcted result of the prefetch
+ * @param int $page The page number to prefetch the text for
+ * @param int $revision The revision number to prefetch the text for
+ */
+ private function assertPrefetchEquals( $expected, $page, $revision ) {
+ $this->assertEquals( $expected, $this->dump->prefetch( $page, $revision ),
+ "Prefetch of page $page revision $revision" );
+ }
+
+ function testSequential() {
+ $fname = $this->setUpPrefetch();
+ $this->dump = new BaseDump( $fname );
+
+ $this->assertPrefetchEquals( "BackupDumperTestP1Text1", 1, 1 );
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 );
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text4 some additional Text", 2, 5 );
+ $this->assertPrefetchEquals( "Talk about BackupDumperTestP1 Text1", 4, 8 );
+ }
+
+ function testSynchronizeRevisionMissToRevision() {
+ $fname = $this->setUpPrefetch();
+ $this->dump = new BaseDump( $fname );
+
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 );
+ $this->assertPrefetchEquals( null, 2, 3 );
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text4 some additional Text", 2, 5 );
+ }
+
+ function testSynchronizeRevisionMissToPage() {
+ $fname = $this->setUpPrefetch();
+ $this->dump = new BaseDump( $fname );
+
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 );
+ $this->assertPrefetchEquals( null, 2, 40 );
+ $this->assertPrefetchEquals( "Talk about BackupDumperTestP1 Text1", 4, 8 );
+ }
+
+ function testSynchronizePageMiss() {
+ $fname = $this->setUpPrefetch();
+ $this->dump = new BaseDump( $fname );
+
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 );
+ $this->assertPrefetchEquals( null, 3, 40 );
+ $this->assertPrefetchEquals( "Talk about BackupDumperTestP1 Text1", 4, 8 );
+ }
+
+ function testPageMissAtEnd() {
+ $fname = $this->setUpPrefetch();
+ $this->dump = new BaseDump( $fname );
+
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 );
+ $this->assertPrefetchEquals( null, 6, 40 );
+ }
+
+ function testRevisionMissAtEnd() {
+ $fname = $this->setUpPrefetch();
+ $this->dump = new BaseDump( $fname );
+
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 );
+ $this->assertPrefetchEquals( null, 4, 40 );
+ }
+
+ function testSynchronizePageMissAtStart() {
+ $fname = $this->setUpPrefetch();
+ $this->dump = new BaseDump( $fname );
+
+ $this->assertPrefetchEquals( null, 0, 2 );
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 );
+ }
+
+ function testSynchronizeRevisionMissAtStart() {
+ $fname = $this->setUpPrefetch();
+ $this->dump = new BaseDump( $fname );
+
+ $this->assertPrefetchEquals( null, 1, -2 );
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 );
+ }
+
+ function testSequentialAcrossFiles() {
+ $fname1 = $this->setUpPrefetch( [ 1 ] );
+ $fname2 = $this->setUpPrefetch( [ 2, 4 ] );
+ $this->dump = new BaseDump( $fname1 . ";" . $fname2 );
+
+ $this->assertPrefetchEquals( "BackupDumperTestP1Text1", 1, 1 );
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 );
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text4 some additional Text", 2, 5 );
+ $this->assertPrefetchEquals( "Talk about BackupDumperTestP1 Text1", 4, 8 );
+ }
+
+ function testSynchronizeSkipAcrossFile() {
+ $fname1 = $this->setUpPrefetch( [ 1 ] );
+ $fname2 = $this->setUpPrefetch( [ 2 ] );
+ $fname3 = $this->setUpPrefetch( [ 4 ] );
+ $this->dump = new BaseDump( $fname1 . ";" . $fname2 . ";" . $fname3 );
+
+ $this->assertPrefetchEquals( "BackupDumperTestP1Text1", 1, 1 );
+ $this->assertPrefetchEquals( "Talk about BackupDumperTestP1 Text1", 4, 8 );
+ }
+
+ function testSynchronizeMissInWholeFirstFile() {
+ $fname1 = $this->setUpPrefetch( [ 1 ] );
+ $fname2 = $this->setUpPrefetch( [ 2 ] );
+ $this->dump = new BaseDump( $fname1 . ";" . $fname2 );
+
+ $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 );
+ }
+
+ /**
+ * Constructs a temporary file that can be used for prefetching
+ *
+ * The temporary file is removed by DumpBackup upon tearDown.
+ *
+ * @param array $requested_pages The indices of the page parts that should
+ * go into the prefetch file. 1,2,4 are available.
+ * @return string The file name of the created temporary file
+ */
+ private function setUpPrefetch( $requested_pages = [ 1, 2, 4 ] ) {
+ // The file name, where we store the prepared prefetch file
+ $fname = $this->getNewTempFile();
+
+ // The header of every prefetch file
+ // phpcs:ignore Generic.Files.LineLength
+ $header = '<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.7/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.7/ http://www.mediawiki.org/xml/export-0.7.xsd" version="0.7" xml:lang="en">
+ <siteinfo>
+ <sitename>wikisvn</sitename>
+ <base>http://localhost/wiki-svn/index.php/Main_Page</base>
+ <generator>MediaWiki 1.21alpha</generator>
+ <case>first-letter</case>
+ <namespaces>
+ <namespace key="-2" case="first-letter">Media</namespace>
+ <namespace key="-1" case="first-letter">Special</namespace>
+ <namespace key="0" case="first-letter" />
+ <namespace key="1" case="first-letter">Talk</namespace>
+ <namespace key="2" case="first-letter">User</namespace>
+ <namespace key="3" case="first-letter">User talk</namespace>
+ <namespace key="4" case="first-letter">Wikisvn</namespace>
+ <namespace key="5" case="first-letter">Wikisvn talk</namespace>
+ <namespace key="6" case="first-letter">File</namespace>
+ <namespace key="7" case="first-letter">File talk</namespace>
+ <namespace key="8" case="first-letter">MediaWiki</namespace>
+ <namespace key="9" case="first-letter">MediaWiki talk</namespace>
+ <namespace key="10" case="first-letter">Template</namespace>
+ <namespace key="11" case="first-letter">Template talk</namespace>
+ <namespace key="12" case="first-letter">Help</namespace>
+ <namespace key="13" case="first-letter">Help talk</namespace>
+ <namespace key="14" case="first-letter">Category</namespace>
+ <namespace key="15" case="first-letter">Category talk</namespace>
+ </namespaces>
+ </siteinfo>
+';
+
+ // An array holding the pages that are available for prefetch
+ $available_pages = [];
+
+ // Simple plain page
+ $available_pages[1] = ' <page>
+ <title>BackupDumperTestP1</title>
+ <ns>0</ns>
+ <id>1</id>
+ <revision>
+ <id>1</id>
+ <timestamp>2012-04-01T16:46:05Z</timestamp>
+ <contributor>
+ <ip>127.0.0.1</ip>
+ </contributor>
+ <comment>BackupDumperTestP1Summary1</comment>
+ <sha1>0bolhl6ol7i6x0e7yq91gxgaan39j87</sha1>
+ <text xml:space="preserve">BackupDumperTestP1Text1</text>
+ <model name="wikitext">1</model>
+ <format mime="text/x-wiki">1</format>
+ </revision>
+ </page>
+';
+ // Page with more than one revisions. Hole in rev ids.
+ $available_pages[2] = ' <page>
+ <title>BackupDumperTestP2</title>
+ <ns>0</ns>
+ <id>2</id>
+ <revision>
+ <id>2</id>
+ <timestamp>2012-04-01T16:46:05Z</timestamp>
+ <contributor>
+ <ip>127.0.0.1</ip>
+ </contributor>
+ <comment>BackupDumperTestP2Summary1</comment>
+ <sha1>jprywrymfhysqllua29tj3sc7z39dl2</sha1>
+ <text xml:space="preserve">BackupDumperTestP2Text1</text>
+ <model name="wikitext">1</model>
+ <format mime="text/x-wiki">1</format>
+ </revision>
+ <revision>
+ <id>5</id>
+ <parentid>2</parentid>
+ <timestamp>2012-04-01T16:46:05Z</timestamp>
+ <contributor>
+ <ip>127.0.0.1</ip>
+ </contributor>
+ <comment>BackupDumperTestP2Summary4 extra</comment>
+ <sha1>6o1ciaxa6pybnqprmungwofc4lv00wv</sha1>
+ <text xml:space="preserve">BackupDumperTestP2Text4 some additional Text</text>
+ <model name="wikitext">1</model>
+ <format mime="text/x-wiki">1</format>
+ </revision>
+ </page>
+';
+ // Page with id higher than previous id + 1
+ $available_pages[4] = ' <page>
+ <title>Talk:BackupDumperTestP1</title>
+ <ns>1</ns>
+ <id>4</id>
+ <revision>
+ <id>8</id>
+ <timestamp>2012-04-01T16:46:05Z</timestamp>
+ <contributor>
+ <ip>127.0.0.1</ip>
+ </contributor>
+ <comment>Talk BackupDumperTestP1 Summary1</comment>
+ <sha1>nktofwzd0tl192k3zfepmlzxoax1lpe</sha1>
+ <model name="wikitext">1</model>
+ <format mime="text/x-wiki">1</format>
+ <text xml:space="preserve">Talk about BackupDumperTestP1 Text1</text>
+ </revision>
+ </page>
+';
+
+ // The common ending for all files
+ $tail = '</mediawiki>
+';
+
+ // Putting together the content of the prefetch files
+ $content = $header;
+ foreach ( $requested_pages as $i ) {
+ $this->assertTrue( array_key_exists( $i, $available_pages ),
+ "Check for availability of requested page " . $i );
+ $content .= $available_pages[$i];
+ }
+ $content .= $tail;
+
+ $this->assertEquals( strlen( $content ), file_put_contents(
+ $fname, $content ), "Length of prepared prefetch" );
+
+ return $fname;
+ }
+}
diff --git a/www/wiki/tests/phpunit/maintenance/backupTextPassTest.php b/www/wiki/tests/phpunit/maintenance/backupTextPassTest.php
new file mode 100644
index 00000000..ad9bf3ea
--- /dev/null
+++ b/www/wiki/tests/phpunit/maintenance/backupTextPassTest.php
@@ -0,0 +1,701 @@
+<?php
+
+namespace MediaWiki\Tests\Maintenance;
+
+use MediaWikiLangTestCase;
+use TextContentHandler;
+use TextPassDumper;
+use Title;
+use WikiExporter;
+use WikiPage;
+
+require_once __DIR__ . "/../../../maintenance/dumpTextPass.php";
+
+/**
+ * Tests for TextPassDumper that rely on the database
+ *
+ * Some of these tests use the old constuctor for TextPassDumper
+ * and the dump() function, while others use the new loadWithArgv( $args )
+ * function and execute(). This is to ensure both the old and new methods
+ * work properly.
+ *
+ * @group Database
+ * @group Dump
+ * @covers TextPassDumper
+ */
+class TextPassDumperDatabaseTest extends DumpTestCase {
+
+ // We'll add several pages, revision and texts. The following variables hold the
+ // corresponding ids.
+ private $pageId1, $pageId2, $pageId3, $pageId4;
+ private static $numOfPages = 4;
+ private $revId1_1, $textId1_1;
+ private $revId2_1, $textId2_1, $revId2_2, $textId2_2;
+ private $revId2_3, $textId2_3, $revId2_4, $textId2_4;
+ private $revId3_1, $textId3_1, $revId3_2, $textId3_2;
+ private $revId4_1, $textId4_1;
+ private static $numOfRevs = 8;
+
+ function addDBData() {
+ $this->tablesUsed[] = 'page';
+ $this->tablesUsed[] = 'revision';
+ $this->tablesUsed[] = 'ip_changes';
+ $this->tablesUsed[] = 'text';
+
+ $this->mergeMwGlobalArrayValue( 'wgContentHandlers', [
+ "BackupTextPassTestModel" => BackupTextPassTestModelHandler::class,
+ ] );
+
+ $ns = $this->getDefaultWikitextNS();
+
+ try {
+ // Simple page
+ $title = Title::newFromText( 'BackupDumperTestP1', $ns );
+ $page = WikiPage::factory( $title );
+ list( $this->revId1_1, $this->textId1_1 ) = $this->addRevision( $page,
+ "BackupDumperTestP1Text1", "BackupDumperTestP1Summary1" );
+ $this->pageId1 = $page->getId();
+
+ // Page with more than one revision
+ $title = Title::newFromText( 'BackupDumperTestP2', $ns );
+ $page = WikiPage::factory( $title );
+ list( $this->revId2_1, $this->textId2_1 ) = $this->addRevision( $page,
+ "BackupDumperTestP2Text1", "BackupDumperTestP2Summary1" );
+ list( $this->revId2_2, $this->textId2_2 ) = $this->addRevision( $page,
+ "BackupDumperTestP2Text2", "BackupDumperTestP2Summary2" );
+ list( $this->revId2_3, $this->textId2_3 ) = $this->addRevision( $page,
+ "BackupDumperTestP2Text3", "BackupDumperTestP2Summary3" );
+ list( $this->revId2_4, $this->textId2_4 ) = $this->addRevision( $page,
+ "BackupDumperTestP2Text4 some additional Text ",
+ "BackupDumperTestP2Summary4 extra " );
+ $this->pageId2 = $page->getId();
+
+ // Deleted page.
+ $title = Title::newFromText( 'BackupDumperTestP3', $ns );
+ $page = WikiPage::factory( $title );
+ list( $this->revId3_1, $this->textId3_1 ) = $this->addRevision( $page,
+ "BackupDumperTestP3Text1", "BackupDumperTestP2Summary1" );
+ list( $this->revId3_2, $this->textId3_2 ) = $this->addRevision( $page,
+ "BackupDumperTestP3Text2", "BackupDumperTestP2Summary2" );
+ $this->pageId3 = $page->getId();
+ $page->doDeleteArticle( "Testing ;)" );
+
+ // Page from non-default namespace and model.
+ // ExportTransform applies.
+
+ if ( $ns === NS_TALK ) {
+ // @todo work around this.
+ throw new MWException( "The default wikitext namespace is the talk namespace. "
+ . " We can't currently deal with that." );
+ }
+
+ $title = Title::newFromText( 'BackupDumperTestP1', NS_TALK );
+ $page = WikiPage::factory( $title );
+ list( $this->revId4_1, $this->textId4_1 ) = $this->addRevision( $page,
+ "Talk about BackupDumperTestP1 Text1",
+ "Talk BackupDumperTestP1 Summary1",
+ "BackupTextPassTestModel" );
+ $this->pageId4 = $page->getId();
+ } catch ( Exception $e ) {
+ // We'd love to pass $e directly. However, ... see
+ // documentation of exceptionFromAddDBData in
+ // DumpTestCase
+ $this->exceptionFromAddDBData = $e;
+ }
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ // Since we will restrict dumping by page ranges (to allow
+ // working tests, even if the db gets prepopulated by a base
+ // class), we have to assert, that the page id are consecutively
+ // increasing
+ $this->assertEquals(
+ [ $this->pageId2, $this->pageId3, $this->pageId4 ],
+ [ $this->pageId1 + 1, $this->pageId1 + 2, $this->pageId1 + 3 ],
+ "Page ids increasing without holes" );
+ }
+
+ function testPlain() {
+ // Setting up the dump
+ $nameStub = $this->setUpStub();
+ $nameFull = $this->getNewTempFile();
+ $dumper = new TextPassDumper( [ "--stub=file:" . $nameStub,
+ "--output=file:" . $nameFull ] );
+ $dumper->reporting = false;
+ $dumper->setDB( $this->db );
+
+ // Performing the dump
+ $dumper->dump( WikiExporter::FULL, WikiExporter::TEXT );
+
+ // Checking for correctness of the dumped data
+ $this->assertDumpStart( $nameFull );
+
+ // Page 1
+ $this->assertPageStart( $this->pageId1, NS_MAIN, "BackupDumperTestP1" );
+ $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1",
+ $this->textId1_1, false, "0bolhl6ol7i6x0e7yq91gxgaan39j87",
+ "BackupDumperTestP1Text1" );
+ $this->assertPageEnd();
+
+ // Page 2
+ $this->assertPageStart( $this->pageId2, NS_MAIN, "BackupDumperTestP2" );
+ $this->assertRevision( $this->revId2_1, "BackupDumperTestP2Summary1",
+ $this->textId2_1, false, "jprywrymfhysqllua29tj3sc7z39dl2",
+ "BackupDumperTestP2Text1" );
+ $this->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2",
+ $this->textId2_2, false, "b7vj5ks32po5m1z1t1br4o7scdwwy95",
+ "BackupDumperTestP2Text2", $this->revId2_1 );
+ $this->assertRevision( $this->revId2_3, "BackupDumperTestP2Summary3",
+ $this->textId2_3, false, "jfunqmh1ssfb8rs43r19w98k28gg56r",
+ "BackupDumperTestP2Text3", $this->revId2_2 );
+ $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra",
+ $this->textId2_4, false, "6o1ciaxa6pybnqprmungwofc4lv00wv",
+ "BackupDumperTestP2Text4 some additional Text", $this->revId2_3 );
+ $this->assertPageEnd();
+
+ // Page 3
+ // -> Page is marked deleted. Hence not visible
+
+ // Page 4
+ $this->assertPageStart( $this->pageId4, NS_TALK, "Talk:BackupDumperTestP1" );
+ $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1",
+ $this->textId4_1, false, "nktofwzd0tl192k3zfepmlzxoax1lpe",
+ "TALK ABOUT BACKUPDUMPERTESTP1 TEXT1",
+ false,
+ "BackupTextPassTestModel",
+ "text/plain" );
+ $this->assertPageEnd();
+
+ $this->assertDumpEnd();
+ }
+
+ function testPrefetchPlain() {
+ // The mapping between ids and text, for the hits of the prefetch mock
+ $prefetchMap = [
+ [ $this->pageId1, $this->revId1_1, "Prefetch_________1Text1" ],
+ [ $this->pageId2, $this->revId2_3, "Prefetch_________2Text3" ]
+ ];
+
+ // The mock itself
+ $prefetchMock = $this->getMockBuilder( BaseDump::class )
+ ->setMethods( [ 'prefetch' ] )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $prefetchMock->expects( $this->exactly( 6 ) )
+ ->method( 'prefetch' )
+ ->will( $this->returnValueMap( $prefetchMap ) );
+
+ // Setting up of the dump
+ $nameStub = $this->setUpStub();
+ $nameFull = $this->getNewTempFile();
+
+ $dumper = new TextPassDumper( [ "--stub=file:" . $nameStub,
+ "--output=file:" . $nameFull ] );
+
+ $dumper->prefetch = $prefetchMock;
+ $dumper->reporting = false;
+ $dumper->setDB( $this->db );
+
+ // Performing the dump
+ $dumper->dump( WikiExporter::FULL, WikiExporter::TEXT );
+
+ // Checking for correctness of the dumped data
+ $this->assertDumpStart( $nameFull );
+
+ // Page 1
+ $this->assertPageStart( $this->pageId1, NS_MAIN, "BackupDumperTestP1" );
+ // Prefetch kicks in. This is still the SHA-1 of the original text,
+ // But the actual text (with different SHA-1) comes from prefetch.
+ $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1",
+ $this->textId1_1, false, "0bolhl6ol7i6x0e7yq91gxgaan39j87",
+ "Prefetch_________1Text1" );
+ $this->assertPageEnd();
+
+ // Page 2
+ $this->assertPageStart( $this->pageId2, NS_MAIN, "BackupDumperTestP2" );
+ $this->assertRevision( $this->revId2_1, "BackupDumperTestP2Summary1",
+ $this->textId2_1, false, "jprywrymfhysqllua29tj3sc7z39dl2",
+ "BackupDumperTestP2Text1" );
+ $this->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2",
+ $this->textId2_2, false, "b7vj5ks32po5m1z1t1br4o7scdwwy95",
+ "BackupDumperTestP2Text2", $this->revId2_1 );
+ // Prefetch kicks in. This is still the SHA-1 of the original text,
+ // But the actual text (with different SHA-1) comes from prefetch.
+ $this->assertRevision( $this->revId2_3, "BackupDumperTestP2Summary3",
+ $this->textId2_3, false, "jfunqmh1ssfb8rs43r19w98k28gg56r",
+ "Prefetch_________2Text3", $this->revId2_2 );
+ $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra",
+ $this->textId2_4, false, "6o1ciaxa6pybnqprmungwofc4lv00wv",
+ "BackupDumperTestP2Text4 some additional Text", $this->revId2_3 );
+ $this->assertPageEnd();
+
+ // Page 3
+ // -> Page is marked deleted. Hence not visible
+
+ // Page 4
+ $this->assertPageStart( $this->pageId4, NS_TALK, "Talk:BackupDumperTestP1" );
+ $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1",
+ $this->textId4_1, false, "nktofwzd0tl192k3zfepmlzxoax1lpe",
+ "TALK ABOUT BACKUPDUMPERTESTP1 TEXT1",
+ false,
+ "BackupTextPassTestModel",
+ "text/plain" );
+ $this->assertPageEnd();
+
+ $this->assertDumpEnd();
+ }
+
+ /**
+ * Ensures that checkpoint dumps are used and written, by successively increasing the
+ * stub size and dumping until the duration crosses a threshold.
+ *
+ * @param string $checkpointFormat Either "file" for plain text or "gzip" for gzipped
+ * checkpoint files.
+ */
+ private function checkpointHelper( $checkpointFormat = "file" ) {
+ // Getting temporary names
+ $nameStub = $this->getNewTempFile();
+ $nameOutputDir = $this->getNewTempDirectory();
+
+ $stderr = fopen( 'php://output', 'a' );
+ if ( $stderr === false ) {
+ $this->fail( "Could not open stream for stderr" );
+ }
+
+ $iterations = 32; // We'll start with that many iterations of revisions
+ // in stub. Make sure that the generated volume is above the buffer size
+ // set below. Otherwise, the checkpointing does not trigger.
+ $lastDuration = 0;
+ $minDuration = 2; // We want the dump to take at least this many seconds
+ $checkpointAfter = 0.5; // Generate checkpoint after this many seconds
+
+ // Until a dump takes at least $minDuration seconds, perform a dump and check
+ // duration. If the dump did not take long enough increase the iteration
+ // count, to generate a bigger stub file next time.
+ while ( $lastDuration < $minDuration ) {
+ // Setting up the dump
+ wfRecursiveRemoveDir( $nameOutputDir );
+ $this->assertTrue( wfMkdirParents( $nameOutputDir ),
+ "Creating temporary output directory " );
+ $this->setUpStub( $nameStub, $iterations );
+ $dumper = new TextPassDumper();
+ $dumper->loadWithArgv( [ "--stub=file:" . $nameStub,
+ "--output=" . $checkpointFormat . ":" . $nameOutputDir . "/full",
+ "--maxtime=1" /*This is in minutes. Fixup is below*/,
+ "--buffersize=32768", // The default of 32 iterations fill up 32KB about twice
+ "--checkpointfile=checkpoint-%s-%s.xml.gz" ] );
+ $dumper->setDB( $this->db );
+ $dumper->maxTimeAllowed = $checkpointAfter; // Patching maxTime from 1 minute
+ $dumper->stderr = $stderr;
+
+ // The actual dump and taking time
+ $ts_before = microtime( true );
+ $dumper->execute();
+ $ts_after = microtime( true );
+ $lastDuration = $ts_after - $ts_before;
+
+ // Handling increasing the iteration count for the stubs
+ if ( $lastDuration < $minDuration ) {
+ $old_iterations = $iterations;
+ if ( $lastDuration > 0.2 ) {
+ // lastDuration is big enough, to allow an educated guess
+ $factor = ( $minDuration + 0.5 ) / $lastDuration;
+ if ( ( $factor > 1.1 ) && ( $factor < 100 ) ) {
+ // educated guess is reasonable
+ $iterations = (int)( $iterations * $factor );
+ }
+ }
+
+ if ( $old_iterations == $iterations ) {
+ // Heuristics were not applied, so we just *2.
+ $iterations *= 2;
+ }
+
+ $this->assertLessThan( 50000, $iterations,
+ "Emergency stop against infinitely increasing iteration "
+ . "count ( last duration: $lastDuration )" );
+ }
+ }
+
+ // The dump (hopefully) did take long enough to produce more than one
+ // checkpoint file.
+ // We now check all the checkpoint files for validity.
+
+ $files = scandir( $nameOutputDir );
+ $this->assertTrue( asort( $files ), "Sorting files in temporary directory" );
+ $fileOpened = false;
+ $lookingForPage = 1;
+ $checkpointFiles = 0;
+
+ // Each run of the following loop body tries to handle exactly 1 /page/ (not
+ // iteration of stub content). $i is only increased after having treated page 4.
+ for ( $i = 0; $i < $iterations; ) {
+ // 1. Assuring a file is opened and ready. Skipping across header if
+ // necessary.
+ if ( !$fileOpened ) {
+ $this->assertNotEmpty( $files, "No more existing dump files, "
+ . "but not yet all pages found" );
+ $fname = array_shift( $files );
+ while ( $fname == "." || $fname == ".." ) {
+ $this->assertNotEmpty( $files, "No more existing dump"
+ . " files, but not yet all pages found" );
+ $fname = array_shift( $files );
+ }
+ if ( $checkpointFormat == "gzip" ) {
+ $this->gunzip( $nameOutputDir . "/" . $fname );
+ }
+ $this->assertDumpStart( $nameOutputDir . "/" . $fname );
+ $fileOpened = true;
+ $checkpointFiles++;
+ }
+
+ // 2. Performing a single page check
+ switch ( $lookingForPage ) {
+ case 1:
+ // Page 1
+ $this->assertPageStart( $this->pageId1 + $i * self::$numOfPages, NS_MAIN,
+ "BackupDumperTestP1" );
+ $this->assertRevision( $this->revId1_1 + $i * self::$numOfRevs, "BackupDumperTestP1Summary1",
+ $this->textId1_1, false, "0bolhl6ol7i6x0e7yq91gxgaan39j87",
+ "BackupDumperTestP1Text1" );
+ $this->assertPageEnd();
+
+ $lookingForPage = 2;
+ break;
+
+ case 2:
+ // Page 2
+ $this->assertPageStart( $this->pageId2 + $i * self::$numOfPages, NS_MAIN,
+ "BackupDumperTestP2" );
+ $this->assertRevision( $this->revId2_1 + $i * self::$numOfRevs, "BackupDumperTestP2Summary1",
+ $this->textId2_1, false, "jprywrymfhysqllua29tj3sc7z39dl2",
+ "BackupDumperTestP2Text1" );
+ $this->assertRevision( $this->revId2_2 + $i * self::$numOfRevs, "BackupDumperTestP2Summary2",
+ $this->textId2_2, false, "b7vj5ks32po5m1z1t1br4o7scdwwy95",
+ "BackupDumperTestP2Text2", $this->revId2_1 + $i * self::$numOfRevs );
+ $this->assertRevision( $this->revId2_3 + $i * self::$numOfRevs, "BackupDumperTestP2Summary3",
+ $this->textId2_3, false, "jfunqmh1ssfb8rs43r19w98k28gg56r",
+ "BackupDumperTestP2Text3", $this->revId2_2 + $i * self::$numOfRevs );
+ $this->assertRevision( $this->revId2_4 + $i * self::$numOfRevs,
+ "BackupDumperTestP2Summary4 extra",
+ $this->textId2_4, false, "6o1ciaxa6pybnqprmungwofc4lv00wv",
+ "BackupDumperTestP2Text4 some additional Text",
+ $this->revId2_3 + $i * self::$numOfRevs );
+ $this->assertPageEnd();
+
+ $lookingForPage = 4;
+ break;
+
+ case 4:
+ // Page 4
+ $this->assertPageStart( $this->pageId4 + $i * self::$numOfPages, NS_TALK,
+ "Talk:BackupDumperTestP1" );
+ $this->assertRevision( $this->revId4_1 + $i * self::$numOfRevs,
+ "Talk BackupDumperTestP1 Summary1",
+ $this->textId4_1, false, "nktofwzd0tl192k3zfepmlzxoax1lpe",
+ "TALK ABOUT BACKUPDUMPERTESTP1 TEXT1",
+ false,
+ "BackupTextPassTestModel",
+ "text/plain" );
+ $this->assertPageEnd();
+
+ $lookingForPage = 1;
+
+ // We dealt with the whole iteration.
+ $i++;
+ break;
+
+ default:
+ $this->fail( "Bad setting for lookingForPage ($lookingForPage)" );
+ }
+
+ // 3. Checking for the end of the current checkpoint file
+ if ( $this->xml->nodeType == XMLReader::END_ELEMENT
+ && $this->xml->name == "mediawiki"
+ ) {
+ $this->assertDumpEnd();
+ $fileOpened = false;
+ }
+ }
+
+ // Assuring we completely read all files ...
+ $this->assertFalse( $fileOpened, "Currently read file still open?" );
+ $this->assertEmpty( $files, "Remaining unchecked files" );
+
+ // ... and have dealt with more than one checkpoint file
+ $this->assertGreaterThan(
+ 1,
+ $checkpointFiles,
+ "expected more than 1 checkpoint to have been created. "
+ . "Checkpoint interval is $checkpointAfter seconds, maybe your computer is too fast?"
+ );
+
+ $this->expectETAOutput();
+ }
+
+ /**
+ * Broken per T70653.
+ *
+ * @group large
+ * @group Broken
+ */
+ function testCheckpointPlain() {
+ $this->checkpointHelper();
+ }
+
+ /**
+ * tests for working checkpoint generation in gzip format work.
+ *
+ * We keep this test in addition to the simpler self::testCheckpointPlain, as there
+ * were once problems when the used sinks were DumpPipeOutputs.
+ *
+ * xmldumps-backup typically uses bzip2 instead of gzip. However, as bzip2 requires
+ * PHP extensions, we go for gzip instead, which triggers the same relevant code
+ * paths while still being testable on more systems.
+ *
+ * Broken per T70653.
+ *
+ * @group large
+ * @group Broken
+ */
+ function testCheckpointGzip() {
+ $this->checkHasGzip();
+ $this->checkpointHelper( "gzip" );
+ }
+
+ /**
+ * Creates a stub file that is used for testing the text pass of dumps
+ *
+ * @param string $fname (Optional) Absolute name of the file to write
+ * the stub into. If this parameter is null, a new temporary
+ * file is generated that is automatically removed upon tearDown.
+ * @param int $iterations (Optional) specifies how often the block
+ * of 3 pages should go into the stub file. The page and
+ * revision id increase further and further, while the text
+ * id of the first iteration is reused. The pages and revision
+ * of iteration > 1 have no corresponding representation in the database.
+ * @return string Absolute filename of the stub
+ */
+ private function setUpStub( $fname = null, $iterations = 1 ) {
+ if ( $fname === null ) {
+ $fname = $this->getNewTempFile();
+ }
+ $header = '<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.10/" '
+ . 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
+ . 'xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.10/ '
+ . 'http://www.mediawiki.org/xml/export-0.10.xsd" version="0.10" xml:lang="en">
+ <siteinfo>
+ <sitename>wikisvn</sitename>
+ <base>http://localhost/wiki-svn/index.php/Main_Page</base>
+ <generator>MediaWiki 1.21alpha</generator>
+ <case>first-letter</case>
+ <namespaces>
+ <namespace key="-2" case="first-letter">Media</namespace>
+ <namespace key="-1" case="first-letter">Special</namespace>
+ <namespace key="0" case="first-letter" />
+ <namespace key="1" case="first-letter">Talk</namespace>
+ <namespace key="2" case="first-letter">User</namespace>
+ <namespace key="3" case="first-letter">User talk</namespace>
+ <namespace key="4" case="first-letter">Wikisvn</namespace>
+ <namespace key="5" case="first-letter">Wikisvn talk</namespace>
+ <namespace key="6" case="first-letter">File</namespace>
+ <namespace key="7" case="first-letter">File talk</namespace>
+ <namespace key="8" case="first-letter">MediaWiki</namespace>
+ <namespace key="9" case="first-letter">MediaWiki talk</namespace>
+ <namespace key="10" case="first-letter">Template</namespace>
+ <namespace key="11" case="first-letter">Template talk</namespace>
+ <namespace key="12" case="first-letter">Help</namespace>
+ <namespace key="13" case="first-letter">Help talk</namespace>
+ <namespace key="14" case="first-letter">Category</namespace>
+ <namespace key="15" case="first-letter">Category talk</namespace>
+ </namespaces>
+ </siteinfo>
+';
+ $tail = '</mediawiki>
+';
+
+ $content = $header;
+ $iterations = intval( $iterations );
+ for ( $i = 0; $i < $iterations; $i++ ) {
+ $page1 = ' <page>
+ <title>BackupDumperTestP1</title>
+ <ns>0</ns>
+ <id>' . ( $this->pageId1 + $i * self::$numOfPages ) . '</id>
+ <revision>
+ <id>' . ( $this->revId1_1 + $i * self::$numOfRevs ) . '</id>
+ <timestamp>2012-04-01T16:46:05Z</timestamp>
+ <contributor>
+ <ip>127.0.0.1</ip>
+ </contributor>
+ <comment>BackupDumperTestP1Summary1</comment>
+ <model>wikitext</model>
+ <format>text/x-wiki</format>
+ <text id="' . $this->textId1_1 . '" bytes="23" />
+ <sha1>0bolhl6ol7i6x0e7yq91gxgaan39j87</sha1>
+ </revision>
+ </page>
+';
+ $page2 = ' <page>
+ <title>BackupDumperTestP2</title>
+ <ns>0</ns>
+ <id>' . ( $this->pageId2 + $i * self::$numOfPages ) . '</id>
+ <revision>
+ <id>' . ( $this->revId2_1 + $i * self::$numOfRevs ) . '</id>
+ <timestamp>2012-04-01T16:46:05Z</timestamp>
+ <contributor>
+ <ip>127.0.0.1</ip>
+ </contributor>
+ <comment>BackupDumperTestP2Summary1</comment>
+ <model>wikitext</model>
+ <format>text/x-wiki</format>
+ <text id="' . $this->textId2_1 . '" bytes="23" />
+ <sha1>jprywrymfhysqllua29tj3sc7z39dl2</sha1>
+ </revision>
+ <revision>
+ <id>' . ( $this->revId2_2 + $i * self::$numOfRevs ) . '</id>
+ <parentid>' . ( $this->revId2_1 + $i * self::$numOfRevs ) . '</parentid>
+ <timestamp>2012-04-01T16:46:05Z</timestamp>
+ <contributor>
+ <ip>127.0.0.1</ip>
+ </contributor>
+ <comment>BackupDumperTestP2Summary2</comment>
+ <model>wikitext</model>
+ <format>text/x-wiki</format>
+ <text id="' . $this->textId2_2 . '" bytes="23" />
+ <sha1>b7vj5ks32po5m1z1t1br4o7scdwwy95</sha1>
+ </revision>
+ <revision>
+ <id>' . ( $this->revId2_3 + $i * self::$numOfRevs ) . '</id>
+ <parentid>' . ( $this->revId2_2 + $i * self::$numOfRevs ) . '</parentid>
+ <timestamp>2012-04-01T16:46:05Z</timestamp>
+ <contributor>
+ <ip>127.0.0.1</ip>
+ </contributor>
+ <comment>BackupDumperTestP2Summary3</comment>
+ <model>wikitext</model>
+ <format>text/x-wiki</format>
+ <text id="' . $this->textId2_3 . '" bytes="23" />
+ <sha1>jfunqmh1ssfb8rs43r19w98k28gg56r</sha1>
+ </revision>
+ <revision>
+ <id>' . ( $this->revId2_4 + $i * self::$numOfRevs ) . '</id>
+ <parentid>' . ( $this->revId2_3 + $i * self::$numOfRevs ) . '</parentid>
+ <timestamp>2012-04-01T16:46:05Z</timestamp>
+ <contributor>
+ <ip>127.0.0.1</ip>
+ </contributor>
+ <comment>BackupDumperTestP2Summary4 extra</comment>
+ <model>wikitext</model>
+ <format>text/x-wiki</format>
+ <text id="' . $this->textId2_4 . '" bytes="44" />
+ <sha1>6o1ciaxa6pybnqprmungwofc4lv00wv</sha1>
+ </revision>
+ </page>
+';
+ // page 3 not in stub
+
+ $page4 = ' <page>
+ <title>Talk:BackupDumperTestP1</title>
+ <ns>1</ns>
+ <id>' . ( $this->pageId4 + $i * self::$numOfPages ) . '</id>
+ <revision>
+ <id>' . ( $this->revId4_1 + $i * self::$numOfRevs ) . '</id>
+ <timestamp>2012-04-01T16:46:05Z</timestamp>
+ <contributor>
+ <ip>127.0.0.1</ip>
+ </contributor>
+ <comment>Talk BackupDumperTestP1 Summary1</comment>
+ <model>BackupTextPassTestModel</model>
+ <format>text/plain</format>
+ <text id="' . $this->textId4_1 . '" bytes="35" />
+ <sha1>nktofwzd0tl192k3zfepmlzxoax1lpe</sha1>
+ </revision>
+ </page>
+';
+ $content .= $page1 . $page2 . $page4;
+ }
+ $content .= $tail;
+ $this->assertEquals( strlen( $content ), file_put_contents(
+ $fname, $content ), "Length of prepared stub" );
+
+ return $fname;
+ }
+}
+
+class BackupTextPassTestModelHandler extends TextContentHandler {
+
+ public function __construct() {
+ parent::__construct( 'BackupTextPassTestModel' );
+ }
+
+ public function exportTransform( $text, $format = null ) {
+ return strtoupper( $text );
+ }
+
+}
+
+/**
+ * Tests for TextPassDumper that do not rely on the database
+ *
+ * (As the Database group is only detected at class level (not method level), we
+ * cannot bring this test case's tests into the above main test case.)
+ *
+ * @group Dump
+ * @covers TextPassDumper
+ */
+class TextPassDumperDatabaselessTest extends MediaWikiLangTestCase {
+ /**
+ * Ensures that setting the buffer size is effective.
+ *
+ * @dataProvider bufferSizeProvider
+ */
+ function testBufferSizeSetting( $expected, $size, $msg ) {
+ $dumper = new TextPassDumperAccessor();
+ $dumper->loadWithArgv( [ "--buffersize=" . $size ] );
+ $dumper->execute();
+ $this->assertEquals( $expected, $dumper->getBufferSize(), $msg );
+ }
+
+ /**
+ * Ensures that setting the buffer size is effective.
+ *
+ * @dataProvider bufferSizeProvider
+ */
+ function bufferSizeProvider() {
+ // expected, bufferSize to initialize with, message
+ return [
+ [ 512 * 1024, 512 * 1024, "Setting 512KB is not effective" ],
+ [ 8192, 8192, "Setting 8KB is not effective" ],
+ [ 4096, 2048, "Could set buffer size below lower bound" ]
+ ];
+ }
+}
+
+/**
+ * Accessor for internal state of TextPassDumper
+ *
+ * Do not warrentless add getters here.
+ */
+class TextPassDumperAccessor extends TextPassDumper {
+ /**
+ * Gets the bufferSize.
+ *
+ * If bufferSize setting does not work correctly, testCheckpoint... tests
+ * fail and point in the wrong direction. To aid in troubleshooting when
+ * testCheckpoint... tests break at some point in the future, we test the
+ * bufferSize setting, hence need this accessor.
+ *
+ * (Yes, bufferSize is internal state of the TextPassDumper, but aiding
+ * debugging of testCheckpoint... in the future seems to be worth testing
+ * against it nonetheless.)
+ */
+ public function getBufferSize() {
+ return $this->bufferSize;
+ }
+
+ function dump( $history, $text = null ) {
+ return true;
+ }
+}
diff --git a/www/wiki/tests/phpunit/maintenance/backup_LogTest.php b/www/wiki/tests/phpunit/maintenance/backup_LogTest.php
new file mode 100644
index 00000000..c215b997
--- /dev/null
+++ b/www/wiki/tests/phpunit/maintenance/backup_LogTest.php
@@ -0,0 +1,241 @@
+<?php
+
+namespace MediaWiki\Tests\Maintenance;
+
+use DumpBackup;
+use ManualLogEntry;
+use Title;
+use User;
+use WikiExporter;
+
+/**
+ * Tests for log dumps of BackupDumper
+ *
+ * Some of these tests use the old constuctor for TextPassDumper
+ * and the dump() function, while others use the new loadWithArgv( $args )
+ * function and execute(). This is to ensure both the old and new methods
+ * work properly.
+ *
+ * @group Database
+ * @group Dump
+ * @covers BackupDumper
+ */
+class BackupDumperLoggerTest extends DumpTestCase {
+
+ // We'll add several log entries and users for this test. The following
+ // variables hold the corresponding ids.
+ private $userId1, $userId2;
+ private $logId1, $logId2, $logId3;
+
+ /**
+ * adds a log entry to the database.
+ *
+ * @param string $type Type of the log entry
+ * @param string $subtype Subtype of the log entry
+ * @param User $user User that performs the logged operation
+ * @param int $ns Number of the namespace for the entry's target's title
+ * @param string $title Title of the entry's target
+ * @param string $comment Comment of the log entry
+ * @param array $parameters (optional) accompanying data that is attached to the entry
+ *
+ * @return int Id of the added log entry
+ */
+ private function addLogEntry( $type, $subtype, User $user, $ns, $title,
+ $comment = null, $parameters = null
+ ) {
+ $logEntry = new ManualLogEntry( $type, $subtype );
+ $logEntry->setPerformer( $user );
+ $logEntry->setTarget( Title::newFromText( $title, $ns ) );
+ if ( $comment !== null ) {
+ $logEntry->setComment( $comment );
+ }
+ if ( $parameters !== null ) {
+ $logEntry->setParameters( $parameters );
+ }
+
+ return $logEntry->insert();
+ }
+
+ function addDBData() {
+ $this->tablesUsed[] = 'logging';
+ $this->tablesUsed[] = 'user';
+
+ try {
+ $user1 = User::newFromName( 'BackupDumperLogUserA' );
+ $this->userId1 = $user1->getId();
+ if ( $this->userId1 === 0 ) {
+ $user1->addToDatabase();
+ $this->userId1 = $user1->getId();
+ }
+ $this->assertGreaterThan( 0, $this->userId1 );
+
+ $user2 = User::newFromName( 'BackupDumperLogUserB' );
+ $this->userId2 = $user2->getId();
+ if ( $this->userId2 === 0 ) {
+ $user2->addToDatabase();
+ $this->userId2 = $user2->getId();
+ }
+ $this->assertGreaterThan( 0, $this->userId2 );
+
+ $this->logId1 = $this->addLogEntry( 'type', 'subtype',
+ $user1, NS_MAIN, "PageA" );
+ $this->assertGreaterThan( 0, $this->logId1 );
+
+ $this->logId2 = $this->addLogEntry( 'supress', 'delete',
+ $user2, NS_TALK, "PageB", "SomeComment" );
+ $this->assertGreaterThan( 0, $this->logId2 );
+
+ $this->logId3 = $this->addLogEntry( 'move', 'delete',
+ $user2, NS_MAIN, "PageA", "SomeOtherComment",
+ [ 'key1' => 1, 3 => 'value3' ] );
+ $this->assertGreaterThan( 0, $this->logId3 );
+ } catch ( Exception $e ) {
+ // We'd love to pass $e directly. However, ... see
+ // documentation of exceptionFromAddDBData in
+ // DumpTestCase
+ $this->exceptionFromAddDBData = $e;
+ }
+ }
+
+ /**
+ * asserts that the xml reader is at the beginning of a log entry and skips over
+ * it while analyzing it.
+ *
+ * @param int $id Id of the log entry
+ * @param string $user_name User name of the log entry's performer
+ * @param int $user_id User id of the log entry 's performer
+ * @param string|null $comment Comment of the log entry. If null, the comment text is ignored.
+ * @param string $type Type of the log entry
+ * @param string $subtype Subtype of the log entry
+ * @param string $title Title of the log entry's target
+ * @param array $parameters (optional) unserialized data accompanying the log entry
+ */
+ private function assertLogItem( $id, $user_name, $user_id, $comment, $type,
+ $subtype, $title, $parameters = []
+ ) {
+ $this->assertNodeStart( "logitem" );
+ $this->skipWhitespace();
+
+ $this->assertTextNode( "id", $id );
+ $this->assertTextNode( "timestamp", false );
+
+ $this->assertNodeStart( "contributor" );
+ $this->skipWhitespace();
+ $this->assertTextNode( "username", $user_name );
+ $this->assertTextNode( "id", $user_id );
+ $this->assertNodeEnd( "contributor" );
+ $this->skipWhitespace();
+
+ if ( $comment !== null ) {
+ $this->assertTextNode( "comment", $comment );
+ }
+ $this->assertTextNode( "type", $type );
+ $this->assertTextNode( "action", $subtype );
+ $this->assertTextNode( "logtitle", $title );
+
+ $this->assertNodeStart( "params" );
+ $parameters_xml = unserialize( $this->xml->value );
+ $this->assertEquals( $parameters, $parameters_xml );
+ $this->assertTrue( $this->xml->read(), "Skipping past processed text of params" );
+ $this->assertNodeEnd( "params" );
+ $this->skipWhitespace();
+
+ $this->assertNodeEnd( "logitem" );
+ $this->skipWhitespace();
+ }
+
+ function testPlain() {
+ global $wgContLang;
+
+ // Preparing the dump
+ $fname = $this->getNewTempFile();
+
+ $dumper = new DumpBackup( [ '--output=file:' . $fname ] );
+ $dumper->startId = $this->logId1;
+ $dumper->endId = $this->logId3 + 1;
+ $dumper->reporting = false;
+ $dumper->setDB( $this->db );
+
+ // Performing the dump
+ $dumper->dump( WikiExporter::LOGS, WikiExporter::TEXT );
+
+ // Analyzing the dumped data
+ $this->assertDumpStart( $fname );
+
+ $this->assertLogItem( $this->logId1, "BackupDumperLogUserA",
+ $this->userId1, null, "type", "subtype", "PageA" );
+
+ $this->assertNotNull( $wgContLang, "Content language object validation" );
+ $namespace = $wgContLang->getNsText( NS_TALK );
+ $this->assertInternalType( 'string', $namespace );
+ $this->assertGreaterThan( 0, strlen( $namespace ) );
+ $this->assertLogItem( $this->logId2, "BackupDumperLogUserB",
+ $this->userId2, "SomeComment", "supress", "delete",
+ $namespace . ":PageB" );
+
+ $this->assertLogItem( $this->logId3, "BackupDumperLogUserB",
+ $this->userId2, "SomeOtherComment", "move", "delete",
+ "PageA", [ 'key1' => 1, 3 => 'value3' ] );
+
+ $this->assertDumpEnd();
+ }
+
+ function testXmlDumpsBackupUseCaseLogging() {
+ global $wgContLang;
+
+ $this->checkHasGzip();
+
+ // Preparing the dump
+ $fname = $this->getNewTempFile();
+
+ $dumper = new DumpBackup();
+ $dumper->loadWithArgv( [ '--logs', '--output=gzip:' . $fname,
+ '--reporting=2' ] );
+ $dumper->startId = $this->logId1;
+ $dumper->endId = $this->logId3 + 1;
+ $dumper->setDB( $this->db );
+
+ // xmldumps-backup demands reporting, although this is currently not
+ // implemented in BackupDumper, when dumping logging data. We
+ // nevertheless capture the output of the dump process already now,
+ // to be able to alert (once dumping produces reports) that this test
+ // needs updates.
+ $dumper->stderr = fopen( 'php://output', 'a' );
+ if ( $dumper->stderr === false ) {
+ $this->fail( "Could not open stream for stderr" );
+ }
+
+ // Performing the dump
+ $dumper->execute();
+
+ $this->assertTrue( fclose( $dumper->stderr ), "Closing stderr handle" );
+
+ // Analyzing the dumped data
+ $this->gunzip( $fname );
+
+ $this->assertDumpStart( $fname );
+
+ $this->assertLogItem( $this->logId1, "BackupDumperLogUserA",
+ $this->userId1, null, "type", "subtype", "PageA" );
+
+ $this->assertNotNull( $wgContLang, "Content language object validation" );
+ $namespace = $wgContLang->getNsText( NS_TALK );
+ $this->assertInternalType( 'string', $namespace );
+ $this->assertGreaterThan( 0, strlen( $namespace ) );
+ $this->assertLogItem( $this->logId2, "BackupDumperLogUserB",
+ $this->userId2, "SomeComment", "supress", "delete",
+ $namespace . ":PageB" );
+
+ $this->assertLogItem( $this->logId3, "BackupDumperLogUserB",
+ $this->userId2, "SomeOtherComment", "move", "delete",
+ "PageA", [ 'key1' => 1, 3 => 'value3' ] );
+
+ $this->assertDumpEnd();
+
+ // Currently, no reporting is implemented. Alert via failure, once
+ // this changes.
+ // If reporting for log dumps has been implemented, please update
+ // the following statement to catch good output
+ $this->expectOutputString( '' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/maintenance/backup_PageTest.php b/www/wiki/tests/phpunit/maintenance/backup_PageTest.php
new file mode 100644
index 00000000..51a1ed69
--- /dev/null
+++ b/www/wiki/tests/phpunit/maintenance/backup_PageTest.php
@@ -0,0 +1,443 @@
+<?php
+
+namespace MediaWiki\Tests\Maintenance;
+
+use DumpBackup;
+use Language;
+use Title;
+use WikiExporter;
+use WikiPage;
+
+/**
+ * Tests for page dumps of BackupDumper
+ *
+ * @group Database
+ * @group Dump
+ * @covers BackupDumper
+ */
+class BackupDumperPageTest extends DumpTestCase {
+
+ // We'll add several pages, revision and texts. The following variables hold the
+ // corresponding ids.
+ private $pageId1, $pageId2, $pageId3, $pageId4;
+ private $pageTitle1, $pageTitle2, $pageTitle3, $pageTitle4;
+ private $revId1_1, $textId1_1;
+ private $revId2_1, $textId2_1, $revId2_2, $textId2_2;
+ private $revId2_3, $textId2_3, $revId2_4, $textId2_4;
+ private $revId3_1, $textId3_1, $revId3_2, $textId3_2;
+ private $revId4_1, $textId4_1;
+ private $namespace, $talk_namespace;
+
+ function addDBData() {
+ // be sure, titles created here using english namespace names
+ $this->setMwGlobals( [
+ 'wgLanguageCode' => 'en',
+ 'wgContLang' => Language::factory( 'en' ),
+ ] );
+
+ $this->tablesUsed[] = 'page';
+ $this->tablesUsed[] = 'revision';
+ $this->tablesUsed[] = 'ip_changes';
+ $this->tablesUsed[] = 'text';
+
+ try {
+ $this->namespace = $this->getDefaultWikitextNS();
+ $this->talk_namespace = NS_TALK;
+
+ if ( $this->namespace === $this->talk_namespace ) {
+ // @todo work around this.
+ throw new MWException( "The default wikitext namespace is the talk namespace. "
+ . " We can't currently deal with that." );
+ }
+
+ $this->pageTitle1 = Title::newFromText( 'BackupDumperTestP1', $this->namespace );
+ $page = WikiPage::factory( $this->pageTitle1 );
+ list( $this->revId1_1, $this->textId1_1 ) = $this->addRevision( $page,
+ "BackupDumperTestP1Text1", "BackupDumperTestP1Summary1" );
+ $this->pageId1 = $page->getId();
+
+ $this->pageTitle2 = Title::newFromText( 'BackupDumperTestP2', $this->namespace );
+ $page = WikiPage::factory( $this->pageTitle2 );
+ list( $this->revId2_1, $this->textId2_1 ) = $this->addRevision( $page,
+ "BackupDumperTestP2Text1", "BackupDumperTestP2Summary1" );
+ list( $this->revId2_2, $this->textId2_2 ) = $this->addRevision( $page,
+ "BackupDumperTestP2Text2", "BackupDumperTestP2Summary2" );
+ list( $this->revId2_3, $this->textId2_3 ) = $this->addRevision( $page,
+ "BackupDumperTestP2Text3", "BackupDumperTestP2Summary3" );
+ list( $this->revId2_4, $this->textId2_4 ) = $this->addRevision( $page,
+ "BackupDumperTestP2Text4 some additional Text ",
+ "BackupDumperTestP2Summary4 extra " );
+ $this->pageId2 = $page->getId();
+
+ $this->pageTitle3 = Title::newFromText( 'BackupDumperTestP3', $this->namespace );
+ $page = WikiPage::factory( $this->pageTitle3 );
+ list( $this->revId3_1, $this->textId3_1 ) = $this->addRevision( $page,
+ "BackupDumperTestP3Text1", "BackupDumperTestP2Summary1" );
+ list( $this->revId3_2, $this->textId3_2 ) = $this->addRevision( $page,
+ "BackupDumperTestP3Text2", "BackupDumperTestP2Summary2" );
+ $this->pageId3 = $page->getId();
+ $page->doDeleteArticle( "Testing ;)" );
+
+ $this->pageTitle4 = Title::newFromText( 'BackupDumperTestP1', $this->talk_namespace );
+ $page = WikiPage::factory( $this->pageTitle4 );
+ list( $this->revId4_1, $this->textId4_1 ) = $this->addRevision( $page,
+ "Talk about BackupDumperTestP1 Text1",
+ "Talk BackupDumperTestP1 Summary1" );
+ $this->pageId4 = $page->getId();
+ } catch ( Exception $e ) {
+ // We'd love to pass $e directly. However, ... see
+ // documentation of exceptionFromAddDBData in
+ // DumpTestCase
+ $this->exceptionFromAddDBData = $e;
+ }
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ // Since we will restrict dumping by page ranges (to allow
+ // working tests, even if the db gets prepopulated by a base
+ // class), we have to assert, that the page id are consecutively
+ // increasing
+ $this->assertEquals(
+ [ $this->pageId2, $this->pageId3, $this->pageId4 ],
+ [ $this->pageId1 + 1, $this->pageId2 + 1, $this->pageId3 + 1 ],
+ "Page ids increasing without holes" );
+ }
+
+ function testFullTextPlain() {
+ // Preparing the dump
+ $fname = $this->getNewTempFile();
+
+ $dumper = new DumpBackup();
+ $dumper->loadWithArgv( [ '--full', '--quiet', '--output', 'file:' . $fname ] );
+ $dumper->startId = $this->pageId1;
+ $dumper->endId = $this->pageId4 + 1;
+ $dumper->setDB( $this->db );
+
+ // Performing the dump
+ $dumper->execute();
+
+ // Checking the dumped data
+ $this->assertDumpStart( $fname );
+
+ // Page 1
+ $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() );
+ $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1",
+ $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87",
+ "BackupDumperTestP1Text1" );
+ $this->assertPageEnd();
+
+ // Page 2
+ $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() );
+ $this->assertRevision( $this->revId2_1, "BackupDumperTestP2Summary1",
+ $this->textId2_1, 23, "jprywrymfhysqllua29tj3sc7z39dl2",
+ "BackupDumperTestP2Text1" );
+ $this->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2",
+ $this->textId2_2, 23, "b7vj5ks32po5m1z1t1br4o7scdwwy95",
+ "BackupDumperTestP2Text2", $this->revId2_1 );
+ $this->assertRevision( $this->revId2_3, "BackupDumperTestP2Summary3",
+ $this->textId2_3, 23, "jfunqmh1ssfb8rs43r19w98k28gg56r",
+ "BackupDumperTestP2Text3", $this->revId2_2 );
+ $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra",
+ $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv",
+ "BackupDumperTestP2Text4 some additional Text", $this->revId2_3 );
+ $this->assertPageEnd();
+
+ // Page 3
+ // -> Page is marked deleted. Hence not visible
+
+ // Page 4
+ $this->assertPageStart(
+ $this->pageId4,
+ $this->talk_namespace,
+ $this->pageTitle4->getPrefixedText()
+ );
+ $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1",
+ $this->textId4_1, 35, "nktofwzd0tl192k3zfepmlzxoax1lpe",
+ "Talk about BackupDumperTestP1 Text1" );
+ $this->assertPageEnd();
+
+ $this->assertDumpEnd();
+ }
+
+ function testFullStubPlain() {
+ // Preparing the dump
+ $fname = $this->getNewTempFile();
+
+ $dumper = new DumpBackup();
+ $dumper->loadWithArgv( [ '--full', '--quiet', '--output', 'file:' . $fname, '--stub' ] );
+ $dumper->startId = $this->pageId1;
+ $dumper->endId = $this->pageId4 + 1;
+ $dumper->setDB( $this->db );
+
+ // Performing the dump
+ $dumper->execute();
+
+ // Checking the dumped data
+ $this->assertDumpStart( $fname );
+
+ // Page 1
+ $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() );
+ $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1",
+ $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87" );
+ $this->assertPageEnd();
+
+ // Page 2
+ $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() );
+ $this->assertRevision( $this->revId2_1, "BackupDumperTestP2Summary1",
+ $this->textId2_1, 23, "jprywrymfhysqllua29tj3sc7z39dl2" );
+ $this->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2",
+ $this->textId2_2, 23, "b7vj5ks32po5m1z1t1br4o7scdwwy95", false, $this->revId2_1 );
+ $this->assertRevision( $this->revId2_3, "BackupDumperTestP2Summary3",
+ $this->textId2_3, 23, "jfunqmh1ssfb8rs43r19w98k28gg56r", false, $this->revId2_2 );
+ $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra",
+ $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", false, $this->revId2_3 );
+ $this->assertPageEnd();
+
+ // Page 3
+ // -> Page is marked deleted. Hence not visible
+
+ // Page 4
+ $this->assertPageStart(
+ $this->pageId4,
+ $this->talk_namespace,
+ $this->pageTitle4->getPrefixedText()
+ );
+ $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1",
+ $this->textId4_1, 35, "nktofwzd0tl192k3zfepmlzxoax1lpe" );
+ $this->assertPageEnd();
+
+ $this->assertDumpEnd();
+ }
+
+ function testCurrentStubPlain() {
+ // Preparing the dump
+ $fname = $this->getNewTempFile();
+
+ $dumper = new DumpBackup( [ '--output', 'file:' . $fname ] );
+ $dumper->startId = $this->pageId1;
+ $dumper->endId = $this->pageId4 + 1;
+ $dumper->reporting = false;
+ $dumper->setDB( $this->db );
+
+ // Performing the dump
+ $dumper->dump( WikiExporter::CURRENT, WikiExporter::STUB );
+
+ // Checking the dumped data
+ $this->assertDumpStart( $fname );
+
+ // Page 1
+ $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() );
+ $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1",
+ $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87" );
+ $this->assertPageEnd();
+
+ // Page 2
+ $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() );
+ $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra",
+ $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", false, $this->revId2_3 );
+ $this->assertPageEnd();
+
+ // Page 3
+ // -> Page is marked deleted. Hence not visible
+
+ // Page 4
+ $this->assertPageStart(
+ $this->pageId4,
+ $this->talk_namespace,
+ $this->pageTitle4->getPrefixedText()
+ );
+ $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1",
+ $this->textId4_1, 35, "nktofwzd0tl192k3zfepmlzxoax1lpe" );
+ $this->assertPageEnd();
+
+ $this->assertDumpEnd();
+ }
+
+ function testCurrentStubGzip() {
+ $this->checkHasGzip();
+
+ // Preparing the dump
+ $fname = $this->getNewTempFile();
+
+ $dumper = new DumpBackup( [ '--output', 'gzip:' . $fname ] );
+ $dumper->startId = $this->pageId1;
+ $dumper->endId = $this->pageId4 + 1;
+ $dumper->reporting = false;
+ $dumper->setDB( $this->db );
+
+ // Performing the dump
+ $dumper->dump( WikiExporter::CURRENT, WikiExporter::STUB );
+
+ // Checking the dumped data
+ $this->gunzip( $fname );
+ $this->assertDumpStart( $fname );
+
+ // Page 1
+ $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() );
+ $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1",
+ $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87" );
+ $this->assertPageEnd();
+
+ // Page 2
+ $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() );
+ $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra",
+ $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", false, $this->revId2_3 );
+ $this->assertPageEnd();
+
+ // Page 3
+ // -> Page is marked deleted. Hence not visible
+
+ // Page 4
+ $this->assertPageStart(
+ $this->pageId4,
+ $this->talk_namespace,
+ $this->pageTitle4->getPrefixedText()
+ );
+ $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1",
+ $this->textId4_1, 35, "nktofwzd0tl192k3zfepmlzxoax1lpe" );
+ $this->assertPageEnd();
+
+ $this->assertDumpEnd();
+ }
+
+ /**
+ * xmldumps-backup typically performs a single dump that that writes
+ * out three files
+ * - gzipped stubs of everything (meta-history)
+ * - gzipped stubs of latest revisions of all pages (meta-current)
+ * - gzipped stubs of latest revisions of all pages of namespage 0
+ * (articles)
+ *
+ * We reproduce such a setup with our mini fixture, although we omit
+ * chunks, and all the other gimmicks of xmldumps-backup.
+ */
+ function testXmlDumpsBackupUseCase() {
+ $this->checkHasGzip();
+
+ $fnameMetaHistory = $this->getNewTempFile();
+ $fnameMetaCurrent = $this->getNewTempFile();
+ $fnameArticles = $this->getNewTempFile();
+
+ $dumper = new DumpBackup( [ "--full", "--stub", "--output=gzip:" . $fnameMetaHistory,
+ "--output=gzip:" . $fnameMetaCurrent, "--filter=latest",
+ "--output=gzip:" . $fnameArticles, "--filter=latest",
+ "--filter=notalk", "--filter=namespace:!NS_USER",
+ "--reporting=1000" ] );
+ $dumper->startId = $this->pageId1;
+ $dumper->endId = $this->pageId4 + 1;
+ $dumper->setDB( $this->db );
+
+ // xmldumps-backup uses reporting. We will not check the exact reported
+ // message, as they are dependent on the processing power of the used
+ // computer. We only check that reporting does not crash the dumping
+ // and that something is reported
+ $dumper->stderr = fopen( 'php://output', 'a' );
+ if ( $dumper->stderr === false ) {
+ $this->fail( "Could not open stream for stderr" );
+ }
+
+ // Performing the dump
+ $dumper->dump( WikiExporter::FULL, WikiExporter::STUB );
+
+ $this->assertTrue( fclose( $dumper->stderr ), "Closing stderr handle" );
+
+ // Checking meta-history -------------------------------------------------
+
+ $this->gunzip( $fnameMetaHistory );
+ $this->assertDumpStart( $fnameMetaHistory );
+
+ // Page 1
+ $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() );
+ $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1",
+ $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87" );
+ $this->assertPageEnd();
+
+ // Page 2
+ $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() );
+ $this->assertRevision( $this->revId2_1, "BackupDumperTestP2Summary1",
+ $this->textId2_1, 23, "jprywrymfhysqllua29tj3sc7z39dl2" );
+ $this->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2",
+ $this->textId2_2, 23, "b7vj5ks32po5m1z1t1br4o7scdwwy95", false, $this->revId2_1 );
+ $this->assertRevision( $this->revId2_3, "BackupDumperTestP2Summary3",
+ $this->textId2_3, 23, "jfunqmh1ssfb8rs43r19w98k28gg56r", false, $this->revId2_2 );
+ $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra",
+ $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", false, $this->revId2_3 );
+ $this->assertPageEnd();
+
+ // Page 3
+ // -> Page is marked deleted. Hence not visible
+
+ // Page 4
+ $this->assertPageStart(
+ $this->pageId4,
+ $this->talk_namespace,
+ $this->pageTitle4->getPrefixedText()
+ );
+ $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1",
+ $this->textId4_1, 35, "nktofwzd0tl192k3zfepmlzxoax1lpe" );
+ $this->assertPageEnd();
+
+ $this->assertDumpEnd();
+
+ // Checking meta-current -------------------------------------------------
+
+ $this->gunzip( $fnameMetaCurrent );
+ $this->assertDumpStart( $fnameMetaCurrent );
+
+ // Page 1
+ $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() );
+ $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1",
+ $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87" );
+ $this->assertPageEnd();
+
+ // Page 2
+ $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() );
+ $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra",
+ $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", false, $this->revId2_3 );
+ $this->assertPageEnd();
+
+ // Page 3
+ // -> Page is marked deleted. Hence not visible
+
+ // Page 4
+ $this->assertPageStart(
+ $this->pageId4,
+ $this->talk_namespace,
+ $this->pageTitle4->getPrefixedText()
+ );
+ $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1",
+ $this->textId4_1, 35, "nktofwzd0tl192k3zfepmlzxoax1lpe" );
+ $this->assertPageEnd();
+
+ $this->assertDumpEnd();
+
+ // Checking articles -------------------------------------------------
+
+ $this->gunzip( $fnameArticles );
+ $this->assertDumpStart( $fnameArticles );
+
+ // Page 1
+ $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() );
+ $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1",
+ $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87" );
+ $this->assertPageEnd();
+
+ // Page 2
+ $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() );
+ $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra",
+ $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", false, $this->revId2_3 );
+ $this->assertPageEnd();
+
+ // Page 3
+ // -> Page is marked deleted. Hence not visible
+
+ // Page 4
+ // -> Page is not in $this->namespace. Hence not visible
+
+ $this->assertDumpEnd();
+
+ $this->expectETAOutput();
+ }
+}
diff --git a/www/wiki/tests/phpunit/maintenance/categoriesRdfTest.php b/www/wiki/tests/phpunit/maintenance/categoriesRdfTest.php
new file mode 100644
index 00000000..5068e701
--- /dev/null
+++ b/www/wiki/tests/phpunit/maintenance/categoriesRdfTest.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace MediaWiki\Tests\Maintenance;
+
+use DumpCategoriesAsRdf;
+use MediaWikiLangTestCase;
+
+/**
+ * @covers CategoriesRdf
+ * @covers DumpCategoriesAsRdf
+ */
+class CategoriesRdfTest extends MediaWikiLangTestCase {
+ public function getCategoryIterator() {
+ return [
+ // batch 1
+ [
+ (object)[
+ 'page_title' => 'Category One',
+ 'page_id' => 1,
+ 'pp_propname' => null,
+ 'cat_pages' => '20',
+ 'cat_subcats' => '10',
+ 'cat_files' => '3'
+ ],
+ (object)[
+ 'page_title' => '2 Category Two',
+ 'page_id' => 2,
+ 'pp_propname' => 'hiddencat',
+ 'cat_pages' => 20,
+ 'cat_subcats' => 0,
+ 'cat_files' => 3
+ ],
+ ],
+ // batch 2
+ [
+ (object)[
+ 'page_title' => 'Третья категория',
+ 'page_id' => 3,
+ 'pp_propname' => null,
+ 'cat_pages' => '0',
+ 'cat_subcats' => '0',
+ 'cat_files' => '0'
+ ],
+ ]
+ ];
+ }
+
+ public function getCategoryLinksIterator( $dbr, array $ids ) {
+ $res = [];
+ foreach ( $ids as $pageid ) {
+ $res[] = (object)[ 'cl_from' => $pageid, 'cl_to' => "Parent of $pageid" ];
+ }
+ return $res;
+ }
+
+ public function testCategoriesDump() {
+ $this->setMwGlobals( [
+ 'wgServer' => 'http://acme.test',
+ 'wgCanonicalServer' => 'http://acme.test',
+ 'wgArticlePath' => '/wiki/$1',
+ 'wgRightsUrl' => '//creativecommons.org/licenses/by-sa/3.0/',
+ ] );
+
+ $dumpScript =
+ $this->getMockBuilder( DumpCategoriesAsRdf::class )
+ ->setMethods( [ 'getCategoryIterator', 'getCategoryLinksIterator' ] )
+ ->getMock();
+
+ $dumpScript->expects( $this->once() )
+ ->method( 'getCategoryIterator' )
+ ->willReturn( $this->getCategoryIterator() );
+
+ $dumpScript->expects( $this->any() )
+ ->method( 'getCategoryLinksIterator' )
+ ->willReturnCallback( [ $this, 'getCategoryLinksIterator' ] );
+
+ /** @var DumpCategoriesAsRdf $dumpScript */
+ $logFileName = tempnam( sys_get_temp_dir(), "Categories-DumpRdfTest" );
+ $outFileName = tempnam( sys_get_temp_dir(), "Categories-DumpRdfTest" );
+
+ $dumpScript->loadParamsAndArgs(
+ null,
+ [
+ 'log' => $logFileName,
+ 'output' => $outFileName,
+ 'format' => 'nt',
+ ]
+ );
+
+ $dumpScript->execute();
+ $actualOut = file_get_contents( $outFileName );
+ $actualOut = preg_replace(
+ '|<http://acme.test/wiki/Special:CategoryDump> <http://schema.org/dateModified> "[^"]+?"|',
+ '<http://acme.test/wiki/Special:CategoryDump> <http://schema.org/dateModified> "{DATE}"',
+ $actualOut
+ );
+
+ $outFile = __DIR__ . '/../data/categoriesrdf/categoriesRdf-out.nt';
+ $this->assertFileContains( $outFile, $actualOut );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/maintenance/deleteAutoPatrolLogsTest.php b/www/wiki/tests/phpunit/maintenance/deleteAutoPatrolLogsTest.php
new file mode 100644
index 00000000..c1418174
--- /dev/null
+++ b/www/wiki/tests/phpunit/maintenance/deleteAutoPatrolLogsTest.php
@@ -0,0 +1,252 @@
+<?php
+
+namespace MediaWiki\Tests\Maintenance;
+
+use DeleteAutoPatrolLogs;
+
+/**
+ * @group Database
+ * @covers DeleteAutoPatrolLogs
+ */
+class DeleteAutoPatrolLogsTest extends MaintenanceBaseTestCase {
+
+ public function getMaintenanceClass() {
+ return DeleteAutoPatrolLogs::class;
+ }
+
+ public function setUp() {
+ parent::setUp();
+ $this->tablesUsed = [ 'logging' ];
+
+ $this->cleanLoggingTable();
+ $this->insertLoggingData();
+ }
+
+ private function cleanLoggingTable() {
+ wfGetDB( DB_MASTER )->delete( 'logging', '*' );
+ }
+
+ private function insertLoggingData() {
+ $logs = [];
+
+ // Manual patrolling
+ $logs[] = [
+ 'log_type' => 'patrol',
+ 'log_action' => 'patrol',
+ 'log_user' => 7251,
+ 'log_params' => '',
+ 'log_timestamp' => 20041223210426
+ ];
+
+ // Autopatrol #1
+ $logs[] = [
+ 'log_type' => 'patrol',
+ 'log_action' => 'autopatrol',
+ 'log_user' => 7252,
+ 'log_params' => '',
+ 'log_timestamp' => 20051223210426
+ ];
+
+ // Block
+ $logs[] = [
+ 'log_type' => 'block',
+ 'log_action' => 'block',
+ 'log_user' => 7253,
+ 'log_params' => '',
+ 'log_timestamp' => 20061223210426
+ ];
+
+ // Very old/ invalid patrol
+ $logs[] = [
+ 'log_type' => 'patrol',
+ 'log_action' => 'patrol',
+ 'log_user' => 7253,
+ 'log_params' => 'nanana',
+ 'log_timestamp' => 20061223210426
+ ];
+
+ // Autopatrol #2
+ $logs[] = [
+ 'log_type' => 'patrol',
+ 'log_action' => 'autopatrol',
+ 'log_user' => 7254,
+ 'log_params' => '',
+ 'log_timestamp' => 20071223210426
+ ];
+
+ // Autopatrol #3 old way
+ $logs[] = [
+ 'log_type' => 'patrol',
+ 'log_action' => 'patrol',
+ 'log_user' => 7255,
+ 'log_params' => serialize( [ '6::auto' => true ] ),
+ 'log_timestamp' => 20081223210426
+ ];
+
+ // Manual patrol #2 old way
+ $logs[] = [
+ 'log_type' => 'patrol',
+ 'log_action' => 'patrol',
+ 'log_user' => 7256,
+ 'log_params' => serialize( [ '6::auto' => false ] ),
+ 'log_timestamp' => 20091223210426
+ ];
+
+ wfGetDB( DB_MASTER )->insert( 'logging', $logs );
+ }
+
+ public function runProvider() {
+ $allRows = [
+ (object)[
+ 'log_type' => 'patrol',
+ 'log_action' => 'patrol',
+ 'log_user' => '7251',
+ ],
+ (object)[
+ 'log_type' => 'patrol',
+ 'log_action' => 'autopatrol',
+ 'log_user' => '7252',
+ ],
+ (object)[
+ 'log_type' => 'block',
+ 'log_action' => 'block',
+ 'log_user' => '7253',
+ ],
+ (object)[
+ 'log_type' => 'patrol',
+ 'log_action' => 'patrol',
+ 'log_user' => '7253',
+ ],
+ (object)[
+ 'log_type' => 'patrol',
+ 'log_action' => 'autopatrol',
+ 'log_user' => '7254',
+ ],
+ (object)[
+ 'log_type' => 'patrol',
+ 'log_action' => 'patrol',
+ 'log_user' => '7255',
+ ],
+ (object)[
+ 'log_type' => 'patrol',
+ 'log_action' => 'patrol',
+ 'log_user' => '7256',
+ ],
+ ];
+
+ $cases = [
+ 'dry run' => [
+ $allRows,
+ [ '--sleep', '0', '--dry-run', '-q' ]
+ ],
+ 'basic run' => [
+ [
+ $allRows[0],
+ $allRows[2],
+ $allRows[3],
+ $allRows[5],
+ $allRows[6],
+ ],
+ [ '--sleep', '0', '-q' ]
+ ],
+ 'run with before' => [
+ [
+ $allRows[0],
+ $allRows[2],
+ $allRows[3],
+ $allRows[4],
+ $allRows[5],
+ $allRows[6],
+ ],
+ [ '--sleep', '0', '--before', '20060123210426', '-q' ]
+ ],
+ 'run with check-old' => [
+ [
+ $allRows[0],
+ $allRows[1],
+ $allRows[2],
+ $allRows[3],
+ $allRows[4],
+ $allRows[6],
+ ],
+ [ '--sleep', '0', '--check-old', '-q' ]
+ ],
+ ];
+
+ foreach ( $cases as $key => $case ) {
+ yield $key . '-batch-size-1' => [
+ $case[0],
+ array_merge( $case[1], [ '--batch-size', '1' ] )
+ ];
+ yield $key . '-batch-size-5' => [
+ $case[0],
+ array_merge( $case[1], [ '--batch-size', '5' ] )
+ ];
+ yield $key . '-batch-size-1000' => [
+ $case[0],
+ array_merge( $case[1], [ '--batch-size', '1000' ] )
+ ];
+ }
+ }
+
+ /**
+ * @dataProvider runProvider
+ */
+ public function testRun( $expected, $args ) {
+ $this->maintenance->loadWithArgv( $args );
+
+ $this->maintenance->execute();
+
+ $remainingLogs = wfGetDB( DB_REPLICA )->select(
+ [ 'logging' ],
+ [ 'log_type', 'log_action', 'log_user' ],
+ [],
+ __METHOD__,
+ [ 'ORDER BY' => 'log_id' ]
+ );
+
+ $this->assertEquals( $expected, iterator_to_array( $remainingLogs, false ) );
+ }
+
+ public function testFromId() {
+ $fromId = wfGetDB( DB_REPLICA )->selectField(
+ 'logging',
+ 'log_id',
+ [ 'log_params' => 'nanana' ]
+ );
+
+ $this->maintenance->loadWithArgv( [ '--sleep', '0', '--from-id', strval( $fromId ), '-q' ] );
+
+ $this->maintenance->execute();
+
+ $remainingLogs = wfGetDB( DB_REPLICA )->select(
+ [ 'logging' ],
+ [ 'log_type', 'log_action', 'log_user' ],
+ [],
+ __METHOD__,
+ [ 'ORDER BY' => 'log_id' ]
+ );
+
+ $deleted = [
+ 'log_type' => 'patrol',
+ 'log_action' => 'autopatrol',
+ 'log_user' => '7254',
+ ];
+ $notDeleted = [
+ 'log_type' => 'patrol',
+ 'log_action' => 'autopatrol',
+ 'log_user' => '7252',
+ ];
+
+ $remainingLogs = array_map(
+ function ( $val ) {
+ return (array)$val;
+ },
+ iterator_to_array( $remainingLogs, false )
+ );
+
+ $this->assertNotContains( $deleted, $remainingLogs );
+ $this->assertContains( $notDeleted, $remainingLogs );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/maintenance/fetchTextTest.php b/www/wiki/tests/phpunit/maintenance/fetchTextTest.php
new file mode 100644
index 00000000..97e0c88f
--- /dev/null
+++ b/www/wiki/tests/phpunit/maintenance/fetchTextTest.php
@@ -0,0 +1,272 @@
+<?php
+
+namespace MediaWiki\Tests\Maintenance;
+
+use ContentHandler;
+use FetchText;
+use MediaWikiTestCase;
+use MWException;
+use Title;
+use PHPUnit_Framework_ExpectationFailedException;
+use WikiPage;
+
+require_once __DIR__ . "/../../../maintenance/fetchText.php";
+
+/**
+ * Mock for the input/output of FetchText
+ *
+ * FetchText internally tries to access stdin and stdout. We mock those aspects
+ * for testing.
+ */
+class SemiMockedFetchText extends FetchText {
+
+ /**
+ * @var string|null Text to pass as stdin
+ */
+ private $mockStdinText = null;
+
+ /**
+ * @var bool Whether or not a text for stdin has been provided
+ */
+ private $mockSetUp = false;
+
+ /**
+ * @var array Invocation counters for the mocked aspects
+ */
+ private $mockInvocations = [ 'getStdin' => 0 ];
+
+ /**
+ * Data for the fake stdin
+ *
+ * @param string $stdin The string to be used instead of stdin
+ */
+ function mockStdin( $stdin ) {
+ $this->mockStdinText = $stdin;
+ $this->mockSetUp = true;
+ }
+
+ /**
+ * Gets invocation counters for mocked methods.
+ *
+ * @return array An array, whose keys are function names. The corresponding values
+ * denote the number of times the function has been invoked.
+ */
+ function mockGetInvocations() {
+ return $this->mockInvocations;
+ }
+
+ // -----------------------------------------------------------------
+ // Mocked functions from FetchText follow.
+
+ function getStdin( $len = null ) {
+ $this->mockInvocations['getStdin']++;
+ if ( $len !== null ) {
+ throw new PHPUnit_Framework_ExpectationFailedException(
+ "Tried to get stdin with non null parameter" );
+ }
+
+ if ( !$this->mockSetUp ) {
+ throw new PHPUnit_Framework_ExpectationFailedException(
+ "Tried to get stdin before setting up rerouting" );
+ }
+
+ return fopen( 'data://text/plain,' . $this->mockStdinText, 'r' );
+ }
+}
+
+/**
+ * TestCase for FetchText
+ *
+ * @group Database
+ * @group Dump
+ * @covers FetchText
+ */
+class FetchTextTest extends MediaWikiTestCase {
+
+ // We add 5 Revisions for this test. Their corresponding text id's
+ // are stored in the following 5 variables.
+ protected static $textId1;
+ protected static $textId2;
+ protected static $textId3;
+ protected static $textId4;
+ protected static $textId5;
+
+ /**
+ * @var Exception|null As the current MediaWikiTestCase::run is not
+ * robust enough to recover from thrown exceptions directly, we cannot
+ * throw frow within addDBData, although it would be appropriate. Hence,
+ * we catch the exception and store it until we are in setUp and may
+ * finally rethrow the exception without crashing the test suite.
+ */
+ protected static $exceptionFromAddDBDataOnce;
+
+ /**
+ * @var FetchText The (mocked) FetchText that is to test
+ */
+ private $fetchText;
+
+ /**
+ * Adds a revision to a page, while returning the resuting text's id
+ *
+ * @param WikiPage $page The page to add the revision to
+ * @param string $text The revisions text
+ * @param string $summary The revisions summare
+ * @return int
+ * @throws MWException
+ */
+ private function addRevision( $page, $text, $summary ) {
+ $status = $page->doEditContent(
+ ContentHandler::makeContent( $text, $page->getTitle() ),
+ $summary
+ );
+
+ if ( $status->isGood() ) {
+ $value = $status->getValue();
+ $revision = $value['revision'];
+ $id = $revision->getTextId();
+
+ if ( $id > 0 ) {
+ return $id;
+ }
+ }
+
+ throw new MWException( "Could not determine text id" );
+ }
+
+ function addDBDataOnce() {
+ $wikitextNamespace = $this->getDefaultWikitextNS();
+
+ try {
+ $title = Title::newFromText( 'FetchTextTestPage1', $wikitextNamespace );
+ $page = WikiPage::factory( $title );
+ self::$textId1 = $this->addRevision(
+ $page,
+ "FetchTextTestPage1Text1",
+ "FetchTextTestPage1Summary1"
+ );
+
+ $title = Title::newFromText( 'FetchTextTestPage2', $wikitextNamespace );
+ $page = WikiPage::factory( $title );
+ self::$textId2 = $this->addRevision(
+ $page,
+ "FetchTextTestPage2Text1",
+ "FetchTextTestPage2Summary1"
+ );
+ self::$textId3 = $this->addRevision(
+ $page,
+ "FetchTextTestPage2Text2",
+ "FetchTextTestPage2Summary2"
+ );
+ self::$textId4 = $this->addRevision(
+ $page,
+ "FetchTextTestPage2Text3",
+ "FetchTextTestPage2Summary3"
+ );
+ self::$textId5 = $this->addRevision(
+ $page,
+ "FetchTextTestPage2Text4 some additional Text ",
+ "FetchTextTestPage2Summary4 extra "
+ );
+ } catch ( Exception $e ) {
+ // We'd love to pass $e directly. However, ... see
+ // documentation of exceptionFromAddDBDataOnce
+ self::$exceptionFromAddDBDataOnce = $e;
+ }
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ // Check if any Exception is stored for rethrowing from addDBData
+ if ( self::$exceptionFromAddDBDataOnce !== null ) {
+ throw self::$exceptionFromAddDBDataOnce;
+ }
+
+ $this->fetchText = new SemiMockedFetchText();
+ }
+
+ /**
+ * Helper to relate FetchText's input and output
+ * @param string $input
+ * @param string $expectedOutput
+ */
+ private function assertFilter( $input, $expectedOutput ) {
+ $this->fetchText->mockStdin( $input );
+ $this->fetchText->execute();
+ $invocations = $this->fetchText->mockGetInvocations();
+ $this->assertEquals( 1, $invocations['getStdin'],
+ "getStdin invocation counter" );
+ $this->expectOutputString( $expectedOutput );
+ }
+
+ // Instead of the following functions, a data provider would be great.
+ // However, as data providers are evaluated /before/ addDBData, a data
+ // provider would not know the required ids.
+
+ function testExistingSimple() {
+ $this->assertFilter( self::$textId2,
+ self::$textId2 . "\n23\nFetchTextTestPage2Text1" );
+ }
+
+ function testExistingSimpleWithNewline() {
+ $this->assertFilter( self::$textId2 . "\n",
+ self::$textId2 . "\n23\nFetchTextTestPage2Text1" );
+ }
+
+ function testExistingSeveral() {
+ $this->assertFilter(
+ implode( "\n", [
+ self::$textId1,
+ self::$textId5,
+ self::$textId3,
+ self::$textId3,
+ ] ),
+ implode( '', [
+ self::$textId1 . "\n23\nFetchTextTestPage1Text1",
+ self::$textId5 . "\n44\nFetchTextTestPage2Text4 "
+ . "some additional Text",
+ self::$textId3 . "\n23\nFetchTextTestPage2Text2",
+ self::$textId3 . "\n23\nFetchTextTestPage2Text2"
+ ] ) );
+ }
+
+ function testEmpty() {
+ $this->assertFilter( "", null );
+ }
+
+ function testNonExisting() {
+ $this->assertFilter( self::$textId5 + 10, ( self::$textId5 + 10 ) . "\n-1\n" );
+ }
+
+ function testNegativeInteger() {
+ $this->assertFilter( "-42", "-42\n-1\n" );
+ }
+
+ function testFloatingPointNumberExisting() {
+ // float -> int -> revision
+ $this->assertFilter( self::$textId3 + 0.14159,
+ self::$textId3 . "\n23\nFetchTextTestPage2Text2" );
+ }
+
+ function testFloatingPointNumberNonExisting() {
+ $this->assertFilter( self::$textId5 + 3.14159,
+ ( self::$textId5 + 3 ) . "\n-1\n" );
+ }
+
+ function testCharacters() {
+ $this->assertFilter( "abc", "0\n-1\n" );
+ }
+
+ function testMix() {
+ $this->assertFilter( "ab\n" . self::$textId4 . ".5cd\n\nefg\n" . self::$textId2
+ . "\n" . self::$textId3,
+ implode( "", [
+ "0\n-1\n",
+ self::$textId4 . "\n23\nFetchTextTestPage2Text3",
+ "0\n-1\n",
+ "0\n-1\n",
+ self::$textId2 . "\n23\nFetchTextTestPage2Text1",
+ self::$textId3 . "\n23\nFetchTextTestPage2Text2"
+ ] ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/MockChangesListFilter.php b/www/wiki/tests/phpunit/mocks/MockChangesListFilter.php
new file mode 100644
index 00000000..79232ad1
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/MockChangesListFilter.php
@@ -0,0 +1,15 @@
+<?php
+
+class MockChangesListFilter extends ChangesListFilter {
+ public function displaysOnUnstructuredUi() {
+ throw new MWException(
+ 'Not implemented: If the test relies on this, put it one of the ' .
+ 'subclasses\' tests (e.g. ChangesListBooleanFilterTest) ' .
+ 'instead of testing the abstract class'
+ );
+ }
+
+ public function isSelected( FormOptions $opts ) {
+ return false;
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/MockChangesListFilterGroup.php b/www/wiki/tests/phpunit/mocks/MockChangesListFilterGroup.php
new file mode 100644
index 00000000..e50b9b4e
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/MockChangesListFilterGroup.php
@@ -0,0 +1,21 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+
+class MockChangesListFilterGroup extends ChangesListFilterGroup {
+ public function createFilter( array $filterDefinition ) {
+ return new MockChangesListFilter( $filterDefinition );
+ }
+
+ public function registerFilter( MockChangesListFilter $filter ) {
+ $this->filters[$filter->getName()] = $filter;
+ }
+
+ public function modifyQuery( IDatabase $dbr, ChangesListSpecialPage $specialPage,
+ &$tables, &$fields, &$conds, &$query_options, &$join_conds, FormOptions $opts,
+ $isStructuredFiltersEnabled ) {
+ }
+
+ public function addOptions( FormOptions $opts, $allowDefaults, $isStructuredFiltersEnabled ) {
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/MockMessageLocalizer.php b/www/wiki/tests/phpunit/mocks/MockMessageLocalizer.php
new file mode 100644
index 00000000..143a419f
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/MockMessageLocalizer.php
@@ -0,0 +1,49 @@
+<?php
+
+/**
+ * A simple {@link MessageLocalizer} implementation for use in tests.
+ * By default, it sets the message language to 'qqx',
+ * to make the tests independent of the wiki configuration.
+ *
+ * @author Lucas Werkmeister
+ * @license GPL-2.0-or-later
+ */
+class MockMessageLocalizer implements MessageLocalizer {
+
+ /**
+ * @var string|null
+ */
+ private $languageCode;
+
+ /**
+ * @param string|null $languageCode The language code to use for messages by default.
+ * You can specify null to use the user language,
+ * but this is not recommended as it may make your tests depend on the wiki configuration.
+ */
+ public function __construct( $languageCode = 'qqx' ) {
+ $this->languageCode = $languageCode;
+ }
+
+ /**
+ * Get a Message object.
+ * Parameters are the same as {@link wfMessage()}.
+ *
+ * @param string|string[]|MessageSpecifier $key Message key, or array of keys,
+ * or a MessageSpecifier.
+ * @param mixed $args,...
+ * @return Message
+ */
+ public function msg( $key ) {
+ $args = func_get_args();
+
+ /** @var Message $message */
+ $message = call_user_func_array( 'wfMessage', $args );
+
+ if ( $this->languageCode !== null ) {
+ $message->inLanguage( $this->languageCode );
+ }
+
+ return $message;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/mocks/MockWebRequest.php b/www/wiki/tests/phpunit/mocks/MockWebRequest.php
new file mode 100644
index 00000000..90475c38
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/MockWebRequest.php
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * A mock WebRequest.
+ *
+ * If the code under test accesses the response via the request (see
+ * WebRequest#response), then you might be able to use this mock to simplify
+ * your tests.
+ */
+class MockWebRequest extends WebRequest {
+ /**
+ * @var WebResponse
+ */
+ protected $response;
+
+ public function __construct( WebResponse $response ) {
+ parent::__construct();
+
+ $this->response = $response;
+ }
+
+ public function response() {
+ return $this->response;
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/content/DummyContentForTesting.php b/www/wiki/tests/phpunit/mocks/content/DummyContentForTesting.php
new file mode 100644
index 00000000..e8259d3a
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/content/DummyContentForTesting.php
@@ -0,0 +1,123 @@
+<?php
+
+class DummyContentForTesting extends AbstractContent {
+
+ const MODEL_ID = "testing";
+
+ public function __construct( $data ) {
+ parent::__construct( self::MODEL_ID );
+
+ $this->data = $data;
+ }
+
+ public function serialize( $format = null ) {
+ return serialize( $this->data );
+ }
+
+ /**
+ * @return string A string representing the content in a way useful for
+ * building a full text search index. If no useful representation exists,
+ * this method returns an empty string.
+ */
+ public function getTextForSearchIndex() {
+ return '';
+ }
+
+ /**
+ * @return string|bool The wikitext to include when another page includes this content,
+ * or false if the content is not includable in a wikitext page.
+ */
+ public function getWikitextForTransclusion() {
+ return false;
+ }
+
+ /**
+ * Returns a textual representation of the content suitable for use in edit
+ * summaries and log messages.
+ *
+ * @param int $maxlength Maximum length of the summary text.
+ * @return string The summary text.
+ */
+ public function getTextForSummary( $maxlength = 250 ) {
+ return '';
+ }
+
+ /**
+ * Returns native represenation of the data. Interpretation depends on the data model used,
+ * as given by getDataModel().
+ *
+ * @return mixed The native representation of the content. Could be a string, a nested array
+ * structure, an object, a binary blob... anything, really.
+ */
+ public function getNativeData() {
+ return $this->data;
+ }
+
+ /**
+ * returns the content's nominal size in bogo-bytes.
+ *
+ * @return int
+ */
+ public function getSize() {
+ return strlen( $this->data );
+ }
+
+ /**
+ * Return a copy of this Content object. The following must be true for the object returned
+ * if $copy = $original->copy()
+ *
+ * * get_class($original) === get_class($copy)
+ * * $original->getModel() === $copy->getModel()
+ * * $original->equals( $copy )
+ *
+ * If and only if the Content object is imutable, the copy() method can and should
+ * return $this. That is, $copy === $original may be true, but only for imutable content
+ * objects.
+ *
+ * @return Content A copy of this object
+ */
+ public function copy() {
+ return $this;
+ }
+
+ /**
+ * Returns true if this content is countable as a "real" wiki page, provided
+ * that it's also in a countable location (e.g. a current revision in the main namespace).
+ *
+ * @param bool|null $hasLinks If it is known whether this content contains links,
+ * provide this information here, to avoid redundant parsing to find out.
+ * @return bool
+ */
+ public function isCountable( $hasLinks = null ) {
+ return false;
+ }
+
+ /**
+ * @param Title $title
+ * @param int $revId Unused.
+ * @param null|ParserOptions $options
+ * @param bool $generateHtml Whether to generate Html (default: true). If false, the result
+ * of calling getText() on the ParserOutput object returned by this method is undefined.
+ *
+ * @return ParserOutput
+ */
+ public function getParserOutput( Title $title, $revId = null,
+ ParserOptions $options = null, $generateHtml = true
+ ) {
+ return new ParserOutput( $this->getNativeData() );
+ }
+
+ /**
+ * @see AbstractContent::fillParserOutput()
+ *
+ * @param Title $title Context title for parsing
+ * @param int|null $revId Revision ID (for {{REVISIONID}})
+ * @param ParserOptions $options
+ * @param bool $generateHtml Whether or not to generate HTML
+ * @param ParserOutput &$output The output object to fill (reference).
+ */
+ protected function fillParserOutput( Title $title, $revId,
+ ParserOptions $options, $generateHtml, ParserOutput &$output ) {
+ $output = new ParserOutput( $this->getNativeData() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/content/DummyContentHandlerForTesting.php b/www/wiki/tests/phpunit/mocks/content/DummyContentHandlerForTesting.php
new file mode 100644
index 00000000..b71577c7
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/content/DummyContentHandlerForTesting.php
@@ -0,0 +1,42 @@
+<?php
+
+class DummyContentHandlerForTesting extends ContentHandler {
+
+ public function __construct( $dataModel, $formats = [ DummyContentForTesting::MODEL_ID ] ) {
+ parent::__construct( $dataModel, $formats );
+ }
+
+ /**
+ * @see ContentHandler::serializeContent
+ *
+ * @param Content $content
+ * @param string $format
+ *
+ * @return string
+ */
+ public function serializeContent( Content $content, $format = null ) {
+ return $content->serialize();
+ }
+
+ /**
+ * @see ContentHandler::unserializeContent
+ *
+ * @param string $blob
+ * @param string $format Unused.
+ *
+ * @return Content
+ */
+ public function unserializeContent( $blob, $format = null ) {
+ $d = unserialize( $blob );
+
+ return new DummyContentForTesting( $d );
+ }
+
+ /**
+ * Creates an empty Content object of the type supported by this ContentHandler.
+ * @return DummyContentForTesting
+ */
+ public function makeEmptyContent() {
+ return new DummyContentForTesting( '' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/content/DummyNonTextContent.php b/www/wiki/tests/phpunit/mocks/content/DummyNonTextContent.php
new file mode 100644
index 00000000..91bb1866
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/content/DummyNonTextContent.php
@@ -0,0 +1,121 @@
+<?php
+
+class DummyNonTextContent extends AbstractContent {
+
+ public function __construct( $data ) {
+ parent::__construct( "testing-nontext" );
+
+ $this->data = $data;
+ }
+
+ public function serialize( $format = null ) {
+ return serialize( $this->data );
+ }
+
+ /**
+ * @return string A string representing the content in a way useful for
+ * building a full text search index. If no useful representation exists,
+ * this method returns an empty string.
+ */
+ public function getTextForSearchIndex() {
+ return '';
+ }
+
+ /**
+ * @return string|bool The wikitext to include when another page includes this content,
+ * or false if the content is not includable in a wikitext page.
+ */
+ public function getWikitextForTransclusion() {
+ return false;
+ }
+
+ /**
+ * Returns a textual representation of the content suitable for use in edit
+ * summaries and log messages.
+ *
+ * @param int $maxlength Maximum length of the summary text.
+ * @return string The summary text.
+ */
+ public function getTextForSummary( $maxlength = 250 ) {
+ return '';
+ }
+
+ /**
+ * Returns native represenation of the data. Interpretation depends on the data model used,
+ * as given by getDataModel().
+ *
+ * @return mixed The native representation of the content. Could be a string, a nested array
+ * structure, an object, a binary blob... anything, really.
+ */
+ public function getNativeData() {
+ return $this->data;
+ }
+
+ /**
+ * returns the content's nominal size in bogo-bytes.
+ *
+ * @return int
+ */
+ public function getSize() {
+ return strlen( $this->data );
+ }
+
+ /**
+ * Return a copy of this Content object. The following must be true for the object returned
+ * if $copy = $original->copy()
+ *
+ * * get_class($original) === get_class($copy)
+ * * $original->getModel() === $copy->getModel()
+ * * $original->equals( $copy )
+ *
+ * If and only if the Content object is imutable, the copy() method can and should
+ * return $this. That is, $copy === $original may be true, but only for imutable content
+ * objects.
+ *
+ * @return Content A copy of this object
+ */
+ public function copy() {
+ return $this;
+ }
+
+ /**
+ * Returns true if this content is countable as a "real" wiki page, provided
+ * that it's also in a countable location (e.g. a current revision in the main namespace).
+ *
+ * @param bool|null $hasLinks If it is known whether this content contains links,
+ * provide this information here, to avoid redundant parsing to find out.
+ * @return bool
+ */
+ public function isCountable( $hasLinks = null ) {
+ return false;
+ }
+
+ /**
+ * @param Title $title
+ * @param int $revId Unused.
+ * @param null|ParserOptions $options
+ * @param bool $generateHtml Whether to generate Html (default: true). If false, the result
+ * of calling getText() on the ParserOutput object returned by this method is undefined.
+ *
+ * @return ParserOutput
+ */
+ public function getParserOutput( Title $title, $revId = null,
+ ParserOptions $options = null, $generateHtml = true
+ ) {
+ return new ParserOutput( $this->getNativeData() );
+ }
+
+ /**
+ * @see AbstractContent::fillParserOutput()
+ *
+ * @param Title $title Context title for parsing
+ * @param int|null $revId Revision ID (for {{REVISIONID}})
+ * @param ParserOptions $options
+ * @param bool $generateHtml Whether or not to generate HTML
+ * @param ParserOutput &$output The output object to fill (reference).
+ */
+ protected function fillParserOutput( Title $title, $revId,
+ ParserOptions $options, $generateHtml, ParserOutput &$output ) {
+ $output = new ParserOutput( $this->getNativeData() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/content/DummyNonTextContentHandler.php b/www/wiki/tests/phpunit/mocks/content/DummyNonTextContentHandler.php
new file mode 100644
index 00000000..9d91d4a1
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/content/DummyNonTextContentHandler.php
@@ -0,0 +1,47 @@
+<?php
+
+class DummyNonTextContentHandler extends DummyContentHandlerForTesting {
+
+ public function __construct( $dataModel ) {
+ parent::__construct( $dataModel, [ "testing-nontext" ] );
+ }
+
+ /**
+ * @see ContentHandler::serializeContent
+ *
+ * @param Content $content
+ * @param string $format
+ *
+ * @return string
+ */
+ public function serializeContent( Content $content, $format = null ) {
+ return $content->serialize();
+ }
+
+ /**
+ * @see ContentHandler::unserializeContent
+ *
+ * @param string $blob
+ * @param string $format Unused.
+ *
+ * @return Content
+ */
+ public function unserializeContent( $blob, $format = null ) {
+ $d = unserialize( $blob );
+
+ return new DummyNonTextContent( $d );
+ }
+
+ /**
+ * Creates an empty Content object of the type supported by this ContentHandler.
+ * @return DummyNonTextContent
+ */
+ public function makeEmptyContent() {
+ return new DummyNonTextContent( '' );
+ }
+
+ public function supportsDirectApiEditing() {
+ return true;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/mocks/content/DummySerializeErrorContentHandler.php b/www/wiki/tests/phpunit/mocks/content/DummySerializeErrorContentHandler.php
new file mode 100644
index 00000000..720547a5
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/content/DummySerializeErrorContentHandler.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * 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
+ */
+
+/**
+ * A dummy content handler that will throw on an attempt to serialize content.
+ */
+class DummySerializeErrorContentHandler extends DummyContentHandlerForTesting {
+
+ public function __construct( $dataModel ) {
+ parent::__construct( $dataModel, [ "testing-serialize-error" ] );
+ }
+
+ /**
+ * @see ContentHandler::unserializeContent
+ *
+ * @param string $blob
+ * @param string $format
+ *
+ * @return Content
+ */
+ public function unserializeContent( $blob, $format = null ) {
+ throw new MWContentSerializationException( 'Could not unserialize content' );
+ }
+
+ /**
+ * @see ContentHandler::supportsDirectEditing
+ *
+ * @return bool
+ *
+ * @todo Should this be in the parent class?
+ */
+ public function supportsDirectApiEditing() {
+ return true;
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/mocks/filebackend/MockFSFile.php b/www/wiki/tests/phpunit/mocks/filebackend/MockFSFile.php
new file mode 100644
index 00000000..ef1caa5d
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/filebackend/MockFSFile.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * Mock of a filesystem 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 FileBackend
+ */
+
+/**
+ * Class representing an in-memory fake file.
+ * This is intended for unit testing / development when you do not want
+ * to hit the filesystem.
+ *
+ * It reimplements abstract methods with some hardcoded values. Might
+ * not be suitable for all tests but is good enough for the parser tests.
+ *
+ * @ingroup FileBackend
+ */
+class MockFSFile extends FSFile {
+ protected $sha1Base36 = null; // File Sha1Base36
+
+ public function exists() {
+ return true;
+ }
+
+ /**
+ * August 22 – The theft of the Mona Lisa is discovered in the Louvre."
+ * T22281
+ * @return int
+ */
+ public function getSize() {
+ return 1911;
+ }
+
+ public function getTimestamp() {
+ return wfTimestamp( TS_MW );
+ }
+
+ public function getProps( $ext = true ) {
+ return [
+ 'fileExists' => $this->exists(),
+ 'size' => $this->getSize(),
+ 'file-mime' => 'text/mock',
+ 'sha1' => $this->getSha1Base36(),
+ ];
+ }
+
+ public function getSha1Base36( $recache = false ) {
+ return '1234567890123456789012345678901';
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/filebackend/MockFileBackend.php b/www/wiki/tests/phpunit/mocks/filebackend/MockFileBackend.php
new file mode 100644
index 00000000..0a049930
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/filebackend/MockFileBackend.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Simulation (mock) of a backend storage.
+ *
+ * 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 FileBackend
+ * @author Antoine Musso <hashar@free.fr>
+ */
+
+/**
+ * Class simulating a backend store.
+ *
+ * @ingroup FileBackend
+ * @since 1.22
+ */
+class MockFileBackend extends MemoryFileBackend {
+ protected function doGetLocalCopyMulti( array $params ) {
+ $tmpFiles = []; // (path => MockFSFile)
+ foreach ( $params['srcs'] as $src ) {
+ $tmpFiles[$src] = new MockFSFile( wfTempDir() . '/' . wfRandomString( 32 ) );
+ }
+ return $tmpFiles;
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/filerepo/MockLocalRepo.php b/www/wiki/tests/phpunit/mocks/filerepo/MockLocalRepo.php
new file mode 100644
index 00000000..eeaf05a0
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/filerepo/MockLocalRepo.php
@@ -0,0 +1,23 @@
+<?php
+
+/**
+ * Class simulating a local file repo.
+ *
+ * @ingroup FileRepo
+ * @since 1.28
+ */
+class MockLocalRepo extends LocalRepo {
+ function getLocalCopy( $virtualUrl ) {
+ return new MockFSFile( wfTempDir() . '/' . wfRandomString( 32 ) );
+ }
+
+ function getLocalReference( $virtualUrl ) {
+ return new MockFSFile( wfTempDir() . '/' . wfRandomString( 32 ) );
+ }
+
+ function getFileProps( $virtualUrl ) {
+ $fsFile = $this->getLocalReference( $virtualUrl );
+
+ return $fsFile->getProps();
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/media/MockBitmapHandler.php b/www/wiki/tests/phpunit/mocks/media/MockBitmapHandler.php
new file mode 100644
index 00000000..38cacf9f
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/media/MockBitmapHandler.php
@@ -0,0 +1,32 @@
+<?php
+/**
+ * Fake handler for Bitmap images.
+ *
+ * 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 Media
+ */
+
+class MockBitmapHandler extends BitmapHandler {
+ function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
+ return MockImageHandler::doFakeTransform( $this, $image, $dstPath, $dstUrl, $params, $flags );
+ }
+
+ function doClientImage( $image, $scalerParams ) {
+ return $this->getClientScalingThumbnailImage( $image, $scalerParams );
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/media/MockDjVuHandler.php b/www/wiki/tests/phpunit/mocks/media/MockDjVuHandler.php
new file mode 100644
index 00000000..0e0b9435
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/media/MockDjVuHandler.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * Fake handler for DjVu images.
+ *
+ * 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 Media
+ */
+
+class MockDjVuHandler extends DjVuHandler {
+ function isEnabled() {
+ return true;
+ }
+
+ function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
+ if ( !$this->normaliseParams( $image, $params ) ) {
+ return new TransformParameterError( $params );
+ }
+ $width = $params['width'];
+ $height = $params['height'];
+ $page = $params['page'];
+ if ( $page > $this->pageCount( $image ) ) {
+ return new MediaTransformError(
+ 'thumbnail_error',
+ $width,
+ $height,
+ wfMessage( 'djvu_page_error' )->text()
+ );
+ }
+
+ $params = [
+ 'width' => $width,
+ 'height' => $height,
+ 'page' => $page
+ ];
+
+ return new ThumbnailImage( $image, $dstUrl, $dstPath, $params );
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/media/MockImageHandler.php b/www/wiki/tests/phpunit/mocks/media/MockImageHandler.php
new file mode 100644
index 00000000..e0082919
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/media/MockImageHandler.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * Fake handler for images.
+ *
+ * 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 Media
+ */
+
+/**
+ * Mock handler for images.
+ *
+ * This is really intended for unit testing.
+ *
+ * @ingroup Media
+ */
+class MockImageHandler {
+
+ /**
+ * Override BitmapHandler::doTransform() making sure we do not generate
+ * a thumbnail at all. That is merely returning a ThumbnailImage that
+ * will be consumed by the unit test. There is no need to create a real
+ * thumbnail on the filesystem.
+ * @param ImageHandler $that
+ * @param File $image
+ * @param string $dstPath
+ * @param string $dstUrl
+ * @param array $params
+ * @param int $flags
+ * @return ThumbnailImage
+ */
+ static function doFakeTransform( $that, $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
+ # Example of what we receive:
+ # $image: LocalFile
+ # $dstPath: /tmp/transform_7d0a7a2f1a09-1.jpg
+ # $dstUrl : http://example.com/images/thumb/0/09/Bad.jpg/320px-Bad.jpg
+ # $params: width: 320, descriptionUrl http://trunk.dev/wiki/File:Bad.jpg
+
+ $that->normaliseParams( $image, $params );
+
+ $scalerParams = [
+ # The size to which the image will be resized
+ 'physicalWidth' => $params['physicalWidth'],
+ 'physicalHeight' => $params['physicalHeight'],
+ 'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}",
+ # The size of the image on the page
+ 'clientWidth' => $params['width'],
+ 'clientHeight' => $params['height'],
+ # Comment as will be added to the EXIF of the thumbnail
+ 'comment' => isset( $params['descriptionUrl'] ) ?
+ "File source: {$params['descriptionUrl']}" : '',
+ # Properties of the original image
+ 'srcWidth' => $image->getWidth(),
+ 'srcHeight' => $image->getHeight(),
+ 'mimeType' => $image->getMimeType(),
+ 'dstPath' => $dstPath,
+ 'dstUrl' => $dstUrl,
+ ];
+
+ # In some cases, we do not bother generating a thumbnail.
+ if ( !$image->mustRender() &&
+ $scalerParams['physicalWidth'] == $scalerParams['srcWidth']
+ && $scalerParams['physicalHeight'] == $scalerParams['srcHeight']
+ ) {
+ wfDebug( __METHOD__ . ": returning unscaled image\n" );
+ // getClientScalingThumbnailImage is protected
+ return $that->doClientImage( $image, $scalerParams );
+ }
+
+ return new ThumbnailImage( $image, $dstUrl, false, $params );
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/media/MockSvgHandler.php b/www/wiki/tests/phpunit/mocks/media/MockSvgHandler.php
new file mode 100644
index 00000000..21520c44
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/media/MockSvgHandler.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * Fake handler for SVG images.
+ *
+ * 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 Media
+ */
+
+class MockSvgHandler extends SvgHandler {
+ function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
+ return MockImageHandler::doFakeTransform( $this, $image, $dstPath, $dstUrl, $params, $flags );
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/session/DummySessionBackend.php b/www/wiki/tests/phpunit/mocks/session/DummySessionBackend.php
new file mode 100644
index 00000000..d5d771bd
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/session/DummySessionBackend.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace MediaWiki\Session;
+
+/**
+ * Dummy session backend
+ *
+ * This isn't a real backend, but implements some methods that SessionBackend
+ * does so tests can run.
+ */
+class DummySessionBackend {
+ public $data = [
+ 'foo' => 1,
+ 'bar' => 2,
+ 0 => 'zero',
+ ];
+ public $dirty = false;
+
+ public function &getData() {
+ return $this->data;
+ }
+
+ public function dirty() {
+ $this->dirty = true;
+ }
+
+ public function deregisterSession( $index ) {
+ }
+}
diff --git a/www/wiki/tests/phpunit/mocks/session/DummySessionProvider.php b/www/wiki/tests/phpunit/mocks/session/DummySessionProvider.php
new file mode 100644
index 00000000..dcbb101a
--- /dev/null
+++ b/www/wiki/tests/phpunit/mocks/session/DummySessionProvider.php
@@ -0,0 +1,60 @@
+<?php
+use MediaWiki\Session\SessionProvider;
+use MediaWiki\Session\SessionInfo;
+use MediaWiki\Session\SessionBackend;
+use MediaWiki\Session\UserInfo;
+
+/**
+ * Dummy session provider
+ *
+ * An implementation of a session provider that doesn't actually do anything.
+ */
+class DummySessionProvider extends SessionProvider {
+
+ const ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+ public function provideSessionInfo( WebRequest $request ) {
+ return new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'provider' => $this,
+ 'id' => self::ID,
+ 'persisted' => true,
+ 'userInfo' => UserInfo::newAnonymous(),
+ ] );
+ }
+
+ public function newSessionInfo( $id = null ) {
+ return new SessionInfo( SessionInfo::MIN_PRIORITY, [
+ 'id' => $id,
+ 'idIsSafe' => true,
+ 'provider' => $this,
+ 'persisted' => false,
+ 'userInfo' => UserInfo::newAnonymous(),
+ ] );
+ }
+
+ public function persistsSessionId() {
+ return true;
+ }
+
+ public function canChangeUser() {
+ return $this->persistsSessionId();
+ }
+
+ public function persistSession( SessionBackend $session, WebRequest $request ) {
+ }
+
+ public function unpersistSession( WebRequest $request ) {
+ }
+
+ public function immutableSessionCouldExistForUser( $user ) {
+ return false;
+ }
+
+ public function preventImmutableSessionsForUser( $user ) {
+ }
+
+ public function suggestLoginUsername( WebRequest $request ) {
+ return $request->getCookie( 'UserName' );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/phpunit.php b/www/wiki/tests/phpunit/phpunit.php
new file mode 100755
index 00000000..650cfcfa
--- /dev/null
+++ b/www/wiki/tests/phpunit/phpunit.php
@@ -0,0 +1,173 @@
+#!/usr/bin/env php
+<?php
+/**
+ * Bootstrapping for MediaWiki PHPUnit tests
+ *
+ * @file
+ */
+
+// Set a flag which can be used to detect when other scripts have been entered
+// through this entry point or not.
+define( 'MW_PHPUNIT_TEST', true );
+
+// Start up MediaWiki in command-line mode
+require_once dirname( dirname( __DIR__ ) ) . "/maintenance/Maintenance.php";
+
+class PHPUnitMaintClass extends Maintenance {
+
+ public static $additionalOptions = [
+ 'file' => false,
+ 'use-filebackend' => false,
+ 'use-bagostuff' => false,
+ 'use-jobqueue' => false,
+ 'use-normal-tables' => false,
+ 'mwdebug' => false,
+ 'reuse-db' => false,
+ 'wiki' => false,
+ 'profiler' => false,
+ ];
+
+ public function __construct() {
+ parent::__construct();
+ $this->addOption(
+ 'with-phpunitclass',
+ 'Class name of the PHPUnit entry point to use',
+ false,
+ true
+ );
+ $this->addOption(
+ 'debug-tests',
+ 'Log testing activity to the PHPUnitCommand log channel.',
+ false, # not required
+ false # no arg needed
+ );
+ $this->addOption( 'file', 'File describing parser tests.', false, true );
+ $this->addOption( 'use-filebackend', 'Use filebackend', false, true );
+ $this->addOption( 'use-bagostuff', 'Use bagostuff', false, true );
+ $this->addOption( 'use-jobqueue', 'Use jobqueue', false, true );
+ $this->addOption( 'use-normal-tables', 'Use normal DB tables.', false, false );
+ $this->addOption(
+ 'reuse-db', 'Init DB only if tables are missing and keep after finish.',
+ false,
+ false
+ );
+ }
+
+ public function finalSetup() {
+ parent::finalSetup();
+
+ // Inject test autoloader
+ self::requireTestsAutoloader();
+
+ TestSetup::applyInitialConfig();
+ }
+
+ public function execute() {
+ global $IP;
+
+ // Deregister handler from MWExceptionHandler::installHandle so that PHPUnit's own handler
+ // stays in tact.
+ // Has to in execute() instead of finalSetup(), because finalSetup() runs before
+ // doMaintenance.php includes Setup.php, which calls MWExceptionHandler::installHandle().
+ restore_error_handler();
+
+ $this->forceFormatServerArgv();
+
+ # Make sure we have --configuration or PHPUnit might complain
+ if ( !in_array( '--configuration', $_SERVER['argv'] ) ) {
+ // Hack to eliminate the need to use the Makefile (which sucks ATM)
+ array_splice( $_SERVER['argv'], 1, 0,
+ [ '--configuration', $IP . '/tests/phpunit/suite.xml' ] );
+ }
+
+ $phpUnitClass = PHPUnit_TextUI_Command::class;
+
+ if ( $this->hasOption( 'with-phpunitclass' ) ) {
+ $phpUnitClass = $this->getOption( 'with-phpunitclass' );
+
+ # Cleanup $args array so the option and its value do not
+ # pollute PHPUnit
+ $key = array_search( '--with-phpunitclass', $_SERVER['argv'] );
+ unset( $_SERVER['argv'][$key] ); // the option
+ unset( $_SERVER['argv'][$key + 1] ); // its value
+ $_SERVER['argv'] = array_values( $_SERVER['argv'] );
+ }
+
+ $key = array_search( '--debug-tests', $_SERVER['argv'] );
+ if ( $key !== false && array_search( '--printer', $_SERVER['argv'] ) === false ) {
+ unset( $_SERVER['argv'][$key] );
+ array_splice( $_SERVER['argv'], 1, 0, 'MediaWikiPHPUnitTestListener' );
+ array_splice( $_SERVER['argv'], 1, 0, '--printer' );
+ }
+
+ foreach ( self::$additionalOptions as $option => $default ) {
+ $key = array_search( '--' . $option, $_SERVER['argv'] );
+ if ( $key !== false ) {
+ unset( $_SERVER['argv'][$key] );
+ if ( $this->mParams[$option]['withArg'] ) {
+ self::$additionalOptions[$option] = $_SERVER['argv'][$key + 1];
+ unset( $_SERVER['argv'][$key + 1] );
+ } else {
+ self::$additionalOptions[$option] = true;
+ }
+ }
+ }
+
+ if ( !class_exists( 'PHPUnit\\Framework\\TestCase' ) ) {
+ echo "PHPUnit not found. Please install it and other dev dependencies by
+ running `composer install` in MediaWiki root directory.\n";
+ exit( 1 );
+ }
+ if ( !class_exists( $phpUnitClass ) ) {
+ echo "PHPUnit entry point '" . $phpUnitClass . "' not found. Please make sure you installed
+ the containing component and check the spelling of the class name.\n";
+ exit( 1 );
+ }
+
+ fwrite( STDERR, defined( 'HHVM_VERSION' ) ?
+ 'Using HHVM ' . HHVM_VERSION . ' (' . PHP_VERSION . ")\n" :
+ 'Using PHP ' . PHP_VERSION . "\n" );
+
+ // Prepare global services for unit tests.
+ MediaWikiTestCase::prepareServices( new GlobalVarConfig() );
+
+ $phpUnitClass::main();
+ }
+
+ public function getDbType() {
+ return Maintenance::DB_ADMIN;
+ }
+
+ protected function addOption( $name, $description, $required = false,
+ $withArg = false, $shortName = false, $multiOccurrence = false
+ ) {
+ // ignore --quiet which does not really make sense for unit tests
+ if ( $name !== 'quiet' ) {
+ parent::addOption( $name, $description, $required, $withArg, $shortName, $multiOccurrence );
+ }
+ }
+
+ /**
+ * Force the format of elements in $_SERVER['argv']
+ * - Split args such as "wiki=enwiki" into two separate arg elements "wiki" and "enwiki"
+ */
+ private function forceFormatServerArgv() {
+ $argv = [];
+ foreach ( $_SERVER['argv'] as $key => $arg ) {
+ if ( $key === 0 ) {
+ $argv[0] = $arg;
+ } elseif ( strstr( $arg, '=' ) ) {
+ foreach ( explode( '=', $arg, 2 ) as $argPart ) {
+ $argv[] = $argPart;
+ }
+ } else {
+ $argv[] = $arg;
+ }
+ }
+ $_SERVER['argv'] = $argv;
+ }
+
+}
+
+$maintClass = 'PHPUnitMaintClass';
+require RUN_MAINTENANCE_IF_MAIN;
diff --git a/www/wiki/tests/phpunit/run-tests.bat b/www/wiki/tests/phpunit/run-tests.bat
new file mode 100644
index 00000000..e6eb3e0c
--- /dev/null
+++ b/www/wiki/tests/phpunit/run-tests.bat
@@ -0,0 +1 @@
+php phpunit.php --configuration suite.xml %*
diff --git a/www/wiki/tests/phpunit/skins/SideBarTest.php b/www/wiki/tests/phpunit/skins/SideBarTest.php
new file mode 100644
index 00000000..ec85bb03
--- /dev/null
+++ b/www/wiki/tests/phpunit/skins/SideBarTest.php
@@ -0,0 +1,221 @@
+<?php
+
+/**
+ * @group Skin
+ */
+class SideBarTest extends MediaWikiLangTestCase {
+
+ /**
+ * A skin template, reinitialized before each test
+ * @var SkinTemplate
+ */
+ private $skin;
+ /** Local cache for sidebar messages */
+ private $messages;
+
+ /** Build $this->messages array */
+ private function initMessagesHref() {
+ # List of default messages for the sidebar. The sidebar doesn't care at
+ # all whether they are full URLs, interwiki links or local titles.
+ $URL_messages = [
+ 'mainpage',
+ 'portal-url',
+ 'currentevents-url',
+ 'recentchanges-url',
+ 'randompage-url',
+ 'helppage',
+ ];
+
+ # We're assuming that isValidURI works as advertised: it's also
+ # tested separately, in tests/phpunit/includes/HttpTest.php.
+ foreach ( $URL_messages as $m ) {
+ $titleName = MessageCache::singleton()->get( $m );
+ if ( Http::isValidURI( $titleName ) ) {
+ $this->messages[$m]['href'] = $titleName;
+ } else {
+ $title = Title::newFromText( $titleName );
+ $this->messages[$m]['href'] = $title->getLocalURL();
+ }
+ }
+ }
+
+ protected function setUp() {
+ parent::setUp();
+ $this->initMessagesHref();
+ $this->skin = new SkinTemplate();
+ $this->skin->getContext()->setLanguage( Language::factory( 'en' ) );
+ }
+
+ /**
+ * Internal helper to test the sidebar
+ * @param array $expected
+ * @param string $text
+ * @param string $message (Default: '')
+ * @todo this assert method to should be converted to a test using a dataprovider..
+ */
+ private function assertSideBar( $expected, $text, $message = '' ) {
+ $bar = [];
+ $this->skin->addToSidebarPlain( $bar, $text );
+ $this->assertEquals( $expected, $bar, $message );
+ }
+
+ /**
+ * @covers SkinTemplate::addToSidebarPlain
+ */
+ public function testSidebarWithOnlyTwoTitles() {
+ $this->assertSideBar(
+ [
+ 'Title1' => [],
+ 'Title2' => [],
+ ],
+ '* Title1
+* Title2
+'
+ );
+ }
+
+ /**
+ * @covers SkinTemplate::addToSidebarPlain
+ */
+ public function testExpandMessages() {
+ $this->assertSideBar(
+ [ 'Title' => [
+ [
+ 'text' => 'Help',
+ 'href' => $this->messages['helppage']['href'],
+ 'id' => 'n-help',
+ 'active' => null
+ ]
+ ] ],
+ '* Title
+** helppage|help
+'
+ );
+ }
+
+ /**
+ * @covers SkinTemplate::addToSidebarPlain
+ */
+ public function testExternalUrlsRequireADescription() {
+ $this->setMwGlobals( [
+ 'wgNoFollowLinks' => true,
+ 'wgNoFollowDomainExceptions' => [],
+ 'wgNoFollowNsExceptions' => [],
+ ] );
+ $this->assertSideBar(
+ [ 'Title' => [
+ # ** https://www.mediawiki.org/| Home
+ [
+ 'text' => 'Home',
+ 'href' => 'https://www.mediawiki.org/',
+ 'id' => 'n-Home',
+ 'active' => null,
+ 'rel' => 'nofollow',
+ ],
+ # ** http://valid.no.desc.org/
+ # ... skipped since it is missing a pipe with a description
+ ] ],
+ '* Title
+** https://www.mediawiki.org/| Home
+** http://valid.no.desc.org/
+'
+ );
+ }
+
+ /**
+ * T35321 - Make sure there's a | after transforming.
+ * @group Database
+ * @covers SkinTemplate::addToSidebarPlain
+ */
+ public function testTrickyPipe() {
+ $this->assertSideBar(
+ [ 'Title' => [
+ # The first 2 are skipped
+ # Doesn't really test the url properly
+ # because it will vary with $wgArticlePath et al.
+ # ** Baz|Fred
+ [
+ 'text' => 'Fred',
+ 'href' => Title::newFromText( 'Baz' )->getLocalURL(),
+ 'id' => 'n-Fred',
+ 'active' => null,
+ ],
+ [
+ 'text' => 'title-to-display',
+ 'href' => Title::newFromText( 'page-to-go-to' )->getLocalURL(),
+ 'id' => 'n-title-to-display',
+ 'active' => null,
+ ],
+ ] ],
+ '* Title
+** {{PAGENAME|Foo}}
+** Bar
+** Baz|Fred
+** {{PLURAL:1|page-to-go-to{{int:pipe-separator/en}}title-to-display|branch not taken}}
+'
+ );
+ }
+
+ #### Attributes for external links ##########################
+ private function getAttribs() {
+ # Sidebar text we will use everytime
+ $text = '* Title
+** https://www.mediawiki.org/| Home';
+
+ $bar = [];
+ $this->skin->addToSidebarPlain( $bar, $text );
+
+ return $bar['Title'][0];
+ }
+
+ /**
+ * Simple test to verify our helper assertAttribs() is functional
+ */
+ public function testTestAttributesAssertionHelper() {
+ $this->setMwGlobals( [
+ 'wgNoFollowLinks' => true,
+ 'wgNoFollowDomainExceptions' => [],
+ 'wgNoFollowNsExceptions' => [],
+ 'wgExternalLinkTarget' => false,
+ ] );
+ $attribs = $this->getAttribs();
+
+ $this->assertArrayHasKey( 'rel', $attribs );
+ $this->assertEquals( 'nofollow', $attribs['rel'] );
+
+ $this->assertArrayNotHasKey( 'target', $attribs );
+ }
+
+ /**
+ * Test $wgNoFollowLinks in sidebar
+ * @covers Skin::addToSidebarPlain
+ */
+ public function testRespectWgnofollowlinks() {
+ $this->setMwGlobals( 'wgNoFollowLinks', false );
+
+ $attribs = $this->getAttribs();
+ $this->assertArrayNotHasKey( 'rel', $attribs,
+ 'External URL in sidebar do not have rel=nofollow when $wgNoFollowLinks = false'
+ );
+ }
+
+ /**
+ * Test $wgExternaLinkTarget in sidebar
+ * @dataProvider dataRespectExternallinktarget
+ * @covers Skin::addToSidebarPlain
+ */
+ public function testRespectExternallinktarget( $externalLinkTarget ) {
+ $this->setMwGlobals( 'wgExternalLinkTarget', $externalLinkTarget );
+
+ $attribs = $this->getAttribs();
+ $this->assertArrayHasKey( 'target', $attribs );
+ $this->assertEquals( $attribs['target'], $externalLinkTarget );
+ }
+
+ public static function dataRespectExternallinktarget() {
+ return [
+ [ '_blank' ],
+ [ '_self' ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/structure/ApiStructureTest.php b/www/wiki/tests/phpunit/structure/ApiStructureTest.php
new file mode 100644
index 00000000..77d6e741
--- /dev/null
+++ b/www/wiki/tests/phpunit/structure/ApiStructureTest.php
@@ -0,0 +1,612 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * Checks that all API modules, core and extensions, conform to the conventions:
+ * - have documentation i18n messages (the test won't catch everything since
+ * i18n messages can vary based on the wiki configuration, but it should
+ * catch many cases for forgotten i18n)
+ * - do not have inconsistencies in the parameter definitions
+ *
+ * @group API
+ */
+class ApiStructureTest extends MediaWikiTestCase {
+
+ /** @var ApiMain */
+ private static $main;
+
+ /** @var array Sets of globals to test. Each array element is input to HashConfig */
+ private static $testGlobals = [
+ [
+ 'MiserMode' => false,
+ ],
+ [
+ 'MiserMode' => true,
+ ],
+ ];
+
+ /**
+ * Values are an array, where each array value is a permitted type. A type
+ * can be a string, which is the name of an internal type or a
+ * class/interface. Or it can be an array, in which case the value must be
+ * an array whose elements are the types given in the array (e.g., [
+ * 'string', integer' ] means an array whose entries are strings and/or
+ * integers).
+ */
+ private static $paramTypes = [
+ // ApiBase::PARAM_DFLT => as appropriate for PARAM_TYPE
+ ApiBase::PARAM_ISMULTI => [ 'boolean' ],
+ ApiBase::PARAM_TYPE => [ 'string', [ 'string' ] ],
+ ApiBase::PARAM_MAX => [ 'integer' ],
+ ApiBase::PARAM_MAX2 => [ 'integer' ],
+ ApiBase::PARAM_MIN => [ 'integer' ],
+ ApiBase::PARAM_ALLOW_DUPLICATES => [ 'boolean' ],
+ ApiBase::PARAM_DEPRECATED => [ 'boolean' ],
+ ApiBase::PARAM_REQUIRED => [ 'boolean' ],
+ ApiBase::PARAM_RANGE_ENFORCE => [ 'boolean' ],
+ ApiBase::PARAM_HELP_MSG => [ 'string', 'array', Message::class ],
+ ApiBase::PARAM_HELP_MSG_APPEND => [ [ 'string', 'array', Message::class ] ],
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'array' ] ],
+ ApiBase::PARAM_VALUE_LINKS => [ [ 'string' ] ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [ [ 'string', 'array', Message::class ] ],
+ ApiBase::PARAM_SUBMODULE_MAP => [ [ 'string' ] ],
+ ApiBase::PARAM_SUBMODULE_PARAM_PREFIX => [ 'string' ],
+ ApiBase::PARAM_ALL => [ 'boolean', 'string' ],
+ ApiBase::PARAM_EXTRA_NAMESPACES => [ [ 'integer' ] ],
+ ApiBase::PARAM_SENSITIVE => [ 'boolean' ],
+ ApiBase::PARAM_DEPRECATED_VALUES => [ 'array' ],
+ ApiBase::PARAM_ISMULTI_LIMIT1 => [ 'integer' ],
+ ApiBase::PARAM_ISMULTI_LIMIT2 => [ 'integer' ],
+ ApiBase::PARAM_MAX_BYTES => [ 'integer' ],
+ ApiBase::PARAM_MAX_CHARS => [ 'integer' ],
+ ];
+
+ // param => [ other param that must be present => required value or null ]
+ private static $paramRequirements = [
+ ApiBase::PARAM_ALLOW_DUPLICATES => [ ApiBase::PARAM_ISMULTI => true ],
+ ApiBase::PARAM_ALL => [ ApiBase::PARAM_ISMULTI => true ],
+ ApiBase::PARAM_ISMULTI_LIMIT1 => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ISMULTI_LIMIT2 => null,
+ ],
+ ApiBase::PARAM_ISMULTI_LIMIT2 => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ISMULTI_LIMIT1 => null,
+ ],
+ ];
+
+ // param => type(s) allowed for this param ('array' is any array)
+ private static $paramAllowedTypes = [
+ ApiBase::PARAM_MAX => [ 'integer', 'limit' ],
+ ApiBase::PARAM_MAX2 => 'limit',
+ ApiBase::PARAM_MIN => [ 'integer', 'limit' ],
+ ApiBase::PARAM_RANGE_ENFORCE => 'integer',
+ ApiBase::PARAM_VALUE_LINKS => 'array',
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => 'array',
+ ApiBase::PARAM_SUBMODULE_MAP => 'submodule',
+ ApiBase::PARAM_SUBMODULE_PARAM_PREFIX => 'submodule',
+ ApiBase::PARAM_ALL => 'array',
+ ApiBase::PARAM_EXTRA_NAMESPACES => 'namespace',
+ ApiBase::PARAM_DEPRECATED_VALUES => 'array',
+ ApiBase::PARAM_MAX_BYTES => [ 'NULL', 'string', 'text', 'password' ],
+ ApiBase::PARAM_MAX_CHARS => [ 'NULL', 'string', 'text', 'password' ],
+ ];
+
+ private static $paramProhibitedTypes = [
+ ApiBase::PARAM_ISMULTI => [ 'boolean', 'limit', 'upload' ],
+ ApiBase::PARAM_ALL => 'namespace',
+ ApiBase::PARAM_SENSITIVE => 'password',
+ ];
+
+ private static $constantNames = null;
+
+ /**
+ * Initialize/fetch the ApiMain instance for testing
+ * @return ApiMain
+ */
+ private static function getMain() {
+ if ( !self::$main ) {
+ self::$main = new ApiMain( RequestContext::getMain() );
+ self::$main->getContext()->setLanguage( 'en' );
+ self::$main->getContext()->setTitle(
+ Title::makeTitle( NS_SPECIAL, 'Badtitle/dummy title for ApiStructureTest' )
+ );
+ }
+ return self::$main;
+ }
+
+ /**
+ * Test a message
+ * @param Message $msg
+ * @param string $what Which message is being checked
+ */
+ private function checkMessage( $msg, $what ) {
+ $msg = ApiBase::makeMessage( $msg, self::getMain()->getContext() );
+ $this->assertInstanceOf( Message::class, $msg, "$what message" );
+ $this->assertTrue( $msg->exists(), "$what message {$msg->getKey()} exists" );
+ }
+
+ /**
+ * @dataProvider provideDocumentationExists
+ * @param string $path Module path
+ * @param array $globals Globals to set
+ */
+ public function testDocumentationExists( $path, array $globals ) {
+ $main = self::getMain();
+
+ // Set configuration variables
+ $main->getContext()->setConfig( new MultiConfig( [
+ new HashConfig( $globals ),
+ RequestContext::getMain()->getConfig(),
+ ] ) );
+ foreach ( $globals as $k => $v ) {
+ $this->setMwGlobals( "wg$k", $v );
+ }
+
+ // Fetch module.
+ $module = TestingAccessWrapper::newFromObject( $main->getModuleFromPath( $path ) );
+
+ // Test messages for flags.
+ foreach ( $module->getHelpFlags() as $flag ) {
+ $this->checkMessage( "api-help-flag-$flag", "Flag $flag" );
+ }
+
+ // Module description messages.
+ $this->checkMessage( $module->getSummaryMessage(), 'Module summary' );
+ $this->checkMessage( $module->getExtendedDescription(), 'Module help top text' );
+
+ // Parameters. Lots of messages in here.
+ $params = $module->getFinalParams( ApiBase::GET_VALUES_FOR_HELP );
+ $tags = [];
+ foreach ( $params as $name => $settings ) {
+ if ( !is_array( $settings ) ) {
+ $settings = [];
+ }
+
+ // Basic description message
+ if ( isset( $settings[ApiBase::PARAM_HELP_MSG] ) ) {
+ $msg = $settings[ApiBase::PARAM_HELP_MSG];
+ } else {
+ $msg = "apihelp-{$path}-param-{$name}";
+ }
+ $this->checkMessage( $msg, "Parameter $name description" );
+
+ // If param-per-value is in use, each value's message
+ if ( isset( $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE] ) ) {
+ $this->assertInternalType( 'array', $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE],
+ "Parameter $name PARAM_HELP_MSG_PER_VALUE is array" );
+ $this->assertInternalType( 'array', $settings[ApiBase::PARAM_TYPE],
+ "Parameter $name PARAM_TYPE is array for msg-per-value mode" );
+ $valueMsgs = $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE];
+ foreach ( $settings[ApiBase::PARAM_TYPE] as $value ) {
+ if ( isset( $valueMsgs[$value] ) ) {
+ $msg = $valueMsgs[$value];
+ } else {
+ $msg = "apihelp-{$path}-paramvalue-{$name}-{$value}";
+ }
+ $this->checkMessage( $msg, "Parameter $name value $value" );
+ }
+ }
+
+ // Appended messages (e.g. "disabled in miser mode")
+ if ( isset( $settings[ApiBase::PARAM_HELP_MSG_APPEND] ) ) {
+ $this->assertInternalType( 'array', $settings[ApiBase::PARAM_HELP_MSG_APPEND],
+ "Parameter $name PARAM_HELP_MSG_APPEND is array" );
+ foreach ( $settings[ApiBase::PARAM_HELP_MSG_APPEND] as $i => $msg ) {
+ $this->checkMessage( $msg, "Parameter $name HELP_MSG_APPEND #$i" );
+ }
+ }
+
+ // Info tags (e.g. "only usable in mode 1") are typically shared by
+ // several parameters, so accumulate them and test them later.
+ if ( !empty( $settings[ApiBase::PARAM_HELP_MSG_INFO] ) ) {
+ foreach ( $settings[ApiBase::PARAM_HELP_MSG_INFO] as $i ) {
+ $tags[array_shift( $i )] = 1;
+ }
+ }
+ }
+
+ // Info tags (e.g. "only usable in mode 1") accumulated above
+ foreach ( $tags as $tag => $dummy ) {
+ $this->checkMessage( "apihelp-{$path}-paraminfo-{$tag}", "HELP_MSG_INFO tag $tag" );
+ }
+
+ // Messages for examples.
+ foreach ( $module->getExamplesMessages() as $qs => $msg ) {
+ $this->assertStringStartsNotWith( 'api.php?', $qs,
+ "Query string must not begin with 'api.php?'" );
+ $this->checkMessage( $msg, "Example $qs" );
+ }
+ }
+
+ public static function provideDocumentationExists() {
+ $main = self::getMain();
+ $paths = self::getSubModulePaths( $main->getModuleManager() );
+ array_unshift( $paths, $main->getModulePath() );
+
+ $ret = [];
+ foreach ( $paths as $path ) {
+ foreach ( self::$testGlobals as $globals ) {
+ $g = [];
+ foreach ( $globals as $k => $v ) {
+ $g[] = "$k=" . var_export( $v, 1 );
+ }
+ $k = "Module $path with " . implode( ', ', $g );
+ $ret[$k] = [ $path, $globals ];
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * @dataProvider provideParameterConsistency
+ * @param string $path
+ */
+ public function testParameterConsistency( $path ) {
+ $main = self::getMain();
+ $module = TestingAccessWrapper::newFromObject( $main->getModuleFromPath( $path ) );
+
+ $paramsPlain = $module->getFinalParams();
+ $paramsForHelp = $module->getFinalParams( ApiBase::GET_VALUES_FOR_HELP );
+
+ // avoid warnings about empty tests when no parameter needs to be checked
+ $this->assertTrue( true );
+
+ if ( self::$constantNames === null ) {
+ self::$constantNames = [];
+
+ foreach ( ( new ReflectionClass( 'ApiBase' ) )->getConstants() as $key => $val ) {
+ if ( substr( $key, 0, 6 ) === 'PARAM_' ) {
+ self::$constantNames[$val] = $key;
+ }
+ }
+ }
+
+ foreach ( [ $paramsPlain, $paramsForHelp ] as $params ) {
+ foreach ( $params as $param => $config ) {
+ if ( !is_array( $config ) ) {
+ $config = [ ApiBase::PARAM_DFLT => $config ];
+ }
+ if ( !isset( $config[ApiBase::PARAM_TYPE] ) ) {
+ $config[ApiBase::PARAM_TYPE] = isset( $config[ApiBase::PARAM_DFLT] )
+ ? gettype( $config[ApiBase::PARAM_DFLT] )
+ : 'NULL';
+ }
+
+ foreach ( self::$paramTypes as $key => $types ) {
+ if ( !isset( $config[$key] ) ) {
+ continue;
+ }
+ $keyName = self::$constantNames[$key];
+ $this->validateType( $types, $config[$key], $param, $keyName );
+ }
+
+ foreach ( self::$paramRequirements as $key => $required ) {
+ if ( !isset( $config[$key] ) ) {
+ continue;
+ }
+ foreach ( $required as $requireKey => $requireVal ) {
+ $this->assertArrayHasKey( $requireKey, $config,
+ "$param: When " . self::$constantNames[$key] . " is set, " .
+ self::$constantNames[$requireKey] . " must also be set" );
+ if ( $requireVal !== null ) {
+ $this->assertSame( $requireVal, $config[$requireKey],
+ "$param: When " . self::$constantNames[$key] . " is set, " .
+ self::$constantNames[$requireKey] . " must equal " .
+ var_export( $requireVal, true ) );
+ }
+ }
+ }
+
+ foreach ( self::$paramAllowedTypes as $key => $allowedTypes ) {
+ if ( !isset( $config[$key] ) ) {
+ continue;
+ }
+
+ $actualType = is_array( $config[ApiBase::PARAM_TYPE] )
+ ? 'array' : $config[ApiBase::PARAM_TYPE];
+
+ $this->assertContains(
+ $actualType,
+ (array)$allowedTypes,
+ "$param: " . self::$constantNames[$key] .
+ " can only be used with PARAM_TYPE " .
+ implode( ', ', (array)$allowedTypes )
+ );
+ }
+
+ foreach ( self::$paramProhibitedTypes as $key => $prohibitedTypes ) {
+ if ( !isset( $config[$key] ) ) {
+ continue;
+ }
+
+ $actualType = is_array( $config[ApiBase::PARAM_TYPE] )
+ ? 'array' : $config[ApiBase::PARAM_TYPE];
+
+ $this->assertNotContains(
+ $actualType,
+ (array)$prohibitedTypes,
+ "$param: " . self::$constantNames[$key] .
+ " cannot be used with PARAM_TYPE " .
+ implode( ', ', (array)$prohibitedTypes )
+ );
+ }
+
+ if ( isset( $config[ApiBase::PARAM_DFLT] ) ) {
+ $this->assertFalse(
+ isset( $config[ApiBase::PARAM_REQUIRED] ) &&
+ $config[ApiBase::PARAM_REQUIRED],
+ "$param: A required parameter cannot have a default" );
+
+ $this->validateDefault( $param, $config );
+ }
+
+ if ( $config[ApiBase::PARAM_TYPE] === 'limit' ) {
+ $this->assertTrue(
+ isset( $config[ApiBase::PARAM_MAX] ) &&
+ isset( $config[ApiBase::PARAM_MAX2] ),
+ "$param: PARAM_MAX and PARAM_MAX2 are required for limits"
+ );
+ $this->assertGreaterThanOrEqual(
+ $config[ApiBase::PARAM_MAX],
+ $config[ApiBase::PARAM_MAX2],
+ "$param: PARAM_MAX cannot be greater than PARAM_MAX2"
+ );
+ }
+
+ if (
+ isset( $config[ApiBase::PARAM_MIN] ) &&
+ isset( $config[ApiBase::PARAM_MAX] )
+ ) {
+ $this->assertGreaterThanOrEqual(
+ $config[ApiBase::PARAM_MIN],
+ $config[ApiBase::PARAM_MAX],
+ "$param: PARAM_MIN cannot be greater than PARAM_MAX"
+ );
+ }
+
+ if ( isset( $config[ApiBase::PARAM_RANGE_ENFORCE] ) ) {
+ $this->assertTrue(
+ isset( $config[ApiBase::PARAM_MIN] ) ||
+ isset( $config[ApiBase::PARAM_MAX] ),
+ "$param: PARAM_RANGE_ENFORCE can only be set together with " .
+ "PARAM_MIN or PARAM_MAX"
+ );
+ }
+
+ if ( isset( $config[ApiBase::PARAM_DEPRECATED_VALUES] ) ) {
+ foreach ( $config[ApiBase::PARAM_DEPRECATED_VALUES] as $key => $unused ) {
+ $this->assertContains( $key, $config[ApiBase::PARAM_TYPE],
+ "$param: Deprecated value \"$key\" is not allowed, " .
+ "how can it be deprecated?" );
+ }
+ }
+
+ if (
+ isset( $config[ApiBase::PARAM_ISMULTI_LIMIT1] ) ||
+ isset( $config[ApiBase::PARAM_ISMULTI_LIMIT2] )
+ ) {
+ $this->assertGreaterThanOrEqual( 0, $config[ApiBase::PARAM_ISMULTI_LIMIT1],
+ "$param: PARAM_ISMULTI_LIMIT1 cannot be negative" );
+ // Zero for both doesn't make sense, but you could have
+ // zero for non-bots
+ $this->assertGreaterThanOrEqual( 1, $config[ApiBase::PARAM_ISMULTI_LIMIT2],
+ "$param: PARAM_ISMULTI_LIMIT2 cannot be negative or zero" );
+ $this->assertGreaterThanOrEqual(
+ $config[ApiBase::PARAM_ISMULTI_LIMIT1],
+ $config[ApiBase::PARAM_ISMULTI_LIMIT2],
+ "$param: PARAM_ISMULTI limit cannot be smaller for users with " .
+ "apihighlimits rights" );
+ }
+
+ if ( isset( $config[ApiBase::PARAM_MAX_BYTES] ) ) {
+ $this->assertGreaterThanOrEqual( 1, $config[ApiBase::PARAM_MAX_BYTES],
+ "$param: PARAM_MAX_BYTES cannot be negative or zero" );
+ }
+
+ if ( isset( $config[ApiBase::PARAM_MAX_CHARS] ) ) {
+ $this->assertGreaterThanOrEqual( 1, $config[ApiBase::PARAM_MAX_CHARS],
+ "$param: PARAM_MAX_CHARS cannot be negative or zero" );
+ }
+
+ if (
+ isset( $config[ApiBase::PARAM_MAX_BYTES] ) &&
+ isset( $config[ApiBase::PARAM_MAX_CHARS] )
+ ) {
+ // Length of a string in chars is always <= length in bytes,
+ // so PARAM_MAX_CHARS is pointless if > PARAM_MAX_BYTES
+ $this->assertGreaterThanOrEqual(
+ $config[ApiBase::PARAM_MAX_CHARS],
+ $config[ApiBase::PARAM_MAX_BYTES],
+ "$param: PARAM_MAX_BYTES cannot be less than PARAM_MAX_CHARS"
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Throws if $value does not match one of the types specified in $types.
+ *
+ * @param array $types From self::$paramTypes array
+ * @param mixed $value Value to check
+ * @param string $param Name of param we're checking, for error messages
+ * @param string $desc Description for error messages
+ */
+ private function validateType( $types, $value, $param, $desc ) {
+ if ( count( $types ) === 1 ) {
+ // Only one type allowed
+ if ( is_string( $types[0] ) ) {
+ $this->assertType( $types[0], $value, "$param: $desc type" );
+ } else {
+ // Array whose values have specified types, recurse
+ $this->assertInternalType( 'array', $value, "$param: $desc type" );
+ foreach ( $value as $subvalue ) {
+ $this->validateType( $types[0], $subvalue, $param, "$desc value" );
+ }
+ }
+ } else {
+ // Multiple options
+ foreach ( $types as $type ) {
+ if ( is_string( $type ) ) {
+ if ( class_exists( $type ) || interface_exists( $type ) ) {
+ if ( $value instanceof $type ) {
+ return;
+ }
+ } else {
+ if ( gettype( $value ) === $type ) {
+ return;
+ }
+ }
+ } else {
+ // Array whose values have specified types, recurse
+ try {
+ $this->validateType( [ $type ], $value, $param, "$desc type" );
+ // Didn't throw, so we're good
+ return;
+ } catch ( Exception $unused ) {
+ }
+ }
+ }
+ // Doesn't match any of them
+ $this->fail( "$param: $desc has incorrect type" );
+ }
+ }
+
+ /**
+ * Asserts that $default is a valid default for $type.
+ *
+ * @param string $param Name of param, for error messages
+ * @param array $config Array of configuration options for this parameter
+ */
+ private function validateDefault( $param, $config ) {
+ $type = $config[ApiBase::PARAM_TYPE];
+ $default = $config[ApiBase::PARAM_DFLT];
+
+ if ( !empty( $config[ApiBase::PARAM_ISMULTI] ) ) {
+ if ( $default === '' ) {
+ // The empty array is fine
+ return;
+ }
+ $defaults = explode( '|', $default );
+ $config[ApiBase::PARAM_ISMULTI] = false;
+ foreach ( $defaults as $defaultValue ) {
+ // Only allow integers in their simplest form with no leading
+ // or trailing characters etc.
+ if ( $type === 'integer' && $defaultValue === (string)(int)$defaultValue ) {
+ $defaultValue = (int)$defaultValue;
+ }
+ $config[ApiBase::PARAM_DFLT] = $defaultValue;
+ $this->validateDefault( $param, $config );
+ }
+ return;
+ }
+ switch ( $type ) {
+ case 'boolean':
+ $this->assertFalse( $default,
+ "$param: Boolean params may only default to false" );
+ break;
+
+ case 'integer':
+ $this->assertInternalType( 'integer', $default,
+ "$param: Default $default is not an integer" );
+ break;
+
+ case 'limit':
+ if ( $default === 'max' ) {
+ break;
+ }
+ $this->assertInternalType( 'integer', $default,
+ "$param: Default $default is neither an integer nor \"max\"" );
+ break;
+
+ case 'namespace':
+ $validValues = MWNamespace::getValidNamespaces();
+ if (
+ isset( $config[ApiBase::PARAM_EXTRA_NAMESPACES] ) &&
+ is_array( $config[ApiBase::PARAM_EXTRA_NAMESPACES] )
+ ) {
+ $validValues = array_merge(
+ $validValues,
+ $config[ApiBase::PARAM_EXTRA_NAMESPACES]
+ );
+ }
+ $this->assertContains( $default, $validValues,
+ "$param: Default $default is not a valid namespace" );
+ break;
+
+ case 'NULL':
+ case 'password':
+ case 'string':
+ case 'submodule':
+ case 'tags':
+ case 'text':
+ $this->assertInternalType( 'string', $default,
+ "$param: Default $default is not a string" );
+ break;
+
+ case 'timestamp':
+ if ( $default === 'now' ) {
+ return;
+ }
+ $this->assertNotFalse( wfTimestamp( TS_MW, $default ),
+ "$param: Default $default is not a valid timestamp" );
+ break;
+
+ case 'user':
+ // @todo Should we make user validation a public static method
+ // in ApiBase() or something so we don't have to resort to
+ // this? Or in User for that matter.
+ $wrapper = TestingAccessWrapper::newFromObject( new ApiMain() );
+ try {
+ $wrapper->validateUser( $default, '' );
+ } catch ( ApiUsageException $e ) {
+ $this->fail( "$param: Default $default is not a valid username/IP address" );
+ }
+ break;
+
+ default:
+ if ( is_array( $type ) ) {
+ $this->assertContains( $default, $type,
+ "$param: Default $default is not any of " .
+ implode( ', ', $type ) );
+ } else {
+ $this->fail( "Unrecognized type $type" );
+ }
+ }
+ }
+
+ /**
+ * @return array List of API module paths to test
+ */
+ public static function provideParameterConsistency() {
+ $main = self::getMain();
+ $paths = self::getSubModulePaths( $main->getModuleManager() );
+ array_unshift( $paths, $main->getModulePath() );
+
+ $ret = [];
+ foreach ( $paths as $path ) {
+ $ret[] = [ $path ];
+ }
+ return $ret;
+ }
+
+ /**
+ * Return paths of all submodules in an ApiModuleManager, recursively
+ * @param ApiModuleManager $manager
+ * @return string[]
+ */
+ protected static function getSubModulePaths( ApiModuleManager $manager ) {
+ $paths = [];
+ foreach ( $manager->getNames() as $name ) {
+ $module = $manager->getModule( $name );
+ $paths[] = $module->getModulePath();
+ $subManager = $module->getModuleManager();
+ if ( $subManager ) {
+ $paths = array_merge( $paths, self::getSubModulePaths( $subManager ) );
+ }
+ }
+ return $paths;
+ }
+}
diff --git a/www/wiki/tests/phpunit/structure/AutoLoaderTest.php b/www/wiki/tests/phpunit/structure/AutoLoaderTest.php
new file mode 100644
index 00000000..217232e3
--- /dev/null
+++ b/www/wiki/tests/phpunit/structure/AutoLoaderTest.php
@@ -0,0 +1,171 @@
+<?php
+
+class AutoLoaderTest extends MediaWikiTestCase {
+ protected function setUp() {
+ parent::setUp();
+
+ // Fancy dance to trigger a rebuild of AutoLoader::$autoloadLocalClassesLower
+ $this->mergeMwGlobalArrayValue( 'wgAutoloadLocalClasses', [
+ 'TestAutoloadedLocalClass' =>
+ __DIR__ . '/../data/autoloader/TestAutoloadedLocalClass.php',
+ 'TestAutoloadedCamlClass' =>
+ __DIR__ . '/../data/autoloader/TestAutoloadedCamlClass.php',
+ 'TestAutoloadedSerializedClass' =>
+ __DIR__ . '/../data/autoloader/TestAutoloadedSerializedClass.php',
+ ] );
+ AutoLoader::resetAutoloadLocalClassesLower();
+
+ $this->mergeMwGlobalArrayValue( 'wgAutoloadClasses', [
+ 'TestAutoloadedClass' => __DIR__ . '/../data/autoloader/TestAutoloadedClass.php',
+ ] );
+ }
+
+ /**
+ * Assert that there were no classes loaded that are not registered with the AutoLoader.
+ *
+ * For example foo.php having class Foo and class Bar but only registering Foo.
+ * This is important because we should not be relying on Foo being used before Bar.
+ */
+ public function testAutoLoadConfig() {
+ $results = self::checkAutoLoadConf();
+
+ $this->assertEquals(
+ $results['expected'],
+ $results['actual']
+ );
+ }
+
+ protected static function checkAutoLoadConf() {
+ global $wgAutoloadLocalClasses, $wgAutoloadClasses, $IP;
+
+ // wgAutoloadLocalClasses has precedence, just like in includes/AutoLoader.php
+ $expected = $wgAutoloadLocalClasses + $wgAutoloadClasses;
+ $actual = [];
+
+ $files = array_unique( $expected );
+
+ foreach ( $files as $class => $file ) {
+ // Only prefix $IP if it doesn't have it already.
+ // Generally local classes don't have it, and those from extensions and test suites do.
+ if ( substr( $file, 0, 1 ) != '/' && substr( $file, 1, 1 ) != ':' ) {
+ $filePath = "$IP/$file";
+ } else {
+ $filePath = $file;
+ }
+
+ if ( !file_exists( $filePath ) ) {
+ $actual[$class] = "[file '$filePath' does not exist]";
+ continue;
+ }
+
+ Wikimedia\suppressWarnings();
+ $contents = file_get_contents( $filePath );
+ Wikimedia\restoreWarnings();
+
+ if ( $contents === false ) {
+ $actual[$class] = "[couldn't read file '$filePath']";
+ continue;
+ }
+
+ // We could use token_get_all() here, but this is faster
+ // Note: Keep in sync with ClassCollector
+ $matches = [];
+ preg_match_all( '/
+ ^ [\t ]* (?:
+ (?:final\s+)? (?:abstract\s+)? (?:class|interface|trait) \s+
+ (?P<class> [a-zA-Z0-9_]+)
+ |
+ class_alias \s* \( \s*
+ ([\'"]) (?P<original> [^\'"]+) \g{-2} \s* , \s*
+ ([\'"]) (?P<alias> [^\'"]+ ) \g{-2} \s*
+ \) \s* ;
+ |
+ class_alias \s* \( \s*
+ (?P<originalStatic> [a-zA-Z0-9_]+)::class \s* , \s*
+ ([\'"]) (?P<aliasString> [^\'"]+ ) \g{-2} \s*
+ \) \s* ;
+ )
+ /imx', $contents, $matches, PREG_SET_ORDER );
+
+ $namespaceMatch = [];
+ preg_match( '/
+ ^ [\t ]*
+ namespace \s+
+ ([a-zA-Z0-9_]+(\\\\[a-zA-Z0-9_]+)*)
+ \s* ;
+ /imx', $contents, $namespaceMatch );
+ $fileNamespace = $namespaceMatch ? $namespaceMatch[1] . '\\' : '';
+
+ $classesInFile = [];
+ $aliasesInFile = [];
+
+ foreach ( $matches as $match ) {
+ if ( !empty( $match['class'] ) ) {
+ // 'class Foo {}'
+ $class = $fileNamespace . $match['class'];
+ $actual[$class] = $file;
+ $classesInFile[$class] = true;
+ } else {
+ if ( !empty( $match['original'] ) ) {
+ // 'class_alias( "Foo", "Bar" );'
+ $aliasesInFile[$match['alias']] = $match['original'];
+ } else {
+ // 'class_alias( Foo::class, "Bar" );'
+ $aliasesInFile[$match['aliasString']] = $fileNamespace . $match['originalStatic'];
+ }
+ }
+ }
+
+ // Only accept aliases for classes in the same file, because for correct
+ // behavior, all aliases for a class must be set up when the class is loaded
+ // (see <https://bugs.php.net/bug.php?id=61422>).
+ foreach ( $aliasesInFile as $alias => $class ) {
+ if ( isset( $classesInFile[$class] ) ) {
+ $actual[$alias] = $file;
+ } else {
+ $actual[$alias] = "[original class not in $file]";
+ }
+ }
+ }
+
+ return [
+ 'expected' => $expected,
+ 'actual' => $actual,
+ ];
+ }
+
+ function testCoreClass() {
+ $this->assertTrue( class_exists( 'TestAutoloadedLocalClass' ) );
+ }
+
+ function testExtensionClass() {
+ $this->assertTrue( class_exists( 'TestAutoloadedClass' ) );
+ }
+
+ function testWrongCaseClass() {
+ $this->setMwGlobals( 'wgAutoloadAttemptLowercase', true );
+
+ $this->assertTrue( class_exists( 'testautoLoadedcamlCLASS' ) );
+ }
+
+ function testWrongCaseSerializedClass() {
+ $this->setMwGlobals( 'wgAutoloadAttemptLowercase', true );
+
+ $dummyCereal = 'O:29:"testautoloadedserializedclass":0:{}';
+ $uncerealized = unserialize( $dummyCereal );
+ $this->assertFalse( $uncerealized instanceof __PHP_Incomplete_Class,
+ "unserialize() can load classes case-insensitively." );
+ }
+
+ function testAutoloadOrder() {
+ $path = realpath( __DIR__ . '/../../..' );
+ $oldAutoload = file_get_contents( $path . '/autoload.php' );
+ $generator = new AutoloadGenerator( $path, 'local' );
+ $generator->setExcludePaths( array_values( AutoLoader::getAutoloadNamespaces() ) );
+ $generator->initMediaWikiDefault();
+ $newAutoload = $generator->getAutoload( 'maintenance/generateLocalAutoload.php' );
+
+ $this->assertEquals( $oldAutoload, $newAutoload, 'autoload.php does not match' .
+ ' output of generateLocalAutoload.php script.' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/structure/AvailableRightsTest.php b/www/wiki/tests/phpunit/structure/AvailableRightsTest.php
new file mode 100644
index 00000000..6c2ff024
--- /dev/null
+++ b/www/wiki/tests/phpunit/structure/AvailableRightsTest.php
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * Try to make sure that extensions register all rights in $wgAvailableRights
+ * or via the 'UserGetAllRights' hook.
+ *
+ * @author Marius Hoch < hoo@online.de >
+ */
+class AvailableRightsTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * Returns all rights that should be in $wgAvailableRights + all rights
+ * registered via the 'UserGetAllRights' hook + all "core" rights.
+ *
+ * @return string[]
+ */
+ private function getAllVisibleRights() {
+ global $wgGroupPermissions, $wgRevokePermissions;
+
+ $rights = User::getAllRights();
+
+ foreach ( $wgGroupPermissions as $permissions ) {
+ $rights = array_merge( $rights, array_keys( $permissions ) );
+ }
+
+ foreach ( $wgRevokePermissions as $permissions ) {
+ $rights = array_merge( $rights, array_keys( $permissions ) );
+ }
+
+ $rights = array_unique( $rights );
+ sort( $rights );
+
+ return $rights;
+ }
+
+ public function testAvailableRights() {
+ $missingRights = array_diff(
+ $this->getAllVisibleRights(),
+ User::getAllRights()
+ );
+
+ $this->assertEquals(
+ [],
+ // Re-index to produce nicer output, keys are meaningless.
+ array_values( $missingRights ),
+ 'Additional user rights need to be added to $wgAvailableRights or ' .
+ 'via the "UserGetAllRights" hook. See the instructions at: ' .
+ 'https://www.mediawiki.org/wiki/Manual:User_rights#Adding_new_rights'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/structure/ContentHandlerSanityTest.php b/www/wiki/tests/phpunit/structure/ContentHandlerSanityTest.php
new file mode 100644
index 00000000..c8bcd60d
--- /dev/null
+++ b/www/wiki/tests/phpunit/structure/ContentHandlerSanityTest.php
@@ -0,0 +1,59 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * 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
+ */
+
+class ContentHandlerSanityTest extends MediaWikiTestCase {
+
+ public static function provideHandlers() {
+ $models = ContentHandler::getContentModels();
+ $handlers = [];
+ foreach ( $models as $model ) {
+ $handlers[] = [ ContentHandler::getForModelID( $model ) ];
+ }
+
+ return $handlers;
+ }
+
+ /**
+ * @dataProvider provideHandlers
+ * @param ContentHandler $handler
+ */
+ public function testMakeEmptyContent( ContentHandler $handler ) {
+ $content = $handler->makeEmptyContent();
+ $this->assertInstanceOf( Content::class, $content );
+ if ( $handler instanceof TextContentHandler ) {
+ // TextContentHandler::getContentClass() is protected, so bypass
+ // that restriction
+ $testingWrapper = TestingAccessWrapper::newFromObject( $handler );
+ $this->assertInstanceOf( $testingWrapper->getContentClass(), $content );
+ }
+
+ $handlerClass = get_class( $handler );
+ $contentClass = get_class( $content );
+
+ if ( $handler->supportsDirectEditing() ) {
+ $this->assertTrue(
+ $content->isValid(),
+ "$handlerClass::makeEmptyContent() did not return a valid content ($contentClass::isValid())"
+ );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/structure/DatabaseIntegrationTest.php b/www/wiki/tests/phpunit/structure/DatabaseIntegrationTest.php
new file mode 100644
index 00000000..b0c1c8f1
--- /dev/null
+++ b/www/wiki/tests/phpunit/structure/DatabaseIntegrationTest.php
@@ -0,0 +1,56 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\Database;
+
+/**
+ * @group Database
+ */
+class DatabaseIntegrationTest extends MediaWikiTestCase {
+ /**
+ * @var Database
+ */
+ protected $db;
+
+ private $functionTest = false;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->db = wfGetDB( DB_MASTER );
+ }
+
+ protected function tearDown() {
+ parent::tearDown();
+ if ( $this->functionTest ) {
+ $this->dropFunctions();
+ $this->functionTest = false;
+ }
+ $this->db->restoreFlags( IDatabase::RESTORE_INITIAL );
+ }
+
+ public function testStoredFunctions() {
+ if ( !in_array( wfGetDB( DB_MASTER )->getType(), [ 'mysql', 'postgres' ] ) ) {
+ $this->markTestSkipped( 'MySQL or Postgres required' );
+ }
+ global $IP;
+ $this->dropFunctions();
+ $this->functionTest = true;
+ $this->assertTrue(
+ $this->db->sourceFile( "$IP/tests/phpunit/data/db/{$this->db->getType()}/functions.sql" )
+ );
+ $res = $this->db->query( 'SELECT mw_test_function() AS test', __METHOD__ );
+ $this->assertEquals( 42, $res->fetchObject()->test );
+ }
+
+ private function dropFunctions() {
+ $this->db->query( 'DROP FUNCTION IF EXISTS mw_test_function'
+ . ( $this->db->getType() == 'postgres' ? '()' : '' )
+ );
+ }
+
+ public function testUnknownTableCorruptsResults() {
+ $res = $this->db->select( 'page', '*', [ 'page_id' => 1 ] );
+ $this->assertFalse( $this->db->tableExists( 'foobarbaz' ) );
+ $this->assertInternalType( 'int', $res->numRows() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/structure/ExtensionJsonValidationTest.php b/www/wiki/tests/phpunit/structure/ExtensionJsonValidationTest.php
new file mode 100644
index 00000000..60c97ccf
--- /dev/null
+++ b/www/wiki/tests/phpunit/structure/ExtensionJsonValidationTest.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * 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
+ */
+
+/**
+ * Validates all loaded extensions and skins using the ExtensionRegistry
+ * against the extension.json schema in the docs/ folder.
+ */
+class ExtensionJsonValidationTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @var ExtensionJsonValidator
+ */
+ protected $validator;
+
+ public function setUp() {
+ parent::setUp();
+
+ $this->validator = new ExtensionJsonValidator( [ $this, 'markTestSkipped' ] );
+ $this->validator->checkDependencies();
+
+ if ( !ExtensionRegistry::getInstance()->getAllThings() ) {
+ $this->markTestSkipped(
+ 'There are no extensions or skins loaded via the ExtensionRegistry'
+ );
+ }
+ }
+
+ public static function providePassesValidation() {
+ $values = [];
+ foreach ( ExtensionRegistry::getInstance()->getAllThings() as $thing ) {
+ $values[] = [ $thing['path'] ];
+ }
+
+ return $values;
+ }
+
+ /**
+ * @dataProvider providePassesValidation
+ * @param string $path Path to thing's json file
+ */
+ public function testPassesValidation( $path ) {
+ try {
+ $this->validator->validate( $path );
+ // All good
+ $this->assertTrue( true );
+ } catch ( ExtensionJsonValidationError $e ) {
+ $this->assertEquals( false, $e->getMessage() );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/structure/ResourcesTest.php b/www/wiki/tests/phpunit/structure/ResourcesTest.php
new file mode 100644
index 00000000..62ddaceb
--- /dev/null
+++ b/www/wiki/tests/phpunit/structure/ResourcesTest.php
@@ -0,0 +1,349 @@
+<?php
+/**
+ * Sanity checks for making sure registered resources are sane.
+ *
+ * @file
+ * @author Antoine Musso
+ * @author Niklas Laxström
+ * @author Santhosh Thottingal
+ * @author Timo Tijhof
+ * @copyright © 2012, Antoine Musso
+ * @copyright © 2012, Niklas Laxström
+ * @copyright © 2012, Santhosh Thottingal
+ * @copyright © 2012, Timo Tijhof
+ */
+class ResourcesTest extends MediaWikiTestCase {
+
+ /**
+ * @dataProvider provideResourceFiles
+ */
+ public function testFileExistence( $filename, $module, $resource ) {
+ $this->assertFileExists( $filename,
+ "File '$resource' referenced by '$module' must exist."
+ );
+ }
+
+ /**
+ * @dataProvider provideMediaStylesheets
+ */
+ public function testStyleMedia( $moduleName, $media, $filename, $css ) {
+ $cssText = CSSMin::minify( $css->cssText );
+
+ $this->assertTrue(
+ strpos( $cssText, '@media' ) === false,
+ 'Stylesheets should not both specify "media" and contain @media'
+ );
+ }
+
+ public function testVersionHash() {
+ $data = self::getAllModules();
+ foreach ( $data['modules'] as $moduleName => $module ) {
+ $version = $module->getVersionHash( $data['context'] );
+ $this->assertEquals( 7, strlen( $version ), "$moduleName must use ResourceLoader::makeHash" );
+ }
+ }
+
+ /**
+ * Verify that nothing explicitly depends on base modules, or other raw modules.
+ *
+ * Depending on them is unsupported as they are not registered client-side by the startup module.
+ *
+ * TODO Modules can dynamically choose dependencies based on context. This method does not
+ * test such dependencies. The same goes for testMissingDependencies() and
+ * testUnsatisfiableDependencies().
+ */
+ public function testIllegalDependencies() {
+ $data = self::getAllModules();
+
+ $illegalDeps = ResourceLoaderStartUpModule::getStartupModules();
+ foreach ( $data['modules'] as $moduleName => $module ) {
+ if ( $module->isRaw() ) {
+ $illegalDeps[] = $moduleName;
+ }
+ }
+ $illegalDeps = array_unique( $illegalDeps );
+
+ /** @var ResourceLoaderModule $module */
+ foreach ( $data['modules'] as $moduleName => $module ) {
+ foreach ( $illegalDeps as $illegalDep ) {
+ $this->assertNotContains(
+ $illegalDep,
+ $module->getDependencies( $data['context'] ),
+ "Module '$moduleName' must not depend on '$illegalDep'"
+ );
+ }
+ }
+ }
+
+ /**
+ * Verify that all modules specified as dependencies of other modules actually exist.
+ */
+ public function testMissingDependencies() {
+ $data = self::getAllModules();
+ $validDeps = array_keys( $data['modules'] );
+
+ /** @var ResourceLoaderModule $module */
+ foreach ( $data['modules'] as $moduleName => $module ) {
+ foreach ( $module->getDependencies( $data['context'] ) as $dep ) {
+ $this->assertContains(
+ $dep,
+ $validDeps,
+ "The module '$dep' required by '$moduleName' must exist"
+ );
+ }
+ }
+ }
+
+ /**
+ * Verify that all specified messages actually exist.
+ */
+ public function testMissingMessages() {
+ $data = self::getAllModules();
+ $lang = Language::factory( 'en' );
+
+ /** @var ResourceLoaderModule $module */
+ foreach ( $data['modules'] as $moduleName => $module ) {
+ foreach ( $module->getMessages() as $msgKey ) {
+ $this->assertTrue(
+ wfMessage( $msgKey )->useDatabase( false )->inLanguage( $lang )->exists(),
+ "Message '$msgKey' required by '$moduleName' must exist"
+ );
+ }
+ }
+ }
+
+ /**
+ * Verify that all dependencies of all modules are always satisfiable with the 'targets' defined
+ * for the involved modules.
+ *
+ * Example: A depends on B. A has targets: mobile, desktop. B has targets: desktop. Therefore the
+ * dependency is sometimes unsatisfiable: it's impossible to load module A on mobile.
+ */
+ public function testUnsatisfiableDependencies() {
+ $data = self::getAllModules();
+
+ /** @var ResourceLoaderModule $module */
+ foreach ( $data['modules'] as $moduleName => $module ) {
+ $moduleTargets = $module->getTargets();
+ foreach ( $module->getDependencies( $data['context'] ) as $dep ) {
+ if ( !isset( $data['modules'][$dep] ) ) {
+ // Missing dependencies reported by testMissingDependencies
+ continue;
+ }
+ $targets = $data['modules'][$dep]->getTargets();
+ foreach ( $moduleTargets as $moduleTarget ) {
+ $this->assertContains(
+ $moduleTarget,
+ $targets,
+ "The module '$moduleName' must not have target '$moduleTarget' "
+ . "because its dependency '$dep' does not have it"
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * CSSMin::getLocalFileReferences should ignore url(...) expressions
+ * that have been commented out.
+ */
+ public function testCommentedLocalFileReferences() {
+ $basepath = __DIR__ . '/../data/css/';
+ $css = file_get_contents( $basepath . 'comments.css' );
+ $files = CSSMin::getLocalFileReferences( $css, $basepath );
+ $expected = [ $basepath . 'not-commented.gif' ];
+ $this->assertArrayEquals(
+ $expected,
+ $files,
+ 'Url(...) expression in comment should be omitted.'
+ );
+ }
+
+ /**
+ * Get all registered modules from ResouceLoader.
+ * @return array
+ */
+ protected static function getAllModules() {
+ global $wgEnableJavaScriptTest;
+
+ // Test existance of test suite files as well
+ // (can't use setUp or setMwGlobals because providers are static)
+ $org_wgEnableJavaScriptTest = $wgEnableJavaScriptTest;
+ $wgEnableJavaScriptTest = true;
+
+ // Initialize ResourceLoader
+ $rl = new ResourceLoader();
+
+ $modules = [];
+
+ foreach ( $rl->getModuleNames() as $moduleName ) {
+ $modules[$moduleName] = $rl->getModule( $moduleName );
+ }
+
+ // Restore settings
+ $wgEnableJavaScriptTest = $org_wgEnableJavaScriptTest;
+
+ return [
+ 'modules' => $modules,
+ 'resourceloader' => $rl,
+ 'context' => new ResourceLoaderContext( $rl, new FauxRequest() )
+ ];
+ }
+
+ /**
+ * Get all stylesheet files from modules that are an instance of
+ * ResourceLoaderFileModule (or one of its subclasses).
+ */
+ public static function provideMediaStylesheets() {
+ $data = self::getAllModules();
+ $cases = [];
+
+ foreach ( $data['modules'] as $moduleName => $module ) {
+ if ( !$module instanceof ResourceLoaderFileModule ) {
+ continue;
+ }
+
+ $reflectedModule = new ReflectionObject( $module );
+
+ $getStyleFiles = $reflectedModule->getMethod( 'getStyleFiles' );
+ $getStyleFiles->setAccessible( true );
+
+ $readStyleFile = $reflectedModule->getMethod( 'readStyleFile' );
+ $readStyleFile->setAccessible( true );
+
+ $styleFiles = $getStyleFiles->invoke( $module, $data['context'] );
+
+ $flip = $module->getFlip( $data['context'] );
+
+ foreach ( $styleFiles as $media => $files ) {
+ if ( $media && $media !== 'all' ) {
+ foreach ( $files as $file ) {
+ $cases[] = [
+ $moduleName,
+ $media,
+ $file,
+ // XXX: Wrapped in an object to keep it out of PHPUnit output
+ (object)[
+ 'cssText' => $readStyleFile->invoke(
+ $module,
+ $file,
+ $flip,
+ $data['context']
+ )
+ ],
+ ];
+ }
+ }
+ }
+ }
+
+ return $cases;
+ }
+
+ /**
+ * Get all resource files from modules that are an instance of
+ * ResourceLoaderFileModule (or one of its subclasses).
+ *
+ * Since the raw data is stored in protected properties, we have to
+ * overrride this through ReflectionObject methods.
+ */
+ public static function provideResourceFiles() {
+ $data = self::getAllModules();
+ $cases = [];
+
+ // See also ResourceLoaderFileModule::__construct
+ $filePathProps = [
+ // Lists of file paths
+ 'lists' => [
+ 'scripts',
+ 'debugScripts',
+ 'styles',
+ ],
+
+ // Collated lists of file paths
+ 'nested-lists' => [
+ 'languageScripts',
+ 'skinScripts',
+ 'skinStyles',
+ ],
+ ];
+
+ foreach ( $data['modules'] as $moduleName => $module ) {
+ if ( !$module instanceof ResourceLoaderFileModule ) {
+ continue;
+ }
+
+ $reflectedModule = new ReflectionObject( $module );
+
+ $files = [];
+
+ foreach ( $filePathProps['lists'] as $propName ) {
+ $property = $reflectedModule->getProperty( $propName );
+ $property->setAccessible( true );
+ $list = $property->getValue( $module );
+ foreach ( $list as $key => $value ) {
+ // 'scripts' are numeral arrays.
+ // 'styles' can be numeral or associative.
+ // In case of associative the key is the file path
+ // and the value is the 'media' attribute.
+ if ( is_int( $key ) ) {
+ $files[] = $value;
+ } else {
+ $files[] = $key;
+ }
+ }
+ }
+
+ foreach ( $filePathProps['nested-lists'] as $propName ) {
+ $property = $reflectedModule->getProperty( $propName );
+ $property->setAccessible( true );
+ $lists = $property->getValue( $module );
+ foreach ( $lists as $list ) {
+ foreach ( $list as $key => $value ) {
+ // We need the same filter as for 'lists',
+ // due to 'skinStyles'.
+ if ( is_int( $key ) ) {
+ $files[] = $value;
+ } else {
+ $files[] = $key;
+ }
+ }
+ }
+ }
+
+ // Get method for resolving the paths to full paths
+ $method = $reflectedModule->getMethod( 'getLocalPath' );
+ $method->setAccessible( true );
+
+ // Populate cases
+ foreach ( $files as $file ) {
+ $cases[] = [
+ $method->invoke( $module, $file ),
+ $moduleName,
+ ( $file instanceof ResourceLoaderFilePath ? $file->getPath() : $file ),
+ ];
+ }
+
+ // To populate missingLocalFileRefs. Not sure how sane this is inside this test...
+ $module->readStyleFiles(
+ $module->getStyleFiles( $data['context'] ),
+ $module->getFlip( $data['context'] ),
+ $data['context']
+ );
+
+ $property = $reflectedModule->getProperty( 'missingLocalFileRefs' );
+ $property->setAccessible( true );
+ $missingLocalFileRefs = $property->getValue( $module );
+
+ foreach ( $missingLocalFileRefs as $file ) {
+ $cases[] = [
+ $file,
+ $moduleName,
+ $file,
+ ];
+ }
+ }
+
+ return $cases;
+ }
+}
diff --git a/www/wiki/tests/phpunit/structure/StructureTest.php b/www/wiki/tests/phpunit/structure/StructureTest.php
new file mode 100644
index 00000000..4df791ec
--- /dev/null
+++ b/www/wiki/tests/phpunit/structure/StructureTest.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * The tests here verify the structure of the code. This is for outright bugs,
+ * not just style issues.
+ */
+
+class StructureTest extends MediaWikiTestCase {
+ /**
+ * Verify all files that appear to be tests have file names ending in
+ * Test. If the file names do not end in Test, they will not be run.
+ * @group medium
+ */
+ public function testUnitTestFileNamesEndWithTest() {
+ if ( wfIsWindows() ) {
+ $this->markTestSkipped( 'This test does not work on Windows' );
+ }
+ $rootPath = escapeshellarg( __DIR__ . '/..' );
+ $testClassRegex = implode( '|', [
+ 'ApiFormatTestBase',
+ 'ApiTestCase',
+ 'ApiQueryTestBase',
+ 'ApiQueryContinueTestBase',
+ 'MediaWikiLangTestCase',
+ 'MediaWikiMediaTestCase',
+ 'MediaWikiTestCase',
+ 'ResourceLoaderTestCase',
+ 'PHPUnit_Framework_TestCase',
+ '\\?PHPUnit\\Framework\\TestCase',
+ 'TestCase', // \PHPUnit\Framework\TestCase with appropriate use statement
+ 'DumpTestCase',
+ ] );
+ $testClassRegex = "^class .* extends ($testClassRegex)";
+ $finder = "find $rootPath -name '*.php' '!' -name '*Test.php'" .
+ " | xargs grep -El '$testClassRegex|function suite\('";
+
+ $results = null;
+ $exitCode = null;
+ exec( $finder, $results, $exitCode );
+
+ $this->assertEquals(
+ 0,
+ $exitCode,
+ 'Verify find/grep command succeeds.'
+ );
+
+ $results = array_filter(
+ $results,
+ [ $this, 'filterSuites' ]
+ );
+ $strip = strlen( $rootPath ) - 1;
+ foreach ( $results as $k => $v ) {
+ $results[$k] = substr( $v, $strip );
+ }
+ $this->assertEquals(
+ [],
+ $results,
+ "Unit test file in $rootPath must end with Test."
+ );
+ }
+
+ /**
+ * Filter to remove testUnitTestFileNamesEndWithTest false positives.
+ * @param string $filename
+ * @return bool
+ */
+ public function filterSuites( $filename ) {
+ return strpos( $filename, __DIR__ . '/../suites/' ) !== 0;
+ }
+}
diff --git a/www/wiki/tests/phpunit/suite.xml b/www/wiki/tests/phpunit/suite.xml
new file mode 100644
index 00000000..16c0c17c
--- /dev/null
+++ b/www/wiki/tests/phpunit/suite.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit bootstrap="./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"
+ stderr="true"
+ verbose="false">
+ <testsuites>
+ <testsuite name="includes">
+ <directory>includes</directory>
+ <!-- Parser tests must be invoked via their suite -->
+ <exclude>includes/parser/ParserIntegrationTest.php</exclude>
+ </testsuite>
+ <testsuite name="languages">
+ <directory>languages</directory>
+ </testsuite>
+ <testsuite name="parsertests">
+ <file>suites/CoreParserTestSuite.php</file>
+ <file>suites/ExtensionsParserTestSuite.php</file>
+ </testsuite>
+ <testsuite name="skins">
+ <directory>skins</directory>
+ <directory>structure</directory>
+ <file>suites/ExtensionsTestSuite.php</file>
+ <file>suites/LessTestSuite.php</file>
+ </testsuite>
+ <!-- As there is a class Maintenance, we cannot use the name "maintenance" directly -->
+ <testsuite name="maintenance_suite">
+ <directory>maintenance</directory>
+ </testsuite>
+ <testsuite name="structure">
+ <directory>structure</directory>
+ </testsuite>
+ <testsuite name="tests">
+ <directory>tests</directory>
+ </testsuite>
+ <testsuite name="uploadfromurl">
+ <file>suites/UploadFromUrlTestSuite.php</file>
+ </testsuite>
+ <testsuite name="extensions">
+ <directory>structure</directory>
+ <file>suites/ExtensionsTestSuite.php</file>
+ <file>suites/ExtensionsParserTestSuite.php</file>
+ <file>suites/LessTestSuite.php</file>
+ </testsuite>
+ </testsuites>
+ <groups>
+ <exclude>
+ <group>Utility</group>
+ <group>Broken</group>
+ <group>Stub</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>
+ </exclude>
+ </whitelist>
+ </filter>
+</phpunit>
diff --git a/www/wiki/tests/phpunit/suites/CoreParserTestSuite.php b/www/wiki/tests/phpunit/suites/CoreParserTestSuite.php
new file mode 100644
index 00000000..7a33a222
--- /dev/null
+++ b/www/wiki/tests/phpunit/suites/CoreParserTestSuite.php
@@ -0,0 +1,9 @@
+<?php
+
+class CoreParserTestSuite extends PHPUnit_Framework_TestSuite {
+
+ public static function suite() {
+ return ParserTestTopLevelSuite::suite( ParserTestTopLevelSuite::CORE_ONLY );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/suites/ExtensionsParserTestSuite.php b/www/wiki/tests/phpunit/suites/ExtensionsParserTestSuite.php
new file mode 100644
index 00000000..8d6ee079
--- /dev/null
+++ b/www/wiki/tests/phpunit/suites/ExtensionsParserTestSuite.php
@@ -0,0 +1,8 @@
+<?php
+class ExtensionsParserTestSuite extends PHPUnit_Framework_TestSuite {
+
+ public static function suite() {
+ return ParserTestTopLevelSuite::suite( ParserTestTopLevelSuite::NO_CORE );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/suites/ExtensionsTestSuite.php b/www/wiki/tests/phpunit/suites/ExtensionsTestSuite.php
new file mode 100644
index 00000000..02934fa7
--- /dev/null
+++ b/www/wiki/tests/phpunit/suites/ExtensionsTestSuite.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * This test suite runs unit tests registered by extensions.
+ * See https://www.mediawiki.org/wiki/Manual:Hooks/UnitTestsList for details of
+ * how to register your tests.
+ */
+
+class ExtensionsTestSuite extends PHPUnit_Framework_TestSuite {
+ public function __construct() {
+ parent::__construct();
+
+ $paths = [];
+ // Autodiscover extension unit tests
+ $registry = ExtensionRegistry::getInstance();
+ foreach ( $registry->getAllThings() as $info ) {
+ $paths[] = dirname( $info['path'] ) . '/tests/phpunit';
+ }
+ // Extensions can return a list of files or directories
+ Hooks::run( 'UnitTestsList', [ &$paths ] );
+ foreach ( array_unique( $paths ) as $path ) {
+ if ( is_dir( $path ) ) {
+ // If the path is a directory, search for test cases.
+ // @since 1.24
+ $suffixes = [ 'Test.php' ];
+ $fileIterator = new File_Iterator_Facade();
+ $matchingFiles = $fileIterator->getFilesAsArray( $path, $suffixes );
+ $this->addTestFiles( $matchingFiles );
+ } elseif ( file_exists( $path ) ) {
+ // Add a single test case or suite class
+ $this->addTestFile( $path );
+ }
+ }
+ if ( !$paths ) {
+ $this->addTest( new DummyExtensionsTest( 'testNothing' ) );
+ }
+ }
+
+ public static function suite() {
+ return new self;
+ }
+}
+
+/**
+ * Needed to avoid warnings like 'No tests found in class "ExtensionsTestSuite".'
+ * when no extensions with tests are used.
+ */
+class DummyExtensionsTest extends MediaWikiTestCase {
+ public function testNothing() {
+ $this->assertTrue( true );
+ }
+}
diff --git a/www/wiki/tests/phpunit/suites/LessTestSuite.php b/www/wiki/tests/phpunit/suites/LessTestSuite.php
new file mode 100644
index 00000000..26a784ad
--- /dev/null
+++ b/www/wiki/tests/phpunit/suites/LessTestSuite.php
@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * @author Sam Smith <samsmith@wikimedia.org>
+ */
+class LessTestSuite extends PHPUnit_Framework_TestSuite {
+ public function __construct() {
+ parent::__construct();
+
+ $resourceLoader = new ResourceLoader();
+
+ foreach ( $resourceLoader->getModuleNames() as $name ) {
+ $module = $resourceLoader->getModule( $name );
+ if ( !$module || !$module instanceof ResourceLoaderFileModule ) {
+ continue;
+ }
+
+ foreach ( $module->getAllStyleFiles() as $styleFile ) {
+ // TODO (phuedx, 2014-03-19) The
+ // ResourceLoaderFileModule class shouldn't
+ // know how to get a file's extension.
+ if ( $module->getStyleSheetLang( $styleFile ) !== 'less' ) {
+ continue;
+ }
+
+ $this->addTest( new LessFileCompilationTest( $styleFile, $module ) );
+ }
+ }
+ }
+
+ public static function suite() {
+ return new static;
+ }
+}
diff --git a/www/wiki/tests/phpunit/suites/ParserTestFileSuite.php b/www/wiki/tests/phpunit/suites/ParserTestFileSuite.php
new file mode 100644
index 00000000..b72d8b84
--- /dev/null
+++ b/www/wiki/tests/phpunit/suites/ParserTestFileSuite.php
@@ -0,0 +1,32 @@
+<?php
+
+/**
+ * This is the suite class for running tests within a single .txt source file.
+ * It is not invoked directly. Use --filter to select files, or
+ * use parserTests.php.
+ */
+class ParserTestFileSuite extends PHPUnit_Framework_TestSuite {
+ private $ptRunner;
+ private $ptFileName;
+ private $ptFileInfo;
+
+ function __construct( $runner, $name, $fileName ) {
+ parent::__construct( $name );
+ $this->ptRunner = $runner;
+ $this->ptFileName = $fileName;
+ $this->ptFileInfo = TestFileReader::read( $this->ptFileName );
+
+ foreach ( $this->ptFileInfo['tests'] as $test ) {
+ $this->addTest( new ParserIntegrationTest( $runner, $fileName, $test ),
+ [ 'Database', 'Parser', 'ParserTests' ] );
+ }
+ }
+
+ function setUp() {
+ if ( !$this->ptRunner->meetsRequirements( $this->ptFileInfo['requirements'] ) ) {
+ $this->markTestSuiteSkipped( 'required extension not enabled' );
+ } else {
+ $this->ptRunner->addArticles( $this->ptFileInfo[ 'articles'] );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/suites/ParserTestTopLevelSuite.php b/www/wiki/tests/phpunit/suites/ParserTestTopLevelSuite.php
new file mode 100644
index 00000000..07b18f59
--- /dev/null
+++ b/www/wiki/tests/phpunit/suites/ParserTestTopLevelSuite.php
@@ -0,0 +1,160 @@
+<?php
+use Wikimedia\ScopedCallback;
+
+/**
+ * The UnitTest must be either a class that inherits from MediaWikiTestCase
+ * or a class that provides a public static suite() method which returns
+ * an PHPUnit_Framework_Test object
+ *
+ * @group Parser
+ * @group ParserTests
+ * @group Database
+ */
+class ParserTestTopLevelSuite extends PHPUnit_Framework_TestSuite {
+ /** @var ParserTestRunner */
+ private $ptRunner;
+
+ /** @var ScopedCallback */
+ private $ptTeardownScope;
+
+ /**
+ * @defgroup filtering_constants Filtering constants
+ *
+ * Limit inclusion of parser tests files coming from MediaWiki core
+ * @{
+ */
+
+ /** Include files shipped with MediaWiki core */
+ const CORE_ONLY = 1;
+ /** Include non core files as set in $wgParserTestFiles */
+ const NO_CORE = 2;
+ /** Include anything set via $wgParserTestFiles */
+ const WITH_ALL = 3; # CORE_ONLY | NO_CORE
+
+ /** @} */
+
+ /**
+ * Get a PHPUnit test suite of parser tests. Optionally filtered with
+ * $flags.
+ *
+ * @par Examples:
+ * Get a suite of parser tests shipped by MediaWiki core:
+ * @code
+ * ParserTestTopLevelSuite::suite( ParserTestTopLevelSuite::CORE_ONLY );
+ * @endcode
+ * Get a suite of various parser tests, like extensions:
+ * @code
+ * ParserTestTopLevelSuite::suite( ParserTestTopLevelSuite::NO_CORE );
+ * @endcode
+ * Get any test defined via $wgParserTestFiles:
+ * @code
+ * ParserTestTopLevelSuite::suite( ParserTestTopLevelSuite::WITH_ALL );
+ * @endcode
+ *
+ * @param int $flags Bitwise flag to filter out the $wgParserTestFiles that
+ * will be included. Default: ParserTestTopLevelSuite::CORE_ONLY
+ *
+ * @return PHPUnit_Framework_TestSuite
+ */
+ public static function suite( $flags = self::CORE_ONLY ) {
+ return new self( $flags );
+ }
+
+ function __construct( $flags ) {
+ parent::__construct();
+
+ $this->ptRecorder = new PhpunitTestRecorder;
+ $this->ptRunner = new ParserTestRunner( $this->ptRecorder );
+
+ if ( is_string( $flags ) ) {
+ $flags = self::CORE_ONLY;
+ }
+ global $IP;
+
+ $mwTestDir = $IP . '/tests/';
+
+ # Human friendly helpers
+ $wantsCore = ( $flags & self::CORE_ONLY );
+ $wantsRest = ( $flags & self::NO_CORE );
+
+ # Will hold the .txt parser test files we will include
+ $filesToTest = [];
+
+ # Filter out .txt files
+ $files = ParserTestRunner::getParserTestFiles();
+ foreach ( $files as $extName => $parserTestFile ) {
+ $isCore = ( 0 === strpos( $parserTestFile, $mwTestDir ) );
+
+ if ( $isCore && $wantsCore ) {
+ self::debug( "included core parser tests: $parserTestFile" );
+ $filesToTest[$extName] = $parserTestFile;
+ } elseif ( !$isCore && $wantsRest ) {
+ self::debug( "included non core parser tests: $parserTestFile" );
+ $filesToTest[$extName] = $parserTestFile;
+ } else {
+ self::debug( "skipped parser tests: $parserTestFile" );
+ }
+ }
+ self::debug( 'parser tests files: '
+ . implode( ' ', $filesToTest ) );
+
+ $testList = [];
+ $counter = 0;
+ foreach ( $filesToTest as $extensionName => $fileName ) {
+ if ( is_int( $extensionName ) ) {
+ // If there's no extension name because this is coming
+ // from the legacy global, then assume the next level directory
+ // is the extension name (e.g. extensions/FooBar/parserTests.txt).
+ $extensionName = basename( dirname( $fileName ) );
+ }
+ $testsName = $extensionName . '__' . basename( $fileName, '.txt' );
+ $parserTestClassName = ucfirst( $testsName );
+
+ // Official spec for class names: https://secure.php.net/manual/en/language.oop5.basic.php
+ // Prepend 'ParserTest_' to be paranoid about it not starting with a number
+ $parserTestClassName = 'ParserTest_' .
+ preg_replace( '/[^a-zA-Z0-9_\x7f-\xff]/', '_', $parserTestClassName );
+
+ if ( isset( $testList[$parserTestClassName] ) ) {
+ // If there is a conflict, append a number.
+ $counter++;
+ $parserTestClassName .= $counter;
+ }
+ $testList[$parserTestClassName] = true;
+
+ // Previously we actually created a class here, with eval(). We now
+ // just override the name.
+
+ self::debug( "Adding test class $parserTestClassName" );
+ $this->addTest( new ParserTestFileSuite(
+ $this->ptRunner, $parserTestClassName, $fileName ) );
+ }
+ }
+
+ public function setUp() {
+ wfDebug( __METHOD__ );
+ $db = wfGetDB( DB_MASTER );
+ $type = $db->getType();
+ $prefix = $type === 'oracle' ?
+ MediaWikiTestCase::ORA_DB_PREFIX : MediaWikiTestCase::DB_PREFIX;
+ MediaWikiTestCase::setupTestDB( $db, $prefix );
+ $teardown = $this->ptRunner->setDatabase( $db );
+ $teardown = $this->ptRunner->setupUploads( $teardown );
+ $this->ptTeardownScope = $teardown;
+ }
+
+ public function tearDown() {
+ wfDebug( __METHOD__ );
+ if ( $this->ptTeardownScope ) {
+ ScopedCallback::consume( $this->ptTeardownScope );
+ }
+ }
+
+ /**
+ * Write $msg under log group 'tests-parser'
+ * @param string $msg Message to log
+ */
+ protected static function debug( $msg ) {
+ wfDebugLog( 'tests-parser', wfGetCaller() . ' ' . $msg );
+ }
+}
diff --git a/www/wiki/tests/phpunit/suites/UploadFromUrlTestSuite.php b/www/wiki/tests/phpunit/suites/UploadFromUrlTestSuite.php
new file mode 100644
index 00000000..556c7541
--- /dev/null
+++ b/www/wiki/tests/phpunit/suites/UploadFromUrlTestSuite.php
@@ -0,0 +1,99 @@
+<?php
+
+require_once dirname( __DIR__ ) . '/includes/upload/UploadFromUrlTest.php';
+
+class UploadFromUrlTestSuite extends PHPUnit_Framework_TestSuite {
+ public $savedGlobals = [];
+
+ public static function addTables( &$tables ) {
+ $tables[] = 'user_properties';
+ $tables[] = 'filearchive';
+ $tables[] = 'logging';
+ $tables[] = 'updatelog';
+ $tables[] = 'iwlinks';
+
+ return true;
+ }
+
+ protected function setUp() {
+ global $wgParser, $wgParserConf, $IP, $messageMemc, $wgMemc, $wgUser,
+ $wgLang, $wgOut, $wgRequest, $wgStyleDirectory,
+ $wgParserCacheType, $wgNamespaceAliases, $wgNamespaceProtection;
+
+ $tmpDir = $this->getNewTempDirectory();
+ $tmpGlobals = [];
+
+ $tmpGlobals['wgScript'] = '/index.php';
+ $tmpGlobals['wgScriptPath'] = '/';
+ $tmpGlobals['wgArticlePath'] = '/wiki/$1';
+ $tmpGlobals['wgStylePath'] = '/skins';
+ $tmpGlobals['wgThumbnailScriptPath'] = false;
+ $tmpGlobals['wgLocalFileRepo'] = [
+ 'class' => LocalRepo::class,
+ 'name' => 'local',
+ 'url' => 'http://example.com/images',
+ 'hashLevels' => 2,
+ 'transformVia404' => false,
+ 'backend' => new FSFileBackend( [
+ 'name' => 'local-backend',
+ 'wikiId' => wfWikiID(),
+ 'containerPaths' => [
+ 'local-public' => "{$tmpDir}/test-repo/public",
+ 'local-thumb' => "{$tmpDir}/test-repo/thumb",
+ 'local-temp' => "{$tmpDir}/test-repo/temp",
+ 'local-deleted' => "{$tmpDir}/test-repo/delete",
+ ]
+ ] ),
+ ];
+ foreach ( $tmpGlobals as $var => $val ) {
+ if ( array_key_exists( $var, $GLOBALS ) ) {
+ $this->savedGlobals[$var] = $GLOBALS[$var];
+ }
+ $GLOBALS[$var] = $val;
+ }
+
+ $wgNamespaceProtection[NS_MEDIAWIKI] = 'editinterface';
+ $wgNamespaceAliases['Image'] = NS_FILE;
+ $wgNamespaceAliases['Image_talk'] = NS_FILE_TALK;
+
+ $wgParserCacheType = CACHE_NONE;
+ DeferredUpdates::clearPendingUpdates();
+ $wgMemc = wfGetMainCache();
+ $messageMemc = wfGetMessageCacheStorage();
+
+ RequestContext::resetMain();
+ $context = RequestContext::getMain();
+ $wgUser = new User;
+ $wgLang = $context->getLanguage();
+ $wgOut = $context->getOutput();
+ $wgParser = new StubObject( 'wgParser', $wgParserConf['class'], [ $wgParserConf ] );
+ $wgRequest = $context->getRequest();
+
+ if ( $wgStyleDirectory === false ) {
+ $wgStyleDirectory = "$IP/skins";
+ }
+
+ RepoGroup::destroySingleton();
+ FileBackendGroup::destroySingleton();
+ }
+
+ protected function tearDown() {
+ foreach ( $this->savedGlobals as $var => $val ) {
+ $GLOBALS[$var] = $val;
+ }
+ // Restore backends
+ RepoGroup::destroySingleton();
+ FileBackendGroup::destroySingleton();
+
+ parent::tearDown();
+ }
+
+ public static function suite() {
+ // Hack to invoke the autoloader required to get phpunit to recognize
+ // the UploadFromUrlTest class
+ class_exists( 'UploadFromUrlTest' );
+ $suite = new UploadFromUrlTestSuite( 'UploadFromUrlTest' );
+
+ return $suite;
+ }
+}
diff --git a/www/wiki/tests/phpunit/tests/MediaWikiTestCaseSchema1Test.php b/www/wiki/tests/phpunit/tests/MediaWikiTestCaseSchema1Test.php
new file mode 100644
index 00000000..d794d131
--- /dev/null
+++ b/www/wiki/tests/phpunit/tests/MediaWikiTestCaseSchema1Test.php
@@ -0,0 +1,51 @@
+<?php
+use Wikimedia\Rdbms\IMaintainableDatabase;
+
+/**
+ * @covers MediaWikiTestCase
+ *
+ * @group Database
+ * @group MediaWikiTestCaseTest
+ */
+class MediaWikiTestCaseSchema1Test extends MediaWikiTestCase {
+
+ public static $hasRun = false;
+
+ public function getSchemaOverrides( IMaintainableDatabase $db ) {
+ return [
+ 'create' => [ 'MediaWikiTestCaseTestTable', 'imagelinks' ],
+ 'drop' => [ 'oldimage' ],
+ 'alter' => [ 'pagelinks' ],
+ 'scripts' => [ __DIR__ . '/MediaWikiTestCaseSchemaTest.sql' ]
+ ];
+ }
+
+ public function testMediaWikiTestCaseSchemaTestOrder() {
+ // The test must be run before the second test
+ self::$hasRun = true;
+ $this->assertTrue( self::$hasRun );
+ }
+
+ public function testTableWasCreated() {
+ // Make sure MediaWikiTestCaseTestTable was created.
+ $this->assertTrue( $this->db->tableExists( 'MediaWikiTestCaseTestTable' ) );
+ }
+
+ public function testTableWasDropped() {
+ // Make sure oldimage was dropped
+ $this->assertFalse( $this->db->tableExists( 'oldimage' ) );
+ }
+
+ public function testTableWasOverriden() {
+ // Make sure imagelinks was overwritten
+ $this->assertTrue( $this->db->tableExists( 'imagelinks' ) );
+ $this->assertTrue( $this->db->fieldExists( 'imagelinks', 'il_frobnitz' ) );
+ }
+
+ public function testTableWasAltered() {
+ // Make sure pagelinks was altered
+ $this->assertTrue( $this->db->tableExists( 'pagelinks' ) );
+ $this->assertTrue( $this->db->fieldExists( 'pagelinks', 'pl_frobnitz' ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/tests/MediaWikiTestCaseSchema2Test.php b/www/wiki/tests/phpunit/tests/MediaWikiTestCaseSchema2Test.php
new file mode 100644
index 00000000..5464dc43
--- /dev/null
+++ b/www/wiki/tests/phpunit/tests/MediaWikiTestCaseSchema2Test.php
@@ -0,0 +1,48 @@
+<?php
+
+/**
+ * @covers MediaWikiTestCase
+ *
+ * @group Database
+ * @group MediaWikiTestCaseTest
+ *
+ * This test is intended to be executed AFTER MediaWikiTestCaseSchema1Test to ensure
+ * that any schema modifications have been cleaned up between test cases.
+ * As there seems to be no way to force execution order, we currently rely on
+ * test classes getting run in anpha-numerical order.
+ * Order is checked by the testMediaWikiTestCaseSchemaTestOrder test in both classes.
+ */
+class MediaWikiTestCaseSchema2Test extends MediaWikiTestCase {
+
+ public function testMediaWikiTestCaseSchemaTestOrder() {
+ // The first test must have run before this one
+ $this->assertTrue( MediaWikiTestCaseSchema1Test::$hasRun );
+ }
+
+ public function testCreatedTableWasRemoved() {
+ // Make sure MediaWikiTestCaseTestTable created by MediaWikiTestCaseSchema1Test
+ // was dropped before executing MediaWikiTestCaseSchema2Test.
+ $this->assertFalse( $this->db->tableExists( 'MediaWikiTestCaseTestTable' ) );
+ }
+
+ public function testDroppedTableWasRestored() {
+ // Make sure oldimage that was dropped by MediaWikiTestCaseSchema1Test
+ // was restored before executing MediaWikiTestCaseSchema2Test.
+ $this->assertTrue( $this->db->tableExists( 'oldimage' ) );
+ }
+
+ public function testOverridenTableWasRestored() {
+ // Make sure imagelinks overwritten by MediaWikiTestCaseSchema1Test
+ // was restored to the original schema before executing MediaWikiTestCaseSchema2Test.
+ $this->assertTrue( $this->db->tableExists( 'imagelinks' ) );
+ $this->assertFalse( $this->db->fieldExists( 'imagelinks', 'il_frobnitz' ) );
+ }
+
+ public function testAlteredTableWasRestored() {
+ // Make sure pagelinks altered by MediaWikiTestCaseSchema1Test
+ // was restored to the original schema before executing MediaWikiTestCaseSchema2Test.
+ $this->assertTrue( $this->db->tableExists( 'pagelinks' ) );
+ $this->assertFalse( $this->db->fieldExists( 'pagelinks', 'pl_frobnitz' ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/tests/MediaWikiTestCaseSchemaTest.sql b/www/wiki/tests/phpunit/tests/MediaWikiTestCaseSchemaTest.sql
new file mode 100644
index 00000000..e2818b55
--- /dev/null
+++ b/www/wiki/tests/phpunit/tests/MediaWikiTestCaseSchemaTest.sql
@@ -0,0 +1,18 @@
+CREATE TABLE /*_*/MediaWikiTestCaseTestTable (
+ id INT NOT NULL,
+ name VARCHAR(20) NOT NULL,
+ PRIMARY KEY (id)
+) /*$wgDBTableOptions*/;
+
+CREATE TABLE /*_*/imagelinks (
+ il_from int NOT NULL DEFAULT 0,
+ il_from_namespace int NOT NULL DEFAULT 0,
+ il_to varchar(127) NOT NULL DEFAULT '',
+ il_frobnitz varchar(127) NOT NULL DEFAULT 'FROB',
+ PRIMARY KEY (il_from,il_to)
+) /*$wgDBTableOptions*/;
+
+ALTER TABLE /*_*/pagelinks
+ADD pl_frobnitz varchar(127) NOT NULL DEFAULT 'FROB';
+
+DROP TABLE /*_*/oldimage;
diff --git a/www/wiki/tests/phpunit/tests/MediaWikiTestCaseTest.php b/www/wiki/tests/phpunit/tests/MediaWikiTestCaseTest.php
new file mode 100644
index 00000000..1850f6fe
--- /dev/null
+++ b/www/wiki/tests/phpunit/tests/MediaWikiTestCaseTest.php
@@ -0,0 +1,184 @@
+<?php
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LoggerInterface;
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * @covers MediaWikiTestCase
+ * @group MediaWikiTestCaseTest
+ *
+ * @author Addshore
+ */
+class MediaWikiTestCaseTest extends MediaWikiTestCase {
+
+ private static $startGlobals = [
+ 'MediaWikiTestCaseTestGLOBAL-ExistingString' => 'foo',
+ 'MediaWikiTestCaseTestGLOBAL-ExistingStringEmpty' => '',
+ 'MediaWikiTestCaseTestGLOBAL-ExistingArray' => [ 1, 'foo' => 'bar' ],
+ 'MediaWikiTestCaseTestGLOBAL-ExistingArrayEmpty' => [],
+ ];
+
+ public static function setUpBeforeClass() {
+ parent::setUpBeforeClass();
+ foreach ( self::$startGlobals as $key => $value ) {
+ $GLOBALS[$key] = $value;
+ }
+ }
+
+ public static function tearDownAfterClass() {
+ parent::tearDownAfterClass();
+ foreach ( self::$startGlobals as $key => $value ) {
+ unset( $GLOBALS[$key] );
+ }
+ }
+
+ public function provideExistingKeysAndNewValues() {
+ $providedArray = [];
+ foreach ( array_keys( self::$startGlobals ) as $key ) {
+ $providedArray[] = [ $key, 'newValue' ];
+ $providedArray[] = [ $key, [ 'newValue' ] ];
+ }
+ return $providedArray;
+ }
+
+ /**
+ * @dataProvider provideExistingKeysAndNewValues
+ *
+ * @covers MediaWikiTestCase::setMwGlobals
+ * @covers MediaWikiTestCase::tearDown
+ */
+ public function testSetGlobalsAreRestoredOnTearDown( $globalKey, $newValue ) {
+ $this->setMwGlobals( $globalKey, $newValue );
+ $this->assertEquals(
+ $newValue,
+ $GLOBALS[$globalKey],
+ 'Global failed to correctly set'
+ );
+
+ $this->tearDown();
+
+ $this->assertEquals(
+ self::$startGlobals[$globalKey],
+ $GLOBALS[$globalKey],
+ 'Global failed to be restored on tearDown'
+ );
+ }
+
+ /**
+ * @dataProvider provideExistingKeysAndNewValues
+ *
+ * @covers MediaWikiTestCase::stashMwGlobals
+ * @covers MediaWikiTestCase::tearDown
+ */
+ public function testStashedGlobalsAreRestoredOnTearDown( $globalKey, $newValue ) {
+ $this->stashMwGlobals( $globalKey );
+ $GLOBALS[$globalKey] = $newValue;
+ $this->assertEquals(
+ $newValue,
+ $GLOBALS[$globalKey],
+ 'Global failed to correctly set'
+ );
+
+ $this->tearDown();
+
+ $this->assertEquals(
+ self::$startGlobals[$globalKey],
+ $GLOBALS[$globalKey],
+ 'Global failed to be restored on tearDown'
+ );
+ }
+
+ /**
+ * @covers MediaWikiTestCase::stashMwGlobals
+ * @covers MediaWikiTestCase::tearDown
+ */
+ public function testSetNonExistentGlobalsAreUnsetOnTearDown() {
+ $globalKey = 'abcdefg1234567';
+ $this->setMwGlobals( $globalKey, true );
+ $this->assertTrue(
+ $GLOBALS[$globalKey],
+ 'Global failed to correctly set'
+ );
+
+ $this->tearDown();
+
+ $this->assertFalse(
+ isset( $GLOBALS[$globalKey] ),
+ 'Global failed to be correctly unset'
+ );
+ }
+
+ public function testOverrideMwServices() {
+ $initialServices = MediaWikiServices::getInstance();
+
+ $this->overrideMwServices();
+ $this->assertNotSame( $initialServices, MediaWikiServices::getInstance() );
+
+ $this->tearDown();
+ $this->assertSame( $initialServices, MediaWikiServices::getInstance() );
+ }
+
+ public function testSetService() {
+ $initialServices = MediaWikiServices::getInstance();
+ $initialService = $initialServices->getDBLoadBalancer();
+ $mockService = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()->getMock();
+
+ $this->setService( 'DBLoadBalancer', $mockService );
+ $this->assertNotSame( $initialServices, MediaWikiServices::getInstance() );
+ $this->assertNotSame(
+ $initialService,
+ MediaWikiServices::getInstance()->getDBLoadBalancer()
+ );
+ $this->assertSame( $mockService, MediaWikiServices::getInstance()->getDBLoadBalancer() );
+
+ $this->tearDown();
+ $this->assertSame( $initialServices, MediaWikiServices::getInstance() );
+ $this->assertNotSame( $mockService, MediaWikiServices::getInstance()->getDBLoadBalancer() );
+ $this->assertSame( $initialService, MediaWikiServices::getInstance()->getDBLoadBalancer() );
+ }
+
+ /**
+ * @covers MediaWikiTestCase::setLogger
+ * @covers MediaWikiTestCase::restoreLoggers
+ */
+ public function testLoggersAreRestoredOnTearDown_replacingExistingLogger() {
+ $logger1 = LoggerFactory::getInstance( 'foo' );
+ $this->setLogger( 'foo', $this->createMock( LoggerInterface::class ) );
+ $logger2 = LoggerFactory::getInstance( 'foo' );
+ $this->tearDown();
+ $logger3 = LoggerFactory::getInstance( 'foo' );
+
+ $this->assertSame( $logger1, $logger3 );
+ $this->assertNotSame( $logger1, $logger2 );
+ }
+
+ /**
+ * @covers MediaWikiTestCase::setLogger
+ * @covers MediaWikiTestCase::restoreLoggers
+ */
+ public function testLoggersAreRestoredOnTearDown_replacingNonExistingLogger() {
+ $this->setLogger( 'foo', $this->createMock( LoggerInterface::class ) );
+ $logger1 = LoggerFactory::getInstance( 'foo' );
+ $this->tearDown();
+ $logger2 = LoggerFactory::getInstance( 'foo' );
+
+ $this->assertNotSame( $logger1, $logger2 );
+ $this->assertInstanceOf( \Psr\Log\LoggerInterface::class, $logger2 );
+ }
+
+ /**
+ * @covers MediaWikiTestCase::setLogger
+ * @covers MediaWikiTestCase::restoreLoggers
+ */
+ public function testLoggersAreRestoredOnTearDown_replacingSameLoggerTwice() {
+ $logger1 = LoggerFactory::getInstance( 'baz' );
+ $this->setLogger( 'foo', $this->createMock( LoggerInterface::class ) );
+ $this->setLogger( 'foo', $this->createMock( LoggerInterface::class ) );
+ $this->tearDown();
+ $logger2 = LoggerFactory::getInstance( 'baz' );
+
+ $this->assertSame( $logger1, $logger2 );
+ }
+}
diff --git a/www/wiki/tests/qunit/.htaccess b/www/wiki/tests/qunit/.htaccess
new file mode 100644
index 00000000..605d2f4c
--- /dev/null
+++ b/www/wiki/tests/qunit/.htaccess
@@ -0,0 +1 @@
+Allow from all
diff --git a/www/wiki/tests/qunit/QUnitTestResources.php b/www/wiki/tests/qunit/QUnitTestResources.php
new file mode 100644
index 00000000..785e1146
--- /dev/null
+++ b/www/wiki/tests/qunit/QUnitTestResources.php
@@ -0,0 +1,149 @@
+<?php
+
+/* Modules registered when $wgEnableJavaScriptTest is true */
+
+return [
+
+ /* Utilities */
+
+ 'test.sinonjs' => [
+ 'scripts' => [
+ 'tests/qunit/suites/resources/test.sinonjs/index.js',
+ 'resources/lib/sinonjs/sinon-1.17.3.js',
+ // We want tests to work in IE, but can't include this as it
+ // will break the placeholders in Sinon because the hack it uses
+ // to hijack IE globals relies on running in the global scope
+ // and in ResourceLoader this won't be running in the global scope.
+ // Including it results (among other things) in sandboxed timers
+ // being broken due to Date inheritance being undefined.
+ // 'resources/lib/sinonjs/sinon-ie-1.15.4.js',
+ ],
+ 'targets' => [ 'desktop', 'mobile' ],
+ ],
+
+ 'test.mediawiki.qunit.testrunner' => [
+ 'scripts' => [
+ 'tests/qunit/data/testrunner.js',
+ ],
+ 'dependencies' => [
+ // Test runner configures QUnit but can't have it as dependency,
+ // see SpecialJavaScriptTest::viewQUnit.
+ 'jquery.getAttrs',
+ 'mediawiki.page.ready',
+ 'mediawiki.page.startup',
+ 'test.sinonjs',
+ ],
+ 'targets' => [ 'desktop', 'mobile' ],
+ ],
+
+ /*
+ Test suites for MediaWiki core modules
+ These must have a dependency on test.mediawiki.qunit.testrunner!
+ */
+
+ 'test.mediawiki.qunit.suites' => [
+ 'scripts' => [
+ 'tests/qunit/suites/resources/startup.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.accessKeyLabel.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.color.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.colorUtil.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.getAttrs.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.hidpi.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.highlightText.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.localize.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.tabIndex.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js',
+ 'tests/qunit/suites/resources/jquery/jquery.textSelection.test.js',
+ 'tests/qunit/data/mediawiki.jqueryMsg.data.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.errorLogger.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.html.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.inspect.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.track.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js',
+ 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js',
+ 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js',
+ 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js',
+ 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js',
+ 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js',
+ 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js',
+ 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js',
+ 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js',
+ 'tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js',
+ 'tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js',
+ 'tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js',
+ 'tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js',
+ 'tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js',
+ 'tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js',
+ 'tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js',
+ 'tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js',
+ ],
+ 'dependencies' => [
+ 'jquery.accessKeyLabel',
+ 'jquery.color',
+ 'jquery.colorUtil',
+ 'jquery.getAttrs',
+ 'jquery.hidpi',
+ 'jquery.highlightText',
+ 'jquery.lengthLimit',
+ 'jquery.localize',
+ 'jquery.makeCollapsible',
+ 'jquery.tabIndex',
+ 'jquery.tablesorter',
+ 'jquery.textSelection',
+ 'mediawiki.api',
+ 'mediawiki.api.category',
+ 'mediawiki.api.messages',
+ 'mediawiki.api.options',
+ 'mediawiki.api.parse',
+ 'mediawiki.api.upload',
+ 'mediawiki.api.watch',
+ 'mediawiki.ForeignApi.core',
+ 'mediawiki.jqueryMsg',
+ 'mediawiki.messagePoster',
+ 'mediawiki.RegExp',
+ 'mediawiki.String',
+ 'mediawiki.storage',
+ 'mediawiki.Title',
+ 'mediawiki.toc',
+ 'mediawiki.Uri',
+ 'mediawiki.user',
+ 'mediawiki.template.mustache',
+ 'mediawiki.template',
+ 'mediawiki.util',
+ 'mediawiki.viewport',
+ 'mediawiki.special.recentchanges',
+ 'mediawiki.rcfilters.filters.dm',
+ 'mediawiki.language',
+ 'mediawiki.cldr',
+ 'mediawiki.cookie',
+ 'mediawiki.experiments',
+ 'mediawiki.inspect',
+ 'mediawiki.visibleTimeout',
+ 'test.mediawiki.qunit.testrunner',
+ ],
+ ]
+];
diff --git a/www/wiki/tests/qunit/data/defineCallMwLoaderTestCallback.js b/www/wiki/tests/qunit/data/defineCallMwLoaderTestCallback.js
new file mode 100644
index 00000000..641071a2
--- /dev/null
+++ b/www/wiki/tests/qunit/data/defineCallMwLoaderTestCallback.js
@@ -0,0 +1 @@
+module.exports = 'Defined.';
diff --git a/www/wiki/tests/qunit/data/generateJqueryMsgData.php b/www/wiki/tests/qunit/data/generateJqueryMsgData.php
new file mode 100644
index 00000000..e4f87f81
--- /dev/null
+++ b/www/wiki/tests/qunit/data/generateJqueryMsgData.php
@@ -0,0 +1,148 @@
+<?php
+/**
+ * This PHP script defines the spec that the mediawiki.jqueryMsg module should conform to.
+ *
+ * It does this by looking up the results of various kinds of string parsing, with various
+ * languages, in the current installation of MediaWiki. It then outputs a static specification,
+ * mapping expected inputs to outputs, which can be used fed into a unit test framework.
+ * (QUnit, Jasmine, anything, it just outputs an object with key/value pairs).
+ *
+ * This is similar to Michael Dale (mdale@mediawiki.org)'s parser tests, except that it doesn't
+ * look up the API results while doing the test, so the test run is much faster (at the cost
+ * of being out of date in rare circumstances. But mostly the parsing that we are doing in
+ * Javascript doesn't change much).
+ */
+
+/*
+ * @example QUnit
+ * <code>
+ QUnit.test( 'Output matches PHP parser', function ( assert ) {
+ mw.messages.set( mw.libs.phpParserData.messages );
+ $.each( mw.libs.phpParserData.tests, function ( i, test ) {
+ QUnit.stop();
+ getMwLanguage( test.lang, function ( langClass ) {
+ var parser = new mw.jqueryMsg.Parser( { language: langClass } );
+ assert.equal(
+ parser.parse( test.key, test.args ).html(),
+ test.result,
+ test.name
+ );
+ QUnit.start();
+ } );
+ } );
+ });
+ * </code>
+ *
+ * @example Jasmine
+ * <code>
+ describe( 'match output to output from PHP parser', function () {
+ mw.messages.set( mw.libs.phpParserData.messages );
+ $.each( mw.libs.phpParserData.tests, function ( i, test ) {
+ it( 'should parse ' + test.name, function () {
+ var langClass;
+ runs( function () {
+ getMwLanguage( test.lang, function ( gotIt ) {
+ langClass = gotIt;
+ });
+ });
+ waitsFor( function () {
+ return langClass !== undefined;
+ }, 'Language class should be loaded', 1000 );
+ runs( function () {
+ console.log( test.lang, 'running tests' );
+ var parser = new mw.jqueryMsg.Parser( { language: langClass } );
+ expect(
+ parser.parse( test.key, test.args ).html()
+ ).toEqual( test.result );
+ } );
+ } );
+ } );
+ } );
+ * </code>
+ */
+
+require __DIR__ . '/../../../maintenance/Maintenance.php';
+
+class GenerateJqueryMsgData extends Maintenance {
+
+ public static $keyToTestArgs = [
+ 'undelete_short' => [
+ [ 0 ],
+ [ 1 ],
+ [ 2 ],
+ [ 5 ],
+ [ 21 ],
+ [ 101 ]
+ ],
+ 'category-subcat-count' => [
+ [ 0, 10 ],
+ [ 1, 1 ],
+ [ 1, 2 ],
+ [ 3, 30 ]
+ ]
+ ];
+
+ public function __construct() {
+ parent::__construct();
+ $this->mDescription = 'Create a specification for message parsing ini JSON format';
+ // add any other options here
+ }
+
+ public function execute() {
+ list( $messages, $tests ) = $this->getMessagesAndTests();
+ $this->writeJavascriptFile( $messages, $tests, __DIR__ . '/mediawiki.jqueryMsg.data.js' );
+ }
+
+ private function getMessagesAndTests() {
+ $messages = [];
+ $tests = [];
+ foreach ( [ 'en', 'fr', 'ar', 'jp', 'zh' ] as $languageCode ) {
+ foreach ( self::$keyToTestArgs as $key => $testArgs ) {
+ foreach ( $testArgs as $args ) {
+ // Get the raw message, without any transformations.
+ $template = wfMessage( $key )->inLanguage( $languageCode )->plain();
+
+ // Get the magic-parsed version with args.
+ $result = wfMessage( $key, $args )->inLanguage( $languageCode )->text();
+
+ // Record the template, args, language, and expected result
+ // fake multiple languages by flattening them together.
+ $langKey = $languageCode . '_' . $key;
+ $messages[$langKey] = $template;
+ $tests[] = [
+ 'name' => $languageCode . ' ' . $key . ' ' . implode( ',', $args ),
+ 'key' => $langKey,
+ 'args' => $args,
+ 'result' => $result,
+ 'lang' => $languageCode
+ ];
+ }
+ }
+ }
+ return [ $messages, $tests ];
+ }
+
+ private function writeJavascriptFile( $messages, $tests, $dataSpecFile ) {
+ $phpParserData = [
+ 'messages' => $messages,
+ 'tests' => $tests,
+ ];
+
+ $output =
+ "// This file stores the output from the PHP parser for various messages, arguments,\n"
+ . "// languages, and parser modes. Intended for use by a unit test framework by looping\n"
+ . "// through the object and comparing its parser return value with the 'result' property.\n"
+ . '// Last generated with ' . basename( __FILE__ ) . ' at ' . gmdate( 'r' ) . "\n"
+ . "/* eslint-disable */\n"
+ . "\n"
+ . 'mediaWiki.libs.phpParserData = ' . FormatJson::encode( $phpParserData, true ) . ";\n";
+
+ $fp = file_put_contents( $dataSpecFile, $output );
+ if ( $fp === false ) {
+ die( "Couldn't write to $dataSpecFile." );
+ }
+ }
+}
+
+$maintClass = "GenerateJqueryMsgData";
+require_once RUN_MAINTENANCE_IF_MAIN;
diff --git a/www/wiki/tests/qunit/data/load.mock.php b/www/wiki/tests/qunit/data/load.mock.php
new file mode 100644
index 00000000..23009498
--- /dev/null
+++ b/www/wiki/tests/qunit/data/load.mock.php
@@ -0,0 +1,107 @@
+<?php
+/**
+ * Mock load.php with pre-defined test modules.
+ *
+ * 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
+ * @package MediaWiki
+ * @author Lupo
+ * @since 1.20
+ */
+header( 'Content-Type: text/javascript; charset=utf-8' );
+
+$moduleImplementations = [
+ 'testUsesMissing' => "
+mw.loader.implement( 'testUsesMissing', function () {
+ mw.loader.testFail( 'Module usesMissing script should not run.' );
+}, {}, {});
+",
+
+ 'testUsesNestedMissing' => "
+mw.loader.implement( 'testUsesNestedMissing', function () {
+ mw.loader.testFail('Module testUsesNestedMissing script should not run.' );
+}, {}, {});
+",
+
+ 'testSkipped' => "
+mw.loader.implement( 'testSkipped', function () {
+ mw.loader.testFail( false, 'Module testSkipped was supposed to be skipped.' );
+}, {}, {});
+",
+
+ 'testNotSkipped' => "
+mw.loader.implement( 'testNotSkipped', function () {}, {}, {});
+",
+
+ 'testUsesSkippable' => "
+mw.loader.implement( 'testUsesSkippable', function () {}, {}, {});
+",
+
+ 'testUrlInc' => "
+mw.loader.implement( 'testUrlInc', function () {} );
+",
+ 'testUrlInc.a' => "
+mw.loader.implement( 'testUrlInc.a', function () {} );
+",
+ 'testUrlInc.b' => "
+mw.loader.implement( 'testUrlInc.b', function () {} );
+",
+ 'testUrlOrder' => "
+mw.loader.implement( 'testUrlOrder', function () {} );
+",
+ 'testUrlOrder.a' => "
+mw.loader.implement( 'testUrlOrder.a', function () {} );
+",
+ 'testUrlOrder.b' => "
+mw.loader.implement( 'testUrlOrder.b', function () {} );
+",
+];
+
+$response = '';
+
+// Does not support the full behaviour of ResourceLoaderContext::expandModuleNames(),
+// Only supports dotless module names joined by comma,
+// with the exception of the hardcoded cases for testUrl*.
+if ( isset( $_GET['modules'] ) ) {
+ if ( $_GET['modules'] === 'testUrlInc,testUrlIncDump|testUrlInc.a,b' ) {
+ $modules = [ 'testUrlInc', 'testUrlIncDump', 'testUrlInc.a', 'testUrlInc.b' ];
+ } elseif ( $_GET['modules'] === 'testUrlOrder,testUrlOrderDump|testUrlOrder.a,b' ) {
+ $modules = [ 'testUrlOrder', 'testUrlOrderDump', 'testUrlOrder.a', 'testUrlOrder.b' ];
+ } else {
+ $modules = explode( ',', $_GET['modules'] );
+ }
+ foreach ( $modules as $module ) {
+ if ( isset( $moduleImplementations[$module] ) ) {
+ $response .= $moduleImplementations[$module];
+ } elseif ( preg_match( '/^test.*Dump$/', $module ) === 1 ) {
+ $queryModules = $_GET['modules'];
+ $queryVersion = isset( $_GET['version'] ) ? strval( $_GET['version'] ) : null;
+ $response .= 'mw.loader.implement( ' . json_encode( $module )
+ . ', function ( $, jQuery, require, module ) {'
+ . 'module.exports.query = { '
+ . 'modules: ' . json_encode( $queryModules ) . ','
+ . 'version: ' . json_encode( $queryVersion )
+ . ' };'
+ . '} );';
+ } else {
+ // Default
+ $response .= 'mw.loader.state(' . json_encode( $module ) . ', "missing" );' . "\n";
+ }
+ }
+}
+
+echo $response;
diff --git a/www/wiki/tests/qunit/data/mediawiki.jqueryMsg.data.js b/www/wiki/tests/qunit/data/mediawiki.jqueryMsg.data.js
new file mode 100644
index 00000000..90dc1b28
--- /dev/null
+++ b/www/wiki/tests/qunit/data/mediawiki.jqueryMsg.data.js
@@ -0,0 +1,492 @@
+// This file stores the output from the PHP parser for various messages, arguments,
+// languages, and parser modes. Intended for use by a unit test framework by looping
+// through the object and comparing its parser return value with the 'result' property.
+// Last generated with generateJqueryMsgData.php at Fri, 10 Jul 2015 11:44:08 +0000
+/* eslint-disable */
+
+mediaWiki.libs.phpParserData = {
+ "messages": {
+ "en_undelete_short": "Undelete {{PLURAL:$1|one edit|$1 edits}}",
+ "en_category-subcat-count": "{{PLURAL:$2|This category has only the following subcategory.|This category has the following {{PLURAL:$1|subcategory|$1 subcategories}}, out of $2 total.}}",
+ "fr_undelete_short": "Restaurer $1 modification{{PLURAL:$1||s}}",
+ "fr_category-subcat-count": "Cette cat\u00e9gorie comprend {{PLURAL:$2|la sous-cat\u00e9gorie|$2 sous-cat\u00e9gories, dont {{PLURAL:$1|celle|les $1}}}} ci-dessous.",
+ "ar_undelete_short": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 {{PLURAL:$1||\u062a\u0639\u062f\u064a\u0644 \u0648\u0627\u062d\u062f|\u062a\u0639\u062f\u064a\u0644\u064a\u0646|$1 \u062a\u0639\u062f\u064a\u0644\u0627\u062a|$1 \u062a\u0639\u062f\u064a\u0644\u0627\u064b|$1 \u062a\u0639\u062f\u064a\u0644}}",
+ "ar_category-subcat-count": "{{PLURAL:$2|\u0647\u0630\u0627 \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u064a\u062d\u0648\u064a \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u0627\u0644\u0641\u0631\u0639\u064a \u0627\u0644\u062a\u0627\u0644\u064a|\u0647\u0630\u0627 \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u064a\u062d\u0648\u064a {{PLURAL:$1||\u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u0627\u0644\u0641\u0631\u0639\u064a|\u062a\u0635\u0646\u064a\u0641\u064a\u0646 \u0641\u0631\u0639\u064a\u064a\u0646|$1 \u062a\u0635\u0646\u064a\u0641\u0627\u062a \u0641\u0631\u0639\u064a\u0629}}\u060c \u0645\u0646 \u0625\u062c\u0645\u0627\u0644\u064a $2.}}",
+ "jp_undelete_short": "Undelete {{PLURAL:$1|one edit|$1 edits}}",
+ "jp_category-subcat-count": "{{PLURAL:$2|This category has only the following subcategory.|This category has the following {{PLURAL:$1|subcategory|$1 subcategories}}, out of $2 total.}}",
+ "zh_undelete_short": "\u8fd8\u539f{{PLURAL:$1|$1\u4e2a\u7f16\u8f91}}",
+ "zh_category-subcat-count": "{{PLURAL:$2|\u672c\u5206\u7c7b\u53ea\u6709\u4ee5\u4e0b\u5b50\u5206\u7c7b\u3002|\u672c\u5206\u7c7b\u6709\u4ee5\u4e0b$1\u4e2a\u5b50\u5206\u7c7b\uff0c\u5171\u6709$2\u4e2a\u5b50\u5206\u7c7b\u3002}}"
+ },
+ "tests": [
+ {
+ "name": "en undelete_short 0",
+ "key": "en_undelete_short",
+ "args": [
+ 0
+ ],
+ "result": "Undelete 0 edits",
+ "lang": "en"
+ },
+ {
+ "name": "en undelete_short 1",
+ "key": "en_undelete_short",
+ "args": [
+ 1
+ ],
+ "result": "Undelete one edit",
+ "lang": "en"
+ },
+ {
+ "name": "en undelete_short 2",
+ "key": "en_undelete_short",
+ "args": [
+ 2
+ ],
+ "result": "Undelete 2 edits",
+ "lang": "en"
+ },
+ {
+ "name": "en undelete_short 5",
+ "key": "en_undelete_short",
+ "args": [
+ 5
+ ],
+ "result": "Undelete 5 edits",
+ "lang": "en"
+ },
+ {
+ "name": "en undelete_short 21",
+ "key": "en_undelete_short",
+ "args": [
+ 21
+ ],
+ "result": "Undelete 21 edits",
+ "lang": "en"
+ },
+ {
+ "name": "en undelete_short 101",
+ "key": "en_undelete_short",
+ "args": [
+ 101
+ ],
+ "result": "Undelete 101 edits",
+ "lang": "en"
+ },
+ {
+ "name": "en category-subcat-count 0,10",
+ "key": "en_category-subcat-count",
+ "args": [
+ 0,
+ 10
+ ],
+ "result": "This category has the following 0 subcategories, out of 10 total.",
+ "lang": "en"
+ },
+ {
+ "name": "en category-subcat-count 1,1",
+ "key": "en_category-subcat-count",
+ "args": [
+ 1,
+ 1
+ ],
+ "result": "This category has only the following subcategory.",
+ "lang": "en"
+ },
+ {
+ "name": "en category-subcat-count 1,2",
+ "key": "en_category-subcat-count",
+ "args": [
+ 1,
+ 2
+ ],
+ "result": "This category has the following subcategory, out of 2 total.",
+ "lang": "en"
+ },
+ {
+ "name": "en category-subcat-count 3,30",
+ "key": "en_category-subcat-count",
+ "args": [
+ 3,
+ 30
+ ],
+ "result": "This category has the following 3 subcategories, out of 30 total.",
+ "lang": "en"
+ },
+ {
+ "name": "fr undelete_short 0",
+ "key": "fr_undelete_short",
+ "args": [
+ 0
+ ],
+ "result": "Restaurer 0 modification",
+ "lang": "fr"
+ },
+ {
+ "name": "fr undelete_short 1",
+ "key": "fr_undelete_short",
+ "args": [
+ 1
+ ],
+ "result": "Restaurer 1 modification",
+ "lang": "fr"
+ },
+ {
+ "name": "fr undelete_short 2",
+ "key": "fr_undelete_short",
+ "args": [
+ 2
+ ],
+ "result": "Restaurer 2 modifications",
+ "lang": "fr"
+ },
+ {
+ "name": "fr undelete_short 5",
+ "key": "fr_undelete_short",
+ "args": [
+ 5
+ ],
+ "result": "Restaurer 5 modifications",
+ "lang": "fr"
+ },
+ {
+ "name": "fr undelete_short 21",
+ "key": "fr_undelete_short",
+ "args": [
+ 21
+ ],
+ "result": "Restaurer 21 modifications",
+ "lang": "fr"
+ },
+ {
+ "name": "fr undelete_short 101",
+ "key": "fr_undelete_short",
+ "args": [
+ 101
+ ],
+ "result": "Restaurer 101 modifications",
+ "lang": "fr"
+ },
+ {
+ "name": "fr category-subcat-count 0,10",
+ "key": "fr_category-subcat-count",
+ "args": [
+ 0,
+ 10
+ ],
+ "result": "Cette cat\u00e9gorie comprend 10 sous-cat\u00e9gories, dont celle ci-dessous.",
+ "lang": "fr"
+ },
+ {
+ "name": "fr category-subcat-count 1,1",
+ "key": "fr_category-subcat-count",
+ "args": [
+ 1,
+ 1
+ ],
+ "result": "Cette cat\u00e9gorie comprend la sous-cat\u00e9gorie ci-dessous.",
+ "lang": "fr"
+ },
+ {
+ "name": "fr category-subcat-count 1,2",
+ "key": "fr_category-subcat-count",
+ "args": [
+ 1,
+ 2
+ ],
+ "result": "Cette cat\u00e9gorie comprend 2 sous-cat\u00e9gories, dont celle ci-dessous.",
+ "lang": "fr"
+ },
+ {
+ "name": "fr category-subcat-count 3,30",
+ "key": "fr_category-subcat-count",
+ "args": [
+ 3,
+ 30
+ ],
+ "result": "Cette cat\u00e9gorie comprend 30 sous-cat\u00e9gories, dont les 3 ci-dessous.",
+ "lang": "fr"
+ },
+ {
+ "name": "ar undelete_short 0",
+ "key": "ar_undelete_short",
+ "args": [
+ 0
+ ],
+ "result": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 ",
+ "lang": "ar"
+ },
+ {
+ "name": "ar undelete_short 1",
+ "key": "ar_undelete_short",
+ "args": [
+ 1
+ ],
+ "result": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 \u062a\u0639\u062f\u064a\u0644 \u0648\u0627\u062d\u062f",
+ "lang": "ar"
+ },
+ {
+ "name": "ar undelete_short 2",
+ "key": "ar_undelete_short",
+ "args": [
+ 2
+ ],
+ "result": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 \u062a\u0639\u062f\u064a\u0644\u064a\u0646",
+ "lang": "ar"
+ },
+ {
+ "name": "ar undelete_short 5",
+ "key": "ar_undelete_short",
+ "args": [
+ 5
+ ],
+ "result": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 5 \u062a\u0639\u062f\u064a\u0644\u0627\u062a",
+ "lang": "ar"
+ },
+ {
+ "name": "ar undelete_short 21",
+ "key": "ar_undelete_short",
+ "args": [
+ 21
+ ],
+ "result": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 21 \u062a\u0639\u062f\u064a\u0644\u0627\u064b",
+ "lang": "ar"
+ },
+ {
+ "name": "ar undelete_short 101",
+ "key": "ar_undelete_short",
+ "args": [
+ 101
+ ],
+ "result": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 101 \u062a\u0639\u062f\u064a\u0644",
+ "lang": "ar"
+ },
+ {
+ "name": "ar category-subcat-count 0,10",
+ "key": "ar_category-subcat-count",
+ "args": [
+ 0,
+ 10
+ ],
+ "result": "\u0647\u0630\u0627 \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u064a\u062d\u0648\u064a \u060c \u0645\u0646 \u0625\u062c\u0645\u0627\u0644\u064a 10.",
+ "lang": "ar"
+ },
+ {
+ "name": "ar category-subcat-count 1,1",
+ "key": "ar_category-subcat-count",
+ "args": [
+ 1,
+ 1
+ ],
+ "result": "\u0647\u0630\u0627 \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u064a\u062d\u0648\u064a \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u0627\u0644\u0641\u0631\u0639\u064a\u060c \u0645\u0646 \u0625\u062c\u0645\u0627\u0644\u064a 1.",
+ "lang": "ar"
+ },
+ {
+ "name": "ar category-subcat-count 1,2",
+ "key": "ar_category-subcat-count",
+ "args": [
+ 1,
+ 2
+ ],
+ "result": "\u0647\u0630\u0627 \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u064a\u062d\u0648\u064a \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u0627\u0644\u0641\u0631\u0639\u064a\u060c \u0645\u0646 \u0625\u062c\u0645\u0627\u0644\u064a 2.",
+ "lang": "ar"
+ },
+ {
+ "name": "ar category-subcat-count 3,30",
+ "key": "ar_category-subcat-count",
+ "args": [
+ 3,
+ 30
+ ],
+ "result": "\u0647\u0630\u0627 \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u064a\u062d\u0648\u064a 3 \u062a\u0635\u0646\u064a\u0641\u0627\u062a \u0641\u0631\u0639\u064a\u0629\u060c \u0645\u0646 \u0625\u062c\u0645\u0627\u0644\u064a 30.",
+ "lang": "ar"
+ },
+ {
+ "name": "jp undelete_short 0",
+ "key": "jp_undelete_short",
+ "args": [
+ 0
+ ],
+ "result": "Undelete 0 edits",
+ "lang": "jp"
+ },
+ {
+ "name": "jp undelete_short 1",
+ "key": "jp_undelete_short",
+ "args": [
+ 1
+ ],
+ "result": "Undelete one edit",
+ "lang": "jp"
+ },
+ {
+ "name": "jp undelete_short 2",
+ "key": "jp_undelete_short",
+ "args": [
+ 2
+ ],
+ "result": "Undelete 2 edits",
+ "lang": "jp"
+ },
+ {
+ "name": "jp undelete_short 5",
+ "key": "jp_undelete_short",
+ "args": [
+ 5
+ ],
+ "result": "Undelete 5 edits",
+ "lang": "jp"
+ },
+ {
+ "name": "jp undelete_short 21",
+ "key": "jp_undelete_short",
+ "args": [
+ 21
+ ],
+ "result": "Undelete 21 edits",
+ "lang": "jp"
+ },
+ {
+ "name": "jp undelete_short 101",
+ "key": "jp_undelete_short",
+ "args": [
+ 101
+ ],
+ "result": "Undelete 101 edits",
+ "lang": "jp"
+ },
+ {
+ "name": "jp category-subcat-count 0,10",
+ "key": "jp_category-subcat-count",
+ "args": [
+ 0,
+ 10
+ ],
+ "result": "This category has the following 0 subcategories, out of 10 total.",
+ "lang": "jp"
+ },
+ {
+ "name": "jp category-subcat-count 1,1",
+ "key": "jp_category-subcat-count",
+ "args": [
+ 1,
+ 1
+ ],
+ "result": "This category has only the following subcategory.",
+ "lang": "jp"
+ },
+ {
+ "name": "jp category-subcat-count 1,2",
+ "key": "jp_category-subcat-count",
+ "args": [
+ 1,
+ 2
+ ],
+ "result": "This category has the following subcategory, out of 2 total.",
+ "lang": "jp"
+ },
+ {
+ "name": "jp category-subcat-count 3,30",
+ "key": "jp_category-subcat-count",
+ "args": [
+ 3,
+ 30
+ ],
+ "result": "This category has the following 3 subcategories, out of 30 total.",
+ "lang": "jp"
+ },
+ {
+ "name": "zh undelete_short 0",
+ "key": "zh_undelete_short",
+ "args": [
+ 0
+ ],
+ "result": "\u8fd8\u539f0\u4e2a\u7f16\u8f91",
+ "lang": "zh"
+ },
+ {
+ "name": "zh undelete_short 1",
+ "key": "zh_undelete_short",
+ "args": [
+ 1
+ ],
+ "result": "\u8fd8\u539f1\u4e2a\u7f16\u8f91",
+ "lang": "zh"
+ },
+ {
+ "name": "zh undelete_short 2",
+ "key": "zh_undelete_short",
+ "args": [
+ 2
+ ],
+ "result": "\u8fd8\u539f2\u4e2a\u7f16\u8f91",
+ "lang": "zh"
+ },
+ {
+ "name": "zh undelete_short 5",
+ "key": "zh_undelete_short",
+ "args": [
+ 5
+ ],
+ "result": "\u8fd8\u539f5\u4e2a\u7f16\u8f91",
+ "lang": "zh"
+ },
+ {
+ "name": "zh undelete_short 21",
+ "key": "zh_undelete_short",
+ "args": [
+ 21
+ ],
+ "result": "\u8fd8\u539f21\u4e2a\u7f16\u8f91",
+ "lang": "zh"
+ },
+ {
+ "name": "zh undelete_short 101",
+ "key": "zh_undelete_short",
+ "args": [
+ 101
+ ],
+ "result": "\u8fd8\u539f101\u4e2a\u7f16\u8f91",
+ "lang": "zh"
+ },
+ {
+ "name": "zh category-subcat-count 0,10",
+ "key": "zh_category-subcat-count",
+ "args": [
+ 0,
+ 10
+ ],
+ "result": "\u672c\u5206\u7c7b\u6709\u4ee5\u4e0b0\u4e2a\u5b50\u5206\u7c7b\uff0c\u5171\u670910\u4e2a\u5b50\u5206\u7c7b\u3002",
+ "lang": "zh"
+ },
+ {
+ "name": "zh category-subcat-count 1,1",
+ "key": "zh_category-subcat-count",
+ "args": [
+ 1,
+ 1
+ ],
+ "result": "\u672c\u5206\u7c7b\u53ea\u6709\u4ee5\u4e0b\u5b50\u5206\u7c7b\u3002",
+ "lang": "zh"
+ },
+ {
+ "name": "zh category-subcat-count 1,2",
+ "key": "zh_category-subcat-count",
+ "args": [
+ 1,
+ 2
+ ],
+ "result": "\u672c\u5206\u7c7b\u6709\u4ee5\u4e0b1\u4e2a\u5b50\u5206\u7c7b\uff0c\u5171\u67092\u4e2a\u5b50\u5206\u7c7b\u3002",
+ "lang": "zh"
+ },
+ {
+ "name": "zh category-subcat-count 3,30",
+ "key": "zh_category-subcat-count",
+ "args": [
+ 3,
+ 30
+ ],
+ "result": "\u672c\u5206\u7c7b\u6709\u4ee5\u4e0b3\u4e2a\u5b50\u5206\u7c7b\uff0c\u5171\u670930\u4e2a\u5b50\u5206\u7c7b\u3002",
+ "lang": "zh"
+ }
+ ]
+};
diff --git a/www/wiki/tests/qunit/data/mwLoaderTestCallback.js b/www/wiki/tests/qunit/data/mwLoaderTestCallback.js
new file mode 100644
index 00000000..dd034115
--- /dev/null
+++ b/www/wiki/tests/qunit/data/mwLoaderTestCallback.js
@@ -0,0 +1 @@
+mediaWiki.loader.testCallback();
diff --git a/www/wiki/tests/qunit/data/requireCallMwLoaderTestCallback.js b/www/wiki/tests/qunit/data/requireCallMwLoaderTestCallback.js
new file mode 100644
index 00000000..815a3b48
--- /dev/null
+++ b/www/wiki/tests/qunit/data/requireCallMwLoaderTestCallback.js
@@ -0,0 +1,6 @@
+module.exports = {
+ immediate: require( 'test.require.define' ),
+ later: function () {
+ return require( 'test.require.define' );
+ }
+};
diff --git a/www/wiki/tests/qunit/data/styleTest.css.php b/www/wiki/tests/qunit/data/styleTest.css.php
new file mode 100644
index 00000000..0e845811
--- /dev/null
+++ b/www/wiki/tests/qunit/data/styleTest.css.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * Dynamically create a simple stylesheet for unit tests in MediaWiki.
+ *
+ * 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
+ * @package MediaWiki
+ * @author Timo Tijhof
+ * @since 1.20
+ */
+header( 'Content-Type: text/css; charset=utf-8' );
+
+/**
+ * Allows characters in ranges [a-z], [A-Z] and [0-9],
+ * in addition to a dot ("."), dash ("-"), space (" ") and hash ("#").
+ * @since 1.20
+ *
+ * @param string $val
+ * @return string Value with any illegal characters removed.
+ */
+function cssfilter( $val ) {
+ return preg_replace( '/[^A-Za-z0-9\.\- #]/', '', $val );
+}
+
+// Do basic sanitization
+$params = array_map( 'cssfilter', $_GET );
+
+// Defaults
+$selector = isset( $params['selector'] ) ? $params['selector'] : '.mw-test-example';
+$property = isset( $params['prop'] ) ? $params['prop'] : 'float';
+$value = isset( $params['val'] ) ? $params['val'] : 'right';
+$wait = isset( $params['wait'] ) ? (int)$params['wait'] : 0; // seconds
+
+sleep( $wait );
+
+$css = "
+/**
+ * Generated " . gmdate( 'r' ) . ".
+ * Waited {$wait}s.
+ */
+
+$selector {
+ $property: $value;
+}
+";
+
+echo trim( $css ) . "\n";
diff --git a/www/wiki/tests/qunit/data/testrunner.js b/www/wiki/tests/qunit/data/testrunner.js
new file mode 100644
index 00000000..06c146c2
--- /dev/null
+++ b/www/wiki/tests/qunit/data/testrunner.js
@@ -0,0 +1,652 @@
+/* global sinon */
+( function ( $, mw, QUnit ) {
+ 'use strict';
+
+ var addons, nested;
+
+ /**
+ * Make a safe copy of localEnv:
+ * - Creates a new object that inherits, instead of modifying the original.
+ * This prevents recursion in the event that a test suite stores inherits
+ * hooks object statically and passes it to multiple QUnit.module() calls.
+ * - Supporting QUnit 1.x 'setup' and 'teardown' hooks
+ * (deprecated in QUnit 1.16, removed in QUnit 2).
+ */
+ function makeSafeEnv( localEnv ) {
+ var wrap = localEnv ? Object.create( localEnv ) : {};
+ if ( wrap.setup ) {
+ wrap.beforeEach = wrap.beforeEach || wrap.setup;
+ }
+ if ( wrap.teardown ) {
+ wrap.afterEach = wrap.afterEach || wrap.teardown;
+ }
+ return wrap;
+ }
+
+ /**
+ * Add bogus to url to prevent IE crazy caching
+ *
+ * @param {string} value a relative path (eg. 'data/foo.js'
+ * or 'data/test.php?foo=bar').
+ * @return {string} Such as 'data/foo.js?131031765087663960'
+ */
+ QUnit.fixurl = function ( value ) {
+ return value + ( /\?/.test( value ) ? '&' : '?' )
+ + String( new Date().getTime() )
+ + String( parseInt( Math.random() * 100000, 10 ) );
+ };
+
+ /**
+ * Configuration
+ */
+
+ // For each test() that is asynchronous, allow this time to pass before
+ // killing the test and assuming timeout failure.
+ QUnit.config.testTimeout = 60 * 1000;
+
+ // Reduce default animation duration from 400ms to 0ms for unit tests
+ // eslint-disable-next-line no-underscore-dangle
+ $.fx.speeds._default = 0;
+
+ // Add a checkbox to QUnit header to toggle MediaWiki ResourceLoader debug mode.
+ QUnit.config.urlConfig.push( {
+ id: 'debug',
+ label: 'Enable ResourceLoaderDebug',
+ tooltip: 'Enable debug mode in ResourceLoader',
+ value: 'true'
+ } );
+
+ /**
+ * SinonJS
+ *
+ * Glue code for nicer integration with QUnit setup/teardown
+ * Inspired by http://sinonjs.org/releases/sinon-qunit-1.0.0.js
+ */
+ sinon.assert.fail = function ( msg ) {
+ QUnit.assert.ok( false, msg );
+ };
+ sinon.assert.pass = function ( msg ) {
+ QUnit.assert.ok( true, msg );
+ };
+ sinon.config = {
+ injectIntoThis: true,
+ injectInto: null,
+ properties: [ 'spy', 'stub', 'mock', 'sandbox' ],
+ // Don't fake timers by default
+ useFakeTimers: false,
+ useFakeServer: false
+ };
+ // Extend QUnit.module with:
+ // - Add support for QUnit 1.x 'setup' and 'teardown' hooks
+ // - Add a Sinon sandbox to the test context.
+ // - Add a test fixture to the test context.
+ ( function () {
+ var orgModule = QUnit.module;
+ QUnit.module = function ( name, localEnv, executeNow ) {
+ var orgExecute, orgBeforeEach, orgAfterEach;
+ if ( nested ) {
+ // In a nested module, don't re-add our hooks, QUnit does that already.
+ return orgModule.apply( this, arguments );
+ }
+ if ( arguments.length === 2 && typeof localEnv === 'function' ) {
+ executeNow = localEnv;
+ localEnv = undefined;
+ }
+ if ( executeNow ) {
+ // Wrap executeNow() so that we can detect nested modules
+ orgExecute = executeNow;
+ executeNow = function () {
+ var ret;
+ nested = true;
+ ret = orgExecute.apply( this, arguments );
+ nested = false;
+ return ret;
+ };
+ }
+
+ localEnv = makeSafeEnv( localEnv );
+ orgBeforeEach = localEnv.beforeEach;
+ orgAfterEach = localEnv.afterEach;
+
+ localEnv.beforeEach = function () {
+ // Sinon sandbox
+ var config = sinon.getConfig( sinon.config );
+ config.injectInto = this;
+ sinon.sandbox.create( config );
+
+ // Fixture element
+ this.fixture = document.createElement( 'div' );
+ this.fixture.id = 'qunit-fixture';
+ document.body.appendChild( this.fixture );
+
+ if ( orgBeforeEach ) {
+ return orgBeforeEach.apply( this, arguments );
+ }
+ };
+ localEnv.afterEach = function () {
+ var ret;
+ if ( orgAfterEach ) {
+ ret = orgAfterEach.apply( this, arguments );
+ }
+ this.sandbox.verifyAndRestore();
+ this.fixture.parentNode.removeChild( this.fixture );
+ return ret;
+ };
+
+ return orgModule( name, localEnv, executeNow );
+ };
+ }() );
+
+ /**
+ * Reset mw.config and others to a fresh copy of the live config for each test(),
+ * and restore it back to the live one afterwards.
+ *
+ * @param {Object} [localEnv]
+ * @example (see test suite at the bottom of this file)
+ * </code>
+ */
+ QUnit.newMwEnvironment = ( function () {
+ var warn, error, liveConfig, liveMessages,
+ MwMap = mw.config.constructor, // internal use only
+ ajaxRequests = [];
+
+ liveConfig = mw.config;
+ liveMessages = mw.messages;
+
+ function suppressWarnings() {
+ if ( warn === undefined ) {
+ warn = mw.log.warn;
+ error = mw.log.error;
+ mw.log.warn = mw.log.error = $.noop;
+ }
+ }
+
+ function restoreWarnings() {
+ // Guard against calls not balanced with suppressWarnings()
+ if ( warn !== undefined ) {
+ mw.log.warn = warn;
+ mw.log.error = error;
+ warn = error = undefined;
+ }
+ }
+
+ function freshConfigCopy( custom ) {
+ var copy;
+ // Tests should mock all factors that directly influence the tested code.
+ // For backwards compatibility though we set mw.config to a fresh copy of the live
+ // config. This way any modifications made to mw.config during the test will not
+ // affect other tests, nor the global scope outside the test runner.
+ // This is a shallow copy, since overriding an array or object value via "custom"
+ // should replace it. Setting a config property means you override it, not extend it.
+ // NOTE: It is important that we suppress warnings because extend() will also access
+ // deprecated properties and trigger deprecation warnings from mw.log#deprecate.
+ suppressWarnings();
+ copy = $.extend( {}, liveConfig.get(), custom );
+ restoreWarnings();
+
+ return copy;
+ }
+
+ function freshMessagesCopy( custom ) {
+ return $.extend( /* deep */true, {}, liveMessages.get(), custom );
+ }
+
+ /**
+ * @param {jQuery.Event} event
+ * @param {jqXHR} jqXHR
+ * @param {Object} ajaxOptions
+ */
+ function trackAjax( event, jqXHR, ajaxOptions ) {
+ ajaxRequests.push( { xhr: jqXHR, options: ajaxOptions } );
+ }
+
+ return function ( orgEnv ) {
+ var localEnv, orgBeforeEach, orgAfterEach;
+
+ localEnv = makeSafeEnv( orgEnv );
+ // MediaWiki env testing
+ localEnv.config = localEnv.config || {};
+ localEnv.messages = localEnv.messages || {};
+
+ orgBeforeEach = localEnv.beforeEach;
+ orgAfterEach = localEnv.afterEach;
+
+ localEnv.beforeEach = function () {
+ // Greetings, mock environment!
+ mw.config = new MwMap();
+ mw.config.set( freshConfigCopy( localEnv.config ) );
+ mw.messages = new MwMap();
+ mw.messages.set( freshMessagesCopy( localEnv.messages ) );
+ // Update reference to mw.messages
+ mw.jqueryMsg.setParserDefaults( {
+ messages: mw.messages
+ } );
+
+ this.suppressWarnings = suppressWarnings;
+ this.restoreWarnings = restoreWarnings;
+
+ // Start tracking ajax requests
+ $( document ).on( 'ajaxSend', trackAjax );
+
+ if ( orgBeforeEach ) {
+ return orgBeforeEach.apply( this, arguments );
+ }
+ };
+ localEnv.afterEach = function () {
+ var timers, pending, $activeLen, ret;
+
+ if ( orgAfterEach ) {
+ ret = orgAfterEach.apply( this, arguments );
+ }
+
+ // Stop tracking ajax requests
+ $( document ).off( 'ajaxSend', trackAjax );
+
+ // As a convenience feature, automatically restore warnings if they're
+ // still suppressed by the end of the test.
+ restoreWarnings();
+
+ // Farewell, mock environment!
+ mw.config = liveConfig;
+ mw.messages = liveMessages;
+ // Restore reference to mw.messages
+ mw.jqueryMsg.setParserDefaults( {
+ messages: liveMessages
+ } );
+
+ // Tests should use fake timers or wait for animations to complete
+ // Check for incomplete animations/requests/etc and throw if there are any.
+ if ( $.timers && $.timers.length !== 0 ) {
+ timers = $.timers.length;
+ $.each( $.timers, function ( i, timer ) {
+ var node = timer.elem;
+ mw.log.warn( 'Unfinished animation #' + i + ' in ' + timer.queue + ' queue on ' +
+ mw.html.element( node.nodeName.toLowerCase(), $( node ).getAttrs() )
+ );
+ } );
+ // Force animations to stop to give the next test a clean start
+ $.timers = [];
+ $.fx.stop();
+
+ throw new Error( 'Unfinished animations: ' + timers );
+ }
+
+ // Test should use fake XHR, wait for requests, or call abort()
+ $activeLen = $.active;
+ if ( $activeLen !== undefined && $activeLen !== 0 ) {
+ pending = ajaxRequests.filter( function ( ajax ) {
+ return ajax.xhr.state() === 'pending';
+ } );
+ if ( pending.length !== $activeLen ) {
+ mw.log.warn( 'Pending requests does not match jQuery.active count' );
+ }
+ // Force requests to stop to give the next test a clean start
+ ajaxRequests.forEach( function ( ajax, i ) {
+ mw.log.warn(
+ 'AJAX request #' + i + ' (state: ' + ajax.xhr.state() + ')',
+ ajax.options
+ );
+ ajax.xhr.abort();
+ } );
+ ajaxRequests = [];
+
+ throw new Error( 'Pending AJAX requests: ' + pending.length + ' (active: ' + $activeLen + ')' );
+ }
+
+ return ret;
+ };
+ return localEnv;
+ };
+ }() );
+
+ // $.when stops as soon as one fails, which makes sense in most
+ // practical scenarios, but not in a unit test where we really do
+ // need to wait until all of them are finished.
+ QUnit.whenPromisesComplete = function () {
+ var altPromises = [];
+
+ $.each( arguments, function ( i, arg ) {
+ var alt = $.Deferred();
+ altPromises.push( alt );
+
+ // Whether this one fails or not, forwards it to
+ // the 'done' (resolve) callback of the alternative promise.
+ arg.always( alt.resolve );
+ } );
+
+ return $.when.apply( $, altPromises );
+ };
+
+ /**
+ * Recursively convert a node to a plain object representing its structure.
+ * Only considers attributes and contents (elements and text nodes).
+ * Attribute values are compared strictly and not normalised.
+ *
+ * @param {Node} node
+ * @return {Object|string} Plain JavaScript value representing the node.
+ */
+ function getDomStructure( node ) {
+ var $node, children, processedChildren, i, len, el;
+ $node = $( node );
+ if ( node.nodeType === Node.ELEMENT_NODE ) {
+ children = $node.contents();
+ processedChildren = [];
+ for ( i = 0, len = children.length; i < len; i++ ) {
+ el = children[ i ];
+ if ( el.nodeType === Node.ELEMENT_NODE || el.nodeType === Node.TEXT_NODE ) {
+ processedChildren.push( getDomStructure( el ) );
+ }
+ }
+
+ return {
+ tagName: node.tagName,
+ attributes: $node.getAttrs(),
+ contents: processedChildren
+ };
+ } else {
+ // Should be text node
+ return $node.text();
+ }
+ }
+
+ /**
+ * Gets structure of node for this HTML.
+ *
+ * @param {string} html HTML markup for one or more nodes.
+ */
+ function getHtmlStructure( html ) {
+ var el = $( '<div>' ).append( html )[ 0 ];
+ return getDomStructure( el );
+ }
+
+ /**
+ * Add-on assertion helpers
+ */
+ // Define the add-ons
+ addons = {
+
+ // Expect boolean true
+ assertTrue: function ( actual, message ) {
+ this.pushResult( {
+ result: actual === true,
+ actual: actual,
+ expected: true,
+ message: message
+ } );
+ },
+
+ // Expect boolean false
+ assertFalse: function ( actual, message ) {
+ this.pushResult( {
+ result: actual === false,
+ actual: actual,
+ expected: false,
+ message: message
+ } );
+ },
+
+ // Expect numerical value less than X
+ lt: function ( actual, expected, message ) {
+ this.pushResult( {
+ result: actual < expected,
+ actual: actual,
+ expected: 'less than ' + expected,
+ message: message
+ } );
+ },
+
+ // Expect numerical value less than or equal to X
+ ltOrEq: function ( actual, expected, message ) {
+ this.pushResult( {
+ result: actual <= expected,
+ actual: actual,
+ expected: 'less than or equal to ' + expected,
+ message: message
+ } );
+ },
+
+ // Expect numerical value greater than X
+ gt: function ( actual, expected, message ) {
+ this.pushResult( {
+ result: actual > expected,
+ actual: actual,
+ expected: 'greater than ' + expected,
+ message: message
+ } );
+ },
+
+ // Expect numerical value greater than or equal to X
+ gtOrEq: function ( actual, expected, message ) {
+ this.pushResult( {
+ result: actual >= true,
+ actual: actual,
+ expected: 'greater than or equal to ' + expected,
+ message: message
+ } );
+ },
+
+ /**
+ * Asserts that two HTML strings are structurally equivalent.
+ *
+ * @param {string} actualHtml Actual HTML markup.
+ * @param {string} expectedHtml Expected HTML markup
+ * @param {string} message Assertion message.
+ */
+ htmlEqual: function ( actualHtml, expectedHtml, message ) {
+ var actual = getHtmlStructure( actualHtml ),
+ expected = getHtmlStructure( expectedHtml );
+ this.pushResult( {
+ result: QUnit.equiv( actual, expected ),
+ actual: actual,
+ expected: expected,
+ message: message
+ } );
+ },
+
+ /**
+ * Asserts that two HTML strings are not structurally equivalent.
+ *
+ * @param {string} actualHtml Actual HTML markup.
+ * @param {string} expectedHtml Expected HTML markup.
+ * @param {string} message Assertion message.
+ */
+ notHtmlEqual: function ( actualHtml, expectedHtml, message ) {
+ var actual = getHtmlStructure( actualHtml ),
+ expected = getHtmlStructure( expectedHtml );
+
+ this.pushResult( {
+ result: !QUnit.equiv( actual, expected ),
+ actual: actual,
+ expected: expected,
+ message: message,
+ negative: true
+ } );
+ }
+ };
+
+ $.extend( QUnit.assert, addons );
+
+ /**
+ * Small test suite to confirm proper functionality of the utilities and
+ * initializations defined above in this file.
+ */
+ QUnit.module( 'testrunner', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.mwHtmlLive = mw.html;
+ mw.html = {
+ escape: function () {
+ return 'mocked';
+ }
+ };
+ },
+ teardown: function () {
+ mw.html = this.mwHtmlLive;
+ },
+ config: {
+ testVar: 'foo'
+ },
+ messages: {
+ testMsg: 'Foo.'
+ }
+ } ) );
+
+ QUnit.test( 'Setup', function ( assert ) {
+ assert.equal( mw.html.escape( 'foo' ), 'mocked', 'setup() callback was ran.' );
+ assert.equal( mw.config.get( 'testVar' ), 'foo', 'config object applied' );
+ assert.equal( mw.messages.get( 'testMsg' ), 'Foo.', 'messages object applied' );
+
+ mw.config.set( 'testVar', 'bar' );
+ mw.messages.set( 'testMsg', 'Bar.' );
+ } );
+
+ QUnit.test( 'Teardown', function ( assert ) {
+ assert.equal( mw.config.get( 'testVar' ), 'foo', 'config object restored and re-applied after test()' );
+ assert.equal( mw.messages.get( 'testMsg' ), 'Foo.', 'messages object restored and re-applied after test()' );
+ } );
+
+ QUnit.test( 'Loader status', function ( assert ) {
+ var i, len, state,
+ modules = mw.loader.getModuleNames(),
+ error = [],
+ missing = [];
+
+ for ( i = 0, len = modules.length; i < len; i++ ) {
+ state = mw.loader.getState( modules[ i ] );
+ if ( state === 'error' ) {
+ error.push( modules[ i ] );
+ } else if ( state === 'missing' ) {
+ missing.push( modules[ i ] );
+ }
+ }
+
+ assert.deepEqual( error, [], 'Modules in error state' );
+ assert.deepEqual( missing, [], 'Modules in missing state' );
+ } );
+
+ QUnit.test( 'assert.htmlEqual', function ( assert ) {
+ assert.htmlEqual(
+ '<div><p class="some classes" data-length="10">Child paragraph with <a href="http://example.com">A link</a></p>Regular text<span>A span</span></div>',
+ '<div><p data-length=\'10\' class=\'some classes\'>Child paragraph with <a href=\'http://example.com\' >A link</a></p>Regular text<span>A span</span></div>',
+ 'Attribute order, spacing and quotation marks (equal)'
+ );
+
+ assert.notHtmlEqual(
+ '<div><p class="some classes" data-length="10">Child paragraph with <a href="http://example.com">A link</a></p>Regular text<span>A span</span></div>',
+ '<div><p data-length=\'10\' class=\'some more classes\'>Child paragraph with <a href=\'http://example.com\' >A link</a></p>Regular text<span>A span</span></div>',
+ 'Attribute order, spacing and quotation marks (not equal)'
+ );
+
+ assert.htmlEqual(
+ '<label for="firstname" accesskey="f" class="important">First</label><input id="firstname" /><label for="lastname" accesskey="l" class="minor">Last</label><input id="lastname" />',
+ '<label for="firstname" accesskey="f" class="important">First</label><input id="firstname" /><label for="lastname" accesskey="l" class="minor">Last</label><input id="lastname" />',
+ 'Multiple root nodes (equal)'
+ );
+
+ assert.notHtmlEqual(
+ '<label for="firstname" accesskey="f" class="important">First</label><input id="firstname" /><label for="lastname" accesskey="l" class="minor">Last</label><input id="lastname" />',
+ '<label for="firstname" accesskey="f" class="important">First</label><input id="firstname" /><label for="lastname" accesskey="l" class="important" >Last</label><input id="lastname" />',
+ 'Multiple root nodes (not equal, last label node is different)'
+ );
+
+ assert.htmlEqual(
+ 'fo&quot;o<br/>b&gt;ar',
+ 'fo"o<br/>b>ar',
+ 'Extra escaping is equal'
+ );
+ assert.notHtmlEqual(
+ 'foo&lt;br/&gt;bar',
+ 'foo<br/>bar',
+ 'Text escaping (not equal)'
+ );
+
+ assert.htmlEqual(
+ 'foo<a href="http://example.com">example</a>bar',
+ 'foo<a href="http://example.com">example</a>bar',
+ 'Outer text nodes are compared (equal)'
+ );
+
+ assert.notHtmlEqual(
+ 'foo<a href="http://example.com">example</a>bar',
+ 'foo<a href="http://example.com">example</a>quux',
+ 'Outer text nodes are compared (last text node different)'
+ );
+ } );
+
+ QUnit.module( 'testrunner-after', QUnit.newMwEnvironment() );
+
+ QUnit.test( 'Teardown', function ( assert ) {
+ assert.equal( mw.html.escape( '<' ), '&lt;', 'teardown() callback was ran.' );
+ assert.equal( mw.config.get( 'testVar' ), null, 'config object restored to live in next module()' );
+ assert.equal( mw.messages.get( 'testMsg' ), null, 'messages object restored to live in next module()' );
+ } );
+
+ QUnit.module( 'testrunner-each', {
+ beforeEach: function () {
+ this.mwHtmlLive = mw.html;
+ },
+ afterEach: function () {
+ mw.html = this.mwHtmlLive;
+ }
+ } );
+ QUnit.test( 'beforeEach', function ( assert ) {
+ assert.ok( this.mwHtmlLive, 'setup() ran' );
+ mw.html = null;
+ } );
+ QUnit.test( 'afterEach', function ( assert ) {
+ assert.equal( mw.html.escape( '<' ), '&lt;', 'afterEach() ran' );
+ } );
+
+ QUnit.module( 'testrunner-each-compat', {
+ setup: function () {
+ this.mwHtmlLive = mw.html;
+ },
+ teardown: function () {
+ mw.html = this.mwHtmlLive;
+ }
+ } );
+ QUnit.test( 'setup', function ( assert ) {
+ assert.ok( this.mwHtmlLive, 'setup() ran' );
+ mw.html = null;
+ } );
+ QUnit.test( 'teardown', function ( assert ) {
+ assert.equal( mw.html.escape( '<' ), '&lt;', 'teardown() ran' );
+ } );
+
+ // Regression test for 'this.sandbox undefined' error, fixed by
+ // ensuring Sinon setup/teardown is not re-run on inner module.
+ QUnit.module( 'testrunner-nested', function () {
+ QUnit.module( 'testrunner-nested-inner', function () {
+ QUnit.test( 'Dummy', function ( assert ) {
+ assert.ok( true, 'Nested modules supported' );
+ } );
+ } );
+ } );
+
+ QUnit.module( 'testrunner-hooks-outer', function () {
+ var beforeHookWasExecuted = false,
+ afterHookWasExecuted = false;
+ QUnit.module( 'testrunner-hooks', {
+ before: function () {
+ beforeHookWasExecuted = true;
+
+ // This way we can be sure that module `testrunner-hook-after` will always
+ // be executed after module `testrunner-hooks`
+ QUnit.module( 'testrunner-hooks-after' );
+ QUnit.test(
+ '`after` hook for module `testrunner-hooks` was executed',
+ function ( assert ) {
+ assert.ok( afterHookWasExecuted );
+ }
+ );
+ },
+ after: function () {
+ afterHookWasExecuted = true;
+ }
+ } );
+
+ QUnit.test( '`before` hook was executed', function ( assert ) {
+ assert.ok( beforeHookWasExecuted );
+ } );
+ } );
+
+}( jQuery, mediaWiki, QUnit ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.accessKeyLabel.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.accessKeyLabel.test.js
new file mode 100644
index 00000000..e4b61572
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.accessKeyLabel.test.js
@@ -0,0 +1,121 @@
+( function ( $ ) {
+ var getAccessKeyPrefixTestData, updateTooltipAccessKeysTestData;
+
+ QUnit.module( 'jquery.accessKeyLabel', QUnit.newMwEnvironment( {
+ messages: {
+ brackets: '[$1]',
+ 'word-separator': ' '
+ }
+ } ) );
+
+ getAccessKeyPrefixTestData = [
+ // ua string, platform string, expected prefix
+ // Internet Explorer
+ [ 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)', 'Win32', 'alt-' ],
+ [ 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0)', 'Win32', 'alt-' ],
+ [ 'Mozilla/5.0 (Windows NT 6.3; Win64; x64; Trident/7.0; rv:11.0) like Gecko', 'Win64', 'alt-' ],
+ [ 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.10136', 'Win64', 'alt-' ],
+ // Firefox
+ [ 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1.19) Gecko/20110420 Firefox/3.5.19', 'MacIntel', 'ctrl-' ],
+ [ 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.2.17) Gecko/20110422 Ubuntu/10.10 (maverick) Firefox/3.6.17', 'Linux i686', 'alt-shift-' ],
+ [ 'Mozilla/5.0 (Windows NT 6.0; rv:2.0.1) Gecko/20100101 Firefox/4.0.1', 'Win32', 'alt-shift-' ],
+ [ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:50.0) Gecko/20100101 Firefox/50.0', 'MacIntel', 'ctrl-option-' ],
+ [ 'Mozilla/5.0 (X11; Linux x86_64; rv:17.0) Gecko/20121202 Firefox/17.0 Iceweasel/17.0.1', 'Linux 1686', 'alt-shift-' ],
+ [ 'Mozilla/5.0 (Windows NT 5.2; U; de; rv:1.8.0) Gecko/20060728 Firefox/1.5.0', 'Win32', 'alt-' ],
+ // Safari / Konqueror
+ [ 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_7; nl-nl) AppleWebKit/531.22.7 (KHTML, like Gecko) Version/4.0.5 Safari/531.22.7', 'MacIntel', 'ctrl-option-' ],
+ [ 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_7; de-de) AppleWebKit/525.28.3 (KHTML, like Gecko) Version/3.2.3 Safari/525.28.3', 'MacIntel', 'ctrl-' ],
+ [ 'Mozilla/5.0 (Windows; U; Windows NT 5.1; cs-CZ) AppleWebKit/525.28.3 (KHTML, like Gecko) Version/3.2.3 Safari/525.29', 'Win32', 'alt-' ],
+ [ 'Mozilla/5.0 (Windows; U; Windows NT 6.0; cs-CZ) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/4.0.5 Safari/531.22.7', 'Win32', 'alt-' ],
+ [ 'Mozilla/5.0 (X11; Linux i686) KHTML/4.9.1 (like Gecko) Konqueror/4.9', 'Linux i686', 'ctrl-' ],
+ // Opera
+ [ 'Opera/9.80 (Windows NT 5.1)', 'Win32', 'shift-esc-' ],
+ [ 'Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.52 Safari/537.36 OPR/15.0.1147.130', 'Win32', 'alt-shift-' ],
+ // Chrome
+ [ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_5_8) AppleWebKit/534.30 (KHTML, like Gecko) Chrome/12.0.742.112 Safari/534.30', 'MacIntel', 'ctrl-option-' ],
+ [ 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/534.30 (KHTML, like Gecko) Chrome/12.0.742.68 Safari/534.30', 'Linux i686', 'alt-shift-' ],
+ // Unknown! Note: These aren't necessarily *right*, this is just
+ // testing that we're getting the expected output based on the
+ // platform.
+ [ 'Mozilla/5.0 (Macintosh; U; PPC Mac OS X; en-US; rv:1.0.1) Gecko/20021111 Chimera/0.6', 'MacPPC', 'ctrl-' ],
+ [ 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.3a) Gecko/20021207 Phoenix/0.5', 'Linux i686', 'alt-' ]
+ ];
+ // strings appended to title to make sure updateTooltipAccessKeys handles them correctly
+ updateTooltipAccessKeysTestData = [ '', ' [a]', ' [test-a]', ' [alt-b]' ];
+
+ function makeInput( title, accessKey ) {
+ // The properties aren't escaped, so make sure you don't call this function with values that need to be escaped!
+ return '<input title="' + title + '" ' + ( accessKey ? 'accessKey="' + accessKey + '" ' : '' ) + ' />';
+ }
+
+ QUnit.test( 'getAccessKeyPrefix', function ( assert ) {
+ var i;
+ for ( i = 0; i < getAccessKeyPrefixTestData.length; i++ ) {
+ assert.equal( $.fn.updateTooltipAccessKeys.getAccessKeyPrefix( {
+ userAgent: getAccessKeyPrefixTestData[ i ][ 0 ],
+ platform: getAccessKeyPrefixTestData[ i ][ 1 ]
+ } ), getAccessKeyPrefixTestData[ i ][ 2 ], 'Correct prefix for ' + getAccessKeyPrefixTestData[ i ][ 0 ] );
+ }
+ } );
+
+ QUnit.test( 'updateTooltipAccessKeys - current browser', function ( assert ) {
+ var title = $( makeInput( 'Title', 'a' ) ).updateTooltipAccessKeys().prop( 'title' ),
+ // The new title should be something like "Title [alt-a]", but the exact label will depend on the browser.
+ // The "a" could be capitalized, and the prefix could be anything, e.g. a simple "^" for ctrl-
+ // (no browser is known using such a short prefix, though) or "Alt+Umschalt+" in German Firefox.
+ result = /^Title \[(.+)[aA]\]$/.exec( title );
+ assert.ok( result, 'title should match expected structure.' );
+ assert.notEqual( result[ 1 ], 'test-', 'Prefix used for testing shouldn\'t be used in production.' );
+ } );
+
+ QUnit.test( 'updateTooltipAccessKeys - no access key', function ( assert ) {
+ var i, oldTitle, $input, newTitle;
+ for ( i = 0; i < updateTooltipAccessKeysTestData.length; i++ ) {
+ oldTitle = 'Title' + updateTooltipAccessKeysTestData[ i ];
+ $input = $( makeInput( oldTitle ) );
+ $( '#qunit-fixture' ).append( $input );
+ newTitle = $input.updateTooltipAccessKeys().prop( 'title' );
+ assert.equal( newTitle, 'Title', 'title="' + oldTitle + '"' );
+ }
+ } );
+
+ QUnit.test( 'updateTooltipAccessKeys - with access key', function ( assert ) {
+ var i, oldTitle, $input, newTitle;
+ $.fn.updateTooltipAccessKeys.setTestMode( true );
+ for ( i = 0; i < updateTooltipAccessKeysTestData.length; i++ ) {
+ oldTitle = 'Title' + updateTooltipAccessKeysTestData[ i ];
+ $input = $( makeInput( oldTitle, 'a' ) );
+ $( '#qunit-fixture' ).append( $input );
+ newTitle = $input.updateTooltipAccessKeys().prop( 'title' );
+ assert.equal( newTitle, 'Title [test-a]', 'title="' + oldTitle + '"' );
+ }
+ $.fn.updateTooltipAccessKeys.setTestMode( false );
+ } );
+
+ QUnit.test( 'updateTooltipAccessKeys with label element', function ( assert ) {
+ var html, $label, $input;
+ $.fn.updateTooltipAccessKeys.setTestMode( true );
+ html = '<label for="testInput" title="Title">Label</label><input id="testInput" accessKey="a" />';
+ $( '#qunit-fixture' ).html( html );
+ $label = $( '#qunit-fixture label' );
+ $input = $( '#qunit-fixture input' );
+ $input.updateTooltipAccessKeys();
+ assert.equal( $input.prop( 'title' ), '', 'No title attribute added to input element.' );
+ assert.equal( $label.prop( 'title' ), 'Title [test-a]', 'title updated for associated label element.' );
+ $.fn.updateTooltipAccessKeys.setTestMode( false );
+ } );
+
+ QUnit.test( 'updateTooltipAccessKeys with label element as parent', function ( assert ) {
+ var html, $label, $input;
+ $.fn.updateTooltipAccessKeys.setTestMode( true );
+ html = '<label title="Title">Label<input id="testInput" accessKey="a" /></label>';
+ $( '#qunit-fixture' ).html( html );
+ $label = $( '#qunit-fixture label' );
+ $input = $( '#qunit-fixture input' );
+ $input.updateTooltipAccessKeys();
+ assert.equal( $input.prop( 'title' ), '', 'No title attribute added to input element.' );
+ assert.equal( $label.prop( 'title' ), 'Title [test-a]', 'title updated for associated label element.' );
+ $.fn.updateTooltipAccessKeys.setTestMode( false );
+ } );
+
+}( jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.color.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.color.test.js
new file mode 100644
index 00000000..ca6a512f
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.color.test.js
@@ -0,0 +1,15 @@
+( function ( $ ) {
+ QUnit.module( 'jquery.color', QUnit.newMwEnvironment() );
+
+ QUnit.test( 'animate', function ( assert ) {
+ var done = assert.async(),
+ $canvas = $( '<div>' ).css( 'background-color', '#fff' ).appendTo( '#qunit-fixture' );
+
+ $canvas.animate( { 'background-color': '#000' }, 3 ).promise()
+ .done( function () {
+ var endColors = $.colorUtil.getRGB( $canvas.css( 'background-color' ) );
+ assert.deepEqual( endColors, [ 0, 0, 0 ], 'end state' );
+ } )
+ .always( done );
+ } );
+}( jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.colorUtil.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.colorUtil.test.js
new file mode 100644
index 00000000..d6208e91
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.colorUtil.test.js
@@ -0,0 +1,63 @@
+( function ( $ ) {
+ QUnit.module( 'jquery.colorUtil', QUnit.newMwEnvironment() );
+
+ QUnit.test( 'getRGB', function ( assert ) {
+ assert.strictEqual( $.colorUtil.getRGB(), undefined, 'No arguments' );
+ assert.strictEqual( $.colorUtil.getRGB( '' ), undefined, 'Empty string' );
+ assert.deepEqual( $.colorUtil.getRGB( [ 0, 100, 255 ] ), [ 0, 100, 255 ], 'Parse array of rgb values' );
+ assert.deepEqual( $.colorUtil.getRGB( 'rgb(0,100,255)' ), [ 0, 100, 255 ], 'Parse simple rgb string' );
+ assert.deepEqual( $.colorUtil.getRGB( 'rgb(0, 100, 255)' ), [ 0, 100, 255 ], 'Parse simple rgb string with spaces' );
+ assert.deepEqual( $.colorUtil.getRGB( 'rgb(0%,20%,40%)' ), [ 0, 51, 102 ], 'Parse rgb string with percentages' );
+ assert.deepEqual( $.colorUtil.getRGB( 'rgb(0%, 20%, 40%)' ), [ 0, 51, 102 ], 'Parse rgb string with percentages and spaces' );
+ assert.deepEqual( $.colorUtil.getRGB( '#f2ddee' ), [ 242, 221, 238 ], 'Hex string: 6 char lowercase' );
+ assert.deepEqual( $.colorUtil.getRGB( '#f2DDEE' ), [ 242, 221, 238 ], 'Hex string: 6 char uppercase' );
+ assert.deepEqual( $.colorUtil.getRGB( '#f2DdEe' ), [ 242, 221, 238 ], 'Hex string: 6 char mixed' );
+ assert.deepEqual( $.colorUtil.getRGB( '#eee' ), [ 238, 238, 238 ], 'Hex string: 3 char lowercase' );
+ assert.deepEqual( $.colorUtil.getRGB( '#EEE' ), [ 238, 238, 238 ], 'Hex string: 3 char uppercase' );
+ assert.deepEqual( $.colorUtil.getRGB( '#eEe' ), [ 238, 238, 238 ], 'Hex string: 3 char mixed' );
+ assert.deepEqual( $.colorUtil.getRGB( 'rgba(0, 0, 0, 0)' ), [ 255, 255, 255 ], 'Zero rgba for Safari 3; Transparent (whitespace)' );
+
+ // Perhaps this is a bug in colorUtil, but it is the current behavior so, let's keep
+ // track of it, so we will know in case it would ever change.
+ assert.strictEqual( $.colorUtil.getRGB( 'rgba(0,0,0,0)' ), undefined, 'Zero rgba without whitespace' );
+
+ assert.deepEqual( $.colorUtil.getRGB( 'lightGreen' ), [ 144, 238, 144 ], 'Color names (lightGreen)' );
+ assert.deepEqual( $.colorUtil.getRGB( 'transparent' ), [ 255, 255, 255 ], 'Color names (transparent)' );
+ assert.strictEqual( $.colorUtil.getRGB( 'mediaWiki' ), undefined, 'Inexisting color name' );
+ } );
+
+ QUnit.test( 'rgbToHsl', function ( assert ) {
+ var hsl, ret;
+
+ // Cross-browser differences in decimals...
+ // Round to two decimals so they can be more reliably checked.
+ function dualDecimals( a ) {
+ return Math.round( a * 100 ) / 100;
+ }
+
+ // Re-create the rgbToHsl return array items, limited to two decimals.
+ hsl = $.colorUtil.rgbToHsl( 144, 238, 144 );
+ ret = [ dualDecimals( hsl[ 0 ] ), dualDecimals( hsl[ 1 ] ), dualDecimals( hsl[ 2 ] ) ];
+
+ assert.deepEqual( ret, [ 0.33, 0.73, 0.75 ], 'rgb(144, 238, 144): hsl(0.33, 0.73, 0.75)' );
+ } );
+
+ QUnit.test( 'hslToRgb', function ( assert ) {
+ var rgb, ret;
+ rgb = $.colorUtil.hslToRgb( 0.3, 0.7, 0.8 );
+
+ // Re-create the hslToRgb return array items, rounded to whole numbers.
+ ret = [ Math.round( rgb[ 0 ] ), Math.round( rgb[ 1 ] ), Math.round( rgb[ 2 ] ) ];
+
+ assert.deepEqual( ret, [ 183, 240, 168 ], 'hsl(0.3, 0.7, 0.8): rgb(183, 240, 168)' );
+ } );
+
+ QUnit.test( 'getColorBrightness', function ( assert ) {
+ var a, b;
+ a = $.colorUtil.getColorBrightness( 'red', +0.1 );
+ assert.equal( a, 'rgb(255,50,50)', 'Start with named color "red", brighten 10%' );
+
+ b = $.colorUtil.getColorBrightness( 'rgb(200,50,50)', -0.2 );
+ assert.equal( b, 'rgb(118,29,29)', 'Start with rgb string "rgb(200,50,50)", darken 20%' );
+ } );
+}( jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.getAttrs.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.getAttrs.test.js
new file mode 100644
index 00000000..74d85090
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.getAttrs.test.js
@@ -0,0 +1,14 @@
+( function ( $ ) {
+ QUnit.module( 'jquery.getAttrs', QUnit.newMwEnvironment() );
+
+ QUnit.test( 'getAttrs()', function ( assert ) {
+ var attrs = {
+ foo: 'bar',
+ class: 'lorem',
+ 'data-foo': 'data value'
+ },
+ $el = $( '<div>' ).attr( attrs );
+
+ assert.propEqual( $el.getAttrs(), attrs, 'keys and values match' );
+ } );
+}( jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.hidpi.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.hidpi.test.js
new file mode 100644
index 00000000..6a265eb5
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.hidpi.test.js
@@ -0,0 +1,38 @@
+( function ( $ ) {
+ QUnit.module( 'jquery.hidpi', QUnit.newMwEnvironment() );
+
+ QUnit.test( 'devicePixelRatio', function ( assert ) {
+ var devicePixelRatio = $.devicePixelRatio();
+ assert.equal( typeof devicePixelRatio, 'number', '$.devicePixelRatio() returns a number' );
+ } );
+
+ QUnit.test( 'bracketedDevicePixelRatio', function ( assert ) {
+ var ratio = $.bracketedDevicePixelRatio();
+ assert.equal( typeof ratio, 'number', '$.bracketedDevicePixelRatio() returns a number' );
+ } );
+
+ QUnit.test( 'bracketDevicePixelRatio', function ( assert ) {
+ assert.equal( $.bracketDevicePixelRatio( 0.75 ), 1, '0.75 gives 1' );
+ assert.equal( $.bracketDevicePixelRatio( 1 ), 1, '1 gives 1' );
+ assert.equal( $.bracketDevicePixelRatio( 1.25 ), 1.5, '1.25 gives 1.5' );
+ assert.equal( $.bracketDevicePixelRatio( 1.5 ), 1.5, '1.5 gives 1.5' );
+ assert.equal( $.bracketDevicePixelRatio( 1.75 ), 2, '1.75 gives 2' );
+ assert.equal( $.bracketDevicePixelRatio( 2 ), 2, '2 gives 2' );
+ assert.equal( $.bracketDevicePixelRatio( 2.5 ), 2, '2.5 gives 2' );
+ assert.equal( $.bracketDevicePixelRatio( 3 ), 2, '3 gives 2' );
+ } );
+
+ QUnit.test( 'matchSrcSet', function ( assert ) {
+ var srcset = 'onefive.png 1.5x, two.png 2x';
+
+ // Nice exact matches
+ assert.equal( $.matchSrcSet( 1, srcset ), null, '1.0 gives no match' );
+ assert.equal( $.matchSrcSet( 1.5, srcset ), 'onefive.png', '1.5 gives match' );
+ assert.equal( $.matchSrcSet( 2, srcset ), 'two.png', '2 gives match' );
+
+ // Non-exact matches; should return the next-biggest specified
+ assert.equal( $.matchSrcSet( 1.25, srcset ), null, '1.25 gives no match' );
+ assert.equal( $.matchSrcSet( 1.75, srcset ), 'onefive.png', '1.75 gives match to 1.5' );
+ assert.equal( $.matchSrcSet( 2.25, srcset ), 'two.png', '2.25 gives match to 2' );
+ } );
+}( jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.highlightText.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.highlightText.test.js
new file mode 100644
index 00000000..277ba3f2
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.highlightText.test.js
@@ -0,0 +1,235 @@
+( function ( $ ) {
+ QUnit.module( 'jquery.highlightText', QUnit.newMwEnvironment() );
+
+ QUnit.test( 'Check', function ( assert ) {
+ var $fixture,
+ cases = [
+ {
+ desc: 'Test 001',
+ text: 'Blue Öyster Cult',
+ highlight: 'Blue',
+ expected: '<span class="highlight">Blue</span> Öyster Cult'
+ },
+ {
+ desc: 'Test 002',
+ text: 'Blue Öyster Cult',
+ highlight: 'Blue ',
+ expected: '<span class="highlight">Blue</span> Öyster Cult'
+ },
+ {
+ desc: 'Test 003',
+ text: 'Blue Öyster Cult',
+ highlight: 'Blue Ö',
+ expected: '<span class="highlight">Blue</span> <span class="highlight">Ö</span>yster Cult'
+ },
+ {
+ desc: 'Test 004',
+ text: 'Blue Öyster Cult',
+ highlight: 'Blue Öy',
+ expected: '<span class="highlight">Blue</span> <span class="highlight">Öy</span>ster Cult'
+ },
+ {
+ desc: 'Test 005',
+ text: 'Blue Öyster Cult',
+ highlight: ' Blue',
+ expected: '<span class="highlight">Blue</span> Öyster Cult'
+ },
+ {
+ desc: 'Test 006',
+ text: 'Blue Öyster Cult',
+ highlight: ' Blue ',
+ expected: '<span class="highlight">Blue</span> Öyster Cult'
+ },
+ {
+ desc: 'Test 007',
+ text: 'Blue Öyster Cult',
+ highlight: ' Blue Ö',
+ expected: '<span class="highlight">Blue</span> <span class="highlight">Ö</span>yster Cult'
+ },
+ {
+ desc: 'Test 008',
+ text: 'Blue Öyster Cult',
+ highlight: ' Blue Öy',
+ expected: '<span class="highlight">Blue</span> <span class="highlight">Öy</span>ster Cult'
+ },
+ {
+ desc: 'Test 009: Highlighter broken on starting Umlaut?',
+ text: 'Österreich',
+ highlight: 'Österreich',
+ expected: '<span class="highlight">Österreich</span>'
+ },
+ {
+ desc: 'Test 010: Highlighter broken on starting Umlaut?',
+ text: 'Österreich',
+ highlight: 'Ö',
+ expected: '<span class="highlight">Ö</span>sterreich'
+ },
+ {
+ desc: 'Test 011: Highlighter broken on starting Umlaut?',
+ text: 'Österreich',
+ highlight: 'Öst',
+ expected: '<span class="highlight">Öst</span>erreich'
+ },
+ {
+ desc: 'Test 012: Highlighter broken on starting Umlaut?',
+ text: 'Österreich',
+ highlight: 'Oe',
+ expected: 'Österreich'
+ },
+ {
+ desc: 'Test 013: Highlighter broken on punctuation mark?',
+ text: 'So good. To be there',
+ highlight: 'good',
+ expected: 'So <span class="highlight">good</span>. To be there'
+ },
+ {
+ desc: 'Test 014: Highlighter broken on space?',
+ text: 'So good. To be there',
+ highlight: 'be',
+ expected: 'So good. To <span class="highlight">be</span> there'
+ },
+ {
+ desc: 'Test 015: Highlighter broken on space?',
+ text: 'So good. To be there',
+ highlight: ' be',
+ expected: 'So good. To <span class="highlight">be</span> there'
+ },
+ {
+ desc: 'Test 016: Highlighter broken on space?',
+ text: 'So good. To be there',
+ highlight: 'be ',
+ expected: 'So good. To <span class="highlight">be</span> there'
+ },
+ {
+ desc: 'Test 017: Highlighter broken on space?',
+ text: 'So good. To be there',
+ highlight: ' be ',
+ expected: 'So good. To <span class="highlight">be</span> there'
+ },
+ {
+ desc: 'Test 018: en de Highlighter broken on special character at the end?',
+ text: 'So good. xbß',
+ highlight: 'xbß',
+ expected: 'So good. <span class="highlight">xbß</span>'
+ },
+ {
+ desc: 'Test 019: en de Highlighter broken on special character at the end?',
+ text: 'So good. xbß.',
+ highlight: 'xbß.',
+ expected: 'So good. <span class="highlight">xbß.</span>'
+ },
+ {
+ desc: 'Test 020: RTL he Hebrew',
+ text: 'חסיד אומות העולם',
+ highlight: 'חסיד אומות העולם',
+ expected: '<span class="highlight">חסיד</span> <span class="highlight">אומות</span> <span class="highlight">העולם</span>'
+ },
+ {
+ desc: 'Test 021: RTL he Hebrew',
+ text: 'חסיד אומות העולם',
+ highlight: 'חסי',
+ expected: '<span class="highlight">חסי</span>ד אומות העולם'
+ },
+ {
+ desc: 'Test 022: ja Japanese',
+ text: '諸国民の中の正義の人',
+ highlight: '諸国民の中の正義の人',
+ expected: '<span class="highlight">諸国民の中の正義の人</span>'
+ },
+ {
+ desc: 'Test 023: ja Japanese',
+ text: '諸国民の中の正義の人',
+ highlight: '諸国',
+ expected: '<span class="highlight">諸国</span>民の中の正義の人'
+ },
+ {
+ desc: 'Test 024: fr French text and « french quotes » (guillemets)',
+ text: '« L\'oiseau est sur l’île »',
+ highlight: '« L\'oiseau est sur l’île »',
+ expected: '<span class="highlight">«</span> <span class="highlight">L\'oiseau</span> <span class="highlight">est</span> <span class="highlight">sur</span> <span class="highlight">l’île</span> <span class="highlight">»</span>'
+ },
+ {
+ desc: 'Test 025: fr French text and « french quotes » (guillemets)',
+ text: '« L\'oiseau est sur l’île »',
+ highlight: '« L\'oise',
+ expected: '<span class="highlight">«</span> <span class="highlight">L\'oise</span>au est sur l’île »'
+ },
+ {
+ desc: 'Test 025a: fr French text and « french quotes » (guillemets) - does it match the single strings "«" and "L" separately?',
+ text: '« L\'oiseau est sur l’île »',
+ highlight: '« L',
+ expected: '<span class="highlight">«</span> <span class="highlight">L</span>\'oiseau est sur <span class="highlight">l</span>’île »'
+ },
+ {
+ desc: 'Test 026: ru Russian',
+ text: 'Праведники мира',
+ highlight: 'Праведники мира',
+ expected: '<span class="highlight">Праведники</span> <span class="highlight">мира</span>'
+ },
+ {
+ desc: 'Test 027: ru Russian',
+ text: 'Праведники мира',
+ highlight: 'Праве',
+ expected: '<span class="highlight">Праве</span>дники мира'
+ },
+ {
+ desc: 'Test 028 ka Georgian',
+ text: 'მთავარი გვერდი',
+ highlight: 'მთავარი გვერდი',
+ expected: '<span class="highlight">მთავარი</span> <span class="highlight">გვერდი</span>'
+ },
+ {
+ desc: 'Test 029 ka Georgian',
+ text: 'მთავარი გვერდი',
+ highlight: 'მთა',
+ expected: '<span class="highlight">მთა</span>ვარი გვერდი'
+ },
+ {
+ desc: 'Test 030 hy Armenian',
+ text: 'Նոնա Գափրինդաշվիլի',
+ highlight: 'Նոնա Գափրինդաշվիլի',
+ expected: '<span class="highlight">Նոնա</span> <span class="highlight">Գափրինդաշվիլի</span>'
+ },
+ {
+ desc: 'Test 031 hy Armenian',
+ text: 'Նոնա Գափրինդաշվիլի',
+ highlight: 'Նոն',
+ expected: '<span class="highlight">Նոն</span>ա Գափրինդաշվիլի'
+ },
+ {
+ desc: 'Test 032: th Thai',
+ text: 'พอล แอร์ดิช',
+ highlight: 'พอล แอร์ดิช',
+ expected: '<span class="highlight">พอล</span> <span class="highlight">แอร์ดิช</span>'
+ },
+ {
+ desc: 'Test 033: th Thai',
+ text: 'พอล แอร์ดิช',
+ highlight: 'พอ',
+ expected: '<span class="highlight">พอ</span>ล แอร์ดิช'
+ },
+ {
+ desc: 'Test 034: RTL ar Arabic',
+ text: 'بول إيردوس',
+ highlight: 'بول إيردوس',
+ expected: '<span class="highlight">بول</span> <span class="highlight">إيردوس</span>'
+ },
+ {
+ desc: 'Test 035: RTL ar Arabic',
+ text: 'بول إيردوس',
+ highlight: 'بو',
+ expected: '<span class="highlight">بو</span>ل إيردوس'
+ }
+ ];
+
+ cases.forEach( function ( item ) {
+ $fixture = $( '<p>' ).text( item.text ).highlightText( item.highlight );
+ assert.equal(
+ $fixture.html(),
+ // Re-parse to normalize
+ $( '<p>' ).html( item.expected ).html(),
+ item.desc || undefined
+ );
+ } );
+ } );
+}( jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js
new file mode 100644
index 00000000..7117d1f4
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js
@@ -0,0 +1,286 @@
+( function ( $, mw ) {
+ var simpleSample, U_20AC, poop, mbSample;
+
+ QUnit.module( 'jquery.lengthLimit', QUnit.newMwEnvironment() );
+
+ // Simple sample (20 chars, 20 bytes)
+ simpleSample = '12345678901234567890';
+
+ // 3 bytes (euro-symbol)
+ U_20AC = '\u20AC';
+
+ // Outside of the BMP (pile of poo emoji)
+ poop = '\uD83D\uDCA9'; // "💩"
+
+ // Multi-byte sample (22 chars, 26 bytes)
+ mbSample = '1234567890' + U_20AC + '1234567890' + U_20AC;
+
+ // Basic sendkey-implementation
+ function addChars( $input, charstr ) {
+ var c, len;
+
+ function x( $input, i ) {
+ // Add character to the value
+ return $input.val() + charstr.charAt( i );
+ }
+
+ for ( c = 0, len = charstr.length; c < len; c += 1 ) {
+ $input
+ .val( x( $input, c ) )
+ .trigger( 'change' );
+ }
+ }
+
+ /**
+ * Test factory for $.fn.byteLimit
+ *
+ * @param {Object} options
+ * @param {string} options.description Test name
+ * @param {jQuery} options.$input jQuery object in an input element
+ * @param {string} options.sample Sequence of characters to simulate being
+ * added one by one
+ * @param {string} options.expected Expected final value of `$input`
+ */
+ function byteLimitTest( options ) {
+ var opt = $.extend( {
+ description: '',
+ $input: null,
+ sample: '',
+ expected: ''
+ }, options );
+
+ QUnit.test( opt.description, function ( assert ) {
+ opt.$input.appendTo( '#qunit-fixture' );
+
+ // Simulate pressing keys for each of the sample characters
+ addChars( opt.$input, opt.sample );
+
+ assert.equal(
+ opt.$input.val(),
+ opt.expected,
+ 'New value matches the expected string'
+ );
+ } );
+ }
+
+ byteLimitTest( {
+ description: 'Plain text input',
+ $input: $( '<input>' ).attr( 'type', 'text' ),
+ sample: simpleSample,
+ expected: simpleSample
+ } );
+
+ byteLimitTest( {
+ description: 'Plain text input. Calling byteLimit with no parameters and no maxlength attribute (T38310)',
+ $input: $( '<input>' ).attr( 'type', 'text' )
+ .byteLimit(),
+ sample: simpleSample,
+ expected: simpleSample
+ } );
+
+ byteLimitTest( {
+ description: 'Limit using the maxlength attribute',
+ $input: $( '<input>' ).attr( 'type', 'text' )
+ .attr( 'maxlength', '10' )
+ .byteLimit(),
+ sample: simpleSample,
+ expected: '1234567890'
+ } );
+
+ byteLimitTest( {
+ description: 'Limit using a custom value',
+ $input: $( '<input>' ).attr( 'type', 'text' )
+ .byteLimit( 10 ),
+ sample: simpleSample,
+ expected: '1234567890'
+ } );
+
+ byteLimitTest( {
+ description: 'Limit using a custom value, overriding maxlength attribute',
+ $input: $( '<input>' ).attr( 'type', 'text' )
+ .attr( 'maxlength', '10' )
+ .byteLimit( 15 ),
+ sample: simpleSample,
+ expected: '123456789012345'
+ } );
+
+ byteLimitTest( {
+ description: 'Limit using a custom value (multibyte)',
+ $input: $( '<input>' ).attr( 'type', 'text' )
+ .byteLimit( 14 ),
+ sample: mbSample,
+ expected: '1234567890' + U_20AC + '1'
+ } );
+
+ byteLimitTest( {
+ description: 'Limit using a custom value (multibyte, outside BMP)',
+ $input: $( '<input>' ).attr( 'type', 'text' )
+ .byteLimit( 3 ),
+ sample: poop,
+ expected: ''
+ } );
+
+ byteLimitTest( {
+ description: 'Limit using a custom value (multibyte) overlapping a byte',
+ $input: $( '<input>' ).attr( 'type', 'text' )
+ .byteLimit( 12 ),
+ sample: mbSample,
+ expected: '123456789012'
+ } );
+
+ byteLimitTest( {
+ description: 'Pass the limit and a callback as input filter',
+ $input: $( '<input>' ).attr( 'type', 'text' )
+ .byteLimit( 6, function ( val ) {
+ var title = mw.Title.newFromText( String( val ) );
+ // Return without namespace prefix
+ return title ? title.getMain() : '';
+ } ),
+ sample: 'User:Sample',
+ expected: 'User:Sample'
+ } );
+
+ byteLimitTest( {
+ description: 'Limit using the maxlength attribute and pass a callback as input filter',
+ $input: $( '<input>' ).attr( 'type', 'text' )
+ .attr( 'maxlength', '6' )
+ .byteLimit( function ( val ) {
+ var title = mw.Title.newFromText( String( val ) );
+ // Return without namespace prefix
+ return title ? title.getMain() : '';
+ } ),
+ sample: 'User:Sample',
+ expected: 'User:Sample'
+ } );
+
+ byteLimitTest( {
+ description: 'Pass the limit and a callback as input filter',
+ $input: $( '<input>' ).attr( 'type', 'text' )
+ .byteLimit( 6, function ( val ) {
+ var title = mw.Title.newFromText( String( val ) );
+ // Return without namespace prefix
+ return title ? title.getMain() : '';
+ } ),
+ sample: 'User:Example',
+ // The callback alters the value to be used to calculeate
+ // the length. The altered value is "Exampl" which has
+ // a length of 6, the "e" would exceed the limit.
+ expected: 'User:Exampl'
+ } );
+
+ byteLimitTest( {
+ description: 'Input filter that increases the length',
+ $input: $( '<input>' ).attr( 'type', 'text' )
+ .byteLimit( 10, function ( text ) {
+ return 'prefix' + text;
+ } ),
+ sample: simpleSample,
+ // Prefix adds 6 characters, limit is reached after 4
+ expected: '1234'
+ } );
+
+ // Regression tests for T43450
+ byteLimitTest( {
+ description: 'Input filter of which the base exceeds the limit',
+ $input: $( '<input>' ).attr( 'type', 'text' )
+ .byteLimit( 3, function ( text ) {
+ return 'prefix' + text;
+ } ),
+ sample: simpleSample,
+ expected: ''
+ } );
+
+ QUnit.test( 'Confirm properties and attributes set', function ( assert ) {
+ var $el;
+
+ $el = $( '<input>' ).attr( 'type', 'text' )
+ .attr( 'maxlength', '7' )
+ .appendTo( '#qunit-fixture' )
+ .byteLimit();
+
+ assert.strictEqual( $el.attr( 'maxlength' ), '7', 'maxlength attribute unchanged for simple limit' );
+
+ $el = $( '<input>' ).attr( 'type', 'text' )
+ .attr( 'maxlength', '7' )
+ .appendTo( '#qunit-fixture' )
+ .byteLimit( 12 );
+
+ assert.strictEqual( $el.attr( 'maxlength' ), '12', 'maxlength attribute updated for custom limit' );
+
+ $el = $( '<input>' ).attr( 'type', 'text' )
+ .attr( 'maxlength', '7' )
+ .appendTo( '#qunit-fixture' )
+ .byteLimit( 12, function ( val ) {
+ return val;
+ } );
+
+ assert.strictEqual( $el.attr( 'maxlength' ), undefined, 'maxlength attribute removed for limit with callback' );
+
+ $( '<input>' ).attr( 'type', 'text' )
+ .addClass( 'mw-test-byteLimit-foo' )
+ .attr( 'maxlength', '7' )
+ .appendTo( '#qunit-fixture' );
+
+ $( '<input>' ).attr( 'type', 'text' )
+ .addClass( 'mw-test-byteLimit-foo' )
+ .attr( 'maxlength', '12' )
+ .appendTo( '#qunit-fixture' );
+
+ $el = $( '.mw-test-byteLimit-foo' );
+
+ assert.strictEqual( $el.length, 2, 'Verify that there are no other elements clashing with this test suite' );
+
+ $el.byteLimit();
+ } );
+
+ QUnit.test( 'Trim from insertion when limit exceeded', function ( assert ) {
+ var $el;
+
+ // Use a new <input> because the bug only occurs on the first time
+ // the limit it reached (T42850)
+ $el = $( '<input>' ).attr( 'type', 'text' )
+ .appendTo( '#qunit-fixture' )
+ .byteLimit( 3 )
+ .val( 'abc' ).trigger( 'change' )
+ .val( 'zabc' ).trigger( 'change' );
+
+ assert.strictEqual( $el.val(), 'abc', 'Trim from the insertion point (at 0), not the end' );
+
+ $el = $( '<input>' ).attr( 'type', 'text' )
+ .appendTo( '#qunit-fixture' )
+ .byteLimit( 3 )
+ .val( 'abc' ).trigger( 'change' )
+ .val( 'azbc' ).trigger( 'change' );
+
+ assert.strictEqual( $el.val(), 'abc', 'Trim from the insertion point (at 1), not the end' );
+ } );
+
+ QUnit.test( 'Do not cut up false matching substrings in emoji insertions', function ( assert ) {
+ var $el,
+ oldVal = '\uD83D\uDCA9\uD83D\uDCA9', // "💩💩"
+ newVal = '\uD83D\uDCA9\uD83D\uDCB9\uD83E\uDCA9\uD83D\uDCA9', // "💩💹🢩💩"
+ expected = '\uD83D\uDCA9\uD83D\uDCB9\uD83D\uDCA9'; // "💩💹💩"
+
+ // Possible bad results:
+ // * With no surrogate support:
+ // '\uD83D\uDCA9\uD83D\uDCB9\uD83E\uDCA9' "💩💹🢩"
+ // * With correct trimming but bad detection of inserted text:
+ // '\uD83D\uDCA9\uD83D\uDCB9\uDCA9' "💩💹�"
+
+ $el = $( '<input>' ).attr( 'type', 'text' )
+ .appendTo( '#qunit-fixture' )
+ .byteLimit( 12 )
+ .val( oldVal ).trigger( 'change' )
+ .val( newVal ).trigger( 'change' );
+
+ assert.strictEqual( $el.val(), expected, 'Pasted emoji correctly trimmed at the end' );
+ } );
+
+ byteLimitTest( {
+ description: 'Unpaired surrogates do not crash',
+ $input: $( '<input>' ).attr( 'type', 'text' ).byteLimit( 4 ),
+ sample: '\uD800\uD800\uDFFF',
+ expected: '\uD800'
+ } );
+
+}( jQuery, mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.localize.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.localize.test.js
new file mode 100644
index 00000000..a3e46abe
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.localize.test.js
@@ -0,0 +1,135 @@
+( function ( $, mw ) {
+ QUnit.module( 'jquery.localize', QUnit.newMwEnvironment() );
+
+ QUnit.test( 'Handle basic replacements', function ( assert ) {
+ var html, $lc;
+ mw.messages.set( 'basic', 'Basic stuff' );
+
+ // Tag: html:msg
+ html = '<div><span><html:msg key="basic" /></span></div>';
+ $lc = $( html ).localize().find( 'span' );
+
+ assert.strictEqual( $lc.text(), 'Basic stuff', 'Tag: html:msg' );
+
+ // Attribute: title-msg
+ html = '<div><span title-msg="basic"></span></div>';
+ $lc = $( html ).localize().find( 'span' );
+
+ assert.strictEqual( $lc.attr( 'title' ), 'Basic stuff', 'Attribute: title-msg' );
+
+ // Attribute: alt-msg
+ html = '<div><span alt-msg="basic"></span></div>';
+ $lc = $( html ).localize().find( 'span' );
+
+ assert.strictEqual( $lc.attr( 'alt' ), 'Basic stuff', 'Attribute: alt-msg' );
+
+ // Attribute: placeholder-msg
+ html = '<div><input placeholder-msg="basic" /></div>';
+ $lc = $( html ).localize().find( 'input' );
+
+ assert.strictEqual( $lc.attr( 'placeholder' ), 'Basic stuff', 'Attribute: placeholder-msg' );
+ } );
+
+ QUnit.test( 'Proper escaping', function ( assert ) {
+ var html, $lc;
+ mw.messages.set( 'properfoo', '<proper esc="test">' );
+
+ // This is handled by jQuery inside $.fn.localize, just a simple sanity checked
+ // making sure it is actually using text() and attr() (or something with the same effect)
+
+ // Text escaping
+ html = '<div><span><html:msg key="properfoo" /></span></div>';
+ $lc = $( html ).localize().find( 'span' );
+
+ assert.strictEqual( $lc.text(), mw.msg( 'properfoo' ), 'Content is inserted as text, not as html.' );
+
+ // Attribute escaping
+ html = '<div><span title-msg="properfoo"></span></div>';
+ $lc = $( html ).localize().find( 'span' );
+
+ assert.strictEqual( $lc.attr( 'title' ), mw.msg( 'properfoo' ), 'Attributes are not inserted raw.' );
+ } );
+
+ QUnit.test( 'Options', function ( assert ) {
+ var html, $lc, x, sitename = 'Wikipedia';
+ mw.messages.set( {
+ 'foo-lorem': 'Lorem',
+ 'foo-ipsum': 'Ipsum',
+ 'foo-bar-title': 'Read more about bars',
+ 'foo-bar-label': 'The Bars',
+ 'foo-bazz-title': 'Read more about bazz at $1 (last modified: $2)',
+ 'foo-bazz-label': 'The Bazz ($1)',
+ 'foo-welcome': 'Welcome to $1! (last visit: $2)'
+ } );
+
+ // Message key prefix
+ html = '<div><span title-msg="lorem"><html:msg key="ipsum" /></span></div>';
+ $lc = $( html ).localize( {
+ prefix: 'foo-'
+ } ).find( 'span' );
+
+ assert.strictEqual( $lc.attr( 'title' ), 'Lorem', 'Message key prefix - attr' );
+ assert.strictEqual( $lc.text(), 'Ipsum', 'Message key prefix - text' );
+
+ // Variable keys mapping
+ x = 'bar';
+ html = '<div><span title-msg="title"><html:msg key="label" /></span></div>';
+ $lc = $( html ).localize( {
+ keys: {
+ title: 'foo-' + x + '-title',
+ label: 'foo-' + x + '-label'
+ }
+ } ).find( 'span' );
+
+ assert.strictEqual( $lc.attr( 'title' ), 'Read more about bars', 'Variable keys mapping - attr' );
+ assert.strictEqual( $lc.text(), 'The Bars', 'Variable keys mapping - text' );
+
+ // Passing parameteters to mw.msg
+ html = '<div><span><html:msg key="foo-welcome" /></span></div>';
+ $lc = $( html ).localize( {
+ params: {
+ 'foo-welcome': [ sitename, 'yesterday' ]
+ }
+ } ).find( 'span' );
+
+ assert.strictEqual( $lc.text(), 'Welcome to Wikipedia! (last visit: yesterday)', 'Passing parameteters to mw.msg' );
+
+ // Combination of options prefix, params and keys
+ x = 'bazz';
+ html = '<div><span title-msg="title"><html:msg key="label" /></span></div>';
+ $lc = $( html ).localize( {
+ prefix: 'foo-',
+ keys: {
+ title: x + '-title',
+ label: x + '-label'
+ },
+ params: {
+ title: [ sitename, '3 minutes ago' ],
+ label: [ sitename, '3 minutes ago' ]
+
+ }
+ } ).find( 'span' );
+
+ assert.strictEqual( $lc.text(), 'The Bazz (Wikipedia)', 'Combination of options prefix, params and keys - text' );
+ assert.strictEqual( $lc.attr( 'title' ), 'Read more about bazz at Wikipedia (last modified: 3 minutes ago)', 'Combination of options prefix, params and keys - attr' );
+ } );
+
+ QUnit.test( 'Handle data text', function ( assert ) {
+ var html, $lc;
+ mw.messages.set( 'option-one', 'Item 1' );
+ mw.messages.set( 'option-two', 'Item 2' );
+ html = '<select><option data-msg-text="option-one"></option><option data-msg-text="option-two"></option></select>';
+ $lc = $( html ).localize().find( 'option' );
+ assert.strictEqual( $lc.eq( 0 ).text(), mw.msg( 'option-one' ), 'data-msg-text becomes text of options' );
+ assert.strictEqual( $lc.eq( 1 ).text(), mw.msg( 'option-two' ), 'data-msg-text becomes text of options' );
+ } );
+
+ QUnit.test( 'Handle data html', function ( assert ) {
+ var html, $lc;
+ mw.messages.set( 'html', 'behold... there is a <a>link</a> here!!' );
+ html = '<div><div data-msg-html="html"></div></div>';
+ $lc = $( html ).localize().find( 'a' );
+ assert.strictEqual( $lc.length, 1, 'link is created' );
+ assert.strictEqual( $lc.text(), 'link', 'the link text got added' );
+ } );
+}( jQuery, mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js
new file mode 100644
index 00000000..d51dc373
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js
@@ -0,0 +1,377 @@
+( function ( $ ) {
+ var loremIpsum = 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit.';
+
+ QUnit.module( 'jquery.makeCollapsible', QUnit.newMwEnvironment() );
+
+ function prepareCollapsible( html, options ) {
+ return $( $.parseHTML( html ) )
+ .appendTo( '#qunit-fixture' )
+ // options might be undefined here - this is okay
+ .makeCollapsible( options );
+ }
+
+ // This test is first because if it fails, then almost all of the latter tests are meaningless.
+ QUnit.test( 'testing hooks/triggers', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div class="mw-collapsible">' + loremIpsum + '</div>'
+ ),
+ $content = $collapsible.find( '.mw-collapsible-content' ),
+ $toggle = $collapsible.find( '.mw-collapsible-toggle' );
+
+ // In one full collapse-expand cycle, each event will be fired once
+
+ // On collapse...
+ $collapsible.on( 'beforeCollapse.mw-collapsible', function () {
+ assert.assertTrue( $content.is( ':visible' ), 'first beforeCollapseExpand: content is visible' );
+ } );
+ $collapsible.on( 'afterCollapse.mw-collapsible', function () {
+ assert.assertTrue( $content.is( ':hidden' ), 'first afterCollapseExpand: content is hidden' );
+
+ // On expand...
+ $collapsible.on( 'beforeExpand.mw-collapsible', function () {
+ assert.assertTrue( $content.is( ':hidden' ), 'second beforeCollapseExpand: content is hidden' );
+ } );
+ $collapsible.on( 'afterExpand.mw-collapsible', function () {
+ assert.assertTrue( $content.is( ':visible' ), 'second afterCollapseExpand: content is visible' );
+ } );
+
+ // ...expanding happens here
+ $toggle.trigger( 'click' );
+ } );
+
+ // ...collapsing happens here
+ $toggle.trigger( 'click' );
+ } );
+
+ QUnit.test( 'basic operation (<div>)', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div class="mw-collapsible">' + loremIpsum + '</div>'
+ ),
+ $content = $collapsible.find( '.mw-collapsible-content' ),
+ $toggle = $collapsible.find( '.mw-collapsible-toggle' );
+
+ assert.equal( $content.length, 1, 'content is present' );
+ assert.equal( $content.find( $toggle ).length, 0, 'toggle is not a descendant of content' );
+
+ assert.assertTrue( $content.is( ':visible' ), 'content is visible' );
+
+ $collapsible.on( 'afterCollapse.mw-collapsible', function () {
+ assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' );
+
+ $collapsible.on( 'afterExpand.mw-collapsible', function () {
+ assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' );
+ } );
+
+ $toggle.trigger( 'click' );
+ } );
+
+ $toggle.trigger( 'click' );
+ } );
+
+ QUnit.test( 'basic operation (<table>)', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<table class="mw-collapsible">' +
+ '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' +
+ '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' +
+ '<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' );
+
+ assert.equal( $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' );
+
+ $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' );
+
+ $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' );
+ } );
+
+ $toggle.trigger( 'click' );
+ } );
+
+ $toggle.trigger( 'click' );
+ } );
+
+ function tableWithCaptionTest( $collapsible, test, assert ) {
+ var $caption = $collapsible.find( 'caption' ),
+ $headerRow = $collapsible.find( 'tr:first' ),
+ $contentRow = $collapsible.find( 'tr:last' ),
+ $toggle = $caption.find( '.mw-collapsible-toggle' );
+
+ assert.equal( $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' );
+
+ $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' );
+
+ $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' );
+ } );
+
+ $toggle.trigger( 'click' );
+ } );
+
+ $toggle.trigger( 'click' );
+ }
+
+ QUnit.test( 'basic operation (<table> with caption)', function ( assert ) {
+ tableWithCaptionTest( prepareCollapsible(
+ '<table class="mw-collapsible">' +
+ '<caption>' + loremIpsum + '</caption>' +
+ '<tr><th>' + loremIpsum + '</th><th>' + loremIpsum + '</th></tr>' +
+ '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' +
+ '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' +
+ '</table>'
+ ), this, assert );
+ } );
+
+ QUnit.test( 'basic operation (<table> with caption and <thead>)', function ( assert ) {
+ tableWithCaptionTest( prepareCollapsible(
+ '<table class="mw-collapsible">' +
+ '<caption>' + loremIpsum + '</caption>' +
+ '<thead><tr><th>' + loremIpsum + '</th><th>' + loremIpsum + '</th></tr></thead>' +
+ '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' +
+ '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' +
+ '</table>'
+ ), this, assert );
+ } );
+
+ function listTest( listType, test, assert ) {
+ var $collapsible = prepareCollapsible(
+ '<' + listType + ' class="mw-collapsible">' +
+ '<li>' + loremIpsum + '</li>' +
+ '<li>' + loremIpsum + '</li>' +
+ '</' + listType + '>'
+ ),
+ $toggleItem = $collapsible.find( 'li.mw-collapsible-toggle-li:first-child' ),
+ $contentItem = $collapsible.find( 'li:last' ),
+ $toggle = $toggleItem.find( '.mw-collapsible-toggle' );
+
+ assert.equal( $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' );
+
+ $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' );
+
+ $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' );
+ } );
+
+ $toggle.trigger( 'click' );
+ } );
+
+ $toggle.trigger( 'click' );
+ }
+
+ QUnit.test( 'basic operation (<ul>)', function ( assert ) {
+ listTest( 'ul', this, assert );
+ } );
+
+ QUnit.test( 'basic operation (<ol>)', function ( assert ) {
+ listTest( 'ol', this, assert );
+ } );
+
+ QUnit.test( 'basic operation when synchronous (options.instantHide)', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div class="mw-collapsible">' + loremIpsum + '</div>',
+ { instantHide: true }
+ ),
+ $content = $collapsible.find( '.mw-collapsible-content' );
+
+ assert.assertTrue( $content.is( ':visible' ), 'content is visible' );
+
+ $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
+
+ assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' );
+ } );
+
+ QUnit.test( 'mw-made-collapsible data added', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div>' + loremIpsum + '</div>'
+ );
+
+ assert.equal( $collapsible.data( 'mw-made-collapsible' ), true, 'mw-made-collapsible data present' );
+ } );
+
+ QUnit.test( 'mw-collapsible added when missing', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div>' + loremIpsum + '</div>'
+ );
+
+ assert.assertTrue( $collapsible.hasClass( 'mw-collapsible' ), 'mw-collapsible class present' );
+ } );
+
+ QUnit.test( 'mw-collapsed added when missing', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div>' + loremIpsum + '</div>',
+ { collapsed: true }
+ );
+
+ assert.assertTrue( $collapsible.hasClass( 'mw-collapsed' ), 'mw-collapsed class present' );
+ } );
+
+ QUnit.test( 'initial collapse (mw-collapsed class)', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div class="mw-collapsible mw-collapsed">' + loremIpsum + '</div>'
+ ),
+ $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' );
+
+ $collapsible.on( 'afterExpand.mw-collapsible', function () {
+ assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' );
+ } );
+
+ $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
+ } );
+
+ QUnit.test( 'initial collapse (options.collapsed)', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div class="mw-collapsible">' + loremIpsum + '</div>',
+ { collapsed: true }
+ ),
+ $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' );
+
+ $collapsible.on( 'afterExpand.mw-collapsible', function () {
+ assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' );
+ } );
+
+ $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
+ } );
+
+ QUnit.test( 'clicks on links inside toggler pass through', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div class="mw-collapsible">' +
+ '<div class="mw-collapsible-toggle">' +
+ 'Toggle <a href="#top">toggle</a> toggle <b>toggle</b>' +
+ '</div>' +
+ '<div class="mw-collapsible-content">' + loremIpsum + '</div>' +
+ '</div>',
+ // Can't do asynchronous because we're testing that the event *doesn't* happen
+ { instantHide: true }
+ ),
+ $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)' );
+
+ $collapsible.find( '.mw-collapsible-toggle b' ).trigger( 'click' );
+ assert.assertTrue( $content.is( ':hidden' ), 'click event on non-link inside toggle toggles content' );
+ } );
+
+ QUnit.test( 'click on non-link inside toggler counts as trigger', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div class="mw-collapsible">' +
+ '<div class="mw-collapsible-toggle">' +
+ 'Toggle <a>toggle</a> toggle <b>toggle</b>' +
+ '</div>' +
+ '<div class="mw-collapsible-content">' + loremIpsum + '</div>' +
+ '</div>',
+ { instantHide: true }
+ ),
+ $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' );
+ } );
+
+ QUnit.test( 'collapse/expand text (data-collapsetext, data-expandtext)', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div class="mw-collapsible" data-collapsetext="Collapse me!" data-expandtext="Expand me!">' +
+ loremIpsum +
+ '</div>'
+ ),
+ $toggleText = $collapsible.find( '.mw-collapsible-text' );
+
+ assert.equal( $toggleText.text(), 'Collapse me!', 'data-collapsetext is respected' );
+
+ $collapsible.on( 'afterCollapse.mw-collapsible', function () {
+ assert.equal( $toggleText.text(), 'Expand me!', 'data-expandtext is respected' );
+ } );
+
+ $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
+ } );
+
+ QUnit.test( 'collapse/expand text (options.collapseText, options.expandText)', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div class="mw-collapsible">' + loremIpsum + '</div>',
+ { collapseText: 'Collapse me!', expandText: 'Expand me!' }
+ ),
+ $toggleText = $collapsible.find( '.mw-collapsible-text' );
+
+ assert.equal( $toggleText.text(), 'Collapse me!', 'options.collapseText is respected' );
+
+ $collapsible.on( 'afterCollapse.mw-collapsible', function () {
+ assert.equal( $toggleText.text(), 'Expand me!', 'options.expandText is respected' );
+ } );
+
+ $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
+ } );
+
+ QUnit.test( 'predefined toggle button and text (.mw-collapsible-toggle/.mw-collapsible-text)', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div class="mw-collapsible">' +
+ '<div class="mw-collapsible-toggle">' +
+ '<span>[</span><span class="mw-collapsible-text">Toggle</span><span>]</span>' +
+ '</div>' +
+ '<div class="mw-collapsible-content">' + loremIpsum + '</div>' +
+ '</div>',
+ { collapseText: 'Hide', expandText: 'Show' }
+ ),
+ $toggleText = $collapsible.find( '.mw-collapsible-text' );
+
+ assert.equal( $toggleText.text(), 'Toggle', 'predefined text remains' );
+
+ $collapsible.on( 'afterCollapse.mw-collapsible', function () {
+ assert.equal( $toggleText.text(), 'Show', 'predefined text is toggled' );
+
+ $collapsible.on( 'afterExpand.mw-collapsible', function () {
+ assert.equal( $toggleText.text(), 'Hide', 'predefined text is toggled back' );
+ } );
+
+ $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
+ } );
+
+ $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
+ } );
+
+ QUnit.test( 'cloned collapsibles can be made collapsible again', function ( assert ) {
+ var $collapsible = prepareCollapsible(
+ '<div class="mw-collapsible">' + loremIpsum + '</div>'
+ ),
+ $clone = $collapsible.clone() // clone without data and events
+ .appendTo( '#qunit-fixture' ).makeCollapsible(),
+ $content = $clone.find( '.mw-collapsible-content' );
+
+ assert.assertTrue( $content.is( ':visible' ), 'content is visible' );
+
+ $clone.on( 'afterCollapse.mw-collapsible', function () {
+ assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' );
+ } );
+
+ $clone.find( '.mw-collapsible-toggle a' ).trigger( 'click' );
+ } );
+}( jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.tabIndex.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.tabIndex.test.js
new file mode 100644
index 00000000..ec3539b9
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.tabIndex.test.js
@@ -0,0 +1,35 @@
+( function ( $ ) {
+ QUnit.module( 'jquery.tabIndex', QUnit.newMwEnvironment() );
+
+ QUnit.test( 'firstTabIndex', function ( assert ) {
+ var html, $testA, $testB;
+ html = '<form>' +
+ '<input tabindex="7" />' +
+ '<input tabindex="9" />' +
+ '<textarea tabindex="2">Foobar</textarea>' +
+ '<textarea tabindex="5">Foobar</textarea>' +
+ '</form>';
+
+ $testA = $( '<div>' ).html( html ).appendTo( '#qunit-fixture' );
+ assert.strictEqual( $testA.firstTabIndex(), 2, 'First tabindex should be 2 within this context.' );
+
+ $testB = $( '<div>' );
+ assert.strictEqual( $testB.firstTabIndex(), null, 'Return null if none available.' );
+ } );
+
+ QUnit.test( 'lastTabIndex', function ( assert ) {
+ var html, $testA, $testB;
+ html = '<form>' +
+ '<input tabindex="7" />' +
+ '<input tabindex="9" />' +
+ '<textarea tabindex="2">Foobar</textarea>' +
+ '<textarea tabindex="5">Foobar</textarea>' +
+ '</form>';
+
+ $testA = $( '<div>' ).html( html ).appendTo( '#qunit-fixture' );
+ assert.strictEqual( $testA.lastTabIndex(), 9, 'Last tabindex should be 9 within this context.' );
+
+ $testB = $( '<div>' );
+ assert.strictEqual( $testB.lastTabIndex(), null, 'Return null if none available.' );
+ } );
+}( jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js
new file mode 100644
index 00000000..2865cbba
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js
@@ -0,0 +1,267 @@
+( function ( $, mw ) {
+ /**
+ * This module tests the input/output capabilities of the parsers of tablesorter.
+ * It does not test actual sorting.
+ */
+
+ var text, ipv4,
+ simpleMDYDatesInMDY, simpleMDYDatesInDMY, oldMDYDates, complexMDYDates, clobberedDates, MYDates, YDates, ISODates,
+ currencyData, transformedCurrencyData;
+
+ QUnit.module( 'jquery.tablesorter.parsers', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.liveMonths = mw.language.months;
+ mw.language.months = {
+ keys: {
+ names: [ 'january', 'february', 'march', 'april', 'may_long', 'june',
+ 'july', 'august', 'september', 'october', 'november', 'december' ],
+ genitive: [ 'january-gen', 'february-gen', 'march-gen', 'april-gen', 'may-gen', 'june-gen',
+ 'july-gen', 'august-gen', 'september-gen', 'october-gen', 'november-gen', 'december-gen' ],
+ abbrev: [ 'jan', 'feb', 'mar', 'apr', 'may', 'jun',
+ 'jul', 'aug', 'sep', 'oct', 'nov', 'dec' ]
+ },
+ names: [ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December' ],
+ genitive: [ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December' ],
+ abbrev: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]
+ };
+ },
+ teardown: function () {
+ mw.language.months = this.liveMonths;
+ },
+ config: {
+ wgPageContentLanguage: 'en',
+ /* default date format of the content language */
+ wgDefaultDateFormat: 'dmy',
+ /* These two are important for numeric interpretations */
+ wgSeparatorTransformTable: [ '', '' ],
+ wgDigitTransformTable: [ '', '' ]
+ }
+ } ) );
+
+ /**
+ * For a value, check if the parser recognizes it and how it transforms it
+ *
+ * @param {string} msg text to pass on to qunit describing the test case
+ * @param {string[]} parserId of the parser that will be tested
+ * @param {string[][]} data Array of testcases. Each testcase, array of
+ * inputValue: The string value that we want to test the parser for
+ * recognized: If we expect that this value's type is detectable by the parser
+ * outputValue: The value the parser has converted the input to
+ * msg: describing the testcase
+ * @param {function($table)} callback something to do before we start the testcase
+ */
+ function parserTest( msg, parserId, data, callback ) {
+ QUnit.test( msg, function ( assert ) {
+ var extractedR, extractedF, parser;
+
+ if ( callback !== undefined ) {
+ callback();
+ }
+
+ parser = $.tablesorter.getParser( parserId );
+ data.forEach( function ( testcase ) {
+ extractedR = parser.is( testcase[ 0 ] );
+ extractedF = parser.format( testcase[ 0 ] );
+
+ assert.strictEqual( extractedR, testcase[ 1 ], 'Detect: ' + testcase[ 3 ] );
+ assert.strictEqual( extractedF, testcase[ 2 ], 'Sortkey: ' + testcase[ 3 ] );
+ } );
+
+ } );
+ }
+
+ text = [
+ [ 'Mars', true, 'mars', 'Simple text' ],
+ [ 'Mẘas', true, 'mẘas', 'Non ascii character' ],
+ [ 'A sentence', true, 'a sentence', 'A sentence with space chars' ]
+ ];
+ parserTest( 'Textual keys', 'text', text );
+
+ ipv4 = [
+ // Some randomly generated fake IPs
+ [ '0.0.0.0', true, 0, 'An IP address' ],
+ [ '255.255.255.255', true, 255255255255, 'An IP address' ],
+ [ '45.238.27.109', true, 45238027109, 'An IP address' ],
+ [ '1.238.27.1', true, 1238027001, 'An IP address with small numbers' ],
+ [ '238.27.1', false, 238027001, 'A malformed IP Address' ],
+ [ '1', false, 1, 'A super malformed IP Address' ],
+ [ 'Just text', false, -Infinity, 'A line with just text' ],
+ [ '45.238.27.109Postfix', false, 45238027109, 'An IP address with a connected postfix' ],
+ [ '45.238.27.109 postfix', false, 45238027109, 'An IP address with a seperated postfix' ]
+ ];
+ parserTest( 'IPv4', 'IPAddress', ipv4 );
+
+ simpleMDYDatesInMDY = [
+ [ 'January 17, 2010', true, 20100117, 'Long middle endian date' ],
+ [ 'Jan 17, 2010', true, 20100117, 'Short middle endian date' ],
+ [ '1/17/2010', true, 20100117, 'Numeric middle endian date' ],
+ [ '01/17/2010', true, 20100117, 'Numeric middle endian date with padding on month' ],
+ [ '01/07/2010', true, 20100107, 'Numeric middle endian date with padding on day' ],
+ [ '01/07/0010', true, 20100107, 'Numeric middle endian date with padding on year' ],
+ [ '5.12.1990', true, 19900512, 'Numeric middle endian date with . separator' ]
+ ];
+ parserTest( 'MDY Dates using mdy content language', 'date', simpleMDYDatesInMDY );
+
+ simpleMDYDatesInDMY = [
+ [ 'January 17, 2010', true, 20100117, 'Long middle endian date' ],
+ [ 'Jan 17, 2010', true, 20100117, 'Short middle endian date' ],
+ [ '1/17/2010', true, 20101701, 'Numeric middle endian date' ],
+ [ '01/17/2010', true, 20101701, 'Numeric middle endian date with padding on month' ],
+ [ '01/07/2010', true, 20100701, 'Numeric middle endian date with padding on day' ],
+ [ '01/07/0010', true, 20100701, 'Numeric middle endian date with padding on year' ],
+ [ '5.12.1990', true, 19901205, 'Numeric middle endian date with . separator' ]
+ ];
+ parserTest( 'MDY Dates using dmy content language', 'date', simpleMDYDatesInDMY, function () {
+ mw.config.set( {
+ wgDefaultDateFormat: 'dmy',
+ wgPageContentLanguage: 'de'
+ } );
+ } );
+
+ oldMDYDates = [
+ [ 'January 19, 1400 BC', false, '99999999', 'BC' ],
+ [ 'January 19, 1400BC', false, '99999999', 'Connected BC' ],
+ [ 'January, 19 1400 B.C.', false, '99999999', 'B.C.' ],
+ [ 'January 19, 1400 AD', false, '99999999', 'AD' ],
+ [ 'January, 19 10', true, 20100119, 'AD' ],
+ [ 'January, 19 1', false, '99999999', 'AD' ]
+ ];
+ parserTest( 'Very old MDY dates', 'date', oldMDYDates );
+
+ complexMDYDates = [
+ [ 'January, 19 2010', true, 20100119, 'Comma after month' ],
+ [ 'January 19, 2010', true, 20100119, 'Comma after day' ],
+ [ 'January/19/2010', true, 20100119, 'Forward slash separator' ],
+ [ '04 22 1991', true, 19910422, 'Month with 0 padding' ],
+ [ 'April 21 1991', true, 19910421, 'Space separation' ],
+ [ '04 22 1991', true, 19910422, 'Month with 0 padding' ],
+ [ 'December 12 \'10', true, 20101212, '' ],
+ [ 'Dec 12 \'10', true, 20101212, '' ],
+ [ 'Dec. 12 \'10', true, 20101212, '' ]
+ ];
+ parserTest( 'MDY Dates', 'date', complexMDYDates );
+
+ clobberedDates = [
+ [ 'January, 19 2010 - January, 20 2010', false, '99999999', 'Date range with hyphen' ],
+ [ 'January, 19 2010 — January, 20 2010', false, '99999999', 'Date range with mdash' ],
+ [ 'prefixJanuary, 19 2010', false, '99999999', 'Connected prefix' ],
+ [ 'prefix January, 19 2010', false, '99999999', 'Prefix' ],
+ [ 'December 12 2010postfix', false, '99999999', 'ConnectedPostfix' ],
+ [ 'December 12 2010 postfix', false, '99999999', 'Postfix' ],
+ [ 'A simple text', false, '99999999', 'Plain text in date sort' ],
+ [ '04l22l1991', false, '99999999', 'l char as separator' ],
+ [ 'January\\19\\2010', false, '99999999', 'backslash as date separator' ]
+ ];
+ parserTest( 'Clobbered Dates', 'date', clobberedDates );
+
+ MYDates = [
+ [ 'December 2010', false, '99999999', 'Plain month year' ],
+ [ 'Dec 2010', false, '99999999', 'Abreviated month year' ],
+ [ '12 2010', false, '99999999', 'Numeric month year' ]
+ ];
+ parserTest( 'MY Dates', 'date', MYDates );
+
+ YDates = [
+ [ '2010', false, '99999999', 'Plain 4-digit year' ],
+ [ '876', false, '99999999', '3-digit year' ],
+ [ '76', false, '99999999', '2-digit year' ],
+ [ '\'76', false, '99999999', '2-digit millenium bug year' ],
+ [ '2010 BC', false, '99999999', '4-digit year BC' ]
+ ];
+ parserTest( 'Y Dates', 'date', YDates );
+
+ ISODates = [
+ [ '', false, -Infinity, 'Not a date' ],
+ [ '2000', false, 946684800000, 'Plain 4-digit year' ],
+ [ '2000-01', true, 946684800000, 'Year with month' ],
+ [ '2000-01-01', true, 946684800000, 'Year with month and day' ],
+ [ '2000-13-01', false, 978307200000, 'Non existant month' ],
+ [ '2000-01-32', true, 949363200000, 'Non existant day' ],
+ [ '2000-01-01T12:30:30', true, 946729830000, 'Date with a time' ],
+ [ '2000-01-01T12:30:30Z', true, 946729830000, 'Date with a UTC+0 time' ],
+ [ '2000-01-01T24:30:30Z', true, 946773030000, 'Date with invalid hours' ],
+ [ '2000-01-01T12:60:30Z', true, 946728000000, 'Date with invalid minutes' ],
+ [ '2000-01-01T12:30:61Z', true, 946729800000, 'Date with invalid amount of seconds, drops seconds' ],
+ [ '2000-01-01T23:59:59Z', true, 946771199000, 'Edges of time' ],
+ [ '2000-01-01T12:30:30.111Z', true, 946729830111, 'Date with milliseconds' ],
+ [ '2000-01-01T12:30:30.11111Z', true, 946729830111, 'Date with too high precision' ],
+ [ '2000-01-01T12:30:30,111Z', true, 946729830111, 'Date with milliseconds and , separator' ],
+ [ '2000-01-01T12:30:30+01:00', true, 946726230000, 'Date time in UTC+1' ],
+ [ '2000-01-01T12:30:30+01:30', true, 946724430000, 'Date time in UTC+1:30' ],
+ [ '2000-01-01T12:30:30-01:00', true, 946733430000, 'Date time in UTC-1' ],
+ [ '2000-01-01T12:30:30-01:30', true, 946735230000, 'Date time in UTC-1:30' ],
+ [ '2000-01-01T12:30:30.111+01:00', true, 946726230111, 'Date time and milliseconds in UTC+1' ],
+ [ '2000-01-01Postfix', true, 946684800000, 'Date with appended postfix' ],
+ [ '2000-01-01 Postfix', true, 946684800000, 'Date with separate postfix' ],
+ [ '2 Postfix', false, -62104060800000, 'One digit with separate postfix' ],
+ [ 'ca. 2', false, -62104060800000, 'Three digit with separate prefix' ],
+ [ '~200', false, -55855785600000, 'Three digit with appended prefix' ],
+ [ 'ca. 200[1]', false, -55855785600000, 'Three digit with separate prefix and postfix' ],
+ [ '2000-11-31', true, 975628800000, '31 days in 30 day month' ],
+ [ '50-01-01', true, -60589296000000, 'Year with just two digits' ],
+ [ '2', false, -62104060800000, 'Year with one digit' ],
+ [ '02-01', true, -62104060800000, 'Year with one digit and leading zero' ],
+ [ ' 2-01', true, -62104060800000, 'Year with one digit and leading space' ],
+ [ '-2-10', true, -62206704000000, 'Year BC with month' ],
+ [ '-9999', false, -377705116800000, 'max. Year BC' ],
+ [ '+9999-12', true, 253399622400000, 'max. Date with +sign' ],
+ [ '2000-01-01 12:30:30Z', true, 946729830000, 'Date and time with no T marker' ],
+ [ '2000-01-01T12:30:60Z', true, 946729860000, 'Date with leap second' ],
+ [ '2000-01-01T12:30:30-23:59', true, 946816170000, 'Date time in UTC-23:59' ],
+ [ '2000-01-01T12:30:30+23:59', true, 946643490000, 'Date time in UTC+23:59' ],
+ [ '2000-01-01T123030+0100', true, 946726230000, 'Time without separators' ],
+ [ '20000101T123030+0100', false, 946726230000, 'All without separators' ]
+ ];
+ parserTest( 'ISO Dates', 'isoDate', ISODates );
+
+ currencyData = [
+ [ '1.02 $', true, 1.02, '' ],
+ [ '$ 3.00', true, 3, '' ],
+ [ '€ 2,99', true, 299, '' ],
+ [ '$ 1.00', true, 1, '' ],
+ [ '$3.50', true, 3.50, '' ],
+ [ '$ 1.50', true, 1.50, '' ],
+ [ '€ 0.99', true, 0.99, '' ],
+ [ '$ 299.99', true, 299.99, '' ],
+ [ '$ 2,299.99', true, 2299.99, '' ],
+ [ '$ 2,989', true, 2989, '' ],
+ [ '$ 2 299.99', true, 2299.99, '' ],
+ [ '$ 2 989', true, 2989, '' ],
+ [ '$ 2.989', true, 2.989, '' ]
+ ];
+ parserTest( 'Currency', 'currency', currencyData );
+
+ transformedCurrencyData = [
+ [ '1.02 $', true, 102, '' ],
+ [ '$ 3.00', true, 300, '' ],
+ [ '€ 2,99', true, 2.99, '' ],
+ [ '$ 1.00', true, 100, '' ],
+ [ '$3.50', true, 350, '' ],
+ [ '$ 1.50', true, 150, '' ],
+ [ '€ 0.99', true, 99, '' ],
+ [ '$ 299.99', true, 29999, '' ],
+ [ '$ 2\'299,99', true, 2299.99, '' ],
+ [ '$ 2,989', true, 2.989, '' ],
+ [ '$ 2 299.99', true, 229999, '' ],
+ [ '2 989 $', true, 2989, '' ],
+ [ '299.99 $', true, 29999, '' ],
+ [ '2\'299,99 $', true, 2299.99, '' ],
+ [ '2,989 $', true, 2.989, '' ],
+ [ '2 299.99 $', true, 229999, '' ],
+ [ '2 989 $', true, 2989, '' ]
+ ];
+ parserTest( 'Currency with european separators', 'currency', transformedCurrencyData, function () {
+ mw.config.set( {
+ // We expect 22'234.444,22
+ // Map from ascii separators => localized separators
+ wgSeparatorTransformTable: [ ', . ,', '\' , .' ],
+ wgDigitTransformTable: [ '', '' ]
+ } );
+ } );
+
+ // TODO add numbers sorting tests for T10115 with a different language
+
+}( jQuery, mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js
new file mode 100644
index 00000000..23ef26f6
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js
@@ -0,0 +1,1498 @@
+( function ( $, mw ) {
+ var header = [ 'Planet', 'Radius (km)' ],
+
+ // Data set "planets"
+ mercury = [ 'Mercury', '2439.7' ],
+ venus = [ 'Venus', '6051.8' ],
+ earth = [ 'Earth', '6371.0' ],
+ mars = [ 'Mars', '3390.0' ],
+ jupiter = [ 'Jupiter', '69911' ],
+ saturn = [ 'Saturn', '58232' ],
+ planets = [ mercury, venus, earth, mars, jupiter, saturn ],
+ planetsAscName = [ earth, jupiter, mars, mercury, saturn, venus ],
+ planetsAscRadius = [ mercury, mars, venus, earth, saturn, jupiter ],
+ planetsRowspan,
+ planetsRowspanII,
+ planetsAscNameLegacy,
+
+ // Data set "simple"
+ a1 = [ 'A', '1' ],
+ a2 = [ 'A', '2' ],
+ a3 = [ 'A', '3' ],
+ b1 = [ 'B', '1' ],
+ b2 = [ 'B', '2' ],
+ b3 = [ 'B', '3' ],
+ simple = [ a2, b3, a1, a3, b2, b1 ],
+ simpleAsc = [ a1, a2, a3, b1, b2, b3 ],
+ simpleDescasc = [ b1, b2, b3, a1, a2, a3 ],
+
+ // Data set "colspan"
+ header4 = [ 'column1a', 'column1b', 'column1c', 'column2' ],
+ aaa1 = [ 'A', 'A', 'A', '1' ],
+ aab5 = [ 'A', 'A', 'B', '5' ],
+ abc3 = [ 'A', 'B', 'C', '3' ],
+ bbc2 = [ 'B', 'B', 'C', '2' ],
+ caa4 = [ 'C', 'A', 'A', '4' ],
+ colspanInitial = [ aab5, aaa1, abc3, bbc2, caa4 ],
+
+ // Data set "ipv4"
+ ipv4 = [
+ // Some randomly generated fake IPs
+ [ '45.238.27.109' ],
+ [ '44.172.9.22' ],
+ [ '247.240.82.209' ],
+ [ '204.204.132.158' ],
+ [ '170.38.91.162' ],
+ [ '197.219.164.9' ],
+ [ '45.68.154.72' ],
+ [ '182.195.149.80' ]
+ ],
+ ipv4Sorted = [
+ // Sort order should go octet by octet
+ [ '44.172.9.22' ],
+ [ '45.68.154.72' ],
+ [ '45.238.27.109' ],
+ [ '170.38.91.162' ],
+ [ '182.195.149.80' ],
+ [ '197.219.164.9' ],
+ [ '204.204.132.158' ],
+ [ '247.240.82.209' ]
+ ],
+
+ // Data set "umlaut"
+ umlautWords = [
+ [ 'Günther' ],
+ [ 'Peter' ],
+ [ 'Björn' ],
+ [ 'Bjorn' ],
+ [ 'Apfel' ],
+ [ 'Äpfel' ],
+ [ 'Strasse' ],
+ [ 'Sträßschen' ]
+ ],
+ umlautWordsSorted = [
+ [ 'Äpfel' ],
+ [ 'Apfel' ],
+ [ 'Björn' ],
+ [ 'Bjorn' ],
+ [ 'Günther' ],
+ [ 'Peter' ],
+ [ 'Sträßschen' ],
+ [ 'Strasse' ]
+ ],
+
+ // Data set "digraph"
+ digraphWords = [
+ [ 'London' ],
+ [ 'Ljubljana' ],
+ [ 'Luxembourg' ],
+ [ 'Njivice' ],
+ [ 'Norwich' ],
+ [ 'New York' ]
+ ],
+ digraphWordsSorted = [
+ [ 'London' ],
+ [ 'Luxembourg' ],
+ [ 'Ljubljana' ],
+ [ 'New York' ],
+ [ 'Norwich' ],
+ [ 'Njivice' ]
+ ],
+
+ complexMDYDates = [
+ [ 'January, 19 2010' ],
+ [ 'April 21 1991' ],
+ [ '04 22 1991' ],
+ [ '5.12.1990' ],
+ [ 'December 12 \'10' ]
+ ],
+ complexMDYSorted = [
+ [ '5.12.1990' ],
+ [ 'April 21 1991' ],
+ [ '04 22 1991' ],
+ [ 'January, 19 2010' ],
+ [ 'December 12 \'10' ]
+ ],
+
+ currencyUnsorted = [
+ [ '1.02 $' ],
+ [ '$ 3.00' ],
+ [ '€ 2,99' ],
+ [ '$ 1.00' ],
+ [ '$3.50' ],
+ [ '$ 1.50' ],
+ [ '€ 0.99' ]
+ ],
+ currencySorted = [
+ [ '€ 0.99' ],
+ [ '$ 1.00' ],
+ [ '1.02 $' ],
+ [ '$ 1.50' ],
+ [ '$ 3.00' ],
+ [ '$3.50' ],
+ // Commas sort after dots
+ // Not intentional but test to detect changes
+ [ '€ 2,99' ]
+ ],
+
+ numbers = [
+ [ '12' ],
+ [ '7' ],
+ [ '13,000' ],
+ [ '9' ],
+ [ '14' ],
+ [ '8.0' ]
+ ],
+ numbersAsc = [
+ [ '7' ],
+ [ '8.0' ],
+ [ '9' ],
+ [ '12' ],
+ [ '14' ],
+ [ '13,000' ]
+ ],
+
+ correctDateSorting1 = [
+ [ '01 January 2010' ],
+ [ '05 February 2010' ],
+ [ '16 January 2010' ]
+ ],
+ correctDateSortingSorted1 = [
+ [ '01 January 2010' ],
+ [ '16 January 2010' ],
+ [ '05 February 2010' ]
+ ],
+
+ correctDateSorting2 = [
+ [ 'January 01 2010' ],
+ [ 'February 05 2010' ],
+ [ 'January 16 2010' ]
+ ],
+ correctDateSortingSorted2 = [
+ [ 'January 01 2010' ],
+ [ 'January 16 2010' ],
+ [ 'February 05 2010' ]
+ ],
+ isoDateSorting = [
+ [ '2010-02-01' ],
+ [ '2009-12-25T12:30:45.001Z' ],
+ [ '2010-01-31' ],
+ [ '2009' ],
+ [ '2009-12-25T12:30:45' ],
+ [ '2009-12-25T12:30:45.111' ],
+ [ '2009-12-25T12:30:45+01:00' ]
+ ],
+ isoDateSortingSorted = [
+ [ '2009' ],
+ [ '2009-12-25T12:30:45+01:00' ],
+ [ '2009-12-25T12:30:45' ],
+ [ '2009-12-25T12:30:45.001Z' ],
+ [ '2009-12-25T12:30:45.111' ],
+ [ '2010-01-31' ],
+ [ '2010-02-01' ]
+ ];
+
+ QUnit.module( 'jquery.tablesorter', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.liveMonths = mw.language.months;
+ mw.language.months = {
+ keys: {
+ names: [ 'january', 'february', 'march', 'april', 'may_long', 'june',
+ 'july', 'august', 'september', 'october', 'november', 'december' ],
+ genitive: [ 'january-gen', 'february-gen', 'march-gen', 'april-gen', 'may-gen', 'june-gen',
+ 'july-gen', 'august-gen', 'september-gen', 'october-gen', 'november-gen', 'december-gen' ],
+ abbrev: [ 'jan', 'feb', 'mar', 'apr', 'may', 'jun',
+ 'jul', 'aug', 'sep', 'oct', 'nov', 'dec' ]
+ },
+ names: [ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December' ],
+ genitive: [ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December' ],
+ abbrev: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]
+ };
+ },
+ teardown: function () {
+ mw.language.months = this.liveMonths;
+ },
+ config: {
+ wgDefaultDateFormat: 'dmy',
+ wgSeparatorTransformTable: [ '', '' ],
+ wgDigitTransformTable: [ '', '' ],
+ wgPageContentLanguage: 'en'
+ }
+ } ) );
+
+ /**
+ * Create an HTML table from an array of row arrays containing text strings.
+ * First row will be header row. No fancy rowspan/colspan stuff.
+ *
+ * @param {string[]} header
+ * @param {string[][]} data
+ * @return {jQuery}
+ */
+ function tableCreate( header, data ) {
+ var i,
+ $table = $( '<table class="sortable"><thead></thead><tbody></tbody></table>' ),
+ $thead = $table.find( 'thead' ),
+ $tbody = $table.find( 'tbody' ),
+ $tr = $( '<tr>' );
+
+ header.forEach( function ( str ) {
+ var $th = $( '<th>' );
+ $th.text( str ).appendTo( $tr );
+ } );
+ $tr.appendTo( $thead );
+
+ for ( i = 0; i < data.length; i++ ) {
+ $tr = $( '<tr>' );
+ // eslint-disable-next-line no-loop-func
+ data[ i ].forEach( function ( str ) {
+ var $td = $( '<td>' );
+ $td.text( str ).appendTo( $tr );
+ } );
+ $tr.appendTo( $tbody );
+ }
+ return $table;
+ }
+
+ /**
+ * Extract text from table.
+ *
+ * @param {jQuery} $table
+ * @return {string[][]}
+ */
+ function tableExtract( $table ) {
+ var data = [];
+
+ $table.find( 'tbody' ).find( 'tr' ).each( function ( i, tr ) {
+ var row = [];
+ $( tr ).find( 'td,th' ).each( function ( i, td ) {
+ row.push( $( td ).text() );
+ } );
+ data.push( row );
+ } );
+ return data;
+ }
+
+ /**
+ * Run a table test by building a table with the given data,
+ * running some callback on it, then checking the results.
+ *
+ * @param {string} msg text to pass on to qunit for the comparison
+ * @param {string[]} header cols to make the table
+ * @param {string[][]} data rows/cols to make the table
+ * @param {string[][]} expected rows/cols to compare against at end
+ * @param {function($table)} callback something to do with the table before we compare
+ */
+ function tableTest( msg, header, data, expected, callback ) {
+ QUnit.test( msg, function ( assert ) {
+ var extracted,
+ $table = tableCreate( header, data );
+
+ // Give caller a chance to set up sorting and manipulate the table.
+ callback( $table );
+
+ // Table sorting is done synchronously; if it ever needs to change back
+ // to asynchronous, we'll need a timeout or a callback here.
+ extracted = tableExtract( $table );
+ assert.deepEqual( extracted, expected, msg );
+ } );
+ }
+
+ /**
+ * Run a table test by building a table with the given HTML,
+ * running some callback on it, then checking the results.
+ *
+ * @param {string} msg text to pass on to qunit for the comparison
+ * @param {string} html HTML to make the table
+ * @param {string[][]} expected Rows/cols to compare against at end
+ * @param {function($table)} callback Something to do with the table before we compare
+ */
+ function tableTestHTML( msg, html, expected, callback ) {
+ QUnit.test( msg, function ( assert ) {
+ var extracted,
+ $table = $( html );
+
+ // Give caller a chance to set up sorting and manipulate the table.
+ if ( callback ) {
+ callback( $table );
+ } else {
+ $table.tablesorter();
+ $table.find( '#sortme' ).click();
+ }
+
+ // Table sorting is done synchronously; if it ever needs to change back
+ // to asynchronous, we'll need a timeout or a callback here.
+ extracted = tableExtract( $table );
+ assert.deepEqual( extracted, expected, msg );
+ } );
+ }
+
+ function reversed( arr ) {
+ // Clone array
+ var arr2 = arr.slice( 0 );
+
+ arr2.reverse();
+
+ return arr2;
+ }
+
+ // Sample data set using planets named and their radius
+
+ tableTest(
+ 'Basic planet table: sorting initially - ascending by name',
+ header,
+ planets,
+ planetsAscName,
+ function ( $table ) {
+ $table.tablesorter( { sortList: [
+ { 0: 'asc' }
+ ] } );
+ }
+ );
+ tableTest(
+ 'Basic planet table: sorting initially - descending by radius',
+ header,
+ planets,
+ reversed( planetsAscRadius ),
+ function ( $table ) {
+ $table.tablesorter( { sortList: [
+ { 1: 'desc' }
+ ] } );
+ }
+ );
+ tableTest(
+ 'Basic planet table: ascending by name',
+ header,
+ planets,
+ planetsAscName,
+ function ( $table ) {
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+ tableTest(
+ 'Basic planet table: ascending by name a second time',
+ header,
+ planets,
+ planetsAscName,
+ function ( $table ) {
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+ tableTest(
+ 'Basic planet table: ascending by name (multiple clicks)',
+ header,
+ planets,
+ planetsAscName,
+ function ( $table ) {
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ $table.find( '.headerSort:eq(1)' ).click();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+ tableTest(
+ 'Basic planet table: descending by name',
+ header,
+ planets,
+ reversed( planetsAscName ),
+ function ( $table ) {
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click().click();
+ }
+ );
+ tableTest(
+ 'Basic planet table: ascending radius',
+ header,
+ planets,
+ planetsAscRadius,
+ function ( $table ) {
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(1)' ).click();
+ }
+ );
+ tableTest(
+ 'Basic planet table: descending radius',
+ header,
+ planets,
+ reversed( planetsAscRadius ),
+ function ( $table ) {
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(1)' ).click().click();
+ }
+ );
+ tableTest(
+ 'Sorting multiple columns by passing sort list',
+ header,
+ simple,
+ simpleAsc,
+ function ( $table ) {
+ $table.tablesorter(
+ { sortList: [
+ { 0: 'asc' },
+ { 1: 'asc' }
+ ] }
+ );
+ }
+ );
+ tableTest(
+ 'Sorting multiple columns by programmatically triggering sort()',
+ header,
+ simple,
+ simpleDescasc,
+ function ( $table ) {
+ $table.tablesorter();
+ $table.data( 'tablesorter' ).sort(
+ [
+ { 0: 'desc' },
+ { 1: 'asc' }
+ ]
+ );
+ }
+ );
+ tableTest(
+ 'Reset to initial sorting by triggering sort() without any parameters',
+ header,
+ simple,
+ simpleAsc,
+ function ( $table ) {
+ $table.tablesorter(
+ { sortList: [
+ { 0: 'asc' },
+ { 1: 'asc' }
+ ] }
+ );
+ $table.data( 'tablesorter' ).sort(
+ [
+ { 0: 'desc' },
+ { 1: 'asc' }
+ ]
+ );
+ $table.data( 'tablesorter' ).sort();
+ }
+ );
+ tableTest(
+ 'Sort via click event after having initialized the tablesorter with initial sorting',
+ header,
+ simple,
+ simpleDescasc,
+ function ( $table ) {
+ $table.tablesorter(
+ { sortList: [ { 0: 'asc' }, { 1: 'asc' } ] }
+ );
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+ tableTest(
+ 'Multi-sort via click event after having initialized the tablesorter with initial sorting',
+ header,
+ simple,
+ simpleAsc,
+ function ( $table ) {
+ var event;
+ $table.tablesorter(
+ { sortList: [ { 0: 'desc' }, { 1: 'desc' } ] }
+ );
+ $table.find( '.headerSort:eq(0)' ).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 );
+ }
+ );
+ QUnit.test( 'Reset sorting making table appear unsorted', function ( assert ) {
+ var $table = tableCreate( header, simple );
+ $table.tablesorter(
+ { sortList: [
+ { 0: 'desc' },
+ { 1: 'asc' }
+ ] }
+ );
+ $table.data( 'tablesorter' ).sort( [] );
+
+ assert.equal(
+ $table.find( 'th.headerSortUp' ).length + $table.find( 'th.headerSortDown' ).length,
+ 0,
+ 'No sort specific sort classes addign to header cells'
+ );
+
+ assert.equal(
+ $table.find( 'th' ).first().attr( 'title' ),
+ mw.msg( 'sort-ascending' ),
+ 'First header cell has default title'
+ );
+
+ assert.equal(
+ $table.find( 'th' ).first().attr( 'title' ),
+ $table.find( 'th' ).last().attr( 'title' ),
+ 'Both header cells\' titles match'
+ );
+ } );
+
+ // Sorting with colspans
+
+ tableTest( 'Sorting with colspanned headers: spanned column',
+ header4,
+ colspanInitial,
+ [ 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.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+ tableTest( 'Sorting with colspanned headers: sort spanned column twice',
+ header4,
+ colspanInitial,
+ [ 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.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+ tableTest( 'Sorting with colspanned headers: subsequent column',
+ header4,
+ colspanInitial,
+ [ 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.tablesorter();
+ $table.find( '.headerSort:eq(1)' ).click();
+ }
+ );
+ tableTest( 'Sorting with colspanned headers: sort subsequent column twice',
+ header4,
+ colspanInitial,
+ [ 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.tablesorter();
+ $table.find( '.headerSort:eq(1)' ).click();
+ $table.find( '.headerSort:eq(1)' ).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.tablesorter();
+ $table.find( 'tr:eq(0) > th:eq(0)' ).click();
+
+ assert.deepEqual(
+ tableExtract( $table ),
+ planets,
+ 'table not sorted'
+ );
+
+ $cell = $table.find( 'tr:eq(0) > th:eq(0)' );
+ $table.find( 'tr:eq(0) > th:eq(1)' ).click();
+
+ assert.equal(
+ $cell.hasClass( 'headerSortUp' ) || $cell.hasClass( 'headerSortDown' ),
+ false,
+ 'after sort: no class headerSortUp or headerSortDown'
+ );
+
+ assert.equal(
+ $cell.attr( 'title' ),
+ undefined,
+ 'after sort: no title tag added'
+ );
+
+ } );
+
+ // Regression tests!
+ tableTest(
+ 'T30775: German-style (dmy) short numeric dates',
+ [ 'Date' ],
+ [
+ // German-style dates are day-month-year
+ [ '11.11.2011' ],
+ [ '01.11.2011' ],
+ [ '02.10.2011' ],
+ [ '03.08.2011' ],
+ [ '09.11.2011' ]
+ ],
+ [
+ // Sorted by ascending date
+ [ '03.08.2011' ],
+ [ '02.10.2011' ],
+ [ '01.11.2011' ],
+ [ '09.11.2011' ],
+ [ '11.11.2011' ]
+ ],
+ function ( $table ) {
+ mw.config.set( 'wgDefaultDateFormat', 'dmy' );
+ mw.config.set( 'wgPageContentLanguage', 'de' );
+
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+
+ tableTest(
+ 'T30775: American-style (mdy) short numeric dates',
+ [ 'Date' ],
+ [
+ // American-style dates are month-day-year
+ [ '11.11.2011' ],
+ [ '01.11.2011' ],
+ [ '02.10.2011' ],
+ [ '03.08.2011' ],
+ [ '09.11.2011' ]
+ ],
+ [
+ // Sorted by ascending date
+ [ '01.11.2011' ],
+ [ '02.10.2011' ],
+ [ '03.08.2011' ],
+ [ '09.11.2011' ],
+ [ '11.11.2011' ]
+ ],
+ function ( $table ) {
+ mw.config.set( 'wgDefaultDateFormat', 'mdy' );
+
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+
+ tableTest(
+ 'T19141: IPv4 address sorting',
+ [ 'IP' ],
+ ipv4,
+ ipv4Sorted,
+ function ( $table ) {
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+ tableTest(
+ 'T19141: IPv4 address sorting (reverse)',
+ [ 'IP' ],
+ ipv4,
+ reversed( ipv4Sorted ),
+ function ( $table ) {
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click().click();
+ }
+ );
+
+ tableTest(
+ 'Accented Characters with custom collation',
+ [ 'Name' ],
+ umlautWords,
+ umlautWordsSorted,
+ function ( $table ) {
+ mw.config.set( 'tableSorterCollation', {
+ ä: 'ae',
+ ö: 'oe',
+ ß: 'ss',
+ ü: 'ue'
+ } );
+
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+
+ tableTest(
+ 'Digraphs with custom collation',
+ [ 'City' ],
+ digraphWords,
+ digraphWordsSorted,
+ function ( $table ) {
+ mw.config.set( 'tableSorterCollation', {
+ lj: 'lzzzz',
+ nj: 'nzzzz'
+ } );
+
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+
+ QUnit.test( 'Rowspan not exploded on init', function ( assert ) {
+ var $table = tableCreate( header, planets );
+
+ // 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();
+ // - 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.tablesorter();
+
+ assert.equal(
+ $table.find( 'tr:eq(2) td:eq(1)' ).prop( 'rowSpan' ),
+ 3,
+ 'Rowspan not exploded'
+ );
+ } );
+
+ planetsRowspan = [
+ [ 'Earth', '6051.8' ],
+ jupiter,
+ [ 'Mars', '6051.8' ],
+ mercury,
+ saturn,
+ venus
+ ];
+ planetsRowspanII = [ jupiter, mercury, saturn, venus, [ 'Venus', '6371.0' ], [ 'Venus', '3390.0' ] ];
+
+ tableTest(
+ 'Basic planet table: same value for multiple rows via rowspan',
+ header,
+ planets,
+ planetsRowspan,
+ 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();
+ // - 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.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+ tableTest(
+ 'Basic planet table: same value for multiple rows via rowspan (sorting initially)',
+ header,
+ planets,
+ planetsRowspan,
+ 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();
+ // - 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.tablesorter( { sortList: [
+ { 0: 'asc' }
+ ] } );
+ }
+ );
+ tableTest(
+ 'Basic planet table: Same value for multiple rows via rowspan II',
+ header,
+ planets,
+ planetsRowspanII,
+ 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();
+ // - 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.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+
+ tableTest(
+ 'Complex date parsing I',
+ [ 'date' ],
+ complexMDYDates,
+ complexMDYSorted,
+ function ( $table ) {
+ mw.config.set( 'wgDefaultDateFormat', 'mdy' );
+
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+
+ tableTest(
+ 'Currency parsing I',
+ [ 'currency' ],
+ currencyUnsorted,
+ currencySorted,
+ function ( $table ) {
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+
+ planetsAscNameLegacy = planetsAscName.slice( 0 );
+ planetsAscNameLegacy[ 4 ] = planetsAscNameLegacy[ 5 ];
+ planetsAscNameLegacy.pop();
+
+ tableTest(
+ 'Legacy compat with .sortbottom',
+ header,
+ planets,
+ planetsAscNameLegacy,
+ function ( $table ) {
+ $table.find( 'tr:last' ).addClass( 'sortbottom' );
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+
+ QUnit.test( 'Test detection routine', function ( assert ) {
+ var $table;
+ $table = $(
+ '<table class="sortable">' +
+ '<caption>CAPTION</caption>' +
+ '<tr><th>THEAD</th></tr>' +
+ '<tr><td>1</td></tr>' +
+ '<tr class="sortbottom"><td>text</td></tr>' +
+ '</table>'
+ );
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+
+ assert.equal(
+ $table.data( 'tablesorter' ).config.parsers[ 0 ].id,
+ 'number',
+ 'Correctly detected column content skipping sortbottom'
+ );
+ } );
+
+ /** FIXME: the diff output is not very readeable. */
+ QUnit.test( 'T34047 - caption must be before thead', function ( assert ) {
+ var $table;
+ $table = $(
+ '<table class="sortable">' +
+ '<caption>CAPTION</caption>' +
+ '<tr><th>THEAD</th></tr>' +
+ '<tr><td>A</td></tr>' +
+ '<tr><td>B</td></tr>' +
+ '<tr class="sortbottom"><td>TFOOT</td></tr>' +
+ '</table>'
+ );
+ $table.tablesorter();
+
+ assert.equal(
+ $table.children().get( 0 ).nodeName,
+ 'CAPTION',
+ 'First element after <thead> must be <caption> (T34047)'
+ );
+ } );
+
+ QUnit.test( 'data-sort-value attribute, when available, should override sorting position', function ( assert ) {
+ var $table, data;
+
+ // Example 1: All cells except one cell without data-sort-value,
+ // which should be sorted at it's text content value.
+ $table = $(
+ '<table class="sortable"><thead><tr><th>Data</th></tr></thead>' +
+ '<tbody>' +
+ '<tr><td>Cheetah</td></tr>' +
+ '<tr><td data-sort-value="Apple">Bird</td></tr>' +
+ '<tr><td data-sort-value="Bananna">Ferret</td></tr>' +
+ '<tr><td data-sort-value="Drupe">Elephant</td></tr>' +
+ '<tr><td data-sort-value="Cherry">Dolphin</td></tr>' +
+ '</tbody></table>'
+ );
+ $table.tablesorter().find( '.headerSort:eq(0)' ).click();
+
+ data = [];
+ $table.find( 'tbody > tr' ).each( function ( i, tr ) {
+ $( tr ).find( 'td' ).each( function ( i, td ) {
+ data.push( {
+ data: $( td ).data( 'sortValue' ),
+ text: $( td ).text()
+ } );
+ } );
+ } );
+
+ assert.deepEqual( data, [
+ {
+ data: 'Apple',
+ text: 'Bird'
+ },
+ {
+ data: 'Bananna',
+ text: 'Ferret'
+ },
+ {
+ data: undefined,
+ text: 'Cheetah'
+ },
+ {
+ data: 'Cherry',
+ text: 'Dolphin'
+ },
+ {
+ data: 'Drupe',
+ text: 'Elephant'
+ }
+ ], 'Order matches expected order (based on data-sort-value attribute values)' );
+
+ // Example 2
+ $table = $(
+ '<table class="sortable"><thead><tr><th>Data</th></tr></thead>' +
+ '<tbody>' +
+ '<tr><td>D</td></tr>' +
+ '<tr><td data-sort-value="E">A</td></tr>' +
+ '<tr><td>B</td></tr>' +
+ '<tr><td>G</td></tr>' +
+ '<tr><td data-sort-value="F">C</td></tr>' +
+ '</tbody></table>'
+ );
+ $table.tablesorter().find( '.headerSort:eq(0)' ).click();
+
+ data = [];
+ $table.find( 'tbody > tr' ).each( function ( i, tr ) {
+ $( tr ).find( 'td' ).each( function ( i, td ) {
+ data.push( {
+ data: $( td ).data( 'sortValue' ),
+ text: $( td ).text()
+ } );
+ } );
+ } );
+
+ assert.deepEqual( data, [
+ {
+ data: undefined,
+ text: 'B'
+ },
+ {
+ data: undefined,
+ text: 'D'
+ },
+ {
+ data: 'E',
+ text: 'A'
+ },
+ {
+ data: 'F',
+ text: 'C'
+ },
+ {
+ data: undefined,
+ text: 'G'
+ }
+ ], 'Order matches expected order (based on data-sort-value attribute values)' );
+
+ // Example 3: Test that live changes are used from data-sort-value,
+ // even if they change after the tablesorter is constructed (T40152).
+ $table = $(
+ '<table class="sortable"><thead><tr><th>Data</th></tr></thead>' +
+ '<tbody>' +
+ '<tr><td>D</td></tr>' +
+ '<tr><td data-sort-value="1">A</td></tr>' +
+ '<tr><td>B</td></tr>' +
+ '<tr><td data-sort-value="2">G</td></tr>' +
+ '<tr><td>C</td></tr>' +
+ '</tbody></table>'
+ );
+ // initialize table sorter and sort once
+ $table
+ .tablesorter()
+ .find( '.headerSort:eq(0)' ).click();
+
+ // Change the sortValue data properties (T40152)
+ // - change data
+ $table.find( 'td:contains(A)' ).data( 'sortValue', 3 );
+ // - add data
+ $table.find( 'td:contains(B)' ).data( 'sortValue', 1 );
+ // - remove data, bring back attribute: 2
+ $table.find( 'td:contains(G)' ).removeData( 'sortValue' );
+
+ // Now sort again (twice, so it is back at Ascending)
+ $table.find( '.headerSort:eq(0)' ).click();
+ $table.find( '.headerSort:eq(0)' ).click();
+
+ data = [];
+ $table.find( 'tbody > tr' ).each( function ( i, tr ) {
+ $( tr ).find( 'td' ).each( function ( i, td ) {
+ data.push( {
+ data: $( td ).data( 'sortValue' ),
+ text: $( td ).text()
+ } );
+ } );
+ } );
+
+ assert.deepEqual( data, [
+ {
+ data: 1,
+ text: 'B'
+ },
+ {
+ data: 2,
+ text: 'G'
+ },
+ {
+ data: 3,
+ text: 'A'
+ },
+ {
+ data: undefined,
+ text: 'C'
+ },
+ {
+ data: undefined,
+ text: 'D'
+ }
+ ], 'Order matches expected order, using the current sortValue in $.data()' );
+
+ } );
+
+ tableTest( 'T10115: sort numbers with commas (ascending)',
+ [ 'Numbers' ], numbers, numbersAsc,
+ function ( $table ) {
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+
+ tableTest( 'T10115: sort numbers with commas (descending)',
+ [ 'Numbers' ], numbers, reversed( numbersAsc ),
+ function ( $table ) {
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click().click();
+ }
+ );
+ // TODO add numbers sorting tests for T10115 with a different language
+
+ QUnit.test( 'T34888 - Tables inside a tableheader cell', function ( assert ) {
+ var $table;
+ $table = $(
+ '<table class="sortable" id="mw-bug-32888">' +
+ '<tr><th>header<table id="mw-bug-32888-2">' +
+ '<tr><th>1</th><th>2</th></tr>' +
+ '</table></th></tr>' +
+ '<tr><td>A</td></tr>' +
+ '<tr><td>B</td></tr>' +
+ '</table>'
+ );
+ $table.tablesorter();
+
+ assert.equal(
+ $table.find( '> thead:eq(0) > tr > th.headerSort' ).length,
+ 1,
+ 'Child tables inside a headercell should not interfere with sortable headers (T34888)'
+ );
+ assert.equal(
+ $( '#mw-bug-32888-2' ).find( 'th.headerSort' ).length,
+ 0,
+ 'The headers of child tables inside a headercell should not be sortable themselves (T34888)'
+ );
+ } );
+
+ tableTest(
+ 'Correct date sorting I',
+ [ 'date' ],
+ correctDateSorting1,
+ correctDateSortingSorted1,
+ function ( $table ) {
+ mw.config.set( 'wgDefaultDateFormat', 'mdy' );
+
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+
+ tableTest(
+ 'Correct date sorting II',
+ [ 'date' ],
+ correctDateSorting2,
+ correctDateSortingSorted2,
+ function ( $table ) {
+ mw.config.set( 'wgDefaultDateFormat', 'dmy' );
+
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+
+ tableTest(
+ 'ISO date sorting',
+ [ 'isoDate' ],
+ isoDateSorting,
+ isoDateSortingSorted,
+ function ( $table ) {
+ mw.config.set( 'wgDefaultDateFormat', 'dmy' );
+
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ }
+ );
+
+ QUnit.test( 'Sorting images using alt text', function ( assert ) {
+ var $table = $(
+ '<table class="sortable">' +
+ '<tr><th>THEAD</th></tr>' +
+ '<tr><td><img alt="2"/></td></tr>' +
+ '<tr><td>1</td></tr>' +
+ '</table>'
+ );
+ $table.tablesorter().find( '.headerSort:eq(0)' ).click();
+
+ assert.equal(
+ $table.find( 'td' ).first().text(),
+ '1',
+ 'Applied correct sorting order'
+ );
+ } );
+
+ QUnit.test( 'Sorting images using alt text (complex)', function ( assert ) {
+ var $table = $(
+ '<table class="sortable">' +
+ '<tr><th>THEAD</th></tr>' +
+ '<tr><td><img alt="D" />A</td></tr>' +
+ '<tr><td>CC</td></tr>' +
+ '<tr><td><a><img alt="A" /></a>F</tr>' +
+ '<tr><td><img alt="A" /><strong>E</strong></tr>' +
+ '<tr><td><strong><img alt="A" />D</strong></tr>' +
+ '<tr><td><img alt="A" />C</tr>' +
+ '</table>'
+ );
+ $table.tablesorter().find( '.headerSort:eq(0)' ).click();
+
+ assert.equal(
+ $table.find( 'td' ).text(),
+ 'CDEFCCA',
+ 'Applied correct sorting order'
+ );
+ } );
+
+ QUnit.test( 'Sorting images using alt text (with format autodetection)', function ( assert ) {
+ var $table = $(
+ '<table class="sortable">' +
+ '<tr><th>THEAD</th></tr>' +
+ '<tr><td><img alt="1" />7</td></tr>' +
+ '<tr><td>1<img alt="6" /></td></tr>' +
+ '<tr><td>5</td></tr>' +
+ '<tr><td>4</td></tr>' +
+ '</table>'
+ );
+ $table.tablesorter().find( '.headerSort:eq(0)' ).click();
+
+ assert.equal(
+ $table.find( 'td' ).text(),
+ '4517',
+ 'Applied correct sorting order'
+ );
+ } );
+
+ QUnit.test( 'T40911 - The row with the largest amount of columns should receive the sort indicators', function ( assert ) {
+ var $table = $(
+ '<table class="sortable">' +
+ '<thead>' +
+ '<tr><th rowspan="2" id="A1">A1</th><th colspan="2">B2a</th></tr>' +
+ '<tr><th id="B2b">B2b</th><th id="C2b">C2b</th></tr>' +
+ '</thead>' +
+ '<tr><td>A</td><td>Aa</td><td>Ab</td></tr>' +
+ '<tr><td>B</td><td>Ba</td><td>Bb</td></tr>' +
+ '</table>'
+ );
+ $table.tablesorter();
+
+ assert.equal(
+ $table.find( '#A1' ).attr( 'class' ),
+ 'headerSort',
+ 'The first column of the first row should be sortable'
+ );
+ assert.equal(
+ $table.find( '#B2b' ).attr( 'class' ),
+ 'headerSort',
+ 'The th element of the 2nd row of the 2nd column should be sortable'
+ );
+ assert.equal(
+ $table.find( '#C2b' ).attr( 'class' ),
+ 'headerSort',
+ 'The th element of the 2nd row of the 3rd column should be sortable'
+ );
+ } );
+
+ QUnit.test( 'rowspans in table headers should prefer the last row when rows are equal in length', function ( assert ) {
+ var $table = $(
+ '<table class="sortable">' +
+ '<thead>' +
+ '<tr><th rowspan="2" id="A1">A1</th><th>B2a</th></tr>' +
+ '<tr><th id="B2b">B2b</th></tr>' +
+ '</thead>' +
+ '<tr><td>A</td><td>Aa</td></tr>' +
+ '<tr><td>B</td><td>Ba</td></tr>' +
+ '</table>'
+ );
+ $table.tablesorter();
+
+ assert.equal(
+ $table.find( '#A1' ).attr( 'class' ),
+ 'headerSort',
+ 'The first column of the first row should be sortable'
+ );
+ assert.equal(
+ $table.find( '#B2b' ).attr( 'class' ),
+ 'headerSort',
+ 'The th element of the 2nd row of the 2nd column should be sortable'
+ );
+ } );
+
+ QUnit.test( 'holes in the table headers should not throw JS errors', function ( assert ) {
+ var $table = $(
+ '<table class="sortable">' +
+ '<thead>' +
+ '<tr><th id="A1">A1</th><th>B1</th><th id="C1" rowspan="2">C1</th></tr>' +
+ '<tr><th id="A2">A2</th></tr>' +
+ '</thead>' +
+ '<tr><td>A</td><td>Aa</td><td>Aaa</td></tr>' +
+ '<tr><td>B</td><td>Ba</td><td>Bbb</td></tr>' +
+ '</table>'
+ );
+ $table.tablesorter();
+ assert.equal( $table.find( '#A2' ).data( 'headerIndex' ),
+ undefined,
+ 'A2 should not be a sort header'
+ );
+ assert.equal( $table.find( '#C1' ).data( 'headerIndex' ),
+ 2,
+ 'C1 should be a sort header'
+ );
+ } );
+
+ // T55527
+ QUnit.test( 'td cells in thead should not be taken into account for longest row calculation', function ( assert ) {
+ var $table = $(
+ '<table class="sortable">' +
+ '<thead>' +
+ '<tr><th id="A1">A1</th><th>B1</th><td id="C1">C1</td></tr>' +
+ '<tr><th id="A2">A2</th><th>B2</th><th id="C2">C2</th></tr>' +
+ '</thead>' +
+ '</table>'
+ );
+ $table.tablesorter();
+ assert.equal( $table.find( '#C2' ).data( 'headerIndex' ),
+ 2,
+ 'C2 should be a sort header'
+ );
+ assert.equal( $table.find( '#C1' ).data( 'headerIndex' ),
+ undefined,
+ 'C1 should not be a sort header'
+ );
+ } );
+
+ // T43889 - exploding rowspans in more complex cases
+ tableTestHTML(
+ 'Rowspan exploding with row headers',
+ '<table class="sortable">' +
+ '<thead><tr><th id="sortme">n</th><th>foo</th><th>bar</th><th>baz</th></tr></thead>' +
+ '<tbody>' +
+ '<tr><td>1</td><th rowspan="2">foo</th><td rowspan="2">bar</td><td>baz</td></tr>' +
+ '<tr><td>2</td><td>baz</td></tr>' +
+ '</tbody></table>',
+ [
+ [ '1', 'foo', 'bar', 'baz' ],
+ [ '2', 'foo', 'bar', 'baz' ]
+ ]
+ );
+
+ // T55211 - exploding rowspans in more complex cases
+ QUnit.test(
+ 'Rowspan exploding with row headers and colspans', function ( assert ) {
+ var $table = $( '<table class="sortable">' +
+ '<thead><tr><th rowspan="2">n</th><th colspan="2">foo</th><th rowspan="2">baz</th></tr>' +
+ '<tr><th>foo</th><th>bar</th></tr></thead>' +
+ '<tbody>' +
+ '<tr><td>1</td><td>foo</td><td>bar</td><td>baz</td></tr>' +
+ '<tr><td>2</td><td>foo</td><td>bar</td><td>baz</td></tr>' +
+ '</tbody></table>' );
+
+ $table.tablesorter();
+ assert.equal( $table.find( 'tr:eq(1) th:eq(1)' ).data( 'headerIndex' ),
+ 2,
+ 'Incorrect index of sort header'
+ );
+ }
+ );
+
+ tableTestHTML(
+ 'Rowspan exploding with colspanned cells',
+ '<table class="sortable">' +
+ '<thead><tr><th id="sortme">n</th><th>foo</th><th>bar</th><th>baz</th></tr></thead>' +
+ '<tbody>' +
+ '<tr><td>1</td><td>foo</td><td>bar</td><td rowspan="2">baz</td></tr>' +
+ '<tr><td>2</td><td colspan="2">foobar</td></tr>' +
+ '</tbody></table>',
+ [
+ [ '1', 'foo', 'bar', 'baz' ],
+ [ '2', 'foobar', 'baz' ]
+ ]
+ );
+
+ tableTestHTML(
+ 'Rowspan exploding with colspanned cells (2)',
+ '<table class="sortable">' +
+ '<thead><tr><th>n</th><th>foo</th><th>bar</th><th>baz</th><th id="sortme">n2</th></tr></thead>' +
+ '<tbody>' +
+ '<tr><td>1</td><td>foo</td><td>bar</td><td rowspan="2">baz</td><td>2</td></tr>' +
+ '<tr><td>2</td><td colspan="2">foobar</td><td>1</td></tr>' +
+ '</tbody></table>',
+ [
+ [ '2', 'foobar', 'baz', '1' ],
+ [ '1', 'foo', 'bar', 'baz', '2' ]
+ ]
+ );
+
+ tableTestHTML(
+ 'Rowspan exploding with rightmost rows spanning most',
+ '<table class="sortable">' +
+ '<thead><tr><th id="sortme">n</th><th>foo</th><th>bar</th></tr></thead>' +
+ '<tbody>' +
+ '<tr><td>1</td><td rowspan="2">foo</td><td rowspan="4">bar</td></tr>' +
+ '<tr><td>2</td></tr>' +
+ '<tr><td>3</td><td rowspan="2">foo</td></tr>' +
+ '<tr><td>4</td></tr>' +
+ '</tbody></table>',
+ [
+ [ '1', 'foo', 'bar' ],
+ [ '2', 'foo', 'bar' ],
+ [ '3', 'foo', 'bar' ],
+ [ '4', 'foo', 'bar' ]
+ ]
+ );
+
+ tableTestHTML(
+ 'Rowspan exploding with rightmost rows spanning most (2)',
+ '<table class="sortable">' +
+ '<thead><tr><th id="sortme">n</th><th>foo</th><th>bar</th><th>baz</th></tr></thead>' +
+ '<tbody>' +
+ '<tr><td>1</td><td rowspan="2">foo</td><td rowspan="4">bar</td><td>baz</td></tr>' +
+ '<tr><td>2</td><td>baz</td></tr>' +
+ '<tr><td>3</td><td rowspan="2">foo</td><td>baz</td></tr>' +
+ '<tr><td>4</td><td>baz</td></tr>' +
+ '</tbody></table>',
+ [
+ [ '1', 'foo', 'bar', 'baz' ],
+ [ '2', 'foo', 'bar', 'baz' ],
+ [ '3', 'foo', 'bar', 'baz' ],
+ [ '4', 'foo', 'bar', 'baz' ]
+ ]
+ );
+
+ tableTestHTML(
+ 'Rowspan exploding with row-and-colspanned cells',
+ '<table class="sortable">' +
+ '<thead><tr><th id="sortme">n</th><th>foo1</th><th>foo2</th><th>bar</th><th>baz</th></tr></thead>' +
+ '<tbody>' +
+ '<tr><td>1</td><td rowspan="2">foo1</td><td rowspan="2">foo2</td><td rowspan="4">bar</td><td>baz</td></tr>' +
+ '<tr><td>2</td><td>baz</td></tr>' +
+ '<tr><td>3</td><td colspan="2" rowspan="2">foo</td><td>baz</td></tr>' +
+ '<tr><td>4</td><td>baz</td></tr>' +
+ '</tbody></table>',
+ [
+ [ '1', 'foo1', 'foo2', 'bar', 'baz' ],
+ [ '2', 'foo1', 'foo2', 'bar', 'baz' ],
+ [ '3', 'foo', 'bar', 'baz' ],
+ [ '4', 'foo', 'bar', 'baz' ]
+ ]
+ );
+
+ tableTestHTML(
+ 'Rowspan exploding with uneven rowspan layout',
+ '<table class="sortable">' +
+ '<thead><tr><th id="sortme">n</th><th>foo1</th><th>foo2</th><th>foo3</th><th>bar</th><th>baz</th></tr></thead>' +
+ '<tbody>' +
+ '<tr><td>1</td><td rowspan="2">foo1</td><td rowspan="2">foo2</td><td rowspan="2">foo3</td><td>bar</td><td>baz</td></tr>' +
+ '<tr><td>2</td><td rowspan="3">bar</td><td>baz</td></tr>' +
+ '<tr><td>3</td><td rowspan="2">foo1</td><td rowspan="2">foo2</td><td rowspan="2">foo3</td><td>baz</td></tr>' +
+ '<tr><td>4</td><td>baz</td></tr>' +
+ '</tbody></table>',
+ [
+ [ '1', 'foo1', 'foo2', 'foo3', 'bar', 'baz' ],
+ [ '2', 'foo1', 'foo2', 'foo3', 'bar', 'baz' ],
+ [ '3', 'foo1', 'foo2', 'foo3', 'bar', 'baz' ],
+ [ '4', 'foo1', 'foo2', 'foo3', 'bar', 'baz' ]
+ ]
+ );
+
+ QUnit.test( 'T105731 - incomplete rows in table body', function ( assert ) {
+ var $table, parsers;
+ $table = $(
+ '<table class="sortable">' +
+ '<tr><th>A</th><th>B</th></tr>' +
+ '<tr><td>3</td></tr>' +
+ '<tr><td>1</td><td>2</td></tr>' +
+ '</table>'
+ );
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+ // now the first row have 2 columns
+ $table.find( '.headerSort:eq(1)' ).click();
+
+ parsers = $table.data( 'tablesorter' ).config.parsers;
+
+ assert.equal(
+ parsers.length,
+ 2,
+ 'detectParserForColumn() detect 2 parsers'
+ );
+
+ assert.equal(
+ parsers[ 1 ].id,
+ 'number',
+ 'detectParserForColumn() detect parser.id "number" for second column'
+ );
+
+ assert.equal(
+ parsers[ 1 ].format( $table.find( 'tbody > tr > td:eq(1)' ).text() ),
+ -Infinity,
+ 'empty cell is sorted as number -Infinity'
+ );
+ } );
+
+ QUnit.test( 'bug T114721 - use of expand-child class', function ( assert ) {
+ var $table, parsers;
+ $table = $(
+ '<table class="sortable">' +
+ '<tr><th>A</th><th>B</th></tr>' +
+ '<tr><td>b</td><td>4</td></tr>' +
+ '<tr class="expand-child"><td colspan="2">some text follow b</td></tr>' +
+ '<tr><td>a</td><td>2</td></tr>' +
+ '<tr class="expand-child"><td colspan="2">some text follow a</td></tr>' +
+ '<tr class="expand-child"><td colspan="2">more text</td></tr>' +
+ '</table>'
+ );
+ $table.tablesorter();
+ $table.find( '.headerSort:eq(0)' ).click();
+
+ assert.deepEqual(
+ tableExtract( $table ),
+ [
+ [ 'a', '2' ],
+ [ 'some text follow a' ],
+ [ 'more text' ],
+ [ 'b', '4' ],
+ [ 'some text follow b' ]
+ ],
+ 'row with expand-child class follow above row'
+ );
+
+ parsers = $table.data( 'tablesorter' ).config.parsers;
+ assert.equal(
+ parsers[ 1 ].id,
+ 'number',
+ 'detectParserForColumn() detect parser.id "number" for second column'
+ );
+ } );
+
+}( jQuery, mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.textSelection.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.textSelection.test.js
new file mode 100644
index 00000000..32cda7eb
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.textSelection.test.js
@@ -0,0 +1,266 @@
+( function ( $ ) {
+ var caretSample,
+ sig = {
+ pre: '--~~~~'
+ },
+ bold = {
+ pre: '\'\'\'',
+ peri: 'Bold text',
+ post: '\'\'\''
+ },
+ h2 = {
+ pre: '== ',
+ peri: 'Heading 2',
+ post: ' ==',
+ regex: /^(\s*)(={1,6})(.*?)\2(\s*)$/,
+ regexReplace: '$1==$3==$4',
+ ownline: true
+ },
+ ulist = {
+ pre: '* ',
+ peri: 'Bulleted list item',
+ post: '',
+ ownline: true,
+ splitlines: true
+ };
+
+ QUnit.module( 'jquery.textSelection', QUnit.newMwEnvironment() );
+
+ /**
+ * Test factory for $.fn.textSelection( 'encapsulateText' )
+ *
+ * @param {Object} options Associative configuration array
+ * @param {string} options.description Description
+ * @param {string} options.input Input
+ * @param {string} options.output Output
+ * @param {int} options.start Starting char for selection
+ * @param {int} options.end Ending char for selection
+ * @param {Object} options.params Additional parameters for $().textSelection( 'encapsulateText' )
+ */
+ function encapsulateTest( options ) {
+ var opt = $.extend( {
+ description: '',
+ before: {},
+ after: {},
+ replace: {}
+ }, options );
+
+ opt.before = $.extend( {
+ text: '',
+ start: 0,
+ end: 0
+ }, opt.before );
+ opt.after = $.extend( {
+ text: '',
+ selected: null
+ }, opt.after );
+
+ QUnit.test( opt.description, function ( assert ) {
+ var $textarea, start, end, options, text, selected;
+
+ $textarea = $( '<textarea>' );
+
+ $( '#qunit-fixture' ).append( $textarea );
+
+ $textarea.textSelection( 'setContents', opt.before.text );
+
+ start = opt.before.start;
+ end = opt.before.end;
+
+ // Clone opt.replace
+ options = $.extend( {}, opt.replace );
+ options.selectionStart = start;
+ options.selectionEnd = end;
+ $textarea.textSelection( 'encapsulateSelection', options );
+
+ text = $textarea.textSelection( 'getContents' ).replace( /\r\n/g, '\n' );
+
+ assert.equal( text, opt.after.text, 'Checking full text after encapsulation' );
+
+ if ( opt.after.selected !== null ) {
+ selected = $textarea.textSelection( 'getSelection' );
+ assert.equal( selected, opt.after.selected, 'Checking selected text after encapsulation.' );
+ }
+
+ } );
+ }
+
+ encapsulateTest( {
+ description: 'Adding sig to end of text',
+ before: {
+ text: 'Wikilove dude! ',
+ start: 15,
+ end: 15
+ },
+ after: {
+ text: 'Wikilove dude! --~~~~',
+ selected: ''
+ },
+ replace: sig
+ } );
+
+ encapsulateTest( {
+ description: 'Adding bold to empty',
+ before: {
+ text: '',
+ start: 0,
+ end: 0
+ },
+ after: {
+ text: '\'\'\'Bold text\'\'\'',
+ selected: 'Bold text' // selected because it's the default
+ },
+ replace: bold
+ } );
+
+ encapsulateTest( {
+ description: 'Adding bold to existing text',
+ before: {
+ text: 'Now is the time for all good men to come to the aid of their country',
+ start: 20,
+ end: 32
+ },
+ after: {
+ text: 'Now is the time for \'\'\'all good men\'\'\' to come to the aid of their country',
+ selected: '' // empty because it's not the default'
+ },
+ replace: bold
+ } );
+
+ encapsulateTest( {
+ description: 'ownline option: adding new h2',
+ before: {
+ text: 'Before\nAfter',
+ start: 7,
+ end: 7
+ },
+ after: {
+ text: 'Before\n== Heading 2 ==\nAfter',
+ selected: 'Heading 2'
+ },
+ replace: h2
+ } );
+
+ encapsulateTest( {
+ description: 'ownline option: turn a whole line into new h2',
+ before: {
+ text: 'Before\nMy heading\nAfter',
+ start: 7,
+ end: 17
+ },
+ after: {
+ text: 'Before\n== My heading ==\nAfter',
+ selected: ''
+ },
+ replace: h2
+ } );
+
+ encapsulateTest( {
+ description: 'ownline option: turn a partial line into new h2',
+ before: {
+ text: 'BeforeMy headingAfter',
+ start: 6,
+ end: 16
+ },
+ after: {
+ text: 'Before\n== My heading ==\nAfter',
+ selected: ''
+ },
+ replace: h2
+ } );
+
+ encapsulateTest( {
+ description: 'splitlines option: no selection, insert new list item',
+ before: {
+ text: 'Before\nAfter',
+ start: 7,
+ end: 7
+ },
+ after: {
+ text: 'Before\n* Bulleted list item\nAfter'
+ },
+ replace: ulist
+ } );
+
+ encapsulateTest( {
+ description: 'splitlines option: single partial line selection, insert new list item',
+ before: {
+ text: 'BeforeMy List ItemAfter',
+ start: 6,
+ end: 18
+ },
+ after: {
+ text: 'Before\n* My List Item\nAfter'
+ },
+ replace: ulist
+ } );
+
+ encapsulateTest( {
+ description: 'splitlines option: multiple lines',
+ before: {
+ text: 'Before\nFirst\nSecond\nThird\nAfter',
+ start: 7,
+ end: 25
+ },
+ after: {
+ text: 'Before\n* First\n* Second\n* Third\nAfter'
+ },
+ replace: ulist
+ } );
+
+ function caretTest( options ) {
+ QUnit.test( options.description, function ( assert ) {
+ var pos,
+ $textarea = $( '<textarea>' ).text( options.text );
+
+ $( '#qunit-fixture' ).append( $textarea );
+
+ if ( options.mode === 'set' ) {
+ $textarea.textSelection( 'setSelection', {
+ start: options.start,
+ end: options.end
+ } );
+ }
+
+ function among( actual, expected, message ) {
+ if ( Array.isArray( expected ) ) {
+ assert.ok( expected.indexOf( actual ) !== -1, message + ' (got ' + actual + '; expected one of ' + expected.join( ', ' ) + ')' );
+ } else {
+ assert.equal( actual, expected, message );
+ }
+ }
+
+ pos = $textarea.textSelection( 'getCaretPosition', { startAndEnd: true } );
+ among( pos[ 0 ], options.start, 'Caret start should be where we set it.' );
+ among( pos[ 1 ], options.end, 'Caret end should be where we set it.' );
+ } );
+ }
+
+ caretSample = 'Some big text that we like to work with. Nothing fancy... you know what I mean?';
+
+ /* @broken: Disabled per T36820
+ caretTest({
+ description: 'getCaretPosition with original/empty selection - T33847 with IE 6/7/8',
+ text: caretSample,
+ start: [0, caretSample.length], // Opera and Firefox (prior to FF 6.0) default caret to the end of the box (caretSample.length)
+ end: [0, caretSample.length], // Other browsers default it to the beginning (0), so check both.
+ mode: 'get'
+ });
+ */
+
+ caretTest( {
+ description: 'set/getCaretPosition with forced empty selection',
+ text: caretSample,
+ start: 7,
+ end: 7,
+ mode: 'set'
+ } );
+
+ caretTest( {
+ description: 'set/getCaretPosition with small selection',
+ text: caretSample,
+ start: 6,
+ end: 11,
+ mode: 'set'
+ } );
+}( jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js
new file mode 100644
index 00000000..69ab7975
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js
@@ -0,0 +1,32 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.ForeignApi', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.server = this.sandbox.useFakeServer();
+ this.server.respondImmediately = true;
+ }
+ } ) );
+
+ QUnit.test( 'origin is included in GET requests', function ( assert ) {
+ var api = new mw.ForeignApi( '//localhost:4242/w/api.php' );
+
+ this.server.respond( function ( request ) {
+ assert.ok( request.url.match( /origin=/ ), 'origin is included in GET requests' );
+ request.respond( 200, { 'Content-Type': 'application/json' }, '[]' );
+ } );
+
+ return api.get( {} );
+ } );
+
+ QUnit.test( 'origin is included in POST requests', function ( assert ) {
+ var api = new mw.ForeignApi( '//localhost:4242/w/api.php' );
+
+ this.server.respond( function ( request ) {
+ assert.ok( request.requestBody.match( /origin=/ ), 'origin is included in POST request body' );
+ assert.ok( request.url.match( /origin=/ ), 'origin is included in POST request URL, too' );
+ request.respond( 200, { 'Content-Type': 'application/json' }, '[]' );
+ } );
+
+ return api.post( {} );
+ } );
+
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js
new file mode 100644
index 00000000..50fa6d15
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js
@@ -0,0 +1,114 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.api.category', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.server = this.sandbox.useFakeServer();
+ this.server.respondImmediately = true;
+ }
+ } ) );
+
+ QUnit.test( '.getCategoriesByPrefix()', function ( assert ) {
+ this.server.respondWith( [ 200, { 'Content-Type': 'application/json' },
+ '{ "query": { "allpages": [ ' +
+ '{ "title": "Category:Food" },' +
+ '{ "title": "Category:Fool Supermarine S.6" },' +
+ '{ "title": "Category:Fools" }' +
+ '] } }'
+ ] );
+
+ return new mw.Api().getCategoriesByPrefix( 'Foo' ).then( function ( matches ) {
+ assert.deepEqual(
+ matches,
+ [ 'Food', 'Fool Supermarine S.6', 'Fools' ]
+ );
+ } );
+ } );
+
+ QUnit.test( '.isCategory("")', function ( assert ) {
+ this.server.respondWith( /titles=$/, [
+ 200,
+ { 'Content-Type': 'application/json' },
+ '{"batchcomplete":true}'
+ ] );
+ return new mw.Api().isCategory( '' ).then( function ( response ) {
+ assert.equal( response, false );
+ } );
+ } );
+
+ QUnit.test( '.isCategory("#")', function ( assert ) {
+ this.server.respondWith( /titles=%23$/, [
+ 200,
+ { 'Content-Type': 'application/json' },
+ '{"batchcomplete":true,"query":{"normalized":[{"fromencoded":false,"from":"#","to":""}]}}'
+ ] );
+ return new mw.Api().isCategory( '#' ).then( function ( response ) {
+ assert.equal( response, false );
+ } );
+ } );
+
+ QUnit.test( '.isCategory("mw:")', function ( assert ) {
+ this.server.respondWith( /titles=mw%3A$/, [
+ 200,
+ { 'Content-Type': 'application/json' },
+ '{"batchcomplete":true,"query":{"interwiki":[{"title":"mw:","iw":"mw"}]}}'
+ ] );
+ return new mw.Api().isCategory( 'mw:' ).then( function ( response ) {
+ assert.equal( response, false );
+ } );
+ } );
+
+ QUnit.test( '.isCategory("|")', function ( assert ) {
+ this.server.respondWith( /titles=%1F%7C$/, [
+ 200,
+ { 'Content-Type': 'application/json' },
+ '{"batchcomplete":true,"query":{"pages":[{"title":"|","invalidreason":"The requested page title contains invalid characters: \\"|\\".","invalid":true}]}}'
+ ] );
+ return new mw.Api().isCategory( '|' ).then( function ( response ) {
+ assert.equal( response, false );
+ } );
+ } );
+
+ QUnit.test( '.getCategories("")', function ( assert ) {
+ this.server.respondWith( /titles=$/, [
+ 200,
+ { 'Content-Type': 'application/json' },
+ '{"batchcomplete":true}'
+ ] );
+ return new mw.Api().getCategories( '' ).then( function ( response ) {
+ assert.equal( response, false );
+ } );
+ } );
+
+ QUnit.test( '.getCategories("#")', function ( assert ) {
+ this.server.respondWith( /titles=%23$/, [
+ 200,
+ { 'Content-Type': 'application/json' },
+ '{"batchcomplete":true,"query":{"normalized":[{"fromencoded":false,"from":"#","to":""}]}}'
+ ] );
+ return new mw.Api().getCategories( '#' ).then( function ( response ) {
+ assert.equal( response, false );
+ } );
+ } );
+
+ QUnit.test( '.getCategories("mw:")', function ( assert ) {
+ this.server.respondWith( /titles=mw%3A$/, [
+ 200,
+ { 'Content-Type': 'application/json' },
+ '{"batchcomplete":true,"query":{"interwiki":[{"title":"mw:","iw":"mw"}]}}'
+ ] );
+ return new mw.Api().getCategories( 'mw:' ).then( function ( response ) {
+ assert.equal( response, false );
+ } );
+ } );
+
+ QUnit.test( '.getCategories("|")', function ( assert ) {
+ this.server.respondWith( /titles=%1F%7C$/, [
+ 200,
+ { 'Content-Type': 'application/json' },
+ '{"batchcomplete":true,"query":{"pages":[{"title":"|","invalidreason":"The requested page title contains invalid characters: \\"|\\".","invalid":true}]}}'
+ ] );
+ return new mw.Api().getCategories( '|' ).then( function ( response ) {
+ assert.equal( response, false );
+ } );
+ } );
+
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js
new file mode 100644
index 00000000..4ce7c5db
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js
@@ -0,0 +1,220 @@
+( function ( mw, $ ) {
+ QUnit.module( 'mediawiki.api.edit', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.server = this.sandbox.useFakeServer();
+ this.server.respondImmediately = true;
+ }
+ } ) );
+
+ QUnit.test( 'edit( title, transform String )', function ( assert ) {
+ this.server.respond( function ( req ) {
+ if ( /query.+titles=Sandbox/.test( req.url ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( {
+ curtimestamp: '2016-01-02T12:00:00Z',
+ query: {
+ pages: [ {
+ pageid: 1,
+ ns: 0,
+ title: 'Sandbox',
+ revisions: [ {
+ timestamp: '2016-01-01T12:00:00Z',
+ contentformat: 'text/x-wiki',
+ contentmodel: 'wikitext',
+ content: 'Sand.'
+ } ]
+ } ]
+ }
+ } ) );
+ }
+ if ( /edit.+basetimestamp=2016-01-01.+starttimestamp=2016-01-02.+text=Box%2E/.test( req.requestBody ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( {
+ edit: {
+ result: 'Success',
+ oldrevid: 11,
+ newrevid: 13,
+ newtimestamp: '2016-01-03T12:00:00Z'
+ }
+ } ) );
+ }
+ } );
+
+ return new mw.Api()
+ .edit( 'Sandbox', function ( revision ) {
+ return revision.content.replace( 'Sand', 'Box' );
+ } )
+ .then( function ( edit ) {
+ assert.equal( edit.newrevid, 13 );
+ } );
+ } );
+
+ QUnit.test( 'edit( mw.Title, transform String )', function ( assert ) {
+ this.server.respond( function ( req ) {
+ if ( /query.+titles=Sandbox/.test( req.url ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( {
+ curtimestamp: '2016-01-02T12:00:00Z',
+ query: {
+ pages: [ {
+ pageid: 1,
+ ns: 0,
+ title: 'Sandbox',
+ revisions: [ {
+ timestamp: '2016-01-01T12:00:00Z',
+ contentformat: 'text/x-wiki',
+ contentmodel: 'wikitext',
+ content: 'Sand.'
+ } ]
+ } ]
+ }
+ } ) );
+ }
+ if ( /edit.+basetimestamp=2016-01-01.+starttimestamp=2016-01-02.+text=Box%2E/.test( req.requestBody ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( {
+ edit: {
+ result: 'Success',
+ oldrevid: 11,
+ newrevid: 13,
+ newtimestamp: '2016-01-03T12:00:00Z'
+ }
+ } ) );
+ }
+ } );
+
+ return new mw.Api()
+ .edit( new mw.Title( 'Sandbox' ), function ( revision ) {
+ return revision.content.replace( 'Sand', 'Box' );
+ } )
+ .then( function ( edit ) {
+ assert.equal( edit.newrevid, 13 );
+ } );
+ } );
+
+ QUnit.test( 'edit( title, transform Promise )', function ( assert ) {
+ this.server.respond( function ( req ) {
+ if ( /query.+titles=Async/.test( req.url ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( {
+ curtimestamp: '2016-02-02T12:00:00Z',
+ query: {
+ pages: [ {
+ pageid: 4,
+ ns: 0,
+ title: 'Async',
+ revisions: [ {
+ timestamp: '2016-02-01T12:00:00Z',
+ contentformat: 'text/x-wiki',
+ contentmodel: 'wikitext',
+ content: 'Async.'
+ } ]
+ } ]
+ }
+ } ) );
+ }
+ if ( /edit.+basetimestamp=2016-02-01.+starttimestamp=2016-02-02.+text=Promise%2E/.test( req.requestBody ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( {
+ edit: {
+ result: 'Success',
+ oldrevid: 21,
+ newrevid: 23,
+ newtimestamp: '2016-02-03T12:00:00Z'
+ }
+ } ) );
+ }
+ } );
+
+ return new mw.Api()
+ .edit( 'Async', function ( revision ) {
+ return $.Deferred().resolve( revision.content.replace( 'Async', 'Promise' ) );
+ } )
+ .then( function ( edit ) {
+ assert.equal( edit.newrevid, 23 );
+ } );
+ } );
+
+ QUnit.test( 'edit( title, transform Object )', function ( assert ) {
+ this.server.respond( function ( req ) {
+ if ( /query.+titles=Param/.test( req.url ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( {
+ curtimestamp: '2016-03-02T12:00:00Z',
+ query: {
+ pages: [ {
+ pageid: 3,
+ ns: 0,
+ title: 'Param',
+ revisions: [ {
+ timestamp: '2016-03-01T12:00:00Z',
+ contentformat: 'text/x-wiki',
+ contentmodel: 'wikitext',
+ content: '...'
+ } ]
+ } ]
+ }
+ } ) );
+ }
+ if ( /edit.+basetimestamp=2016-03-01.+starttimestamp=2016-03-02.+text=Content&summary=Sum/.test( req.requestBody ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( {
+ edit: {
+ result: 'Success',
+ oldrevid: 31,
+ newrevid: 33,
+ newtimestamp: '2016-03-03T12:00:00Z'
+ }
+ } ) );
+ }
+ } );
+
+ return new mw.Api()
+ .edit( 'Param', function () {
+ return { text: 'Content', summary: 'Sum' };
+ } )
+ .then( function ( edit ) {
+ assert.equal( edit.newrevid, 33 );
+ } );
+ } );
+
+ QUnit.test( 'edit( invalid-title, transform String )', function ( assert ) {
+ this.server.respond( function ( req ) {
+ if ( /query.+titles=%1F%7C/.test( req.url ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( {
+ query: {
+ pages: [ {
+ title: '|',
+ invalidreason: 'The requested page title contains invalid characters: "|".',
+ invalid: true
+ } ]
+ }
+ } ) );
+ }
+ } );
+
+ return new mw.Api()
+ .edit( '|', function ( revision ) {
+ return revision.content.replace( 'Sand', 'Box' );
+ } )
+ .then( function () {
+ return $.Deferred().reject( 'Unexpected success' );
+ }, function ( reason ) {
+ assert.equal( reason, 'invalidtitle' );
+ } );
+ } );
+
+ QUnit.test( 'create( title, content )', function ( assert ) {
+ this.server.respond( function ( req ) {
+ if ( /edit.+text=Sand/.test( req.requestBody ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( {
+ edit: {
+ new: true,
+ result: 'Success',
+ newrevid: 41,
+ newtimestamp: '2016-04-01T12:00:00Z'
+ }
+ } ) );
+ }
+ } );
+
+ return new mw.Api()
+ .create( 'Sandbox', { summary: 'Load sand particles.' }, 'Sand.' )
+ .then( function ( page ) {
+ assert.equal( page.newrevid, 41 );
+ } );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js
new file mode 100644
index 00000000..7282b3fb
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js
@@ -0,0 +1,29 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.api.messages', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.server = this.sandbox.useFakeServer();
+ this.server.respondImmediately = true;
+ }
+ } ) );
+
+ QUnit.test( '.getMessages()', function ( assert ) {
+ this.server.respondWith( /ammessages=foo%7Cbaz/, [
+ 200,
+ { 'Content-Type': 'application/json' },
+ '{ "query": { "allmessages": [' +
+ '{ "name": "foo", "content": "Foo bar" },' +
+ '{ "name": "baz", "content": "Baz Quux" }' +
+ '] } }'
+ ] );
+
+ return new mw.Api().getMessages( [ 'foo', 'baz' ] ).then( function ( messages ) {
+ assert.deepEqual(
+ messages,
+ {
+ foo: 'Foo bar',
+ baz: 'Baz Quux'
+ }
+ );
+ } );
+ } );
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js
new file mode 100644
index 00000000..997a42c8
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js
@@ -0,0 +1,141 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.api.options', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.server = this.sandbox.useFakeServer();
+ this.server.respondImmediately = true;
+ }
+ } ) );
+
+ QUnit.test( 'saveOption', function ( assert ) {
+ var api = new mw.Api(),
+ stub = this.sandbox.stub( mw.Api.prototype, 'saveOptions' );
+
+ api.saveOption( 'foo', 'bar' );
+
+ assert.ok( stub.calledOnce, '#saveOptions called once' );
+ assert.deepEqual( stub.getCall( 0 ).args, [ { foo: 'bar' } ], '#saveOptions called correctly' );
+ } );
+
+ QUnit.test( 'saveOptions without Unit Separator', function ( assert ) {
+ var api = new mw.Api( { useUS: false } );
+
+ // We need to respond to the request for token first, otherwise the other requests won't be sent
+ // until after the server.respond call, which confuses sinon terribly. This sucks a lot.
+ api.getToken( 'options' );
+ this.server.respond(
+ /meta=tokens&type=csrf/,
+ [ 200, { 'Content-Type': 'application/json' },
+ '{ "query": { "tokens": { "csrftoken": "+\\\\" } } }' ]
+ );
+
+ // Requests are POST, match requestBody instead of url
+ this.server.respond( function ( request ) {
+ if ( [
+ // simple
+ 'action=options&format=json&formatversion=2&change=foo%3Dbar&token=%2B%5C',
+ // two options
+ 'action=options&format=json&formatversion=2&change=foo%3Dbar%7Cbaz%3Dquux&token=%2B%5C',
+ // not bundleable
+ 'action=options&format=json&formatversion=2&optionname=foo&optionvalue=bar%7Cquux&token=%2B%5C',
+ 'action=options&format=json&formatversion=2&optionname=bar&optionvalue=a%7Cb%7Cc&token=%2B%5C',
+ 'action=options&format=json&formatversion=2&change=baz%3Dquux&token=%2B%5C',
+ // reset an option
+ 'action=options&format=json&formatversion=2&change=foo&token=%2B%5C',
+ // reset an option, not bundleable
+ 'action=options&format=json&formatversion=2&optionname=foo%7Cbar%3Dquux&token=%2B%5C'
+ ].indexOf( request.requestBody ) !== -1 ) {
+ assert.ok( true, 'Repond to ' + request.requestBody );
+ request.respond( 200, { 'Content-Type': 'application/json' },
+ '{ "options": "success" }' );
+ } else {
+ assert.ok( false, 'Unexpected request: ' + request.requestBody );
+ }
+ } );
+
+ return QUnit.whenPromisesComplete(
+ api.saveOptions( {} ).then( function () {
+ assert.ok( true, 'Request completed: empty case' );
+ } ),
+ api.saveOptions( { foo: 'bar' } ).then( function () {
+ assert.ok( true, 'Request completed: simple' );
+ } ),
+ api.saveOptions( { foo: 'bar', baz: 'quux' } ).then( function () {
+ assert.ok( true, 'Request completed: two options' );
+ } ),
+ api.saveOptions( { foo: 'bar|quux', bar: 'a|b|c', baz: 'quux' } ).then( function () {
+ assert.ok( true, 'Request completed: not bundleable' );
+ } ),
+ api.saveOptions( { foo: null } ).then( function () {
+ assert.ok( true, 'Request completed: reset an option' );
+ } ),
+ api.saveOptions( { 'foo|bar=quux': null } ).then( function () {
+ assert.ok( true, 'Request completed: reset an option, not bundleable' );
+ } )
+ );
+ } );
+
+ QUnit.test( 'saveOptions with Unit Separator', function ( assert ) {
+ var api = new mw.Api( { useUS: true } );
+
+ // We need to respond to the request for token first, otherwise the other requests won't be sent
+ // until after the server.respond call, which confuses sinon terribly. This sucks a lot.
+ api.getToken( 'options' );
+ this.server.respond(
+ /meta=tokens&type=csrf/,
+ [ 200, { 'Content-Type': 'application/json' },
+ '{ "query": { "tokens": { "csrftoken": "+\\\\" } } }' ]
+ );
+
+ // Requests are POST, match requestBody instead of url
+ this.server.respond( function ( request ) {
+ if ( [
+ // simple
+ 'action=options&format=json&formatversion=2&change=foo%3Dbar&token=%2B%5C',
+ // two options
+ 'action=options&format=json&formatversion=2&change=foo%3Dbar%7Cbaz%3Dquux&token=%2B%5C',
+ // bundleable with unit separator
+ 'action=options&format=json&formatversion=2&change=%1Ffoo%3Dbar%7Cquux%1Fbar%3Da%7Cb%7Cc%1Fbaz%3Dquux&token=%2B%5C',
+ // not bundleable with unit separator
+ 'action=options&format=json&formatversion=2&optionname=baz%3Dbaz&optionvalue=quux&token=%2B%5C',
+ 'action=options&format=json&formatversion=2&change=%1Ffoo%3Dbar%7Cquux%1Fbar%3Da%7Cb%7Cc&token=%2B%5C',
+ // reset an option
+ 'action=options&format=json&formatversion=2&change=foo&token=%2B%5C',
+ // reset an option, not bundleable
+ 'action=options&format=json&formatversion=2&optionname=foo%7Cbar%3Dquux&token=%2B%5C'
+ ].indexOf( request.requestBody ) !== -1 ) {
+ assert.ok( true, 'Repond to ' + request.requestBody );
+ request.respond(
+ 200,
+ { 'Content-Type': 'application/json' },
+ '{ "options": "success" }'
+ );
+ } else {
+ assert.ok( false, 'Unexpected request: ' + request.requestBody );
+ }
+ } );
+
+ return QUnit.whenPromisesComplete(
+ api.saveOptions( {} ).done( function () {
+ assert.ok( true, 'Request completed: empty case' );
+ } ),
+ api.saveOptions( { foo: 'bar' } ).done( function () {
+ assert.ok( true, 'Request completed: simple' );
+ } ),
+ api.saveOptions( { foo: 'bar', baz: 'quux' } ).done( function () {
+ assert.ok( true, 'Request completed: two options' );
+ } ),
+ api.saveOptions( { foo: 'bar|quux', bar: 'a|b|c', baz: 'quux' } ).done( function () {
+ assert.ok( true, 'Request completed: bundleable with unit separator' );
+ } ),
+ api.saveOptions( { foo: 'bar|quux', bar: 'a|b|c', 'baz=baz': 'quux' } ).done( function () {
+ assert.ok( true, 'Request completed: not bundleable with unit separator' );
+ } ),
+ api.saveOptions( { foo: null } ).done( function () {
+ assert.ok( true, 'Request completed: reset an option' );
+ } ),
+ api.saveOptions( { 'foo|bar=quux': null } ).done( function () {
+ assert.ok( true, 'Request completed: reset an option, not bundleable' );
+ } )
+ );
+ } );
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js
new file mode 100644
index 00000000..74da0098
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js
@@ -0,0 +1,45 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.api.parse', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.server = this.sandbox.useFakeServer();
+ this.server.respondImmediately = true;
+ }
+ } ) );
+
+ QUnit.test( '.parse( string )', function ( assert ) {
+ this.server.respondWith( /action=parse.*&text='''Hello(\+|%20)world'''/, [ 200,
+ { 'Content-Type': 'application/json' },
+ '{ "parse": { "text": "<p><b>Hello world</b></p>" } }'
+ ] );
+
+ return new mw.Api().parse( '\'\'\'Hello world\'\'\'' ).done( function ( html ) {
+ assert.equal( html, '<p><b>Hello world</b></p>', 'Parse wikitext by string' );
+ } );
+ } );
+
+ QUnit.test( '.parse( Object.toString )', function ( assert ) {
+ this.server.respondWith( /action=parse.*&text='''Hello(\+|%20)world'''/, [ 200,
+ { 'Content-Type': 'application/json' },
+ '{ "parse": { "text": "<p><b>Hello world</b></p>" } }'
+ ] );
+
+ return new mw.Api().parse( {
+ toString: function () {
+ return '\'\'\'Hello world\'\'\'';
+ }
+ } ).done( function ( html ) {
+ assert.equal( html, '<p><b>Hello world</b></p>', 'Parse wikitext by toString object' );
+ } );
+ } );
+
+ QUnit.test( '.parse( mw.Title )', function ( assert ) {
+ this.server.respondWith( /action=parse.*&page=Earth/, [ 200,
+ { 'Content-Type': 'application/json' },
+ '{ "parse": { "text": "<p><b>Earth</b> is a planet.</p>" } }'
+ ] );
+
+ return new mw.Api().parse( new mw.Title( 'Earth' ) ).done( function ( html ) {
+ assert.equal( html, '<p><b>Earth</b> is a planet.</p>', 'Parse page by Title object' );
+ } );
+ } );
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js
new file mode 100644
index 00000000..417ad3d8
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js
@@ -0,0 +1,456 @@
+( function ( mw, $ ) {
+ QUnit.module( 'mediawiki.api', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.server = this.sandbox.useFakeServer();
+ this.server.respondImmediately = true;
+ }
+ } ) );
+
+ function sequence( responses ) {
+ var i = 0;
+ return function ( request ) {
+ var response = responses[ i ];
+ if ( response ) {
+ i++;
+ request.respond.apply( request, response );
+ }
+ };
+ }
+
+ function sequenceBodies( status, headers, bodies ) {
+ bodies.forEach( function ( body, i ) {
+ bodies[ i ] = [ status, headers, body ];
+ } );
+ return sequence( bodies );
+ }
+
+ // Utility to make inline use with an assert easier
+ function match( text, pattern ) {
+ var m = text.match( pattern );
+ return m && m[ 1 ] || null;
+ }
+
+ QUnit.test( 'get()', function ( assert ) {
+ var api = new mw.Api();
+
+ this.server.respond( [ 200, { 'Content-Type': 'application/json' }, '[]' ] );
+
+ return api.get( {} ).then( function ( data ) {
+ assert.deepEqual( data, [], 'If request succeeds without errors, resolve deferred' );
+ } );
+ } );
+
+ QUnit.test( 'post()', function ( assert ) {
+ var api = new mw.Api();
+
+ this.server.respond( [ 200, { 'Content-Type': 'application/json' }, '[]' ] );
+
+ return api.post( {} ).then( function ( data ) {
+ assert.deepEqual( data, [], 'Simple POST request' );
+ } );
+ } );
+
+ QUnit.test( 'API error errorformat=bc', function ( assert ) {
+ var api = new mw.Api();
+
+ this.server.respond( [ 200, { 'Content-Type': 'application/json' },
+ '{ "error": { "code": "unknown_action" } }'
+ ] );
+
+ api.get( { action: 'doesntexist' } )
+ .fail( function ( errorCode ) {
+ assert.equal( errorCode, 'unknown_action', 'API error should reject the deferred' );
+ } )
+ .always( assert.async() );
+ } );
+
+ QUnit.test( 'API error errorformat!=bc', function ( assert ) {
+ var api = new mw.Api();
+
+ this.server.respond( [ 200, { 'Content-Type': 'application/json' },
+ '{ "errors": [ { "code": "unknown_action", "key": "unknown-error", "params": [] } ] }'
+ ] );
+
+ api.get( { action: 'doesntexist' } )
+ .fail( function ( errorCode ) {
+ assert.equal( errorCode, 'unknown_action', 'API error should reject the deferred' );
+ } )
+ .always( assert.async() );
+ } );
+
+ QUnit.test( 'FormData support', function ( assert ) {
+ var api = new mw.Api();
+
+ this.server.respond( function ( request ) {
+ if ( window.FormData ) {
+ assert.ok( !request.url.match( /action=/ ), 'Request has no query string' );
+ assert.ok( request.requestBody instanceof FormData, 'Request uses FormData body' );
+ } else {
+ assert.ok( !request.url.match( /action=test/ ), 'Request has no query string' );
+ assert.equal( request.requestBody, 'action=test&format=json', 'Request uses query string body' );
+ }
+ request.respond( 200, { 'Content-Type': 'application/json' }, '[]' );
+ } );
+
+ return api.post( { action: 'test' }, { contentType: 'multipart/form-data' } );
+ } );
+
+ QUnit.test( 'Converting arrays to pipe-separated (string)', function ( assert ) {
+ var api = new mw.Api();
+
+ this.server.respond( function ( request ) {
+ assert.equal( match( request.url, /test=([^&]+)/ ), 'foo%7Cbar%7Cbaz', 'Pipe-separated value was submitted' );
+ request.respond( 200, { 'Content-Type': 'application/json' }, '[]' );
+ } );
+
+ return api.get( { test: [ 'foo', 'bar', 'baz' ] } );
+ } );
+
+ QUnit.test( 'Converting arrays to pipe-separated (mw.Title)', function ( assert ) {
+ var api = new mw.Api();
+
+ this.server.respond( function ( request ) {
+ assert.equal( match( request.url, /test=([^&]+)/ ), 'Foo%7CBar', 'Pipe-separated value was submitted' );
+ request.respond( 200, { 'Content-Type': 'application/json' }, '[]' );
+ } );
+
+ return api.get( { test: [ new mw.Title( 'Foo' ), new mw.Title( 'Bar' ) ] } );
+ } );
+
+ QUnit.test( 'Converting arrays to pipe-separated (misc primitives)', function ( assert ) {
+ var api = new mw.Api();
+
+ this.server.respond( function ( request ) {
+ assert.equal( match( request.url, /test=([^&]+)/ ), 'true%7Cfalse%7C%7C%7C0%7C1%2E2', 'Pipe-separated value was submitted' );
+ request.respond( 200, { 'Content-Type': 'application/json' }, '[]' );
+ } );
+
+ // undefined/null will become empty string
+ return api.get( { test: [ true, false, undefined, null, 0, 1.2 ] } );
+ } );
+
+ QUnit.test( 'Omitting false booleans', function ( assert ) {
+ var api = new mw.Api();
+
+ this.server.respond( function ( request ) {
+ assert.ok( !request.url.match( /foo/ ), 'foo query parameter is not present' );
+ assert.ok( request.url.match( /bar=true/ ), 'bar query parameter is present with value true' );
+ request.respond( 200, { 'Content-Type': 'application/json' }, '[]' );
+ } );
+
+ return api.get( { foo: false, bar: true } );
+ } );
+
+ QUnit.test( 'getToken() - cached', function ( assert ) {
+ var api = new mw.Api(),
+ test = this;
+
+ // Get csrfToken for local wiki, this should not make
+ // a request as it should be retrieved from mw.user.tokens.
+ return api.getToken( 'csrf' )
+ .then( function ( token ) {
+ assert.ok( token.length, 'Got a token' );
+ }, function ( err ) {
+ assert.equal( '', err, 'API error' );
+ } )
+ .then( function () {
+ assert.equal( test.server.requests.length, 0, 'Requests made' );
+ } );
+ } );
+
+ QUnit.test( 'getToken() - uncached', function ( assert ) {
+ var api = new mw.Api(),
+ firstDone = assert.async(),
+ secondDone = assert.async();
+
+ this.server.respondWith( /type=testuncached/, [ 200, { 'Content-Type': 'application/json' },
+ '{ "query": { "tokens": { "testuncachedtoken": "good" } } }'
+ ] );
+
+ // Get a token of a type that isn't prepopulated by user.tokens.
+ // Could use "block" or "delete" here, but those could in theory
+ // be added to user.tokens, use a fake one instead.
+ api.getToken( 'testuncached' )
+ .done( function ( token ) {
+ assert.equal( token, 'good', 'The token' );
+ } )
+ .fail( function ( err ) {
+ assert.equal( err, '', 'API error' );
+ } )
+ .always( firstDone );
+
+ api.getToken( 'testuncached' )
+ .done( function ( token ) {
+ assert.equal( token, 'good', 'The cached token' );
+ } )
+ .fail( function ( err ) {
+ assert.equal( err, '', 'API error' );
+ } )
+ .always( secondDone );
+
+ assert.equal( this.server.requests.length, 1, 'Requests made' );
+ } );
+
+ QUnit.test( 'getToken() - error', function ( assert ) {
+ var api = new mw.Api();
+
+ this.server.respondWith( /type=testerror/, sequenceBodies( 200, { 'Content-Type': 'application/json' },
+ [
+ '{ "error": { "code": "bite-me", "info": "Smite me, O Mighty Smiter" } }',
+ '{ "query": { "tokens": { "testerrortoken": "good" } } }'
+ ]
+ ) );
+
+ // Don't cache error (T67268)
+ return api.getToken( 'testerror' )
+ .catch( function ( err ) {
+ assert.equal( err, 'bite-me', 'Expected error' );
+
+ return api.getToken( 'testerror' );
+ } )
+ .then( function ( token ) {
+ assert.equal( token, 'good', 'The token' );
+ } );
+ } );
+
+ QUnit.test( 'getToken() - deprecated', function ( assert ) {
+ // Cache API endpoint from default to avoid cachehit in mw.user.tokens
+ var api = new mw.Api( { ajax: { url: '/postWithToken/api.php' } } ),
+ test = this;
+
+ this.server.respondWith( /type=csrf/, [ 200, { 'Content-Type': 'application/json' },
+ '{ "query": { "tokens": { "csrftoken": "csrfgood" } } }'
+ ] );
+
+ // Get a token of a type that is in the legacy map.
+ return api.getToken( 'email' )
+ .done( function ( token ) {
+ assert.equal( token, 'csrfgood', 'Token' );
+ } )
+ .fail( function ( err ) {
+ assert.equal( err, '', 'API error' );
+ } )
+ .always( function () {
+ assert.equal( test.server.requests.length, 1, 'Requests made' );
+ } );
+ } );
+
+ QUnit.test( 'badToken()', function ( assert ) {
+ var api = new mw.Api(),
+ test = this;
+
+ this.server.respondWith( /type=testbad/, sequenceBodies( 200, { 'Content-Type': 'application/json' },
+ [
+ '{ "query": { "tokens": { "testbadtoken": "bad" } } }',
+ '{ "query": { "tokens": { "testbadtoken": "good" } } }'
+ ]
+ ) );
+
+ return api.getToken( 'testbad' )
+ .then( function () {
+ api.badToken( 'testbad' );
+ return api.getToken( 'testbad' );
+ } )
+ .then( function ( token ) {
+ assert.equal( token, 'good', 'The token' );
+ assert.equal( test.server.requests.length, 2, 'Requests made' );
+ } );
+
+ } );
+
+ QUnit.test( 'badToken( legacy )', function ( assert ) {
+ var api = new mw.Api( { ajax: { url: '/badTokenLegacy/api.php' } } ),
+ test = this;
+
+ this.server.respondWith( /type=csrf/, sequenceBodies( 200, { 'Content-Type': 'application/json' },
+ [
+ '{ "query": { "tokens": { "csrftoken": "badlegacy" } } }',
+ '{ "query": { "tokens": { "csrftoken": "goodlegacy" } } }'
+ ]
+ ) );
+
+ return api.getToken( 'options' )
+ .then( function () {
+ api.badToken( 'options' );
+ return api.getToken( 'options' );
+ } )
+ .then( function ( token ) {
+ assert.equal( token, 'goodlegacy', 'The token' );
+ assert.equal( test.server.requests.length, 2, 'Request made' );
+ } );
+
+ } );
+
+ QUnit.test( 'postWithToken( tokenType, params )', function ( assert ) {
+ var api = new mw.Api( { ajax: { url: '/postWithToken/api.php' } } );
+
+ this.server.respondWith( 'GET', /type=testpost/, [ 200, { 'Content-Type': 'application/json' },
+ '{ "query": { "tokens": { "testposttoken": "good" } } }'
+ ] );
+ this.server.respondWith( 'POST', /api/, function ( request ) {
+ if ( request.requestBody.match( /token=good/ ) ) {
+ request.respond( 200, { 'Content-Type': 'application/json' },
+ '{ "example": { "foo": "quux" } }'
+ );
+ }
+ } );
+
+ return api.postWithToken( 'testpost', { action: 'example', key: 'foo' } )
+ .then( function ( data ) {
+ assert.deepEqual( data, { example: { foo: 'quux' } } );
+ } );
+ } );
+
+ QUnit.test( 'postWithToken( tokenType, params with assert )', function ( assert ) {
+ var api = new mw.Api( { ajax: { url: '/postWithToken/api.php' } } ),
+ test = this;
+
+ this.server.respondWith( /assert=user/, [ 200, { 'Content-Type': 'application/json' },
+ '{ "error": { "code": "assertuserfailed", "info": "Assertion failed" } }'
+ ] );
+
+ return api.postWithToken( 'testassertpost', { action: 'example', key: 'foo', assert: 'user' } )
+ // Cast error to success and vice versa
+ .then( function () {
+ return $.Deferred().reject( 'Unexpected success' );
+ }, function ( errorCode ) {
+ assert.equal( errorCode, 'assertuserfailed', 'getToken fails assert' );
+ return $.Deferred().resolve();
+ } )
+ .then( function () {
+ assert.equal( test.server.requests.length, 1, 'Requests made' );
+ } );
+ } );
+
+ QUnit.test( 'postWithToken( tokenType, params, ajaxOptions )', function ( assert ) {
+ var api = new mw.Api(),
+ test = this;
+
+ this.server.respond( [ 200, { 'Content-Type': 'application/json' }, '{ "example": "quux" }' ] );
+
+ return api.postWithToken( 'csrf',
+ { action: 'example' },
+ {
+ headers: {
+ 'X-Foo': 'Bar'
+ }
+ }
+ ).then( function () {
+ assert.equal( test.server.requests[ 0 ].requestHeaders[ 'X-Foo' ], 'Bar', 'Header sent' );
+
+ return api.postWithToken( 'csrf',
+ { action: 'example' },
+ function () {
+ assert.ok( false, 'This parameter cannot be a callback' );
+ }
+ );
+ } ).then( function ( data ) {
+ assert.equal( data.example, 'quux' );
+
+ assert.equal( test.server.requests.length, 2, 'Request made' );
+ } );
+ } );
+
+ QUnit.test( 'postWithToken() - badtoken', function ( assert ) {
+ var api = new mw.Api();
+
+ this.server.respondWith( /type=testbadtoken/, sequenceBodies( 200, { 'Content-Type': 'application/json' },
+ [
+ '{ "query": { "tokens": { "testbadtokentoken": "bad" } } }',
+ '{ "query": { "tokens": { "testbadtokentoken": "good" } } }'
+ ]
+ ) );
+ this.server.respondWith( 'POST', /api/, function ( request ) {
+ if ( request.requestBody.match( /token=bad/ ) ) {
+ request.respond( 200, { 'Content-Type': 'application/json' },
+ '{ "error": { "code": "badtoken" } }'
+ );
+ }
+ if ( request.requestBody.match( /token=good/ ) ) {
+ request.respond( 200, { 'Content-Type': 'application/json' },
+ '{ "example": { "foo": "quux" } }'
+ );
+ }
+ } );
+
+ // - Request: new token -> bad
+ // - Request: action=example -> badtoken error
+ // - Request: new token -> good
+ // - Request: action=example -> success
+ return api.postWithToken( 'testbadtoken', { action: 'example', key: 'foo' } )
+ .then( function ( data ) {
+ assert.deepEqual( data, { example: { foo: 'quux' } } );
+ } );
+ } );
+
+ QUnit.test( 'postWithToken() - badtoken-cached', function ( assert ) {
+ var sequenceA,
+ api = new mw.Api();
+
+ this.server.respondWith( /type=testonce/, sequenceBodies( 200, { 'Content-Type': 'application/json' },
+ [
+ '{ "query": { "tokens": { "testoncetoken": "good-A" } } }',
+ '{ "query": { "tokens": { "testoncetoken": "good-B" } } }'
+ ]
+ ) );
+ sequenceA = sequenceBodies( 200, { 'Content-Type': 'application/json' },
+ [
+ '{ "example": { "value": "A" } }',
+ '{ "error": { "code": "badtoken" } }'
+ ]
+ );
+ this.server.respondWith( 'POST', /api/, function ( request ) {
+ if ( request.requestBody.match( /token=good-A/ ) ) {
+ sequenceA( request );
+ } else if ( request.requestBody.match( /token=good-B/ ) ) {
+ request.respond( 200, { 'Content-Type': 'application/json' },
+ '{ "example": { "value": "B" } }'
+ );
+ }
+ } );
+
+ // - Request: new token -> A
+ // - Request: action=example
+ return api.postWithToken( 'testonce', { action: 'example', key: 'foo' } )
+ .then( function ( data ) {
+ assert.deepEqual( data, { example: { value: 'A' } } );
+
+ // - Request: action=example w/ token A -> badtoken error
+ // - Request: new token -> B
+ // - Request: action=example w/ token B -> success
+ return api.postWithToken( 'testonce', { action: 'example', key: 'bar' } );
+ } )
+ .then( function ( data ) {
+ assert.deepEqual( data, { example: { value: 'B' } } );
+ } );
+ } );
+
+ QUnit.module( 'mediawiki.api (2)', {
+ setup: function () {
+ var self = this,
+ requests = this.requests = [];
+ this.api = new mw.Api();
+ this.sandbox.stub( jQuery, 'ajax', function () {
+ var request = $.extend( {
+ abort: self.sandbox.spy()
+ }, $.Deferred() );
+ requests.push( request );
+ return request;
+ } );
+ }
+ } );
+
+ QUnit.test( '#abort', function ( assert ) {
+ this.api.get( {
+ a: 1
+ } );
+ this.api.post( {
+ b: 2
+ } );
+ this.api.abort();
+ assert.ok( this.requests.length === 2, 'Check both requests triggered' );
+ this.requests.forEach( function ( request, i ) {
+ assert.ok( request.abort.calledOnce, 'abort request number ' + i );
+ } );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js
new file mode 100644
index 00000000..788a427e
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js
@@ -0,0 +1,33 @@
+( function ( mw, $ ) {
+ QUnit.module( 'mediawiki.api.upload', QUnit.newMwEnvironment( {} ) );
+
+ QUnit.test( 'Basic functionality', function ( assert ) {
+ var api = new mw.Api();
+ assert.ok( api.upload );
+ assert.throws( function () {
+ api.upload();
+ } );
+ } );
+
+ QUnit.test( 'Set up iframe upload', function ( assert ) {
+ var $iframe, $form, $input,
+ api = new mw.Api();
+
+ this.sandbox.stub( api, 'getEditToken', function () {
+ return $.Deferred().promise();
+ } );
+
+ api.uploadWithIframe( $( '<input>' )[ 0 ], { filename: 'Testing API upload.jpg' } );
+
+ $iframe = $( 'iframe:last-child' );
+ $form = $( 'form.mw-api-upload-form' );
+ $input = $form.find( 'input[name=filename]' );
+
+ assert.ok( $form.length > 0, 'form' );
+ assert.ok( $input.length > 0, 'input' );
+ assert.ok( $iframe.length > 0, 'frame' );
+ assert.strictEqual( $form.prop( 'target' ), $iframe.prop( 'id' ), 'form.target and frame.id ' );
+ assert.strictEqual( $input.val(), 'Testing API upload.jpg', 'input value' );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js
new file mode 100644
index 00000000..86414691
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js
@@ -0,0 +1,60 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.api.watch', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.server = this.sandbox.useFakeServer();
+ this.server.respondImmediately = true;
+ }
+ } ) );
+
+ QUnit.test( '.watch( string )', function ( assert ) {
+ this.server.respond( function ( req ) {
+ // Match POST requestBody
+ if ( /action=watch.*&titles=Foo(&|$)/.test( req.requestBody ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' },
+ '{ "watch": [ { "title": "Foo", "watched": true, "message": "<b>Added</b>" } ] }'
+ );
+ }
+ } );
+
+ return new mw.Api().watch( 'Foo' ).done( function ( item ) {
+ assert.equal( item.title, 'Foo' );
+ } );
+ } );
+
+ // Ensure we don't mistake a single item array for a single item and vice versa.
+ // The query parameter in request is the same either way (separated by pipe).
+ QUnit.test( '.watch( Array ) - single', function ( assert ) {
+ this.server.respond( function ( req ) {
+ // Match POST requestBody
+ if ( /action=watch.*&titles=Foo(&|$)/.test( req.requestBody ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' },
+ '{ "watch": [ { "title": "Foo", "watched": true, "message": "<b>Added</b>" } ] }'
+ );
+ }
+ } );
+
+ return new mw.Api().watch( [ 'Foo' ] ).done( function ( items ) {
+ assert.equal( items[ 0 ].title, 'Foo' );
+ } );
+ } );
+
+ QUnit.test( '.watch( Array ) - multi', function ( assert ) {
+ this.server.respond( function ( req ) {
+ // Match POST requestBody
+ if ( /action=watch.*&titles=Foo%7CBar/.test( req.requestBody ) ) {
+ req.respond( 200, { 'Content-Type': 'application/json' },
+ '{ "watch": [ ' +
+ '{ "title": "Foo", "watched": true, "message": "<b>Added</b>" },' +
+ '{ "title": "Bar", "watched": true, "message": "<b>Added</b>" }' +
+ '] }'
+ );
+ }
+ } );
+
+ return new mw.Api().watch( [ 'Foo', 'Bar' ] ).done( function ( items ) {
+ assert.equal( items[ 0 ].title, 'Foo' );
+ assert.equal( items[ 1 ].title, 'Bar' );
+ } );
+ } );
+
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js
new file mode 100644
index 00000000..872f4ddf
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js
@@ -0,0 +1,354 @@
+/* eslint-disable camelcase */
+/* eslint no-underscore-dangle: "off" */
+( function ( mw, $ ) {
+ var mockFilterStructure = [ {
+ name: 'group1',
+ title: 'Group 1',
+ type: 'send_unselected_if_any',
+ filters: [
+ { name: 'filter1', cssClass: 'filter1class', default: true },
+ { name: 'filter2', cssClass: 'filter2class' }
+ ]
+ }, {
+ name: 'group2',
+ title: 'Group 2',
+ type: 'send_unselected_if_any',
+ filters: [
+ { name: 'filter3', cssClass: 'filter3class' },
+ { name: 'filter4', cssClass: 'filter4class', default: true }
+ ]
+ }, {
+ name: 'group3',
+ title: 'Group 3',
+ type: 'string_options',
+ filters: [
+ { name: 'filter5', cssClass: 'filter5class' },
+ { name: 'filter6' } // Not supporting highlights
+ ]
+ }, {
+ name: 'group4',
+ title: 'Group 4',
+ type: 'boolean',
+ sticky: true,
+ filters: [
+ { name: 'stickyFilter7', cssClass: 'filter7class' },
+ { name: 'stickyFilter8', cssClass: 'filter8class' }
+ ]
+ } ],
+ minimalDefaultParams = {
+ filter1: '1',
+ filter4: '1'
+ };
+
+ QUnit.module( 'mediawiki.rcfilters - UriProcessor' );
+
+ QUnit.test( 'getVersion', function ( assert ) {
+ var uriProcessor = new mw.rcfilters.UriProcessor( new mw.rcfilters.dm.FiltersViewModel() );
+
+ assert.ok(
+ uriProcessor.getVersion( { param1: 'foo', urlversion: '2' } ),
+ 2,
+ 'Retrieving the version from the URI query'
+ );
+
+ assert.ok(
+ uriProcessor.getVersion( { param1: 'foo' } ),
+ 1,
+ 'Getting version 1 if no version is specified'
+ );
+ } );
+
+ QUnit.test( 'getUpdatedUri', function ( assert ) {
+ var uriProcessor,
+ filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+ makeUri = function ( queryParams ) {
+ var uri = new mw.Uri( 'http://server/wiki/Special:RC' );
+ uri.query = queryParams;
+ return uri;
+ };
+
+ filtersModel.initializeFilters( mockFilterStructure );
+ uriProcessor = new mw.rcfilters.UriProcessor( filtersModel );
+
+ assert.deepEqual(
+ ( uriProcessor.getUpdatedUri( makeUri( {} ) ) ).query,
+ { urlversion: '2' },
+ 'Empty model state with empty uri state, assumes the given uri is already normalized, and adds urlversion=2'
+ );
+
+ assert.deepEqual(
+ ( uriProcessor.getUpdatedUri( makeUri( { foo: 'bar' } ) ) ).query,
+ { urlversion: '2', foo: 'bar' },
+ 'Empty model state with unrecognized params retains unrecognized params'
+ );
+
+ // Update the model
+ filtersModel.toggleFiltersSelected( {
+ group1__filter1: true, // Param: filter2: '1'
+ group3__filter5: true // Param: group3: 'filter5'
+ } );
+
+ assert.deepEqual(
+ ( uriProcessor.getUpdatedUri( makeUri( {} ) ) ).query,
+ { urlversion: '2', filter2: '1', group3: 'filter5' },
+ 'Model state is reflected in the updated URI'
+ );
+
+ assert.deepEqual(
+ ( uriProcessor.getUpdatedUri( makeUri( { foo: 'bar' } ) ) ).query,
+ { urlversion: '2', filter2: '1', group3: 'filter5', foo: 'bar' },
+ 'Model state is reflected in the updated URI with existing uri params'
+ );
+ } );
+
+ QUnit.test( 'updateModelBasedOnQuery', function ( assert ) {
+ var uriProcessor,
+ filtersModel = new mw.rcfilters.dm.FiltersViewModel();
+
+ filtersModel.initializeFilters( mockFilterStructure );
+ uriProcessor = new mw.rcfilters.UriProcessor( filtersModel );
+
+ uriProcessor.updateModelBasedOnQuery( {} );
+ assert.deepEqual(
+ filtersModel.getCurrentParameterState(),
+ minimalDefaultParams,
+ 'Version 1: Empty url query sets model to defaults'
+ );
+
+ uriProcessor.updateModelBasedOnQuery( { urlversion: '2' } );
+ assert.deepEqual(
+ filtersModel.getCurrentParameterState(),
+ {},
+ 'Version 2: Empty url query sets model to all-false'
+ );
+
+ uriProcessor.updateModelBasedOnQuery( { filter1: '1', urlversion: '2' } );
+ assert.deepEqual(
+ filtersModel.getCurrentParameterState(),
+ $.extend( true, {}, { filter1: '1' } ),
+ 'Parameters in Uri query set parameter value in the model'
+ );
+ } );
+
+ QUnit.test( 'isNewState', function ( assert ) {
+ var uriProcessor,
+ filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+ cases = [
+ {
+ states: {
+ curr: {},
+ new: {}
+ },
+ result: false,
+ message: 'Empty objects are not new state.'
+ },
+ {
+ states: {
+ curr: { filter1: '1' },
+ new: { filter1: '0' }
+ },
+ result: true,
+ message: 'Nulified parameter is a new state'
+ },
+ {
+ states: {
+ curr: { filter1: '1' },
+ new: { filter1: '1', filter2: '1' }
+ },
+ result: true,
+ message: 'Added parameters are a new state'
+ },
+ {
+ states: {
+ curr: { filter1: '1' },
+ new: { filter1: '1', filter2: '0' }
+ },
+ result: false,
+ message: 'Added null parameters are not a new state (normalizing equals old state)'
+ },
+ {
+ states: {
+ curr: { filter1: '1' },
+ new: { filter1: '1', foo: 'bar' }
+ },
+ result: true,
+ message: 'Added unrecognized parameters are a new state'
+ },
+ {
+ states: {
+ curr: { filter1: '1', foo: 'bar' },
+ new: { filter1: '1', foo: 'baz' }
+ },
+ result: true,
+ message: 'Changed unrecognized parameters are a new state'
+ }
+ ];
+
+ filtersModel.initializeFilters( mockFilterStructure );
+ uriProcessor = new mw.rcfilters.UriProcessor( filtersModel );
+
+ cases.forEach( function ( testCase ) {
+ assert.equal(
+ uriProcessor.isNewState( testCase.states.curr, testCase.states.new ),
+ testCase.result,
+ testCase.message
+ );
+ } );
+ } );
+
+ QUnit.test( 'doesQueryContainRecognizedParams', function ( assert ) {
+ var uriProcessor,
+ filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+ cases = [
+ {
+ query: {},
+ result: false,
+ message: 'Empty query is not valid for load.'
+ },
+ {
+ query: { highlight: '1' },
+ result: false,
+ message: 'Highlight state alone is not valid for load'
+ },
+ {
+ query: { urlversion: '2' },
+ result: true,
+ message: 'urlversion=2 state alone is valid for load as an empty state'
+ },
+ {
+ query: { filter1: '1', foo: 'bar' },
+ result: true,
+ message: 'Existence of recognized parameters makes the query valid for load'
+ },
+ {
+ query: { foo: 'bar', debug: true },
+ result: false,
+ message: 'Only unrecognized parameters makes the query invalid for load'
+ }
+ ];
+
+ filtersModel.initializeFilters( mockFilterStructure );
+ uriProcessor = new mw.rcfilters.UriProcessor( filtersModel );
+
+ cases.forEach( function ( testCase ) {
+ assert.equal(
+ uriProcessor.doesQueryContainRecognizedParams( testCase.query ),
+ testCase.result,
+ testCase.message
+ );
+ } );
+ } );
+
+ QUnit.test( '_getNormalizedQueryParams', function ( assert ) {
+ var uriProcessor,
+ filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+ cases = [
+ {
+ query: {},
+ result: $.extend( true, { urlversion: '2' }, minimalDefaultParams ),
+ message: 'Empty query returns defaults (urlversion 1).'
+ },
+ {
+ query: { urlversion: '2' },
+ result: { urlversion: '2' },
+ message: 'Empty query returns empty (urlversion 2)'
+ },
+ {
+ query: { filter1: '0' },
+ result: { urlversion: '2', filter4: '1' },
+ message: 'urlversion 1 returns query that overrides defaults'
+ },
+ {
+ query: { filter3: '1' },
+ result: { urlversion: '2', filter1: '1', filter4: '1', filter3: '1' },
+ message: 'urlversion 1 with an extra param value returns query that is joined with defaults'
+ }
+ ];
+
+ filtersModel.initializeFilters( mockFilterStructure );
+ uriProcessor = new mw.rcfilters.UriProcessor( filtersModel );
+
+ cases.forEach( function ( testCase ) {
+ assert.deepEqual(
+ uriProcessor._getNormalizedQueryParams( testCase.query ),
+ testCase.result,
+ testCase.message
+ );
+ } );
+ } );
+
+ QUnit.test( '_normalizeTargetInUri', function ( assert ) {
+ var cases = [
+ {
+ input: 'http://host/wiki/Special:RecentChangesLinked/Moai',
+ output: 'http://host/wiki/Special:RecentChangesLinked?target=Moai',
+ message: 'Target as subpage in path'
+ },
+ {
+ input: 'http://host/wiki/Special:RecentChangesLinked/Château',
+ output: 'http://host/wiki/Special:RecentChangesLinked?target=Château',
+ message: 'Target as subpage in path with special characters'
+ },
+ {
+ input: 'http://host/wiki/Special:RecentChangesLinked/Moai/Sub1',
+ output: 'http://host/wiki/Special:RecentChangesLinked?target=Moai/Sub1',
+ message: 'Target as subpage also has a subpage'
+ },
+ {
+ input: 'http://host/wiki/Special:RecentChangesLinked/Category:Foo',
+ output: 'http://host/wiki/Special:RecentChangesLinked?target=Category:Foo',
+ message: 'Target as subpage in path (with namespace)'
+ },
+ {
+ input: 'http://host/wiki/Special:RecentChangesLinked/Category:Foo/Bar',
+ output: 'http://host/wiki/Special:RecentChangesLinked?target=Category:Foo/Bar',
+ message: 'Target as subpage in path also has a subpage (with namespace)'
+ },
+ {
+ input: 'http://host/w/index.php?title=Special:RecentChangesLinked/Moai',
+ output: 'http://host/w/index.php?title=Special:RecentChangesLinked&target=Moai',
+ message: 'Target as subpage in title param'
+ },
+ {
+ input: 'http://host/w/index.php?title=Special:RecentChangesLinked/Moai/Sub1',
+ output: 'http://host/w/index.php?title=Special:RecentChangesLinked&target=Moai/Sub1',
+ message: 'Target as subpage in title param also has a subpage'
+ },
+ {
+ input: 'http://host/w/index.php?title=Special:RecentChangesLinked/Category:Foo/Bar',
+ output: 'http://host/w/index.php?title=Special:RecentChangesLinked&target=Category:Foo/Bar',
+ message: 'Target as subpage in title param also has a subpage (with namespace)'
+ },
+ {
+ input: 'http://host/wiki/Special:Watchlist',
+ output: 'http://host/wiki/Special:Watchlist',
+ message: 'No target specified'
+ },
+ {
+ normalizeTarget: false,
+ input: 'http://host/wiki/Special:RecentChanges/Foo',
+ output: 'http://host/wiki/Special:RecentChanges/Foo',
+ message: 'Do not normalize if "normalizeTarget" is false.'
+ }
+ ];
+
+ cases.forEach( function ( testCase ) {
+ var uriProcessor = new mw.rcfilters.UriProcessor(
+ null,
+ {
+ normalizeTarget: testCase.normalizeTarget === undefined ?
+ true : testCase.normalizeTarget
+ }
+ );
+
+ assert.equal(
+ uriProcessor._normalizeTargetInUri(
+ new mw.Uri( testCase.input )
+ ).toString(),
+ new mw.Uri( testCase.output ).toString(),
+ testCase.message
+ );
+ } );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js
new file mode 100644
index 00000000..18a2c9ce
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js
@@ -0,0 +1,205 @@
+/* eslint-disable camelcase */
+( function ( mw ) {
+ QUnit.module( 'mediawiki.rcfilters - FilterItem' );
+
+ QUnit.test( 'Initializing filter item', function ( assert ) {
+ var item,
+ group1 = new mw.rcfilters.dm.FilterGroup( 'group1' ),
+ group2 = new mw.rcfilters.dm.FilterGroup( 'group2' );
+
+ item = new mw.rcfilters.dm.FilterItem( 'filter1', group1 );
+ assert.equal(
+ item.getName(),
+ 'group1__filter1',
+ 'Filter name is retained.'
+ );
+ assert.equal(
+ item.getGroupName(),
+ 'group1',
+ 'Group name is retained.'
+ );
+
+ item = new mw.rcfilters.dm.FilterItem(
+ 'filter1',
+ group1,
+ {
+ label: 'test label',
+ description: 'test description'
+ }
+ );
+ assert.equal(
+ item.getLabel(),
+ 'test label',
+ 'Label information is retained.'
+ );
+ assert.equal(
+ item.getLabel(),
+ 'test label',
+ 'Description information is retained.'
+ );
+
+ item = new mw.rcfilters.dm.FilterItem(
+ 'filter1',
+ group1,
+ {
+ selected: true
+ }
+ );
+ assert.equal(
+ item.isSelected(),
+ true,
+ 'Item can be selected in the config.'
+ );
+ item.toggleSelected( true );
+ assert.equal(
+ item.isSelected(),
+ true,
+ 'Item can toggle its selected state.'
+ );
+
+ // Subsets
+ item = new mw.rcfilters.dm.FilterItem(
+ 'filter1',
+ group1,
+ {
+ subset: [ 'sub1', 'sub2', 'sub3' ]
+ }
+ );
+ assert.deepEqual(
+ item.getSubset(),
+ [ 'sub1', 'sub2', 'sub3' ],
+ 'Subset information is retained.'
+ );
+ assert.equal(
+ item.existsInSubset( 'sub1' ),
+ true,
+ 'Specific item exists in subset.'
+ );
+ assert.equal(
+ item.existsInSubset( 'sub10' ),
+ false,
+ 'Specific item does not exists in subset.'
+ );
+ assert.equal(
+ item.isIncluded(),
+ false,
+ 'Initial state of "included" is false.'
+ );
+
+ item.toggleIncluded( true );
+ assert.equal(
+ item.isIncluded(),
+ true,
+ 'Item toggles its included state.'
+ );
+
+ // Conflicts
+ item = new mw.rcfilters.dm.FilterItem(
+ 'filter1',
+ group1,
+ {
+ conflicts: {
+ group2__conflict1: { group: 'group2', filter: 'group2__conflict1' },
+ group2__conflict2: { group: 'group2', filter: 'group2__conflict2' },
+ group2__conflict3: { group: 'group2', filter: 'group2__conflict3' }
+ }
+ }
+ );
+ assert.deepEqual(
+ item.getConflicts(),
+ {
+ group2__conflict1: { group: 'group2', filter: 'group2__conflict1' },
+ group2__conflict2: { group: 'group2', filter: 'group2__conflict2' },
+ group2__conflict3: { group: 'group2', filter: 'group2__conflict3' }
+ },
+ 'Conflict information is retained.'
+ );
+ assert.equal(
+ item.existsInConflicts( new mw.rcfilters.dm.FilterItem( 'conflict1', group2 ) ),
+ true,
+ 'Specific item exists in conflicts.'
+ );
+ assert.equal(
+ item.existsInConflicts( new mw.rcfilters.dm.FilterItem( 'conflict10', group1 ) ),
+ false,
+ 'Specific item does not exists in conflicts.'
+ );
+ assert.equal(
+ item.isConflicted(),
+ false,
+ 'Initial state of "conflicted" is false.'
+ );
+
+ item.toggleConflicted( true );
+ assert.equal(
+ item.isConflicted(),
+ true,
+ 'Item toggles its conflicted state.'
+ );
+
+ // Fully covered
+ item = new mw.rcfilters.dm.FilterItem( 'filter1', group1 );
+ assert.equal(
+ item.isFullyCovered(),
+ false,
+ 'Initial state of "full coverage" is false.'
+ );
+ item.toggleFullyCovered( true );
+ assert.equal(
+ item.isFullyCovered(),
+ true,
+ 'Item toggles its fully coverage state.'
+ );
+
+ } );
+
+ QUnit.test( 'Emitting events', function ( assert ) {
+ var group1 = new mw.rcfilters.dm.FilterGroup( 'group1' ),
+ item = new mw.rcfilters.dm.FilterItem( 'filter1', group1 ),
+ events = [];
+
+ // Listen to update events
+ item.on( 'update', function () {
+ events.push( item.getState() );
+ } );
+
+ // Do stuff
+ item.toggleSelected( true ); // { selected: true, included: false, conflicted: false, fullyCovered: false }
+ item.toggleSelected( true ); // No event (duplicate state)
+ item.toggleIncluded( true ); // { selected: true, included: true, conflicted: false, fullyCovered: false }
+ item.toggleConflicted( true ); // { selected: true, included: true, conflicted: true, fullyCovered: false }
+ item.toggleFullyCovered( true ); // { selected: true, included: true, conflicted: true, fullyCovered: true }
+ item.toggleSelected(); // { selected: false, included: true, conflicted: true, fullyCovered: true }
+
+ // Check emitted events
+ assert.deepEqual(
+ events,
+ [
+ { selected: true, included: false, conflicted: false, fullyCovered: false },
+ { selected: true, included: true, conflicted: false, fullyCovered: false },
+ { selected: true, included: true, conflicted: true, fullyCovered: false },
+ { selected: true, included: true, conflicted: true, fullyCovered: true },
+ { selected: false, included: true, conflicted: true, fullyCovered: true }
+ ],
+ 'Events emitted successfully.'
+ );
+ } );
+
+ QUnit.test( 'get/set boolean value', function ( assert ) {
+ var group = new mw.rcfilters.dm.FilterGroup( 'group1', { type: 'boolean' } ),
+ item = new mw.rcfilters.dm.FilterItem( 'filter1', group );
+
+ item.setValue( '1' );
+
+ assert.equal( item.getValue(), true, 'Value is coerced to boolean' );
+ } );
+
+ QUnit.test( 'get/set any value', function ( assert ) {
+ var group = new mw.rcfilters.dm.FilterGroup( 'group1', { type: 'any_value' } ),
+ item = new mw.rcfilters.dm.FilterItem( 'filter1', group );
+
+ item.setValue( '1' );
+
+ assert.equal( item.getValue(), '1', 'Value is kept as-is' );
+ } );
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js
new file mode 100644
index 00000000..2b42b5ab
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js
@@ -0,0 +1,1562 @@
+/* eslint-disable camelcase */
+( function ( mw, $ ) {
+ var filterDefinition = [ {
+ name: 'group1',
+ type: 'send_unselected_if_any',
+ filters: [
+ {
+ name: 'filter1', label: 'group1filter1-label', description: 'group1filter1-desc',
+ default: true,
+ cssClass: 'filter1class',
+ conflicts: [ { group: 'group2' } ],
+ subset: [
+ {
+ group: 'group1',
+ filter: 'filter2'
+ },
+ {
+ group: 'group1',
+ filter: 'filter3'
+ }
+ ]
+ },
+ {
+ name: 'filter2', label: 'group1filter2-label', description: 'group1filter2-desc',
+ conflicts: [ { group: 'group2', filter: 'filter6' } ],
+ cssClass: 'filter2class',
+ subset: [
+ {
+ group: 'group1',
+ filter: 'filter3'
+ }
+ ]
+ },
+ // NOTE: This filter has no highlight!
+ { name: 'filter3', label: 'group1filter3-label', description: 'group1filter3-desc', default: true }
+ ]
+ }, {
+ name: 'group2',
+ type: 'send_unselected_if_any',
+ fullCoverage: true,
+ conflicts: [ { group: 'group1', filter: 'filter1' } ],
+ filters: [
+ { name: 'filter4', label: 'group2filter4-label', description: 'group2filter4-desc', cssClass: 'filter4class' },
+ { name: 'filter5', label: 'group2filter5-label', description: 'group2filter5-desc', default: true, cssClass: 'filter5class' },
+ {
+ name: 'filter6', label: 'group2filter6-label', description: 'group2filter6-desc', cssClass: 'filter6class',
+ conflicts: [ { group: 'group1', filter: 'filter2' } ]
+ }
+ ]
+ }, {
+ name: 'group3',
+ type: 'string_options',
+ separator: ',',
+ default: 'filter8',
+ filters: [
+ { name: 'filter7', label: 'group3filter7-label', description: 'group3filter7-desc', cssClass: 'filter7class' },
+ { name: 'filter8', label: 'group3filter8-label', description: 'group3filter8-desc', cssClass: 'filter8class' },
+ { name: 'filter9', label: 'group3filter9-label', description: 'group3filter9-desc', cssClass: 'filter9class' }
+ ]
+ }, {
+ name: 'group4',
+ type: 'single_option',
+ hidden: true,
+ default: 'option2',
+ filters: [
+ // NOTE: The entire group has no highlight supported
+ { name: 'option1', label: 'group4option1-label', description: 'group4option1-desc' },
+ { name: 'option2', label: 'group4option2-label', description: 'group4option2-desc' },
+ { name: 'option3', label: 'group4option3-label', description: 'group4option3-desc' }
+ ]
+ }, {
+ name: 'group5',
+ type: 'single_option',
+ filters: [
+ { name: 'option1', label: 'group5option1-label', description: 'group5option1-desc', cssClass: 'group5opt1class' },
+ { name: 'option2', label: 'group5option2-label', description: 'group5option2-desc', cssClass: 'group5opt2class' },
+ { name: 'option3', label: 'group5option3-label', description: 'group5option3-desc', cssClass: 'group5opt3class' }
+ ]
+ }, {
+ name: 'group6',
+ type: 'boolean',
+ sticky: true,
+ filters: [
+ { name: 'group6option1', label: 'group6option1-label', description: 'group6option1-desc', cssClass: 'group6opt1class' },
+ { name: 'group6option2', label: 'group6option2-label', description: 'group6option2-desc', default: true, cssClass: 'group6opt2class' },
+ { name: 'group6option3', label: 'group6option3-label', description: 'group6option3-desc', default: true, cssClass: 'group6opt3class' }
+ ]
+ }, {
+ name: 'group7',
+ type: 'single_option',
+ sticky: true,
+ default: 'group7option2',
+ filters: [
+ { name: 'group7option1', label: 'group7option1-label', description: 'group7option1-desc', cssClass: 'group7opt1class' },
+ { name: 'group7option2', label: 'group7option2-label', description: 'group7option2-desc', cssClass: 'group7opt2class' },
+ { name: 'group7option3', label: 'group7option3-label', description: 'group7option3-desc', cssClass: 'group7opt3class' }
+ ]
+ } ],
+ shortFilterDefinition = [ {
+ name: 'group1',
+ type: 'send_unselected_if_any',
+ filters: [ { name: 'filter1' }, { name: 'filter2' } ]
+ }, {
+ name: 'group2',
+ type: 'boolean',
+ hidden: true,
+ filters: [ { name: 'filter3' }, { name: 'filter4' } ]
+ }, {
+ name: 'group3',
+ type: 'string_options',
+ sticky: true,
+ default: 'filter6',
+ filters: [ { name: 'filter5' }, { name: 'filter6' }, { name: 'filter7' } ]
+ } ],
+ viewsDefinition = {
+ namespaces: {
+ label: 'Namespaces',
+ trigger: ':',
+ groups: [ {
+ name: 'namespace',
+ label: 'Namespaces',
+ type: 'string_options',
+ separator: ';',
+ filters: [
+ { name: 0, label: 'Main', cssClass: 'namespace-0' },
+ { name: 1, label: 'Talk', cssClass: 'namespace-1' },
+ { name: 2, label: 'User', cssClass: 'namespace-2' },
+ { name: 3, label: 'User talk', cssClass: 'namespace-3' }
+ ]
+ } ]
+ }
+ },
+ defaultParameters = {
+ filter1: '1',
+ filter2: '0',
+ filter3: '1',
+ filter4: '0',
+ filter5: '1',
+ filter6: '0',
+ group3: 'filter8',
+ group4: 'option2',
+ group5: 'option1',
+ namespace: ''
+ },
+ baseParamRepresentation = {
+ filter1: '0',
+ filter2: '0',
+ filter3: '0',
+ filter4: '0',
+ filter5: '0',
+ filter6: '0',
+ group3: '',
+ group4: 'option2',
+ group5: 'option1',
+ group6option1: '0',
+ group6option2: '1',
+ group6option3: '1',
+ group7: 'group7option2',
+ namespace: ''
+ },
+ emptyParamRepresentation = {
+ filter1: '0',
+ filter2: '0',
+ filter3: '0',
+ filter4: '0',
+ filter5: '0',
+ filter6: '0',
+ group3: '',
+ group4: '',
+ group5: '',
+ group6option1: '0',
+ group6option2: '0',
+ group6option3: '0',
+ group7: '',
+ namespace: '',
+ // Null highlights
+ group1__filter1_color: null,
+ group1__filter2_color: null,
+ // group1__filter3_color: null, // Highlight isn't supported
+ group2__filter4_color: null,
+ group2__filter5_color: null,
+ group2__filter6_color: null,
+ group3__filter7_color: null,
+ group3__filter8_color: null,
+ group3__filter9_color: null,
+ // group4__option1_color: null, // Highlight isn't supported
+ // group4__option2_color: null, // Highlight isn't supported
+ // group4__option3_color: null, // Highlight isn't supported
+ group5__option1_color: null,
+ group5__option2_color: null,
+ group5__option3_color: null,
+ group6__group6option1_color: null,
+ group6__group6option2_color: null,
+ group6__group6option3_color: null,
+ group7__group7option1_color: null,
+ group7__group7option2_color: null,
+ group7__group7option3_color: null,
+ namespace__0_color: null,
+ namespace__1_color: null,
+ namespace__2_color: null,
+ namespace__3_color: null
+ },
+ baseFilterRepresentation = {
+ group1__filter1: false,
+ group1__filter2: false,
+ group1__filter3: false,
+ group2__filter4: false,
+ group2__filter5: false,
+ group2__filter6: false,
+ group3__filter7: false,
+ group3__filter8: false,
+ group3__filter9: false,
+ // The 'single_value' type of group can't have empty value; it's either
+ // the default given or the first item that will get the truthy value
+ group4__option1: false,
+ group4__option2: true, // Default
+ group4__option3: false,
+ group5__option1: true, // No default set, first item is default value
+ group5__option2: false,
+ group5__option3: false,
+ group6__group6option1: false,
+ group6__group6option2: true,
+ group6__group6option3: true,
+ group7__group7option1: false,
+ group7__group7option2: true,
+ group7__group7option3: false,
+ namespace__0: false,
+ namespace__1: false,
+ namespace__2: false,
+ namespace__3: false
+ },
+ baseFullFilterState = {
+ group1__filter1: { selected: false, conflicted: false, included: false },
+ group1__filter2: { selected: false, conflicted: false, included: false },
+ group1__filter3: { selected: false, conflicted: false, included: false },
+ group2__filter4: { selected: false, conflicted: false, included: false },
+ group2__filter5: { selected: false, conflicted: false, included: false },
+ group2__filter6: { selected: false, conflicted: false, included: false },
+ group3__filter7: { selected: false, conflicted: false, included: false },
+ group3__filter8: { selected: false, conflicted: false, included: false },
+ group3__filter9: { selected: false, conflicted: false, included: false },
+ group4__option1: { selected: false, conflicted: false, included: false },
+ group4__option2: { selected: true, conflicted: false, included: false },
+ group4__option3: { selected: false, conflicted: false, included: false },
+ group5__option1: { selected: true, conflicted: false, included: false },
+ group5__option2: { selected: false, conflicted: false, included: false },
+ group5__option3: { selected: false, conflicted: false, included: false },
+ group6__group6option1: { selected: false, conflicted: false, included: false },
+ group6__group6option2: { selected: true, conflicted: false, included: false },
+ group6__group6option3: { selected: true, conflicted: false, included: false },
+ group7__group7option1: { selected: false, conflicted: false, included: false },
+ group7__group7option2: { selected: true, conflicted: false, included: false },
+ group7__group7option3: { selected: false, conflicted: false, included: false },
+ namespace__0: { selected: false, conflicted: false, included: false },
+ namespace__1: { selected: false, conflicted: false, included: false },
+ namespace__2: { selected: false, conflicted: false, included: false },
+ namespace__3: { selected: false, conflicted: false, included: false }
+ };
+
+ QUnit.module( 'mediawiki.rcfilters - FiltersViewModel', QUnit.newMwEnvironment( {
+ messages: {
+ 'group1filter1-label': 'Group 1: Filter 1 title',
+ 'group1filter1-desc': 'Description of Filter 1 in Group 1',
+ 'group1filter2-label': 'Group 1: Filter 2 title',
+ 'group1filter2-desc': 'Description of Filter 2 in Group 1',
+ 'group1filter3-label': 'Group 1: Filter 3',
+ 'group1filter3-desc': 'Description of Filter 3 in Group 1',
+
+ 'group2filter4-label': 'Group 2: Filter 4 title',
+ 'group2filter4-desc': 'Description of Filter 4 in Group 2',
+ 'group2filter5-label': 'Group 2: Filter 5',
+ 'group2filter5-desc': 'Description of Filter 5 in Group 2',
+ 'group2filter6-label': 'xGroup 2: Filter 6',
+ 'group2filter6-desc': 'Description of Filter 6 in Group 2'
+ }
+ } ) );
+
+ QUnit.test( 'Setting up filters', function ( assert ) {
+ var model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ // Test that all items were created
+ assert.ok(
+ Object.keys( baseFilterRepresentation ).every( function ( filterName ) {
+ return model.getItemByName( filterName ) instanceof mw.rcfilters.dm.FilterItem;
+ } ),
+ 'Filters instantiated and stored correctly'
+ );
+
+ assert.deepEqual(
+ model.getSelectedState(),
+ baseFilterRepresentation,
+ 'Initial state of filters'
+ );
+
+ model.toggleFiltersSelected( {
+ group1__filter1: true,
+ group2__filter5: true,
+ group3__filter7: true
+ } );
+ assert.deepEqual(
+ model.getSelectedState(),
+ $.extend( true, {}, baseFilterRepresentation, {
+ group1__filter1: true,
+ group2__filter5: true,
+ group3__filter7: true
+ } ),
+ 'Updating filter states correctly'
+ );
+ } );
+
+ QUnit.test( 'Default filters', function ( assert ) {
+ var model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ // Empty query = only default values
+ assert.deepEqual(
+ model.getDefaultParams(),
+ defaultParameters,
+ 'Default parameters are stored properly per filter and group (sticky groups are ignored)'
+ );
+ } );
+
+ QUnit.test( 'Parameter minimal state', function ( assert ) {
+ var model = new mw.rcfilters.dm.FiltersViewModel(),
+ cases = [
+ {
+ input: {},
+ result: {},
+ msg: 'Empty parameter representation produces an empty result'
+ },
+ {
+ input: {
+ filter1: '1',
+ filter2: '0',
+ filter3: '0',
+ group3: '',
+ group4: 'option2'
+ },
+ result: {
+ filter1: '1',
+ group4: 'option2'
+ },
+ msg: 'Mixed input results in only non-falsey values as result'
+ },
+ {
+ input: {
+ filter1: '0',
+ filter2: '0',
+ filter3: '0',
+ group3: '',
+ group4: '',
+ group1__filter1_color: null
+ },
+ result: {},
+ msg: 'An all-falsey input results in an empty result.'
+ },
+ {
+ input: {
+ filter1: '0',
+ filter2: '0',
+ filter3: '0',
+ group3: '',
+ group4: '',
+ group1__filter1_color: 'c1'
+ },
+ result: {
+ group1__filter1_color: 'c1'
+ },
+ msg: 'An all-falsey input with highlight params result in only the highlight param.'
+ },
+ {
+ input: {
+ group1__filter1_color: 'c1',
+ group1__filter3_color: 'c3' // Not supporting highlights
+ },
+ result: {
+ group1__filter1_color: 'c1'
+ },
+ msg: 'Unsupported highlights are removed.'
+ }
+ ];
+
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ cases.forEach( function ( test ) {
+ assert.deepEqual(
+ model.getMinimizedParamRepresentation( test.input ),
+ test.result,
+ test.msg
+ );
+ } );
+ } );
+
+ QUnit.test( 'Parameter states', function ( assert ) {
+ // Some groups / params have their defaults immediately applied
+ // to their state. These include single_option which can never
+ // be empty, etc. These are these states:
+ var parametersWithoutExcluded,
+ appliedDefaultParameters = {
+ group4: 'option2',
+ group5: 'option1',
+ // Sticky, their defaults apply immediately
+ group6option2: '1',
+ group6option3: '1',
+ group7: 'group7option2'
+ },
+ model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( filterDefinition, viewsDefinition );
+ assert.deepEqual(
+ model.getEmptyParameterState(),
+ emptyParamRepresentation,
+ 'Producing an empty parameter state'
+ );
+
+ model.toggleFiltersSelected( {
+ group1__filter1: true,
+ group3__filter7: true
+ } );
+
+ assert.deepEqual(
+ model.getCurrentParameterState(),
+ // appliedDefaultParams applies the default value to parameters
+ // who must have an initial value to begin with, so we have to
+ // take it into account in the current state
+ $.extend( true, {}, appliedDefaultParameters, {
+ filter2: '1',
+ filter3: '1',
+ group3: 'filter7'
+ } ),
+ 'Producing a current parameter state'
+ );
+
+ // Reset
+ model = new mw.rcfilters.dm.FiltersViewModel();
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ parametersWithoutExcluded = $.extend( true, {}, appliedDefaultParameters );
+ delete parametersWithoutExcluded.group7;
+ delete parametersWithoutExcluded.group6option2;
+ delete parametersWithoutExcluded.group6option3;
+
+ assert.deepEqual(
+ model.getCurrentParameterState( true ),
+ parametersWithoutExcluded,
+ 'Producing a current clean parameter state without excluded filters'
+ );
+ } );
+
+ QUnit.test( 'Cleaning up parameter states', function ( assert ) {
+ var model = new mw.rcfilters.dm.FiltersViewModel(),
+ cases = [
+ {
+ input: {},
+ result: {},
+ msg: 'Empty parameter representation produces an empty result'
+ },
+ {
+ input: {
+ filter1: '1', // Regular (do not strip)
+ group6option1: '1' // Sticky
+ },
+ result: { filter1: '1' },
+ msg: 'Valid input strips all sticky params regardless of value'
+ }
+ ];
+
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ cases.forEach( function ( test ) {
+ assert.deepEqual(
+ model.removeStickyParams( test.input ),
+ test.result,
+ test.msg
+ );
+ } );
+
+ } );
+
+ QUnit.test( 'Finding matching filters', function ( assert ) {
+ var matches,
+ testCases = [
+ {
+ query: 'group',
+ expectedMatches: {
+ group1: [ 'group1__filter1', 'group1__filter2', 'group1__filter3' ],
+ group2: [ 'group2__filter4', 'group2__filter5' ]
+ },
+ reason: 'Finds filters starting with the query string'
+ },
+ {
+ query: 'in Group 2',
+ expectedMatches: {
+ group2: [ 'group2__filter4', 'group2__filter5', 'group2__filter6' ]
+ },
+ reason: 'Finds filters containing the query string in their description'
+ },
+ {
+ query: 'title',
+ expectedMatches: {
+ group1: [ 'group1__filter1', 'group1__filter2' ],
+ group2: [ 'group2__filter4' ]
+ },
+ reason: 'Finds filters containing the query string in their group title'
+ },
+ {
+ query: ':Main',
+ expectedMatches: {
+ namespace: [ 'namespace__0' ]
+ },
+ reason: 'Finds item in view when a prefix is used'
+ },
+ {
+ query: ':group',
+ expectedMatches: {},
+ reason: 'Finds no results if using namespaces prefix (:) to search for filter title'
+ }
+ ],
+ model = new mw.rcfilters.dm.FiltersViewModel(),
+ extractNames = function ( matches ) {
+ var result = {};
+ Object.keys( matches ).forEach( function ( groupName ) {
+ result[ groupName ] = matches[ groupName ].map( function ( item ) {
+ return item.getName();
+ } );
+ } );
+ return result;
+ };
+
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ testCases.forEach( function ( testCase ) {
+ matches = model.findMatches( testCase.query );
+ assert.deepEqual(
+ extractNames( matches ),
+ testCase.expectedMatches,
+ testCase.reason
+ );
+ } );
+
+ matches = model.findMatches( 'foo' );
+ assert.ok(
+ $.isEmptyObject( matches ),
+ 'findMatches returns an empty object when no results found'
+ );
+ } );
+
+ QUnit.test( 'getParametersFromFilters', function ( assert ) {
+ var model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ // Starting with all filters unselected
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ baseParamRepresentation,
+ 'Unselected filters return all parameters falsey or \'\'.'
+ );
+
+ // Select 1 filter
+ model.toggleFiltersSelected( {
+ group1__filter1: true
+ } );
+ // Only one filter in one group
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ $.extend( true, {}, baseParamRepresentation, {
+ // Group 1 (one selected, the others are true)
+ filter2: '1',
+ filter3: '1'
+ } ),
+ 'One filter in one "send_unselected_if_any" group returns the other parameters truthy.'
+ );
+
+ // Select 2 filters
+ model.toggleFiltersSelected( {
+ group1__filter1: true,
+ group1__filter2: true
+ } );
+ // Two selected filters in one group
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ $.extend( true, {}, baseParamRepresentation, {
+ // Group 1 (two selected, the other is true)
+ filter3: '1'
+ } ),
+ 'Two filters in one "send_unselected_if_any" group returns the other parameters truthy.'
+ );
+
+ // Select 3 filters
+ model.toggleFiltersSelected( {
+ group1__filter1: true,
+ group1__filter2: true,
+ group1__filter3: true
+ } );
+ // All filters of the group are selected == this is the same as not selecting any
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ baseParamRepresentation,
+ 'All filters selected in one "send_unselected_if_any" group returns all parameters falsy.'
+ );
+
+ // Select 1 filter from string_options
+ model.toggleFiltersSelected( {
+ group3__filter7: true,
+ group3__filter8: false,
+ group3__filter9: false
+ } );
+ // All filters of the group are selected == this is the same as not selecting any
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ $.extend( true, {}, baseParamRepresentation, {
+ group3: 'filter7'
+ } ),
+ 'One filter selected in "string_option" group returns that filter in the value.'
+ );
+
+ // Select 2 filters from string_options
+ model.toggleFiltersSelected( {
+ group3__filter7: true,
+ group3__filter8: true,
+ group3__filter9: false
+ } );
+ // All filters of the group are selected == this is the same as not selecting any
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ $.extend( true, {}, baseParamRepresentation, {
+ group3: 'filter7,filter8'
+ } ),
+ 'Two filters selected in "string_option" group returns those filters in the value.'
+ );
+
+ // Select 3 filters from string_options
+ model.toggleFiltersSelected( {
+ group3__filter7: true,
+ group3__filter8: true,
+ group3__filter9: true
+ } );
+ // All filters of the group are selected == this is the same as not selecting any
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ $.extend( true, {}, baseParamRepresentation, {
+ group3: 'all'
+ } ),
+ 'All filters selected in "string_option" group returns \'all\'.'
+ );
+
+ // Reset
+ model = new mw.rcfilters.dm.FiltersViewModel();
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ // Select an option from single_option group
+ model.toggleFiltersSelected( {
+ group4__option2: true
+ } );
+ // All filters of the group are selected == this is the same as not selecting any
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ $.extend( true, {}, baseParamRepresentation, {
+ group4: 'option2'
+ } ),
+ 'Selecting an option from "single_option" group returns that option as a value.'
+ );
+
+ // Select a different option from single_option group
+ model.toggleFiltersSelected( {
+ group4__option3: true
+ } );
+ // All filters of the group are selected == this is the same as not selecting any
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ $.extend( true, {}, baseParamRepresentation, {
+ group4: 'option3'
+ } ),
+ 'Selecting a different option from "single_option" group changes the selection.'
+ );
+ } );
+
+ QUnit.test( 'getParametersFromFilters (custom object)', function ( assert ) {
+ // This entire test uses different base definition than the global one
+ // on purpose, to verify that the values inserted as a custom object
+ // are the ones we expect in return
+ var originalState,
+ model = new mw.rcfilters.dm.FiltersViewModel(),
+ definition = [ {
+ name: 'group1',
+ title: 'Group 1',
+ type: 'send_unselected_if_any',
+ filters: [
+ { name: 'hidefilter1', label: 'Hide filter 1', description: '' },
+ { name: 'hidefilter2', label: 'Hide filter 2', description: '' },
+ { name: 'hidefilter3', label: 'Hide filter 3', description: '' }
+ ]
+ }, {
+ name: 'group2',
+ title: 'Group 2',
+ type: 'send_unselected_if_any',
+ filters: [
+ { name: 'hidefilter4', label: 'Hide filter 4', description: '' },
+ { name: 'hidefilter5', label: 'Hide filter 5', description: '' },
+ { name: 'hidefilter6', label: 'Hide filter 6', description: '' }
+ ]
+ }, {
+ name: 'group3',
+ title: 'Group 3',
+ type: 'string_options',
+ separator: ',',
+ filters: [
+ { name: 'filter7', label: 'Hide filter 7', description: '' },
+ { name: 'filter8', label: 'Hide filter 8', description: '' },
+ { name: 'filter9', label: 'Hide filter 9', description: '' }
+ ]
+ }, {
+ name: 'group4',
+ title: 'Group 4',
+ type: 'single_option',
+ filters: [
+ { name: 'filter10', label: 'Hide filter 10', description: '' },
+ { name: 'filter11', label: 'Hide filter 11', description: '' },
+ { name: 'filter12', label: 'Hide filter 12', description: '' }
+ ]
+ } ],
+ baseResult = {
+ hidefilter1: '0',
+ hidefilter2: '0',
+ hidefilter3: '0',
+ hidefilter4: '0',
+ hidefilter5: '0',
+ hidefilter6: '0',
+ group3: '',
+ group4: ''
+ },
+ cases = [
+ {
+ // This is mocking the cases above, both
+ // - 'Two filters in one "send_unselected_if_any" group returns the other parameters truthy.'
+ // - 'Two filters selected in "string_option" group returns those filters in the value.'
+ input: {
+ group1__hidefilter1: true,
+ group1__hidefilter2: true,
+ group1__hidefilter3: false,
+ group2__hidefilter4: false,
+ group2__hidefilter5: false,
+ group2__hidefilter6: false,
+ group3__filter7: true,
+ group3__filter8: true,
+ group3__filter9: false
+ },
+ expected: $.extend( true, {}, baseResult, {
+ // Group 1 (two selected, the others are true)
+ hidefilter3: '1',
+ // Group 3 (two selected)
+ group3: 'filter7,filter8'
+ } ),
+ msg: 'Given an explicit (complete) filter state object, the result is the same as if the object given represented the model state.'
+ },
+ {
+ // This is mocking case above
+ // - 'One filter in one "send_unselected_if_any" group returns the other parameters truthy.'
+ input: {
+ group1__hidefilter1: 1
+ },
+ expected: $.extend( true, {}, baseResult, {
+ // Group 1 (one selected, the others are true)
+ hidefilter2: '1',
+ hidefilter3: '1'
+ } ),
+ msg: 'Given an explicit (incomplete) filter state object, the result is the same as if the object give represented the model state.'
+ },
+ {
+ input: {
+ group4__filter10: true
+ },
+ expected: $.extend( true, {}, baseResult, {
+ group4: 'filter10'
+ } ),
+ msg: 'Given a single value for "single_option" that option is represented in the result.'
+ },
+ {
+ input: {
+ group4__filter10: true,
+ group4__filter11: true
+ },
+ expected: $.extend( true, {}, baseResult, {
+ group4: 'filter10'
+ } ),
+ msg: 'Given more than one true value for "single_option" (which should not happen!) only the first value counts, and the second is ignored.'
+ },
+ {
+ input: {},
+ expected: baseResult,
+ msg: 'Given an explicit empty object, the result is all filters set to their falsey unselected value.'
+ }
+ ];
+
+ model.initializeFilters( definition );
+ // Store original state
+ originalState = model.getSelectedState();
+
+ // Test each case
+ cases.forEach( function ( test ) {
+ assert.deepEqual(
+ model.getParametersFromFilters( test.input ),
+ test.expected,
+ test.msg
+ );
+ } );
+
+ // After doing the above tests, make sure the actual state
+ // of the filter stayed the same
+ assert.deepEqual(
+ model.getSelectedState(),
+ originalState,
+ 'Running the method with external definition to parse does not actually change the state of the model'
+ );
+ } );
+
+ QUnit.test( 'getFiltersFromParameters', function ( assert ) {
+ var model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ // Empty query = only default values
+ assert.deepEqual(
+ model.getFiltersFromParameters( {} ),
+ baseFilterRepresentation,
+ 'Empty parameter query results in an object representing all filters set to their base state'
+ );
+
+ assert.deepEqual(
+ model.getFiltersFromParameters( {
+ filter2: '1'
+ } ),
+ $.extend( {}, baseFilterRepresentation, {
+ group1__filter1: true, // The text is "show filter 1"
+ group1__filter2: false, // The text is "show filter 2"
+ group1__filter3: true // The text is "show filter 3"
+ } ),
+ 'One truthy parameter in a group whose other parameters are true by default makes the rest of the filters in the group false (unchecked)'
+ );
+
+ assert.deepEqual(
+ model.getFiltersFromParameters( {
+ filter1: '1',
+ filter2: '1',
+ filter3: '1'
+ } ),
+ $.extend( {}, baseFilterRepresentation, {
+ group1__filter1: false, // The text is "show filter 1"
+ group1__filter2: false, // The text is "show filter 2"
+ group1__filter3: false // The text is "show filter 3"
+ } ),
+ 'All paremeters in the same \'send_unselected_if_any\' group false is equivalent to none are truthy (checked) in the interface'
+ );
+
+ // The ones above don't update the model, so we have a clean state.
+ // getFiltersFromParameters is stateless; any change is unaffected by the current state
+ // This test is demonstrating wrong usage of the method;
+ // We should be aware that getFiltersFromParameters is stateless,
+ // so each call gives us a filter state that only reflects the query given.
+ // This means that the two calls to toggleFiltersSelected() below collide.
+ // The result of the first is overridden by the result of the second,
+ // since both get a full state object from getFiltersFromParameters that **only** relates
+ // to the input it receives.
+ model.toggleFiltersSelected(
+ model.getFiltersFromParameters( {
+ filter1: '1'
+ } )
+ );
+
+ model.toggleFiltersSelected(
+ model.getFiltersFromParameters( {
+ filter6: '1'
+ } )
+ );
+
+ // The result here is ignoring the first toggleFiltersSelected call
+ assert.deepEqual(
+ model.getSelectedState(),
+ $.extend( {}, baseFilterRepresentation, {
+ group2__filter4: true,
+ group2__filter5: true,
+ group2__filter6: false
+ } ),
+ 'getFiltersFromParameters does not care about previous or existing state.'
+ );
+
+ // Reset
+ model = new mw.rcfilters.dm.FiltersViewModel();
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ model.toggleFiltersSelected(
+ model.getFiltersFromParameters( {
+ group3: 'filter7'
+ } )
+ );
+ assert.deepEqual(
+ model.getSelectedState(),
+ $.extend( {}, baseFilterRepresentation, {
+ group3__filter7: true,
+ group3__filter8: false,
+ group3__filter9: false
+ } ),
+ 'A \'string_options\' parameter containing 1 value, results in the corresponding filter as checked'
+ );
+
+ model.toggleFiltersSelected(
+ model.getFiltersFromParameters( {
+ group3: 'filter7,filter8'
+ } )
+ );
+ assert.deepEqual(
+ model.getSelectedState(),
+ $.extend( {}, baseFilterRepresentation, {
+ group3__filter7: true,
+ group3__filter8: true,
+ group3__filter9: false
+ } ),
+ 'A \'string_options\' parameter containing 2 values, results in both corresponding filters as checked'
+ );
+
+ model.toggleFiltersSelected(
+ model.getFiltersFromParameters( {
+ group3: 'filter7,filter8,filter9'
+ } )
+ );
+ assert.deepEqual(
+ model.getSelectedState(),
+ $.extend( {}, baseFilterRepresentation, {
+ group3__filter7: true,
+ group3__filter8: true,
+ group3__filter9: true
+ } ),
+ 'A \'string_options\' parameter containing all values, results in all filters of the group as checked.'
+ );
+
+ model.toggleFiltersSelected(
+ model.getFiltersFromParameters( {
+ group3: 'filter7,all,filter9'
+ } )
+ );
+ assert.deepEqual(
+ model.getSelectedState(),
+ $.extend( {}, baseFilterRepresentation, {
+ group3__filter7: true,
+ group3__filter8: true,
+ group3__filter9: true
+ } ),
+ 'A \'string_options\' parameter containing the value \'all\', results in all filters of the group as checked.'
+ );
+
+ model.toggleFiltersSelected(
+ model.getFiltersFromParameters( {
+ group3: 'filter7,foo,filter9'
+ } )
+ );
+ assert.deepEqual(
+ model.getSelectedState(),
+ $.extend( {}, baseFilterRepresentation, {
+ group3__filter7: true,
+ group3__filter8: false,
+ group3__filter9: true
+ } ),
+ 'A \'string_options\' parameter containing an invalid value, results in the invalid value ignored and the valid corresponding filters checked.'
+ );
+
+ model.toggleFiltersSelected(
+ model.getFiltersFromParameters( {
+ group4: 'option1'
+ } )
+ );
+ assert.deepEqual(
+ model.getSelectedState(),
+ $.extend( {}, baseFilterRepresentation, {
+ group4__option1: true,
+ group4__option2: false
+ } ),
+ 'A \'single_option\' parameter reflects a single selected value.'
+ );
+
+ assert.deepEqual(
+ model.getFiltersFromParameters( {
+ group4: 'option1,option2'
+ } ),
+ baseFilterRepresentation,
+ 'An invalid \'single_option\' parameter is ignored.'
+ );
+
+ // Change to one value
+ model.toggleFiltersSelected(
+ model.getFiltersFromParameters( {
+ group4: 'option1'
+ } )
+ );
+ // Change again to another value
+ model.toggleFiltersSelected(
+ model.getFiltersFromParameters( {
+ group4: 'option2'
+ } )
+ );
+ assert.deepEqual(
+ model.getSelectedState(),
+ $.extend( {}, baseFilterRepresentation, {
+ group4__option2: true
+ } ),
+ 'A \'single_option\' parameter always reflects the latest selected value.'
+ );
+ } );
+
+ QUnit.test( 'sanitizeStringOptionGroup', function ( assert ) {
+ var model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ assert.deepEqual(
+ model.sanitizeStringOptionGroup( 'group1', [ 'filter1', 'filter1', 'filter2' ] ),
+ [ 'filter1', 'filter2' ],
+ 'Remove duplicate values'
+ );
+
+ assert.deepEqual(
+ model.sanitizeStringOptionGroup( 'group1', [ 'filter1', 'foo', 'filter2' ] ),
+ [ 'filter1', 'filter2' ],
+ 'Remove invalid values'
+ );
+
+ assert.deepEqual(
+ model.sanitizeStringOptionGroup( 'group1', [ 'filter1', 'all', 'filter2' ] ),
+ [ 'all' ],
+ 'If any value is "all", the only value is "all".'
+ );
+ } );
+
+ QUnit.test( 'Filter interaction: subsets', function ( assert ) {
+ var model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ // Select a filter that has subset with another filter
+ model.toggleFiltersSelected( {
+ group1__filter1: true
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'group1__filter1' ) );
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullFilterState, {
+ group1__filter1: { selected: true },
+ group1__filter2: { included: true },
+ group1__filter3: { included: true },
+ // Conflicts are affected
+ group2__filter4: { conflicted: true },
+ group2__filter5: { conflicted: true },
+ group2__filter6: { conflicted: true }
+ } ),
+ 'Filters with subsets are represented in the model.'
+ );
+
+ // Select another filter that has a subset with the same previous filter
+ model.toggleFiltersSelected( {
+ group1__filter2: true
+ } );
+ model.reassessFilterInteractions( model.getItemByName( 'filter2' ) );
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullFilterState, {
+ group1__filter1: { selected: true },
+ group1__filter2: { selected: true, included: true },
+ group1__filter3: { included: true },
+ // Conflicts are affected
+ group2__filter6: { conflicted: true }
+ } ),
+ 'Filters that have multiple subsets are represented.'
+ );
+
+ // Remove one filter (but leave the other) that affects filter3
+ model.toggleFiltersSelected( {
+ group1__filter1: false
+ } );
+ model.reassessFilterInteractions( model.getItemByName( 'group1__filter1' ) );
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullFilterState, {
+ group1__filter2: { selected: true, included: false },
+ group1__filter3: { included: true },
+ // Conflicts are affected
+ group2__filter6: { conflicted: true }
+ } ),
+ 'Removing a filter only un-includes its subset if there is no other filter affecting.'
+ );
+
+ model.toggleFiltersSelected( {
+ group1__filter2: false
+ } );
+ model.reassessFilterInteractions( model.getItemByName( 'group1__filter2' ) );
+ assert.deepEqual(
+ model.getFullState(),
+ baseFullFilterState,
+ 'Removing all supersets also un-includes the subsets.'
+ );
+ } );
+
+ QUnit.test( 'Filter interaction: full coverage', function ( assert ) {
+ var model = new mw.rcfilters.dm.FiltersViewModel(),
+ isCapsuleItemMuted = function ( filterName ) {
+ var itemModel = model.getItemByName( filterName ),
+ groupModel = itemModel.getGroupModel();
+
+ // This is the logic inside the capsule widget
+ return (
+ // The capsule item widget only appears if the item is selected
+ itemModel.isSelected() &&
+ // Muted state is only valid if group is full coverage and all items are selected
+ groupModel.isFullCoverage() && groupModel.areAllSelected()
+ );
+ },
+ getCurrentItemsMutedState = function () {
+ return {
+ group1__filter1: isCapsuleItemMuted( 'group1__filter1' ),
+ group1__filter2: isCapsuleItemMuted( 'group1__filter2' ),
+ group1__filter3: isCapsuleItemMuted( 'group1__filter3' ),
+ group2__filter4: isCapsuleItemMuted( 'group2__filter4' ),
+ group2__filter5: isCapsuleItemMuted( 'group2__filter5' ),
+ group2__filter6: isCapsuleItemMuted( 'group2__filter6' )
+ };
+ },
+ baseMuteState = {
+ group1__filter1: false,
+ group1__filter2: false,
+ group1__filter3: false,
+ group2__filter4: false,
+ group2__filter5: false,
+ group2__filter6: false
+ };
+
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ // Starting state, no selection, all items are non-muted
+ assert.deepEqual(
+ getCurrentItemsMutedState(),
+ baseMuteState,
+ 'No selection - all items are non-muted'
+ );
+
+ // Select most (but not all) items in each group
+ model.toggleFiltersSelected( {
+ group1__filter1: true,
+ group1__filter2: true,
+ group2__filter4: true,
+ group2__filter5: true
+ } );
+
+ // Both groups have multiple (but not all) items selected, all items are non-muted
+ assert.deepEqual(
+ getCurrentItemsMutedState(),
+ baseMuteState,
+ 'Not all items in the group selected - all items are non-muted'
+ );
+
+ // Select all items in 'fullCoverage' group (group2)
+ model.toggleFiltersSelected( {
+ group2__filter6: true
+ } );
+
+ // Group2 (full coverage) has all items selected, all its items are muted
+ assert.deepEqual(
+ getCurrentItemsMutedState(),
+ $.extend( {}, baseMuteState, {
+ group2__filter4: true,
+ group2__filter5: true,
+ group2__filter6: true
+ } ),
+ 'All items in \'full coverage\' group are selected - all items in the group are muted'
+ );
+
+ // Select all items in non 'fullCoverage' group (group1)
+ model.toggleFiltersSelected( {
+ group1__filter3: true
+ } );
+
+ // Group1 (full coverage) has all items selected, no items in it are muted (non full coverage)
+ assert.deepEqual(
+ getCurrentItemsMutedState(),
+ $.extend( {}, baseMuteState, {
+ group2__filter4: true,
+ group2__filter5: true,
+ group2__filter6: true
+ } ),
+ 'All items in a non \'full coverage\' group are selected - none of the items in the group are muted'
+ );
+
+ // Uncheck an item from each group
+ model.toggleFiltersSelected( {
+ group1__filter3: false,
+ group2__filter5: false
+ } );
+ assert.deepEqual(
+ getCurrentItemsMutedState(),
+ baseMuteState,
+ 'Not all items in the group are checked - all items are non-muted regardless of group coverage'
+ );
+ } );
+
+ QUnit.test( 'Filter interaction: conflicts', function ( assert ) {
+ var model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ assert.deepEqual(
+ model.getFullState(),
+ baseFullFilterState,
+ 'Initial state: no conflicts because no selections.'
+ );
+
+ // Select a filter that has a conflict with an entire group
+ model.toggleFiltersSelected( {
+ group1__filter1: true // conflicts: entire of group 2 ( filter4, filter5, filter6)
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'group1__filter1' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullFilterState, {
+ group1__filter1: { selected: true },
+ group2__filter4: { conflicted: true },
+ group2__filter5: { conflicted: true },
+ group2__filter6: { conflicted: true },
+ // Subsets are affected by the selection
+ group1__filter2: { included: true },
+ group1__filter3: { included: true }
+ } ),
+ 'Selecting a filter that conflicts with a group sets all the conflicted group items as "conflicted".'
+ );
+
+ // Select one of the conflicts (both filters are now conflicted and selected)
+ model.toggleFiltersSelected( {
+ group2__filter4: true // conflicts: filter 1
+ } );
+ model.reassessFilterInteractions( model.getItemByName( 'group2__filter4' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullFilterState, {
+ group1__filter1: { selected: true, conflicted: true },
+ group2__filter4: { selected: true, conflicted: true },
+ group2__filter5: { conflicted: true },
+ group2__filter6: { conflicted: true },
+ // Subsets are affected by the selection
+ group1__filter2: { included: true },
+ group1__filter3: { included: true }
+ } ),
+ 'Selecting a conflicting filter inside a group, sets both sides to conflicted and selected.'
+ );
+
+ // Reset
+ model = new mw.rcfilters.dm.FiltersViewModel();
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ // Select a filter that has a conflict with a specific filter
+ model.toggleFiltersSelected( {
+ group1__filter2: true // conflicts: filter6
+ } );
+ model.reassessFilterInteractions( model.getItemByName( 'group1__filter2' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullFilterState, {
+ group1__filter2: { selected: true },
+ group2__filter6: { conflicted: true },
+ // Subsets are affected by the selection
+ group1__filter3: { included: true }
+ } ),
+ 'Selecting a filter that conflicts with another filter sets the other as "conflicted".'
+ );
+
+ // Select the conflicting filter
+ model.toggleFiltersSelected( {
+ group2__filter6: true // conflicts: filter2
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'group2__filter6' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullFilterState, {
+ group1__filter2: { selected: true, conflicted: true },
+ group2__filter6: { selected: true, conflicted: true },
+ // This is added to the conflicts because filter6 is part of group2,
+ // who is in conflict with filter1; note that filter2 also conflicts
+ // with filter6 which means that filter1 conflicts with filter6 (because it's in group2)
+ // and also because its **own sibling** (filter2) is **also** in conflict with the
+ // selected items in group2 (filter6)
+ group1__filter1: { conflicted: true },
+
+ // Subsets are affected by the selection
+ group1__filter3: { included: true }
+ } ),
+ 'Selecting a conflicting filter with an individual filter, sets both sides to conflicted and selected.'
+ );
+
+ // Now choose a non-conflicting filter from the group
+ model.toggleFiltersSelected( {
+ group2__filter5: true
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'group2__filter5' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullFilterState, {
+ group1__filter2: { selected: true },
+ group2__filter6: { selected: true },
+ group2__filter5: { selected: true },
+ // Filter6 and filter1 are no longer in conflict because
+ // filter5, while it is in conflict with filter1, it is
+ // not in conflict with filter2 - and since filter2 is
+ // selected, it removes the conflict bidirectionally
+
+ // Subsets are affected by the selection
+ group1__filter3: { included: true }
+ } ),
+ 'Selecting a non-conflicting filter within the group of a conflicting filter removes the conflicts.'
+ );
+
+ // Followup on the previous test, unselect filter2 so filter1
+ // is now the only one selected in its own group, and since
+ // it is in conflict with the entire of group2, it means
+ // filter1 is once again conflicted
+ model.toggleFiltersSelected( {
+ group1__filter2: false
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'group1__filter2' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullFilterState, {
+ group1__filter1: { conflicted: true },
+ group2__filter6: { selected: true },
+ group2__filter5: { selected: true }
+ } ),
+ 'Unselecting an item that did not conflict returns the conflict state.'
+ );
+
+ // Followup #2: Now actually select filter1, and make everything conflicted
+ model.toggleFiltersSelected( {
+ group1__filter1: true
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'group1__filter1' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullFilterState, {
+ group1__filter1: { selected: true, conflicted: true },
+ group2__filter6: { selected: true, conflicted: true },
+ group2__filter5: { selected: true, conflicted: true },
+ group2__filter4: { conflicted: true }, // Not selected but conflicted because it's in group2
+ // Subsets are affected by the selection
+ group1__filter2: { included: true },
+ group1__filter3: { included: true }
+ } ),
+ 'Selecting an item that conflicts with a whole group makes all selections in that group conflicted.'
+ );
+
+ /* Simple case */
+ // Reset
+ model = new mw.rcfilters.dm.FiltersViewModel();
+ model.initializeFilters( filterDefinition, viewsDefinition );
+
+ // Select a filter that has a conflict with a specific filter
+ model.toggleFiltersSelected( {
+ group1__filter2: true // conflicts: filter6
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'group1__filter2' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullFilterState, {
+ group1__filter2: { selected: true },
+ group2__filter6: { conflicted: true },
+ // Subsets are affected by the selection
+ group1__filter3: { included: true }
+ } ),
+ 'Simple case: Selecting a filter that conflicts with another filter sets the other as "conflicted".'
+ );
+
+ model.toggleFiltersSelected( {
+ group1__filter3: true // conflicts: filter6
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'group1__filter3' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullFilterState, {
+ group1__filter2: { selected: true },
+ // Subsets are affected by the selection
+ group1__filter3: { selected: true, included: true }
+ } ),
+ 'Simple case: Selecting a filter that is not in conflict removes the conflict.'
+ );
+ } );
+
+ QUnit.test( 'Filter highlights', function ( assert ) {
+ // We are using a different (smaller) definition here than the global one
+ var definition = [ {
+ name: 'group1',
+ title: 'Group 1',
+ type: 'string_options',
+ filters: [
+ { name: 'filter1', cssClass: 'class1', label: '1', description: '1' },
+ { name: 'filter2', cssClass: 'class2', label: '2', description: '2' },
+ { name: 'filter3', cssClass: 'class3', label: '3', description: '3' },
+ { name: 'filter4', cssClass: 'class4', label: '4', description: '4' },
+ { name: 'filter5', cssClass: 'class5', label: '5', description: '5' },
+ { name: 'filter6', label: '6', description: '6' }
+ ]
+ } ],
+ model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( definition );
+
+ assert.ok(
+ !model.isHighlightEnabled(),
+ 'Initially, highlight is disabled.'
+ );
+
+ model.toggleHighlight( true );
+ assert.ok(
+ model.isHighlightEnabled(),
+ 'Highlight is enabled on toggle.'
+ );
+
+ model.setHighlightColor( 'group1__filter1', 'color1' );
+ model.setHighlightColor( 'group1__filter2', 'color2' );
+
+ assert.deepEqual(
+ model.getHighlightedItems().map( function ( item ) {
+ return item.getName();
+ } ),
+ [
+ 'group1__filter1',
+ 'group1__filter2'
+ ],
+ 'Highlighted items are highlighted.'
+ );
+
+ assert.equal(
+ model.getItemByName( 'group1__filter1' ).getHighlightColor(),
+ 'color1',
+ 'Item highlight color is set.'
+ );
+
+ model.setHighlightColor( 'group1__filter1', 'color1changed' );
+ assert.equal(
+ model.getItemByName( 'group1__filter1' ).getHighlightColor(),
+ 'color1changed',
+ 'Item highlight color is changed on setHighlightColor.'
+ );
+
+ model.clearHighlightColor( 'group1__filter1' );
+ assert.deepEqual(
+ model.getHighlightedItems().map( function ( item ) {
+ return item.getName();
+ } ),
+ [
+ 'group1__filter2'
+ ],
+ 'Clear highlight from an item results in the item no longer being highlighted.'
+ );
+
+ // Reset
+ model = new mw.rcfilters.dm.FiltersViewModel();
+ model.initializeFilters( definition );
+
+ model.setHighlightColor( 'group1__filter1', 'color1' );
+ model.setHighlightColor( 'group1__filter2', 'color2' );
+ model.setHighlightColor( 'group1__filter3', 'color3' );
+
+ assert.deepEqual(
+ model.getHighlightedItems().map( function ( item ) {
+ return item.getName();
+ } ),
+ [
+ 'group1__filter1',
+ 'group1__filter2',
+ 'group1__filter3'
+ ],
+ 'Even if highlights are not enabled, the items remember their highlight state'
+ // NOTE: When actually displaying the highlights, the UI checks whether
+ // highlighting is generally active and then goes over the highlighted
+ // items. The item models, however, and the view model in general, still
+ // retains the knowledge about which filters have different colors, so we
+ // can seamlessly return to the colors the user previously chose if they
+ // reapply highlights.
+ );
+
+ // Reset
+ model = new mw.rcfilters.dm.FiltersViewModel();
+ model.initializeFilters( definition );
+
+ model.setHighlightColor( 'group1__filter1', 'color1' );
+ model.setHighlightColor( 'group1__filter6', 'color6' );
+
+ assert.deepEqual(
+ model.getHighlightedItems().map( function ( item ) {
+ return item.getName();
+ } ),
+ [
+ 'group1__filter1'
+ ],
+ 'Items without a specified class identifier are not highlighted.'
+ );
+ } );
+
+ QUnit.test( 'emptyAllFilters', function ( assert ) {
+ var model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( shortFilterDefinition, null );
+
+ model.toggleFiltersSelected( {
+ group1__filter1: true,
+ group2__filter4: true, // hidden
+ group3__filter5: true // sticky
+ } );
+
+ model.emptyAllFilters();
+
+ assert.deepEqual(
+ model.getSelectedState( true ),
+ {
+ group3__filter5: true,
+ group3__filter6: true
+ },
+ 'Emptying filters does not affect sticky filters'
+ );
+ } );
+
+ QUnit.test( 'areVisibleFiltersEmpty', function ( assert ) {
+ var model = new mw.rcfilters.dm.FiltersViewModel();
+ model.initializeFilters( shortFilterDefinition, null );
+
+ model.emptyAllFilters();
+ assert.ok( model.areVisibleFiltersEmpty() );
+
+ model.toggleFiltersSelected( {
+ group3__filter5: true // sticky
+ } );
+ assert.ok( model.areVisibleFiltersEmpty() );
+
+ model.toggleFiltersSelected( {
+ group1__filter1: true
+ } );
+ assert.notOk( model.areVisibleFiltersEmpty() );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js
new file mode 100644
index 00000000..ed054bd7
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js
@@ -0,0 +1,520 @@
+/* eslint-disable camelcase */
+( function ( mw ) {
+ var filterDefinition = [ {
+ name: 'group1',
+ type: 'send_unselected_if_any',
+ filters: [
+ // Note: The fact filter2 is default means that in the
+ // filter representation, filter1 and filter3 are 'true'
+ { name: 'filter1', cssClass: 'filter1class' },
+ { name: 'filter2', cssClass: 'filter2class', default: true },
+ { name: 'filter3', cssClass: 'filter3class' }
+ ]
+ }, {
+ name: 'group2',
+ type: 'string_options',
+ separator: ',',
+ filters: [
+ { name: 'filter4', cssClass: 'filter4class' },
+ { name: 'filter5' }, // NOTE: Not supporting highlights!
+ { name: 'filter6', cssClass: 'filter6class' }
+ ]
+ }, {
+ name: 'group3',
+ type: 'boolean',
+ sticky: true,
+ filters: [
+ { name: 'group3option1', cssClass: 'filter1class' },
+ { name: 'group3option2', cssClass: 'filter1class' },
+ { name: 'group3option3', cssClass: 'filter1class' }
+ ]
+ }, {
+ // Copy of the way the controller defines invert
+ // to check whether the conversion works
+ name: 'invertGroup',
+ type: 'boolean',
+ hidden: true,
+ filters: [ {
+ name: 'invert',
+ default: '0'
+ } ]
+ } ],
+ queriesFilterRepresentation = {
+ queries: {
+ 1234: {
+ label: 'Item converted',
+ data: {
+ filters: {
+ // - This value is true, but the original filter-representation
+ // of the saved queries ran against defaults. Since filter1 was
+ // set as default in the definition, the value would actually
+ // not appear in the representation itself.
+ // It is considered 'true', though, and should appear in the
+ // converted result in its parameter representation.
+ // >> group1__filter1: true,
+ // - The reverse is true for filter3. Filter3 is set as default
+ // but we don't want it in this representation of the saved query.
+ // Since the filter representation ran against default values,
+ // it will appear as 'false' value in this representation explicitly
+ // and the resulting parameter representation should have that
+ // as the result as well
+ group1__filter3: false,
+ group2__filter4: true,
+ group3__group3option1: true
+ },
+ highlights: {
+ highlight: true,
+ group1__filter1: 'c5',
+ group3__group3option1: 'c1'
+ },
+ invert: true
+ }
+ }
+ }
+ },
+ queriesParamRepresentation = {
+ version: '2',
+ queries: {
+ 1234: {
+ label: 'Item converted',
+ data: {
+ params: {
+ // filter1 is 'true' so filter2 and filter3 are both '1'
+ // in param representation
+ filter2: '1', filter3: '1',
+ // Group type string_options
+ group2: 'filter4'
+ // Note - Group3 is sticky, so it won't show in output
+ },
+ highlights: {
+ group1__filter1_color: 'c5',
+ group3__group3option1_color: 'c1'
+ }
+ }
+ }
+ }
+ },
+ removeHighlights = function ( data ) {
+ var copy = $.extend( true, {}, data );
+ copy.queries[ 1234 ].data.highlights = {};
+ return copy;
+ };
+
+ QUnit.module( 'mediawiki.rcfilters - SavedQueriesModel' );
+
+ QUnit.test( 'Initializing queries', function ( assert ) {
+ var filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+ queriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
+ exampleQueryStructure = {
+ version: '2',
+ default: '1234',
+ queries: {
+ 1234: {
+ label: 'Query 1234',
+ data: {
+ params: {
+ filter2: '1'
+ },
+ highlights: {
+ group1__filter3_color: 'c2'
+ }
+ }
+ }
+ }
+ },
+ cases = [
+ {
+ input: {},
+ finalState: { version: '2', queries: {} },
+ msg: 'Empty initial query structure results in base saved queries structure.'
+ },
+ {
+ input: $.extend( true, {}, exampleQueryStructure ),
+ finalState: $.extend( true, {}, exampleQueryStructure ),
+ msg: 'Initialization of given query structure does not corrupt the structure.'
+ },
+ {
+ // Converting from old structure
+ input: $.extend( true, {}, queriesFilterRepresentation ),
+ finalState: $.extend( true, {}, queriesParamRepresentation ),
+ msg: 'Conversion from filter representation to parameters retains data.'
+ },
+ {
+ // Converting from old structure
+ input: $.extend( true, {}, queriesFilterRepresentation, { queries: { 1234: { data: {
+ filters: {
+ // Entire group true: normalize params
+ filter1: true,
+ filter2: true,
+ filter3: true
+ },
+ highlights: {
+ filter3: null // Get rid of empty highlight
+ }
+ } } } } ),
+ finalState: $.extend( true, {}, queriesParamRepresentation ),
+ msg: 'Conversion from filter representation to parameters normalizes params and highlights.'
+ },
+ {
+ // Converting from old structure with default
+ input: $.extend( true, { default: '1234' }, queriesFilterRepresentation ),
+ finalState: $.extend( true, { default: '1234' }, queriesParamRepresentation ),
+ msg: 'Conversion from filter representation to parameters, with default set up, retains data.'
+ },
+ {
+ // Converting from old structure and cleaning up highlights
+ input: $.extend( true, queriesFilterRepresentation, { queries: { 1234: { data: { highlights: { highlight: false } } } } } ),
+ finalState: removeHighlights( queriesParamRepresentation ),
+ msg: 'Conversion from filter representation to parameters and highlight cleanup'
+ },
+ {
+ // New structure
+ input: $.extend( true, {}, queriesParamRepresentation ),
+ finalState: $.extend( true, {}, queriesParamRepresentation ),
+ msg: 'Parameter representation retains its queries structure'
+ },
+ {
+ // Do not touch invalid color parameters from the initialization routine
+ // (Normalization, or "fixing" the query should only happen when we add new query or actively convert queries)
+ input: $.extend( true, { queries: { 1234: { data: { highlights: { group2__filter5_color: 'c2' } } } } }, exampleQueryStructure ),
+ finalState: $.extend( true, { queries: { 1234: { data: { highlights: { group2__filter5_color: 'c2' } } } } }, exampleQueryStructure ),
+ msg: 'Structure that contains invalid highlights remains the same in initialization'
+ },
+ {
+ // Trim colors when highlight=false is stored
+ input: $.extend( true, { queries: { 1234: { data: { params: { highlight: '0' } } } } }, queriesParamRepresentation ),
+ finalState: removeHighlights( queriesParamRepresentation ),
+ msg: 'Colors are removed when highlight=false'
+ },
+ {
+ // Remove highlight when it is true but no colors are specified
+ input: $.extend( true, { queries: { 1234: { data: { params: { highlight: '1' } } } } }, removeHighlights( queriesParamRepresentation ) ),
+ finalState: removeHighlights( queriesParamRepresentation ),
+ msg: 'remove highlight when it is true but there is no colors'
+ }
+ ];
+
+ filtersModel.initializeFilters( filterDefinition );
+
+ cases.forEach( function ( testCase ) {
+ queriesModel.initialize( testCase.input );
+ assert.deepEqual(
+ queriesModel.getState(),
+ testCase.finalState,
+ testCase.msg
+ );
+ } );
+ } );
+
+ QUnit.test( 'Adding new queries', function ( assert ) {
+ var filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+ queriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
+ cases = [
+ {
+ methodParams: [
+ 'label1', // Label
+ { // Data
+ filter1: '1',
+ filter2: '2',
+ group1__filter1_color: 'c2',
+ group1__filter3_color: 'c5'
+ },
+ true, // isDefault
+ '1234' // ID
+ ],
+ result: {
+ itemState: {
+ label: 'label1',
+ data: {
+ params: {
+ filter1: '1',
+ filter2: '2'
+ },
+ highlights: {
+ group1__filter1_color: 'c2',
+ group1__filter3_color: 'c5'
+ }
+ }
+ },
+ isDefault: true,
+ id: '1234'
+ },
+ msg: 'Given valid data is preserved.'
+ },
+ {
+ methodParams: [
+ 'label2',
+ {
+ filter1: '1',
+ invert: '1',
+ filter15: '1', // Invalid filter - removed
+ filter2: '0', // Falsey value - removed
+ group1__filter1_color: 'c3',
+ foobar: 'w00t' // Unrecognized parameter - removed
+ }
+ ],
+ result: {
+ itemState: {
+ label: 'label2',
+ data: {
+ params: {
+ filter1: '1' // Invert will be dropped because there are no namespaces
+ },
+ highlights: {
+ group1__filter1_color: 'c3'
+ }
+ }
+ },
+ isDefault: false
+ },
+ msg: 'Given data with invalid filters and highlights is normalized'
+ }
+ ];
+
+ filtersModel.initializeFilters( filterDefinition );
+
+ // Start with an empty saved queries model
+ queriesModel.initialize( {} );
+
+ cases.forEach( function ( testCase ) {
+ var itemID = queriesModel.addNewQuery.apply( queriesModel, testCase.methodParams ),
+ item = queriesModel.getItemByID( itemID );
+
+ assert.deepEqual(
+ item.getState(),
+ testCase.result.itemState,
+ testCase.msg + ' (itemState)'
+ );
+
+ assert.equal(
+ item.isDefault(),
+ testCase.result.isDefault,
+ testCase.msg + ' (isDefault)'
+ );
+
+ if ( testCase.result.id !== undefined ) {
+ assert.equal(
+ item.getID(),
+ testCase.result.id,
+ testCase.msg + ' (item ID)'
+ );
+ }
+ } );
+ } );
+
+ QUnit.test( 'Manipulating queries', function ( assert ) {
+ var id1, id2, item1, matchingItem,
+ queriesStructure = {},
+ filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+ queriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel );
+
+ filtersModel.initializeFilters( filterDefinition );
+
+ // Start with an empty saved queries model
+ queriesModel.initialize( {} );
+
+ // Add items
+ id1 = queriesModel.addNewQuery(
+ 'New query 1',
+ {
+ group2: 'filter5',
+ group1__filter1_color: 'c5',
+ group3__group3option1_color: 'c1'
+ }
+ );
+ id2 = queriesModel.addNewQuery(
+ 'New query 2',
+ {
+ filter1: '1',
+ filter2: '1',
+ invert: '1'
+ }
+ );
+ item1 = queriesModel.getItemByID( id1 );
+
+ assert.equal(
+ item1.getID(),
+ id1,
+ 'Item created and its data retained successfully'
+ );
+
+ // NOTE: All other methods that the item itself returns are
+ // tested in the dm.SavedQueryItemModel.test.js file
+
+ // Build the query structure we expect per item
+ queriesStructure[ id1 ] = {
+ label: 'New query 1',
+ data: {
+ params: {
+ group2: 'filter5'
+ },
+ highlights: {
+ group1__filter1_color: 'c5',
+ group3__group3option1_color: 'c1'
+ }
+ }
+ };
+ queriesStructure[ id2 ] = {
+ label: 'New query 2',
+ data: {
+ params: {
+ filter1: '1',
+ filter2: '1'
+ },
+ highlights: {}
+ }
+ };
+
+ assert.deepEqual(
+ queriesModel.getState(),
+ {
+ version: '2',
+ queries: queriesStructure
+ },
+ 'Full query represents current state of items'
+ );
+
+ // Add default
+ queriesModel.setDefault( id2 );
+
+ assert.deepEqual(
+ queriesModel.getState(),
+ {
+ version: '2',
+ default: id2,
+ queries: queriesStructure
+ },
+ 'Setting default is reflected in queries state'
+ );
+
+ // Remove default
+ queriesModel.setDefault( null );
+
+ assert.deepEqual(
+ queriesModel.getState(),
+ {
+ version: '2',
+ queries: queriesStructure
+ },
+ 'Removing default is reflected in queries state'
+ );
+
+ // Find matching query
+ matchingItem = queriesModel.findMatchingQuery(
+ {
+ group2: 'filter5',
+ group1__filter1_color: 'c5',
+ group3__group3option1_color: 'c1'
+ }
+ );
+ assert.deepEqual(
+ matchingItem.getID(),
+ id1,
+ 'Finding matching item by identical state'
+ );
+
+ // Find matching query with 0-values (base state)
+ matchingItem = queriesModel.findMatchingQuery(
+ {
+ group2: 'filter5',
+ filter1: '0',
+ filter2: '0',
+ group1__filter1_color: 'c5',
+ group3__group3option1_color: 'c1'
+ }
+ );
+ assert.deepEqual(
+ matchingItem.getID(),
+ id1,
+ 'Finding matching item by "dirty" state with 0-base values'
+ );
+ } );
+
+ QUnit.test( 'Testing invert property', function ( assert ) {
+ var itemID, item,
+ filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+ queriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
+ viewsDefinition = {
+ namespace: {
+ label: 'Namespaces',
+ trigger: ':',
+ groups: [ {
+ name: 'namespace',
+ label: 'Namespaces',
+ type: 'string_options',
+ separator: ';',
+ filters: [
+ { name: 0, label: 'Main', cssClass: 'namespace-0' },
+ { name: 1, label: 'Talk', cssClass: 'namespace-1' },
+ { name: 2, label: 'User', cssClass: 'namespace-2' },
+ { name: 3, label: 'User talk', cssClass: 'namespace-3' }
+ ]
+ } ]
+ }
+ };
+
+ filtersModel.initializeFilters( filterDefinition, viewsDefinition );
+
+ // Start with an empty saved queries model
+ queriesModel.initialize( {} );
+
+ filtersModel.toggleFiltersSelected( {
+ group1__filter3: true,
+ invertGroup__invert: true
+ } );
+ itemID = queriesModel.addNewQuery(
+ 'label1', // Label
+ filtersModel.getMinimizedParamRepresentation(),
+ true, // isDefault
+ '2345' // ID
+ );
+ item = queriesModel.getItemByID( itemID );
+
+ assert.deepEqual(
+ item.getState(),
+ {
+ label: 'label1',
+ data: {
+ params: {
+ filter1: '1',
+ filter2: '1'
+ },
+ highlights: {}
+ }
+ },
+ 'Invert parameter is not saved if there are no namespaces.'
+ );
+
+ // Reset
+ filtersModel.initializeFilters( filterDefinition, viewsDefinition );
+ filtersModel.toggleFiltersSelected( {
+ group1__filter3: true,
+ invertGroup__invert: true,
+ namespace__1: true
+ } );
+ itemID = queriesModel.addNewQuery(
+ 'label1', // Label
+ filtersModel.getMinimizedParamRepresentation(),
+ true, // isDefault
+ '1234' // ID
+ );
+ item = queriesModel.getItemByID( itemID );
+
+ assert.deepEqual(
+ item.getState(),
+ {
+ label: 'label1',
+ data: {
+ params: {
+ filter1: '1',
+ filter2: '1',
+ invert: '1',
+ namespace: '1'
+ },
+ highlights: {}
+ }
+ },
+ 'Invert parameter saved if there are namespaces.'
+ );
+ } );
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js
new file mode 100644
index 00000000..181e9925
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js
@@ -0,0 +1,89 @@
+/* eslint-disable camelcase */
+( function ( mw ) {
+ var itemData = {
+ params: {
+ param1: '1',
+ param2: 'foo|bar',
+ invert: '0'
+ },
+ highlights: {
+ param1_color: 'c1',
+ param2_color: 'c2'
+ }
+ };
+
+ QUnit.module( 'mediawiki.rcfilters - SavedQueryItemModel' );
+
+ QUnit.test( 'Initializing and getters', function ( assert ) {
+ var model;
+
+ model = new mw.rcfilters.dm.SavedQueryItemModel(
+ 'randomID',
+ 'Some label',
+ $.extend( true, {}, itemData )
+ );
+
+ assert.equal(
+ model.getID(),
+ 'randomID',
+ 'Item ID is retained'
+ );
+
+ assert.equal(
+ model.getLabel(),
+ 'Some label',
+ 'Item label is retained'
+ );
+
+ assert.deepEqual(
+ model.getData(),
+ itemData,
+ 'Item data is retained'
+ );
+
+ assert.ok(
+ !model.isDefault(),
+ 'Item default state is retained.'
+ );
+ } );
+
+ QUnit.test( 'Default', function ( assert ) {
+ var model;
+
+ model = new mw.rcfilters.dm.SavedQueryItemModel(
+ 'randomID',
+ 'Some label',
+ $.extend( true, {}, itemData )
+ );
+
+ assert.ok(
+ !model.isDefault(),
+ 'Default state represented when item initialized with default:false.'
+ );
+
+ model.toggleDefault( true );
+ assert.ok(
+ model.isDefault(),
+ 'Default state toggles to true successfully'
+ );
+
+ model.toggleDefault( false );
+ assert.ok(
+ !model.isDefault(),
+ 'Default state toggles to false successfully'
+ );
+
+ // Reset
+ model = new mw.rcfilters.dm.SavedQueryItemModel(
+ 'randomID',
+ 'Some label',
+ $.extend( true, {}, itemData ),
+ { default: true }
+ );
+
+ assert.ok(
+ model.isDefault(),
+ 'Default state represented when item initialized with default:true.'
+ );
+ } );
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js
new file mode 100644
index 00000000..14c2bb4c
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js
@@ -0,0 +1,64 @@
+( function ( $ ) {
+ QUnit.module( 'mediawiki.special.recentchanges', QUnit.newMwEnvironment() );
+
+ // TODO: verify checkboxes == [ 'nsassociated', 'nsinvert' ]
+
+ QUnit.test( '"all" namespace disable checkboxes', function ( assert ) {
+ var selectHtml, $env, $options,
+ rc = require( 'mediawiki.special.recentchanges' );
+
+ // from Special:Recentchanges
+ selectHtml = '<select id="namespace" name="namespace" class="namespaceselector">'
+ + '<option value="" selected="selected">all</option>'
+ + '<option value="0">(Main)</option>'
+ + '<option value="1">Talk</option>'
+ + '<option value="2">User</option>'
+ + '<option value="3">User talk</option>'
+ + '<option value="4">ProjectName</option>'
+ + '<option value="5">ProjectName talk</option>'
+ + '</select>'
+ + '<input name="invert" type="checkbox" value="1" id="nsinvert" title="no title" />'
+ + '<label for="nsinvert" title="no title">Invert selection</label>'
+ + '<input name="associated" type="checkbox" value="1" id="nsassociated" title="no title" />'
+ + '<label for="nsassociated" title="no title">Associated namespace</label>'
+ + '<input type="submit" value="Go" />'
+ + '<input type="hidden" value="Special:RecentChanges" name="title" />';
+
+ $env = $( '<div>' ).html( selectHtml ).appendTo( 'body' );
+
+ // TODO abstract the double strictEquals
+
+ // At first checkboxes are enabled
+ assert.strictEqual( $( '#nsinvert' ).prop( 'disabled' ), false );
+ assert.strictEqual( $( '#nsassociated' ).prop( 'disabled' ), false );
+
+ // Initiate the recentchanges module
+ rc.init();
+
+ // By default
+ assert.strictEqual( $( '#nsinvert' ).prop( 'disabled' ), true );
+ assert.strictEqual( $( '#nsassociated' ).prop( 'disabled' ), true );
+
+ // select second option...
+ $options = $( '#namespace' ).find( 'option' );
+ $options.eq( 0 ).removeProp( 'selected' );
+ $options.eq( 1 ).prop( 'selected', true );
+ $( '#namespace' ).change();
+
+ // ... and checkboxes should be enabled again
+ assert.strictEqual( $( '#nsinvert' ).prop( 'disabled' ), false );
+ assert.strictEqual( $( '#nsassociated' ).prop( 'disabled' ), false );
+
+ // select first option ( 'all' namespace)...
+ $options.eq( 1 ).removeProp( 'selected' );
+ $options.eq( 0 ).prop( 'selected', true );
+ $( '#namespace' ).change();
+
+ // ... and checkboxes should now be disabled
+ assert.strictEqual( $( '#nsinvert' ).prop( 'disabled' ), true );
+ assert.strictEqual( $( '#nsassociated' ).prop( 'disabled' ), true );
+
+ // DOM cleanup
+ $env.remove();
+ } );
+}( jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js
new file mode 100644
index 00000000..4e15cf01
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js
@@ -0,0 +1,38 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.RegExp' );
+
+ QUnit.test( 'escape', function ( assert ) {
+ var specials, normal;
+
+ specials = [
+ '\\',
+ '{',
+ '}',
+ '(',
+ ')',
+ '[',
+ ']',
+ '|',
+ '.',
+ '?',
+ '*',
+ '+',
+ '-',
+ '^',
+ '$'
+ ];
+
+ normal = [
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+ 'abcdefghijklmnopqrstuvwxyz',
+ '0123456789'
+ ].join( '' );
+
+ specials.forEach( function ( str ) {
+ assert.propEqual( str.match( new RegExp( mw.RegExp.escape( str ) ) ), [ str ], 'Match ' + str );
+ } );
+
+ assert.equal( mw.RegExp.escape( normal ), normal, 'Alphanumerals are left alone' );
+ } );
+
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js
new file mode 100644
index 00000000..ae3ebbf7
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js
@@ -0,0 +1,39 @@
+( function () {
+ var byteLength = require( 'mediawiki.String' ).byteLength;
+
+ QUnit.module( 'mediawiki.String.byteLength', QUnit.newMwEnvironment() );
+
+ QUnit.test( 'Simple text', function ( assert ) {
+ var azLc = 'abcdefghijklmnopqrstuvwxyz',
+ azUc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+ num = '0123456789',
+ x = '*',
+ space = ' ';
+
+ assert.equal( byteLength( azLc ), 26, 'Lowercase a-z' );
+ assert.equal( byteLength( azUc ), 26, 'Uppercase A-Z' );
+ assert.equal( byteLength( num ), 10, 'Numbers 0-9' );
+ assert.equal( byteLength( x ), 1, 'An asterisk' );
+ assert.equal( byteLength( space ), 3, '3 spaces' );
+
+ } );
+
+ QUnit.test( 'Special text', function ( assert ) {
+ // https://en.wikipedia.org/wiki/UTF-8
+ var u0024 = '$',
+ // Cent symbol
+ u00A2 = '\u00A2',
+ // Euro symbol
+ u20AC = '\u20AC',
+ // Character \U00024B62 (Han script) can't be represented in javascript as a single
+ // code point, instead it is composed as a surrogate pair of two separate code units.
+ // http://codepoints.net/U+24B62
+ // http://www.fileformat.info/info/unicode/char/24B62/index.htm
+ u024B62 = '\uD852\uDF62';
+
+ assert.strictEqual( byteLength( u0024 ), 1, 'U+0024' );
+ assert.strictEqual( byteLength( u00A2 ), 2, 'U+00A2' );
+ assert.strictEqual( byteLength( u20AC ), 3, 'U+20AC' );
+ assert.strictEqual( byteLength( u024B62 ), 4, 'U+024B62 (surrogate pair: \\uD852\\uDF62)' );
+ } );
+}() );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js
new file mode 100644
index 00000000..e2eea94e
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js
@@ -0,0 +1,150 @@
+( function ( $, mw ) {
+ var simpleSample, U_20AC, poop, mbSample,
+ trimByteLength = require( 'mediawiki.String' ).trimByteLength;
+
+ QUnit.module( 'mediawiki.String.trimByteLength', QUnit.newMwEnvironment() );
+
+ // Simple sample (20 chars, 20 bytes)
+ simpleSample = '12345678901234567890';
+
+ // 3 bytes (euro-symbol)
+ U_20AC = '\u20AC';
+
+ // Outside of the BMP (pile of poo emoji)
+ poop = '\uD83D\uDCA9'; // "💩"
+
+ // Multi-byte sample (22 chars, 26 bytes)
+ mbSample = '1234567890' + U_20AC + '1234567890' + U_20AC;
+
+ /**
+ * Test factory for mw.String#trimByteLength
+ *
+ * @param {Object} options
+ * @param {string} options.description Test name
+ * @param {string} options.sample Sequence of characters to trim
+ * @param {string} [options.initial] Previous value of the sequence of characters, if any
+ * @param {Number} options.limit Length to trim to
+ * @param {Function} [options.fn] Filter function
+ * @param {string} options.expected Expected final value
+ */
+ function byteLimitTest( options ) {
+ var opt = $.extend( {
+ description: '',
+ sample: '',
+ initial: '',
+ limit: 0,
+ fn: function ( a ) { return a; },
+ expected: ''
+ }, options );
+
+ QUnit.test( opt.description, function ( assert ) {
+ var res = trimByteLength( opt.initial, opt.sample, opt.limit, opt.fn );
+
+ assert.equal(
+ res.newVal,
+ opt.expected,
+ 'New value matches the expected string'
+ );
+ } );
+ }
+
+ byteLimitTest( {
+ description: 'Limit using the maxlength attribute',
+ limit: 10,
+ sample: simpleSample,
+ expected: '1234567890'
+ } );
+
+ byteLimitTest( {
+ description: 'Limit using a custom value (multibyte)',
+ limit: 14,
+ sample: mbSample,
+ expected: '1234567890' + U_20AC + '1'
+ } );
+
+ byteLimitTest( {
+ description: 'Limit using a custom value (multibyte, outside BMP)',
+ limit: 3,
+ sample: poop,
+ expected: ''
+ } );
+
+ byteLimitTest( {
+ description: 'Limit using a custom value (multibyte) overlapping a byte',
+ limit: 12,
+ sample: mbSample,
+ expected: '1234567890'
+ } );
+
+ byteLimitTest( {
+ description: 'Pass the limit and a callback as input filter',
+ limit: 6,
+ fn: function ( val ) {
+ var title = mw.Title.newFromText( String( val ) );
+ // Return without namespace prefix
+ return title ? title.getMain() : '';
+ },
+ sample: 'User:Sample',
+ expected: 'User:Sample'
+ } );
+
+ byteLimitTest( {
+ description: 'Pass the limit and a callback as input filter',
+ limit: 6,
+ fn: function ( val ) {
+ var title = mw.Title.newFromText( String( val ) );
+ // Return without namespace prefix
+ return title ? title.getMain() : '';
+ },
+ sample: 'User:Example',
+ // The callback alters the value to be used to calculeate
+ // the length. The altered value is "Exampl" which has
+ // a length of 6, the "e" would exceed the limit.
+ expected: 'User:Exampl'
+ } );
+
+ byteLimitTest( {
+ description: 'Input filter that increases the length',
+ limit: 10,
+ fn: function ( text ) {
+ return 'prefix' + text;
+ },
+ sample: simpleSample,
+ // Prefix adds 6 characters, limit is reached after 4
+ expected: '1234'
+ } );
+
+ byteLimitTest( {
+ description: 'Trim from insertion when limit exceeded',
+ limit: 3,
+ initial: 'abc',
+ sample: 'zabc',
+ // Trim from the insertion point (at 0), not the end
+ expected: 'abc'
+ } );
+
+ byteLimitTest( {
+ description: 'Trim from insertion when limit exceeded',
+ limit: 3,
+ initial: 'abc',
+ sample: 'azbc',
+ // Trim from the insertion point (at 1), not the end
+ expected: 'abc'
+ } );
+
+ byteLimitTest( {
+ description: 'Do not cut up false matching substrings in emoji insertions',
+ limit: 12,
+ initial: '\uD83D\uDCA9\uD83D\uDCA9', // "💩💩"
+ sample: '\uD83D\uDCA9\uD83D\uDCB9\uD83E\uDCA9\uD83D\uDCA9', // "💩💹🢩💩"
+ expected: '\uD83D\uDCA9\uD83D\uDCB9\uD83D\uDCA9' // "💩💹💩"
+ } );
+
+ byteLimitTest( {
+ description: 'Unpaired surrogates do not crash',
+ limit: 4,
+ sample: '\uD800\uD800\uDFFF',
+ expected: '\uD800'
+ } );
+
+}( jQuery, mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js
new file mode 100644
index 00000000..d6fe744f
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js
@@ -0,0 +1,738 @@
+( function ( mw, $ ) {
+ /* eslint-disable camelcase */
+ var repeat = function ( input, multiplier ) {
+ return new Array( multiplier + 1 ).join( input );
+ },
+ // See also TitleTest.php#testSecureAndSplit
+ cases = {
+ valid: [
+ 'Sandbox',
+ 'A "B"',
+ 'A \'B\'',
+ '.com',
+ '~',
+ '"',
+ '\'',
+ 'Talk:Sandbox',
+ 'Talk:Foo:Sandbox',
+ 'File:Example.svg',
+ 'File_talk:Example.svg',
+ 'Foo/.../Sandbox',
+ 'Sandbox/...',
+ 'A~~',
+ ':A',
+ // Length is 256 total, but only title part matters
+ 'Category:' + repeat( 'x', 248 ),
+ repeat( 'x', 252 )
+ ],
+ invalid: [
+ '',
+ ':',
+ '__ __',
+ ' __ ',
+ // Bad characters forbidden regardless of wgLegalTitleChars
+ 'A [ B',
+ 'A ] B',
+ 'A { B',
+ 'A } B',
+ 'A < B',
+ 'A > B',
+ 'A | B',
+ 'A \t B',
+ 'A \n B',
+ // URL encoding
+ 'A%20B',
+ 'A%23B',
+ 'A%2523B',
+ // XML/HTML character entity references
+ // Note: The ones with # are commented out as those are interpreted as fragment and
+ // as such end up being valid.
+ 'A &eacute; B',
+ // 'A &#233; B',
+ // 'A &#x00E9; B',
+ // Subject of NS_TALK does not roundtrip to NS_MAIN
+ 'Talk:File:Example.svg',
+ // Directory navigation
+ '.',
+ '..',
+ './Sandbox',
+ '../Sandbox',
+ 'Foo/./Sandbox',
+ 'Foo/../Sandbox',
+ 'Sandbox/.',
+ 'Sandbox/..',
+ // Tilde
+ 'A ~~~ Name',
+ 'A ~~~~ Signature',
+ 'A ~~~~~ Timestamp',
+ repeat( 'x', 256 ),
+ // Extension separation is a js invention, for length
+ // purposes it is part of the title
+ repeat( 'x', 252 ) + '.json',
+ // Namespace prefix without actual title
+ 'Talk:',
+ 'Category: ',
+ 'Category: #bar'
+ ]
+ };
+
+ QUnit.module( 'mediawiki.Title', QUnit.newMwEnvironment( {
+ // mw.Title relies on these three config vars
+ // Restore them after each test run
+ config: {
+ wgFormattedNamespaces: {
+ '-2': 'Media',
+ '-1': 'Special',
+ 0: '',
+ 1: 'Talk',
+ 2: 'User',
+ 3: 'User talk',
+ 4: 'Wikipedia',
+ 5: 'Wikipedia talk',
+ 6: 'File',
+ 7: 'File talk',
+ 8: 'MediaWiki',
+ 9: 'MediaWiki talk',
+ 10: 'Template',
+ 11: 'Template talk',
+ 12: 'Help',
+ 13: 'Help talk',
+ 14: 'Category',
+ 15: 'Category talk',
+ // testing custom / localized namespace
+ 100: 'Penguins'
+ },
+ wgNamespaceIds: {
+ media: -2,
+ special: -1,
+ '': 0,
+ talk: 1,
+ user: 2,
+ user_talk: 3,
+ wikipedia: 4,
+ wikipedia_talk: 5,
+ file: 6,
+ file_talk: 7,
+ mediawiki: 8,
+ mediawiki_talk: 9,
+ template: 10,
+ template_talk: 11,
+ help: 12,
+ help_talk: 13,
+ category: 14,
+ category_talk: 15,
+ image: 6,
+ image_talk: 7,
+ project: 4,
+ project_talk: 5,
+ // Testing custom namespaces and aliases
+ penguins: 100,
+ antarctic_waterfowl: 100
+ },
+ wgCaseSensitiveNamespaces: []
+ }
+ } ) );
+
+ QUnit.test( 'constructor', function ( assert ) {
+ var i, title;
+ for ( i = 0; i < cases.valid.length; i++ ) {
+ title = new mw.Title( cases.valid[ i ] );
+ }
+ for ( i = 0; i < cases.invalid.length; i++ ) {
+ title = cases.invalid[ i ];
+ // eslint-disable-next-line no-loop-func
+ assert.throws( function () {
+ return new mw.Title( title );
+ }, cases.invalid[ i ] );
+ }
+ } );
+
+ QUnit.test( 'newFromText', function ( assert ) {
+ var i;
+ for ( i = 0; i < cases.valid.length; i++ ) {
+ assert.equal(
+ $.type( mw.Title.newFromText( cases.valid[ i ] ) ),
+ 'object',
+ cases.valid[ i ]
+ );
+ }
+ for ( i = 0; i < cases.invalid.length; i++ ) {
+ assert.equal(
+ $.type( mw.Title.newFromText( cases.invalid[ i ] ) ),
+ 'null',
+ cases.invalid[ i ]
+ );
+ }
+ } );
+
+ QUnit.test( 'makeTitle', function ( assert ) {
+ var cases, i, title, expected,
+ NS_MAIN = 0,
+ NS_TALK = 1,
+ NS_TEMPLATE = 10;
+
+ cases = [
+ [ NS_TEMPLATE, 'Foo', 'Template:Foo' ],
+ [ NS_TEMPLATE, 'Category:Foo', 'Template:Category:Foo' ],
+ [ NS_TEMPLATE, 'Template:Foo', 'Template:Template:Foo' ],
+ [ NS_TALK, 'Help:Foo', null ],
+ [ NS_TEMPLATE, '<', null ],
+ [ NS_MAIN, 'Help:Foo', 'Help:Foo' ]
+ ];
+
+ for ( i = 0; i < cases.length; i++ ) {
+ title = mw.Title.makeTitle( cases[ i ][ 0 ], cases[ i ][ 1 ] );
+ expected = cases[ i ][ 2 ];
+ if ( expected === null ) {
+ assert.strictEqual( title, expected );
+ } else {
+ assert.strictEqual( title.getPrefixedText(), expected );
+ }
+ }
+ } );
+
+ QUnit.test( 'Basic parsing', function ( assert ) {
+ var title;
+ title = new mw.Title( 'File:Foo_bar.JPG' );
+
+ assert.equal( title.getNamespaceId(), 6 );
+ assert.equal( title.getNamespacePrefix(), 'File:' );
+ assert.equal( title.getName(), 'Foo_bar' );
+ assert.equal( title.getNameText(), 'Foo bar' );
+ assert.equal( title.getExtension(), 'JPG' );
+ assert.equal( title.getDotExtension(), '.JPG' );
+ assert.equal( title.getMain(), 'Foo_bar.JPG' );
+ assert.equal( title.getMainText(), 'Foo bar.JPG' );
+ assert.equal( title.getPrefixedDb(), 'File:Foo_bar.JPG' );
+ assert.equal( title.getPrefixedText(), 'File:Foo bar.JPG' );
+
+ title = new mw.Title( 'Foo#bar' );
+ assert.equal( title.getPrefixedText(), 'Foo' );
+ assert.equal( title.getFragment(), 'bar' );
+
+ title = new mw.Title( '.foo' );
+ assert.equal( title.getPrefixedText(), '.foo' );
+ assert.equal( title.getName(), '' );
+ assert.equal( title.getNameText(), '' );
+ assert.equal( title.getExtension(), 'foo' );
+ assert.equal( title.getDotExtension(), '.foo' );
+ assert.equal( title.getMain(), '.foo' );
+ assert.equal( title.getMainText(), '.foo' );
+ assert.equal( title.getPrefixedDb(), '.foo' );
+ assert.equal( title.getPrefixedText(), '.foo' );
+ } );
+
+ QUnit.test( 'Transformation', function ( assert ) {
+ var title;
+
+ title = new mw.Title( 'File:quux pif.jpg' );
+ assert.equal( title.getNameText(), 'Quux pif', 'First character of title' );
+
+ title = new mw.Title( 'File:Glarg_foo_glang.jpg' );
+ assert.equal( title.getNameText(), 'Glarg foo glang', 'Underscores' );
+
+ title = new mw.Title( 'User:ABC.DEF' );
+ assert.equal( title.toText(), 'User:ABC.DEF', 'Round trip text' );
+ assert.equal( title.getNamespaceId(), 2, 'Parse canonical namespace prefix' );
+
+ title = new mw.Title( 'Image:quux pix.jpg' );
+ assert.equal( title.getNamespacePrefix(), 'File:', 'Transform alias to canonical namespace' );
+
+ title = new mw.Title( 'uSEr:hAshAr' );
+ assert.equal( title.toText(), 'User:HAshAr' );
+ assert.equal( title.getNamespaceId(), 2, 'Case-insensitive namespace prefix' );
+
+ title = new mw.Title( 'Foo \u00A0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000 bar' );
+ assert.equal( title.getMain(), 'Foo_bar', 'Merge multiple types of whitespace/underscores into a single underscore' );
+
+ title = new mw.Title( 'Foo\u200E\u200F\u202A\u202B\u202C\u202D\u202Ebar' );
+ assert.equal( title.getMain(), 'Foobar', 'Strip Unicode bidi override characters' );
+
+ // Regression test: Previously it would only detect an extension if there is no space after it
+ title = new mw.Title( 'Example.js ' );
+ assert.equal( title.getExtension(), 'js', 'Space after an extension is stripped' );
+
+ title = new mw.Title( 'Example#foo' );
+ assert.equal( title.getFragment(), 'foo', 'Fragment' );
+
+ title = new mw.Title( 'Example#_foo_bar baz_' );
+ assert.equal( title.getFragment(), ' foo bar baz', 'Fragment' );
+ } );
+
+ QUnit.test( 'Namespace detection and conversion', function ( assert ) {
+ var title;
+
+ title = new mw.Title( 'File:User:Example' );
+ assert.equal( title.getNamespaceId(), 6, 'Titles can contain namespace prefixes, which are otherwise ignored' );
+
+ title = new mw.Title( 'Example', 6 );
+ assert.equal( title.getNamespaceId(), 6, 'Default namespace passed is used' );
+
+ title = new mw.Title( 'User:Example', 6 );
+ assert.equal( title.getNamespaceId(), 2, 'Included namespace prefix overrides the given default' );
+
+ title = new mw.Title( ':Example', 6 );
+ assert.equal( title.getNamespaceId(), 0, 'Colon forces main namespace' );
+
+ title = new mw.Title( 'something.PDF', 6 );
+ assert.equal( title.toString(), 'File:Something.PDF' );
+
+ title = new mw.Title( 'NeilK', 3 );
+ assert.equal( title.toString(), 'User_talk:NeilK' );
+ assert.equal( title.toText(), 'User talk:NeilK' );
+
+ title = new mw.Title( 'Frobisher', 100 );
+ assert.equal( title.toString(), 'Penguins:Frobisher' );
+
+ title = new mw.Title( 'antarctic_waterfowl:flightless_yet_cute.jpg' );
+ assert.equal( title.toString(), 'Penguins:Flightless_yet_cute.jpg' );
+
+ title = new mw.Title( 'Penguins:flightless_yet_cute.jpg' );
+ assert.equal( title.toString(), 'Penguins:Flightless_yet_cute.jpg' );
+ } );
+
+ QUnit.test( 'Throw error on invalid title', function ( assert ) {
+ assert.throws( function () {
+ return new mw.Title( '' );
+ }, 'Throw error on empty string' );
+ } );
+
+ QUnit.test( 'Case-sensivity', function ( assert ) {
+ var title;
+
+ // Default config
+ mw.config.set( 'wgCaseSensitiveNamespaces', [] );
+
+ title = new mw.Title( 'article' );
+ assert.equal( title.toString(), 'Article', 'Default config: No sensitive namespaces by default. First-letter becomes uppercase' );
+
+ title = new mw.Title( 'ß' );
+ assert.equal( title.toString(), 'ß', 'Uppercasing matches PHP behaviour (ß -> ß, not SS)' );
+
+ title = new mw.Title( 'dž (digraph)' );
+ assert.equal( title.toString(), 'Dž_(digraph)', 'Uppercasing matches PHP behaviour (dž -> Dž, not DŽ)' );
+
+ // $wgCapitalLinks = false;
+ mw.config.set( 'wgCaseSensitiveNamespaces', [ 0, -2, 1, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15 ] );
+
+ title = new mw.Title( 'article' );
+ assert.equal( title.toString(), 'article', '$wgCapitalLinks=false: Article namespace is sensitive, first-letter case stays lowercase' );
+
+ title = new mw.Title( 'john', 2 );
+ assert.equal( title.toString(), 'User:John', '$wgCapitalLinks=false: User namespace is insensitive, first-letter becomes uppercase' );
+ } );
+
+ QUnit.test( 'toString / toText', function ( assert ) {
+ var title = new mw.Title( 'Some random page' );
+
+ assert.equal( title.toString(), title.getPrefixedDb() );
+ assert.equal( title.toText(), title.getPrefixedText() );
+ } );
+
+ QUnit.test( 'getExtension', function ( assert ) {
+ function extTest( pagename, ext, description ) {
+ var title = new mw.Title( pagename );
+ assert.equal( title.getExtension(), ext, description || pagename );
+ }
+
+ extTest( 'MediaWiki:Vector.js', 'js' );
+ extTest( 'User:Example/common.css', 'css' );
+ extTest( 'File:Example.longextension', 'longextension', 'Extension parsing not limited (T38151)' );
+ extTest( 'Example/information.json', 'json', 'Extension parsing not restricted from any namespace' );
+ extTest( 'Foo.', null, 'Trailing dot is not an extension' );
+ extTest( 'Foo..', null, 'Trailing dots are not an extension' );
+ extTest( 'Foo.a.', null, 'Page name with dots and ending in a dot does not have an extension' );
+
+ // @broken: Throws an exception
+ // extTest( '.NET', null, 'Leading dot is (or is not?) an extension' );
+ } );
+
+ QUnit.test( 'exists', function ( assert ) {
+ var title;
+
+ // Empty registry, checks default to null
+
+ title = new mw.Title( 'Some random page', 4 );
+ assert.strictEqual( title.exists(), null, 'Return null with empty existance registry' );
+
+ // Basic registry, checks default to boolean
+ mw.Title.exist.set( [ 'Does_exist', 'User_talk:NeilK', 'Wikipedia:Sandbox_rules' ], true );
+ mw.Title.exist.set( [ 'Does_not_exist', 'User:John', 'Foobar' ], false );
+
+ title = new mw.Title( 'Project:Sandbox rules' );
+ assert.assertTrue( title.exists(), 'Return true for page titles marked as existing' );
+ title = new mw.Title( 'Foobar' );
+ assert.assertFalse( title.exists(), 'Return false for page titles marked as nonexistent' );
+
+ } );
+
+ QUnit.test( 'getUrl', function ( assert ) {
+ var title;
+ mw.config.set( {
+ wgScript: '/w/index.php',
+ wgArticlePath: '/wiki/$1'
+ } );
+
+ title = new mw.Title( 'Foobar' );
+ assert.equal( title.getUrl(), '/wiki/Foobar', 'Basic functionality, getUrl uses mw.util.getUrl' );
+ assert.equal( title.getUrl( { action: 'edit' } ), '/w/index.php?title=Foobar&action=edit', 'Basic functionality, \'params\' parameter' );
+
+ title = new mw.Title( 'John Doe', 3 );
+ assert.equal( title.getUrl(), '/wiki/User_talk:John_Doe', 'Escaping in title and namespace for urls' );
+
+ title = new mw.Title( 'John Cena#And_His_Name_Is', 3 );
+ assert.equal( title.getUrl( { meme: true } ), '/w/index.php?title=User_talk:John_Cena&meme=true#And_His_Name_Is', 'title with fragment and query parameter' );
+ } );
+
+ QUnit.test( 'newFromImg', function ( assert ) {
+ var title, i, thisCase, prefix,
+ cases = [
+ {
+ url: '//upload.wikimedia.org/wikipedia/commons/thumb/b/bf/Princess_Alexandra_of_Denmark_%28later_Queen_Alexandra%2C_wife_of_Edward_VII%29_with_her_two_eldest_sons%2C_Prince_Albert_Victor_%28Eddy%29_and_George_Frederick_Ernest_Albert_%28later_George_V%29.jpg/939px-thumbnail.jpg',
+ typeOfUrl: 'Hashed thumb with shortened path',
+ nameText: 'Princess Alexandra of Denmark (later Queen Alexandra, wife of Edward VII) with her two eldest sons, Prince Albert Victor (Eddy) and George Frederick Ernest Albert (later George V)',
+ prefixedText: 'File:Princess Alexandra of Denmark (later Queen Alexandra, wife of Edward VII) with her two eldest sons, Prince Albert Victor (Eddy) and George Frederick Ernest Albert (later George V).jpg'
+ },
+
+ {
+ url: '//upload.wikimedia.org/wikipedia/commons/thumb/b/bf/Princess_Alexandra_of_Denmark_%28later_Queen_Alexandra%2C_wife_of_Edward_VII%29_with_her_two_eldest_sons%2C_Prince_Albert_Victor_%28Eddy%29_and_George_Frederick_Ernest_Albert_%28later_George_V%29.jpg/939px-ki708pr1r6g2dl5lbhvwdqxenhait13.jpg',
+ typeOfUrl: 'Hashed thumb with sha1-ed path',
+ nameText: 'Princess Alexandra of Denmark (later Queen Alexandra, wife of Edward VII) with her two eldest sons, Prince Albert Victor (Eddy) and George Frederick Ernest Albert (later George V)',
+ prefixedText: 'File:Princess Alexandra of Denmark (later Queen Alexandra, wife of Edward VII) with her two eldest sons, Prince Albert Victor (Eddy) and George Frederick Ernest Albert (later George V).jpg'
+ },
+
+ {
+ url: '/wiki/images/thumb/9/91/Anticlockwise_heliotrope%27s.jpg/99px-Anticlockwise_heliotrope%27s.jpg',
+ typeOfUrl: 'Normal hashed directory thumbnail',
+ nameText: 'Anticlockwise heliotrope\'s',
+ prefixedText: 'File:Anticlockwise heliotrope\'s.jpg'
+ },
+
+ {
+ url: '/wiki/images/thumb/8/80/Wikipedia-logo-v2.svg/langde-150px-Wikipedia-logo-v2.svg.png',
+ typeOfUrl: 'Normal hashed directory thumbnail with complex thumbnail parameters',
+ nameText: 'Wikipedia-logo-v2',
+ prefixedText: 'File:Wikipedia-logo-v2.svg'
+ },
+
+ {
+ url: '//upload.wikimedia.org/wikipedia/commons/thumb/8/80/Wikipedia-logo-v2.svg/150px-Wikipedia-logo-v2.svg.png',
+ typeOfUrl: 'Commons thumbnail',
+ nameText: 'Wikipedia-logo-v2',
+ prefixedText: 'File:Wikipedia-logo-v2.svg'
+ },
+
+ {
+ url: '/wiki/images/9/91/Anticlockwise_heliotrope%27s.jpg',
+ typeOfUrl: 'Full image',
+ nameText: 'Anticlockwise heliotrope\'s',
+ prefixedText: 'File:Anticlockwise heliotrope\'s.jpg'
+ },
+
+ {
+ url: 'http://localhost/thumb.php?f=Stuffless_Figaro%27s.jpg&width=180',
+ typeOfUrl: 'thumb.php-based thumbnail',
+ nameText: 'Stuffless Figaro\'s',
+ prefixedText: 'File:Stuffless Figaro\'s.jpg'
+ },
+
+ {
+ url: '/wikipedia/commons/thumb/Wikipedia-logo-v2.svg/150px-Wikipedia-logo-v2.svg.png',
+ typeOfUrl: 'Commons unhashed thumbnail',
+ nameText: 'Wikipedia-logo-v2',
+ prefixedText: 'File:Wikipedia-logo-v2.svg'
+ },
+
+ {
+ url: '/wikipedia/commons/thumb/Wikipedia-logo-v2.svg/langde-150px-Wikipedia-logo-v2.svg.png',
+ typeOfUrl: 'Commons unhashed thumbnail with complex thumbnail parameters',
+ nameText: 'Wikipedia-logo-v2',
+ prefixedText: 'File:Wikipedia-logo-v2.svg'
+ },
+
+ {
+ url: '/wiki/images/Anticlockwise_heliotrope%27s.jpg',
+ typeOfUrl: 'Unhashed local file',
+ nameText: 'Anticlockwise heliotrope\'s',
+ prefixedText: 'File:Anticlockwise heliotrope\'s.jpg'
+ },
+
+ {
+ url: '',
+ typeOfUrl: 'Empty string'
+ },
+
+ {
+ url: 'foo',
+ typeOfUrl: 'String with only alphabet characters'
+ },
+
+ {
+ url: 'foobar.foobar',
+ typeOfUrl: 'Not a file path'
+ },
+
+ {
+ url: '/a/a0/blah blah blah',
+ typeOfUrl: 'Space characters'
+ }
+ ];
+
+ for ( i = 0; i < cases.length; i++ ) {
+ thisCase = cases[ i ];
+ title = mw.Title.newFromImg( { src: thisCase.url } );
+
+ if ( thisCase.nameText !== undefined ) {
+ prefix = '[' + thisCase.typeOfUrl + ' URL] ';
+
+ assert.notStrictEqual( title, null, prefix + 'Parses successfully' );
+ assert.equal( title.getNameText(), thisCase.nameText, prefix + 'Filename matches original' );
+ assert.equal( title.getPrefixedText(), thisCase.prefixedText, prefix + 'File page title matches original' );
+ assert.equal( title.getNamespaceId(), 6, prefix + 'Namespace ID matches File namespace' );
+ } else {
+ assert.strictEqual( title, null, thisCase.typeOfUrl + ', should not produce an mw.Title object' );
+ }
+ }
+ } );
+
+ QUnit.test( 'getRelativeText', function ( assert ) {
+ var i, thisCase, title,
+ cases = [
+ {
+ text: 'asd',
+ relativeTo: 123,
+ expectedResult: ':Asd'
+ },
+ {
+ text: 'dfg',
+ relativeTo: 0,
+ expectedResult: 'Dfg'
+ },
+ {
+ text: 'Template:Ghj',
+ relativeTo: 0,
+ expectedResult: 'Template:Ghj'
+ },
+ {
+ text: 'Template:1',
+ relativeTo: 10,
+ expectedResult: '1'
+ },
+ {
+ text: 'User:Hi',
+ relativeTo: 10,
+ expectedResult: 'User:Hi'
+ }
+ ];
+
+ for ( i = 0; i < cases.length; i++ ) {
+ thisCase = cases[ i ];
+
+ title = mw.Title.newFromText( thisCase.text );
+ assert.equal( title.getRelativeText( thisCase.relativeTo ), thisCase.expectedResult );
+ }
+ } );
+
+ QUnit.test( 'normalizeExtension', function ( assert ) {
+ var extension, i, thisCase, prefix,
+ cases = [
+ {
+ extension: 'png',
+ expected: 'png',
+ description: 'Extension already in canonical form'
+ },
+ {
+ extension: 'PNG',
+ expected: 'png',
+ description: 'Extension lowercased in canonical form'
+ },
+ {
+ extension: 'jpeg',
+ expected: 'jpg',
+ description: 'Extension changed in canonical form'
+ },
+ {
+ extension: 'JPEG',
+ expected: 'jpg',
+ description: 'Extension lowercased and changed in canonical form'
+ },
+ {
+ extension: '~~~',
+ expected: '',
+ description: 'Extension invalid and discarded'
+ }
+ ];
+
+ for ( i = 0; i < cases.length; i++ ) {
+ thisCase = cases[ i ];
+ extension = mw.Title.normalizeExtension( thisCase.extension );
+
+ prefix = '[' + thisCase.description + '] ';
+ assert.equal( extension, thisCase.expected, prefix + 'Extension as expected' );
+ }
+ } );
+
+ QUnit.test( 'newFromUserInput', function ( assert ) {
+ var title, i, thisCase, prefix,
+ cases = [
+ {
+ title: 'DCS0001557854455.JPG',
+ expected: 'DCS0001557854455.JPG',
+ description: 'Title in normal namespace without anything invalid but with "file extension"'
+ },
+ {
+ title: 'MediaWiki:Msg-awesome',
+ expected: 'MediaWiki:Msg-awesome',
+ description: 'Full title (page in MediaWiki namespace) supplied as string'
+ },
+ {
+ title: 'The/Mw/Sound.flac',
+ defaultNamespace: -2,
+ expected: 'Media:The-Mw-Sound.flac',
+ description: 'Page in Media-namespace without explicit options'
+ },
+ {
+ title: 'File:The/Mw/Sound.kml',
+ defaultNamespace: 6,
+ options: {
+ forUploading: false
+ },
+ expected: 'File:The/Mw/Sound.kml',
+ description: 'Page in File-namespace without explicit options'
+ },
+ {
+ title: 'File:Foo.JPEG',
+ expected: 'File:Foo.JPEG',
+ description: 'Page in File-namespace with non-canonical extension'
+ },
+ {
+ title: 'File:Foo.JPEG ',
+ expected: 'File:Foo.JPEG',
+ description: 'Page in File-namespace with trailing whitespace'
+ }
+ ];
+
+ for ( i = 0; i < cases.length; i++ ) {
+ thisCase = cases[ i ];
+ title = mw.Title.newFromUserInput( thisCase.title, thisCase.defaultNamespace, thisCase.options );
+
+ if ( thisCase.expected !== undefined ) {
+ prefix = '[' + thisCase.description + '] ';
+
+ assert.notStrictEqual( title, null, prefix + 'Parses successfully' );
+ assert.equal( title.toText(), thisCase.expected, prefix + 'Title as expected' );
+ } else {
+ assert.strictEqual( title, null, thisCase.description + ', should not produce an mw.Title object' );
+ }
+ }
+ } );
+
+ QUnit.test( 'newFromFileName', function ( assert ) {
+ var title, i, thisCase, prefix,
+ cases = [
+ {
+ fileName: 'DCS0001557854455.JPG',
+ typeOfName: 'Standard camera output',
+ nameText: 'DCS0001557854455',
+ prefixedText: 'File:DCS0001557854455.JPG'
+ },
+ {
+ fileName: 'File:Sample.png',
+ typeOfName: 'Carrying namespace',
+ nameText: 'File-Sample',
+ prefixedText: 'File:File-Sample.png'
+ },
+ {
+ fileName: 'Treppe 2222 Test upload.jpg',
+ typeOfName: 'File name with spaces in it and lower case file extension',
+ nameText: 'Treppe 2222 Test upload',
+ prefixedText: 'File:Treppe 2222 Test upload.jpg'
+ },
+ {
+ fileName: 'I contain a \ttab.jpg',
+ typeOfName: 'Name containing a tab character',
+ nameText: 'I contain a tab',
+ prefixedText: 'File:I contain a tab.jpg'
+ },
+ {
+ fileName: 'I_contain multiple__ ___ _underscores.jpg',
+ typeOfName: 'Name containing multiple underscores',
+ nameText: 'I contain multiple underscores',
+ prefixedText: 'File:I contain multiple underscores.jpg'
+ },
+ {
+ fileName: 'I like ~~~~~~~~es.jpg',
+ typeOfName: 'Name containing more than three consecutive tilde characters',
+ nameText: 'I like ~~es',
+ prefixedText: 'File:I like ~~es.jpg'
+ },
+ {
+ fileName: 'BI\u200EDI.jpg',
+ typeOfName: 'Name containing BIDI overrides',
+ nameText: 'BIDI',
+ prefixedText: 'File:BIDI.jpg'
+ },
+ {
+ fileName: '100%ab progress.jpg',
+ typeOfName: 'File name with URL encoding',
+ nameText: '100% ab progress',
+ prefixedText: 'File:100% ab progress.jpg'
+ },
+ {
+ fileName: '<([>]):/#.jpg',
+ typeOfName: 'File name with characters not permitted in titles that are replaced',
+ nameText: '((()))---',
+ prefixedText: 'File:((()))---.jpg'
+ },
+ {
+ fileName: 'spaces\u0009\u2000\u200A\u200Bx.djvu',
+ typeOfName: 'File name with different kind of spaces',
+ nameText: 'Spaces \u200Bx',
+ prefixedText: 'File:Spaces \u200Bx.djvu'
+ },
+ {
+ fileName: 'dot.dot.dot.dot.dotdot',
+ typeOfName: 'File name with a lot of dots',
+ nameText: 'Dot.dot.dot.dot',
+ prefixedText: 'File:Dot.dot.dot.dot.dotdot'
+ },
+ {
+ fileName: 'dot. dot ._dot',
+ typeOfName: 'File name with multiple dots and spaces',
+ nameText: 'Dot. dot',
+ prefixedText: 'File:Dot. dot. dot'
+ },
+ {
+ fileName: '𠜎𠜱𠝹𠱓𠱸𠲖𠳏𠳕𠴕𠵼𠵿𠸎𠸏𠹷𠺝𠺢𠻗𠻹𠻺𠼭𠼮𠽌𠾴𠾼𠿪𡁜𡁯𡁵𡁶𡁻𡃁𡃉𡇙𢃇𢞵𢫕𢭃𢯊𢱑𢱕𢳂𠻹𠻺𠼭𠼮𠽌𠾴𠾼𠿪𡁜𡁯𡁵𡁶𡁻𡃁𡃉𡇙𢃇𢞵𢫕𢭃𢯊𢱑𢱕𢳂.png',
+ typeOfName: 'File name longer than 240 bytes',
+ nameText: '𠜎𠜱𠝹𠱓𠱸𠲖𠳏𠳕𠴕𠵼𠵿𠸎𠸏𠹷𠺝𠺢𠻗𠻹𠻺𠼭𠼮𠽌𠾴𠾼𠿪𡁜𡁯𡁵𡁶𡁻𡃁𡃉𡇙𢃇𢞵𢫕𢭃𢯊𢱑𢱕𢳂𠻹𠻺𠼭𠼮𠽌𠾴𠾼𠿪𡁜𡁯𡁵𡁶𡁻𡃁𡃉𡇙𢃇𢞵',
+ prefixedText: 'File:𠜎𠜱𠝹𠱓𠱸𠲖𠳏𠳕𠴕𠵼𠵿𠸎𠸏𠹷𠺝𠺢𠻗𠻹𠻺𠼭𠼮𠽌𠾴𠾼𠿪𡁜𡁯𡁵𡁶𡁻𡃁𡃉𡇙𢃇𢞵𢫕𢭃𢯊𢱑𢱕𢳂𠻹𠻺𠼭𠼮𠽌𠾴𠾼𠿪𡁜𡁯𡁵𡁶𡁻𡃁𡃉𡇙𢃇𢞵.png'
+ },
+ {
+ fileName: '',
+ typeOfName: 'Empty string'
+ },
+ {
+ fileName: 'foo',
+ typeOfName: 'String with only alphabet characters'
+ }
+ ];
+
+ for ( i = 0; i < cases.length; i++ ) {
+ thisCase = cases[ i ];
+ title = mw.Title.newFromFileName( thisCase.fileName );
+
+ if ( thisCase.nameText !== undefined ) {
+ prefix = '[' + thisCase.typeOfName + '] ';
+
+ assert.notStrictEqual( title, null, prefix + 'Parses successfully' );
+ assert.equal( title.getNameText(), thisCase.nameText, prefix + 'Filename matches original' );
+ assert.equal( title.getPrefixedText(), thisCase.prefixedText, prefix + 'File page title matches original' );
+ assert.equal( title.getNamespaceId(), 6, prefix + 'Namespace ID matches File namespace' );
+ } else {
+ assert.strictEqual( title, null, thisCase.typeOfName + ', should not produce an mw.Title object' );
+ }
+ }
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js
new file mode 100644
index 00000000..918c923a
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js
@@ -0,0 +1,509 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.Uri', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.mwUriOrg = mw.Uri;
+ mw.Uri = mw.UriRelative( 'http://example.org/w/index.php' );
+ },
+ teardown: function () {
+ mw.Uri = this.mwUriOrg;
+ delete this.mwUriOrg;
+ }
+ } ) );
+
+ [ true, false ].forEach( function ( strictMode ) {
+ QUnit.test( 'Basic construction and properties (' + ( strictMode ? '' : 'non-' ) + 'strict mode)', function ( assert ) {
+ var uriString, uri;
+ uriString = 'http://www.ietf.org/rfc/rfc2396.txt';
+ uri = new mw.Uri( uriString, {
+ strictMode: strictMode
+ } );
+
+ assert.deepEqual(
+ {
+ protocol: uri.protocol,
+ host: uri.host,
+ port: uri.port,
+ path: uri.path,
+ query: uri.query,
+ fragment: uri.fragment
+ }, {
+ protocol: 'http',
+ host: 'www.ietf.org',
+ port: undefined,
+ path: '/rfc/rfc2396.txt',
+ query: {},
+ fragment: undefined
+ },
+ 'basic object properties'
+ );
+
+ assert.deepEqual(
+ {
+ userInfo: uri.getUserInfo(),
+ authority: uri.getAuthority(),
+ hostPort: uri.getHostPort(),
+ queryString: uri.getQueryString(),
+ relativePath: uri.getRelativePath(),
+ toString: uri.toString()
+ },
+ {
+ userInfo: '',
+ authority: 'www.ietf.org',
+ hostPort: 'www.ietf.org',
+ queryString: '',
+ relativePath: '/rfc/rfc2396.txt',
+ toString: uriString
+ },
+ 'construct composite components of URI on request'
+ );
+ } );
+ } );
+
+ QUnit.test( 'Constructor( String[, Object ] )', function ( assert ) {
+ var uri;
+
+ uri = new mw.Uri( 'http://www.example.com/dir/?m=foo&m=bar&n=1', {
+ overrideKeys: true
+ } );
+
+ // Strict comparison to assert that numerical values stay strings
+ assert.strictEqual( uri.query.n, '1', 'Simple parameter with overrideKeys:true' );
+ assert.strictEqual( uri.query.m, 'bar', 'Last key overrides earlier keys with overrideKeys:true' );
+
+ uri = new mw.Uri( 'http://www.example.com/dir/?m=foo&m=bar&n=1', {
+ overrideKeys: false
+ } );
+
+ assert.strictEqual( uri.query.n, '1', 'Simple parameter with overrideKeys:false' );
+ assert.strictEqual( uri.query.m[ 0 ], 'foo', 'Order of multi-value parameters with overrideKeys:true' );
+ assert.strictEqual( uri.query.m[ 1 ], 'bar', 'Order of multi-value parameters with overrideKeys:true' );
+ assert.strictEqual( uri.query.m.length, 2, 'Number of mult-value field is correct' );
+
+ uri = new mw.Uri( 'ftp://usr:pwd@192.0.2.16/' );
+
+ assert.deepEqual(
+ {
+ protocol: uri.protocol,
+ user: uri.user,
+ password: uri.password,
+ host: uri.host,
+ port: uri.port,
+ path: uri.path,
+ query: uri.query,
+ fragment: uri.fragment
+ },
+ {
+ protocol: 'ftp',
+ user: 'usr',
+ password: 'pwd',
+ host: '192.0.2.16',
+ port: undefined,
+ path: '/',
+ query: {},
+ fragment: undefined
+ },
+ 'Parse an ftp URI correctly with user and password'
+ );
+
+ assert.throws(
+ function () {
+ return new mw.Uri( 'glaswegian penguins' );
+ },
+ function ( e ) {
+ return e.message === 'Bad constructor arguments';
+ },
+ 'throw error on non-URI as argument to constructor'
+ );
+
+ assert.throws(
+ function () {
+ return new mw.Uri( 'example.com/bar/baz', {
+ strictMode: true
+ } );
+ },
+ function ( e ) {
+ return e.message === 'Bad constructor arguments';
+ },
+ 'throw error on URI without protocol or // or leading / in strict mode'
+ );
+
+ uri = new mw.Uri( 'example.com/bar/baz', {
+ strictMode: false
+ } );
+ assert.equal( uri.toString(), 'http://example.com/bar/baz', 'normalize URI without protocol or // in loose mode' );
+
+ uri = new mw.Uri( 'http://example.com/index.php?key=key&hasOwnProperty=hasOwnProperty&constructor=constructor&watch=watch' );
+ assert.deepEqual(
+ uri.query,
+ {
+ key: 'key',
+ constructor: 'constructor',
+ hasOwnProperty: 'hasOwnProperty',
+ watch: 'watch'
+ },
+ 'Keys in query strings support names of Object prototypes (bug T114344)'
+ );
+ } );
+
+ QUnit.test( 'Constructor( Object )', function ( assert ) {
+ var uri = new mw.Uri( {
+ protocol: 'http',
+ host: 'www.foo.local',
+ path: '/this'
+ } );
+ assert.equal( uri.toString(), 'http://www.foo.local/this', 'Basic properties' );
+
+ uri = new mw.Uri( {
+ protocol: 'http',
+ host: 'www.foo.local',
+ path: '/this',
+ query: { hi: 'there' },
+ fragment: 'blah'
+ } );
+ assert.equal( uri.toString(), 'http://www.foo.local/this?hi=there#blah', 'More complex properties' );
+
+ assert.throws(
+ function () {
+ return new mw.Uri( {
+ protocol: 'http',
+ host: 'www.foo.local'
+ } );
+ },
+ function ( e ) {
+ return e.message === 'Bad constructor arguments';
+ },
+ 'Construction failed when missing required properties'
+ );
+ } );
+
+ QUnit.test( 'Constructor( empty[, Object ] )', function ( assert ) {
+ var testuri, MyUri, uri;
+
+ testuri = 'http://example.org/w/index.php?a=1&a=2';
+ MyUri = mw.UriRelative( testuri );
+
+ uri = new MyUri();
+ assert.equal( uri.toString(), testuri, 'no arguments' );
+
+ uri = new MyUri( undefined );
+ assert.equal( uri.toString(), testuri, 'undefined' );
+
+ uri = new MyUri( null );
+ assert.equal( uri.toString(), testuri, 'null' );
+
+ uri = new MyUri( '' );
+ assert.equal( uri.toString(), testuri, 'empty string' );
+
+ uri = new MyUri( null, { overrideKeys: true } );
+ assert.deepEqual( uri.query, { a: '2' }, 'null, with options' );
+ } );
+
+ QUnit.test( 'Properties', function ( assert ) {
+ var uriBase, uri;
+
+ uriBase = new mw.Uri( 'http://en.wiki.local/w/api.php' );
+
+ uri = uriBase.clone();
+ uri.fragment = 'frag';
+ assert.equal( uri.toString(), 'http://en.wiki.local/w/api.php#frag', 'add a fragment' );
+ uri.fragment = 'café';
+ assert.equal( uri.toString(), 'http://en.wiki.local/w/api.php#caf%C3%A9', 'fragment is url-encoded' );
+
+ uri = uriBase.clone();
+ uri.host = 'fr.wiki.local';
+ uri.port = '8080';
+ assert.equal( uri.toString(), 'http://fr.wiki.local:8080/w/api.php', 'change host and port' );
+
+ uri = uriBase.clone();
+ uri.query.foo = 'bar';
+ assert.equal( uri.toString(), 'http://en.wiki.local/w/api.php?foo=bar', 'add query arguments' );
+
+ delete uri.query.foo;
+ assert.equal( uri.toString(), 'http://en.wiki.local/w/api.php', 'delete query arguments' );
+
+ uri = uriBase.clone();
+ uri.query.foo = 'bar';
+ assert.equal( uri.toString(), 'http://en.wiki.local/w/api.php?foo=bar', 'extend query arguments' );
+ uri.extend( {
+ foo: 'quux',
+ pif: 'paf'
+ } );
+ assert.ok( uri.toString().indexOf( 'foo=quux' ) >= 0, 'extend query arguments' );
+ assert.ok( uri.toString().indexOf( 'foo=bar' ) === -1, 'extend query arguments' );
+ assert.ok( uri.toString().indexOf( 'pif=paf' ) >= 0, 'extend query arguments' );
+ } );
+
+ QUnit.test( '.getQueryString()', function ( assert ) {
+ var uri = new mw.Uri( 'http://search.example.com/?q=uri' );
+
+ assert.deepEqual(
+ {
+ protocol: uri.protocol,
+ host: uri.host,
+ port: uri.port,
+ path: uri.path,
+ query: uri.query,
+ fragment: uri.fragment,
+ queryString: uri.getQueryString()
+ },
+ {
+ protocol: 'http',
+ host: 'search.example.com',
+ port: undefined,
+ path: '/',
+ query: { q: 'uri' },
+ fragment: undefined,
+ queryString: 'q=uri'
+ },
+ 'basic object properties'
+ );
+
+ uri = new mw.Uri( 'https://example.com/mw/index.php?title=Sandbox/7&other=Sandbox/7&foo' );
+ assert.equal(
+ uri.getQueryString(),
+ 'title=Sandbox/7&other=Sandbox%2F7&foo',
+ 'title parameter is escaped the wiki-way'
+ );
+
+ } );
+
+ QUnit.test( '.clone()', function ( assert ) {
+ var original, clone;
+
+ original = new mw.Uri( 'http://foo.example.org/index.php?one=1&two=2' );
+ clone = original.clone();
+
+ assert.deepEqual( clone, original, 'clone has equivalent properties' );
+ assert.equal( original.toString(), clone.toString(), 'toString matches original' );
+
+ assert.notStrictEqual( clone, original, 'clone is a different object when compared by reference' );
+
+ clone.host = 'bar.example.org';
+ assert.notEqual( original.host, clone.host, 'manipulating clone did not effect original' );
+ assert.notEqual( original.toString(), clone.toString(), 'Stringified url no longer matches original' );
+
+ clone.query.three = 3;
+
+ assert.deepEqual(
+ original.query,
+ { one: '1', two: '2' },
+ 'Properties is deep cloned (T39708)'
+ );
+ } );
+
+ QUnit.test( '.toString() after query manipulation', function ( assert ) {
+ var uri;
+
+ uri = new mw.Uri( 'http://www.example.com/dir/?m=foo&m=bar&n=1', {
+ overrideKeys: true
+ } );
+
+ uri.query.n = [ 'x', 'y', 'z' ];
+
+ // Verify parts and total length instead of entire string because order
+ // of iteration can vary.
+ assert.ok( uri.toString().indexOf( 'm=bar' ), 'toString preserves other values' );
+ assert.ok( uri.toString().indexOf( 'n=x&n=y&n=z' ), 'toString parameter includes all values of an array query parameter' );
+ assert.equal( uri.toString().length, 'http://www.example.com/dir/?m=bar&n=x&n=y&n=z'.length, 'toString matches expected string' );
+
+ uri = new mw.Uri( 'http://www.example.com/dir/?m=foo&m=bar&n=1', {
+ overrideKeys: false
+ } );
+
+ // Change query values
+ uri.query.n = [ 'x', 'y', 'z' ];
+
+ // Verify parts and total length instead of entire string because order
+ // of iteration can vary.
+ assert.ok( uri.toString().indexOf( 'm=foo&m=bar' ) >= 0, 'toString preserves other values' );
+ assert.ok( uri.toString().indexOf( 'n=x&n=y&n=z' ) >= 0, 'toString parameter includes all values of an array query parameter' );
+ assert.equal( uri.toString().length, 'http://www.example.com/dir/?m=foo&m=bar&n=x&n=y&n=z'.length, 'toString matches expected string' );
+
+ // Remove query values
+ uri.query.m.splice( 0, 1 );
+ delete uri.query.n;
+
+ assert.equal( uri.toString(), 'http://www.example.com/dir/?m=bar', 'deletion properties' );
+
+ // Remove more query values, leaving an empty array
+ uri.query.m.splice( 0, 1 );
+ assert.equal( uri.toString(), 'http://www.example.com/dir/', 'empty array value is ommitted' );
+ } );
+
+ QUnit.test( 'Variable defaultUri', function ( assert ) {
+ var uri,
+ href = 'http://example.org/w/index.php#here',
+ UriClass = mw.UriRelative( function () {
+ return href;
+ } );
+
+ uri = new UriClass();
+ assert.deepEqual(
+ {
+ protocol: uri.protocol,
+ user: uri.user,
+ password: uri.password,
+ host: uri.host,
+ port: uri.port,
+ path: uri.path,
+ query: uri.query,
+ fragment: uri.fragment
+ },
+ {
+ protocol: 'http',
+ user: undefined,
+ password: undefined,
+ host: 'example.org',
+ port: undefined,
+ path: '/w/index.php',
+ query: {},
+ fragment: 'here'
+ },
+ 'basic object properties'
+ );
+
+ // Default URI may change, e.g. via history.replaceState, pushState or location.hash (T74334)
+ href = 'https://example.com/wiki/Foo?v=2';
+ uri = new UriClass();
+ assert.deepEqual(
+ {
+ protocol: uri.protocol,
+ user: uri.user,
+ password: uri.password,
+ host: uri.host,
+ port: uri.port,
+ path: uri.path,
+ query: uri.query,
+ fragment: uri.fragment
+ },
+ {
+ protocol: 'https',
+ user: undefined,
+ password: undefined,
+ host: 'example.com',
+ port: undefined,
+ path: '/wiki/Foo',
+ query: { v: '2' },
+ fragment: undefined
+ },
+ 'basic object properties'
+ );
+ } );
+
+ QUnit.test( 'Advanced URL', function ( assert ) {
+ var uri, queryString, relativePath;
+
+ uri = new mw.Uri( 'http://auth@www.example.com:81/dir/dir.2/index.htm?q1=0&&test1&test2=value+%28escaped%29#caf%C3%A9' );
+
+ assert.deepEqual(
+ {
+ protocol: uri.protocol,
+ user: uri.user,
+ password: uri.password,
+ host: uri.host,
+ port: uri.port,
+ path: uri.path,
+ query: uri.query,
+ fragment: uri.fragment
+ },
+ {
+ protocol: 'http',
+ user: 'auth',
+ password: undefined,
+ host: 'www.example.com',
+ port: '81',
+ path: '/dir/dir.2/index.htm',
+ query: { q1: '0', test1: null, test2: 'value (escaped)' },
+ fragment: 'café'
+ },
+ 'basic object properties'
+ );
+
+ assert.equal( uri.getUserInfo(), 'auth', 'user info' );
+
+ assert.equal( uri.getAuthority(), 'auth@www.example.com:81', 'authority equal to auth@hostport' );
+
+ assert.equal( uri.getHostPort(), 'www.example.com:81', 'hostport equal to host:port' );
+
+ queryString = uri.getQueryString();
+ assert.ok( queryString.indexOf( 'q1=0' ) >= 0, 'query param with numbers' );
+ assert.ok( queryString.indexOf( 'test1' ) >= 0, 'query param with null value is included' );
+ assert.ok( queryString.indexOf( 'test1=' ) === -1, 'query param with null value does not generate equals sign' );
+ assert.ok( queryString.indexOf( 'test2=value+%28escaped%29' ) >= 0, 'query param is url escaped' );
+
+ relativePath = uri.getRelativePath();
+ assert.ok( relativePath.indexOf( uri.path ) >= 0, 'path in relative path' );
+ assert.ok( relativePath.indexOf( uri.getQueryString() ) >= 0, 'query string in relative path' );
+ assert.ok( relativePath.indexOf( mw.Uri.encode( uri.fragment ) ) >= 0, 'escaped fragment in relative path' );
+ } );
+
+ QUnit.test( 'Parse a uri with an @ symbol in the path and query', function ( assert ) {
+ var uri = new mw.Uri( 'http://www.example.com/test@test?x=@uri&y@=uri&z@=@' );
+
+ assert.deepEqual(
+ {
+ protocol: uri.protocol,
+ user: uri.user,
+ password: uri.password,
+ host: uri.host,
+ port: uri.port,
+ path: uri.path,
+ query: uri.query,
+ fragment: uri.fragment,
+ queryString: uri.getQueryString()
+ },
+ {
+ protocol: 'http',
+ user: undefined,
+ password: undefined,
+ host: 'www.example.com',
+ port: undefined,
+ path: '/test@test',
+ query: { x: '@uri', 'y@': 'uri', 'z@': '@' },
+ fragment: undefined,
+ queryString: 'x=%40uri&y%40=uri&z%40=%40'
+ },
+ 'basic object properties'
+ );
+ } );
+
+ QUnit.test( 'Handle protocol-relative URLs', function ( assert ) {
+ var UriRel, uri;
+
+ UriRel = mw.UriRelative( 'glork://en.wiki.local/foo.php' );
+
+ uri = new UriRel( '//en.wiki.local/w/api.php' );
+ assert.equal( uri.protocol, 'glork', 'create protocol-relative URLs with same protocol as document' );
+
+ uri = new UriRel( '/foo.com' );
+ assert.equal( uri.toString(), 'glork://en.wiki.local/foo.com', 'handle absolute paths by supplying protocol and host from document in loose mode' );
+
+ uri = new UriRel( 'http:/foo.com' );
+ assert.equal( uri.toString(), 'http://en.wiki.local/foo.com', 'handle absolute paths by supplying host from document in loose mode' );
+
+ uri = new UriRel( '/foo.com', true );
+ assert.equal( uri.toString(), 'glork://en.wiki.local/foo.com', 'handle absolute paths by supplying protocol and host from document in strict mode' );
+
+ uri = new UriRel( 'http:/foo.com', true );
+ assert.equal( uri.toString(), 'http://en.wiki.local/foo.com', 'handle absolute paths by supplying host from document in strict mode' );
+ } );
+
+ QUnit.test( 'T37658', function ( assert ) {
+ var testProtocol, testServer, testPort, testPath, UriClass, uri, href;
+
+ testProtocol = 'https://';
+ testServer = 'foo.example.org';
+ testPort = '3004';
+ testPath = '/!1qy';
+
+ UriClass = mw.UriRelative( testProtocol + testServer + '/some/path/index.html' );
+ uri = new UriClass( testPath );
+ href = uri.toString();
+ assert.equal( href, testProtocol + testServer + testPath, 'Root-relative URL gets host & protocol supplied' );
+
+ UriClass = mw.UriRelative( testProtocol + testServer + ':' + testPort + '/some/path.php' );
+ uri = new UriClass( testPath );
+ href = uri.toString();
+ assert.equal( href, testProtocol + testServer + ':' + testPort + testPath, 'Root-relative URL gets host, protocol, and port supplied' );
+ } );
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js
new file mode 100644
index 00000000..4170897c
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js
@@ -0,0 +1,82 @@
+( function ( mw, $ ) {
+ var pluralTestcases = {
+ /*
+ * Sample:
+ * languagecode : [
+ * [ number, [ 'form1', 'form2', ... ], 'expected', 'description' ]
+ * ];
+ */
+ en: [
+ [ 0, [ 'one', 'other' ], 'other', 'English plural test- 0 is other' ],
+ [ 1, [ 'one', 'other' ], 'one', 'English plural test- 1 is one' ]
+ ],
+ fa: [
+ [ 0, [ 'one', 'other' ], 'other', 'Persian plural test- 0 is other' ],
+ [ 1, [ 'one', 'other' ], 'one', 'Persian plural test- 1 is one' ],
+ [ 2, [ 'one', 'other' ], 'other', 'Persian plural test- 2 is other' ]
+ ],
+ fr: [
+ [ 0, [ 'one', 'other' ], 'other', 'French plural test- 0 is other' ],
+ [ 1, [ 'one', 'other' ], 'one', 'French plural test- 1 is one' ]
+ ],
+ hi: [
+ [ 0, [ 'one', 'other' ], 'one', 'Hindi plural test- 0 is one' ],
+ [ 1, [ 'one', 'other' ], 'one', 'Hindi plural test- 1 is one' ],
+ [ 2, [ 'one', 'other' ], 'other', 'Hindi plural test- 2 is other' ]
+ ],
+ he: [
+ [ 0, [ 'one', 'other' ], 'other', 'Hebrew plural test- 0 is other' ],
+ [ 1, [ 'one', 'other' ], 'one', 'Hebrew plural test- 1 is one' ],
+ [ 2, [ 'one', 'other' ], 'other', 'Hebrew plural test- 2 is other with 2 forms' ],
+ [ 2, [ 'one', 'dual', 'other' ], 'dual', 'Hebrew plural test- 2 is dual with 3 forms' ]
+ ],
+ hu: [
+ [ 0, [ 'one', 'other' ], 'other', 'Hungarian plural test- 0 is other' ],
+ [ 1, [ 'one', 'other' ], 'one', 'Hungarian plural test- 1 is one' ],
+ [ 2, [ 'one', 'other' ], 'other', 'Hungarian plural test- 2 is other' ]
+ ],
+ hy: [
+ [ 0, [ 'one', 'other' ], 'other', 'Armenian plural test- 0 is other' ],
+ [ 1, [ 'one', 'other' ], 'one', 'Armenian plural test- 1 is one' ],
+ [ 2, [ 'one', 'other' ], 'other', 'Armenian plural test- 2 is other' ]
+ ],
+ ar: [
+ [ 0, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'zero', 'Arabic plural test - 0 is zero' ],
+ [ 1, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'one', 'Arabic plural test - 1 is one' ],
+ [ 2, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'two', 'Arabic plural test - 2 is two' ],
+ [ 3, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'few', 'Arabic plural test - 3 is few' ],
+ [ 9, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'few', 'Arabic plural test - 9 is few' ],
+ [ '9', [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'few', 'Arabic plural test - 9 is few' ],
+ [ 110, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'few', 'Arabic plural test - 110 is few' ],
+ [ 11, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'many', 'Arabic plural test - 11 is many' ],
+ [ 15, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'many', 'Arabic plural test - 15 is many' ],
+ [ 99, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'many', 'Arabic plural test - 99 is many' ],
+ [ 9999, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'many', 'Arabic plural test - 9999 is many' ],
+ [ 100, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'other', 'Arabic plural test - 100 is other' ],
+ [ 102, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'other', 'Arabic plural test - 102 is other' ],
+ [ 1000, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'other', 'Arabic plural test - 1000 is other' ],
+ [ 1.7, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'other', 'Arabic plural test - 1.7 is other' ]
+ ]
+ };
+
+ QUnit.module( 'mediawiki.cldr', QUnit.newMwEnvironment() );
+
+ function pluralTest( langCode, tests ) {
+ QUnit.test( 'Plural Test for ' + langCode, function ( assert ) {
+ var i;
+ for ( i = 0; i < tests.length; i++ ) {
+ assert.equal(
+ mw.language.convertPlural( tests[ i ][ 0 ], tests[ i ][ 1 ] ),
+ tests[ i ][ 2 ],
+ tests[ i ][ 3 ]
+ );
+ }
+ } );
+ }
+
+ $.each( pluralTestcases, function ( langCode, tests ) {
+ if ( langCode === mw.config.get( 'wgUserLanguage' ) ) {
+ pluralTest( langCode, tests );
+ }
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js
new file mode 100644
index 00000000..59bf7376
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js
@@ -0,0 +1,179 @@
+( function ( mw, $ ) {
+
+ var NOW = 9012, // miliseconds
+ DEFAULT_DURATION = 5678, // seconds
+ expiryDate = new Date();
+
+ expiryDate.setTime( NOW + ( DEFAULT_DURATION * 1000 ) );
+
+ QUnit.module( 'mediawiki.cookie', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.stub( $, 'cookie' ).returns( null );
+
+ this.sandbox.useFakeTimers( NOW );
+ },
+ config: {
+ wgCookiePrefix: 'mywiki',
+ wgCookieDomain: 'example.org',
+ wgCookiePath: '/path',
+ wgCookieExpiration: DEFAULT_DURATION
+ }
+ } ) );
+
+ QUnit.test( 'set( key, value )', function ( assert ) {
+ var call;
+
+ // Simple case
+ mw.cookie.set( 'foo', 'bar' );
+
+ call = $.cookie.lastCall.args;
+ assert.strictEqual( call[ 0 ], 'mywikifoo' );
+ assert.strictEqual( call[ 1 ], 'bar' );
+ assert.deepEqual( call[ 2 ], {
+ expires: expiryDate,
+ domain: 'example.org',
+ path: '/path',
+ secure: false
+ } );
+
+ mw.cookie.set( 'foo', null );
+ call = $.cookie.lastCall.args;
+ assert.strictEqual( call[ 1 ], null, 'null removes cookie' );
+
+ mw.cookie.set( 'foo', undefined );
+ call = $.cookie.lastCall.args;
+ assert.strictEqual( call[ 1 ], 'undefined', 'undefined is value' );
+
+ mw.cookie.set( 'foo', false );
+ call = $.cookie.lastCall.args;
+ assert.strictEqual( call[ 1 ], 'false', 'false is a value' );
+
+ mw.cookie.set( 'foo', 0 );
+ call = $.cookie.lastCall.args;
+ assert.strictEqual( call[ 1 ], '0', '0 is value' );
+ } );
+
+ QUnit.test( 'set( key, value, expires )', function ( assert ) {
+ var date, options;
+
+ date = new Date();
+ date.setTime( 1234 );
+
+ mw.cookie.set( 'foo', 'bar' );
+ options = $.cookie.lastCall.args[ 2 ];
+ assert.deepEqual( options.expires, expiryDate, 'default expiration' );
+
+ mw.cookie.set( 'foo', 'bar', date );
+ options = $.cookie.lastCall.args[ 2 ];
+ assert.strictEqual( options.expires, date, 'custom expiration as Date' );
+
+ date = new Date();
+ date.setDate( date.getDate() + 1 );
+
+ mw.cookie.set( 'foo', 'bar', 86400 );
+ options = $.cookie.lastCall.args[ 2 ];
+ assert.deepEqual( options.expires, date, 'custom expiration as lifetime in seconds' );
+
+ mw.cookie.set( 'foo', 'bar', null );
+ options = $.cookie.lastCall.args[ 2 ];
+ assert.strictEqual( options.expires, undefined, 'null forces session cookie' );
+
+ // Per DefaultSettings.php, when wgCookieExpiration is 0, the default should
+ // be session cookies
+ mw.config.set( 'wgCookieExpiration', 0 );
+
+ mw.cookie.set( 'foo', 'bar' );
+ options = $.cookie.lastCall.args[ 2 ];
+ assert.strictEqual( options.expires, undefined, 'wgCookieExpiration=0 results in session cookies by default' );
+
+ mw.cookie.set( 'foo', 'bar', date );
+ options = $.cookie.lastCall.args[ 2 ];
+ assert.strictEqual( options.expires, date, 'custom expiration (with wgCookieExpiration=0)' );
+ } );
+
+ QUnit.test( 'set( key, value, options )', function ( assert ) {
+ var date, call;
+
+ mw.cookie.set( 'foo', 'bar', {
+ prefix: 'myPrefix',
+ domain: 'myDomain',
+ path: 'myPath',
+ secure: true
+ } );
+
+ call = $.cookie.lastCall.args;
+ assert.strictEqual( call[ 0 ], 'myPrefixfoo' );
+ assert.deepEqual( call[ 2 ], {
+ expires: expiryDate,
+ domain: 'myDomain',
+ path: 'myPath',
+ secure: true
+ }, 'Options (without expires)' );
+
+ date = new Date();
+ date.setTime( 1234 );
+
+ mw.cookie.set( 'foo', 'bar', {
+ expires: date,
+ prefix: 'myPrefix',
+ domain: 'myDomain',
+ path: 'myPath',
+ secure: true
+ } );
+
+ call = $.cookie.lastCall.args;
+ assert.strictEqual( call[ 0 ], 'myPrefixfoo' );
+ assert.deepEqual( call[ 2 ], {
+ expires: date,
+ domain: 'myDomain',
+ path: 'myPath',
+ secure: true
+ }, 'Options (incl. expires)' );
+ } );
+
+ QUnit.test( 'get( key ) - no values', function ( assert ) {
+ var key, value;
+
+ mw.cookie.get( 'foo' );
+
+ key = $.cookie.lastCall.args[ 0 ];
+ assert.strictEqual( key, 'mywikifoo', 'Default prefix' );
+
+ mw.cookie.get( 'foo', undefined );
+ key = $.cookie.lastCall.args[ 0 ];
+ assert.strictEqual( key, 'mywikifoo', 'Use default prefix for undefined' );
+
+ mw.cookie.get( 'foo', null );
+ key = $.cookie.lastCall.args[ 0 ];
+ assert.strictEqual( key, 'mywikifoo', 'Use default prefix for null' );
+
+ mw.cookie.get( 'foo', '' );
+ key = $.cookie.lastCall.args[ 0 ];
+ assert.strictEqual( key, 'foo', 'Don\'t use default prefix for empty string' );
+
+ value = mw.cookie.get( 'foo' );
+ assert.strictEqual( value, null, 'Return null by default' );
+
+ value = mw.cookie.get( 'foo', null, 'bar' );
+ assert.strictEqual( value, 'bar', 'Custom default value' );
+ } );
+
+ QUnit.test( 'get( key ) - with value', function ( assert ) {
+ var value;
+
+ $.cookie.returns( 'bar' );
+
+ value = mw.cookie.get( 'foo' );
+ assert.strictEqual( value, 'bar', 'Return value of cookie' );
+ } );
+
+ QUnit.test( 'get( key, prefix )', function ( assert ) {
+ var key;
+
+ mw.cookie.get( 'foo', 'bar' );
+
+ key = $.cookie.lastCall.args[ 0 ];
+ assert.strictEqual( key, 'barfoo' );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.errorLogger.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.errorLogger.test.js
new file mode 100644
index 00000000..2a4d9912
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.errorLogger.test.js
@@ -0,0 +1,42 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.errorLogger', QUnit.newMwEnvironment() );
+
+ QUnit.test( 'installGlobalHandler', function ( assert ) {
+ var w = {},
+ errorMessage = 'Foo',
+ errorUrl = 'http://example.com',
+ errorLine = '123',
+ errorColumn = '45',
+ errorObject = new Error( 'Foo' ),
+ oldHandler = this.sandbox.stub();
+
+ this.sandbox.stub( mw, 'track' );
+
+ mw.errorLogger.installGlobalHandler( w );
+
+ assert.ok( w.onerror, 'Global handler has been installed' );
+ assert.strictEqual( w.onerror( errorMessage, errorUrl, errorLine ), false,
+ 'Global handler returns false when there is no previous handler' );
+ sinon.assert.calledWithExactly( mw.track, 'global.error',
+ sinon.match( { errorMessage: errorMessage, url: errorUrl, lineNumber: errorLine } ) );
+
+ mw.track.reset();
+ w.onerror( errorMessage, errorUrl, errorLine, errorColumn, errorObject );
+ sinon.assert.calledWithExactly( mw.track, 'global.error',
+ sinon.match( { errorMessage: errorMessage, url: errorUrl, lineNumber: errorLine,
+ columnNumber: errorColumn, errorObject: errorObject } ) );
+
+ w = { onerror: oldHandler };
+
+ mw.errorLogger.installGlobalHandler( w );
+ w.onerror( errorMessage, errorUrl, errorLine );
+ sinon.assert.calledWithExactly( oldHandler, errorMessage, errorUrl, errorLine );
+
+ oldHandler.returns( false );
+ assert.strictEqual( w.onerror( errorMessage, errorUrl, errorLine ), false,
+ 'Global handler preserves false return from previous handler' );
+ oldHandler.returns( true );
+ assert.strictEqual( w.onerror( errorMessage, errorUrl, errorLine ), true,
+ 'Global handler preserves true return from previous handler' );
+ } );
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js
new file mode 100644
index 00000000..177c3580
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js
@@ -0,0 +1,63 @@
+( function ( mw ) {
+
+ var getBucket = mw.experiments.getBucket;
+
+ function createExperiment() {
+ return {
+ name: 'experiment',
+ enabled: true,
+ buckets: {
+ control: 0.25,
+ A: 0.25,
+ B: 0.25,
+ C: 0.25
+ }
+ };
+ }
+
+ QUnit.module( 'mediawiki.experiments' );
+
+ QUnit.test( 'getBucket( experiment, token )', function ( assert ) {
+ var experiment = createExperiment(),
+ token = '123457890';
+
+ assert.equal(
+ getBucket( experiment, token ),
+ getBucket( experiment, token ),
+ 'It returns the same bucket for the same experiment-token pair.'
+ );
+
+ // --------
+ experiment = createExperiment();
+ experiment.buckets = {
+ A: 0.314159265359
+ };
+
+ assert.equal(
+ 'A',
+ getBucket( experiment, token ),
+ 'It returns the bucket if only one is defined.'
+ );
+
+ // --------
+ experiment = createExperiment();
+ experiment.enabled = false;
+
+ assert.equal(
+ 'control',
+ getBucket( experiment, token ),
+ 'It returns "control" if the experiment is disabled.'
+ );
+
+ // --------
+ experiment = createExperiment();
+ experiment.buckets = {};
+
+ assert.equal(
+ 'control',
+ getBucket( experiment, token ),
+ 'It returns "control" if the experiment doesn\'t have any buckets.'
+ );
+ } );
+
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.html.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.html.test.js
new file mode 100644
index 00000000..16f8cf3b
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.html.test.js
@@ -0,0 +1,105 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.html' );
+
+ QUnit.test( 'escape', function ( assert ) {
+ assert.throws(
+ function () {
+ mw.html.escape();
+ },
+ TypeError,
+ 'throw a TypeError if argument is not a string'
+ );
+
+ assert.equal(
+ mw.html.escape( '<mw awesome="awesome" value=\'test\' />' ),
+ '&lt;mw awesome=&quot;awesome&quot; value=&#039;test&#039; /&gt;',
+ 'Escape special characters to html entities'
+ );
+ } );
+
+ QUnit.test( 'element()', function ( assert ) {
+ assert.equal(
+ mw.html.element(),
+ '<undefined/>',
+ 'return valid html even without arguments'
+ );
+ } );
+
+ QUnit.test( 'element( tagName )', function ( assert ) {
+ assert.equal( mw.html.element( 'div' ), '<div/>', 'DIV' );
+ } );
+
+ QUnit.test( 'element( tagName, attrs )', function ( assert ) {
+ assert.equal( mw.html.element( 'div', {} ), '<div/>', 'DIV' );
+
+ assert.equal(
+ mw.html.element(
+ 'div', {
+ id: 'foobar'
+ }
+ ),
+ '<div id="foobar"/>',
+ 'DIV with attribs'
+ );
+ } );
+
+ QUnit.test( 'element( tagName, attrs, content )', function ( assert ) {
+
+ assert.equal( mw.html.element( 'div', {}, '' ), '<div></div>', 'DIV with empty attributes and content' );
+
+ assert.equal( mw.html.element( 'p', {}, 12 ), '<p>12</p>', 'numbers as content cast to strings' );
+
+ assert.equal( mw.html.element( 'p', { title: 12 }, '' ), '<p title="12"></p>', 'number as attribute value' );
+
+ assert.equal(
+ mw.html.element(
+ 'div',
+ {},
+ new mw.html.Raw(
+ mw.html.element( 'img', { src: '<' } )
+ )
+ ),
+ '<div><img src="&lt;"/></div>',
+ 'unescaped content with mw.html.Raw'
+ );
+
+ assert.equal(
+ mw.html.element(
+ 'option',
+ {
+ selected: true
+ },
+ 'Foo'
+ ),
+ '<option selected="selected">Foo</option>',
+ 'boolean true attribute value'
+ );
+
+ assert.equal(
+ mw.html.element(
+ 'option',
+ {
+ value: 'foo',
+ selected: false
+ },
+ 'Foo'
+ ),
+ '<option value="foo">Foo</option>',
+ 'boolean false attribute value'
+ );
+
+ assert.equal(
+ mw.html.element( 'div', null, 'a' ),
+ '<div>a</div>',
+ 'Skip attributes with null' );
+
+ assert.equal(
+ mw.html.element( 'a', {
+ href: 'http://mediawiki.org/w/index.php?title=RL&action=history'
+ }, 'a' ),
+ '<a href="http://mediawiki.org/w/index.php?title=RL&amp;action=history">a</a>',
+ 'Andhor tag with attributes and content'
+ );
+ } );
+
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.inspect.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.inspect.test.js
new file mode 100644
index 00000000..1f7a5ece
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.inspect.test.js
@@ -0,0 +1,74 @@
+( function ( mw ) {
+
+ QUnit.module( 'mediawiki.inspect' );
+
+ QUnit.test( '.getModuleSize() - scripts', function ( assert ) {
+ mw.loader.implement(
+ 'test.inspect.script',
+ function () { 'example'; }
+ );
+
+ return mw.loader.using( 'test.inspect.script' ).then( function () {
+ assert.equal(
+ mw.inspect.getModuleSize( 'test.inspect.script' ),
+ // name, script function
+ 43,
+ 'test.inspect.script'
+ );
+ } );
+ } );
+
+ QUnit.test( '.getModuleSize() - scripts, styles', function ( assert ) {
+ mw.loader.implement(
+ 'test.inspect.both',
+ function () { 'example'; },
+ { css: [ '.example {}' ] }
+ );
+
+ return mw.loader.using( 'test.inspect.both' ).then( function () {
+ assert.equal(
+ mw.inspect.getModuleSize( 'test.inspect.both' ),
+ // name, script function, styles object
+ 64,
+ 'test.inspect.both'
+ );
+ } );
+ } );
+
+ QUnit.test( '.getModuleSize() - scripts, messages', function ( assert ) {
+ mw.loader.implement(
+ 'test.inspect.scriptmsg',
+ function () { 'example'; },
+ {},
+ { example: 'Hello world.' }
+ );
+
+ return mw.loader.using( 'test.inspect.scriptmsg' ).then( function () {
+ assert.equal(
+ mw.inspect.getModuleSize( 'test.inspect.scriptmsg' ),
+ // name, script function, empty styles object, messages object
+ 74,
+ 'test.inspect.scriptmsg'
+ );
+ } );
+ } );
+
+ QUnit.test( '.getModuleSize() - scripts, styles, messages, templates', function ( assert ) {
+ mw.loader.implement(
+ 'test.inspect.all',
+ function () { 'example'; },
+ { css: [ '.example {}' ] },
+ { example: 'Hello world.' },
+ { 'example.html': '<p>Hello world.<p>' }
+ );
+
+ return mw.loader.using( 'test.inspect.all' ).then( function () {
+ assert.equal(
+ mw.inspect.getModuleSize( 'test.inspect.all' ),
+ // name, script function, styles object, messages object, templates object
+ 126,
+ 'test.inspect.all'
+ );
+ } );
+ } );
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js
new file mode 100644
index 00000000..0653dfd3
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js
@@ -0,0 +1,1233 @@
+( function ( mw, $ ) {
+ /* eslint-disable camelcase */
+ var formatText, formatParse, formatnumTests, specialCharactersPageName, expectedListUsers,
+ expectedListUsersSitename, expectedLinkPagenamee, expectedEntrypoints,
+ mwLanguageCache = {},
+ hasOwn = Object.hasOwnProperty;
+
+ // When the expected result is the same in both modes
+ function assertBothModes( assert, parserArguments, expectedResult, assertMessage ) {
+ assert.equal( formatText.apply( null, parserArguments ), expectedResult, assertMessage + ' when format is \'text\'' );
+ assert.equal( formatParse.apply( null, parserArguments ), expectedResult, assertMessage + ' when format is \'parse\'' );
+ }
+
+ QUnit.module( 'mediawiki.jqueryMsg', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.originalMwLanguage = mw.language;
+ this.parserDefaults = mw.jqueryMsg.getParserDefaults();
+ mw.jqueryMsg.setParserDefaults( {
+ magic: {
+ PAGENAME: '2 + 2',
+ PAGENAMEE: mw.util.wikiUrlencode( '2 + 2' ),
+ SITENAME: 'Wiki'
+ }
+ } );
+
+ specialCharactersPageName = '"Who" wants to be a millionaire & live on \'Exotic Island\'?';
+
+ expectedListUsers = '注册<a title="Special:ListUsers" href="/wiki/Special:ListUsers">用户</a>';
+ expectedListUsersSitename = '注册<a title="Special:ListUsers" href="/wiki/Special:ListUsers">用户' +
+ 'Wiki</a>';
+ expectedLinkPagenamee = '<a href="https://example.org/wiki/Foo?bar=baz#val/2_%2B_2">Test</a>';
+
+ expectedEntrypoints = '<a href="https://www.mediawiki.org/wiki/Manual:index.php">index.php</a>';
+
+ formatText = mw.jqueryMsg.getMessageFunction( {
+ format: 'text'
+ } );
+
+ formatParse = mw.jqueryMsg.getMessageFunction( {
+ format: 'parse'
+ } );
+ },
+ teardown: function () {
+ mw.language = this.originalMwLanguage;
+ mw.jqueryMsg.setParserDefaults( this.parserDefaults );
+ },
+ config: {
+ wgArticlePath: '/wiki/$1',
+ wgNamespaceIds: {
+ template: 10,
+ template_talk: 11,
+ // Localised
+ szablon: 10,
+ dyskusja_szablonu: 11
+ },
+ wgFormattedNamespaces: {
+ // Localised
+ 10: 'Szablon',
+ 11: 'Dyskusja szablonu'
+ }
+ },
+ // Messages that are reused in multiple tests
+ messages: {
+ // The values for gender are not significant,
+ // what matters is which of the values is choosen by the parser
+ 'gender-msg': '$1: {{GENDER:$2|blue|pink|green}}',
+ 'gender-msg-currentuser': '{{GENDER:|blue|pink|green}}',
+
+ 'plural-msg': 'Found $1 {{PLURAL:$1|item|items}}',
+ // See https://phabricator.wikimedia.org/T71993
+ 'plural-msg-explicit-forms-nested': 'Found {{PLURAL:$1|$1 results|0=no results in {{SITENAME}}|1=$1 result}}',
+ // Assume the grammar form grammar_case_foo is not valid in any language
+ 'grammar-msg': 'Przeszukaj {{GRAMMAR:grammar_case_foo|{{SITENAME}}}}',
+
+ 'formatnum-msg': '{{formatnum:$1}}',
+
+ 'portal-url': 'Project:Community portal',
+ 'see-portal-url': '{{Int:portal-url}} is an important community page.',
+
+ 'jquerymsg-test-statistics-users': '注册[[Special:ListUsers|用户]]',
+ 'jquerymsg-test-statistics-users-sitename': '注册[[Special:ListUsers|用户{{SITENAME}}]]',
+ 'jquerymsg-test-link-pagenamee': '[https://example.org/wiki/Foo?bar=baz#val/{{PAGENAMEE}} Test]',
+
+ 'jquerymsg-test-version-entrypoints-index-php': '[https://www.mediawiki.org/wiki/Manual:index.php index.php]',
+
+ 'external-link-replace': 'Foo [$1 bar]',
+ 'external-link-plural': 'Foo {{PLURAL:$1|is [$2 one]|are [$2 some]|2=[$2 two]|3=three|4=a=b}} things.',
+ 'plural-only-explicit-forms': 'It is a {{PLURAL:$1|1=single|2=double}} room.',
+ 'plural-empty-explicit-form': 'There is me{{PLURAL:$1|0=| and other people}}.'
+ }
+ } ) );
+
+ /**
+ * Be careful to no run this in parallel as it uses a global identifier (mw.language)
+ * to transport the module back to the test. It musn't be overwritten concurrentely.
+ *
+ * This function caches the mw.language data to avoid having to request the same module
+ * multiple times. There is more than one test case for any given language.
+ */
+ function getMwLanguage( langCode ) {
+ if ( !hasOwn.call( mwLanguageCache, langCode ) ) {
+ mwLanguageCache[ langCode ] = $.ajax( {
+ url: mw.util.wikiScript( 'load' ),
+ data: {
+ skin: mw.config.get( 'skin' ),
+ lang: langCode,
+ debug: mw.config.get( 'debug' ),
+ modules: [
+ 'mediawiki.language.data',
+ 'mediawiki.language'
+ ].join( '|' ),
+ only: 'scripts'
+ },
+ dataType: 'script',
+ cache: true
+ } ).then( function () {
+ return mw.language;
+ } );
+ }
+ return mwLanguageCache[ langCode ];
+ }
+
+ /**
+ * @param {Function[]} tasks List of functions that perform tasks
+ * that may be asynchronous. Invoke the callback parameter when done.
+ */
+ function process( tasks ) {
+ function abort() {
+ tasks.splice( 0, tasks.length );
+ // eslint-disable-next-line no-use-before-define
+ next();
+ }
+ function next() {
+ var task;
+ if ( !tasks ) {
+ // This happens if after the process is completed, one of our callbacks is
+ // invoked. This can happen if a test timed out but the process was still
+ // running. In that case, ignore it. Don't invoke complete() a second time.
+ return;
+ }
+ task = tasks.shift();
+ if ( task ) {
+ task( next, abort );
+ } else {
+ // Remove tasks list to indicate the process is final.
+ tasks = null;
+ }
+ }
+ next();
+ }
+
+ QUnit.test( 'Replace', function ( assert ) {
+ mw.messages.set( 'simple', 'Foo $1 baz $2' );
+
+ assert.equal( formatParse( 'simple' ), 'Foo $1 baz $2', 'Replacements with no substitutes' );
+ assert.equal( formatParse( 'simple', 'bar' ), 'Foo bar baz $2', 'Replacements with less substitutes' );
+ assert.equal( formatParse( 'simple', 'bar', 'quux' ), 'Foo bar baz quux', 'Replacements with all substitutes' );
+
+ mw.messages.set( 'plain-input', '<foo foo="foo">x$1y&lt;</foo>z' );
+
+ assert.equal(
+ formatParse( 'plain-input', 'bar' ),
+ '&lt;foo foo="foo"&gt;xbary&amp;lt;&lt;/foo&gt;z',
+ 'Input is not considered html'
+ );
+
+ mw.messages.set( 'plain-replace', 'Foo $1' );
+
+ assert.equal(
+ formatParse( 'plain-replace', '<bar bar="bar">&gt;</bar>' ),
+ 'Foo &lt;bar bar="bar"&gt;&amp;gt;&lt;/bar&gt;',
+ 'Replacement is not considered html'
+ );
+
+ mw.messages.set( 'object-replace', 'Foo $1' );
+
+ assert.equal(
+ formatParse( 'object-replace', $( '<div class="bar">&gt;</div>' ) ),
+ 'Foo <div class="bar">&gt;</div>',
+ 'jQuery objects are preserved as raw html'
+ );
+
+ assert.equal(
+ formatParse( 'object-replace', $( '<div class="bar">&gt;</div>' ).get( 0 ) ),
+ 'Foo <div class="bar">&gt;</div>',
+ 'HTMLElement objects are preserved as raw html'
+ );
+
+ assert.equal(
+ formatParse( 'object-replace', $( '<div class="bar">&gt;</div>' ).toArray() ),
+ 'Foo <div class="bar">&gt;</div>',
+ 'HTMLElement[] arrays are preserved as raw html'
+ );
+
+ assert.equal(
+ formatParse( 'external-link-replace', 'http://example.org/?x=y&z' ),
+ 'Foo <a href="http://example.org/?x=y&amp;z">bar</a>',
+ 'Href is not double-escaped in wikilink function'
+ );
+ assert.equal(
+ formatParse( 'external-link-plural', 1, 'http://example.org' ),
+ 'Foo is <a href="http://example.org">one</a> things.',
+ 'Link is expanded inside plural and is not escaped html'
+ );
+ assert.equal(
+ formatParse( 'external-link-plural', 2, 'http://example.org' ),
+ 'Foo <a href="http://example.org">two</a> things.',
+ 'Link is expanded inside an explicit plural form and is not escaped html'
+ );
+ assert.equal(
+ formatParse( 'external-link-plural', 3 ),
+ 'Foo three things.',
+ 'A simple explicit plural form co-existing with complex explicit plural forms'
+ );
+ assert.equal(
+ formatParse( 'external-link-plural', 4, 'http://example.org' ),
+ 'Foo a=b things.',
+ 'Only first equal sign is used as delimiter for explicit plural form. Repeated equal signs does not create issue'
+ );
+ assert.equal(
+ formatParse( 'external-link-plural', 6, 'http://example.org' ),
+ 'Foo are <a href="http://example.org">some</a> things.',
+ 'Plural fallback to the "other" plural form'
+ );
+ assert.equal(
+ formatParse( 'plural-only-explicit-forms', 2 ),
+ 'It is a double room.',
+ 'Plural with explicit forms alone.'
+ );
+ } );
+
+ QUnit.test( 'Plural', function ( assert ) {
+ assert.equal( formatParse( 'plural-msg', 0 ), 'Found 0 items', 'Plural test for english with zero as count' );
+ assert.equal( formatParse( 'plural-msg', 1 ), 'Found 1 item', 'Singular test for english' );
+ assert.equal( formatParse( 'plural-msg', 2 ), 'Found 2 items', 'Plural test for english' );
+ assert.equal( formatParse( 'plural-msg-explicit-forms-nested', 6 ), 'Found 6 results', 'Plural message with explicit plural forms' );
+ assert.equal( formatParse( 'plural-msg-explicit-forms-nested', 0 ), 'Found no results in Wiki', 'Plural message with explicit plural forms, with nested {{SITENAME}}' );
+ assert.equal( formatParse( 'plural-msg-explicit-forms-nested', 1 ), 'Found 1 result', 'Plural message with explicit plural forms with placeholder nested' );
+ assert.equal( formatParse( 'plural-empty-explicit-form', 0 ), 'There is me.' );
+ assert.equal( formatParse( 'plural-empty-explicit-form', 1 ), 'There is me and other people.' );
+ assert.equal( formatParse( 'plural-empty-explicit-form', 2 ), 'There is me and other people.' );
+ } );
+
+ QUnit.test( 'Gender', function ( assert ) {
+ var originalGender = mw.user.options.get( 'gender' );
+
+ // TODO: These tests should be for mw.msg once mw.msg integrated with mw.jqueryMsg
+ // TODO: English may not be the best language for these tests. Use a language like Arabic or Russian
+ mw.user.options.set( 'gender', 'male' );
+ assert.equal(
+ formatParse( 'gender-msg', 'Bob', 'male' ),
+ 'Bob: blue',
+ 'Masculine from string "male"'
+ );
+ assert.equal(
+ formatParse( 'gender-msg', 'Bob', mw.user ),
+ 'Bob: blue',
+ 'Masculine from mw.user object'
+ );
+ assert.equal(
+ formatParse( 'gender-msg-currentuser' ),
+ 'blue',
+ 'Masculine for current user'
+ );
+
+ mw.user.options.set( 'gender', 'female' );
+ assert.equal(
+ formatParse( 'gender-msg', 'Alice', 'female' ),
+ 'Alice: pink',
+ 'Feminine from string "female"' );
+ assert.equal(
+ formatParse( 'gender-msg', 'Alice', mw.user ),
+ 'Alice: pink',
+ 'Feminine from mw.user object'
+ );
+ assert.equal(
+ formatParse( 'gender-msg-currentuser' ),
+ 'pink',
+ 'Feminine for current user'
+ );
+
+ mw.user.options.set( 'gender', 'unknown' );
+ assert.equal(
+ formatParse( 'gender-msg', 'Foo', mw.user ),
+ 'Foo: green',
+ 'Neutral from mw.user object' );
+ assert.equal(
+ formatParse( 'gender-msg', 'User' ),
+ 'User: green',
+ 'Neutral when no parameter given' );
+ assert.equal(
+ formatParse( 'gender-msg', 'User', 'unknown' ),
+ 'User: green',
+ 'Neutral from string "unknown"'
+ );
+ assert.equal(
+ formatParse( 'gender-msg-currentuser' ),
+ 'green',
+ 'Neutral for current user'
+ );
+
+ mw.messages.set( 'gender-msg-one-form', '{{GENDER:$1|User}}: $2 {{PLURAL:$2|edit|edits}}' );
+
+ assert.equal(
+ formatParse( 'gender-msg-one-form', 'male', 10 ),
+ 'User: 10 edits',
+ 'Gender neutral and plural form'
+ );
+ assert.equal(
+ formatParse( 'gender-msg-one-form', 'female', 1 ),
+ 'User: 1 edit',
+ 'Gender neutral and singular form'
+ );
+
+ mw.messages.set( 'gender-msg-lowercase', '{{gender:$1|he|she}} is awesome' );
+ assert.equal(
+ formatParse( 'gender-msg-lowercase', 'male' ),
+ 'he is awesome',
+ 'Gender masculine'
+ );
+ assert.equal(
+ formatParse( 'gender-msg-lowercase', 'female' ),
+ 'she is awesome',
+ 'Gender feminine'
+ );
+
+ mw.messages.set( 'gender-msg-wrong', '{{gender}} test' );
+ assert.equal(
+ formatParse( 'gender-msg-wrong', 'female' ),
+ ' test',
+ 'Invalid syntax should result in {{gender}} simply being stripped away'
+ );
+
+ mw.user.options.set( 'gender', originalGender );
+ } );
+
+ QUnit.test( 'Case changing', function ( assert ) {
+ mw.messages.set( 'to-lowercase', '{{lc:thIS hAS MEsSed uP CapItaliZatiON}}' );
+ assert.equal( formatParse( 'to-lowercase' ), 'this has messed up capitalization', 'To lowercase' );
+
+ mw.messages.set( 'to-caps', '{{uc:thIS hAS MEsSed uP CapItaliZatiON}}' );
+ assert.equal( formatParse( 'to-caps' ), 'THIS HAS MESSED UP CAPITALIZATION', 'To caps' );
+
+ mw.messages.set( 'uc-to-lcfirst', '{{lcfirst:THis hAS MEsSed uP CapItaliZatiON}}' );
+ mw.messages.set( 'lc-to-lcfirst', '{{lcfirst:thIS hAS MEsSed uP CapItaliZatiON}}' );
+ assert.equal( formatParse( 'uc-to-lcfirst' ), 'tHis hAS MEsSed uP CapItaliZatiON', 'Lcfirst caps' );
+ assert.equal( formatParse( 'lc-to-lcfirst' ), 'thIS hAS MEsSed uP CapItaliZatiON', 'Lcfirst lowercase' );
+
+ mw.messages.set( 'uc-to-ucfirst', '{{ucfirst:THis hAS MEsSed uP CapItaliZatiON}}' );
+ mw.messages.set( 'lc-to-ucfirst', '{{ucfirst:thIS hAS MEsSed uP CapItaliZatiON}}' );
+ assert.equal( formatParse( 'uc-to-ucfirst' ), 'THis hAS MEsSed uP CapItaliZatiON', 'Ucfirst caps' );
+ assert.equal( formatParse( 'lc-to-ucfirst' ), 'ThIS hAS MEsSed uP CapItaliZatiON', 'Ucfirst lowercase' );
+
+ mw.messages.set( 'mixed-to-sentence', '{{ucfirst:{{lc:thIS hAS MEsSed uP CapItaliZatiON}}}}' );
+ assert.equal( formatParse( 'mixed-to-sentence' ), 'This has messed up capitalization', 'To sentence case' );
+ mw.messages.set( 'all-caps-except-first', '{{lcfirst:{{uc:thIS hAS MEsSed uP CapItaliZatiON}}}}' );
+ assert.equal( formatParse( 'all-caps-except-first' ), 'tHIS HAS MESSED UP CAPITALIZATION', 'To opposite sentence case' );
+ } );
+
+ QUnit.test( 'Grammar', function ( assert ) {
+ assert.equal( formatParse( 'grammar-msg' ), 'Przeszukaj Wiki', 'Grammar Test with sitename' );
+
+ mw.messages.set( 'grammar-msg-wrong-syntax', 'Przeszukaj {{GRAMMAR:grammar_case_xyz}}' );
+ assert.equal( formatParse( 'grammar-msg-wrong-syntax' ), 'Przeszukaj ', 'Grammar Test with wrong grammar template syntax' );
+ } );
+
+ QUnit.test( 'Match PHP parser', function ( assert ) {
+ var tasks;
+ mw.messages.set( mw.libs.phpParserData.messages );
+ tasks = $.map( mw.libs.phpParserData.tests, function ( test ) {
+ var done = assert.async();
+ return function ( next, abort ) {
+ getMwLanguage( test.lang )
+ .then( function ( langClass ) {
+ var parser;
+ mw.config.set( 'wgUserLanguage', test.lang );
+ parser = new mw.jqueryMsg.Parser( { language: langClass } );
+ assert.equal(
+ parser.parse( test.key, test.args ).html(),
+ test.result,
+ test.name
+ );
+ }, function () {
+ assert.ok( false, 'Language "' + test.lang + '" failed to load.' );
+ } )
+ .then( done, done )
+ .then( next, abort );
+ };
+ } );
+
+ process( tasks );
+ } );
+
+ QUnit.test( 'Links', function ( assert ) {
+ var testCases,
+ expectedDisambiguationsText,
+ expectedMultipleBars,
+ expectedSpecialCharacters;
+
+ // The below three are all identical to or based on real messages. For disambiguations-text,
+ // the bold was removed because it is not yet implemented.
+
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-test-statistics-users' ),
+ expectedListUsers,
+ 'Piped wikilink'
+ );
+
+ expectedDisambiguationsText = 'The following pages contain at least one link to a disambiguation page.\nThey may have to link to a more appropriate page instead.\nA page is treated as a disambiguation page if it uses a template that is linked from ' +
+ '<a title="MediaWiki:Disambiguationspage" href="/wiki/MediaWiki:Disambiguationspage">MediaWiki:Disambiguationspage</a>.';
+
+ mw.messages.set( 'disambiguations-text', 'The following pages contain at least one link to a disambiguation page.\nThey may have to link to a more appropriate page instead.\nA page is treated as a disambiguation page if it uses a template that is linked from [[MediaWiki:Disambiguationspage]].' );
+ assert.htmlEqual(
+ formatParse( 'disambiguations-text' ),
+ expectedDisambiguationsText,
+ 'Wikilink without pipe'
+ );
+
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-test-version-entrypoints-index-php' ),
+ expectedEntrypoints,
+ 'External link'
+ );
+
+ // Pipe trick is not supported currently, but should not parse as text either.
+ mw.messages.set( 'pipe-trick', '[[Tampa, Florida|]]' );
+ mw.messages.set( 'reverse-pipe-trick', '[[|Tampa, Florida]]' );
+ mw.messages.set( 'empty-link', '[[]]' );
+ this.suppressWarnings();
+ assert.equal(
+ formatParse( 'pipe-trick' ),
+ '[[Tampa, Florida|]]',
+ 'Pipe trick should not be parsed.'
+ );
+ assert.equal(
+ formatParse( 'reverse-pipe-trick' ),
+ '[[|Tampa, Florida]]',
+ 'Reverse pipe trick should not be parsed.'
+ );
+ assert.equal(
+ formatParse( 'empty-link' ),
+ '[[]]',
+ 'Empty link should not be parsed.'
+ );
+ this.restoreWarnings();
+
+ expectedMultipleBars = '<a title="Main Page" href="/wiki/Main_Page">Main|Page</a>';
+ mw.messages.set( 'multiple-bars', '[[Main Page|Main|Page]]' );
+ assert.htmlEqual(
+ formatParse( 'multiple-bars' ),
+ expectedMultipleBars,
+ 'Bar in anchor'
+ );
+
+ expectedSpecialCharacters = '<a title="&quot;Who&quot; wants to be a millionaire &amp; live on &#039;Exotic Island&#039;?" href="/wiki/%22Who%22_wants_to_be_a_millionaire_%26_live_on_%27Exotic_Island%27%3F">&quot;Who&quot; wants to be a millionaire &amp; live on &#039;Exotic Island&#039;?</a>';
+
+ mw.messages.set( 'special-characters', '[[' + specialCharactersPageName + ']]' );
+ assert.htmlEqual(
+ formatParse( 'special-characters' ),
+ expectedSpecialCharacters,
+ 'Special characters'
+ );
+
+ mw.messages.set( 'leading-colon', '[[:File:Foo.jpg]]' );
+ assert.htmlEqual(
+ formatParse( 'leading-colon' ),
+ '<a title="File:Foo.jpg" href="/wiki/File:Foo.jpg">File:Foo.jpg</a>',
+ 'Leading colon in links is stripped'
+ );
+
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-test-statistics-users-sitename' ),
+ expectedListUsersSitename,
+ 'Piped wikilink with parser function in the text'
+ );
+
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-test-link-pagenamee' ),
+ expectedLinkPagenamee,
+ 'External link with parser function in the URL'
+ );
+
+ testCases = [
+ [
+ 'extlink-html-full',
+ 'asd [http://example.org <strong>Example</strong>] asd',
+ 'asd <a href="http://example.org"><strong>Example</strong></a> asd'
+ ],
+ [
+ 'extlink-html-partial',
+ 'asd [http://example.org foo <strong>Example</strong> bar] asd',
+ 'asd <a href="http://example.org">foo <strong>Example</strong> bar</a> asd'
+ ],
+ [
+ 'wikilink-html-full',
+ 'asd [[Example|<strong>Example</strong>]] asd',
+ 'asd <a title="Example" href="/wiki/Example"><strong>Example</strong></a> asd'
+ ],
+ [
+ 'wikilink-html-partial',
+ 'asd [[Example|foo <strong>Example</strong> bar]] asd',
+ 'asd <a title="Example" href="/wiki/Example">foo <strong>Example</strong> bar</a> asd'
+ ]
+ ];
+
+ testCases.forEach( function ( testCase ) {
+ var
+ key = testCase[ 0 ],
+ input = testCase[ 1 ],
+ output = testCase[ 2 ];
+ mw.messages.set( key, input );
+ assert.htmlEqual(
+ formatParse( key ),
+ output,
+ 'HTML in links: ' + key
+ );
+ } );
+ } );
+
+ QUnit.test( 'Replacements in links', function ( assert ) {
+ var testCases = [
+ [
+ 'extlink-param-href-full',
+ 'asd [$1 Example] asd',
+ 'asd <a href="http://example.com">Example</a> asd'
+ ],
+ [
+ 'extlink-param-href-partial',
+ 'asd [$1/example Example] asd',
+ 'asd <a href="http://example.com/example">Example</a> asd'
+ ],
+ [
+ 'extlink-param-text-full',
+ 'asd [http://example.org $2] asd',
+ 'asd <a href="http://example.org">Text</a> asd'
+ ],
+ [
+ 'extlink-param-text-partial',
+ 'asd [http://example.org Example $2] asd',
+ 'asd <a href="http://example.org">Example Text</a> asd'
+ ],
+ [
+ 'extlink-param-both-full',
+ 'asd [$1 $2] asd',
+ 'asd <a href="http://example.com">Text</a> asd'
+ ],
+ [
+ 'extlink-param-both-partial',
+ 'asd [$1/example Example $2] asd',
+ 'asd <a href="http://example.com/example">Example Text</a> asd'
+ ],
+ [
+ 'wikilink-param-href-full',
+ 'asd [[$1|Example]] asd',
+ 'asd <a title="Example" href="/wiki/Example">Example</a> asd'
+ ],
+ [
+ 'wikilink-param-href-partial',
+ 'asd [[$1/Test|Example]] asd',
+ 'asd <a title="Example/Test" href="/wiki/Example/Test">Example</a> asd'
+ ],
+ [
+ 'wikilink-param-text-full',
+ 'asd [[Example|$2]] asd',
+ 'asd <a title="Example" href="/wiki/Example">Text</a> asd'
+ ],
+ [
+ 'wikilink-param-text-partial',
+ 'asd [[Example|Example $2]] asd',
+ 'asd <a title="Example" href="/wiki/Example">Example Text</a> asd'
+ ],
+ [
+ 'wikilink-param-both-full',
+ 'asd [[$1|$2]] asd',
+ 'asd <a title="Example" href="/wiki/Example">Text</a> asd'
+ ],
+ [
+ 'wikilink-param-both-partial',
+ 'asd [[$1/Test|Example $2]] asd',
+ 'asd <a title="Example/Test" href="/wiki/Example/Test">Example Text</a> asd'
+ ],
+ [
+ 'wikilink-param-unpiped-full',
+ 'asd [[$1]] asd',
+ 'asd <a title="Example" href="/wiki/Example">Example</a> asd'
+ ],
+ [
+ 'wikilink-param-unpiped-partial',
+ 'asd [[$1/Test]] asd',
+ 'asd <a title="Example/Test" href="/wiki/Example/Test">Example/Test</a> asd'
+ ]
+ ];
+
+ testCases.forEach( function ( testCase ) {
+ var
+ key = testCase[ 0 ],
+ input = testCase[ 1 ],
+ output = testCase[ 2 ],
+ paramHref = key.slice( 0, 8 ) === 'wikilink' ? 'Example' : 'http://example.com',
+ paramText = 'Text';
+ mw.messages.set( key, input );
+ assert.htmlEqual(
+ formatParse( key, paramHref, paramText ),
+ output,
+ 'Replacements in links: ' + key
+ );
+ } );
+ } );
+
+ // Tests that {{-transformation vs. general parsing are done as requested
+ QUnit.test( 'Curly brace transformation', function ( assert ) {
+ var oldUserLang = mw.config.get( 'wgUserLanguage' );
+
+ assertBothModes( assert, [ 'gender-msg', 'Bob', 'male' ], 'Bob: blue', 'gender is resolved' );
+
+ assertBothModes( assert, [ 'plural-msg', 5 ], 'Found 5 items', 'plural is resolved' );
+
+ assertBothModes( assert, [ 'grammar-msg' ], 'Przeszukaj Wiki', 'grammar is resolved' );
+
+ mw.config.set( 'wgUserLanguage', 'en' );
+ assertBothModes( assert, [ 'formatnum-msg', '987654321.654321' ], '987,654,321.654', 'formatnum is resolved' );
+
+ // Test non-{{ wikitext, where behavior differs
+
+ // Wikilink
+ assert.equal(
+ formatText( 'jquerymsg-test-statistics-users' ),
+ mw.messages.get( 'jquerymsg-test-statistics-users' ),
+ 'Internal link message unchanged when format is \'text\''
+ );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-test-statistics-users' ),
+ expectedListUsers,
+ 'Internal link message parsed when format is \'parse\''
+ );
+
+ // External link
+ assert.equal(
+ formatText( 'jquerymsg-test-version-entrypoints-index-php' ),
+ mw.messages.get( 'jquerymsg-test-version-entrypoints-index-php' ),
+ 'External link message unchanged when format is \'text\''
+ );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-test-version-entrypoints-index-php' ),
+ expectedEntrypoints,
+ 'External link message processed when format is \'parse\''
+ );
+
+ // External link with parameter
+ assert.equal(
+ formatText( 'external-link-replace', 'http://example.com' ),
+ 'Foo [http://example.com bar]',
+ 'External link message only substitutes parameter when format is \'text\''
+ );
+ assert.htmlEqual(
+ formatParse( 'external-link-replace', 'http://example.com' ),
+ 'Foo <a href="http://example.com">bar</a>',
+ 'External link message processed when format is \'parse\''
+ );
+ assert.htmlEqual(
+ formatParse( 'external-link-replace', $( '<i>' ) ),
+ 'Foo <i>bar</i>',
+ 'External link message processed as jQuery object when format is \'parse\''
+ );
+ assert.htmlEqual(
+ formatParse( 'external-link-replace', function () {} ),
+ 'Foo <a role="button" tabindex="0">bar</a>',
+ 'External link message processed as function when format is \'parse\''
+ );
+
+ mw.config.set( 'wgUserLanguage', oldUserLang );
+ } );
+
+ QUnit.test( 'Int', function ( assert ) {
+ var newarticletextSource = 'You have followed a link to a page that does not exist yet. To create the page, start typing in the box below (see the [[{{Int:Foobar}}|foobar]] for more info). If you are here by mistake, click your browser\'s back button.',
+ expectedNewarticletext,
+ helpPageTitle = 'Help:Foobar';
+
+ mw.messages.set( 'foobar', helpPageTitle );
+
+ expectedNewarticletext = 'You have followed a link to a page that does not exist yet. To create the page, start typing in the box below (see the ' +
+ '<a title="Help:Foobar" href="/wiki/Help:Foobar">foobar</a> for more info). If you are here by mistake, click your browser\'s back button.';
+
+ mw.messages.set( 'newarticletext', newarticletextSource );
+
+ assert.htmlEqual(
+ formatParse( 'newarticletext' ),
+ expectedNewarticletext,
+ 'Link with nested message'
+ );
+
+ assert.equal(
+ formatParse( 'see-portal-url' ),
+ 'Project:Community portal is an important community page.',
+ 'Nested message'
+ );
+
+ mw.messages.set( 'newarticletext-lowercase',
+ newarticletextSource.replace( 'Int:Helppage', 'int:helppage' ) );
+
+ assert.htmlEqual(
+ formatParse( 'newarticletext-lowercase' ),
+ expectedNewarticletext,
+ 'Link with nested message, lowercase include'
+ );
+
+ mw.messages.set( 'uses-missing-int', '{{int:doesnt-exist}}' );
+
+ assert.equal(
+ formatParse( 'uses-missing-int' ),
+ '⧼doesnt-exist⧽',
+ 'int: where nested message does not exist'
+ );
+ } );
+
+ QUnit.test( 'Ns', function ( assert ) {
+ mw.messages.set( 'ns-template-talk', '{{ns:Template talk}}' );
+ assert.equal(
+ formatParse( 'ns-template-talk' ),
+ 'Dyskusja szablonu',
+ 'ns: returns localised namespace when used with a canonical namespace name'
+ );
+
+ mw.messages.set( 'ns-10', '{{ns:10}}' );
+ assert.equal(
+ formatParse( 'ns-10' ),
+ 'Szablon',
+ 'ns: returns localised namespace when used with a namespace number'
+ );
+
+ mw.messages.set( 'ns-unknown', '{{ns:doesnt-exist}}' );
+ assert.equal(
+ formatParse( 'ns-unknown' ),
+ '',
+ 'ns: returns empty string for unknown namespace name'
+ );
+
+ mw.messages.set( 'ns-in-a-link', '[[{{ns:template}}:Foo]]' );
+ assert.equal(
+ formatParse( 'ns-in-a-link' ),
+ '<a title="Szablon:Foo" href="/wiki/Szablon:Foo">Szablon:Foo</a>',
+ 'ns: works when used inside a wikilink'
+ );
+ } );
+
+ // Tests that getMessageFunction is used for non-plain messages with curly braces or
+ // square brackets, but not otherwise.
+ QUnit.test( 'mw.Message.prototype.parser monkey-patch', function ( assert ) {
+ var oldGMF, outerCalled, innerCalled;
+
+ mw.messages.set( {
+ 'curly-brace': '{{int:message}}',
+ 'single-square-bracket': '[https://www.mediawiki.org/ MediaWiki]',
+ 'double-square-bracket': '[[Some page]]',
+ regular: 'Other message'
+ } );
+
+ oldGMF = mw.jqueryMsg.getMessageFunction;
+
+ mw.jqueryMsg.getMessageFunction = function () {
+ outerCalled = true;
+ return function () {
+ innerCalled = true;
+ };
+ };
+
+ function verifyGetMessageFunction( key, format, shouldCall ) {
+ var message;
+ outerCalled = false;
+ innerCalled = false;
+ message = mw.message( key );
+ message[ format ]();
+ assert.strictEqual( outerCalled, shouldCall, 'Outer function called for ' + key );
+ assert.strictEqual( innerCalled, shouldCall, 'Inner function called for ' + key );
+ delete mw.messages[ format ];
+ }
+
+ verifyGetMessageFunction( 'curly-brace', 'parse', true );
+ verifyGetMessageFunction( 'curly-brace', 'plain', false );
+
+ verifyGetMessageFunction( 'single-square-bracket', 'parse', true );
+ verifyGetMessageFunction( 'single-square-bracket', 'plain', false );
+
+ verifyGetMessageFunction( 'double-square-bracket', 'parse', true );
+ verifyGetMessageFunction( 'double-square-bracket', 'plain', false );
+
+ verifyGetMessageFunction( 'regular', 'parse', false );
+ verifyGetMessageFunction( 'regular', 'plain', false );
+
+ verifyGetMessageFunction( 'jquerymsg-test-pagetriage-del-talk-page-notify-summary', 'plain', false );
+ verifyGetMessageFunction( 'jquerymsg-test-categorytree-collapse-bullet', 'plain', false );
+ verifyGetMessageFunction( 'jquerymsg-test-wikieditor-toolbar-help-content-signature-result', 'plain', false );
+
+ mw.jqueryMsg.getMessageFunction = oldGMF;
+ } );
+
+ formatnumTests = [
+ {
+ lang: 'en',
+ number: 987654321.654321,
+ result: '987,654,321.654',
+ description: 'formatnum test for English, decimal separator'
+ },
+ {
+ lang: 'ar',
+ number: 987654321.654321,
+ result: '٩٨٧٬٦٥٤٬٣٢١٫٦٥٤',
+ description: 'formatnum test for Arabic, with decimal separator'
+ },
+ {
+ lang: 'ar',
+ number: '٩٨٧٦٥٤٣٢١٫٦٥٤٣٢١',
+ result: 987654321,
+ integer: true,
+ description: 'formatnum test for Arabic, with decimal separator, reverse'
+ },
+ {
+ lang: 'ar',
+ number: -12.89,
+ result: '-١٢٫٨٩',
+ description: 'formatnum test for Arabic, negative number'
+ },
+ {
+ lang: 'ar',
+ number: '-١٢٫٨٩',
+ result: -12,
+ integer: true,
+ description: 'formatnum test for Arabic, negative number, reverse'
+ },
+ {
+ lang: 'nl',
+ number: 987654321.654321,
+ result: '987.654.321,654',
+ description: 'formatnum test for Nederlands, decimal separator'
+ },
+ {
+ lang: 'nl',
+ number: -12.89,
+ result: '-12,89',
+ description: 'formatnum test for Nederlands, negative number'
+ },
+ {
+ lang: 'nl',
+ number: '.89',
+ result: '0,89',
+ description: 'formatnum test for Nederlands'
+ },
+ {
+ lang: 'nl',
+ number: 'invalidnumber',
+ result: 'invalidnumber',
+ description: 'formatnum test for Nederlands, invalid number'
+ },
+ {
+ lang: 'ml',
+ number: '1000000000',
+ result: '1,00,00,00,000',
+ description: 'formatnum test for Malayalam'
+ },
+ {
+ lang: 'ml',
+ number: '-1000000000',
+ result: '-1,00,00,00,000',
+ description: 'formatnum test for Malayalam, negative number'
+ },
+ /*
+ * This will fail because of wrong pattern for ml in MW(different from CLDR)
+ {
+ lang: 'ml',
+ number: '1000000000.000',
+ result: '1,00,00,00,000.000',
+ description: 'formatnum test for Malayalam with decimal place'
+ },
+ */
+ {
+ lang: 'hi',
+ number: '123456789.123456789',
+ result: '१२,३४,५६,७८९',
+ description: 'formatnum test for Hindi'
+ },
+ {
+ lang: 'hi',
+ number: '१२,३४,५६,७८९',
+ result: '१२,३४,५६,७८९',
+ description: 'formatnum test for Hindi, Devanagari digits passed'
+ },
+ {
+ lang: 'hi',
+ number: '१,२३,४५६',
+ result: '123456',
+ integer: true,
+ description: 'formatnum test for Hindi, Devanagari digits passed to get integer value'
+ }
+ ];
+
+ QUnit.test( 'formatnum', function ( assert ) {
+ var queue;
+ mw.messages.set( 'formatnum-msg', '{{formatnum:$1}}' );
+ mw.messages.set( 'formatnum-msg-int', '{{formatnum:$1|R}}' );
+ queue = formatnumTests.map( function ( test ) {
+ var done = assert.async();
+ return function ( next, abort ) {
+ getMwLanguage( test.lang )
+ .then( function ( langClass ) {
+ var parser;
+ mw.config.set( 'wgUserLanguage', test.lang );
+ parser = new mw.jqueryMsg.Parser( { language: langClass } );
+ assert.equal(
+ parser.parse( test.integer ? 'formatnum-msg-int' : 'formatnum-msg',
+ [ test.number ] ).html(),
+ test.result,
+ test.description
+ );
+ }, function () {
+ assert.ok( false, 'Language "' + test.lang + '" failed to load' );
+ } )
+ .then( done, done )
+ .then( next, abort );
+ };
+ } );
+ process( queue );
+ } );
+
+ // HTML in wikitext
+ QUnit.test( 'HTML', function ( assert ) {
+ mw.messages.set( 'jquerymsg-italics-msg', '<i>Very</i> important' );
+
+ assertBothModes( assert, [ 'jquerymsg-italics-msg' ], mw.messages.get( 'jquerymsg-italics-msg' ), 'Simple italics unchanged' );
+
+ mw.messages.set( 'jquerymsg-bold-msg', '<b>Strong</b> speaker' );
+ assertBothModes( assert, [ 'jquerymsg-bold-msg' ], mw.messages.get( 'jquerymsg-bold-msg' ), 'Simple bold unchanged' );
+
+ mw.messages.set( 'jquerymsg-bold-italics-msg', 'It is <b><i>key</i></b>' );
+ assertBothModes( assert, [ 'jquerymsg-bold-italics-msg' ], mw.messages.get( 'jquerymsg-bold-italics-msg' ), 'Bold and italics nesting order preserved' );
+
+ mw.messages.set( 'jquerymsg-italics-bold-msg', 'It is <i><b>vital</b></i>' );
+ assertBothModes( assert, [ 'jquerymsg-italics-bold-msg' ], mw.messages.get( 'jquerymsg-italics-bold-msg' ), 'Italics and bold nesting order preserved' );
+
+ mw.messages.set( 'jquerymsg-italics-with-link', 'An <i>italicized [[link|wiki-link]]</i>' );
+
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-italics-with-link' ),
+ 'An <i>italicized <a title="link" href="' + mw.html.escape( mw.util.getUrl( 'link' ) ) + '">wiki-link</i>',
+ 'Italics with link inside in parse mode'
+ );
+
+ assert.equal(
+ formatText( 'jquerymsg-italics-with-link' ),
+ mw.messages.get( 'jquerymsg-italics-with-link' ),
+ 'Italics with link unchanged in text mode'
+ );
+
+ mw.messages.set( 'jquerymsg-italics-id-class', '<i id="foo" class="bar">Foo</i>' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-italics-id-class' ),
+ mw.messages.get( 'jquerymsg-italics-id-class' ),
+ 'ID and class are allowed'
+ );
+
+ mw.messages.set( 'jquerymsg-italics-onclick', '<i onclick="alert(\'foo\')">Foo</i>' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-italics-onclick' ),
+ '&lt;i onclick=&quot;alert(\'foo\')&quot;&gt;Foo&lt;/i&gt;',
+ 'element with onclick is escaped because it is not allowed'
+ );
+
+ mw.messages.set( 'jquerymsg-script-msg', '<script >alert( "Who put this tag here?" );</script>' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-script-msg' ),
+ '&lt;script &gt;alert( &quot;Who put this tag here?&quot; );&lt;/script&gt;',
+ 'Tag outside whitelist escaped in parse mode'
+ );
+
+ assert.equal(
+ formatText( 'jquerymsg-script-msg' ),
+ mw.messages.get( 'jquerymsg-script-msg' ),
+ 'Tag outside whitelist unchanged in text mode'
+ );
+
+ mw.messages.set( 'jquerymsg-script-link-msg', '<script>[[Foo|bar]]</script>' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-script-link-msg' ),
+ '&lt;script&gt;<a title="Foo" href="' + mw.html.escape( mw.util.getUrl( 'Foo' ) ) + '">bar</a>&lt;/script&gt;',
+ 'Script tag text is escaped because that element is not allowed, but link inside is still HTML'
+ );
+
+ mw.messages.set( 'jquerymsg-mismatched-html', '<i class="important">test</b>' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-mismatched-html' ),
+ '&lt;i class=&quot;important&quot;&gt;test&lt;/b&gt;',
+ 'Mismatched HTML start and end tag treated as text'
+ );
+
+ mw.messages.set( 'jquerymsg-script-and-external-link', '<script>alert( "jquerymsg-script-and-external-link test" );</script> [http://example.com <i>Foo</i> bar]' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-script-and-external-link' ),
+ '&lt;script&gt;alert( "jquerymsg-script-and-external-link test" );&lt;/script&gt; <a href="http://example.com"><i>Foo</i> bar</a>',
+ 'HTML tags in external links not interfering with escaping of other tags'
+ );
+
+ mw.messages.set( 'jquerymsg-link-script', '[http://example.com <script>alert( "jquerymsg-link-script test" );</script>]' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-link-script' ),
+ '<a href="http://example.com">&lt;script&gt;alert( "jquerymsg-link-script test" );&lt;/script&gt;</a>',
+ 'Non-whitelisted HTML tag in external link anchor treated as text'
+ );
+
+ // Intentionally not using htmlEqual for the quote tests
+ mw.messages.set( 'jquerymsg-double-quotes-preserved', '<i id="double">Double</i>' );
+ assert.equal(
+ formatParse( 'jquerymsg-double-quotes-preserved' ),
+ mw.messages.get( 'jquerymsg-double-quotes-preserved' ),
+ 'Attributes with double quotes are preserved as such'
+ );
+
+ mw.messages.set( 'jquerymsg-single-quotes-normalized-to-double', '<i id=\'single\'>Single</i>' );
+ assert.equal(
+ formatParse( 'jquerymsg-single-quotes-normalized-to-double' ),
+ '<i id="single">Single</i>',
+ 'Attributes with single quotes are normalized to double'
+ );
+
+ mw.messages.set( 'jquerymsg-escaped-double-quotes-attribute', '<i style="font-family:&quot;Arial&quot;">Styled</i>' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-escaped-double-quotes-attribute' ),
+ mw.messages.get( 'jquerymsg-escaped-double-quotes-attribute' ),
+ 'Escaped attributes are parsed correctly'
+ );
+
+ mw.messages.set( 'jquerymsg-escaped-single-quotes-attribute', '<i style=\'font-family:&#039;Arial&#039;\'>Styled</i>' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-escaped-single-quotes-attribute' ),
+ mw.messages.get( 'jquerymsg-escaped-single-quotes-attribute' ),
+ 'Escaped attributes are parsed correctly'
+ );
+
+ mw.messages.set( 'jquerymsg-wikitext-contents-parsed', '<i>[http://example.com Example]</i>' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-wikitext-contents-parsed' ),
+ '<i><a href="http://example.com">Example</a></i>',
+ 'Contents of valid tag are treated as wikitext, so external link is parsed'
+ );
+
+ mw.messages.set( 'jquerymsg-wikitext-contents-script', '<i><script>Script inside</script></i>' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-wikitext-contents-script' ),
+ '<i>&lt;script&gt;Script inside&lt;/script&gt;</i>',
+ 'Contents of valid tag are treated as wikitext, so invalid HTML element is treated as text'
+ );
+
+ mw.messages.set( 'jquerymsg-unclosed-tag', 'Foo<tag>bar' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-unclosed-tag' ),
+ 'Foo&lt;tag&gt;bar',
+ 'Nonsupported unclosed tags are escaped'
+ );
+
+ mw.messages.set( 'jquerymsg-self-closing-tag', 'Foo<tag/>bar' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-self-closing-tag' ),
+ 'Foo&lt;tag/&gt;bar',
+ 'Self-closing tags don\'t cause a parse error'
+ );
+
+ mw.messages.set( 'jquerymsg-asciialphabetliteral-regression', '<b >>>="dir">asd</b>' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-asciialphabetliteral-regression' ),
+ '<b>&gt;&gt;="dir"&gt;asd</b>',
+ 'Regression test for bad "asciiAlphabetLiteral" definition'
+ );
+
+ mw.messages.set( 'jquerymsg-entities1', 'A&B' );
+ mw.messages.set( 'jquerymsg-entities2', 'A&gt;B' );
+ mw.messages.set( 'jquerymsg-entities3', 'A&rarr;B' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-entities1' ),
+ 'A&amp;B',
+ 'Lone "&" is escaped in text'
+ );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-entities2' ),
+ 'A&amp;gt;B',
+ '"&gt;" entity is double-escaped in text' // (WHY?)
+ );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-entities3' ),
+ 'A&amp;rarr;B',
+ '"&rarr;" entity is double-escaped in text'
+ );
+
+ mw.messages.set( 'jquerymsg-entities-attr1', '<i title="A&B"></i>' );
+ mw.messages.set( 'jquerymsg-entities-attr2', '<i title="A&gt;B"></i>' );
+ mw.messages.set( 'jquerymsg-entities-attr3', '<i title="A&rarr;B"></i>' );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-entities-attr1' ),
+ '<i title="A&amp;B"></i>',
+ 'Lone "&" is escaped in attribute'
+ );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-entities-attr2' ),
+ '<i title="A&gt;B"></i>',
+ '"&gt;" entity is not double-escaped in attribute' // (WHY?)
+ );
+ assert.htmlEqual(
+ formatParse( 'jquerymsg-entities-attr3' ),
+ '<i title="A&amp;rarr;B"></i>',
+ '"&rarr;" entity is double-escaped in attribute'
+ );
+ } );
+
+ QUnit.test( 'Nowiki', function ( assert ) {
+ mw.messages.set( 'jquerymsg-nowiki-link', 'Foo <nowiki>[[bar]]</nowiki> baz.' );
+ assert.equal(
+ formatParse( 'jquerymsg-nowiki-link' ),
+ 'Foo [[bar]] baz.',
+ 'Link inside nowiki is not parsed'
+ );
+
+ mw.messages.set( 'jquerymsg-nowiki-htmltag', 'Foo <nowiki><b>bar</b></nowiki> baz.' );
+ assert.equal(
+ formatParse( 'jquerymsg-nowiki-htmltag' ),
+ 'Foo &lt;b&gt;bar&lt;/b&gt; baz.',
+ 'HTML inside nowiki is not parsed and escaped'
+ );
+
+ mw.messages.set( 'jquerymsg-nowiki-template', 'Foo <nowiki>{{bar}}</nowiki> baz.' );
+ assert.equal(
+ formatParse( 'jquerymsg-nowiki-template' ),
+ 'Foo {{bar}} baz.',
+ 'Template inside nowiki is not parsed and does not cause a parse error'
+ );
+ } );
+
+ QUnit.test( 'Behavior in case of invalid wikitext', function ( assert ) {
+ var logSpy;
+ mw.messages.set( 'invalid-wikitext', '<b>{{FAIL}}</b>' );
+
+ this.suppressWarnings();
+ logSpy = this.sandbox.spy( mw.log, 'warn' );
+
+ assert.equal(
+ formatParse( 'invalid-wikitext' ),
+ '&lt;b&gt;{{FAIL}}&lt;/b&gt;',
+ 'Invalid wikitext: \'parse\' format'
+ );
+
+ assert.equal(
+ formatText( 'invalid-wikitext' ),
+ '<b>{{FAIL}}</b>',
+ 'Invalid wikitext: \'text\' format'
+ );
+
+ assert.equal( logSpy.callCount, 2, 'mw.log.warn calls' );
+ } );
+
+ QUnit.test( 'Integration', function ( assert ) {
+ var expected, logSpy, msg;
+
+ expected = '<b><a title="Bold" href="/wiki/Bold">Bold</a>!</b>';
+ mw.messages.set( 'integration-test', '<b>[[Bold]]!</b>' );
+
+ this.suppressWarnings();
+ logSpy = this.sandbox.spy( mw.log, 'warn' );
+ assert.equal(
+ window.gM( 'integration-test' ),
+ expected,
+ 'Global function gM() works correctly'
+ );
+ assert.equal( logSpy.callCount, 1, 'mw.log.warn called' );
+ this.restoreWarnings();
+
+ assert.equal(
+ mw.message( 'integration-test' ).parse(),
+ expected,
+ 'mw.message().parse() works correctly'
+ );
+
+ assert.equal(
+ $( '<span>' ).msg( 'integration-test' ).html(),
+ expected,
+ 'jQuery plugin $.fn.msg() works correctly'
+ );
+
+ mw.messages.set( 'integration-test-extlink', '[$1 Link]' );
+ msg = mw.message(
+ 'integration-test-extlink',
+ $( '<a>' ).attr( 'href', 'http://example.com/' )
+ );
+ msg.parse(); // Not a no-op
+ assert.equal(
+ msg.parse(),
+ '<a href="http://example.com/">Link</a>',
+ 'Calling .parse() multiple times does not duplicate link contents'
+ );
+ } );
+
+ QUnit.test( 'setParserDefaults', function ( assert ) {
+ mw.jqueryMsg.setParserDefaults( {
+ magic: {
+ FOO: 'foo',
+ BAR: 'bar'
+ }
+ } );
+
+ assert.deepEqual(
+ mw.jqueryMsg.getParserDefaults().magic,
+ {
+ FOO: 'foo',
+ BAR: 'bar'
+ },
+ 'setParserDefaults is shallow by default'
+ );
+
+ mw.jqueryMsg.setParserDefaults(
+ {
+ magic: {
+ BAZ: 'baz'
+ }
+ },
+ true
+ );
+
+ assert.deepEqual(
+ mw.jqueryMsg.getParserDefaults().magic,
+ {
+ FOO: 'foo',
+ BAR: 'bar',
+ BAZ: 'baz'
+ },
+ 'setParserDefaults is deep if requested'
+ );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js
new file mode 100644
index 00000000..b0b2e7a8
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js
@@ -0,0 +1,69 @@
+/**
+ * Some misc JavaScript compatibility tests,
+ * just to make sure the environments we run in are consistent.
+ */
+( function ( $ ) {
+ QUnit.module( 'mediawiki.jscompat', QUnit.newMwEnvironment() );
+
+ QUnit.test( 'Variable with Unicode letter in name', function ( assert ) {
+ var orig, ŝablono;
+
+ orig = 'some token';
+ ŝablono = orig;
+
+ assert.deepEqual( ŝablono, orig, 'ŝablono' );
+ assert.deepEqual( \u015dablono, orig, '\\u015dablono' );
+ assert.deepEqual( \u015Dablono, orig, '\\u015Dablono' );
+ } );
+
+ /*
+ // Not that we need this. ;)
+ // This fails on IE 6-8
+ // Works on IE 9, Firefox 6, Chrome 14
+ QUnit.test( 'Keyword workaround: "if" as variable name using Unicode escapes', function ( assert ) {
+ var orig = "another token";
+ \u0069\u0066 = orig;
+ assert.deepEqual( \u0069\u0066, orig, '\\u0069\\u0066' );
+ });
+ */
+
+ /*
+ // Not that we need this. ;)
+ // This fails on IE 6-9
+ // Works on Firefox 6, Chrome 14
+ QUnit.test( 'Keyword workaround: "if" as member variable name using Unicode escapes', function ( assert ) {
+ var orig = "another token";
+ var foo = {};
+ foo.\u0069\u0066 = orig;
+ assert.deepEqual( foo.\u0069\u0066, orig, 'foo.\\u0069\\u0066' );
+ });
+ */
+
+ QUnit.test( 'Stripping of single initial newline from textarea\'s literal contents (T14130)', function ( assert ) {
+ var maxn, n,
+ expected, $textarea;
+
+ maxn = 4;
+
+ function repeat( str, n ) {
+ var out;
+ if ( n <= 0 ) {
+ return '';
+ } else {
+ out = [];
+ out.length = n + 1;
+ return out.join( str );
+ }
+ }
+
+ for ( n = 0; n < maxn; n++ ) {
+ expected = repeat( '\n', n ) + 'some text';
+
+ $textarea = $( '<textarea>\n' + expected + '</textarea>' );
+ assert.equal( $textarea.val(), expected, 'Expecting ' + n + ' newlines (HTML contained ' + ( n + 1 ) + ')' );
+
+ $textarea = $( '<textarea>' ).val( expected );
+ assert.equal( $textarea.val(), expected, 'Expecting ' + n + ' newlines (from DOM set with ' + n + ')' );
+ }
+ } );
+}( jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js
new file mode 100644
index 00000000..e4db771c
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js
@@ -0,0 +1,708 @@
+( function ( mw, $ ) {
+ 'use strict';
+
+ var grammarTests, bcp47Tests;
+
+ QUnit.module( 'mediawiki.language', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.liveLangData = mw.language.data;
+ mw.language.data = {};
+ },
+ teardown: function () {
+ mw.language.data = this.liveLangData;
+ },
+ messages: {
+ // mw.language.listToText test
+ and: ' and',
+ 'comma-separator': ', ',
+ 'word-separator': ' '
+ }
+ } ) );
+
+ QUnit.test( 'mw.language getData and setData', function ( assert ) {
+ mw.language.setData( 'en', 'testkey', 'testvalue' );
+ assert.equal( mw.language.getData( 'en', 'testkey' ), 'testvalue', 'Getter setter test for mw.language' );
+ assert.equal( mw.language.getData( 'en', 'invalidkey' ), undefined, 'Getter setter test for mw.language with invalid key' );
+ mw.language.setData( 'en-us', 'testkey', 'testvalue' );
+ assert.equal( mw.language.getData( 'en-US', 'testkey' ), 'testvalue', 'Case insensitive test for mw.language' );
+ } );
+
+ QUnit.test( 'mw.language.commafy test', function ( assert ) {
+ mw.language.setData( 'en', 'digitGroupingPattern', null );
+ mw.language.setData( 'en', 'digitTransformTable', null );
+ mw.language.setData( 'en', 'separatorTransformTable', null );
+
+ mw.config.set( 'wgUserLanguage', 'en' );
+ // Number grouping patterns are as per http://cldr.unicode.org/translation/number-patterns
+ assert.equal( mw.language.commafy( 1234.567, '###0.#####' ), '1234.567', 'Pattern with no digit grouping separator defined' );
+ assert.equal( mw.language.commafy( 123456789.567, '###0.#####' ), '123456789.567', 'Pattern with no digit grouping separator defined, bigger decimal part' );
+ assert.equal( mw.language.commafy( 0.567, '###0.#####' ), '0.567', 'Decimal part 0' );
+ assert.equal( mw.language.commafy( '.567', '###0.#####' ), '0.567', 'Decimal part missing. replace with zero' );
+ assert.equal( mw.language.commafy( 1234, '##,#0.#####' ), '12,34', 'Pattern with no fractional part' );
+ assert.equal( mw.language.commafy( -1234.567, '###0.#####' ), '-1234.567', 'Negative number' );
+ assert.equal( mw.language.commafy( -1234.567, '#,###.00' ), '-1,234.56', 'Fractional part bigger than pattern.' );
+ assert.equal( mw.language.commafy( 123456789.567, '###,##0.00' ), '123,456,789.56', 'Decimal part as group of 3' );
+ assert.equal( mw.language.commafy( 123456789.567, '###,###,#0.00' ), '1,234,567,89.56', 'Decimal part as group of 3 and last one 2' );
+ } );
+
+ QUnit.test( 'mw.language.convertNumber', function ( assert ) {
+ mw.language.setData( 'en', 'digitGroupingPattern', null );
+ mw.language.setData( 'en', 'digitTransformTable', null );
+ mw.language.setData( 'en', 'separatorTransformTable', { ',': '.', '.': ',' } );
+ mw.language.setData( 'en', 'minimumGroupingDigits', null );
+ mw.config.set( 'wgUserLanguage', 'en' );
+ mw.config.set( 'wgTranslateNumerals', true );
+
+ assert.equal( mw.language.convertNumber( 180 ), '180', 'formatting 3-digit' );
+ assert.equal( mw.language.convertNumber( 1800 ), '1.800', 'formatting 4-digit' );
+ assert.equal( mw.language.convertNumber( 18000 ), '18.000', 'formatting 5-digit' );
+
+ assert.equal( mw.language.convertNumber( '1.800', true ), '1800', 'unformatting' );
+
+ mw.language.setData( 'en', 'minimumGroupingDigits', 2 );
+ assert.equal( mw.language.convertNumber( 180 ), '180', 'formatting 3-digit with minimumGroupingDigits=2' );
+ assert.equal( mw.language.convertNumber( 1800 ), '1800', 'formatting 4-digit with minimumGroupingDigits=2' );
+ assert.equal( mw.language.convertNumber( 18000 ), '18.000', 'formatting 5-digit with minimumGroupingDigits=2' );
+ } );
+
+ QUnit.test( 'mw.language.convertNumber - digitTransformTable', function ( assert ) {
+ mw.config.set( 'wgUserLanguage', 'hi' );
+ mw.config.set( 'wgTranslateNumerals', true );
+ mw.language.setData( 'hi', 'digitGroupingPattern', null );
+ mw.language.setData( 'hi', 'separatorTransformTable', { ',': '.', '.': ',' } );
+ mw.language.setData( 'hi', 'minimumGroupingDigits', null );
+
+ // Example from Hindi (MessagesHi.php)
+ mw.language.setData( 'hi', 'digitTransformTable', {
+ 0: '०',
+ 1: '१',
+ 2: '२'
+ } );
+
+ assert.equal( mw.language.convertNumber( 1200 ), '१.२००', 'format' );
+ assert.equal( mw.language.convertNumber( '१.२००', true ), '1200', 'unformat from digit transform' );
+ assert.equal( mw.language.convertNumber( '1.200', true ), '1200', 'unformat plain' );
+
+ mw.config.set( 'wgTranslateNumerals', false );
+
+ assert.equal( mw.language.convertNumber( 1200 ), '1.200', 'format (digit transform disabled)' );
+ assert.equal( mw.language.convertNumber( '१.२००', true ), '1200', 'unformat from digit transform (when disabled)' );
+ assert.equal( mw.language.convertNumber( '1.200', true ), '1200', 'unformat plain (digit transform disabled)' );
+ } );
+
+ function grammarTest( langCode, test ) {
+ // The test works only if the content language is opt.language
+ // because it requires [lang].js to be loaded.
+ QUnit.test( 'Grammar test for lang=' + langCode, function ( assert ) {
+ var i;
+ for ( i = 0; i < test.length; i++ ) {
+ assert.equal(
+ mw.language.convertGrammar( test[ i ].word, test[ i ].grammarForm ),
+ test[ i ].expected,
+ test[ i ].description
+ );
+ }
+ } );
+ }
+
+ // These tests run only for the current UI language.
+ grammarTests = {
+ bs: [
+ {
+ word: 'word',
+ grammarForm: 'instrumental',
+ expected: 's word',
+ description: 'Grammar test for instrumental case'
+ },
+ {
+ word: 'word',
+ grammarForm: 'lokativ',
+ expected: 'o word',
+ description: 'Grammar test for lokativ case'
+ }
+ ],
+
+ he: [
+ {
+ word: 'ויקיפדיה',
+ grammarForm: 'prefixed',
+ expected: 'וויקיפדיה',
+ description: 'Duplicate the "Waw" if prefixed'
+ },
+ {
+ word: 'וולפגנג',
+ grammarForm: 'prefixed',
+ expected: 'וולפגנג',
+ description: 'Duplicate the "Waw" if prefixed, but not if it is already duplicated.'
+ },
+ {
+ word: 'הקובץ',
+ grammarForm: 'prefixed',
+ expected: 'קובץ',
+ description: 'Remove the "He" if prefixed'
+ },
+ {
+ word: 'Wikipedia',
+ grammarForm: 'תחילית',
+ expected: '־Wikipedia',
+ description: 'Add a hyphen (maqaf) before non-Hebrew letters'
+ },
+ {
+ word: '1995',
+ grammarForm: 'תחילית',
+ expected: '־1995',
+ description: 'Add a hyphen (maqaf) before numbers'
+ }
+ ],
+
+ hsb: [
+ {
+ word: 'word',
+ grammarForm: 'instrumental',
+ expected: 'z word',
+ description: 'Grammar test for instrumental case'
+ },
+ {
+ word: 'word',
+ grammarForm: 'lokatiw',
+ expected: 'wo word',
+ description: 'Grammar test for lokatiw case'
+ }
+ ],
+
+ dsb: [
+ {
+ word: 'word',
+ grammarForm: 'instrumental',
+ expected: 'z word',
+ description: 'Grammar test for instrumental case'
+ },
+ {
+ word: 'word',
+ grammarForm: 'lokatiw',
+ expected: 'wo word',
+ description: 'Grammar test for lokatiw case'
+ }
+ ],
+
+ hy: [
+ {
+ word: 'Մաունա',
+ grammarForm: 'genitive',
+ expected: 'Մաունայի',
+ description: 'Grammar test for genitive case'
+ },
+ {
+ word: 'հետո',
+ grammarForm: 'genitive',
+ expected: 'հետոյի',
+ description: 'Grammar test for genitive case'
+ },
+ {
+ word: 'գիրք',
+ grammarForm: 'genitive',
+ expected: 'գրքի',
+ description: 'Grammar test for genitive case'
+ },
+ {
+ word: 'ժամանակի',
+ grammarForm: 'genitive',
+ expected: 'ժամանակիի',
+ description: 'Grammar test for genitive case'
+ }
+ ],
+
+ fi: [
+ {
+ word: 'talo',
+ grammarForm: 'genitive',
+ expected: 'talon',
+ description: 'Grammar test for genitive case'
+ },
+ {
+ word: 'linux',
+ grammarForm: 'genitive',
+ expected: 'linuxin',
+ description: 'Grammar test for genitive case'
+ },
+ {
+ word: 'talo',
+ grammarForm: 'elative',
+ expected: 'talosta',
+ description: 'Grammar test for elative case'
+ },
+ {
+ word: 'pastöroitu',
+ grammarForm: 'partitive',
+ expected: 'pastöroitua',
+ description: 'Grammar test for partitive case'
+ },
+ {
+ word: 'talo',
+ grammarForm: 'partitive',
+ expected: 'taloa',
+ description: 'Grammar test for partitive case'
+ },
+ {
+ word: 'talo',
+ grammarForm: 'illative',
+ expected: 'taloon',
+ description: 'Grammar test for illative case'
+ },
+ {
+ word: 'linux',
+ grammarForm: 'inessive',
+ expected: 'linuxissa',
+ description: 'Grammar test for inessive case'
+ }
+ ],
+
+ ru: [
+ {
+ word: 'тесть',
+ grammarForm: 'genitive',
+ expected: 'тестя',
+ description: 'Grammar test for genitive case, тесть -> тестя'
+ },
+ {
+ word: 'привилегия',
+ grammarForm: 'genitive',
+ expected: 'привилегии',
+ description: 'Grammar test for genitive case, привилегия -> привилегии'
+ },
+ {
+ word: 'установка',
+ grammarForm: 'genitive',
+ expected: 'установки',
+ description: 'Grammar test for genitive case, установка -> установки'
+ },
+ {
+ word: 'похоти',
+ grammarForm: 'genitive',
+ expected: 'похотей',
+ description: 'Grammar test for genitive case, похоти -> похотей'
+ },
+ {
+ word: 'доводы',
+ grammarForm: 'genitive',
+ expected: 'доводов',
+ description: 'Grammar test for genitive case, доводы -> доводов'
+ },
+ {
+ word: 'песчаник',
+ grammarForm: 'genitive',
+ expected: 'песчаника',
+ description: 'Grammar test for genitive case, песчаник -> песчаника'
+ },
+ {
+ word: 'данные',
+ grammarForm: 'genitive',
+ expected: 'данных',
+ description: 'Grammar test for genitive case, данные -> данных'
+ },
+ {
+ word: 'тесть',
+ grammarForm: 'prepositional',
+ expected: 'тесте',
+ description: 'Grammar test for prepositional case, тесть -> тесте'
+ },
+ {
+ word: 'привилегия',
+ grammarForm: 'prepositional',
+ expected: 'привилегии',
+ description: 'Grammar test for prepositional case, привилегия -> привилегии'
+ },
+ {
+ word: 'университет',
+ grammarForm: 'prepositional',
+ expected: 'университете',
+ description: 'Grammar test for prepositional case, университет -> университете'
+ },
+ {
+ word: 'университет',
+ grammarForm: 'genitive',
+ expected: 'университета',
+ description: 'Grammar test for prepositional case, университет -> университете'
+ },
+ {
+ word: 'установка',
+ grammarForm: 'prepositional',
+ expected: 'установке',
+ description: 'Grammar test for prepositional case, установка -> установке'
+ },
+ {
+ word: 'похоти',
+ grammarForm: 'prepositional',
+ expected: 'похотях',
+ description: 'Grammar test for prepositional case, похоти -> похотях'
+ },
+ {
+ word: 'доводы',
+ grammarForm: 'prepositional',
+ expected: 'доводах',
+ description: 'Grammar test for prepositional case, доводы -> доводах'
+ },
+ {
+ word: 'Викисклад',
+ grammarForm: 'prepositional',
+ expected: 'Викискладе',
+ description: 'Grammar test for prepositional case, Викисклад -> Викискладе'
+ },
+ {
+ word: 'Викисклад',
+ grammarForm: 'genitive',
+ expected: 'Викисклада',
+ description: 'Grammar test for genitive case, Викисклад -> Викисклада'
+ },
+ {
+ word: 'песчаник',
+ grammarForm: 'prepositional',
+ expected: 'песчанике',
+ description: 'Grammar test for prepositional case, песчаник -> песчанике'
+ },
+ {
+ word: 'данные',
+ grammarForm: 'prepositional',
+ expected: 'данных',
+ description: 'Grammar test for prepositional case, данные -> данных'
+ },
+ {
+ word: 'русский',
+ grammarForm: 'languagegen',
+ expected: 'русского',
+ description: 'Grammar test for languagegen case, русский -> русского'
+ },
+ {
+ word: 'немецкий',
+ grammarForm: 'languagegen',
+ expected: 'немецкого',
+ description: 'Grammar test for languagegen case, немецкий -> немецкого'
+ },
+ {
+ word: 'иврит',
+ grammarForm: 'languagegen',
+ expected: 'иврита',
+ description: 'Grammar test for languagegen case, иврит -> иврита'
+ },
+ {
+ word: 'эсперанто',
+ grammarForm: 'languagegen',
+ expected: 'эсперанто',
+ description: 'Grammar test for languagegen case, эсперанто -> эсперанто'
+ },
+ {
+ word: 'русский',
+ grammarForm: 'languageprep',
+ expected: 'русском',
+ description: 'Grammar test for languageprep case, русский -> русском'
+ },
+ {
+ word: 'немецкий',
+ grammarForm: 'languageprep',
+ expected: 'немецком',
+ description: 'Grammar test for languageprep case, немецкий -> немецком'
+ },
+ {
+ word: 'идиш',
+ grammarForm: 'languageprep',
+ expected: 'идише',
+ description: 'Grammar test for languageprep case, идиш -> идише'
+ },
+ {
+ word: 'эсперанто',
+ grammarForm: 'languageprep',
+ expected: 'эсперанто',
+ description: 'Grammar test for languageprep case, эсперанто -> эсперанто'
+ },
+ {
+ word: 'русский',
+ grammarForm: 'languageadverb',
+ expected: 'по-русски',
+ description: 'Grammar test for languageadverb case, русский -> по-русски'
+ },
+ {
+ word: 'немецкий',
+ grammarForm: 'languageadverb',
+ expected: 'по-немецки',
+ description: 'Grammar test for languageadverb case, немецкий -> по-немецки'
+ },
+ {
+ word: 'иврит',
+ grammarForm: 'languageadverb',
+ expected: 'на иврите',
+ description: 'Grammar test for languageadverb case, иврит -> на иврите'
+ },
+ {
+ word: 'эсперанто',
+ grammarForm: 'languageadverb',
+ expected: 'на эсперанто',
+ description: 'Grammar test for languageadverb case, эсперанто -> на эсперанто'
+ },
+ {
+ word: 'гуарани',
+ grammarForm: 'languageadverb',
+ expected: 'на языке гуарани',
+ description: 'Grammar test for languageadverb case, гуарани -> на языке гуарани'
+ }
+ ],
+
+ hu: [
+ {
+ word: 'Wikipédiá',
+ grammarForm: 'rol',
+ expected: 'Wikipédiáról',
+ description: 'Grammar test for rol case'
+ },
+ {
+ word: 'Wikipédiá',
+ grammarForm: 'ba',
+ expected: 'Wikipédiába',
+ description: 'Grammar test for ba case'
+ },
+ {
+ word: 'Wikipédiá',
+ grammarForm: 'k',
+ expected: 'Wikipédiák',
+ description: 'Grammar test for k case'
+ }
+ ],
+
+ ga: [
+ {
+ word: 'an Domhnach',
+ grammarForm: 'ainmlae',
+ expected: 'Dé Domhnaigh',
+ description: 'Grammar test for ainmlae case'
+ },
+ {
+ word: 'an Luan',
+ grammarForm: 'ainmlae',
+ expected: 'Dé Luain',
+ description: 'Grammar test for ainmlae case'
+ },
+ {
+ word: 'an Satharn',
+ grammarForm: 'ainmlae',
+ expected: 'Dé Sathairn',
+ description: 'Grammar test for ainmlae case'
+ }
+ ],
+
+ uk: [
+ {
+ word: 'Вікіпедія',
+ grammarForm: 'genitive',
+ expected: 'Вікіпедії',
+ description: 'Grammar test for genitive case'
+ },
+ {
+ word: 'Віківиди',
+ grammarForm: 'genitive',
+ expected: 'Віківидів',
+ description: 'Grammar test for genitive case'
+ },
+ {
+ word: 'Вікіцитати',
+ grammarForm: 'genitive',
+ expected: 'Вікіцитат',
+ description: 'Grammar test for genitive case'
+ },
+ {
+ word: 'Вікіпідручник',
+ grammarForm: 'genitive',
+ expected: 'Вікіпідручника',
+ description: 'Grammar test for genitive case'
+ },
+ {
+ word: 'Вікіпедія',
+ grammarForm: 'accusative',
+ expected: 'Вікіпедію',
+ description: 'Grammar test for accusative case'
+ }
+ ],
+
+ sl: [
+ {
+ word: 'word',
+ grammarForm: 'orodnik',
+ expected: 'z word',
+ description: 'Grammar test for orodnik case'
+ },
+ {
+ word: 'word',
+ grammarForm: 'mestnik',
+ expected: 'o word',
+ description: 'Grammar test for mestnik case'
+ }
+ ],
+
+ os: [
+ {
+ word: 'бæстæ',
+ grammarForm: 'genitive',
+ expected: 'бæсты',
+ description: 'Grammar test for genitive case'
+ },
+ {
+ word: 'бæстæ',
+ grammarForm: 'allative',
+ expected: 'бæстæм',
+ description: 'Grammar test for allative case'
+ },
+ {
+ word: 'Тигр',
+ grammarForm: 'dative',
+ expected: 'Тигрæн',
+ description: 'Grammar test for dative case'
+ },
+ {
+ word: 'цъити',
+ grammarForm: 'dative',
+ expected: 'цъитийæн',
+ description: 'Grammar test for dative case'
+ },
+ {
+ word: 'лæппу',
+ grammarForm: 'genitive',
+ expected: 'лæппуйы',
+ description: 'Grammar test for genitive case'
+ },
+ {
+ word: '2011',
+ grammarForm: 'equative',
+ expected: '2011-ау',
+ description: 'Grammar test for equative case'
+ }
+ ],
+
+ la: [
+ {
+ word: 'Translatio',
+ grammarForm: 'genitive',
+ expected: 'Translationis',
+ description: 'Grammar test for genitive case'
+ },
+ {
+ word: 'Translatio',
+ grammarForm: 'accusative',
+ expected: 'Translationem',
+ description: 'Grammar test for accusative case'
+ },
+ {
+ word: 'Translatio',
+ grammarForm: 'ablative',
+ expected: 'Translatione',
+ description: 'Grammar test for ablative case'
+ }
+ ]
+ };
+
+ $.each( grammarTests, function ( langCode, test ) {
+ if ( langCode === mw.config.get( 'wgUserLanguage' ) ) {
+ grammarTest( langCode, test );
+ }
+ } );
+
+ QUnit.test( 'List to text test', function ( assert ) {
+ assert.equal( mw.language.listToText( [] ), '', 'Blank list' );
+ assert.equal( mw.language.listToText( [ 'a' ] ), 'a', 'Single item' );
+ assert.equal( mw.language.listToText( [ 'a', 'b' ] ), 'a and b', 'Two items' );
+ assert.equal( mw.language.listToText( [ 'a', 'b', 'c' ] ), 'a, b and c', 'More than two items' );
+ } );
+
+ bcp47Tests = [
+ // Extracted from BCP 47 (list not exhaustive)
+ // # 2.1.1
+ [ 'en-ca-x-ca', 'en-CA-x-ca' ],
+ [ 'sgn-be-fr', 'sgn-BE-FR' ],
+ [ 'az-latn-x-latn', 'az-Latn-x-latn' ],
+ // # 2.2
+ [ 'sr-Latn-RS', 'sr-Latn-RS' ],
+ [ 'az-arab-ir', 'az-Arab-IR' ],
+
+ // # 2.2.5
+ [ 'sl-nedis', 'sl-nedis' ],
+ [ 'de-ch-1996', 'de-CH-1996' ],
+
+ // # 2.2.6
+ [
+ 'en-latn-gb-boont-r-extended-sequence-x-private',
+ 'en-Latn-GB-boont-r-extended-sequence-x-private'
+ ],
+
+ // Examples from BCP 47 Appendix A
+ // # Simple language subtag:
+ [ 'DE', 'de' ],
+ [ 'fR', 'fr' ],
+ [ 'ja', 'ja' ],
+
+ // # Language subtag plus script subtag:
+ [ 'zh-hans', 'zh-Hans' ],
+ [ 'sr-cyrl', 'sr-Cyrl' ],
+ [ 'sr-latn', 'sr-Latn' ],
+
+ // # Extended language subtags and their primary language subtag
+ // # counterparts:
+ [ 'zh-cmn-hans-cn', 'zh-cmn-Hans-CN' ],
+ [ 'cmn-hans-cn', 'cmn-Hans-CN' ],
+ [ 'zh-yue-hk', 'zh-yue-HK' ],
+ [ 'yue-hk', 'yue-HK' ],
+
+ // # Language-Script-Region:
+ [ 'zh-hans-cn', 'zh-Hans-CN' ],
+ [ 'sr-latn-RS', 'sr-Latn-RS' ],
+
+ // # Language-Variant:
+ [ 'sl-rozaj', 'sl-rozaj' ],
+ [ 'sl-rozaj-biske', 'sl-rozaj-biske' ],
+ [ 'sl-nedis', 'sl-nedis' ],
+
+ // # Language-Region-Variant:
+ [ 'de-ch-1901', 'de-CH-1901' ],
+ [ 'sl-it-nedis', 'sl-IT-nedis' ],
+
+ // # Language-Script-Region-Variant:
+ [ 'hy-latn-it-arevela', 'hy-Latn-IT-arevela' ],
+
+ // # Language-Region:
+ [ 'de-de', 'de-DE' ],
+ [ 'en-us', 'en-US' ],
+ [ 'es-419', 'es-419' ],
+
+ // # Private use subtags:
+ [ 'de-ch-x-phonebk', 'de-CH-x-phonebk' ],
+ [ 'az-arab-x-aze-derbend', 'az-Arab-x-aze-derbend' ],
+ /**
+ * Previous test does not reflect the BCP 47 which states:
+ * az-Arab-x-AZE-derbend
+ * AZE being private, it should be lower case, hence the test above
+ * should probably be:
+ * [ 'az-arab-x-aze-derbend', 'az-Arab-x-AZE-derbend' ],
+ */
+
+ // # Private use registry values:
+ [ 'x-whatever', 'x-whatever' ],
+ [ 'qaa-qaaa-qm-x-southern', 'qaa-Qaaa-QM-x-southern' ],
+ [ 'de-qaaa', 'de-Qaaa' ],
+ [ 'sr-latn-qm', 'sr-Latn-QM' ],
+ [ 'sr-qaaa-rs', 'sr-Qaaa-RS' ],
+
+ // # Tags that use extensions
+ [ 'en-us-u-islamcal', 'en-US-u-islamcal' ],
+ [ 'zh-cn-a-myext-x-private', 'zh-CN-a-myext-x-private' ],
+ [ 'en-a-myext-b-another', 'en-a-myext-b-another' ]
+
+ // # Invalid:
+ // de-419-DE
+ // a-DE
+ // ar-a-aaa-b-bbb-a-ccc
+ ];
+
+ QUnit.test( 'mw.language.bcp47', function ( assert ) {
+ bcp47Tests.forEach( function ( data ) {
+ var input = data[ 0 ],
+ expected = data[ 1 ];
+ assert.equal( mw.language.bcp47( input ), expected );
+ } );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js
new file mode 100644
index 00000000..42bc0a76
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js
@@ -0,0 +1,980 @@
+( function ( mw, $ ) {
+ QUnit.module( 'mediawiki.loader', QUnit.newMwEnvironment( {
+ setup: function ( assert ) {
+ mw.loader.store.enabled = false;
+
+ // Expose for load.mock.php
+ mw.loader.testFail = function ( reason ) {
+ assert.ok( false, reason );
+ };
+ },
+ teardown: function () {
+ mw.loader.store.enabled = false;
+ // Teardown for StringSet shim test
+ if ( this.nativeSet ) {
+ window.Set = this.nativeSet;
+ mw.redefineFallbacksForTest();
+ }
+ // Remove any remaining temporary statics
+ // exposed for cross-file mocks.
+ delete mw.loader.testCallback;
+ delete mw.loader.testFail;
+ }
+ } ) );
+
+ mw.loader.addSource(
+ 'testloader',
+ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/load.mock.php' )
+ );
+
+ /**
+ * The sync style load test (for @import). This is, in a way, also an open bug for
+ * ResourceLoader ("execute js after styles are loaded"), but browsers don't offer a
+ * way to get a callback from when a stylesheet is loaded (that is, including any
+ * `@import` rules inside). To work around this, we'll have a little time loop to check
+ * if the styles apply.
+ *
+ * Note: This test originally used new Image() and onerror to get a callback
+ * when the url is loaded, but that is fragile since it doesn't monitor the
+ * same request as the css @import, and Safari 4 has issues with
+ * onerror/onload not being fired at all in weird cases like this.
+ */
+ function assertStyleAsync( assert, $element, prop, val, fn ) {
+ var styleTestStart,
+ el = $element.get( 0 ),
+ styleTestTimeout = ( QUnit.config.testTimeout || 5000 ) - 200;
+
+ function isCssImportApplied() {
+ // Trigger reflow, repaint, redraw, whatever (cross-browser)
+ $element.css( 'height' );
+ // eslint-disable-next-line no-unused-expressions
+ el.innerHTML;
+ el.className = el.className;
+ // eslint-disable-next-line no-unused-expressions
+ document.documentElement.clientHeight;
+
+ return $element.css( prop ) === val;
+ }
+
+ function styleTestLoop() {
+ var styleTestSince = new Date().getTime() - styleTestStart;
+ // If it is passing or if we timed out, run the real test and stop the loop
+ if ( isCssImportApplied() || styleTestSince > styleTestTimeout ) {
+ assert.equal( $element.css( prop ), val,
+ 'style "' + prop + ': ' + val + '" from url is applied (after ' + styleTestSince + 'ms)'
+ );
+
+ if ( fn ) {
+ fn();
+ }
+
+ return;
+ }
+ // Otherwise, keep polling
+ setTimeout( styleTestLoop );
+ }
+
+ // Start the loop
+ styleTestStart = new Date().getTime();
+ styleTestLoop();
+ }
+
+ function urlStyleTest( selector, prop, val ) {
+ return QUnit.fixurl(
+ mw.config.get( 'wgScriptPath' ) +
+ '/tests/qunit/data/styleTest.css.php?' +
+ $.param( {
+ selector: selector,
+ prop: prop,
+ val: val
+ } )
+ );
+ }
+
+ QUnit.test( '.using( .., Function callback ) Promise', function ( assert ) {
+ var script = 0, callback = 0;
+ mw.loader.testCallback = function () {
+ script++;
+ };
+ mw.loader.implement( 'test.promise', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/mwLoaderTestCallback.js' ) ] );
+
+ return mw.loader.using( 'test.promise', function () {
+ callback++;
+ } ).then( function () {
+ assert.strictEqual( script, 1, 'module script ran' );
+ assert.strictEqual( callback, 1, 'using() callback ran' );
+ } );
+ } );
+
+ QUnit.test( 'Prototype method as module name', function ( assert ) {
+ var call = 0;
+ mw.loader.testCallback = function () {
+ call++;
+ };
+ mw.loader.implement( 'hasOwnProperty', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/mwLoaderTestCallback.js' ) ], {}, {} );
+
+ return mw.loader.using( 'hasOwnProperty', function () {
+ assert.strictEqual( call, 1, 'module script ran' );
+ } );
+ } );
+
+ // Covers mw.loader#sortDependencies (with native Set if available)
+ QUnit.test( '.using() - Error: Circular dependency [StringSet default]', function ( assert ) {
+ var done = assert.async();
+
+ mw.loader.register( [
+ [ 'test.circle1', '0', [ 'test.circle2' ] ],
+ [ 'test.circle2', '0', [ 'test.circle3' ] ],
+ [ 'test.circle3', '0', [ 'test.circle1' ] ]
+ ] );
+ mw.loader.using( 'test.circle3' ).then(
+ function done() {
+ assert.ok( false, 'Unexpected resolution, expected error.' );
+ },
+ function fail( e ) {
+ assert.ok( /Circular/.test( String( e ) ), 'Detect circular dependency' );
+ }
+ )
+ .always( done );
+ } );
+
+ // @covers mw.loader#sortDependencies (with fallback shim)
+ QUnit.test( '.using() - Error: Circular dependency [StringSet shim]', function ( assert ) {
+ var done = assert.async();
+
+ if ( !window.Set ) {
+ assert.expect( 0 );
+ done();
+ return;
+ }
+
+ this.nativeSet = window.Set;
+ window.Set = undefined;
+ mw.redefineFallbacksForTest();
+
+ mw.loader.register( [
+ [ 'test.shim.circle1', '0', [ 'test.shim.circle2' ] ],
+ [ 'test.shim.circle2', '0', [ 'test.shim.circle3' ] ],
+ [ 'test.shim.circle3', '0', [ 'test.shim.circle1' ] ]
+ ] );
+ mw.loader.using( 'test.shim.circle3' ).then(
+ function done() {
+ assert.ok( false, 'Unexpected resolution, expected error.' );
+ },
+ function fail( e ) {
+ assert.ok( /Circular/.test( String( e ) ), 'Detect circular dependency' );
+ }
+ )
+ .always( done );
+ } );
+
+ QUnit.test( '.load() - Error: Circular dependency', function ( assert ) {
+ var capture = [];
+ mw.loader.register( [
+ [ 'test.circleA', '0', [ 'test.circleB' ] ],
+ [ 'test.circleB', '0', [ 'test.circleC' ] ],
+ [ 'test.circleC', '0', [ 'test.circleA' ] ]
+ ] );
+ this.sandbox.stub( mw, 'track', function ( topic, data ) {
+ capture.push( {
+ topic: topic,
+ error: data.exception && data.exception.message,
+ source: data.source
+ } );
+ } );
+
+ mw.loader.load( 'test.circleC' );
+ assert.deepEqual(
+ [ {
+ topic: 'resourceloader.exception',
+ error: 'Circular reference detected: test.circleB -> test.circleC',
+ source: 'resolve'
+ } ],
+ capture,
+ 'Detect circular dependency'
+ );
+ } );
+
+ QUnit.test( '.using() - Error: Unregistered', function ( assert ) {
+ var done = assert.async();
+
+ mw.loader.using( 'test.using.unreg' ).then(
+ function done() {
+ assert.ok( false, 'Unexpected resolution, expected error.' );
+ },
+ function fail( e ) {
+ assert.ok( /Unknown/.test( String( e ) ), 'Detect unknown dependency' );
+ }
+ ).always( done );
+ } );
+
+ QUnit.test( '.load() - Error: Unregistered', function ( assert ) {
+ var capture = [];
+ this.sandbox.stub( mw, 'track', function ( topic, data ) {
+ capture.push( {
+ topic: topic,
+ error: data.exception && data.exception.message,
+ source: data.source
+ } );
+ } );
+
+ mw.loader.load( 'test.load.unreg' );
+ assert.deepEqual(
+ [ {
+ topic: 'resourceloader.exception',
+ error: 'Unknown dependency: test.load.unreg',
+ source: 'resolve'
+ } ],
+ capture
+ );
+ } );
+
+ // Regression test for T36853
+ QUnit.test( '.load() - Error: Missing dependency', function ( assert ) {
+ var capture = [];
+ this.sandbox.stub( mw, 'track', function ( topic, data ) {
+ capture.push( {
+ topic: topic,
+ error: data.exception && data.exception.message,
+ source: data.source
+ } );
+ } );
+
+ mw.loader.register( [
+ [ 'test.load.missingdep1', '0', [ 'test.load.missingdep2' ] ],
+ [ 'test.load.missingdep', '0', [ 'test.load.missingdep1' ] ]
+ ] );
+ mw.loader.load( 'test.load.missingdep' );
+ assert.deepEqual(
+ [ {
+ topic: 'resourceloader.exception',
+ error: 'Unknown dependency: test.load.missingdep2',
+ source: 'resolve'
+ } ],
+ capture
+ );
+ } );
+
+ QUnit.test( '.implement( styles={ "css": [text, ..] } )', function ( assert ) {
+ var $element = $( '<div class="mw-test-implement-a"></div>' ).appendTo( '#qunit-fixture' );
+
+ assert.notEqual(
+ $element.css( 'float' ),
+ 'right',
+ 'style is clear'
+ );
+
+ mw.loader.implement(
+ 'test.implement.a',
+ function () {
+ assert.equal(
+ $element.css( 'float' ),
+ 'right',
+ 'style is applied'
+ );
+ },
+ {
+ all: '.mw-test-implement-a { float: right; }'
+ }
+ );
+
+ return mw.loader.using( 'test.implement.a' );
+ } );
+
+ QUnit.test( '.implement( styles={ "url": { <media>: [url, ..] } } )', function ( assert ) {
+ var $element1 = $( '<div class="mw-test-implement-b1"></div>' ).appendTo( '#qunit-fixture' ),
+ $element2 = $( '<div class="mw-test-implement-b2"></div>' ).appendTo( '#qunit-fixture' ),
+ $element3 = $( '<div class="mw-test-implement-b3"></div>' ).appendTo( '#qunit-fixture' ),
+ done = assert.async();
+
+ assert.notEqual(
+ $element1.css( 'text-align' ),
+ 'center',
+ 'style is clear'
+ );
+ assert.notEqual(
+ $element2.css( 'float' ),
+ 'left',
+ 'style is clear'
+ );
+ assert.notEqual(
+ $element3.css( 'text-align' ),
+ 'right',
+ 'style is clear'
+ );
+
+ mw.loader.implement(
+ 'test.implement.b',
+ function () {
+ // Note: done() must only be called when the entire test is
+ // complete. So, make sure that we don't start until *both*
+ // assertStyleAsync calls have completed.
+ var pending = 2;
+ assertStyleAsync( assert, $element2, 'float', 'left', function () {
+ assert.notEqual( $element1.css( 'text-align' ), 'center', 'print style is not applied' );
+
+ pending--;
+ if ( pending === 0 ) {
+ done();
+ }
+ } );
+ assertStyleAsync( assert, $element3, 'float', 'right', function () {
+ assert.notEqual( $element1.css( 'text-align' ), 'center', 'print style is not applied' );
+
+ pending--;
+ if ( pending === 0 ) {
+ done();
+ }
+ } );
+ },
+ {
+ url: {
+ print: [ urlStyleTest( '.mw-test-implement-b1', 'text-align', 'center' ) ],
+ screen: [
+ // T42834: Make sure it actually works with more than 1 stylesheet reference
+ urlStyleTest( '.mw-test-implement-b2', 'float', 'left' ),
+ urlStyleTest( '.mw-test-implement-b3', 'float', 'right' )
+ ]
+ }
+ }
+ );
+
+ mw.loader.load( 'test.implement.b' );
+ } );
+
+ // Backwards compatibility
+ QUnit.test( '.implement( styles={ <media>: text } ) (back-compat)', function ( assert ) {
+ var $element = $( '<div class="mw-test-implement-c"></div>' ).appendTo( '#qunit-fixture' );
+
+ assert.notEqual(
+ $element.css( 'float' ),
+ 'right',
+ 'style is clear'
+ );
+
+ mw.loader.implement(
+ 'test.implement.c',
+ function () {
+ assert.equal(
+ $element.css( 'float' ),
+ 'right',
+ 'style is applied'
+ );
+ },
+ {
+ all: '.mw-test-implement-c { float: right; }'
+ }
+ );
+
+ return mw.loader.using( 'test.implement.c' );
+ } );
+
+ // Backwards compatibility
+ QUnit.test( '.implement( styles={ <media>: [url, ..] } ) (back-compat)', function ( assert ) {
+ var $element = $( '<div class="mw-test-implement-d"></div>' ).appendTo( '#qunit-fixture' ),
+ $element2 = $( '<div class="mw-test-implement-d2"></div>' ).appendTo( '#qunit-fixture' ),
+ done = assert.async();
+
+ assert.notEqual(
+ $element.css( 'float' ),
+ 'right',
+ 'style is clear'
+ );
+ assert.notEqual(
+ $element2.css( 'text-align' ),
+ 'center',
+ 'style is clear'
+ );
+
+ mw.loader.implement(
+ 'test.implement.d',
+ function () {
+ assertStyleAsync( assert, $element, 'float', 'right', function () {
+ assert.notEqual( $element2.css( 'text-align' ), 'center', 'print style is not applied (T42500)' );
+ done();
+ } );
+ },
+ {
+ all: [ urlStyleTest( '.mw-test-implement-d', 'float', 'right' ) ],
+ print: [ urlStyleTest( '.mw-test-implement-d2', 'text-align', 'center' ) ]
+ }
+ );
+
+ mw.loader.load( 'test.implement.d' );
+ } );
+
+ QUnit.test( '.implement( messages before script )', function ( assert ) {
+ mw.loader.implement(
+ 'test.implement.order',
+ function () {
+ assert.equal( mw.loader.getState( 'test.implement.order' ), 'executing', 'state during script execution' );
+ assert.equal( mw.msg( 'test-foobar' ), 'Hello Foobar, $1!', 'messages load before script execution' );
+ },
+ {},
+ {
+ 'test-foobar': 'Hello Foobar, $1!'
+ }
+ );
+
+ return mw.loader.using( 'test.implement.order' ).then( function () {
+ assert.equal( mw.loader.getState( 'test.implement.order' ), 'ready', 'final success state' );
+ } );
+ } );
+
+ // @import (T33676)
+ QUnit.test( '.implement( styles with @import )', function ( assert ) {
+ var $element,
+ done = assert.async();
+
+ mw.loader.implement(
+ 'test.implement.import',
+ function () {
+ $element = $( '<div class="mw-test-implement-import">Foo bar</div>' ).appendTo( '#qunit-fixture' );
+
+ assertStyleAsync( assert, $element, 'float', 'right', function () {
+ assert.equal( $element.css( 'text-align' ), 'center',
+ 'CSS styles after the @import rule are working'
+ );
+
+ done();
+ } );
+ },
+ {
+ css: [
+ '@import url(\''
+ + urlStyleTest( '.mw-test-implement-import', 'float', 'right' )
+ + '\');\n'
+ + '.mw-test-implement-import { text-align: center; }'
+ ]
+ }
+ );
+
+ return mw.loader.using( 'test.implement.import' );
+ } );
+
+ QUnit.test( '.implement( dependency with styles )', function ( assert ) {
+ var $element = $( '<div class="mw-test-implement-e"></div>' ).appendTo( '#qunit-fixture' ),
+ $element2 = $( '<div class="mw-test-implement-e2"></div>' ).appendTo( '#qunit-fixture' );
+
+ assert.notEqual(
+ $element.css( 'float' ),
+ 'right',
+ 'style is clear'
+ );
+ assert.notEqual(
+ $element2.css( 'float' ),
+ 'left',
+ 'style is clear'
+ );
+
+ mw.loader.register( [
+ [ 'test.implement.e', '0', [ 'test.implement.e2' ] ],
+ [ 'test.implement.e2', '0' ]
+ ] );
+
+ mw.loader.implement(
+ 'test.implement.e',
+ function () {
+ assert.equal(
+ $element.css( 'float' ),
+ 'right',
+ 'Depending module\'s style is applied'
+ );
+ },
+ {
+ all: '.mw-test-implement-e { float: right; }'
+ }
+ );
+
+ mw.loader.implement(
+ 'test.implement.e2',
+ function () {
+ assert.equal(
+ $element2.css( 'float' ),
+ 'left',
+ 'Dependency\'s style is applied'
+ );
+ },
+ {
+ all: '.mw-test-implement-e2 { float: left; }'
+ }
+ );
+
+ return mw.loader.using( 'test.implement.e' );
+ } );
+
+ QUnit.test( '.implement( only scripts )', function ( assert ) {
+ mw.loader.implement( 'test.onlyscripts', function () {} );
+ assert.strictEqual( mw.loader.getState( 'test.onlyscripts' ), 'ready' );
+ } );
+
+ QUnit.test( '.implement( only messages )', function ( assert ) {
+ assert.assertFalse( mw.messages.exists( 'T31107' ), 'Verify that the test message doesn\'t exist yet' );
+
+ mw.loader.implement( 'test.implement.msgs', [], {}, { T31107: 'loaded' } );
+
+ return mw.loader.using( 'test.implement.msgs', function () {
+ assert.ok( mw.messages.exists( 'T31107' ), 'T31107: messages-only module should implement ok' );
+ } );
+ } );
+
+ QUnit.test( '.implement( empty )', function ( assert ) {
+ mw.loader.implement( 'test.empty' );
+ assert.strictEqual( mw.loader.getState( 'test.empty' ), 'ready' );
+ } );
+
+ // @covers mw.loader#batchRequest
+ // This is a regression test because in the past we called getCombinedVersion()
+ // for all requested modules, before url splitting took place.
+ // Discovered as part of T188076, but not directly related.
+ QUnit.test( 'Url composition (modules considered for version)', function ( assert ) {
+ mw.loader.register( [
+ // [module, version, dependencies, group, source]
+ [ 'testUrlInc', 'url', [], null, 'testloader' ],
+ [ 'testUrlIncDump', 'dump', [], null, 'testloader' ]
+ ] );
+
+ mw.config.set( 'wgResourceLoaderMaxQueryLength', 10 );
+
+ return mw.loader.using( [ 'testUrlIncDump', 'testUrlInc' ] ).then( function ( require ) {
+ assert.propEqual(
+ require( 'testUrlIncDump' ).query,
+ {
+ modules: 'testUrlIncDump',
+ // Expected: Wrapped hash just for this one module
+ // $hash = hash( 'fnv132', 'dump');
+ // base_convert( $hash, 16, 36 ); // "13e9zzn"
+ // Previously: Wrapped hash for both modules, despite being in separate requests
+ // $hash = hash( 'fnv132', 'urldump' );
+ // base_convert( $hash, 16, 36 ); // "18kz9ca"
+ version: '13e9zzn'
+ },
+ 'Query parameters'
+ );
+
+ assert.strictEqual( mw.loader.getState( 'testUrlInc' ), 'ready', 'testUrlInc also loaded' );
+ } );
+ } );
+
+ // @covers mw.loader#batchRequest
+ // @covers mw.loader#buildModulesString
+ QUnit.test( 'Url composition (order of modules for version) – T188076', function ( assert ) {
+ mw.loader.register( [
+ // [module, version, dependencies, group, source]
+ [ 'testUrlOrder', 'url', [], null, 'testloader' ],
+ [ 'testUrlOrder.a', '1', [], null, 'testloader' ],
+ [ 'testUrlOrder.b', '2', [], null, 'testloader' ],
+ [ 'testUrlOrderDump', 'dump', [], null, 'testloader' ]
+ ] );
+
+ return mw.loader.using( [
+ 'testUrlOrderDump',
+ 'testUrlOrder.b',
+ 'testUrlOrder.a',
+ 'testUrlOrder'
+ ] ).then( function ( require ) {
+ assert.propEqual(
+ require( 'testUrlOrderDump' ).query,
+ {
+ modules: 'testUrlOrder,testUrlOrderDump|testUrlOrder.a,b',
+ // Expected: Combined in order after string packing
+ // $hash = hash( 'fnv132', 'urldump12' );
+ // base_convert( $hash, 16, 36 ); // "1knqzan"
+ // Previously: Combined in order of before string packing
+ // $hash = hash( 'fnv132', 'url12dump' );
+ // base_convert( $hash, 16, 36 ); // "11eo3in"
+ version: '1knqzan'
+ },
+ 'Query parameters'
+ );
+ } );
+ } );
+
+ QUnit.test( 'Broken indirect dependency', function ( assert ) {
+ // don't emit an error event
+ this.sandbox.stub( mw, 'track' );
+
+ mw.loader.register( [
+ [ 'test.module1', '0' ],
+ [ 'test.module2', '0', [ 'test.module1' ] ],
+ [ 'test.module3', '0', [ 'test.module2' ] ]
+ ] );
+ mw.loader.implement( 'test.module1', function () {
+ throw new Error( 'expected' );
+ }, {}, {} );
+ assert.strictEqual( mw.loader.getState( 'test.module1' ), 'error', 'Expected "error" state for test.module1' );
+ assert.strictEqual( mw.loader.getState( 'test.module2' ), 'error', 'Expected "error" state for test.module2' );
+ assert.strictEqual( mw.loader.getState( 'test.module3' ), 'error', 'Expected "error" state for test.module3' );
+
+ assert.strictEqual( mw.track.callCount, 1 );
+ } );
+
+ QUnit.test( 'Out-of-order implementation', function ( assert ) {
+ mw.loader.register( [
+ [ 'test.module4', '0' ],
+ [ 'test.module5', '0', [ 'test.module4' ] ],
+ [ 'test.module6', '0', [ 'test.module5' ] ]
+ ] );
+ mw.loader.implement( 'test.module4', function () {} );
+ assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' );
+ assert.strictEqual( mw.loader.getState( 'test.module5' ), 'registered', 'Expected "registered" state for test.module5' );
+ assert.strictEqual( mw.loader.getState( 'test.module6' ), 'registered', 'Expected "registered" state for test.module6' );
+ mw.loader.implement( 'test.module6', function () {} );
+ assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' );
+ assert.strictEqual( mw.loader.getState( 'test.module5' ), 'registered', 'Expected "registered" state for test.module5' );
+ assert.strictEqual( mw.loader.getState( 'test.module6' ), 'loaded', 'Expected "loaded" state for test.module6' );
+ mw.loader.implement( 'test.module5', function () {} );
+ assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' );
+ assert.strictEqual( mw.loader.getState( 'test.module5' ), 'ready', 'Expected "ready" state for test.module5' );
+ assert.strictEqual( mw.loader.getState( 'test.module6' ), 'ready', 'Expected "ready" state for test.module6' );
+ } );
+
+ QUnit.test( 'Missing dependency', function ( assert ) {
+ mw.loader.register( [
+ [ 'test.module7', '0' ],
+ [ 'test.module8', '0', [ 'test.module7' ] ],
+ [ 'test.module9', '0', [ 'test.module8' ] ]
+ ] );
+ mw.loader.implement( 'test.module8', function () {} );
+ assert.strictEqual( mw.loader.getState( 'test.module7' ), 'registered', 'Expected "registered" state for test.module7' );
+ assert.strictEqual( mw.loader.getState( 'test.module8' ), 'loaded', 'Expected "loaded" state for test.module8' );
+ assert.strictEqual( mw.loader.getState( 'test.module9' ), 'registered', 'Expected "registered" state for test.module9' );
+ mw.loader.state( 'test.module7', 'missing' );
+ assert.strictEqual( mw.loader.getState( 'test.module7' ), 'missing', 'Expected "missing" state for test.module7' );
+ assert.strictEqual( mw.loader.getState( 'test.module8' ), 'error', 'Expected "error" state for test.module8' );
+ assert.strictEqual( mw.loader.getState( 'test.module9' ), 'error', 'Expected "error" state for test.module9' );
+ mw.loader.implement( 'test.module9', function () {} );
+ assert.strictEqual( mw.loader.getState( 'test.module7' ), 'missing', 'Expected "missing" state for test.module7' );
+ assert.strictEqual( mw.loader.getState( 'test.module8' ), 'error', 'Expected "error" state for test.module8' );
+ assert.strictEqual( mw.loader.getState( 'test.module9' ), 'error', 'Expected "error" state for test.module9' );
+ mw.loader.using(
+ [ 'test.module7' ],
+ function () {
+ assert.ok( false, 'Success fired despite missing dependency' );
+ assert.ok( true, 'QUnit expected() count dummy' );
+ },
+ function ( e, dependencies ) {
+ assert.strictEqual( Array.isArray( dependencies ), true, 'Expected array of dependencies' );
+ assert.deepEqual( dependencies, [ 'test.module7' ], 'Error callback called with module test.module7' );
+ }
+ );
+ mw.loader.using(
+ [ 'test.module9' ],
+ function () {
+ assert.ok( false, 'Success fired despite missing dependency' );
+ assert.ok( true, 'QUnit expected() count dummy' );
+ },
+ function ( e, dependencies ) {
+ assert.strictEqual( Array.isArray( dependencies ), true, 'Expected array of dependencies' );
+ dependencies.sort();
+ assert.deepEqual(
+ dependencies,
+ [ 'test.module7', 'test.module8', 'test.module9' ],
+ 'Error callback called with all three modules as dependencies'
+ );
+ }
+ );
+ } );
+
+ QUnit.test( 'Dependency handling', function ( assert ) {
+ var done = assert.async();
+ mw.loader.register( [
+ // [module, version, dependencies, group, source]
+ [ 'testMissing', '1', [], null, 'testloader' ],
+ [ 'testUsesMissing', '1', [ 'testMissing' ], null, 'testloader' ],
+ [ 'testUsesNestedMissing', '1', [ 'testUsesMissing' ], null, 'testloader' ]
+ ] );
+
+ function verifyModuleStates() {
+ assert.equal( mw.loader.getState( 'testMissing' ), 'missing', 'Module "testMissing" state' );
+ assert.equal( mw.loader.getState( 'testUsesMissing' ), 'error', 'Module "testUsesMissing" state' );
+ assert.equal( mw.loader.getState( 'testUsesNestedMissing' ), 'error', 'Module "testUsesNestedMissing" state' );
+ }
+
+ mw.loader.using( [ 'testUsesNestedMissing' ],
+ function () {
+ assert.ok( false, 'Error handler should be invoked.' );
+ assert.ok( true ); // Dummy to reach QUnit expect()
+
+ verifyModuleStates();
+
+ done();
+ },
+ function ( e, badmodules ) {
+ assert.ok( true, 'Error handler should be invoked.' );
+ // As soon as server spits out state('testMissing', 'missing');
+ // it will bubble up and trigger the error callback.
+ // Therefor the badmodules array is not testUsesMissing or testUsesNestedMissing.
+ assert.deepEqual( badmodules, [ 'testMissing' ], 'Bad modules as expected.' );
+
+ verifyModuleStates();
+
+ done();
+ }
+ );
+ } );
+
+ QUnit.test( 'Skip-function handling', function ( assert ) {
+ mw.loader.register( [
+ // [module, version, dependencies, group, source, skip]
+ [ 'testSkipped', '1', [], null, 'testloader', 'return true;' ],
+ [ 'testNotSkipped', '1', [], null, 'testloader', 'return false;' ],
+ [ 'testUsesSkippable', '1', [ 'testSkipped', 'testNotSkipped' ], null, 'testloader' ]
+ ] );
+
+ return mw.loader.using( [ 'testUsesSkippable' ] ).then(
+ function () {
+ assert.equal( mw.loader.getState( 'testSkipped' ), 'ready', 'Skipped module' );
+ assert.equal( mw.loader.getState( 'testNotSkipped' ), 'ready', 'Regular module' );
+ assert.equal( mw.loader.getState( 'testUsesSkippable' ), 'ready', 'Regular module with skippable dependency' );
+ },
+ function ( e, badmodules ) {
+ // Should not fail and QUnit would already catch this,
+ // but add a handler anyway to report details from 'badmodules
+ assert.deepEqual( badmodules, [], 'Bad modules' );
+ }
+ );
+ } );
+
+ // This bug was actually already fixed in 1.18 and later when discovered in 1.17.
+ QUnit.test( '.load( "//protocol-relative" ) - T32825', function ( assert ) {
+ var target,
+ done = assert.async();
+
+ // URL to the callback script
+ target = QUnit.fixurl(
+ mw.config.get( 'wgServer' ) + mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/mwLoaderTestCallback.js'
+ );
+ // Ensure a protocol-relative URL for this test
+ target = target.replace( /https?:/, '' );
+ assert.equal( target.slice( 0, 2 ), '//', 'URL is protocol-relative' );
+
+ mw.loader.testCallback = function () {
+ // Ensure once, delete now
+ delete mw.loader.testCallback;
+ assert.ok( true, 'callback' );
+ done();
+ };
+
+ // Go!
+ mw.loader.load( target );
+ } );
+
+ QUnit.test( '.load( "/absolute-path" )', function ( assert ) {
+ var target,
+ done = assert.async();
+
+ // URL to the callback script
+ target = QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/mwLoaderTestCallback.js' );
+ assert.equal( target.slice( 0, 1 ), '/', 'URL is relative to document root' );
+
+ mw.loader.testCallback = function () {
+ // Ensure once, delete now
+ delete mw.loader.testCallback;
+ assert.ok( true, 'callback' );
+ done();
+ };
+
+ // Go!
+ mw.loader.load( target );
+ } );
+
+ QUnit.test( 'Empty string module name - T28804', function ( assert ) {
+ var done = false;
+
+ assert.strictEqual( mw.loader.getState( '' ), null, 'State (unregistered)' );
+
+ mw.loader.register( '', 'v1' );
+ assert.strictEqual( mw.loader.getState( '' ), 'registered', 'State (registered)' );
+ assert.strictEqual( mw.loader.getVersion( '' ), 'v1', 'Version' );
+
+ mw.loader.implement( '', function () {
+ done = true;
+ } );
+
+ return mw.loader.using( '', function () {
+ assert.strictEqual( done, true, 'script ran' );
+ assert.strictEqual( mw.loader.getState( '' ), 'ready', 'State (ready)' );
+ } );
+ } );
+
+ QUnit.test( 'Executing race - T112232', function ( assert ) {
+ var done = false;
+
+ // The red herring schedules its CSS buffer first. In T112232, a bug in the
+ // state machine would cause the job for testRaceLoadMe to run with an earlier job.
+ mw.loader.implement(
+ 'testRaceRedHerring',
+ function () {},
+ { css: [ '.mw-testRaceRedHerring {}' ] }
+ );
+ mw.loader.implement(
+ 'testRaceLoadMe',
+ function () {
+ done = true;
+ },
+ { css: [ '.mw-testRaceLoadMe { float: left; }' ] }
+ );
+
+ mw.loader.load( [ 'testRaceRedHerring', 'testRaceLoadMe' ] );
+ return mw.loader.using( 'testRaceLoadMe', function () {
+ assert.strictEqual( done, true, 'script ran' );
+ assert.strictEqual( mw.loader.getState( 'testRaceLoadMe' ), 'ready', 'state' );
+ } );
+ } );
+
+ QUnit.test( 'Stale response caching - T117587', function ( assert ) {
+ var count = 0;
+ mw.loader.store.enabled = true;
+ mw.loader.register( 'test.stale', 'v2' );
+ assert.strictEqual( mw.loader.store.get( 'test.stale' ), false, 'Not in store' );
+
+ mw.loader.implement( 'test.stale@v1', function () {
+ count++;
+ } );
+
+ return mw.loader.using( 'test.stale' )
+ .then( function () {
+ assert.strictEqual( count, 1 );
+ // After implementing, registry contains version as implemented by the response.
+ assert.strictEqual( mw.loader.getVersion( 'test.stale' ), 'v1', 'Override version' );
+ assert.strictEqual( mw.loader.getState( 'test.stale' ), 'ready' );
+ assert.ok( mw.loader.store.get( 'test.stale' ), 'In store' );
+ } )
+ .then( function () {
+ // Reset run time, but keep mw.loader.store
+ mw.loader.moduleRegistry[ 'test.stale' ].script = undefined;
+ mw.loader.moduleRegistry[ 'test.stale' ].state = 'registered';
+ mw.loader.moduleRegistry[ 'test.stale' ].version = 'v2';
+
+ // Module was stored correctly as v1
+ // On future navigations, it will be ignored until evicted
+ assert.strictEqual( mw.loader.store.get( 'test.stale' ), false, 'Not in store' );
+ } );
+ } );
+
+ QUnit.test( 'Stale response caching - backcompat', function ( assert ) {
+ var script = 0;
+ mw.loader.store.enabled = true;
+ mw.loader.register( 'test.stalebc', 'v2' );
+ assert.strictEqual( mw.loader.store.get( 'test.stalebc' ), false, 'Not in store' );
+
+ mw.loader.implement( 'test.stalebc', function () {
+ script++;
+ } );
+
+ return mw.loader.using( 'test.stalebc' )
+ .then( function () {
+ assert.strictEqual( script, 1, 'module script ran' );
+ assert.strictEqual( mw.loader.getState( 'test.stalebc' ), 'ready' );
+ assert.ok( mw.loader.store.get( 'test.stalebc' ), 'In store' );
+ } )
+ .then( function () {
+ // Reset run time, but keep mw.loader.store
+ mw.loader.moduleRegistry[ 'test.stalebc' ].script = undefined;
+ mw.loader.moduleRegistry[ 'test.stalebc' ].state = 'registered';
+ mw.loader.moduleRegistry[ 'test.stalebc' ].version = 'v2';
+
+ // Legacy behaviour is storing under the expected version,
+ // which woudl lead to whitewashing and stale values (T117587).
+ assert.ok( mw.loader.store.get( 'test.stalebc' ), 'In store' );
+ } );
+ } );
+
+ QUnit.test( 'require()', function ( assert ) {
+ mw.loader.register( [
+ [ 'test.require1', '0' ],
+ [ 'test.require2', '0' ],
+ [ 'test.require3', '0' ],
+ [ 'test.require4', '0', [ 'test.require3' ] ]
+ ] );
+ mw.loader.implement( 'test.require1', function () {} );
+ mw.loader.implement( 'test.require2', function ( $, jQuery, require, module ) {
+ module.exports = 1;
+ } );
+ mw.loader.implement( 'test.require3', function ( $, jQuery, require, module ) {
+ module.exports = function () {
+ return 'hello world';
+ };
+ } );
+ mw.loader.implement( 'test.require4', function ( $, jQuery, require, module ) {
+ var other = require( 'test.require3' );
+ module.exports = {
+ pizza: function () {
+ return other();
+ }
+ };
+ } );
+ return mw.loader.using( [ 'test.require1', 'test.require2', 'test.require3', 'test.require4' ] ).then( function ( require ) {
+ var module1, module2, module3, module4;
+
+ module1 = require( 'test.require1' );
+ module2 = require( 'test.require2' );
+ module3 = require( 'test.require3' );
+ module4 = require( 'test.require4' );
+
+ assert.strictEqual( typeof module1, 'object', 'export of module with no export' );
+ assert.strictEqual( module2, 1, 'export a number' );
+ assert.strictEqual( module3(), 'hello world', 'export a function' );
+ assert.strictEqual( typeof module4.pizza, 'function', 'export an object' );
+ assert.strictEqual( module4.pizza(), 'hello world', 'module can require other modules' );
+
+ assert.throws( function () {
+ require( '_badmodule' );
+ }, /is not loaded/, 'Requesting non-existent modules throws error.' );
+ } );
+ } );
+
+ QUnit.test( 'require() in debug mode', function ( assert ) {
+ var path = mw.config.get( 'wgScriptPath' );
+ mw.loader.register( [
+ [ 'test.require.define', '0' ],
+ [ 'test.require.callback', '0', [ 'test.require.define' ] ]
+ ] );
+ mw.loader.implement( 'test.require.callback', [ QUnit.fixurl( path + '/tests/qunit/data/requireCallMwLoaderTestCallback.js' ) ] );
+ mw.loader.implement( 'test.require.define', [ QUnit.fixurl( path + '/tests/qunit/data/defineCallMwLoaderTestCallback.js' ) ] );
+
+ return mw.loader.using( 'test.require.callback' ).then( function ( require ) {
+ var cb = require( 'test.require.callback' );
+ assert.strictEqual( cb.immediate, 'Defined.', 'module.exports and require work in debug mode' );
+ // Must use try-catch because cb.later() will throw if require is undefined,
+ // which doesn't work well inside Deferred.then() when using jQuery 1.x with QUnit
+ try {
+ assert.strictEqual( cb.later(), 'Defined.', 'require works asynchrously in debug mode' );
+ } catch ( e ) {
+ assert.equal( null, String( e ), 'require works asynchrously in debug mode' );
+ }
+ } );
+ } );
+
+ QUnit.test( 'Implicit dependencies', function ( assert ) {
+ var user = 0,
+ site = 0,
+ siteFromUser = 0;
+
+ mw.loader.implement(
+ 'site',
+ function () {
+ site++;
+ }
+ );
+ mw.loader.implement(
+ 'user',
+ function () {
+ user++;
+ siteFromUser = site;
+ }
+ );
+
+ return mw.loader.using( 'user', function () {
+ assert.strictEqual( site, 1, 'site module' );
+ assert.strictEqual( user, 1, 'user module' );
+ assert.strictEqual( siteFromUser, 1, 'site ran before user' );
+ } ).always( function () {
+ // Reset
+ mw.loader.moduleRegistry[ 'site' ].state = 'registered';
+ mw.loader.moduleRegistry[ 'user' ].state = 'registered';
+ } );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js
new file mode 100644
index 00000000..923f97d1
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js
@@ -0,0 +1,28 @@
+( function ( mw ) {
+ var TEST_MODEL = 'test-content-model';
+
+ QUnit.module( 'mediawiki.messagePoster', QUnit.newMwEnvironment( {
+ teardown: function () {
+ mw.messagePoster.factory.unregister( TEST_MODEL );
+ }
+ } ) );
+
+ QUnit.test( 'register', function ( assert ) {
+ var testMessagePosterConstructor = function () {};
+
+ mw.messagePoster.factory.register( TEST_MODEL, testMessagePosterConstructor );
+ assert.strictEqual(
+ mw.messagePoster.factory.contentModelToClass[ TEST_MODEL ],
+ testMessagePosterConstructor,
+ 'Constructor is registered'
+ );
+
+ assert.throws(
+ function () {
+ mw.messagePoster.factory.register( TEST_MODEL, testMessagePosterConstructor );
+ },
+ new RegExp( 'Content model "' + TEST_MODEL + '" is already registered' ),
+ 'Throws exception is same model is registered a second time'
+ );
+ } );
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js
new file mode 100644
index 00000000..df02693b
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js
@@ -0,0 +1,108 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.requestIdleCallback', QUnit.newMwEnvironment( {
+ setup: function () {
+ var clock = this.clock = this.sandbox.useFakeTimers();
+
+ this.sandbox.stub( mw, 'now', function () {
+ return +new Date();
+ } );
+
+ this.tick = function ( forward ) {
+ return clock.tick( forward || 1 );
+ };
+
+ // Always test the polyfill, not native
+ this.sandbox.stub( mw, 'requestIdleCallback', mw.requestIdleCallbackInternal );
+ }
+ } ) );
+
+ QUnit.test( 'callback', function ( assert ) {
+ var sequence;
+
+ mw.requestIdleCallback( function () {
+ sequence.push( 'x' );
+ } );
+ mw.requestIdleCallback( function () {
+ sequence.push( 'y' );
+ } );
+ mw.requestIdleCallback( function () {
+ sequence.push( 'z' );
+ } );
+
+ sequence = [];
+ this.tick();
+ assert.deepEqual( sequence, [ 'x', 'y', 'z' ] );
+ } );
+
+ QUnit.test( 'nested', function ( assert ) {
+ var sequence;
+
+ mw.requestIdleCallback( function () {
+ sequence.push( 'x' );
+ } );
+ // Task Y is a task that schedules another task.
+ mw.requestIdleCallback( function () {
+ function other() {
+ sequence.push( 'y' );
+ }
+ mw.requestIdleCallback( other );
+ } );
+ mw.requestIdleCallback( function () {
+ sequence.push( 'z' );
+ } );
+
+ sequence = [];
+ this.tick();
+ assert.deepEqual( sequence, [ 'x', 'z' ] );
+
+ sequence = [];
+ this.tick();
+ assert.deepEqual( sequence, [ 'y' ] );
+ } );
+
+ QUnit.test( 'timeRemaining', function ( assert ) {
+ var sequence,
+ tick = this.tick,
+ jobs = [
+ { time: 10, key: 'a' },
+ { time: 20, key: 'b' },
+ { time: 10, key: 'c' },
+ { time: 20, key: 'd' },
+ { time: 10, key: 'e' }
+ ];
+
+ mw.requestIdleCallback( function doWork( deadline ) {
+ var job;
+ while ( jobs[ 0 ] && deadline.timeRemaining() > 15 ) {
+ job = jobs.shift();
+ tick( job.time );
+ sequence.push( job.key );
+ }
+ if ( jobs[ 0 ] ) {
+ mw.requestIdleCallback( doWork );
+ }
+ } );
+
+ sequence = [];
+ tick();
+ assert.deepEqual( sequence, [ 'a', 'b', 'c' ] );
+
+ sequence = [];
+ tick();
+ assert.deepEqual( sequence, [ 'd', 'e' ] );
+ } );
+
+ if ( window.requestIdleCallback ) {
+ QUnit.test( 'native', function ( assert ) {
+ var done = assert.async();
+ // Remove polyfill and clock stub
+ mw.requestIdleCallback.restore();
+ this.clock.restore();
+ mw.requestIdleCallback( function () {
+ assert.expect( 0 );
+ done();
+ } );
+ } );
+ }
+
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js
new file mode 100644
index 00000000..436cb2ed
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js
@@ -0,0 +1,56 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.storage' );
+
+ QUnit.test( 'set/get with storage support', function ( assert ) {
+ var stub = {
+ setItem: this.sandbox.spy(),
+ getItem: this.sandbox.stub()
+ };
+ stub.getItem.withArgs( 'foo' ).returns( 'test' );
+ stub.getItem.returns( null );
+ this.sandbox.stub( mw.storage, 'store', stub );
+
+ mw.storage.set( 'foo', 'test' );
+ assert.ok( stub.setItem.calledOnce );
+
+ assert.strictEqual( mw.storage.get( 'foo' ), 'test', 'Check value gets stored.' );
+ assert.strictEqual( mw.storage.get( 'bar' ), null, 'Unset values are null.' );
+ } );
+
+ QUnit.test( 'set/get with storage methods disabled', function ( assert ) {
+ // This covers browsers where storage is disabled
+ // (quota full, or security/privacy settings).
+ // On most browsers, these interface will be accessible with
+ // their methods throwing.
+ var stub = {
+ getItem: this.sandbox.stub(),
+ removeItem: this.sandbox.stub(),
+ setItem: this.sandbox.stub()
+ };
+ stub.getItem.throws();
+ stub.setItem.throws();
+ stub.removeItem.throws();
+ this.sandbox.stub( mw.storage, 'store', stub );
+
+ assert.strictEqual( mw.storage.get( 'foo' ), false );
+ assert.strictEqual( mw.storage.set( 'foo', 'test' ), false );
+ assert.strictEqual( mw.storage.remove( 'foo', 'test' ), false );
+ } );
+
+ QUnit.test( 'set/get with storage object disabled', function ( assert ) {
+ // On other browsers, these entire object is disabled.
+ // `'localStorage' in window` would be true (and pass feature test)
+ // but trying to read the object as window.localStorage would throw
+ // an exception. Such case would instantiate SafeStorage with
+ // undefined after the internal try/catch.
+ var old = mw.storage.store;
+ mw.storage.store = undefined;
+
+ assert.strictEqual( mw.storage.get( 'foo' ), false );
+ assert.strictEqual( mw.storage.set( 'foo', 'test' ), false );
+ assert.strictEqual( mw.storage.remove( 'foo', 'test' ), false );
+
+ mw.storage.store = old;
+ } );
+
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js
new file mode 100644
index 00000000..cb583e7a
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js
@@ -0,0 +1,32 @@
+( function ( mw ) {
+
+ QUnit.module( 'mediawiki.template.mustache', {
+ setup: function () {
+ // Stub register some templates
+ this.sandbox.stub( mw.templates, 'get' ).returns( {
+ 'test_greeting.mustache': '<div>{{foo}}{{>suffix}}</div>',
+ 'test_greeting_suffix.mustache': ' goodbye'
+ } );
+ }
+ } );
+
+ QUnit.test( 'render', function ( assert ) {
+ var html, htmlPartial, data, partials,
+ template = mw.template.get( 'stub', 'test_greeting.mustache' ),
+ partial = mw.template.get( 'stub', 'test_greeting_suffix.mustache' );
+
+ data = {
+ foo: 'Hello'
+ };
+ partials = {
+ suffix: partial
+ };
+
+ html = template.render( data ).html();
+ htmlPartial = template.render( data, partials ).html();
+
+ assert.strictEqual( html, 'Hello', 'Render without partial' );
+ assert.strictEqual( htmlPartial, 'Hello goodbye', 'Render with partial' );
+ } );
+
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js
new file mode 100644
index 00000000..a2823253
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js
@@ -0,0 +1,63 @@
+( function ( mw ) {
+
+ QUnit.module( 'mediawiki.template', {
+ setup: function () {
+ var abcCompiler = {
+ compile: function () {
+ return 'abc default compiler';
+ }
+ };
+
+ // Register some template compiler languages
+ mw.template.registerCompiler( 'abc', abcCompiler );
+ mw.template.registerCompiler( 'xyz', {
+ compile: function () {
+ return 'xyz compiler';
+ }
+ } );
+
+ // Stub register some templates
+ this.sandbox.stub( mw.templates, 'get' ).returns( {
+ 'test_templates_foo.xyz': 'goodbye',
+ 'test_templates_foo.abc': 'thankyou'
+ } );
+ }
+ } );
+
+ QUnit.test( 'add', function ( assert ) {
+ assert.throws(
+ function () {
+ mw.template.add( 'module', 'test_templates_foo', 'hello' );
+ },
+ 'When no prefix throw exception'
+ );
+ } );
+
+ QUnit.test( 'compile', function ( assert ) {
+ assert.throws(
+ function () {
+ mw.template.compile( '{{foo}}', 'rainbow' );
+ },
+ 'Unknown compiler names throw exceptions'
+ );
+ } );
+
+ QUnit.test( 'get', function ( assert ) {
+ assert.strictEqual( mw.template.get( 'test.mediawiki.template', 'test_templates_foo.xyz' ), 'xyz compiler' );
+ assert.strictEqual( mw.template.get( 'test.mediawiki.template', 'test_templates_foo.abc' ), 'abc default compiler' );
+ assert.throws(
+ function () {
+ mw.template.get( 'this.should.not.exist', 'hello' );
+ },
+ 'When bad module name given throw error.'
+ );
+
+ assert.throws(
+ function () {
+ mw.template.get( 'mediawiki.template', 'hello' );
+ },
+ 'The template hello should not exist in the mediawiki.templates module and should throw an exception.'
+ );
+ } );
+
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.test.js
new file mode 100644
index 00000000..119222a6
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.test.js
@@ -0,0 +1,447 @@
+( function ( mw ) {
+ var specialCharactersPageName,
+ // Can't mock SITENAME since jqueryMsg caches it at load
+ siteName = mw.config.get( 'wgSiteName' );
+
+ // Since QUnitTestResources.php loads both mediawiki and mediawiki.jqueryMsg as
+ // dependencies, this only tests the monkey-patched behavior with the two of them combined.
+
+ // See mediawiki.jqueryMsg.test.js for unit tests for jqueryMsg-specific functionality.
+
+ QUnit.module( 'mediawiki', QUnit.newMwEnvironment( {
+ setup: function () {
+ specialCharactersPageName = '"Who" wants to be a millionaire & live on \'Exotic Island\'?';
+ },
+ config: {
+ wgArticlePath: '/wiki/$1',
+
+ // For formatnum tests
+ wgUserLanguage: 'en'
+ },
+ // Messages used in multiple tests
+ messages: {
+ 'other-message': 'Other Message',
+ 'mediawiki-test-pagetriage-del-talk-page-notify-summary': 'Notifying author of deletion nomination for [[$1]]',
+ 'gender-plural-msg': '{{GENDER:$1|he|she|they}} {{PLURAL:$2|is|are}} awesome',
+ 'grammar-msg': 'Przeszukaj {{GRAMMAR:grammar_case_foo|{{SITENAME}}}}',
+ 'formatnum-msg': '{{formatnum:$1}}',
+ 'int-msg': 'Some {{int:other-message}}',
+ 'mediawiki-test-version-entrypoints-index-php': '[https://www.mediawiki.org/wiki/Manual:index.php index.php]',
+ 'external-link-replace': 'Foo [$1 bar]'
+ }
+ } ) );
+
+ QUnit.test( 'Initial check', function ( assert ) {
+ assert.ok( window.jQuery, 'jQuery defined' );
+ assert.ok( window.$, '$ defined' );
+ assert.strictEqual( window.$, window.jQuery, '$ alias to jQuery' );
+
+ this.suppressWarnings();
+ assert.ok( window.$j, '$j defined' );
+ assert.strictEqual( window.$j, window.jQuery, '$j alias to jQuery' );
+ this.restoreWarnings();
+
+ // window.mw and window.mediaWiki are not deprecated, but for some reason
+ // PhantomJS is triggerring the accessors on all mw.* properties in this test,
+ // and with that lots of unrelated deprecation notices.
+ this.suppressWarnings();
+ assert.ok( window.mediaWiki, 'mediaWiki defined' );
+ assert.ok( window.mw, 'mw defined' );
+ assert.strictEqual( window.mw, window.mediaWiki, 'mw alias to mediaWiki' );
+ this.restoreWarnings();
+ } );
+
+ QUnit.test( 'mw.format', function ( assert ) {
+ assert.equal(
+ mw.format( 'Format $1 $2', 'foo', 'bar' ),
+ 'Format foo bar',
+ 'Simple parameters'
+ );
+ assert.equal(
+ mw.format( 'Format $1 $2' ),
+ 'Format $1 $2',
+ 'Missing parameters'
+ );
+ } );
+
+ QUnit.test( 'mw.now', function ( assert ) {
+ assert.equal( typeof mw.now(), 'number', 'Return a number' );
+ assert.equal(
+ String( Math.round( mw.now() ) ).length,
+ String( +new Date() ).length,
+ 'Match size of current timestamp'
+ );
+ } );
+
+ QUnit.test( 'mw.Map', function ( assert ) {
+ var arry, conf, funky, globalConf, nummy, someValues;
+
+ conf = new mw.Map();
+
+ // Dummy variables
+ funky = function () {};
+ arry = [];
+ nummy = 7;
+
+ // Single get and set
+
+ assert.strictEqual( conf.set( 'foo', 'Bar' ), true, 'Map.set returns boolean true if a value was set for a valid key string' );
+ assert.equal( conf.get( 'foo' ), 'Bar', 'Map.get returns a single value value correctly' );
+
+ assert.strictEqual( conf.get( 'example' ), null, 'Map.get returns null if selection was a string and the key was not found' );
+ assert.strictEqual( conf.get( 'example', arry ), arry, 'Map.get returns fallback by reference if the key was not found' );
+ assert.strictEqual( conf.get( 'example', undefined ), undefined, 'Map.get supports `undefined` as fallback instead of `null`' );
+
+ assert.strictEqual( conf.get( 'constructor' ), null, 'Map.get does not look at Object.prototype of internal storage (constructor)' );
+ assert.strictEqual( conf.get( 'hasOwnProperty' ), null, 'Map.get does not look at Object.prototype of internal storage (hasOwnProperty)' );
+
+ conf.set( 'hasOwnProperty', function () { return true; } );
+ assert.strictEqual( conf.get( 'example', 'missing' ), 'missing', 'Map.get uses neutral hasOwnProperty method (positive)' );
+
+ conf.set( 'example', 'Foo' );
+ conf.set( 'hasOwnProperty', function () { return false; } );
+ assert.strictEqual( conf.get( 'example' ), 'Foo', 'Map.get uses neutral hasOwnProperty method (negative)' );
+
+ assert.strictEqual( conf.set( 'constructor', 42 ), true, 'Map.set for key "constructor"' );
+ assert.strictEqual( conf.get( 'constructor' ), 42, 'Map.get for key "constructor"' );
+
+ assert.strictEqual( conf.set( 'undef' ), false, 'Map.set requires explicit value (no undefined default)' );
+
+ assert.strictEqual( conf.set( 'undef', undefined ), true, 'Map.set allows setting value to `undefined`' );
+ assert.equal( conf.get( 'undef', 'fallback' ), undefined, 'Map.get supports retreiving value of `undefined`' );
+
+ assert.strictEqual( conf.set( funky, 'Funky' ), false, 'Map.set returns boolean false if key was invalid (Function)' );
+ assert.strictEqual( conf.set( arry, 'Arry' ), false, 'Map.set returns boolean false if key was invalid (Array)' );
+ assert.strictEqual( conf.set( nummy, 'Nummy' ), false, 'Map.set returns boolean false if key was invalid (Number)' );
+
+ conf.set( String( nummy ), 'I used to be a number' );
+
+ assert.strictEqual( conf.get( funky ), null, 'Map.get returns null if selection was invalid (Function)' );
+ assert.strictEqual( conf.get( nummy ), null, 'Map.get returns null if selection was invalid (Number)' );
+ assert.propEqual( conf.get( [ nummy ] ), {}, 'Map.get returns null if selection was invalid (multiple)' );
+ assert.strictEqual( conf.get( nummy, false ), false, 'Map.get returns custom fallback for invalid selection' );
+
+ assert.strictEqual( conf.exists( 'doesNotExist' ), false, 'Map.exists where property does not exist' );
+ assert.strictEqual( conf.exists( 'undef' ), true, 'Map.exists where value is `undefined`' );
+ assert.strictEqual( conf.exists( [ 'undef', 'example' ] ), true, 'Map.exists with multiple keys (all existing)' );
+ assert.strictEqual( conf.exists( [ 'example', 'doesNotExist' ] ), false, 'Map.exists with multiple keys (some non-existing)' );
+ assert.strictEqual( conf.exists( [] ), true, 'Map.exists with no keys' );
+ assert.strictEqual( conf.exists( nummy ), false, 'Map.exists with invalid key that looks like an existing key' );
+ assert.strictEqual( conf.exists( [ nummy ] ), false, 'Map.exists with invalid key that looks like an existing key' );
+
+ // Multiple values at once
+ conf = new mw.Map();
+ someValues = {
+ foo: 'bar',
+ lorem: 'ipsum',
+ MediaWiki: true
+ };
+ assert.strictEqual( conf.set( someValues ), true, 'Map.set returns boolean true if multiple values were set by passing an object' );
+ assert.deepEqual( conf.get( [ 'foo', 'lorem' ] ), {
+ foo: 'bar',
+ lorem: 'ipsum'
+ }, 'Map.get returns multiple values correctly as an object' );
+
+ assert.deepEqual( conf.get( [ 'foo', 'notExist' ] ), {
+ foo: 'bar',
+ notExist: null
+ }, 'Map.get return includes keys that were not found as null values' );
+
+ assert.propEqual( conf.values, someValues, 'Map.values is an internal object with all values (exposed for convenience)' );
+ assert.propEqual( conf.get(), someValues, 'Map.get() returns an object with all values' );
+
+ // Interacting with globals
+ conf.set( 'globalMapChecker', 'Hi' );
+
+ assert.ok( ( 'globalMapChecker' in window ) === false, 'Map does not its store values in the window object by default' );
+
+ globalConf = new mw.Map( true );
+ globalConf.set( 'anotherGlobalMapChecker', 'Hello' );
+
+ assert.ok( 'anotherGlobalMapChecker' in window, 'global Map stores its values in the window object' );
+
+ assert.equal( globalConf.get( 'anotherGlobalMapChecker' ), 'Hello', 'get value from global Map via get()' );
+ this.suppressWarnings();
+ assert.equal( window.anotherGlobalMapChecker, 'Hello', 'get value from global Map via window object' );
+ this.restoreWarnings();
+
+ // Change value via global Map
+ globalConf.set( 'anotherGlobalMapChecker', 'Again' );
+ assert.equal( globalConf.get( 'anotherGlobalMapChecker' ), 'Again', 'Change in global Map reflected via get()' );
+ this.suppressWarnings();
+ assert.equal( window.anotherGlobalMapChecker, 'Again', 'Change in global Map reflected window object' );
+ this.restoreWarnings();
+
+ // Change value via window object
+ this.suppressWarnings();
+ window.anotherGlobalMapChecker = 'World';
+ assert.equal( window.anotherGlobalMapChecker, 'World', 'Change in window object works' );
+ this.restoreWarnings();
+ assert.equal( globalConf.get( 'anotherGlobalMapChecker' ), 'Again', 'Change in window object not reflected in global Map' );
+
+ // Whitelist this global variable for QUnit's 'noglobal' mode
+ if ( QUnit.config.noglobals ) {
+ QUnit.config.pollution.push( 'anotherGlobalMapChecker' );
+ }
+ } );
+
+ QUnit.test( 'mw.message & mw.messages', function ( assert ) {
+ var goodbye, hello;
+
+ // Convenience method for asserting the same result for multiple formats
+ function assertMultipleFormats( messageArguments, formats, expectedResult, assertMessage ) {
+ var format, i,
+ len = formats.length;
+
+ for ( i = 0; i < len; i++ ) {
+ format = formats[ i ];
+ assert.equal( mw.message.apply( null, messageArguments )[ format ](), expectedResult, assertMessage + ' when format is ' + format );
+ }
+ }
+
+ assert.ok( mw.messages, 'messages defined' );
+ assert.ok( mw.messages.set( 'hello', 'Hello <b>awesome</b> world' ), 'mw.messages.set: Register' );
+
+ hello = mw.message( 'hello' );
+
+ // https://phabricator.wikimedia.org/T46459
+ assert.equal( hello.format, 'text', 'Message property "format" defaults to "text"' );
+
+ assert.strictEqual( hello.map, mw.messages, 'Message property "map" defaults to the global instance in mw.messages' );
+ assert.equal( hello.key, 'hello', 'Message property "key" (currect key)' );
+ assert.deepEqual( hello.parameters, [], 'Message property "parameters" defaults to an empty array' );
+
+ // TODO
+ assert.ok( hello.params, 'Message prototype "params"' );
+
+ hello.format = 'plain';
+ assert.equal( hello.toString(), 'Hello <b>awesome</b> world', 'Message.toString returns the message as a string with the current "format"' );
+
+ assert.equal( hello.escaped(), 'Hello &lt;b&gt;awesome&lt;/b&gt; world', 'Message.escaped returns the escaped message' );
+ assert.equal( hello.format, 'escaped', 'Message.escaped correctly updated the "format" property' );
+
+ assert.ok( mw.messages.set( 'multiple-curly-brace', '"{{SITENAME}}" is the home of {{int:other-message}}' ), 'mw.messages.set: Register' );
+ assertMultipleFormats( [ 'multiple-curly-brace' ], [ 'text', 'parse' ], '"' + siteName + '" is the home of Other Message', 'Curly brace format works correctly' );
+ assert.equal( mw.message( 'multiple-curly-brace' ).plain(), mw.messages.get( 'multiple-curly-brace' ), 'Plain format works correctly for curly brace message' );
+ assert.equal( mw.message( 'multiple-curly-brace' ).escaped(), mw.html.escape( '"' + siteName + '" is the home of Other Message' ), 'Escaped format works correctly for curly brace message' );
+
+ assert.ok( mw.messages.set( 'multiple-square-brackets-and-ampersand', 'Visit the [[Project:Community portal|community portal]] & [[Project:Help desk|help desk]]' ), 'mw.messages.set: Register' );
+ assertMultipleFormats( [ 'multiple-square-brackets-and-ampersand' ], [ 'plain', 'text' ], mw.messages.get( 'multiple-square-brackets-and-ampersand' ), 'Square bracket message is not processed' );
+ assert.equal( mw.message( 'multiple-square-brackets-and-ampersand' ).escaped(), 'Visit the [[Project:Community portal|community portal]] &amp; [[Project:Help desk|help desk]]', 'Escaped format works correctly for square bracket message' );
+ assert.htmlEqual( mw.message( 'multiple-square-brackets-and-ampersand' ).parse(), 'Visit the ' +
+ '<a title="Project:Community portal" href="/wiki/Project:Community_portal">community portal</a>' +
+ ' &amp; <a title="Project:Help desk" href="/wiki/Project:Help_desk">help desk</a>', 'Internal links work with parse' );
+
+ assertMultipleFormats( [ 'mediawiki-test-version-entrypoints-index-php' ], [ 'plain', 'text', 'escaped' ], mw.messages.get( 'mediawiki-test-version-entrypoints-index-php' ), 'External link markup is unprocessed' );
+ assert.htmlEqual( mw.message( 'mediawiki-test-version-entrypoints-index-php' ).parse(), '<a href="https://www.mediawiki.org/wiki/Manual:index.php">index.php</a>', 'External link works correctly in parse mode' );
+
+ assertMultipleFormats( [ 'external-link-replace', 'http://example.org/?x=y&z' ], [ 'plain', 'text' ], 'Foo [http://example.org/?x=y&z bar]', 'Parameters are substituted but external link is not processed' );
+ assert.equal( mw.message( 'external-link-replace', 'http://example.org/?x=y&z' ).escaped(), 'Foo [http://example.org/?x=y&amp;z bar]', 'In escaped mode, parameters are substituted and ampersand is escaped, but external link is not processed' );
+ assert.htmlEqual( mw.message( 'external-link-replace', 'http://example.org/?x=y&z' ).parse(), 'Foo <a href="http://example.org/?x=y&amp;z">bar</a>', 'External link with replacement works in parse mode without double-escaping' );
+
+ hello.parse();
+ assert.equal( hello.format, 'parse', 'Message.parse correctly updated the "format" property' );
+
+ hello.plain();
+ assert.equal( hello.format, 'plain', 'Message.plain correctly updated the "format" property' );
+
+ hello.text();
+ assert.equal( hello.format, 'text', 'Message.text correctly updated the "format" property' );
+
+ assert.strictEqual( hello.exists(), true, 'Message.exists returns true for existing messages' );
+
+ goodbye = mw.message( 'goodbye' );
+ assert.strictEqual( goodbye.exists(), false, 'Message.exists returns false for nonexistent messages' );
+
+ assertMultipleFormats( [ 'good<>bye' ], [ 'plain', 'text', 'parse', 'escaped' ], '⧼good&lt;&gt;bye⧽', 'Message.toString returns ⧼key⧽ if key does not exist' );
+
+ assert.ok( mw.messages.set( 'plural-test-msg', 'There {{PLURAL:$1|is|are}} $1 {{PLURAL:$1|result|results}}' ), 'mw.messages.set: Register' );
+ assertMultipleFormats( [ 'plural-test-msg', 6 ], [ 'text', 'parse', 'escaped' ], 'There are 6 results', 'plural get resolved' );
+ assert.equal( mw.message( 'plural-test-msg', 6 ).plain(), 'There {{PLURAL:6|is|are}} 6 {{PLURAL:6|result|results}}', 'Parameter is substituted but plural is not resolved in plain' );
+
+ assert.ok( mw.messages.set( 'plural-test-msg-explicit', 'There {{plural:$1|is one car|are $1 cars|0=are no cars|12=are a dozen cars}}' ), 'mw.messages.set: Register message with explicit plural forms' );
+ assertMultipleFormats( [ 'plural-test-msg-explicit', 12 ], [ 'text', 'parse', 'escaped' ], 'There are a dozen cars', 'explicit plural get resolved' );
+
+ assert.ok( mw.messages.set( 'plural-test-msg-explicit-beginning', 'Basket has {{plural:$1|0=no eggs|12=a dozen eggs|6=half a dozen eggs|one egg|$1 eggs}}' ), 'mw.messages.set: Register message with explicit plural forms' );
+ assertMultipleFormats( [ 'plural-test-msg-explicit-beginning', 1 ], [ 'text', 'parse', 'escaped' ], 'Basket has one egg', 'explicit plural given at beginning get resolved for singular' );
+ assertMultipleFormats( [ 'plural-test-msg-explicit-beginning', 4 ], [ 'text', 'parse', 'escaped' ], 'Basket has 4 eggs', 'explicit plural given at beginning get resolved for plural' );
+ assertMultipleFormats( [ 'plural-test-msg-explicit-beginning', 6 ], [ 'text', 'parse', 'escaped' ], 'Basket has half a dozen eggs', 'explicit plural given at beginning get resolved for 6' );
+ assertMultipleFormats( [ 'plural-test-msg-explicit-beginning', 0 ], [ 'text', 'parse', 'escaped' ], 'Basket has no eggs', 'explicit plural given at beginning get resolved for 0' );
+
+ assertMultipleFormats( [ 'mediawiki-test-pagetriage-del-talk-page-notify-summary' ], [ 'plain', 'text' ], mw.messages.get( 'mediawiki-test-pagetriage-del-talk-page-notify-summary' ), 'Double square brackets with no parameters unchanged' );
+
+ assertMultipleFormats( [ 'mediawiki-test-pagetriage-del-talk-page-notify-summary', specialCharactersPageName ], [ 'plain', 'text' ], 'Notifying author of deletion nomination for [[' + specialCharactersPageName + ']]', 'Double square brackets with one parameter' );
+
+ assert.equal( mw.message( 'mediawiki-test-pagetriage-del-talk-page-notify-summary', specialCharactersPageName ).escaped(), 'Notifying author of deletion nomination for [[' + mw.html.escape( specialCharactersPageName ) + ']]', 'Double square brackets with one parameter, when escaped' );
+
+ assert.ok( mw.messages.set( 'mediawiki-test-categorytree-collapse-bullet', '[<b>−</b>]' ), 'mw.messages.set: Register' );
+ assert.equal( mw.message( 'mediawiki-test-categorytree-collapse-bullet' ).plain(), mw.messages.get( 'mediawiki-test-categorytree-collapse-bullet' ), 'Single square brackets unchanged in plain mode' );
+
+ assert.ok( mw.messages.set( 'mediawiki-test-wikieditor-toolbar-help-content-signature-result', '<a href=\'#\' title=\'{{#special:mypage}}\'>Username</a> (<a href=\'#\' title=\'{{#special:mytalk}}\'>talk</a>)' ), 'mw.messages.set: Register' );
+ assert.equal( mw.message( 'mediawiki-test-wikieditor-toolbar-help-content-signature-result' ).plain(), mw.messages.get( 'mediawiki-test-wikieditor-toolbar-help-content-signature-result' ), 'HTML message with curly braces is not changed in plain mode' );
+
+ assertMultipleFormats( [ 'gender-plural-msg', 'male', 1 ], [ 'text', 'parse', 'escaped' ], 'he is awesome', 'Gender and plural are resolved' );
+ assert.equal( mw.message( 'gender-plural-msg', 'male', 1 ).plain(), '{{GENDER:male|he|she|they}} {{PLURAL:1|is|are}} awesome', 'Parameters are substituted, but gender and plural are not resolved in plain mode' );
+
+ assert.equal( mw.message( 'grammar-msg' ).plain(), mw.messages.get( 'grammar-msg' ), 'Grammar is not resolved in plain mode' );
+ assertMultipleFormats( [ 'grammar-msg' ], [ 'text', 'parse' ], 'Przeszukaj ' + siteName, 'Grammar is resolved' );
+ assert.equal( mw.message( 'grammar-msg' ).escaped(), 'Przeszukaj ' + siteName, 'Grammar is resolved in escaped mode' );
+
+ assertMultipleFormats( [ 'formatnum-msg', '987654321.654321' ], [ 'text', 'parse', 'escaped' ], '987,654,321.654', 'formatnum is resolved' );
+ assert.equal( mw.message( 'formatnum-msg' ).plain(), mw.messages.get( 'formatnum-msg' ), 'formatnum is not resolved in plain mode' );
+
+ assertMultipleFormats( [ 'int-msg' ], [ 'text', 'parse', 'escaped' ], 'Some Other Message', 'int is resolved' );
+ assert.equal( mw.message( 'int-msg' ).plain(), mw.messages.get( 'int-msg' ), 'int is not resolved in plain mode' );
+
+ assert.ok( mw.messages.set( 'mediawiki-italics-msg', '<i>Very</i> important' ), 'mw.messages.set: Register' );
+ assertMultipleFormats( [ 'mediawiki-italics-msg' ], [ 'plain', 'text', 'parse' ], mw.messages.get( 'mediawiki-italics-msg' ), 'Simple italics unchanged' );
+ assert.htmlEqual(
+ mw.message( 'mediawiki-italics-msg' ).escaped(),
+ '&lt;i&gt;Very&lt;/i&gt; important',
+ 'Italics are escaped in escaped mode'
+ );
+
+ assert.ok( mw.messages.set( 'mediawiki-italics-with-link', 'An <i>italicized [[link|wiki-link]]</i>' ), 'mw.messages.set: Register' );
+ assertMultipleFormats( [ 'mediawiki-italics-with-link' ], [ 'plain', 'text' ], mw.messages.get( 'mediawiki-italics-with-link' ), 'Italics with link unchanged' );
+ assert.htmlEqual(
+ mw.message( 'mediawiki-italics-with-link' ).escaped(),
+ 'An &lt;i&gt;italicized [[link|wiki-link]]&lt;/i&gt;',
+ 'Italics and link unchanged except for escaping in escaped mode'
+ );
+ assert.htmlEqual(
+ mw.message( 'mediawiki-italics-with-link' ).parse(),
+ 'An <i>italicized <a title="link" href="' + mw.util.getUrl( 'link' ) + '">wiki-link</i>',
+ 'Italics with link inside in parse mode'
+ );
+
+ assert.ok( mw.messages.set( 'mediawiki-script-msg', '<script >alert( "Who put this script here?" );</script>' ), 'mw.messages.set: Register' );
+ assertMultipleFormats( [ 'mediawiki-script-msg' ], [ 'plain', 'text' ], mw.messages.get( 'mediawiki-script-msg' ), 'Script unchanged' );
+ assert.htmlEqual(
+ mw.message( 'mediawiki-script-msg' ).escaped(),
+ '&lt;script &gt;alert( "Who put this script here?" );&lt;/script&gt;',
+ 'Script escaped when using escaped format'
+ );
+ assert.htmlEqual(
+ mw.message( 'mediawiki-script-msg' ).parse(),
+ '&lt;script &gt;alert( "Who put this script here?" );&lt;/script&gt;',
+ 'Script escaped when using parse format'
+ );
+
+ } );
+
+ QUnit.test( 'mw.msg', function ( assert ) {
+ assert.ok( mw.messages.set( 'hello', 'Hello <b>awesome</b> world' ), 'mw.messages.set: Register' );
+ assert.equal( mw.msg( 'hello' ), 'Hello <b>awesome</b> world', 'Gets message with default options (existing message)' );
+ assert.equal( mw.msg( 'goodbye' ), '⧼goodbye⧽', 'Gets message with default options (nonexistent message)' );
+
+ assert.ok( mw.messages.set( 'plural-item', 'Found $1 {{PLURAL:$1|item|items}}' ), 'mw.messages.set: Register' );
+ assert.equal( mw.msg( 'plural-item', 5 ), 'Found 5 items', 'Apply plural for count 5' );
+ assert.equal( mw.msg( 'plural-item', 0 ), 'Found 0 items', 'Apply plural for count 0' );
+ assert.equal( mw.msg( 'plural-item', 1 ), 'Found 1 item', 'Apply plural for count 1' );
+
+ assert.equal( mw.msg( 'mediawiki-test-pagetriage-del-talk-page-notify-summary', specialCharactersPageName ), 'Notifying author of deletion nomination for [[' + specialCharactersPageName + ']]', 'Double square brackets in mw.msg one parameter' );
+
+ assert.equal( mw.msg( 'gender-plural-msg', 'male', 1 ), 'he is awesome', 'Gender test for male, plural count 1' );
+ assert.equal( mw.msg( 'gender-plural-msg', 'female', '1' ), 'she is awesome', 'Gender test for female, plural count 1' );
+ assert.equal( mw.msg( 'gender-plural-msg', 'unknown', 10 ), 'they are awesome', 'Gender test for neutral, plural count 10' );
+
+ assert.equal( mw.msg( 'grammar-msg' ), 'Przeszukaj ' + siteName, 'Grammar is resolved' );
+
+ assert.equal( mw.msg( 'formatnum-msg', '987654321.654321' ), '987,654,321.654', 'formatnum is resolved' );
+
+ assert.equal( mw.msg( 'int-msg' ), 'Some Other Message', 'int is resolved' );
+ } );
+
+ QUnit.test( 'mw.hook', function ( assert ) {
+ var hook, add, fire, chars, callback;
+
+ mw.hook( 'test.hook.unfired' ).add( function () {
+ assert.ok( false, 'Unfired hook' );
+ } );
+
+ mw.hook( 'test.hook.basic' ).add( function () {
+ assert.ok( true, 'Basic callback' );
+ } );
+ mw.hook( 'test.hook.basic' ).fire();
+
+ mw.hook( 'hasOwnProperty' ).add( function () {
+ assert.ok( true, 'hook with name of predefined method' );
+ } );
+ mw.hook( 'hasOwnProperty' ).fire();
+
+ mw.hook( 'test.hook.data' ).add( function ( data1, data2 ) {
+ assert.equal( data1, 'example', 'Fire with data (string param)' );
+ assert.deepEqual( data2, [ 'two' ], 'Fire with data (array param)' );
+ } );
+ mw.hook( 'test.hook.data' ).fire( 'example', [ 'two' ] );
+
+ hook = mw.hook( 'test.hook.chainable' );
+ assert.strictEqual( hook.add(), hook, 'hook.add is chainable' );
+ assert.strictEqual( hook.remove(), hook, 'hook.remove is chainable' );
+ assert.strictEqual( hook.fire(), hook, 'hook.fire is chainable' );
+
+ hook = mw.hook( 'test.hook.detach' );
+ add = hook.add;
+ fire = hook.fire;
+ add( function ( x, y ) {
+ assert.deepEqual( [ x, y ], [ 'x', 'y' ], 'Detached (contextless) with data' );
+ } );
+ fire( 'x', 'y' );
+
+ mw.hook( 'test.hook.fireBefore' ).fire().add( function () {
+ assert.ok( true, 'Invoke handler right away if it was fired before' );
+ } );
+
+ mw.hook( 'test.hook.fireTwiceBefore' ).fire().fire().add( function () {
+ assert.ok( true, 'Invoke handler right away if it was fired before (only last one)' );
+ } );
+
+ chars = [];
+
+ mw.hook( 'test.hook.many' )
+ .add( function ( chr ) {
+ chars.push( chr );
+ } )
+ .fire( 'x' ).fire( 'y' ).fire( 'z' )
+ .add( function ( chr ) {
+ assert.equal( chr, 'z', 'Adding callback later invokes right away with last data' );
+ } );
+
+ assert.deepEqual( chars, [ 'x', 'y', 'z' ], 'Multiple callbacks with multiple fires' );
+
+ chars = [];
+ callback = function ( chr ) {
+ chars.push( chr );
+ };
+
+ mw.hook( 'test.hook.variadic' )
+ .add(
+ callback,
+ callback,
+ function ( chr ) {
+ chars.push( chr );
+ },
+ callback
+ )
+ .fire( 'x' )
+ .remove(
+ function () {
+ 'not-added';
+ },
+ callback
+ )
+ .fire( 'y' )
+ .remove( callback )
+ .fire( 'z' );
+
+ assert.deepEqual(
+ chars,
+ [ 'x', 'x', 'x', 'x', 'y', 'z' ],
+ '"add" and "remove" support variadic arguments. ' +
+ '"add" does not filter unique. ' +
+ '"remove" removes all equal by reference. ' +
+ '"remove" is silent if the function is not found'
+ );
+ } );
+
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js
new file mode 100644
index 00000000..6a1b83cf
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js
@@ -0,0 +1,39 @@
+( function ( mw, $ ) {
+ QUnit.module( 'mediawiki.toc', QUnit.newMwEnvironment( {
+ setup: function () {
+ // Prevent live cookies from interferring with the test
+ this.stub( $, 'cookie' ).returns( null );
+ }
+ } ) );
+
+ QUnit.test( 'toggleToc', function ( assert ) {
+ var tocHtml, $toc, $toggleLink, $tocList;
+
+ assert.strictEqual( $( '.toc' ).length, 0, 'There is no table of contents on the page at the beginning' );
+
+ tocHtml = '<div id="toc" class="toc">' +
+ '<div class="toctitle" lang="en" dir="ltr">' +
+ '<h2>Contents</h2>' +
+ '</div>' +
+ '<ul><li></li></ul>' +
+ '</div>';
+ $toc = $( tocHtml );
+ $( '#qunit-fixture' ).append( $toc );
+ mw.hook( 'wikipage.content' ).fire( $( '#qunit-fixture' ) );
+
+ $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' );
+
+ $toggleLink.click();
+ return $tocList.promise().then( function () {
+ assert.strictEqual( $tocList.is( ':hidden' ), true, 'The table of contents is now hidden' );
+
+ $toggleLink.click();
+ return $tocList.promise();
+ } );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.track.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.track.test.js
new file mode 100644
index 00000000..6c27c5ba
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.track.test.js
@@ -0,0 +1,60 @@
+( function ( mw ) {
+ QUnit.module( 'mediawiki.track' );
+
+ QUnit.test( 'track', function ( assert ) {
+ var sequence = [];
+ mw.trackSubscribe( 'simple', function ( topic, data ) {
+ sequence.push( [ topic, data ] );
+ } );
+ mw.track( 'simple', { key: 1 } );
+ mw.track( 'simple', { key: 2 } );
+
+ assert.deepEqual( sequence, [
+ [ 'simple', { key: 1 } ],
+ [ 'simple', { key: 2 } ]
+ ], 'Events after subscribing' );
+ } );
+
+ QUnit.test( 'trackSubscribe', function ( assert ) {
+ var now,
+ sequence = [];
+ mw.track( 'before', { key: 1 } );
+ mw.track( 'before', { key: 2 } );
+ mw.trackSubscribe( 'before', function ( topic, data ) {
+ sequence.push( [ topic, data ] );
+ } );
+ mw.track( 'before', { key: 3 } );
+
+ assert.deepEqual( sequence, [
+ [ 'before', { key: 1 } ],
+ [ 'before', { key: 2 } ],
+ [ 'before', { key: 3 } ]
+ ], 'Replay events from before subscribing' );
+
+ now = mw.now();
+ mw.track( 'context', { key: 0 } );
+ mw.trackSubscribe( 'context', function ( topic, data ) {
+ assert.strictEqual( this.topic, topic, 'thisValue has topic' );
+ assert.strictEqual( this.data, data, 'thisValue has data' );
+ assert.assertTrue( this.timeStamp >= now, 'thisValue has sane timestamp' );
+ } );
+ } );
+
+ QUnit.test( 'trackUnsubscribe', function ( assert ) {
+ var sequence = [];
+ function unsubber( topic, data ) {
+ sequence.push( [ topic, data ] );
+ }
+
+ mw.track( 'unsub', { key: 1 } );
+ mw.trackSubscribe( 'unsub', unsubber );
+ mw.track( 'unsub', { key: 2 } );
+ mw.trackUnsubscribe( unsubber );
+ mw.track( 'unsub', { key: 3 } );
+
+ assert.deepEqual( sequence, [
+ [ 'unsub', { key: 1 } ],
+ [ 'unsub', { key: 2 } ]
+ ], 'Stop when unsubscribing' );
+ } );
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js
new file mode 100644
index 00000000..814a2075
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js
@@ -0,0 +1,115 @@
+( function ( mw, $ ) {
+ QUnit.module( 'mediawiki.user', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.server = this.sandbox.useFakeServer();
+ this.crypto = window.crypto;
+ this.msCrypto = window.msCrypto;
+ },
+ teardown: function () {
+ if ( this.crypto ) {
+ window.crypto = this.crypto;
+ }
+ if ( this.msCrypto ) {
+ window.msCrypto = this.msCrypto;
+ }
+ }
+ } ) );
+
+ QUnit.test( 'options', function ( assert ) {
+ assert.ok( mw.user.options instanceof mw.Map, 'options instance of mw.Map' );
+ } );
+
+ QUnit.test( 'getters (anonymous)', function ( assert ) {
+ // Forge an anonymous user
+ mw.config.set( 'wgUserName', null );
+ mw.config.set( 'wgUserId', null );
+
+ assert.strictEqual( mw.user.getName(), null, 'getName()' );
+ assert.strictEqual( mw.user.isAnon(), true, 'isAnon()' );
+ assert.strictEqual( mw.user.getId(), 0, 'getId()' );
+ } );
+
+ QUnit.test( 'getters (logged-in)', function ( assert ) {
+ mw.config.set( 'wgUserName', 'John' );
+ mw.config.set( 'wgUserId', 123 );
+
+ assert.equal( mw.user.getName(), 'John', 'getName()' );
+ assert.strictEqual( mw.user.isAnon(), false, 'isAnon()' );
+ assert.strictEqual( mw.user.getId(), 123, 'getId()' );
+
+ assert.equal( mw.user.id(), 'John', 'user.id()' );
+ } );
+
+ QUnit.test( 'getUserInfo', function ( assert ) {
+ mw.config.set( 'wgUserGroups', [ '*', 'user' ] );
+
+ mw.user.getGroups( function ( groups ) {
+ assert.deepEqual( groups, [ '*', 'user' ], 'Result' );
+ } );
+
+ mw.user.getRights( function ( rights ) {
+ assert.deepEqual( rights, [ 'read', 'edit', 'createtalk' ], 'Result (callback)' );
+ } );
+
+ mw.user.getRights().done( function ( rights ) {
+ assert.deepEqual( rights, [ 'read', 'edit', 'createtalk' ], 'Result (promise)' );
+ } );
+
+ this.server.respondWith( /meta=userinfo/, function ( request ) {
+ request.respond( 200, { 'Content-Type': 'application/json' },
+ '{ "query": { "userinfo": { "groups": [ "unused" ], "rights": [ "read", "edit", "createtalk" ] } } }'
+ );
+ } );
+
+ this.server.respond();
+ } );
+
+ QUnit.test( 'generateRandomSessionId', function ( assert ) {
+ var result, result2;
+
+ result = mw.user.generateRandomSessionId();
+ assert.equal( typeof result, 'string', 'type' );
+ assert.equal( $.trim( result ), result, 'no whitespace at beginning or end' );
+ assert.equal( result.length, 16, 'size' );
+
+ result2 = mw.user.generateRandomSessionId();
+ assert.notEqual( result, result2, 'different when called multiple times' );
+
+ } );
+
+ QUnit.test( 'generateRandomSessionId (fallback)', function ( assert ) {
+ var result, result2;
+
+ // Pretend crypto API is not there to test the Math.random fallback
+ if ( window.crypto ) {
+ window.crypto = undefined;
+ }
+ if ( window.msCrypto ) {
+ window.msCrypto = undefined;
+ }
+
+ result = mw.user.generateRandomSessionId();
+ assert.equal( typeof result, 'string', 'type' );
+ assert.equal( $.trim( result ), result, 'no whitespace at beginning or end' );
+ assert.equal( result.length, 16, 'size' );
+
+ result2 = mw.user.generateRandomSessionId();
+ assert.notEqual( result, result2, 'different when called multiple times' );
+ } );
+
+ QUnit.test( 'stickyRandomId', function ( assert ) {
+ var result = mw.user.stickyRandomId(),
+ result2 = mw.user.stickyRandomId();
+ assert.equal( typeof result, 'string', 'type' );
+ assert.strictEqual( /^[a-f0-9]{16}$/.test( result ), true, '16 HEX symbols string' );
+ assert.equal( result2, result, 'sticky' );
+ } );
+
+ QUnit.test( 'sessionId', function ( assert ) {
+ var result = mw.user.sessionId(),
+ result2 = mw.user.sessionId();
+ assert.equal( typeof result, 'string', 'type' );
+ assert.equal( $.trim( result ), result, 'no leading or trailing whitespace' );
+ assert.equal( result2, result, 'retained' );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js
new file mode 100644
index 00000000..b8464e99
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js
@@ -0,0 +1,465 @@
+( function ( mw, $ ) {
+ var util = require( 'mediawiki.util' ),
+ // Based on IPTest.php > testisIPv4
+ IPV4_CASES = [
+ [ false, false, 'Boolean false is not an IP' ],
+ [ false, true, 'Boolean true is not an IP' ],
+ [ false, '', 'Empty string is not an IP' ],
+ [ false, 'abc', '"abc" is not an IP' ],
+ [ false, ':', 'Colon is not an IP' ],
+ [ false, '124.24.52', 'IPv4 not enough quads' ],
+ [ false, '24.324.52.13', 'IPv4 out of range' ],
+ [ false, '.24.52.13', 'IPv4 starts with period' ],
+
+ [ true, '124.24.52.13', '124.24.52.134 is a valid IP' ],
+ [ true, '1.24.52.13', '1.24.52.13 is a valid IP' ],
+ [ false, '74.24.52.13/20', 'IPv4 ranges are not recognized as valid IPs' ]
+ ],
+
+ // Based on IPTest.php > testisIPv6
+ IPV6_CASES = [
+ [ false, ':fc:100::', 'IPv6 starting with lone ":"' ],
+ [ false, 'fc:100:::', 'IPv6 ending with a ":::"' ],
+ [ false, 'fc:300', 'IPv6 with only 2 words' ],
+ [ false, 'fc:100:300', 'IPv6 with only 3 words' ],
+
+ [ false, 'fc:100:a:d:1:e:ac:0::', 'IPv6 with 8 words ending with "::"' ],
+ [ false, 'fc:100:a:d:1:e:ac:0:1::', 'IPv6 with 9 words ending with "::"' ],
+
+ [ false, ':::' ],
+ [ false, '::0:', 'IPv6 ending in a lone ":"' ],
+
+ [ true, '::', 'IPv6 zero address' ],
+
+ [ false, '::fc:100:a:d:1:e:ac:0', 'IPv6 with "::" and 8 words' ],
+ [ false, '::fc:100:a:d:1:e:ac:0:1', 'IPv6 with 9 words' ],
+
+ [ false, ':fc::100', 'IPv6 starting with lone ":"' ],
+ [ false, 'fc::100:', 'IPv6 ending with lone ":"' ],
+ [ false, 'fc:::100', 'IPv6 with ":::" in the middle' ],
+
+ [ true, 'fc::100', 'IPv6 with "::" and 2 words' ],
+ [ true, 'fc::100:a', 'IPv6 with "::" and 3 words' ],
+ [ true, 'fc::100:a:d', 'IPv6 with "::" and 4 words' ],
+ [ true, 'fc::100:a:d:1', 'IPv6 with "::" and 5 words' ],
+ [ true, 'fc::100:a:d:1:e', 'IPv6 with "::" and 6 words' ],
+ [ true, 'fc::100:a:d:1:e:ac', 'IPv6 with "::" and 7 words' ],
+ [ true, '2001::df', 'IPv6 with "::" and 2 words' ],
+ [ true, '2001:5c0:1400:a::df', 'IPv6 with "::" and 5 words' ],
+ [ true, '2001:5c0:1400:a::df:2', 'IPv6 with "::" and 6 words' ],
+
+ [ false, 'fc::100:a:d:1:e:ac:0', 'IPv6 with "::" and 8 words' ],
+ [ false, 'fc::100:a:d:1:e:ac:0:1', 'IPv6 with 9 words' ]
+ ];
+
+ Array.prototype.push.apply( IPV6_CASES,
+ [
+ 'fc:100::',
+ 'fc:100:a::',
+ 'fc:100:a:d::',
+ 'fc:100:a:d:1::',
+ 'fc:100:a:d:1:e::',
+ 'fc:100:a:d:1:e:ac::',
+ '::0',
+ '::fc',
+ '::fc:100',
+ '::fc:100:a',
+ '::fc:100:a:d',
+ '::fc:100:a:d:1',
+ '::fc:100:a:d:1:e',
+ '::fc:100:a:d:1:e:ac',
+ 'fc:100:a:d:1:e:ac:0'
+ ].map( function ( el ) {
+ return [ true, el, el + ' is a valid IP' ];
+ } )
+ );
+
+ QUnit.module( 'mediawiki.util', QUnit.newMwEnvironment( {
+ setup: function () {
+ $.fn.updateTooltipAccessKeys.setTestMode( true );
+ },
+ teardown: function () {
+ $.fn.updateTooltipAccessKeys.setTestMode( false );
+ },
+ messages: {
+ // Used by accessKeyLabel in test for addPortletLink
+ brackets: '[$1]',
+ 'word-separator': ' '
+ }
+ } ) );
+
+ QUnit.test( 'rawurlencode', function ( assert ) {
+ assert.equal( util.rawurlencode( 'Test:A & B/Here' ), 'Test%3AA%20%26%20B%2FHere' );
+ } );
+
+ QUnit.test( 'escapeId', function ( assert ) {
+ mw.config.set( 'wgFragmentMode', [ 'legacy' ] );
+ $.each( {
+ '+': '.2B',
+ '&': '.26',
+ '=': '.3D',
+ ':': ':',
+ ';': '.3B',
+ '@': '.40',
+ $: '.24',
+ '-_.': '-_.',
+ '!': '.21',
+ '*': '.2A',
+ '/': '.2F',
+ '[]': '.5B.5D',
+ '<>': '.3C.3E',
+ '\'': '.27',
+ '§': '.C2.A7',
+ 'Test:A & B/Here': 'Test:A_.26_B.2FHere',
+ 'A&B&amp;C&amp;amp;D&amp;amp;amp;E': 'A.26B.26amp.3BC.26amp.3Bamp.3BD.26amp.3Bamp.3Bamp.3BE'
+ }, function ( input, output ) {
+ assert.equal( util.escapeId( input ), output );
+ } );
+ } );
+
+ QUnit.test( 'escapeIdForAttribute', function ( assert ) {
+ // Test cases are kept in sync with SanitizerTest.php
+ var text = 'foo тест_#%!\'()[]:<>',
+ legacyEncoded = 'foo_.D1.82.D0.B5.D1.81.D1.82_.23.25.21.27.28.29.5B.5D:.3C.3E',
+ html5Encoded = 'foo_тест_#%!\'()[]:<>',
+ html5Experimental = 'foo_тест_!_()[]:<>',
+ // Settings: this is $wgFragmentMode
+ legacy = [ 'legacy' ],
+ legacyNew = [ 'legacy', 'html5' ],
+ newLegacy = [ 'html5', 'legacy' ],
+ allNew = [ 'html5' ],
+ experimentalLegacy = [ 'html5-legacy', 'legacy' ],
+ newExperimental = [ 'html5', 'html5-legacy' ];
+
+ // Test cases are kept in sync with SanitizerTest.php
+ [
+ // Pure legacy: how MW worked before 2017
+ [ legacy, text, legacyEncoded ],
+ // Transition to a new world: legacy links with HTML5 fallback
+ [ legacyNew, text, legacyEncoded ],
+ // New world: HTML5 links, legacy fallbacks
+ [ newLegacy, text, html5Encoded ],
+ // Distant future: no legacy fallbacks
+ [ allNew, text, html5Encoded ],
+ // Someone flipped $wgExperimentalHtmlIds on
+ [ experimentalLegacy, text, html5Experimental ],
+ // Migration from $wgExperimentalHtmlIds to modern HTML5
+ [ newExperimental, text, html5Encoded ]
+ ].forEach( function ( testCase ) {
+ mw.config.set( 'wgFragmentMode', testCase[ 0 ] );
+
+ assert.equal( util.escapeIdForAttribute( testCase[ 1 ] ), testCase[ 2 ] );
+ } );
+ } );
+
+ QUnit.test( 'escapeIdForLink', function ( assert ) {
+ // Test cases are kept in sync with SanitizerTest.php
+ var text = 'foo тест_#%!\'()[]:<>',
+ legacyEncoded = 'foo_.D1.82.D0.B5.D1.81.D1.82_.23.25.21.27.28.29.5B.5D:.3C.3E',
+ html5Encoded = 'foo_тест_#%!\'()[]:<>',
+ html5Experimental = 'foo_тест_!_()[]:<>',
+ // Settings: this is wgFragmentMode
+ legacy = [ 'legacy' ],
+ legacyNew = [ 'legacy', 'html5' ],
+ newLegacy = [ 'html5', 'legacy' ],
+ allNew = [ 'html5' ],
+ experimentalLegacy = [ 'html5-legacy', 'legacy' ],
+ newExperimental = [ 'html5', 'html5-legacy' ];
+
+ [
+ // Pure legacy: how MW worked before 2017
+ [ legacy, text, legacyEncoded ],
+ // Transition to a new world: legacy links with HTML5 fallback
+ [ legacyNew, text, legacyEncoded ],
+ // New world: HTML5 links, legacy fallbacks
+ [ newLegacy, text, html5Encoded ],
+ // Distant future: no legacy fallbacks
+ [ allNew, text, html5Encoded ],
+ // Someone flipped wgExperimentalHtmlIds on
+ [ experimentalLegacy, text, html5Experimental ],
+ // Migration from wgExperimentalHtmlIds to modern HTML5
+ [ newExperimental, text, html5Encoded ]
+ ].forEach( function ( testCase ) {
+ mw.config.set( 'wgFragmentMode', testCase[ 0 ] );
+
+ assert.equal( util.escapeIdForLink( testCase[ 1 ] ), testCase[ 2 ] );
+ } );
+ } );
+
+ QUnit.test( 'wikiUrlencode', function ( assert ) {
+ assert.equal( util.wikiUrlencode( 'Test:A & B/Here' ), 'Test:A_%26_B/Here' );
+ // See also wfUrlencodeTest.php#provideURLS
+ $.each( {
+ '+': '%2B',
+ '&': '%26',
+ '=': '%3D',
+ ':': ':',
+ ';@$-_.!*': ';@$-_.!*',
+ '/': '/',
+ '~': '~',
+ '[]': '%5B%5D',
+ '<>': '%3C%3E',
+ '\'': '%27'
+ }, function ( input, output ) {
+ assert.equal( util.wikiUrlencode( input ), output );
+ } );
+ } );
+
+ QUnit.test( 'getUrl', function ( assert ) {
+ var href;
+ mw.config.set( {
+ wgScript: '/w/index.php',
+ wgArticlePath: '/wiki/$1',
+ wgPageName: 'Foobar'
+ } );
+
+ href = util.getUrl( 'Sandbox' );
+ assert.equal( href, '/wiki/Sandbox', 'simple title' );
+
+ href = util.getUrl( 'Foo:Sandbox? 5+5=10! (test)/sub ' );
+ assert.equal( href, '/wiki/Foo:Sandbox%3F_5%2B5%3D10!_(test)/sub_', 'complex title' );
+
+ // T149767
+ href = util.getUrl( 'My$$test$$$$$title' );
+ assert.equal( href, '/wiki/My$$test$$$$$title', 'title with multiple consecutive dollar signs' );
+
+ href = util.getUrl();
+ assert.equal( href, '/wiki/Foobar', 'default title' );
+
+ href = util.getUrl( null, { action: 'edit' } );
+ assert.equal( href, '/w/index.php?title=Foobar&action=edit', 'default title with query string' );
+
+ href = util.getUrl( 'Sandbox', { action: 'edit' } );
+ assert.equal( href, '/w/index.php?title=Sandbox&action=edit', 'simple title with query string' );
+
+ // Test fragments
+ href = util.getUrl( 'Foo:Sandbox#Fragment', { action: 'edit' } );
+ assert.equal( href, '/w/index.php?title=Foo:Sandbox&action=edit#Fragment', 'namespaced title with query string and fragment' );
+
+ href = util.getUrl( 'Sandbox#', { action: 'edit' } );
+ assert.equal( href, '/w/index.php?title=Sandbox&action=edit', 'title with query string and empty fragment' );
+
+ href = util.getUrl( 'Sandbox', {} );
+ assert.equal( href, '/wiki/Sandbox', 'title with empty query string' );
+
+ href = util.getUrl( '#Fragment' );
+ assert.equal( href, '/wiki/#Fragment', 'empty title with fragment' );
+
+ href = util.getUrl( '#Fragment', { action: 'edit' } );
+ assert.equal( href, '/w/index.php?action=edit#Fragment', 'empty title with query string and fragment' );
+
+ mw.config.set( 'wgFragmentMode', [ 'legacy' ] );
+ href = util.getUrl( 'Foo:Sandbox \xC4#Fragment \xC4', { action: 'edit' } );
+ assert.equal( href, '/w/index.php?title=Foo:Sandbox_%C3%84&action=edit#Fragment_.C3.84', 'title with query string, fragment, and special characters' );
+
+ mw.config.set( 'wgFragmentMode', [ 'html5' ] );
+ href = util.getUrl( 'Foo:Sandbox \xC4#Fragment \xC4', { action: 'edit' } );
+ assert.equal( href, '/w/index.php?title=Foo:Sandbox_%C3%84&action=edit#Fragment_Ä', 'title with query string, fragment, and special characters' );
+
+ href = util.getUrl( 'Foo:%23#Fragment', { action: 'edit' } );
+ assert.equal( href, '/w/index.php?title=Foo:%2523&action=edit#Fragment', 'title containing %23 (#), fragment, and a query string' );
+
+ mw.config.set( 'wgFragmentMode', [ 'legacy' ] );
+ href = util.getUrl( '#+&=:;@$-_.!*/[]<>\'§', { action: 'edit' } );
+ assert.equal( href, '/w/index.php?action=edit#.2B.26.3D:.3B.40.24-_..21.2A.2F.5B.5D.3C.3E.27.C2.A7', 'fragment with various characters' );
+
+ mw.config.set( 'wgFragmentMode', [ 'html5' ] );
+ href = util.getUrl( '#+&=:;@$-_.!*/[]<>\'§', { action: 'edit' } );
+ assert.equal( href, '/w/index.php?action=edit#+&=:;@$-_.!*/[]<>\'§', 'fragment with various characters' );
+ } );
+
+ QUnit.test( 'wikiScript', function ( assert ) {
+ mw.config.set( {
+ // customized wgScript for T41103
+ wgScript: '/w/i.php',
+ // customized wgLoadScript for T41103
+ wgLoadScript: '/w/l.php',
+ wgScriptPath: '/w'
+ } );
+
+ assert.equal( util.wikiScript(), mw.config.get( 'wgScript' ),
+ 'wikiScript() returns wgScript'
+ );
+ assert.equal( util.wikiScript( 'index' ), mw.config.get( 'wgScript' ),
+ 'wikiScript( index ) returns wgScript'
+ );
+ assert.equal( util.wikiScript( 'load' ), mw.config.get( 'wgLoadScript' ),
+ 'wikiScript( load ) returns wgLoadScript'
+ );
+ assert.equal( util.wikiScript( 'api' ), '/w/api.php', 'API path' );
+ } );
+
+ QUnit.test( 'addCSS', function ( assert ) {
+ var $el, style;
+ $el = $( '<div>' ).attr( 'id', 'mw-addcsstest' ).appendTo( '#qunit-fixture' );
+
+ style = util.addCSS( '#mw-addcsstest { visibility: hidden; }' );
+ assert.equal( typeof style, 'object', 'addCSS returned an object' );
+ assert.strictEqual( style.disabled, false, 'property "disabled" is available and set to false' );
+
+ assert.equal( $el.css( 'visibility' ), 'hidden', 'Added style properties are in effect' );
+
+ // Clean up
+ $( style.ownerNode ).remove();
+ } );
+
+ QUnit.test( 'getParamValue', function ( assert ) {
+ var url;
+
+ url = 'http://example.org/?foo=wrong&foo=right#&foo=bad';
+ assert.equal( util.getParamValue( 'foo', url ), 'right', 'Use latest one, ignore hash' );
+ assert.strictEqual( util.getParamValue( 'bar', url ), null, 'Return null when not found' );
+
+ url = 'http://example.org/#&foo=bad';
+ assert.strictEqual( util.getParamValue( 'foo', url ), null, 'Ignore hash if param is not in querystring but in hash (T29427)' );
+
+ url = 'example.org?' + $.param( { TEST: 'a b+c' } );
+ assert.strictEqual( util.getParamValue( 'TEST', url ), 'a b+c', 'T32441: getParamValue must understand "+" encoding of space' );
+
+ url = 'example.org?' + $.param( { TEST: 'a b+c d' } ); // check for sloppy code from r95332 :)
+ assert.strictEqual( util.getParamValue( 'TEST', url ), 'a b+c d', 'T32441: getParamValue must understand "+" encoding of space (multiple spaces)' );
+ } );
+
+ QUnit.test( '$content', function ( assert ) {
+ assert.ok( util.$content instanceof jQuery, 'mw.util.$content instance of jQuery' );
+ assert.strictEqual( util.$content.length, 1, 'mw.util.$content must have length of 1' );
+ } );
+
+ /**
+ * Portlet names are prefixed with 'p-test' to avoid conflict with core
+ * when running the test suite under a wiki page.
+ * Previously, test elements where invisible to the selector since only
+ * one element can have a given id.
+ */
+ QUnit.test( 'addPortletLink', function ( assert ) {
+ var pTestTb, pCustom, vectorTabs, tbRL, cuQuux, $cuQuux, tbMW, $tbMW, tbRLDM, caFoo,
+ addedAfter, tbRLDMnonexistentid, tbRLDMemptyjquery;
+
+ pTestTb =
+ '<div class="portlet" id="p-test-tb">' +
+ '<h3>Toolbox</h3>' +
+ '<ul class="body"></ul>' +
+ '</div>';
+ pCustom =
+ '<div class="portlet" id="p-test-custom">' +
+ '<h3>Views</h3>' +
+ '<ul class="body">' +
+ '<li id="c-foo"><a href="#">Foo</a></li>' +
+ '<li id="c-barmenu">' +
+ '<ul>' +
+ '<li id="c-bar-baz"><a href="#">Baz</a></a>' +
+ '</ul>' +
+ '</li>' +
+ '</ul>' +
+ '</div>';
+ vectorTabs =
+ '<div id="p-test-views" class="vectorTabs">' +
+ '<h3>Views</h3>' +
+ '<ul></ul>' +
+ '</div>';
+
+ $( '#qunit-fixture' ).append( pTestTb, pCustom, vectorTabs );
+
+ tbRL = util.addPortletLink( 'p-test-tb', '//mediawiki.org/wiki/ResourceLoader',
+ 'ResourceLoader', 't-rl', 'More info about ResourceLoader on MediaWiki.org ', 'l'
+ );
+
+ assert.ok( tbRL && tbRL.nodeType, 'addPortletLink returns a DOM Node' );
+
+ tbMW = util.addPortletLink( 'p-test-tb', '//mediawiki.org/',
+ 'MediaWiki.org', 't-mworg', 'Go to MediaWiki.org', 'm', tbRL );
+ $tbMW = $( tbMW );
+
+ assert.propEqual(
+ $tbMW.getAttrs(),
+ {
+ id: 't-mworg'
+ },
+ 'Validate attributes of created element'
+ );
+
+ assert.propEqual(
+ $tbMW.find( 'a' ).getAttrs(),
+ {
+ href: '//mediawiki.org/',
+ title: 'Go to MediaWiki.org [test-m]',
+ accesskey: 'm'
+ },
+ 'Validate attributes of anchor tag in created element'
+ );
+
+ assert.equal( $tbMW.closest( '.portlet' ).attr( 'id' ), 'p-test-tb', 'Link was inserted within correct portlet' );
+ assert.strictEqual( $tbMW.next()[ 0 ], tbRL, 'Link is in the correct position (nextnode as Node object)' );
+
+ cuQuux = util.addPortletLink( 'p-test-custom', '#', 'Quux', null, 'Example [shift-x]', 'q' );
+ $cuQuux = $( cuQuux );
+
+ assert.equal( $cuQuux.find( 'a' ).attr( 'title' ), 'Example [test-q]', 'Existing accesskey is stripped and updated' );
+
+ assert.equal(
+ $( '#p-test-custom #c-barmenu ul li' ).length,
+ 1,
+ 'addPortletLink did not add the item to all <ul> elements in the portlet (T37082)'
+ );
+
+ tbRLDM = util.addPortletLink( 'p-test-tb', '//mediawiki.org/wiki/RL/DM',
+ 'Default modules', 't-rldm', 'List of all default modules ', 'd', '#t-rl' );
+
+ assert.strictEqual( $( tbRLDM ).next()[ 0 ], tbRL, 'Link is in the correct position (CSS selector as nextnode)' );
+
+ caFoo = util.addPortletLink( 'p-test-views', '#', 'Foo' );
+
+ assert.strictEqual( $tbMW.find( 'span' ).length, 0, 'No <span> element should be added for porlets without vectorTabs class.' );
+ assert.strictEqual( $( caFoo ).find( 'span' ).length, 1, 'A <span> element should be added for porlets with vectorTabs class.' );
+
+ addedAfter = util.addPortletLink( 'p-test-tb', '#', 'After foo', 'post-foo', 'After foo', null, $( tbRL ) );
+ assert.strictEqual( $( addedAfter ).next()[ 0 ], tbRL, 'Link is in the correct position (jQuery object as nextnode)' );
+
+ // test case - nonexistent id as next node
+ tbRLDMnonexistentid = util.addPortletLink( 'p-test-tb', '//mediawiki.org/wiki/RL/DM',
+ 'Default modules', 't-rldm-nonexistent', 'List of all default modules ', 'd', '#t-rl-nonexistent' );
+
+ assert.equal( tbRLDMnonexistentid, $( '#p-test-tb li:last' )[ 0 ], 'Fallback to adding at the end (nextnode non-matching CSS selector)' );
+
+ // test case - empty jquery object as next node
+ tbRLDMemptyjquery = util.addPortletLink( 'p-test-tb', '//mediawiki.org/wiki/RL/DM',
+ 'Default modules', 't-rldm-empty-jquery', 'List of all default modules ', 'd', $( '#t-rl-nonexistent' ) );
+
+ assert.equal( tbRLDMemptyjquery, $( '#p-test-tb li:last' )[ 0 ], 'Fallback to adding at the end (nextnode as empty jQuery object)' );
+ } );
+
+ QUnit.test( 'validateEmail', function ( assert ) {
+ assert.strictEqual( util.validateEmail( '' ), null, 'Should return null for empty string ' );
+ assert.strictEqual( util.validateEmail( 'user@localhost' ), true, 'Return true for a valid e-mail address' );
+
+ // testEmailWithCommasAreInvalids
+ assert.strictEqual( util.validateEmail( 'user,foo@example.org' ), false, 'Emails with commas are invalid' );
+ assert.strictEqual( util.validateEmail( 'userfoo@ex,ample.org' ), false, 'Emails with commas are invalid' );
+
+ // testEmailWithHyphens
+ assert.strictEqual( util.validateEmail( 'user-foo@example.org' ), true, 'Emails may contain a hyphen' );
+ assert.strictEqual( util.validateEmail( 'userfoo@ex-ample.org' ), true, 'Emails may contain a hyphen' );
+ } );
+
+ QUnit.test( 'isIPv6Address', function ( assert ) {
+ IPV6_CASES.forEach( function ( ipCase ) {
+ assert.strictEqual( util.isIPv6Address( ipCase[ 1 ] ), ipCase[ 0 ], ipCase[ 2 ] );
+ } );
+ } );
+
+ QUnit.test( 'isIPv4Address', function ( assert ) {
+ IPV4_CASES.forEach( function ( ipCase ) {
+ assert.strictEqual( util.isIPv4Address( ipCase[ 1 ] ), ipCase[ 0 ], ipCase[ 2 ] );
+ } );
+ } );
+
+ QUnit.test( 'isIPAddress', function ( assert ) {
+ IPV4_CASES.forEach( function ( ipCase ) {
+ assert.strictEqual( util.isIPv4Address( ipCase[ 1 ] ), ipCase[ 0 ], ipCase[ 2 ] );
+ } );
+
+ IPV6_CASES.forEach( function ( ipCase ) {
+ assert.strictEqual( util.isIPv6Address( ipCase[ 1 ] ), ipCase[ 0 ], ipCase[ 2 ] );
+ } );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js
new file mode 100644
index 00000000..98641662
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js
@@ -0,0 +1,112 @@
+( function ( mw, $ ) {
+
+ // Simulate square element with 20px long edges placed at (20, 20) on the page
+ var
+ DEFAULT_VIEWPORT = {
+ top: 0,
+ left: 0,
+ right: 100,
+ bottom: 100
+ };
+
+ QUnit.module( 'mediawiki.viewport', QUnit.newMwEnvironment( {
+ setup: function () {
+ this.el = $( '<div />' )
+ .appendTo( '#qunit-fixture' )
+ .width( 20 )
+ .height( 20 )
+ .offset( {
+ top: 20,
+ left: 20
+ } )
+ .get( 0 );
+ this.sandbox.stub( mw.viewport, 'makeViewportFromWindow' )
+ .returns( DEFAULT_VIEWPORT );
+ }
+ } ) );
+
+ QUnit.test( 'isElementInViewport', function ( assert ) {
+ var viewport = $.extend( {}, DEFAULT_VIEWPORT );
+ assert.ok( mw.viewport.isElementInViewport( this.el, viewport ),
+ 'It should return true when the element is fully enclosed in the viewport' );
+
+ viewport.right = 20;
+ viewport.bottom = 20;
+ assert.ok( mw.viewport.isElementInViewport( this.el, viewport ),
+ 'It should return true when only the top-left of the element is within the viewport' );
+
+ viewport.top = 40;
+ viewport.left = 40;
+ viewport.right = 50;
+ viewport.bottom = 50;
+ assert.ok( mw.viewport.isElementInViewport( this.el, viewport ),
+ 'It should return true when only the bottom-right is within the viewport' );
+
+ viewport.top = 30;
+ viewport.left = 30;
+ viewport.right = 35;
+ viewport.bottom = 35;
+ assert.ok( mw.viewport.isElementInViewport( this.el, viewport ),
+ 'It should return true when the element encapsulates the viewport' );
+
+ viewport.top = 0;
+ viewport.left = 0;
+ viewport.right = 19;
+ viewport.bottom = 19;
+ assert.notOk( mw.viewport.isElementInViewport( this.el, viewport ),
+ 'It should return false when the element is not within the viewport' );
+
+ assert.ok( mw.viewport.isElementInViewport( this.el ),
+ 'It should default to the window object if no viewport is given' );
+ } );
+
+ QUnit.test( 'isElementInViewport with scrolled page', function ( assert ) {
+ var viewport = {
+ top: 2000,
+ left: 0,
+ right: 1000,
+ bottom: 2500
+ },
+ el = $( '<div />' )
+ .appendTo( '#qunit-fixture' )
+ .width( 20 )
+ .height( 20 )
+ .offset( {
+ top: 2300,
+ left: 20
+ } )
+ .get( 0 );
+ window.scrollTo( viewport.left, viewport.top );
+ assert.ok( mw.viewport.isElementInViewport( el, viewport ),
+ 'It should return true when the element is fully enclosed in the ' +
+ 'viewport even when the page is scrolled down' );
+ window.scrollTo( 0, 0 );
+ } );
+
+ QUnit.test( 'isElementCloseToViewport', function ( assert ) {
+ var
+ viewport = {
+ top: 90,
+ left: 90,
+ right: 100,
+ bottom: 100
+ },
+ distantElement = $( '<div />' )
+ .appendTo( '#qunit-fixture' )
+ .width( 20 )
+ .height( 20 )
+ .offset( {
+ top: 220,
+ left: 20
+ } )
+ .get( 0 );
+
+ assert.ok( mw.viewport.isElementCloseToViewport( this.el, 60, viewport ),
+ 'It should return true when the element is within the given threshold away' );
+ assert.notOk( mw.viewport.isElementCloseToViewport( this.el, 20, viewport ),
+ 'It should return false when the element is further than the given threshold away' );
+ assert.notOk( mw.viewport.isElementCloseToViewport( distantElement ),
+ 'It should default to a threshold of 50px and the window\'s viewport' );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js
new file mode 100644
index 00000000..7f8819de
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js
@@ -0,0 +1,115 @@
+( function ( mw ) {
+
+ QUnit.module( 'mediawiki.visibleTimeout', QUnit.newMwEnvironment( {
+ setup: function () {
+ // Document with just enough stuff to make the tests work.
+ var listeners = [];
+ this.mockDocument = {
+ hidden: false,
+ addEventListener: function ( type, listener ) {
+ if ( type === 'visibilitychange' ) {
+ listeners.push( listener );
+ }
+ },
+ removeEventListener: function ( type, listener ) {
+ var i;
+ if ( type === 'visibilitychange' ) {
+ i = listeners.indexOf( listener );
+ if ( i >= 0 ) {
+ listeners.splice( i, 1 );
+ }
+ }
+ },
+ // Helper function to swap visibility and run listeners
+ toggleVisibility: function () {
+ var i;
+ this.hidden = !this.hidden;
+ for ( i = 0; i < listeners.length; i++ ) {
+ listeners[ i ]();
+ }
+ }
+ };
+ this.visibleTimeout = require( 'mediawiki.visibleTimeout' );
+ this.visibleTimeout.setDocument( this.mockDocument );
+
+ this.sandbox.useFakeTimers();
+ // mw.now() doesn't respect the fake clock injected by useFakeTimers
+ this.stub( mw, 'now', ( function () {
+ return this.sandbox.clock.now;
+ } ).bind( this ) );
+ }
+ } ) );
+
+ QUnit.test( 'basic usage', function ( assert ) {
+ var called = 0;
+
+ this.visibleTimeout.set( function () {
+ called++;
+ }, 0 );
+ assert.strictEqual( called, 0 );
+ this.sandbox.clock.tick( 1 );
+ assert.strictEqual( called, 1 );
+
+ this.sandbox.clock.tick( 100 );
+ assert.strictEqual( called, 1 );
+
+ this.visibleTimeout.set( function () {
+ called++;
+ }, 10 );
+ this.sandbox.clock.tick( 10 );
+ assert.strictEqual( called, 2 );
+ } );
+
+ QUnit.test( 'can cancel timeout', function ( assert ) {
+ var called = 0,
+ timeout = this.visibleTimeout.set( function () {
+ called++;
+ }, 0 );
+
+ this.visibleTimeout.clear( timeout );
+ this.sandbox.clock.tick( 10 );
+ assert.strictEqual( called, 0 );
+
+ timeout = this.visibleTimeout.set( function () {
+ called++;
+ }, 100 );
+ this.sandbox.clock.tick( 50 );
+ assert.strictEqual( called, 0 );
+ this.visibleTimeout.clear( timeout );
+ this.sandbox.clock.tick( 100 );
+ assert.strictEqual( called, 0 );
+ } );
+
+ QUnit.test( 'start hidden and become visible', function ( assert ) {
+ var called = 0;
+
+ this.mockDocument.hidden = true;
+ this.visibleTimeout.set( function () {
+ called++;
+ }, 0 );
+ this.sandbox.clock.tick( 10 );
+ assert.strictEqual( called, 0 );
+
+ this.mockDocument.toggleVisibility();
+ this.sandbox.clock.tick( 10 );
+ assert.strictEqual( called, 1 );
+ } );
+
+ QUnit.test( 'timeout is cumulative', function ( assert ) {
+ var called = 0;
+
+ this.visibleTimeout.set( function () {
+ called++;
+ }, 100 );
+ this.sandbox.clock.tick( 50 );
+ assert.strictEqual( called, 0 );
+
+ this.mockDocument.toggleVisibility();
+ this.sandbox.clock.tick( 1000 );
+ assert.strictEqual( called, 0 );
+
+ this.mockDocument.toggleVisibility();
+ this.sandbox.clock.tick( 50 );
+ assert.strictEqual( called, 1 );
+ } );
+}( mediaWiki ) );
diff --git a/www/wiki/tests/qunit/suites/resources/startup.test.js b/www/wiki/tests/qunit/suites/resources/startup.test.js
new file mode 100644
index 00000000..6a704b5a
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/startup.test.js
@@ -0,0 +1,160 @@
+/* global isCompatible: true */
+( function () {
+ var testcases = {
+ tested: [
+ /* Grade A */
+
+ // Chrome
+ 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_7; en-US) AppleWebKit/534.16 (KHTML, like Gecko) Chrome/10.0.648.205 Safari/534.16',
+ // Firefox 4+
+ 'Mozilla/5.0 (Windows NT 6.1.1; rv:5.0) Gecko/20100101 Firefox/5.0',
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:9.0) Gecko/20100101 Firefox/9.0',
+ 'Mozilla/5.0 (Macintosh; I; Intel Mac OS X 11_7_9; de-LI; rv:1.9b4) Gecko/2012010317 Firefox/10.0a4',
+ 'Mozilla/5.0 (X11; Linux i686; rv:10.0) Gecko/20100101 Firefox/10.0',
+ 'Mozilla/5.0 (Windows NT 6.1; rv:12.0) Gecko/20120403211507 Firefox/12.0',
+ 'Mozilla/5.0 (Windows NT 6.2; Win64; x64; rv:16.0.1) Gecko/20121011 Firefox/16.0.1',
+ // Kindle Fire
+ 'Mozilla/5.0 (Linux; U; Android 2.3.4; en-us; Kindle Fire Build/GINGERBREAD) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Safari/533.1',
+ // Safari 5.0+
+ 'Mozilla/5.0 (Macintosh; I; Intel Mac OS X 10_6_7; ru-ru) AppleWebKit/534.31+ (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1',
+ // Opera 15+ (Chromium-based)
+ 'Mozilla/5.0 (Windows NT 6.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.95 Safari/537.36 OPR/15.0.1147.153',
+ 'Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.57 Safari/537.36 OPR/16.0.1196.62',
+ 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/36.0.1985.125 Safari/537.36 OPR/23.0.1522.75',
+ // Internet Explorer 11
+ 'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko',
+ // Edge
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246',
+ // Edge Mobile
+ 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 640 XL LTE) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Mobile Safari/537.36 Edge/12.10166',
+ // BlackBerry 6+
+ 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9300; en) AppleWebKit/534.8+ (KHTML, like Gecko) Version/6.0.0.570 Mobile Safari/534.8+',
+ 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ (KHTML, like Gecko) Version/7.1.0.346 Mobile Safari/534.11+',
+ 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.3+ (KHTML, like Gecko) Version/10.0.9.386 Mobile Safari/537.3+',
+ // Open WebOS 1.4+ (HP Veer 4G)
+ 'Mozilla/5.0 (webOS/2.1.2; U; en-US) AppleWebKit/532.2 (KHTML, like Gecko) Version/1.0 Safari/532.2 P160UNA/1.0',
+ // Firefox Mobile
+ 'Mozilla/5.0 (Mobile; rv:14.0) Gecko/14.0 Firefox/14.0',
+ // iOS
+ 'Mozilla/5.0 (ipod: U;CPU iPhone OS 2_2 like Mac OS X: es_es) AppleWebKit/525.18.1 (KHTML, like Gecko) Version/3.0 Mobile/3B48b Safari/419.3',
+ 'Mozilla/5.0 (iPhone; U; CPU like Mac OS X; en) AppleWebKit/420.1 (KHTML, like Gecko) Version/3.0 Mobile/3B48b Safari/419.3',
+ // Android
+ 'Mozilla/5.0 (Linux; U; Android 2.1; en-us; Nexus One Build/ERD62) AppleWebKit/530.17 (KHTML, like Gecko) Version/4.0 Mobile Safari/530.17',
+ // UC Mini (speed mode off)
+ 'Mozilla/5.0 (Linux; U; Android 6.0.1; en-US; Nexus_5 Build/MMB29S) AppleWebKit/528.5+ (KHTML, like Gecko) Version/3.1.2 Mobile Safari/525.20.1 UCBrowser/10.7.6.805 Mobile',
+
+ /* Grade C */
+
+ // Internet Explorer < 10
+ 'Mozilla/2.0 (compatible; MSIE 3.03; Windows 3.1)',
+ 'Mozilla/4.0 (compatible; MSIE 4.01; Windows 95)',
+ 'Mozilla/4.0 (compatible; MSIE 5.0; Windows 98;)',
+ 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)',
+ 'Mozilla/5.0 (compatible; MSIE 6.0; Windows NT 5.1)',
+ 'Mozilla/5.0 (compatible; MSIE 7.0; Windows NT 6.0; en-US)',
+ 'Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; Media Center PC 4.0; SLCC1; .NET CLR 3.0.04320)',
+ 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 7.1; Trident/5.0)',
+ // Firefox < 4
+ 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.0.2) Gecko/20060308 Firefox/1.5.0.2',
+ 'Mozilla/5.0 (X11; U; Linux i686; nl; rv:1.8.1.1) Gecko/20070311 Firefox/2.0.0.1',
+ 'Mozilla/5.0 (Windows; U; Windows NT 6.1; ru; rv:1.9.2.3) Gecko/20100401 Firefox/3.6.3',
+ // Opera < 15 (Presto-based)
+ 'Mozilla/5.0 (Windows NT 5.0; U) Opera 7.54 [en]',
+ 'Opera/7.54 (Windows NT 5.0; U) [en]',
+ 'Mozilla/5.0 (Windows NT 5.1; U; en) Opera 8.0',
+ 'Opera/8.0 (X11; Linux i686; U; cs)',
+ 'Opera/9.00 (X11; Linux i686; U; de)',
+ 'Opera/9.62 (X11; Linux i686; U; en) Presto/2.1.1',
+ 'Opera/9.80 (Windows NT 6.1; U; en) Presto/2.2.15 Version/10.00',
+ 'Opera/9.80 (Windows NT 6.1; U; ru) Presto/2.8.131 Version/11.10',
+ 'Opera/9.80 (Windows NT 6.1; WOW64; U; pt) Presto/2.10.229 Version/11.62',
+ 'Opera/9.80 (Windows NT 6.1; U; es-ES) Presto/2.9.181 Version/12.00',
+ 'Opera/9.80 (Windows NT 5.1) Presto/2.12.388 Version/12.17',
+ // BlackBerry < 6
+ 'BlackBerry9300/5.0.0.716 Profile/MIDP-2.1 Configuration/CLDC-1.1 VendorID/133',
+ 'BlackBerry7250/4.0.0 Profile/MIDP-2.0 Configuration/CLDC-1.1',
+
+ /* Grade X */
+
+ // Gecko
+ 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.0.7) Gecko/20060928 (Debian|Debian-1.8.0.7-1) Epiphany/2.14',
+ 'Mozilla/5.0 (X11; U; Linux i686 (x86_64); en-US; rv:1.8.1.6) Gecko/20070817 IceWeasel/2.0.0.6-g2',
+ // KHTML
+ 'Mozilla/5.0 (compatible; Konqueror/3.5; Linux) KHTML/3.5.4 (like Gecko)',
+ 'Mozilla/5.0 (compatible; Konqueror/4.3; Linux) KHTML/4.3.5 (like Gecko)',
+ // Text browsers
+ 'Links (2.1pre33; Darwin 8.11.0 Power Macintosh; x)',
+ 'Links (6.9; Unix 6.9-astral sparc; 80x25)',
+ 'Lynx/2.8.6rel.4 libwww-FM/2.14 SSL-MM/1.4.1 OpenSSL/0.9.8g',
+ 'w3m/0.5.1',
+ // Bots
+ 'Googlebot/2.1 (+http://www.google.com/bot.html)',
+ 'Mozilla/5.0 (compatible; googlebot/2.1; +http://www.google.com/bot.html)',
+ 'Mozilla/5.0 (compatible; YandexBot/3.0)',
+ // Scripts
+ 'curl/7.21.4 (universal-apple-darwin11.0) libcurl/7.21.4 OpenSSL/0.9.8r zlib/1.2.5',
+ 'Wget/1.9',
+ 'Wget/1.10.1 (Red Hat modified)',
+ // Unknown
+ 'I\'m an unknown browser',
+ 'I\'m an unknown Glass browser',
+ // Empty
+ ''
+ ],
+ blacklisted: [
+ /* Grade C */
+
+ // Internet Explorer 10
+ 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)',
+ // IE Mobile 10
+ 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; HTC; Windows Phone 8X by HTC)',
+ // PlayStation
+ 'Mozilla/5.0 (PLAYSTATION 3; 1.10)',
+ 'Mozilla/5.0 (PLAYSTATION 3; 3.55)',
+ 'Mozilla/5.0 (PLAYSTATION 3 4.21) AppleWebKit/531.22.8 (KHTML, like Gecko)',
+ 'Mozilla/5.0 (PlayStation 4 1.70) AppleWebKit/536.26 (KHTML, like Gecko)',
+ // Open WebOS < 1.5 (Palm Pre, Palm Pixi)
+ 'Mozilla/5.0 (webOS/1.0; U; en-US) AppleWebKit/525.27.1 (KHTML, like Gecko) Version/1.0 Safari/525.27.1 Pre/1.0',
+ 'Mozilla/5.0 (webOS/1.4.0; U; en-US) AppleWebKit/532.2 (KHTML, like Gecko) Version/1.0 Safari/532.2 Pixi/1.1 ',
+ // SymbianOS
+ 'NokiaN95_8GB-3;Mozilla/5.0 SymbianOS/9.2;U;Series60/3.1 NokiaN95_8GB-3/11.2.011 Profile/MIDP-2.0 Configuration/CLDC-1.1 AppleWebKit/413 (KHTML, like Gecko)',
+ 'Nokia7610/2.0 (5.0509.0) SymbianOS/7.0s Series60/2.1 Profile/MIDP-2.0 Configuration/CLDC-1.0 ',
+ 'Mozilla/5.0 (SymbianOS/9.1; U; [en]; SymbianOS/91 Series60/3.0) AppleWebKit/413 (KHTML, like Gecko) Safari/413',
+ 'Mozilla/5.0 (SymbianOS/9.3; Series60/3.2 NokiaE52-2/091.003; Profile/MIDP-2.1 Configuration/CLDC-1.1 ) AppleWebKit/533.4 (KHTML, like Gecko) NokiaBrowser/7.3.1.34 Mobile Safari/533.4',
+ // NetFront
+ 'Mozilla/4.0 (compatible; Linux 2.6.10) NetFront/3.3 Kindle/1.0 (screen 600x800)',
+ 'Mozilla/4.0 (compatible; Linux 2.6.22) NetFront/3.4 Kindle/2.0 (screen 824x1200; rotate)',
+ 'Mozilla/4.08 (Windows; Mobile Content Viewer/1.0) NetFront/3.2',
+ // Opera Mini
+ 'Opera/9.80 (J2ME/MIDP; Opera Mini/3.1.10423/22.387; U; en) Presto/2.5.25 Version/10.54',
+ 'Opera/9.50 (J2ME/MIDP; Opera Mini/4.0.10031/298; U; en)',
+ 'Opera/9.80 (J2ME/MIDP; Opera Mini/6.24093/26.1305; U; en) Presto/2.8.119 Version/10.54',
+ 'Opera/9.80 (Android; Opera Mini/7.29530/27.1407; U; en) Presto/2.8.119 Version/11.10',
+ // Ovi Browser
+ 'Mozilla/5.0 (Series40; NokiaX3-02/05.60; Profile/MIDP-2.1 Configuration/CLDC-1.1) Gecko/20100401 S40OviBrowser/3.2.0.0.6',
+ 'Mozilla/5.0 (Series40; Nokia305/05.92; Profile/MIDP-2.1 Configuration/CLDC-1.1) Gecko/20100401 S40OviBrowser/3.7.0.0.11',
+ // Google Glass
+ 'Mozilla/5.0 (Linux; U; Android 4.0.4; en-us; Glass 1 Build/IMM76L; XE11) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
+ // MeeGo
+ 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13',
+ // UC Mini (speed mode on)
+ 'Mozilla/5.0 (X11; U; Linux i686; zh-CN; r:1.2.3.4) Gecko/',
+ // Google Web Light proxy
+ 'Mozilla/5.0 (Linux; Android 4.2.1; en-us; Nexus 5 Build/JOP40D) AppleWebKit/535.19 (KHTML, like Gecko; googleweblight) Chrome/38.0.1025.166 Mobile Safari/535.19'
+ ]
+ };
+
+ QUnit.module( 'startup', QUnit.newMwEnvironment() );
+
+ QUnit.test( 'isCompatible( featureTestable )', function ( assert ) {
+ testcases.tested.forEach( function ( ua ) {
+ assert.strictEqual( isCompatible( ua ), true, ua );
+ } );
+ } );
+
+ QUnit.test( 'isCompatible( blacklisted )', function ( assert ) {
+ testcases.blacklisted.forEach( function ( ua ) {
+ assert.strictEqual( isCompatible( ua ), false, ua );
+ } );
+ } );
+}() );
diff --git a/www/wiki/tests/qunit/suites/resources/test.sinonjs/index.js b/www/wiki/tests/qunit/suites/resources/test.sinonjs/index.js
new file mode 100644
index 00000000..b1be9d18
--- /dev/null
+++ b/www/wiki/tests/qunit/suites/resources/test.sinonjs/index.js
@@ -0,0 +1,3 @@
+// Hack: Disable 'module.exports' from ResourceLoader
+// (Otherwise Sinon assumes context as Node.js instead of a browser)
+module.exports = null;
diff --git a/www/wiki/tests/selenium/README.md b/www/wiki/tests/selenium/README.md
new file mode 100644
index 00000000..b15d4073
--- /dev/null
+++ b/www/wiki/tests/selenium/README.md
@@ -0,0 +1,61 @@
+# Selenium tests
+
+## Prerequisites
+
+- [Chrome](https://www.google.com/chrome/)
+- [ChromeDriver](https://sites.google.com/a/chromium.org/chromedriver/)
+- [Node.js](https://nodejs.org/en/)
+- [MediaWiki-Vagrant](https://www.mediawiki.org/wiki/MediaWiki-Vagrant)
+
+Set up MediaWiki-Vagrant:
+
+ cd mediawiki/vagrant
+ vagrant up
+
+## Installation
+
+ cd mediawiki
+ npm install
+
+## Usage
+
+ npm run selenium
+
+By default, Chrome will run in headless mode. If you want to see Chrome, set DISPLAY
+environment variable to any value:
+
+ DISPLAY=:1 npm run selenium
+
+To run only one file (for example page.js), you first need to spawn the chromedriver:
+
+ chromedriver --url-base=wd/hub --port=4444
+
+Then in another terminal:
+
+ cd tests/selenium
+ ../../node_modules/.bin/wdio --spec specs/page.js
+
+To run only one test (name contains string 'preferences'):
+
+ ../../node_modules/.bin/wdio --spec specs/user.js --mochaOpts.grep preferences
+
+The runner reads the config file `wdio.conf.js` and runs the spec listed in
+`page.js`.
+
+The defaults in the configuration files aim are targeting a MediaWiki-Vagrant
+installation on http://127.0.0.1:8080 with a user Admin and
+password 'vagrant'. Those settings can be overridden using environment
+variables:
+
+`MW_SERVER`: to be set to the value of your $wgServer
+`MW_SCRIPT_PATH`: ditto with $wgScriptPath
+`MEDIAWIKI_USER`: username of an account that can create users on the wiki
+`MEDIAWIKI_PASSWORD`: password for above user
+
+Example:
+
+ MW_SERVER=http://example.org MW_SCRIPT_PATH=/dev/w npm run selenium
+
+## Links
+
+- [Selenium/Node.js](https://www.mediawiki.org/wiki/Selenium/Node.js)
diff --git a/www/wiki/tests/selenium/pageobjects/createaccount.page.js b/www/wiki/tests/selenium/pageobjects/createaccount.page.js
new file mode 100644
index 00000000..105f4092
--- /dev/null
+++ b/www/wiki/tests/selenium/pageobjects/createaccount.page.js
@@ -0,0 +1,49 @@
+'use strict';
+const Page = require( './page' );
+
+class CreateAccountPage extends Page {
+
+ get username() { return browser.element( '#wpName2' ); }
+ get password() { return browser.element( '#wpPassword2' ); }
+ get confirmPassword() { return browser.element( '#wpRetype' ); }
+ get create() { return browser.element( '#wpCreateaccount' ); }
+ get heading() { return browser.element( '#firstHeading' ); }
+
+ open() {
+ super.open( 'Special:CreateAccount' );
+ }
+
+ createAccount( username, password ) {
+ this.open();
+ this.username.setValue( username );
+ this.password.setValue( password );
+ this.confirmPassword.setValue( password );
+ this.create.click();
+ }
+
+ apiCreateAccount( username, password ) {
+
+ const MWBot = require( 'mwbot' ), // https://github.com/Fannon/mwbot
+ Promise = require( 'bluebird' );
+ let bot = new MWBot();
+
+ return Promise.coroutine( function* () {
+ yield bot.loginGetCreateaccountToken( {
+ apiUrl: `${browser.options.baseUrl}/api.php`,
+ username: browser.options.username,
+ password: browser.options.password
+ } );
+ yield bot.request( {
+ action: 'createaccount',
+ createreturnurl: browser.options.baseUrl,
+ createtoken: bot.createaccountToken,
+ username: username,
+ password: password,
+ retype: password
+ } );
+ } ).call( this );
+
+ }
+
+}
+module.exports = new CreateAccountPage();
diff --git a/www/wiki/tests/selenium/pageobjects/delete.page.js b/www/wiki/tests/selenium/pageobjects/delete.page.js
new file mode 100644
index 00000000..d43cb9f6
--- /dev/null
+++ b/www/wiki/tests/selenium/pageobjects/delete.page.js
@@ -0,0 +1,39 @@
+'use strict';
+const Page = require( './page' );
+
+class DeletePage extends Page {
+
+ get reason() { return browser.element( '#wpReason' ); }
+ get watch() { return browser.element( '#wpWatch' ); }
+ get submit() { return browser.element( '#wpConfirmB' ); }
+ get displayedContent() { return browser.element( '#mw-content-text' ); }
+
+ open( name ) {
+ super.open( name + '&action=delete' );
+ }
+
+ delete( name, reason ) {
+ this.open( name );
+ this.reason.setValue( reason );
+ this.submit.click();
+ }
+
+ apiDelete( name, reason ) {
+
+ const MWBot = require( 'mwbot' ), // https://github.com/Fannon/mwbot
+ Promise = require( 'bluebird' );
+ let bot = new MWBot();
+
+ return Promise.coroutine( function* () {
+ yield bot.loginGetEditToken( {
+ apiUrl: `${browser.options.baseUrl}/api.php`,
+ username: browser.options.username,
+ password: browser.options.password
+ } );
+ yield bot.delete( name, reason );
+ } ).call( this );
+
+ }
+
+}
+module.exports = new DeletePage();
diff --git a/www/wiki/tests/selenium/pageobjects/edit.page.js b/www/wiki/tests/selenium/pageobjects/edit.page.js
new file mode 100644
index 00000000..33a27f0f
--- /dev/null
+++ b/www/wiki/tests/selenium/pageobjects/edit.page.js
@@ -0,0 +1,39 @@
+'use strict';
+const Page = require( './page' );
+
+class EditPage extends Page {
+
+ get content() { return browser.element( '#wpTextbox1' ); }
+ get displayedContent() { return browser.element( '#mw-content-text' ); }
+ get heading() { return browser.element( '#firstHeading' ); }
+ get save() { return browser.element( '#wpSave' ); }
+
+ openForEditing( name ) {
+ super.open( name + '&action=edit' );
+ }
+
+ edit( name, content ) {
+ this.openForEditing( name );
+ this.content.setValue( content );
+ this.save.click();
+ }
+
+ apiEdit( name, content ) {
+
+ const MWBot = require( 'mwbot' ), // https://github.com/Fannon/mwbot
+ Promise = require( 'bluebird' );
+ let bot = new MWBot();
+
+ return Promise.coroutine( function* () {
+ yield bot.loginGetEditToken( {
+ apiUrl: `${browser.options.baseUrl}/api.php`,
+ username: browser.options.username,
+ password: browser.options.password
+ } );
+ yield bot.edit( name, content, `Created page with "${content}"` );
+ } ).call( this );
+
+ }
+
+}
+module.exports = new EditPage();
diff --git a/www/wiki/tests/selenium/pageobjects/history.page.js b/www/wiki/tests/selenium/pageobjects/history.page.js
new file mode 100644
index 00000000..869484e6
--- /dev/null
+++ b/www/wiki/tests/selenium/pageobjects/history.page.js
@@ -0,0 +1,13 @@
+'use strict';
+const Page = require( './page' );
+
+class HistoryPage extends Page {
+
+ get comment() { return browser.element( '#pagehistory .comment' ); }
+
+ open( name ) {
+ super.open( name + '&action=history' );
+ }
+
+}
+module.exports = new HistoryPage();
diff --git a/www/wiki/tests/selenium/pageobjects/page.js b/www/wiki/tests/selenium/pageobjects/page.js
new file mode 100644
index 00000000..77bb1f4e
--- /dev/null
+++ b/www/wiki/tests/selenium/pageobjects/page.js
@@ -0,0 +1,8 @@
+// From http://webdriver.io/guide/testrunner/pageobjects.html
+'use strict';
+class Page {
+ open( path ) {
+ browser.url( browser.options.baseUrl + '/index.php?title=' + path );
+ }
+}
+module.exports = Page;
diff --git a/www/wiki/tests/selenium/pageobjects/preferences.page.js b/www/wiki/tests/selenium/pageobjects/preferences.page.js
new file mode 100644
index 00000000..98b87fe9
--- /dev/null
+++ b/www/wiki/tests/selenium/pageobjects/preferences.page.js
@@ -0,0 +1,20 @@
+'use strict';
+const Page = require( './page' );
+
+class PreferencesPage extends Page {
+
+ get realName() { return browser.element( '#mw-input-wprealname' ); }
+ get save() { return browser.element( '#prefcontrol' ); }
+
+ open() {
+ super.open( 'Special:Preferences' );
+ }
+
+ changeRealName( realName ) {
+ this.open();
+ this.realName.setValue( realName );
+ this.save.click();
+ }
+
+}
+module.exports = new PreferencesPage();
diff --git a/www/wiki/tests/selenium/pageobjects/restore.page.js b/www/wiki/tests/selenium/pageobjects/restore.page.js
new file mode 100644
index 00000000..071f7f98
--- /dev/null
+++ b/www/wiki/tests/selenium/pageobjects/restore.page.js
@@ -0,0 +1,21 @@
+'use strict';
+const Page = require( './page' );
+
+class RestorePage extends Page {
+
+ get reason() { return browser.element( '#wpComment' ); }
+ get submit() { return browser.element( '#mw-undelete-submit' ); }
+ get displayedContent() { return browser.element( '#mw-content-text' ); }
+
+ open( name ) {
+ super.open( 'Special:Undelete/' + name );
+ }
+
+ restore( name, reason ) {
+ this.open( name );
+ this.reason.setValue( reason );
+ this.submit.click();
+ }
+
+}
+module.exports = new RestorePage();
diff --git a/www/wiki/tests/selenium/pageobjects/userlogin.page.js b/www/wiki/tests/selenium/pageobjects/userlogin.page.js
new file mode 100644
index 00000000..0061d0c2
--- /dev/null
+++ b/www/wiki/tests/selenium/pageobjects/userlogin.page.js
@@ -0,0 +1,27 @@
+'use strict';
+const Page = require( './page' );
+
+class UserLoginPage extends Page {
+
+ get username() { return browser.element( '#wpName1' ); }
+ get password() { return browser.element( '#wpPassword1' ); }
+ get loginButton() { return browser.element( '#wpLoginAttempt' ); }
+ get userPage() { return browser.element( '#pt-userpage' ); }
+
+ open() {
+ super.open( 'Special:UserLogin' );
+ }
+
+ login( username, password ) {
+ this.open();
+ this.username.setValue( username );
+ this.password.setValue( password );
+ this.loginButton.click();
+ }
+
+ loginAdmin() {
+ this.login( browser.options.username, browser.options.password );
+ }
+
+}
+module.exports = new UserLoginPage();
diff --git a/www/wiki/tests/selenium/selenium.sh b/www/wiki/tests/selenium/selenium.sh
new file mode 100755
index 00000000..6b71019b
--- /dev/null
+++ b/www/wiki/tests/selenium/selenium.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -euo pipefail
+chromedriver --url-base=/wd/hub --port=4444 &
+# Make sure it is killed to prevent file descriptors leak
+function kill_chromedriver() {
+ killall chromedriver > /dev/null
+}
+trap kill_chromedriver EXIT
+npm run selenium-test
diff --git a/www/wiki/tests/selenium/specs/page.js b/www/wiki/tests/selenium/specs/page.js
new file mode 100644
index 00000000..376dce59
--- /dev/null
+++ b/www/wiki/tests/selenium/specs/page.js
@@ -0,0 +1,136 @@
+'use strict';
+const assert = require( 'assert' ),
+ DeletePage = require( '../pageobjects/delete.page' ),
+ RestorePage = require( '../pageobjects/restore.page' ),
+ EditPage = require( '../pageobjects/edit.page' ),
+ HistoryPage = require( '../pageobjects/history.page' ),
+ UserLoginPage = require( '../pageobjects/userlogin.page' );
+
+describe( 'Page', function () {
+
+ var content,
+ name;
+
+ function getTestString() {
+ return Math.random().toString() + '-öäü-♠♣♥♦';
+ }
+
+ before( function () {
+ // disable VisualEditor welcome dialog
+ UserLoginPage.open();
+ browser.localStorage( 'POST', { key: 've-beta-welcome-dialog', value: '1' } );
+ } );
+
+ beforeEach( function () {
+ browser.deleteCookie();
+ content = getTestString();
+ name = getTestString();
+ } );
+
+ it( 'should be creatable', function () {
+
+ // create
+ EditPage.edit( name, content );
+
+ // check
+ assert.equal( EditPage.heading.getText(), name );
+ assert.equal( EditPage.displayedContent.getText(), content );
+
+ } );
+
+ it( 'should be re-creatable', function () {
+ let initialContent = getTestString();
+
+ // create
+ browser.call( function () {
+ return EditPage.apiEdit( name, initialContent );
+ } );
+
+ // delete
+ browser.call( function () {
+ return DeletePage.apiDelete( name, 'delete prior to recreate' );
+ } );
+
+ // create
+ EditPage.edit( name, content );
+
+ // check
+ assert.equal( EditPage.heading.getText(), name );
+ assert.equal( EditPage.displayedContent.getText(), content );
+
+ } );
+
+ it( 'should be editable', function () {
+
+ // create
+ browser.call( function () {
+ return EditPage.apiEdit( name, content );
+ } );
+
+ // edit
+ EditPage.edit( name, content );
+
+ // check
+ assert.equal( EditPage.heading.getText(), name );
+ assert.equal( EditPage.displayedContent.getText(), content );
+
+ } );
+
+ it( 'should have history', function () {
+
+ // create
+ browser.call( function () {
+ return EditPage.apiEdit( name, content );
+ } );
+
+ // check
+ HistoryPage.open( name );
+ assert.equal( HistoryPage.comment.getText(), `(Created page with "${content}")` );
+
+ } );
+
+ it( 'should be deletable', function () {
+
+ // login
+ UserLoginPage.loginAdmin();
+
+ // create
+ browser.call( function () {
+ return EditPage.apiEdit( name, content );
+ } );
+
+ // delete
+ DeletePage.delete( name, content + '-deletereason' );
+
+ // check
+ assert.equal(
+ DeletePage.displayedContent.getText(),
+ '"' + name + '" has been deleted. See deletion log for a record of recent deletions.\nReturn to Main Page.'
+ );
+
+ } );
+
+ it( 'should be restorable', function () {
+
+ // login
+ UserLoginPage.loginAdmin();
+
+ // create
+ browser.call( function () {
+ return EditPage.apiEdit( name, content );
+ } );
+
+ // delete
+ browser.call( function () {
+ return DeletePage.apiDelete( name, content + '-deletereason' );
+ } );
+
+ // restore
+ RestorePage.restore( name, content + '-restorereason' );
+
+ // check
+ assert.equal( RestorePage.displayedContent.getText(), name + ' has been restored\nConsult the deletion log for a record of recent deletions and restorations.' );
+
+ } );
+
+} );
diff --git a/www/wiki/tests/selenium/specs/user.js b/www/wiki/tests/selenium/specs/user.js
new file mode 100644
index 00000000..3f3872dc
--- /dev/null
+++ b/www/wiki/tests/selenium/specs/user.js
@@ -0,0 +1,69 @@
+'use strict';
+const assert = require( 'assert' ),
+ CreateAccountPage = require( '../pageobjects/createaccount.page' ),
+ PreferencesPage = require( '../pageobjects/preferences.page' ),
+ UserLoginPage = require( '../pageobjects/userlogin.page' );
+
+describe( 'User', function () {
+
+ var password,
+ username;
+
+ before( function () {
+ // disable VisualEditor welcome dialog
+ UserLoginPage.open();
+ browser.localStorage( 'POST', { key: 've-beta-welcome-dialog', value: '1' } );
+ } );
+
+ beforeEach( function () {
+ browser.deleteCookie();
+ username = `User-${Math.random().toString()}`;
+ password = Math.random().toString();
+ } );
+
+ it( 'should be able to create account', function () {
+
+ // create
+ CreateAccountPage.createAccount( username, password );
+
+ // check
+ assert.equal( CreateAccountPage.heading.getText(), `Welcome, ${username}!` );
+
+ } );
+
+ it( 'should be able to log in', function () {
+
+ // create
+ browser.call( function () {
+ return CreateAccountPage.apiCreateAccount( username, password );
+ } );
+
+ // log in
+ UserLoginPage.login( username, password );
+
+ // check
+ assert.equal( UserLoginPage.userPage.getText(), username );
+
+ } );
+
+ it( 'should be able to change preferences', function () {
+
+ var realName = Math.random().toString();
+
+ // create
+ browser.call( function () {
+ return CreateAccountPage.apiCreateAccount( username, password );
+ } );
+
+ // log in
+ UserLoginPage.login( username, password );
+
+ // change
+ PreferencesPage.changeRealName( realName );
+
+ // check
+ assert.equal( PreferencesPage.realName.getValue(), realName );
+
+ } );
+
+} );
diff --git a/www/wiki/tests/selenium/wdio.conf.js b/www/wiki/tests/selenium/wdio.conf.js
new file mode 100644
index 00000000..0930a0f1
--- /dev/null
+++ b/www/wiki/tests/selenium/wdio.conf.js
@@ -0,0 +1,328 @@
+'use strict';
+
+const fs = require( 'fs' ),
+ path = require( 'path' );
+
+let logPath, password, username;
+
+// username and password will be used only if
+// MEDIAWIKI_USER or MEDIAWIKI_PASSWORD environment variables are not set
+if ( process.env.JENKINS_HOME ) {
+ logPath = '../log/';
+ password = 'testpass';
+ username = 'WikiAdmin';
+} else {
+ logPath = './log/';
+ password = 'vagrant';
+ username = 'Admin';
+}
+
+function relPath( foo ) {
+ return path.resolve( __dirname, '../..', foo );
+}
+
+exports.config = {
+ // ======
+ // Custom
+ // ======
+ // Define any custom variables.
+ // Example:
+ // username: 'Admin',
+ // Use if from tests with:
+ // browser.options.username
+ username: process.env.MEDIAWIKI_USER === undefined ?
+ username :
+ process.env.MEDIAWIKI_USER,
+ password: process.env.MEDIAWIKI_PASSWORD === undefined ?
+ password :
+ process.env.MEDIAWIKI_PASSWORD,
+ //
+ // ======
+ // Sauce Labs
+ // ======
+ //
+ services: [ 'sauce' ],
+ user: process.env.SAUCE_USERNAME,
+ key: process.env.SAUCE_ACCESS_KEY,
+ //
+ // ==================
+ // Specify Test Files
+ // ==================
+ // Define which test specs should run. The pattern is relative to the directory
+ // from which `wdio` was called. Notice that, if you are calling `wdio` from an
+ // NPM script (see https://docs.npmjs.com/cli/run-script) then the current working
+ // directory is where your package.json resides, so `wdio` will be called from there.
+ //
+ specs: [
+ relPath( './tests/selenium/specs/**/*.js' ),
+ relPath( './extensions/*/tests/selenium/specs/**/*.js' ),
+ relPath( './extensions/VisualEditor/modules/ve-mw/tests/selenium/specs/**/*.js' ),
+ relPath( './skins/*/tests/selenium/specs/**/*.js' )
+ ],
+ // Patterns to exclude.
+ exclude: [
+ './extensions/CirrusSearch/tests/selenium/specs/**/*.js'
+ ],
+ //
+ // ============
+ // Capabilities
+ // ============
+ // Define your capabilities here. WebdriverIO can run multiple capabilities at the same
+ // time. Depending on the number of capabilities, WebdriverIO launches several test
+ // sessions. Within your capabilities you can overwrite the spec and exclude options in
+ // order to group specific specs to a specific capability.
+ //
+ // First, you can define how many instances should be started at the same time. Let's
+ // say you have 3 different capabilities (Chrome, Firefox, and Safari) and you have
+ // set maxInstances to 1; wdio will spawn 3 processes. Therefore, if you have 10 spec
+ // files and you set maxInstances to 10, all spec files will get tested at the same time
+ // and 30 processes will get spawned. The property handles how many capabilities
+ // from the same test should run tests.
+ //
+ maxInstances: 1,
+ //
+ // If you have trouble getting all important capabilities together, check out the
+ // Sauce Labs platform configurator - a great tool to configure your capabilities:
+ // https://docs.saucelabs.com/reference/platforms-configurator
+ //
+ // For Chrome/Chromium https://sites.google.com/a/chromium.org/chromedriver/capabilities
+ capabilities: [ {
+ // maxInstances can get overwritten per capability. So if you have an in-house Selenium
+ // grid with only 5 firefox instances available you can make sure that not more than
+ // 5 instances get started at a time.
+ maxInstances: 1,
+ //
+ browserName: 'chrome',
+ chromeOptions: {
+ // Run headless when there is no DISPLAY
+ // --headless: since Chrome 59 https://chromium.googlesource.com/chromium/src/+/59.0.3030.0/headless/README.md
+ args: (
+ process.env.DISPLAY ? [] : [ '--headless' ]
+ ).concat(
+ // Disable Chrome sandbox when running in Docker
+ fs.existsSync( '/.dockerenv' ) ? [ '--no-sandbox' ] : []
+ )
+ }
+ } ],
+ //
+ // ===================
+ // Test Configurations
+ // ===================
+ // Define all options that are relevant for the WebdriverIO instance here
+ //
+ // By default WebdriverIO commands are executed in a synchronous way using
+ // the wdio-sync package. If you still want to run your tests in an async way
+ // e.g. using promises you can set the sync option to false.
+ sync: true,
+ //
+ // Level of logging verbosity: silent | verbose | command | data | result | error
+ logLevel: 'error',
+ //
+ // Enables colors for log output.
+ coloredLogs: true,
+ //
+ // Warns when a deprecated command is used
+ deprecationWarnings: true,
+ //
+ // If you only want to run your tests until a specific amount of tests have failed use
+ // bail (default is 0 - don't bail, run all tests).
+ bail: 0,
+ //
+ // Saves a screenshot to a given path if a command fails.
+ screenshotPath: logPath,
+ //
+ // Set a base URL in order to shorten url command calls. If your `url` parameter starts
+ // with `/`, the base url gets prepended, not including the path portion of your baseUrl.
+ // If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url
+ // gets prepended directly.
+ baseUrl: (
+ process.env.MW_SERVER === undefined ?
+ 'http://127.0.0.1:8080' :
+ process.env.MW_SERVER
+ ) + (
+ process.env.MW_SCRIPT_PATH === undefined ?
+ '/w' :
+ process.env.MW_SCRIPT_PATH
+ ),
+ //
+ // Default timeout for all waitFor* commands.
+ waitforTimeout: 10000,
+ //
+ // Default timeout in milliseconds for request
+ // if Selenium Grid doesn't send response
+ connectionRetryTimeout: 90000,
+ //
+ // Default request retries count
+ connectionRetryCount: 3,
+ //
+ // Initialize the browser instance with a WebdriverIO plugin. The object should have the
+ // plugin name as key and the desired plugin options as properties. Make sure you have
+ // the plugin installed before running any tests. The following plugins are currently
+ // available:
+ // WebdriverCSS: https://github.com/webdriverio/webdrivercss
+ // WebdriverRTC: https://github.com/webdriverio/webdriverrtc
+ // Browserevent: https://github.com/webdriverio/browserevent
+ // plugins: {
+ // webdrivercss: {
+ // screenshotRoot: 'my-shots',
+ // failedComparisonsRoot: 'diffs',
+ // misMatchTolerance: 0.05,
+ // screenWidth: [320,480,640,1024]
+ // },
+ // webdriverrtc: {},
+ // browserevent: {}
+ // },
+ //
+ // Test runner services
+ // Services take over a specific job you don't want to take care of. They enhance
+ // your test setup with almost no effort. Unlike plugins, they don't add new
+ // commands. Instead, they hook themselves up into the test process.
+ // services: [],//
+ // Framework you want to run your specs with.
+ // The following are supported: Mocha, Jasmine, and Cucumber
+ // see also: http://webdriver.io/guide/testrunner/frameworks.html
+ //
+ // Make sure you have the wdio adapter package for the specific framework installed
+ // before running any tests.
+ framework: 'mocha',
+ //
+ // Test reporter for stdout.
+ // The only one supported by default is 'dot'
+ // see also: http://webdriver.io/guide/testrunner/reporters.html
+ reporters: [ 'spec', 'junit' ],
+ reporterOptions: {
+ junit: {
+ outputDir: logPath
+ }
+ },
+ //
+ // Options to be passed to Mocha.
+ // See the full list at http://mochajs.org/
+ mochaOpts: {
+ ui: 'bdd',
+ timeout: 20000
+ },
+ //
+ // =====
+ // Hooks
+ // =====
+ // WebdriverIO provides several hooks you can use to interfere with the test process in order to enhance
+ // it and to build services around it. You can either apply a single function or an array of
+ // methods to it. If one of them returns with a promise, WebdriverIO will wait until that promise got
+ // resolved to continue.
+ /**
+ * Gets executed once before all workers get launched.
+ * @param {Object} config wdio configuration object
+ * @param {Array.<Object>} capabilities list of capabilities details
+ */
+ // onPrepare: function (config, capabilities) {
+ // },
+ /**
+ * Gets executed just before initialising the webdriver session and test framework. It allows you
+ * to manipulate configurations depending on the capability or spec.
+ * @param {Object} config wdio configuration object
+ * @param {Array.<Object>} capabilities list of capabilities details
+ * @param {Array.<String>} specs List of spec file paths that are to be run
+ */
+ // beforeSession: function (config, capabilities, specs) {
+ // },
+ /**
+ * Gets executed before test execution begins. At this point you can access to all global
+ * variables like `browser`. It is the perfect place to define custom commands.
+ * @param {Array.<Object>} capabilities list of capabilities details
+ * @param {Array.<String>} specs List of spec file paths that are to be run
+ */
+ // before: function (capabilities, specs) {
+ // },
+ /**
+ * Runs before a WebdriverIO command gets executed.
+ * @param {String} commandName hook command name
+ * @param {Array} args arguments that command would receive
+ */
+ // beforeCommand: function (commandName, args) {
+ // },
+ /**
+ * Hook that gets executed before the suite starts
+ * @param {Object} suite suite details
+ */
+ // beforeSuite: function (suite) {
+ // },
+ /**
+ * Function to be executed before a test (in Mocha/Jasmine) or a step (in Cucumber) starts.
+ * @param {Object} test test details
+ */
+ // beforeTest: function (test) {
+ // },
+ /**
+ * Hook that gets executed _before_ a hook within the suite starts (e.g. runs before calling
+ * beforeEach in Mocha)
+ */
+ // beforeHook: function () {
+ // },
+ /**
+ * Hook that gets executed _after_ a hook within the suite ends (e.g. runs after calling
+ * afterEach in Mocha)
+ */
+ // afterHook: function () {
+ // },
+ /**
+ * Function to be executed after a test (in Mocha/Jasmine) or a step (in Cucumber) ends.
+ * @param {Object} test test details
+ */
+ // from https://github.com/webdriverio/webdriverio/issues/269#issuecomment-306342170
+ afterTest: function ( test ) {
+ var filename, filePath;
+ // if test passed, ignore, else take and save screenshot
+ if ( test.passed ) {
+ return;
+ }
+ // get current test title and clean it, to use it as file name
+ filename = encodeURIComponent( test.title.replace( /\s+/g, '-' ) );
+ // build file path
+ filePath = this.screenshotPath + filename + '.png';
+ // save screenshot
+ browser.saveScreenshot( filePath );
+ console.log( '\n\tScreenshot location:', filePath, '\n' );
+ }
+ //
+ /**
+ * Hook that gets executed after the suite has ended
+ * @param {Object} suite suite details
+ */
+ // afterSuite: function (suite) {
+ // },
+ /**
+ * Runs after a WebdriverIO command gets executed
+ * @param {String} commandName hook command name
+ * @param {Array} args arguments that command would receive
+ * @param {Number} result 0 - command success, 1 - command error
+ * @param {Object} error error object if any
+ */
+ // afterCommand: function (commandName, args, result, error) {
+ // },
+ /**
+ * Gets executed after all tests are done. You still have access to all global variables from
+ * the test.
+ * @param {Number} result 0 - test pass, 1 - test fail
+ * @param {Array.<Object>} capabilities list of capabilities details
+ * @param {Array.<String>} specs List of spec file paths that ran
+ */
+ // after: function (result, capabilities, specs) {
+ // },
+ /**
+ * Gets executed right after terminating the webdriver session.
+ * @param {Object} config wdio configuration object
+ * @param {Array.<Object>} capabilities list of capabilities details
+ * @param {Array.<String>} specs List of spec file paths that ran
+ */
+ // afterSession: function (config, capabilities, specs) {
+ // },
+ /**
+ * Gets executed after all workers got shut down and the process is about to exit.
+ * @param {Object} exitCode 0 - success, 1 - fail
+ * @param {Object} config wdio configuration object
+ * @param {Array.<Object>} capabilities list of capabilities details
+ */
+ // onComplete: function(exitCode, config, capabilities) {
+ // }
+};